398 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr"
)
const (
giftWallProjectionName = "user_gift_wall"
walletGiftDebitedEvent = "WalletGiftDebited"
// projection 状态机只描述“某个 outbox 事件是否已经投影进礼物墙”。
// 这里不复用 wallet_outbox.status避免展示读模型反向污染账务事件分发语义。
projectionStatusProcessing = "processing"
projectionStatusFailed = "failed"
projectionStatusDone = "done"
projectionStatusDead = "dead"
projectionMaxRetries = 10
)
type giftWallProjectionCandidate struct {
AppCode string
EventID string
CreatedAtMS int64
}
type giftWallProjectionEvent struct {
AppCode string
EventID string
TransactionID string
CommandID string
PayloadJSON string
CreatedAtMS int64
}
// ProjectPendingGiftWallEvents 把 WalletGiftDebited outbox 事件异步投影到 user_gift_wall。
// 扣费主事务只写账务事实和 outbox礼物墙由 worker 后补,因此查询天然允许短暂延迟。
// 单条事件的聚合写入和 done 标记在同一事务内提交;服务重启时未提交事务回滚,已 done 的事件不会重复聚合。
func (r *Repository) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
if r == nil || r.db == nil {
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return 0, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
candidates, err := r.listGiftWallProjectionCandidates(ctx, limit)
if err != nil {
return 0, err
}
processed := 0
for _, candidate := range candidates {
ok, err := r.projectGiftWallEvent(ctx, candidate, workerID, lockTTL)
if err != nil {
return processed, err
}
if ok {
processed++
}
}
return processed, nil
}
// ProjectGiftWallMessage projects one wallet_outbox MQ fact into user_gift_wall.
// Runtime projection consumes MQ and does not scan wallet_outbox for candidates.
func (r *Repository) ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
if r == nil || r.db == nil {
return false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if message.EventType != walletGiftDebitedEvent {
return false, nil
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return false, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
nowMs := time.Now().UnixMilli()
lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
event := giftWallProjectionEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: strings.TrimSpace(message.EventID),
TransactionID: strings.TrimSpace(message.TransactionID),
CommandID: strings.TrimSpace(message.CommandID),
PayloadJSON: strings.TrimSpace(message.PayloadJSON),
CreatedAtMS: message.OccurredAtMS,
}
if event.PayloadJSON == "" {
event.PayloadJSON = "{}"
}
ctx = appcode.WithContext(ctx, event.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
}
func (r *Repository) listGiftWallProjectionCandidates(ctx context.Context, limit int) ([]giftWallProjectionCandidate, error) {
nowMs := time.Now().UnixMilli()
appCode := appcode.FromContext(ctx)
rows, err := r.db.QueryContext(ctx, `
SELECT wo.app_code, wo.event_id, wo.created_at_ms
FROM wallet_outbox wo FORCE INDEX (idx_wallet_outbox_event_created)
LEFT JOIN wallet_projection_events pe
ON pe.projection_name = ? AND pe.app_code = wo.app_code AND pe.event_id = wo.event_id
WHERE wo.app_code = ?
AND wo.event_type = ?
AND pe.event_id IS NULL
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
LIMIT ?`,
giftWallProjectionName,
appCode,
walletGiftDebitedEvent,
limit,
)
if err != nil {
return nil, err
}
candidates, err := scanGiftWallProjectionCandidates(rows, limit)
if err != nil {
return nil, err
}
rows, err = r.db.QueryContext(ctx, `
SELECT wo.app_code, wo.event_id, wo.created_at_ms
FROM wallet_projection_events pe
JOIN wallet_outbox wo
ON wo.app_code = pe.app_code AND wo.event_id = pe.event_id
WHERE pe.projection_name = ?
AND pe.app_code = ?
AND pe.status IN (?, ?)
AND pe.locked_until_ms <= ?
AND wo.event_type = ?
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
LIMIT ?`,
giftWallProjectionName,
appCode,
projectionStatusFailed,
projectionStatusProcessing,
nowMs,
walletGiftDebitedEvent,
limit,
)
if err != nil {
return nil, err
}
retryCandidates, err := scanGiftWallProjectionCandidates(rows, limit)
if err != nil {
return nil, err
}
candidates = append(candidates, retryCandidates...)
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].CreatedAtMS == candidates[j].CreatedAtMS {
return candidates[i].EventID < candidates[j].EventID
}
return candidates[i].CreatedAtMS < candidates[j].CreatedAtMS
})
if len(candidates) > limit {
candidates = candidates[:limit]
}
return candidates, nil
}
func scanGiftWallProjectionCandidates(rows *sql.Rows, limit int) ([]giftWallProjectionCandidate, error) {
defer rows.Close()
candidates := make([]giftWallProjectionCandidate, 0, limit)
for rows.Next() {
var candidate giftWallProjectionCandidate
if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.CreatedAtMS); err != nil {
return nil, err
}
candidates = append(candidates, candidate)
}
return candidates, rows.Err()
}
func (r *Repository) projectGiftWallEvent(ctx context.Context, candidate giftWallProjectionCandidate, workerID string, lockTTL time.Duration) (bool, error) {
nowMs := time.Now().UnixMilli()
lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
ctx = appcode.WithContext(ctx, candidate.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
event, err := r.lockGiftWallOutboxEvent(ctx, tx, candidate)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// 候选扫描和事务锁定之间可能被清理或并发处理;这种竞争不应让 worker 失败。
return false, nil
}
return false, err
}
return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
}
func (r *Repository) projectGiftWallLockedEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
claimed, err := r.claimProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
if err != nil || !claimed {
return false, err
}
var metadata giftMetadata
if err := json.Unmarshal([]byte(event.PayloadJSON), &metadata); err != nil {
// 坏 payload 是单条事件的数据问题,记录失败后跳过本轮,避免一个坏事件卡死整个队列。
if markErr := r.markProjectionFailedInTx(ctx, tx, event, "invalid gift projection payload: "+err.Error(), nowMs); markErr != nil {
return false, markErr
}
return true, tx.Commit()
}
if err := validateGiftWallMetadata(metadata); err != nil {
if markErr := r.markProjectionFailedInTx(ctx, tx, event, err.Error(), nowMs); markErr != nil {
return false, markErr
}
return true, tx.Commit()
}
if err := r.upsertUserGiftWall(ctx, tx, metadata, event.CreatedAtMS); err != nil {
// 聚合写失败时先释放当前事务锁,再把 projection 标记为 failed下轮按 retry_count 继续重试。
_ = tx.Rollback()
if markErr := r.markProjectionFailed(ctx, event, err, nowMs); markErr != nil {
return false, markErr
}
return false, err
}
if err := r.markProjectionDoneInTx(ctx, tx, event, nowMs); err != nil {
return false, err
}
return true, tx.Commit()
}
func (r *Repository) lockGiftWallOutboxEvent(ctx context.Context, tx *sql.Tx, candidate giftWallProjectionCandidate) (giftWallProjectionEvent, error) {
var event giftWallProjectionEvent
err := tx.QueryRowContext(ctx, `
SELECT app_code, event_id, transaction_id, command_id, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
FROM wallet_outbox
WHERE app_code = ? AND event_id = ? AND event_type = ?
FOR UPDATE`,
appcode.Normalize(candidate.AppCode),
candidate.EventID,
walletGiftDebitedEvent,
).Scan(&event.AppCode, &event.EventID, &event.TransactionID, &event.CommandID, &event.PayloadJSON, &event.CreatedAtMS)
return event, err
}
func (r *Repository) claimProjectionEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
// 新事件通过 INSERT 获取处理权;并发 worker 命中同一事件时只有一个实例会插入成功。
_, err := tx.ExecContext(ctx, `
INSERT INTO wallet_projection_events (
projection_name, app_code, event_id, status, retry_count, locked_by,
locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, ?, ?, '', 0, ?, ?)`,
giftWallProjectionName,
event.AppCode,
event.EventID,
projectionStatusProcessing,
workerID,
lockUntilMS,
nowMs,
nowMs,
)
if err == nil {
return true, nil
}
if !isMySQLDuplicateError(err) {
return false, err
}
// 已存在的 failed/过期 processing 事件允许重新认领done/dead 永远不被重复投影。
result, err := tx.ExecContext(ctx, `
UPDATE wallet_projection_events
SET status = ?, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
WHERE projection_name = ? AND app_code = ? AND event_id = ?
AND status IN (?, ?) AND locked_until_ms <= ?`,
projectionStatusProcessing,
workerID,
lockUntilMS,
nowMs,
giftWallProjectionName,
event.AppCode,
event.EventID,
projectionStatusFailed,
projectionStatusProcessing,
nowMs,
)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
return affected > 0, err
}
func (r *Repository) markProjectionDoneInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, nowMs int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE wallet_projection_events
SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', processed_at_ms = ?, updated_at_ms = ?
WHERE projection_name = ? AND app_code = ? AND event_id = ?`,
projectionStatusDone,
nowMs,
nowMs,
giftWallProjectionName,
event.AppCode,
event.EventID,
)
return err
}
func (r *Repository) markProjectionFailedInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, reason string, nowMs int64) error {
reason = trimProjectionError(reason)
// 失败次数达到上限后转 dead保留 last_error 给后台排查,不再无限重试污染 MySQL。
_, err := tx.ExecContext(ctx, `
UPDATE wallet_projection_events
SET status = IF(retry_count + 1 >= ?, ?, ?),
retry_count = retry_count + 1,
locked_by = '',
locked_until_ms = 0,
last_error = ?,
updated_at_ms = ?
WHERE projection_name = ? AND app_code = ? AND event_id = ?`,
projectionMaxRetries,
projectionStatusDead,
projectionStatusFailed,
reason,
nowMs,
giftWallProjectionName,
event.AppCode,
event.EventID,
)
return err
}
func (r *Repository) markProjectionFailed(ctx context.Context, event giftWallProjectionEvent, cause error, nowMs int64) error {
reason := trimProjectionError(cause.Error())
_, err := r.db.ExecContext(ctx, `
INSERT INTO wallet_projection_events (
projection_name, app_code, event_id, status, retry_count, locked_by,
locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 1, '', 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
status = IF(retry_count + 1 >= ?, ?, ?),
retry_count = retry_count + 1,
locked_by = '',
locked_until_ms = 0,
last_error = VALUES(last_error),
updated_at_ms = VALUES(updated_at_ms)`,
giftWallProjectionName,
event.AppCode,
event.EventID,
projectionStatusFailed,
reason,
nowMs,
nowMs,
projectionMaxRetries,
projectionStatusDead,
projectionStatusFailed,
)
return err
}
func validateGiftWallMetadata(metadata giftMetadata) error {
// 礼物墙以收礼人、礼物 ID 和数量为最小可投影事实;缺任何一个都不能安全聚合。
if metadata.TargetUserID <= 0 || strings.TrimSpace(metadata.GiftID) == "" || metadata.GiftCount <= 0 {
return xerr.New(xerr.InvalidArgument, "gift projection metadata is incomplete")
}
return nil
}
func trimProjectionError(value string) string {
value = strings.TrimSpace(value)
if len(value) > 1024 {
return value[:1024]
}
return value
}