553 lines
18 KiB
Go
553 lines
18 KiB
Go
// 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
|
||
}
|