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 }