package walletnotice import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "unicode" "hyapp/pkg/appcode" "hyapp/pkg/walletmq" "hyapp/pkg/xerr" ) const ( sourceWalletOutbox = "wallet_outbox" channelTencentIMC2C = "tencent_im_c2c" eventWalletBalance = "WalletBalanceChanged" eventVIPActivated = "VipActivated" deliveryStatusDelivering = "delivering" deliveryStatusDelivered = "delivered" deliveryStatusRetryable = "retryable" deliveryStatusFailed = "failed" ) // MySQLRepository owns wallet notice delivery state and reads wallet_outbox as an append-only source. type MySQLRepository struct { db *sql.DB walletOutboxTable string } // WalletBalanceEvent 是 notice worker 从 wallet_outbox 认领的一条私有余额通知事实。 type WalletBalanceEvent struct { AppCode string EventID string EventType string TransactionID string CommandID string UserID int64 AssetType string AvailableDelta int64 FrozenDelta int64 PayloadJSON string RetryCount int CreatedAtMS int64 } // NewMySQLRepository 创建钱包余额通知模块仓储。 // notice-service 自己的连接池由 platform/mysql 持有;模块只使用同一 DB 事务管理投递位点。 func NewMySQLRepository(db *sql.DB, walletDatabase string) (*MySQLRepository, error) { if db == nil { return nil, xerr.New(xerr.Unavailable, "mysql db is not configured") } walletDatabase = normalizeDatabaseName(walletDatabase) if walletDatabase == "" { return nil, xerr.New(xerr.InvalidArgument, "wallet_database is invalid") } return &MySQLRepository{ db: db, walletOutboxTable: fmt.Sprintf("`%s`.`wallet_outbox`", walletDatabase), }, nil } // ClaimWalletBalanceEvents 抢占 wallet_outbox 中待投递的余额变更事件。 // wallet_outbox 自身不被 notice-service 修改;多实例互斥和重试状态保存在 notice_delivery_events。 func (r *MySQLRepository) ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "notice repository is not configured") } workerID = strings.TrimSpace(workerID) if workerID == "" { return nil, xerr.New(xerr.InvalidArgument, "worker_id is required") } if limit <= 0 { limit = 100 } if limit > 500 { limit = 500 } if lockTTL <= 0 { lockTTL = 30 * time.Second } candidates, err := r.listWalletBalanceCandidates(ctx, limit, time.Now().UnixMilli()) if err != nil { return nil, err } claimed := make([]WalletBalanceEvent, 0, len(candidates)) for _, candidate := range candidates { event, ok, err := r.claimWalletBalanceCandidate(ctx, candidate, workerID, lockTTL) if err != nil { return claimed, err } if ok { claimed = append(claimed, event) } } return claimed, nil } type walletBalanceCandidate struct { AppCode string EventID string EventType string } func (r *MySQLRepository) listWalletBalanceCandidates(ctx context.Context, limit int, nowMs int64) ([]walletBalanceCandidate, error) { eventTypes := walletPrivateNoticeEventTypes() appCode := appcode.FromContext(ctx) candidates := make([]walletBalanceCandidate, 0) branches := []struct { query string args []any }{ { query: fmt.Sprintf(` SELECT wo.app_code, wo.event_id, wo.event_type FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created) LEFT JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ? WHERE wo.app_code = ? AND wo.event_type IN (%s) AND nde.source_event_id IS NULL ORDER BY wo.created_at_ms ASC, wo.event_id ASC LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))), args: append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), }, { query: fmt.Sprintf(` SELECT wo.app_code, wo.event_id, wo.event_type FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created) JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ? WHERE wo.app_code = ? AND wo.event_type IN (%s) AND nde.status = ? AND nde.next_retry_at_ms <= ? ORDER BY wo.created_at_ms ASC, wo.event_id ASC LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))), args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusRetryable, nowMs), }, { query: fmt.Sprintf(` SELECT wo.app_code, wo.event_id, wo.event_type FROM %s wo FORCE INDEX (idx_wallet_outbox_event_created) JOIN notice_delivery_events nde ON nde.source_name = ? AND nde.app_code = wo.app_code AND nde.source_event_id = wo.event_id AND nde.channel = ? WHERE wo.app_code = ? AND wo.event_type IN (%s) AND nde.status = ? AND nde.lock_until_ms <= ? ORDER BY wo.created_at_ms ASC, wo.event_id ASC LIMIT ?`, r.walletOutboxTable, sqlPlaceholders(len(eventTypes))), args: append(append([]any{sourceWalletOutbox, channelTencentIMC2C, appCode}, stringSliceToAny(eventTypes)...), deliveryStatusDelivering, nowMs), }, } for _, branch := range branches { remaining := limit - len(candidates) if remaining <= 0 { break } args := append(append([]any{}, branch.args...), remaining) branchCandidates, err := queryWalletBalanceCandidates(ctx, r.db, branch.query, args...) if err != nil { return nil, err } candidates = append(candidates, branchCandidates...) } return candidates, nil } func queryWalletBalanceCandidates(ctx context.Context, db *sql.DB, query string, args ...any) ([]walletBalanceCandidate, error) { rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() candidates := make([]walletBalanceCandidate, 0) for rows.Next() { var candidate walletBalanceCandidate if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.EventType); err != nil { return nil, err } candidates = append(candidates, candidate) } return candidates, rows.Err() } func (r *MySQLRepository) claimWalletBalanceCandidate(ctx context.Context, candidate walletBalanceCandidate, workerID string, lockTTL time.Duration) (WalletBalanceEvent, 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 WalletBalanceEvent{}, false, err } defer func() { _ = tx.Rollback() }() event, err := r.lockWalletBalanceEvent(ctx, tx, candidate) if err != nil { if errors.Is(err, sql.ErrNoRows) { // 候选扫描到事务锁定之间可能被清理;这种竞争不应让整批失败。 return WalletBalanceEvent{}, false, nil } return WalletBalanceEvent{}, false, err } retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) if err != nil || !claimed { return WalletBalanceEvent{}, false, err } event.RetryCount = retryCount if err := tx.Commit(); err != nil { return WalletBalanceEvent{}, false, err } return event, true, nil } // ClaimWalletBalanceMessage records a wallet_outbox MQ message in notice_delivery_events. func (r *MySQLRepository) ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, error) { if r == nil || r.db == nil { return WalletBalanceEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured") } workerID = strings.TrimSpace(workerID) if workerID == "" { return WalletBalanceEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required") } if lockTTL <= 0 { lockTTL = 30 * time.Second } event, err := walletBalanceEventFromMessage(message) if err != nil { return WalletBalanceEvent{}, false, err } now := time.Now() lockUntilMS := now.Add(lockTTL).UnixMilli() nowMs := now.UnixMilli() ctx = appcode.WithContext(ctx, event.AppCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return WalletBalanceEvent{}, false, err } defer func() { _ = tx.Rollback() }() retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) if err != nil || !claimed { return WalletBalanceEvent{}, false, err } event.RetryCount = retryCount if err := tx.Commit(); err != nil { return WalletBalanceEvent{}, false, err } return event, true, nil } func walletBalanceEventFromMessage(message walletmq.WalletOutboxMessage) (WalletBalanceEvent, error) { event := WalletBalanceEvent{ AppCode: appcode.Normalize(message.AppCode), EventID: message.EventID, EventType: message.EventType, TransactionID: message.TransactionID, CommandID: message.CommandID, UserID: message.UserID, AssetType: message.AssetType, AvailableDelta: message.AvailableDelta, FrozenDelta: message.FrozenDelta, PayloadJSON: message.PayloadJSON, CreatedAtMS: message.OccurredAtMS, } if event.PayloadJSON == "" { event.PayloadJSON = "{}" } if event.AppCode == "" || event.EventID == "" || event.TransactionID == "" || event.CommandID == "" { return WalletBalanceEvent{}, fmt.Errorf("wallet outbox message is incomplete") } if !isWalletPrivateNoticeEvent(event.EventType) { return WalletBalanceEvent{}, fmt.Errorf("unexpected wallet event type %q", event.EventType) } if event.UserID <= 0 { return WalletBalanceEvent{}, fmt.Errorf("wallet notice user_id is invalid") } return event, nil } func (r *MySQLRepository) lockWalletBalanceEvent(ctx context.Context, tx *sql.Tx, candidate walletBalanceCandidate) (WalletBalanceEvent, error) { var event WalletBalanceEvent query := fmt.Sprintf(` SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type, available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms FROM %s WHERE app_code = ? AND event_id = ? AND event_type = ? FOR UPDATE`, r.walletOutboxTable) err := tx.QueryRowContext(ctx, query, appcode.Normalize(candidate.AppCode), candidate.EventID, candidate.EventType, ).Scan( &event.AppCode, &event.EventID, &event.EventType, &event.TransactionID, &event.CommandID, &event.UserID, &event.AssetType, &event.AvailableDelta, &event.FrozenDelta, &event.PayloadJSON, &event.CreatedAtMS, ) return event, err } func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, event WalletBalanceEvent, workerID string, lockUntilMS int64, nowMs int64) (int, bool, error) { _, err := tx.ExecContext(ctx, ` INSERT INTO notice_delivery_events ( source_name, app_code, source_event_id, channel, notice_type, target_user_id, status, retry_count, locked_by, lock_until_ms, next_retry_at_ms, payload_json, last_error, delivered_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 0, ?, '', 0, ?, ?)`, sourceWalletOutbox, event.AppCode, event.EventID, channelTencentIMC2C, event.EventType, event.UserID, deliveryStatusDelivering, workerID, lockUntilMS, event.PayloadJSON, nowMs, nowMs, ) if err == nil { return 0, true, nil } if !isDuplicateKey(err) { return 0, false, err } result, err := tx.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? AND status = ? AND next_retry_at_ms <= ?`, deliveryStatusDelivering, workerID, lockUntilMS, event.PayloadJSON, nowMs, sourceWalletOutbox, event.AppCode, event.EventID, channelTencentIMC2C, deliveryStatusRetryable, nowMs, ) if err != nil { return 0, false, err } affected, err := result.RowsAffected() if err != nil { return 0, false, err } if affected == 0 { result, err = tx.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, locked_by = ?, lock_until_ms = ?, payload_json = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? AND status = ? AND lock_until_ms <= ?`, deliveryStatusDelivering, workerID, lockUntilMS, event.PayloadJSON, nowMs, sourceWalletOutbox, event.AppCode, event.EventID, channelTencentIMC2C, deliveryStatusDelivering, nowMs, ) if err != nil { return 0, false, err } affected, err = result.RowsAffected() if err != nil || affected == 0 { return 0, false, err } } var retryCount int err = tx.QueryRowContext(ctx, ` SELECT retry_count FROM notice_delivery_events WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ? FOR UPDATE`, sourceWalletOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ).Scan(&retryCount) return retryCount, err == nil, err } // MarkWalletBalanceDelivered 标记一条私有余额通知已投递到腾讯云 IM。 // payload_json 更新成最终客户端负载,便于后台排查和死信重放使用同一消息形态。 func (r *MySQLRepository) MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, nowMs int64) error { _, err := r.db.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0, payload_json = ?, last_error = '', delivered_at_ms = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`, deliveryStatusDelivered, string(deliveredPayload), nowMs, nowMs, sourceWalletOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ) return err } // MarkWalletBalanceRetryable 释放当前投递锁,按 nextRetryAtMS 进入退避重试。 func (r *MySQLRepository) MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error { _, err := r.db.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`, deliveryStatusRetryable, retryCount, nextRetryAtMS, truncateError(lastErr), nowMs, sourceWalletOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ) return err } // MarkWalletBalanceFailed 把 poison event 移入死信状态,不再自动抢占。 func (r *MySQLRepository) MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error { _, err := r.db.ExecContext(ctx, ` UPDATE notice_delivery_events SET status = ?, retry_count = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0, last_error = ?, updated_at_ms = ? WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`, deliveryStatusFailed, retryCount, truncateError(lastErr), nowMs, sourceWalletOutbox, event.AppCode, event.EventID, channelTencentIMC2C, ) return err } func normalizeDatabaseName(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } for _, r := range value { if r == '_' || unicode.IsDigit(r) || unicode.IsLetter(r) { continue } return "" } return value } func walletPrivateNoticeEventTypes() []string { return []string{eventWalletBalance, eventVIPActivated} } func sqlPlaceholders(count int) string { if count <= 0 { return "''" } parts := make([]string, count) for i := range parts { parts[i] = "?" } return strings.Join(parts, ",") } func stringSliceToAny(values []string) []any { out := make([]any, 0, len(values)) for _, value := range values { out = append(out, value) } return out } func isDuplicateKey(err error) bool { if err == nil { return false } return strings.Contains(err.Error(), "Error 1062") || strings.Contains(strings.ToLower(err.Error()), "duplicate") } func truncateError(value string) string { value = strings.TrimSpace(value) if len(value) <= 1000 { return value } return value[:1000] }