553 lines
18 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.

// 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"`
}
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)
}
rows, err := loadUnsettledRows(ctx, db, 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, 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
}
// 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: unsettledRetryQuery(),
args: []any{
appCodeValue,
domain.EventTypeWheelRewardSettlement,
domain.OutboxStatusPending,
domain.OutboxStatusRetryable,
remaining,
},
},
{
query: unsettledDeliveringQuery(),
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 unsettledRetryQuery() string {
return `
SELECT app_code, outbox_id, payload, status, retry_count, created_at_ms
FROM activity_outbox FORCE INDEX (idx_activity_outbox_event_retry)
WHERE app_code = ? AND event_type = ? AND status IN (?, ?)
ORDER BY created_at_ms, outbox_id
LIMIT ?`
}
func unsettledDeliveringQuery() string {
return `
SELECT app_code, outbox_id, payload, status, retry_count, created_at_ms
FROM activity_outbox FORCE INDEX (idx_activity_outbox_event_lock)
WHERE app_code = ? AND event_type = ? AND status = ?
ORDER BY created_at_ms, outbox_id
LIMIT ?`
}
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
}