// Command audit-wheel-rewards performs a read-only, item-by-item reconciliation // between unsettled WheelRewardSettlement facts and wallet owner receipts. package main import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "log" "os" "sort" "strconv" "strings" "time" "unicode" _ "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/services/activity-service/internal/config" domain "hyapp/services/activity-service/internal/domain/wheel" ) type outboxRow struct { AppCode string OutboxID string Payload []byte Status string RetryCount int CreatedMS int64 } type rewardPayload struct { AppCode string `json:"app_code"` DrawID string `json:"draw_id"` DrawIDs []string `json:"draw_ids"` CommandID string `json:"command_id"` UserID int64 `json:"user_id"` WheelID string `json:"wheel_id"` SelectedTierID string `json:"selected_tier_id"` RewardType string `json:"reward_type"` RewardID string `json:"reward_id"` RewardCount int64 `json:"reward_count"` RewardCoins int64 `json:"reward_coins"` MetadataJSON string `json:"metadata_json"` Rewards []domain.DrawReward `json:"rewards"` VisibleRegionID int64 `json:"visible_region_id"` } type itemAudit struct { CommandID string `json:"command_id"` Type string `json:"type"` State string `json:"state"` ReceiptID string `json:"receipt_id,omitempty"` Reason string `json:"reason,omitempty"` } type rowAudit struct { AppCode string `json:"app_code"` OutboxID string `json:"outbox_id"` Status string `json:"outbox_status"` State string `json:"state"` Items []itemAudit `json:"items"` } type auditSummary struct { Rows int `json:"rows"` FullyPaid int `json:"fully_paid"` FullyUnpaid int `json:"fully_unpaid"` PartiallyPaid int `json:"partially_paid"` Conflict int `json:"conflict"` PaidItems int `json:"paid_items"` UnpaidItems int `json:"unpaid_items"` ConflictItems int `json:"conflict_items"` AuditSHA256 string `json:"audit_sha256"` } // outboxAuditIndex 是 FORCE INDEX 唯一允许接受的封闭枚举。information_schema 返回的是数据库数据, // 不能把其中的任意字符串直接拼接进 SQL;查询构造器会再次按用途校验枚举并只输出下列编译期常量。 type outboxAuditIndex uint8 const ( outboxAuditIndexUnknown outboxAuditIndex = iota outboxAuditIndexEventRetry outboxAuditIndexLuckyRetry outboxAuditIndexEventLock outboxAuditIndexLuckyLock ) type outboxAuditIndexes struct { Retry outboxAuditIndex Lock outboxAuditIndex } type appCodeFlags []string func (values *appCodeFlags) String() string { return strings.Join(*values, ",") } func (values *appCodeFlags) Set(value string) error { value = strings.TrimSpace(value) if value == "" { return fmt.Errorf("app-code must not be empty") } *values = append(*values, value) return nil } func main() { configPath := flag.String("config", "services/activity-service/configs/config.yaml", "path to activity-service config") walletDatabase := flag.String("wallet-database", "hyapp_wallet", "wallet owner database on the same MySQL cluster") limit := flag.Int("limit", 500, "maximum unsettled rows, 1..500") details := flag.Bool("details", false, "emit one JSON reconciliation record per outbox row") var requestedAppCodes appCodeFlags flag.Var(&requestedAppCodes, "app-code", "required App partition to audit; repeat for multiple Apps, for example --app-code lalu --app-code fami") flag.Parse() if *limit <= 0 || *limit > 500 { log.Fatal("limit must be between 1 and 500") } walletDB := normalizedDatabaseName(*walletDatabase) if walletDB == "" { log.Fatal("wallet-database contains invalid characters") } appCodes, err := normalizeAuditAppCodes(requestedAppCodes) if err != nil { log.Fatal(err) } cfg, err := config.Load(*configPath) if err != nil { log.Fatal(err) } db, err := sql.Open("mysql", cfg.MySQLDSN) if err != nil { log.Fatal(err) } defer func() { _ = db.Close() }() db.SetMaxOpenConns(2) db.SetMaxIdleConns(1) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() if err := db.PingContext(ctx); err != nil { log.Fatal(err) } indexes, err := discoverOutboxAuditIndexes(ctx, db) if err != nil { log.Fatal(err) } rows, err := loadUnsettledRows(ctx, db, indexes, appCodes, *limit) if err != nil { log.Fatal(err) } audits := make([]rowAudit, 0, len(rows)) for _, row := range rows { audit, err := auditRow(ctx, db, walletDB, row) if err != nil { log.Fatalf("audit app_code=%s outbox_id=%s: %v", row.AppCode, row.OutboxID, err) } audits = append(audits, audit) } summary := summarize(audits) if *details { encoder := json.NewEncoder(os.Stdout) for _, audit := range audits { if err := encoder.Encode(audit); err != nil { log.Fatal(err) } } } encoded, err := json.Marshal(summary) if err != nil { log.Fatal(err) } fmt.Println(string(encoded)) } func loadUnsettledRows(ctx context.Context, db *sql.DB, indexes outboxAuditIndexes, requestedAppCodes []string, limit int) ([]outboxRow, error) { if limit <= 0 || limit > 500 { return nil, fmt.Errorf("limit must be between 1 and 500") } appCodes, err := normalizeAuditAppCodes(requestedAppCodes) if err != nil { return nil, err } retryQuery, err := unsettledRetryQuery(indexes.Retry) if err != nil { return nil, err } deliveringQuery, err := unsettledDeliveringQuery(indexes.Lock) if err != nil { return nil, err } // activity_outbox 的线上索引都以 app_code 开头。每个 App 拆成 retry/lock 两个等值分区查询, // 既能强制使用对应索引,也避免 status OR 让优化器退化为全表扫描。每个分支只取 global remaining, // 合并出该 App 最早的一页后再进入下一 App,因此返回量严格不超过 limit,数据库返回行数也被约束在约 2*limit 内。 result := make([]outboxRow, 0, limit) for _, appCodeValue := range appCodes { remaining := limit - len(result) if remaining <= 0 { break } branches := []struct { query string args []any }{ { query: retryQuery, args: []any{ appCodeValue, domain.EventTypeWheelRewardSettlement, domain.OutboxStatusPending, domain.OutboxStatusRetryable, remaining, }, }, { query: deliveringQuery, args: []any{ appCodeValue, domain.EventTypeWheelRewardSettlement, domain.OutboxStatusDelivering, remaining, }, }, } appCandidates := make([]outboxRow, 0, remaining) seen := make(map[string]struct{}, remaining) for _, branch := range branches { rows, err := loadUnsettledBranch(ctx, db, branch.query, branch.args...) if err != nil { return nil, fmt.Errorf("load app_code=%s: %w", appCodeValue, err) } for _, row := range rows { key := row.AppCode + "\x00" + row.OutboxID if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} appCandidates = append(appCandidates, row) } } sort.Slice(appCandidates, func(i, j int) bool { if appCandidates[i].CreatedMS != appCandidates[j].CreatedMS { return appCandidates[i].CreatedMS < appCandidates[j].CreatedMS } if appCandidates[i].OutboxID != appCandidates[j].OutboxID { return appCandidates[i].OutboxID < appCandidates[j].OutboxID } return appCandidates[i].Status < appCandidates[j].Status }) if len(appCandidates) > remaining { appCandidates = appCandidates[:remaining] } result = append(result, appCandidates...) } return result, nil } func discoverOutboxAuditIndexes(ctx context.Context, db *sql.DB) (outboxAuditIndexes, error) { rows, err := db.QueryContext(ctx, outboxAuditIndexDiscoveryQuery()) if err != nil { return outboxAuditIndexes{}, fmt.Errorf("discover activity_outbox audit indexes: %w", err) } defer rows.Close() type indexPrefix struct { columns [3]string seen [3]bool invalid bool } prefixes := make(map[outboxAuditIndex]*indexPrefix, 4) for rows.Next() { var indexName, columnName string var sequence int if err := rows.Scan(&indexName, &sequence, &columnName); err != nil { return outboxAuditIndexes{}, fmt.Errorf("scan activity_outbox audit index: %w", err) } index, ok := outboxAuditIndexByName(indexName) if !ok || sequence < 1 || sequence > 3 { continue } prefix := prefixes[index] if prefix == nil { prefix = &indexPrefix{} prefixes[index] = prefix } position := sequence - 1 if prefix.seen[position] { prefix.invalid = true continue } prefix.seen[position] = true prefix.columns[position] = strings.ToLower(strings.TrimSpace(columnName)) } if err := rows.Err(); err != nil { return outboxAuditIndexes{}, fmt.Errorf("iterate activity_outbox audit indexes: %w", err) } validPrefix := func(index outboxAuditIndex) bool { prefix := prefixes[index] if prefix == nil || prefix.invalid { return false } expected := [3]string{"app_code", "event_type", "status"} return prefix.seen == [3]bool{true, true, true} && prefix.columns == expected } pick := func(preferred, legacy outboxAuditIndex) outboxAuditIndex { if validPrefix(preferred) { return preferred } if validPrefix(legacy) { return legacy } return outboxAuditIndexUnknown } indexes := outboxAuditIndexes{ Retry: pick(outboxAuditIndexEventRetry, outboxAuditIndexLuckyRetry), Lock: pick(outboxAuditIndexEventLock, outboxAuditIndexLuckyLock), } // 缺失或列前缀不匹配时必须中止。核账命令不能静默退回无索引查询,否则线上只读审计也可能拖垮主库。 if indexes.Retry == outboxAuditIndexUnknown || indexes.Lock == outboxAuditIndexUnknown { return outboxAuditIndexes{}, fmt.Errorf("activity_outbox requires whitelisted retry and lock indexes with prefix (app_code,event_type,status)") } return indexes, nil } func outboxAuditIndexDiscoveryQuery() string { return ` SELECT index_name, seq_in_index, column_name FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'activity_outbox' AND index_name IN ( 'idx_activity_outbox_event_retry', 'idx_activity_outbox_lucky_retry', 'idx_activity_outbox_event_lock', 'idx_activity_outbox_lucky_lock' ) AND seq_in_index <= 3 ORDER BY index_name, seq_in_index` } func outboxAuditIndexByName(value string) (outboxAuditIndex, bool) { switch strings.TrimSpace(value) { case "idx_activity_outbox_event_retry": return outboxAuditIndexEventRetry, true case "idx_activity_outbox_lucky_retry": return outboxAuditIndexLuckyRetry, true case "idx_activity_outbox_event_lock": return outboxAuditIndexEventLock, true case "idx_activity_outbox_lucky_lock": return outboxAuditIndexLuckyLock, true default: return outboxAuditIndexUnknown, false } } func unsettledRetryQuery(index outboxAuditIndex) (string, error) { var indexName string switch index { case outboxAuditIndexEventRetry: indexName = "idx_activity_outbox_event_retry" case outboxAuditIndexLuckyRetry: indexName = "idx_activity_outbox_lucky_retry" default: return "", fmt.Errorf("retry FORCE INDEX is not whitelisted") } return fmt.Sprintf(` SELECT app_code, outbox_id, payload, status, retry_count, created_at_ms FROM activity_outbox FORCE INDEX (%s) WHERE app_code = ? AND event_type = ? AND status IN (?, ?) ORDER BY created_at_ms, outbox_id LIMIT ?`, indexName), nil } func unsettledDeliveringQuery(index outboxAuditIndex) (string, error) { var indexName string switch index { case outboxAuditIndexEventLock: indexName = "idx_activity_outbox_event_lock" case outboxAuditIndexLuckyLock: indexName = "idx_activity_outbox_lucky_lock" default: return "", fmt.Errorf("lock FORCE INDEX is not whitelisted") } return fmt.Sprintf(` SELECT app_code, outbox_id, payload, status, retry_count, created_at_ms FROM activity_outbox FORCE INDEX (%s) WHERE app_code = ? AND event_type = ? AND status = ? ORDER BY created_at_ms, outbox_id LIMIT ?`, indexName), nil } func loadUnsettledBranch(ctx context.Context, db *sql.DB, query string, args ...any) ([]outboxRow, error) { rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() result := make([]outboxRow, 0) for rows.Next() { var row outboxRow if err := rows.Scan(&row.AppCode, &row.OutboxID, &row.Payload, &row.Status, &row.RetryCount, &row.CreatedMS); err != nil { return nil, err } result = append(result, row) } return result, rows.Err() } func normalizeAuditAppCodes(values []string) ([]string, error) { if len(values) == 0 { return nil, fmt.Errorf("at least one --app-code is required") } const maxAuditAppCodes = 16 seen := make(map[string]struct{}, len(values)) result := make([]string, 0, len(values)) for _, value := range values { if strings.TrimSpace(value) == "" { return nil, fmt.Errorf("app-code must not be empty") } value = appcode.Normalize(value) if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} result = append(result, value) } if len(result) == 0 { return nil, fmt.Errorf("at least one --app-code is required") } if len(result) > maxAuditAppCodes { return nil, fmt.Errorf("at most %d app-code values are allowed", maxAuditAppCodes) } sort.Strings(result) return result, nil } func auditRow(ctx context.Context, db *sql.DB, walletDB string, row outboxRow) (rowAudit, error) { var payload rewardPayload if err := json.Unmarshal(row.Payload, &payload); err != nil { return rowAudit{}, fmt.Errorf("decode immutable payload: %w", err) } row.AppCode = appcode.Normalize(row.AppCode) if payload.AppCode != "" && appcode.Normalize(payload.AppCode) != row.AppCode { return rowAudit{}, fmt.Errorf("payload app_code does not match outbox partition") } payload.DrawID = strings.TrimSpace(payload.DrawID) payload.WheelID = strings.TrimSpace(payload.WheelID) if payload.DrawID == "" || payload.WheelID == "" || payload.UserID <= 0 { return rowAudit{}, fmt.Errorf("payload identity is incomplete") } rewards := payload.Rewards if len(rewards) == 0 { rewards = []domain.DrawReward{{ SelectedTierID: payload.SelectedTierID, RewardType: payload.RewardType, RewardID: payload.RewardID, RewardCount: payload.RewardCount, RewardCoins: payload.RewardCoins, MetadataJSON: payload.MetadataJSON, }} } if len(rewards) == 0 { return rowAudit{}, fmt.Errorf("payload reward snapshot is empty") } audit := rowAudit{AppCode: row.AppCode, OutboxID: row.OutboxID, Status: row.Status, Items: make([]itemAudit, 0, len(rewards))} for index, reward := range rewards { reward.RewardType = strings.ToLower(strings.TrimSpace(reward.RewardType)) var item itemAudit var err error switch reward.RewardType { case domain.RewardTypeCoin: item, err = auditCoin(ctx, db, walletDB, row.AppCode, payload, reward, index, len(rewards)) case domain.RewardTypeGift, domain.RewardTypeProp, domain.RewardTypeDress: item, err = auditResource(ctx, db, walletDB, row.AppCode, payload, reward, index, len(rewards)) default: item = itemAudit{Type: reward.RewardType, State: "conflict", Reason: "unsupported reward type"} } if err != nil { return rowAudit{}, err } audit.Items = append(audit.Items, item) } audit.State = aggregateRowState(audit.Items) return audit, nil } func auditCoin(ctx context.Context, db *sql.DB, walletDB string, app string, payload rewardPayload, reward domain.DrawReward, index int, total int) (itemAudit, error) { commandID := rewardCommandID("wheel_reward", payload.DrawID, index, total) item := itemAudit{CommandID: commandID, Type: domain.RewardTypeCoin, State: "unpaid"} query := coinAuditQuery(walletDB) var transactionID, bizType, statusValue, metadataJSON string err := db.QueryRowContext(ctx, query, app, commandID).Scan(&transactionID, &bizType, &statusValue, &metadataJSON) if err != nil { // database/sql 和 driver/observer 包装都可能给 ErrNoRows 增加上下文;审计必须把这种稳定语义归为 unpaid,而不是中止整批核账。 if errors.Is(err, sql.ErrNoRows) { return item, nil } return itemAudit{}, err } metadata := map[string]any{} if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { item.State, item.Reason = "conflict", "wallet metadata is invalid" return item, nil } // wallet_transactions 和 resource_grants 共用 succeeded 终态;completed 从未是当前账本状态,不能把真实已发金币误判为冲突。 if bizType != "wheel_reward" || statusValue != "succeeded" || jsonInt64(metadata, "target_user_id") != payload.UserID || jsonInt64(metadata, "amount") != reward.RewardCoins || jsonString(metadata, "draw_id") != payload.DrawID || jsonString(metadata, "wheel_id") != payload.WheelID || jsonString(metadata, "selected_tier_id") != strings.TrimSpace(reward.SelectedTierID) || jsonString(metadata, "reason") != "wheel_reward" || jsonInt64(metadata, "visible_region_id") != payload.VisibleRegionID { item.State, item.Reason = "conflict", "existing wallet transaction does not match immutable reward snapshot" return item, nil } item.State = "paid" item.ReceiptID = transactionID return item, nil } func coinAuditQuery(walletDB string) string { return fmt.Sprintf(` SELECT transaction_id, biz_type, status, COALESCE(CAST(metadata_json AS CHAR), '{}') FROM %s.wallet_transactions WHERE app_code = ? AND command_id = ?`, walletDB) } func auditResource(ctx context.Context, db *sql.DB, walletDB string, app string, payload rewardPayload, reward domain.DrawReward, index int, total int) (itemAudit, error) { commandID := rewardCommandID("wheel_resource", payload.DrawID, index, total) item := itemAudit{CommandID: commandID, Type: reward.RewardType, State: "unpaid"} resourceID := rewardResourceID(reward) quantity := reward.RewardCount if quantity <= 0 { quantity = 1 } durationMS := metadataInt64(reward.MetadataJSON, "duration_ms", "durationMs", "validity_ms", "validityMs") query := resourceAuditQuery(walletDB) var grantID, statusValue, grantSource, subjectType, subjectID, reason string var targetUserID, operatorUserID, actualQuantity, actualDurationMS, actualResourceID int64 err := db.QueryRowContext(ctx, query, app, commandID).Scan(&grantID, &statusValue, &targetUserID, &grantSource, &subjectType, &subjectID, &reason, &operatorUserID, &actualQuantity, &actualDurationMS, &actualResourceID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return item, nil } return itemAudit{}, err } if resourceID <= 0 || statusValue != "succeeded" || targetUserID != payload.UserID || grantSource != "wheel_reward" || subjectType != "resource" || subjectID != strconv.FormatInt(resourceID, 10) || reason != "wheel_reward" || operatorUserID != payload.UserID || actualQuantity != quantity || actualDurationMS != durationMS || actualResourceID != resourceID { item.State, item.Reason = "conflict", "existing resource grant does not match immutable reward snapshot" return item, nil } item.State = "paid" item.ReceiptID = grantID return item, nil } func resourceAuditQuery(walletDB string) string { // 每个 SELECT 列只出现一次且与 Scan 顺序一一对应;显式逐列排版避免核账命令在生产才暴露漏逗号或重复列。 return fmt.Sprintf(` SELECT rg.grant_id, rg.status, rg.target_user_id, rg.grant_source, rg.grant_subject_type, rg.grant_subject_id, rg.reason, rg.operator_user_id, COALESCE(rgi.quantity, 0), COALESCE(rgi.duration_ms, 0), COALESCE(rgi.resource_id, 0) FROM %s.resource_grants rg LEFT JOIN %s.resource_grant_items rgi ON rgi.app_code = rg.app_code AND rgi.grant_id = rg.grant_id WHERE rg.app_code = ? AND rg.command_id = ? ORDER BY rgi.grant_item_id LIMIT 1`, walletDB, walletDB) } func aggregateRowState(items []itemAudit) string { paid, unpaid, conflict := 0, 0, 0 for _, item := range items { switch item.State { case "paid": paid++ case "unpaid": unpaid++ default: conflict++ } } if conflict > 0 { return "conflict" } if paid == len(items) { return "fully_paid" } if unpaid == len(items) { return "fully_unpaid" } return "partially_paid" } func summarize(audits []rowAudit) auditSummary { summary := auditSummary{Rows: len(audits)} canonical := make([]string, 0, len(audits)) for _, audit := range audits { switch audit.State { case "fully_paid": summary.FullyPaid++ case "fully_unpaid": summary.FullyUnpaid++ case "partially_paid": summary.PartiallyPaid++ default: summary.Conflict++ } for _, item := range audit.Items { switch item.State { case "paid": summary.PaidItems++ case "unpaid": summary.UnpaidItems++ default: summary.ConflictItems++ } } encoded, _ := json.Marshal(audit) canonical = append(canonical, string(encoded)) } sort.Strings(canonical) sum := sha256.Sum256([]byte(strings.Join(canonical, "\n"))) summary.AuditSHA256 = hex.EncodeToString(sum[:]) return summary } func rewardCommandID(prefix string, drawID string, index int, total int) string { if total <= 1 { return prefix + ":" + drawID } return fmt.Sprintf("%s:%s:%d", prefix, drawID, index+1) } func rewardResourceID(reward domain.DrawReward) int64 { if value := metadataInt64(reward.MetadataJSON, "resource_id", "resourceId"); value > 0 { return value } value, _ := strconv.ParseInt(strings.TrimSpace(reward.RewardID), 10, 64) return value } func metadataInt64(raw string, keys ...string) int64 { metadata := map[string]any{} if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil { return 0 } for _, key := range keys { if value := jsonInt64(metadata, key); value > 0 { return value } } return 0 } func jsonInt64(values map[string]any, key string) int64 { switch value := values[key].(type) { case float64: return int64(value) case json.Number: parsed, _ := value.Int64() return parsed case string: parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) return parsed default: return 0 } } func jsonString(values map[string]any, key string) string { value, _ := values[key].(string) return strings.TrimSpace(value) } func normalizedDatabaseName(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } for _, char := range value { if char == '_' || unicode.IsDigit(char) || unicode.IsLetter(char) { continue } return "" } return value }