package mysql import ( "context" "crypto/sha256" "database/sql" "encoding/binary" "encoding/hex" "errors" "fmt" "hash" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" ) const ( walletOutboxPurgeAuthorizationState = "RESTORE_VERIFIED" walletOutboxPurgeReceiptState = "PURGED" walletOutboxPurgePolicyFinancialV1 = "financial-v1" ) // financial-v1 是显式正向安全集合:只覆盖已经确认不被正常在线查询回读的账务事实。 // 未知新事件、礼物/资源目录事件和低频资源/VIP 控制事件默认永久留热,新增清理类型必须发布新 policy version。 var walletOutboxPurgeFinancialV1EventTypes = []string{ "AgencyPointCredited", "HostPeriodDiamondCredited", "HostPointCredited", "WalletAgencyOpeningRewardCredited", "WalletBalanceChanged", "WalletCoinSellerCoinCompensated", "WalletCoinSellerStockPurchased", "WalletCoinSellerSubTransferred", "WalletCoinSellerTransferred", "WalletGiftDebited", "WalletGiftIncomeCredited", "WalletGooglePaymentCredited", "WalletInviteActivityRewardCredited", "WalletLuckyGiftRewardCredited", "WalletRechargeRecorded", "WalletRedPacketClaimed", "WalletRedPacketCreated", "WalletRedPacketRefunded", "WalletRoomTurnoverRewardCredited", "WalletTaskRewardCredited", "WalletWheelRewardCredited", } // WalletOutboxPurgeReceipt 是一个已经提交的精确 PK 清理批次。 // DeletedCount 为零时表示本轮没有成熟候选,此时 PurgeID 为空且数据库不会写空审计行。 type WalletOutboxPurgeReceipt struct { AppCode string PurgeID string State string PolicyVersion string CutoffMS int64 ReceiptVerifiedBeforeMS int64 CandidateSHA256 string CandidateCount int DeletedCount int FirstCursor WalletOutboxArchiveCursor LastCursor WalletOutboxArchiveCursor PurgedAtMS int64 } type walletOutboxPurgeCandidate struct { EventID string UpdatedAtMS int64 } // RequireWalletOutboxPurgeAuthorization 要求生产 wallet DB 已手工固化一条与隔离库 // RESTORE_VERIFIED 回执精确匹配的授权证据。迁移不会自动插入该表,因此仅部署人员 // 明确核验过的 (App, archive batch, manifest hash) 能打开自动清理总闸。 func (r *Repository) RequireWalletOutboxPurgeAuthorization(ctx context.Context, appCode, batchID, manifestSHA string) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx, normalizedAppCode, err := walletOutboxPurgeContext(ctx, appCode) if err != nil { return err } batchID, err = normalizeWalletOutboxPurgeSHA256("archive_batch_id", batchID) if err != nil { return err } manifestSHA, err = normalizeWalletOutboxPurgeSHA256("manifest_sha256", manifestSHA) if err != nil { return err } var marker int err = r.db.QueryRowContext(ctx, ` SELECT 1 FROM wallet_outbox_archive_purge_authorizations auth FORCE INDEX (PRIMARY) JOIN wallet_outbox_archive_receipts receipt FORCE INDEX (PRIMARY) ON receipt.app_code = auth.app_code AND receipt.batch_id = auth.archive_batch_id AND receipt.manifest_sha256 = auth.manifest_sha256 AND receipt.schema_version = auth.schema_version AND receipt.row_count = auth.row_count AND receipt.data_crc64 = auth.data_crc64 AND receipt.manifest_crc64 = auth.manifest_crc64 AND receipt.success_crc64 = auth.success_crc64 WHERE auth.app_code = ? AND auth.archive_batch_id = ? AND auth.manifest_sha256 = ? AND auth.state = ? AND receipt.state = ? LIMIT 1`, normalizedAppCode, batchID, manifestSHA, walletOutboxPurgeAuthorizationState, walletOutboxArchiveStateVerified, ).Scan(&marker) if errors.Is(err, sql.ErrNoRows) { return xerr.New(xerr.Conflict, "wallet outbox purge restore rehearsal is not authorized") } return err } // PurgeDeliveredWalletOutboxBatch 在单个 READ COMMITTED 事务内锁定、删除并审计至多 limit 条旧事实。 // 调用方必须已经持有 AcquireWalletOutboxArchiveAppLock 返回的 App 级 GET_LOCK。候选只能从 // exact member 表产生;每一行都重新连接当前 outbox 和 VERIFIED receipt,杜绝用批次首尾游标 // 推断中间行、迟到写入或旧 receipt 自动获得删除资格。 func (r *Repository) PurgeDeliveredWalletOutboxBatch(ctx context.Context, appCode, policyVersion string, cutoffMS, receiptVerifiedBeforeMS int64, limit int, nowMS int64) (WalletOutboxPurgeReceipt, error) { if r == nil || r.db == nil { return WalletOutboxPurgeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx, normalizedAppCode, err := walletOutboxPurgeContext(ctx, appCode) if err != nil { return WalletOutboxPurgeReceipt{}, err } eventTypes, err := walletOutboxPurgeEventTypes(policyVersion) if err != nil { return WalletOutboxPurgeReceipt{}, err } if cutoffMS <= 0 || receiptVerifiedBeforeMS <= 0 || nowMS <= 0 || cutoffMS >= nowMS || receiptVerifiedBeforeMS >= nowMS { return WalletOutboxPurgeReceipt{}, xerr.New(xerr.InvalidArgument, "wallet outbox purge cutoff, receipt grace, and now are invalid") } if limit <= 0 || limit > 500 { return WalletOutboxPurgeReceipt{}, xerr.New(xerr.InvalidArgument, "wallet outbox purge limit must be within [1,500]") } tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) if err != nil { return WalletOutboxPurgeReceipt{}, err } defer func() { _ = tx.Rollback() }() receipt := WalletOutboxPurgeReceipt{ AppCode: normalizedAppCode, State: walletOutboxPurgeReceiptState, PolicyVersion: policyVersion, CutoffMS: cutoffMS, ReceiptVerifiedBeforeMS: receiptVerifiedBeforeMS, PurgedAtMS: nowMS, } candidates, err := selectWalletOutboxPurgeCandidates(ctx, tx, normalizedAppCode, cutoffMS, receiptVerifiedBeforeMS, eventTypes, limit) if err != nil { return WalletOutboxPurgeReceipt{}, err } if len(candidates) == 0 { return receipt, nil } receipt.CandidateSHA256 = hashWalletOutboxPurgeCandidates(normalizedAppCode, candidates) receipt.CandidateCount = len(candidates) receipt.FirstCursor = WalletOutboxArchiveCursor{UpdatedAtMS: candidates[0].UpdatedAtMS, EventID: candidates[0].EventID} last := candidates[len(candidates)-1] receipt.LastCursor = WalletOutboxArchiveCursor{UpdatedAtMS: last.UpdatedAtMS, EventID: last.EventID} receipt.PurgeID = hashWalletOutboxPurgeReceipt(receipt) deleted, err := deleteWalletOutboxPurgeCandidates(ctx, tx, normalizedAppCode, cutoffMS, eventTypes, candidates) if err != nil { return WalletOutboxPurgeReceipt{}, err } if deleted != int64(len(candidates)) { return WalletOutboxPurgeReceipt{}, fmt.Errorf("wallet outbox purge candidate/delete mismatch: candidates=%d deleted=%d", len(candidates), deleted) } deletedMembers, err := deleteWalletOutboxArchiveMembers(ctx, tx, normalizedAppCode, candidates) if err != nil { return WalletOutboxPurgeReceipt{}, err } if deletedMembers != int64(len(candidates)) { // outbox 与 member 删除必须共同提交;member 数不一致时回滚 outbox 删除,避免留下无法审计的半完成批次。 return WalletOutboxPurgeReceipt{}, fmt.Errorf("wallet outbox purge member/delete mismatch: candidates=%d deleted_members=%d", len(candidates), deletedMembers) } receipt.DeletedCount = int(deleted) if err := insertWalletOutboxPurgeReceipt(ctx, tx, receipt); err != nil { return WalletOutboxPurgeReceipt{}, err } if err := tx.Commit(); err != nil { return WalletOutboxPurgeReceipt{}, err } return receipt, nil } func selectWalletOutboxPurgeCandidates(ctx context.Context, tx *sql.Tx, appCode string, cutoffMS, receiptVerifiedBeforeMS int64, eventTypes []string, limit int) ([]walletOutboxPurgeCandidate, error) { placeholders := walletOutboxPurgePlaceholders(len(eventTypes)) args := make([]any, 0, len(eventTypes)+6) args = append(args, appCode, cutoffMS, walletOutboxArchiveStateVerified, receiptVerifiedBeforeMS, outboxStatusDelivered) for _, eventType := range eventTypes { args = append(args, eventType) } args = append(args, limit) rows, err := tx.QueryContext(ctx, ` SELECT STRAIGHT_JOIN m.event_id, m.updated_at_ms FROM wallet_outbox_archive_members m FORCE INDEX (idx_wallet_outbox_archive_member_purge) JOIN wallet_outbox_archive_receipts ar FORCE INDEX (PRIMARY) ON ar.app_code = m.app_code AND ar.batch_id = m.batch_id AND ar.manifest_sha256 = m.manifest_sha256 AND ar.verified_at_ms = m.receipt_verified_at_ms JOIN wallet_outbox wo FORCE INDEX (PRIMARY) ON wo.app_code = m.app_code AND wo.event_id = m.event_id AND wo.updated_at_ms = m.updated_at_ms WHERE m.app_code = ? AND m.updated_at_ms < ? AND ar.state = ? AND ar.verified_at_ms < ? AND wo.status = ? AND wo.event_type IN (`+placeholders+`) ORDER BY m.updated_at_ms ASC, m.event_id ASC LIMIT ? FOR UPDATE SKIP LOCKED`, args...) if err != nil { return nil, err } defer rows.Close() candidates := make([]walletOutboxPurgeCandidate, 0, limit) for rows.Next() { var candidate walletOutboxPurgeCandidate if err := rows.Scan(&candidate.EventID, &candidate.UpdatedAtMS); err != nil { return nil, err } candidates = append(candidates, candidate) } return candidates, rows.Err() } func deleteWalletOutboxPurgeCandidates(ctx context.Context, tx *sql.Tx, appCode string, cutoffMS int64, eventTypes []string, candidates []walletOutboxPurgeCandidate) (int64, error) { primaryKeys, args, err := walletOutboxPurgeExactKeys(appCode, candidates) if err != nil { return 0, err } args = append(args, appCode, outboxStatusDelivered, cutoffMS) for _, eventType := range eventTypes { args = append(args, eventType) } // 候选 SELECT 已锁住这些行;删除仍逐键复核 app/event/updated、delivered、cutoff 和正向 policy, // 即使未来有人修改候选 JOIN,也不能把未授权类型或时间发生变化的行带入删除。 result, err := tx.ExecContext(ctx, ` DELETE wo FROM wallet_outbox wo FORCE INDEX (PRIMARY) WHERE (wo.app_code, wo.event_id, wo.updated_at_ms) IN (`+primaryKeys+`) AND wo.app_code = ? AND wo.status = ? AND wo.updated_at_ms < ? AND wo.event_type IN (`+walletOutboxPurgePlaceholders(len(eventTypes))+`)`, args...) if err != nil { return 0, err } return result.RowsAffected() } func deleteWalletOutboxArchiveMembers(ctx context.Context, tx *sql.Tx, appCode string, candidates []walletOutboxPurgeCandidate) (int64, error) { primaryKeys, args, err := walletOutboxPurgeExactKeys(appCode, candidates) if err != nil { return 0, err } result, err := tx.ExecContext(ctx, ` DELETE m FROM wallet_outbox_archive_members m FORCE INDEX (PRIMARY) WHERE (m.app_code, m.event_id, m.updated_at_ms) IN (`+primaryKeys+`)`, args...) if err != nil { return 0, err } return result.RowsAffected() } func walletOutboxPurgeExactKeys(appCode string, candidates []walletOutboxPurgeCandidate) (string, []any, error) { if len(candidates) == 0 || len(candidates) > 500 { return "", nil, xerr.New(xerr.InvalidArgument, "wallet outbox purge candidate count must be within [1,500]") } keys := make([]string, 0, len(candidates)) args := make([]any, 0, len(candidates)*3) for _, candidate := range candidates { keys = append(keys, "(?, ?, ?)") args = append(args, appCode, candidate.EventID, candidate.UpdatedAtMS) } return strings.Join(keys, ","), args, nil } func insertWalletOutboxPurgeReceipt(ctx context.Context, tx *sql.Tx, receipt WalletOutboxPurgeReceipt) error { result, err := tx.ExecContext(ctx, ` INSERT INTO wallet_outbox_archive_purge_receipts ( app_code, purge_id, state, policy_version, cutoff_ms, receipt_verified_before_ms, candidate_sha256, candidate_count, deleted_count, first_updated_at_ms, first_event_id, last_updated_at_ms, last_event_id, purged_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, receipt.AppCode, receipt.PurgeID, receipt.State, receipt.PolicyVersion, receipt.CutoffMS, receipt.ReceiptVerifiedBeforeMS, receipt.CandidateSHA256, receipt.CandidateCount, receipt.DeletedCount, receipt.FirstCursor.UpdatedAtMS, receipt.FirstCursor.EventID, receipt.LastCursor.UpdatedAtMS, receipt.LastCursor.EventID, receipt.PurgedAtMS, receipt.PurgedAtMS, receipt.PurgedAtMS, ) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected != 1 { return fmt.Errorf("wallet outbox purge receipt insert affected %d rows", affected) } return nil } func walletOutboxPurgeContext(ctx context.Context, appCode string) (context.Context, string, error) { if strings.TrimSpace(appCode) == "" { return ctx, "", xerr.New(xerr.InvalidArgument, "wallet outbox purge app_code is required") } ctx = contextWithCommandApp(ctx, appCode) normalized := appcode.FromContext(ctx) // 本轮容量治理边界只有 Lalu/Fami;其他租户即使误配也必须在进入事务前失败。 if normalized != "lalu" && normalized != "fami" { return ctx, "", xerr.New(xerr.InvalidArgument, "wallet outbox purge app_code is not allowed") } return ctx, normalized, nil } func walletOutboxPurgeEventTypes(policyVersion string) ([]string, error) { policyVersion = strings.TrimSpace(policyVersion) if policyVersion != walletOutboxPurgePolicyFinancialV1 { return nil, xerr.New(xerr.InvalidArgument, "wallet outbox purge policy_version is unsupported") } return walletOutboxPurgeFinancialV1EventTypes, nil } func normalizeWalletOutboxPurgeSHA256(field string, value string) (string, error) { value = strings.ToLower(strings.TrimSpace(value)) if len(value) != sha256.Size*2 { return "", xerr.New(xerr.InvalidArgument, field+" must be a SHA-256 hex digest") } if _, err := hex.DecodeString(value); err != nil { return "", xerr.New(xerr.InvalidArgument, field+" must be a SHA-256 hex digest") } return value, nil } func walletOutboxPurgePlaceholders(count int) string { values := make([]string, count) for index := range values { values[index] = "?" } return strings.Join(values, ",") } func hashWalletOutboxPurgeCandidates(appCode string, candidates []walletOutboxPurgeCandidate) string { digest := sha256.New() writeWalletOutboxPurgeHashString(digest, appCode) writeWalletOutboxPurgeHashUint64(digest, uint64(len(candidates))) for _, candidate := range candidates { // 候选已按 (updated_at_ms,event_id) 排序;哈希内容采用精确 PK 和归档时 updated_at 快照。 writeWalletOutboxPurgeHashString(digest, candidate.EventID) writeWalletOutboxPurgeHashUint64(digest, uint64(candidate.UpdatedAtMS)) } return hex.EncodeToString(digest.Sum(nil)) } func hashWalletOutboxPurgeReceipt(receipt WalletOutboxPurgeReceipt) string { digest := sha256.New() writeWalletOutboxPurgeHashString(digest, receipt.AppCode) writeWalletOutboxPurgeHashString(digest, receipt.PolicyVersion) writeWalletOutboxPurgeHashString(digest, receipt.CandidateSHA256) writeWalletOutboxPurgeHashUint64(digest, uint64(receipt.CutoffMS)) writeWalletOutboxPurgeHashUint64(digest, uint64(receipt.ReceiptVerifiedBeforeMS)) writeWalletOutboxPurgeHashUint64(digest, uint64(receipt.PurgedAtMS)) return hex.EncodeToString(digest.Sum(nil)) } func writeWalletOutboxPurgeHashString(digest hash.Hash, value string) { // 长度前缀消除字段拼接歧义,使相同候选在不同进程上得到完全一致的审计指纹。 var size [4]byte binary.BigEndian.PutUint32(size[:], uint32(len(value))) _, _ = digest.Write(size[:]) _, _ = digest.Write([]byte(value)) } func writeWalletOutboxPurgeHashUint64(digest hash.Hash, value uint64) { var encoded [8]byte binary.BigEndian.PutUint64(encoded[:], value) _, _ = digest.Write(encoded[:]) }