379 lines
16 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.

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[:])
}