379 lines
16 KiB
Go
379 lines
16 KiB
Go
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[:])
|
||
}
|