feat(wallet): archive delivered outbox to COS

This commit is contained in:
zhx 2026-07-21 13:53:28 +08:00
parent 02d9351b1a
commit 023802de61
12 changed files with 1278 additions and 12 deletions

View File

@ -157,6 +157,31 @@ outbox_worker:
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"
outbox_archive:
# 未配置私有 COS 的容器默认不扫描热表;显式启用后先用 dry_run 核对范围。
enabled: false
mode: "dry_run"
purge_enabled: false
max_age: "720h"
poll_interval: "5m"
query_batch_size: 500
segment_max_rows: 10000
segment_max_uncompressed_bytes: 67108864
round_max_rows: 50000
batch_pause: "50ms"
query_timeout: "10s"
operation_timeout: "2m"
object_prefix: "wallet-outbox/v1"
temp_dir: ""
cos:
credential_source: "static"
cam_role_name: ""
secret_id: ""
secret_key: ""
session_token: ""
bucket: ""
region: ""
http_timeout: "30s"
realtime_outbox_worker:
enabled: true
app_codes: []

View File

@ -158,6 +158,33 @@ outbox_worker:
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"
outbox_archive:
# 模板默认关闭;生产私有配置补齐专用桶/角色后显式切 archive_only。
enabled: false
mode: "dry_run"
# 目录版本、notice repair、statistics replay 和 legacy projection 仍读热表,当前任何环境都禁止 purge。
purge_enabled: false
max_age: "720h"
poll_interval: "5m"
query_batch_size: 500
segment_max_rows: 10000
segment_max_uncompressed_bytes: 67108864
round_max_rows: 50000
batch_pause: "50ms"
query_timeout: "10s"
operation_timeout: "2m"
object_prefix: "wallet-outbox/v1"
temp_dir: ""
cos:
# 生产使用 CVM 实例角色的 STS 临时凭证CAM 策略至少包含 PutObject/HeadObject/GetObject 的归档前缀最小权限。
credential_source: "cvm_role"
cam_role_name: "HyAppWalletOutboxArchiveRole"
secret_id: ""
secret_key: ""
session_token: ""
bucket: "${TENCENT_COS_ARCHIVE_BUCKET}"
region: "${TENCENT_COS_ARCHIVE_REGION}"
http_timeout: "30s"
realtime_outbox_worker:
enabled: true
# 实时 lane 必须与普通 lane 使用同一阶段 allowlist避免同表出现跨 App 抢占差异。

View File

@ -159,6 +159,31 @@ outbox_worker:
max_retry_count: 10
initial_backoff: "5s"
max_backoff: "5m"
outbox_archive:
# 未配置私有 COS 的环境默认不扫描热表;显式启用后先用 dry_run 核对范围。
enabled: false
mode: "dry_run"
purge_enabled: false
max_age: "720h"
poll_interval: "5m"
query_batch_size: 500
segment_max_rows: 10000
segment_max_uncompressed_bytes: 67108864
round_max_rows: 50000
batch_pause: "50ms"
query_timeout: "10s"
operation_timeout: "2m"
object_prefix: "wallet-outbox/v1"
temp_dir: ""
cos:
credential_source: "static"
cam_role_name: ""
secret_id: ""
secret_key: ""
session_token: ""
bucket: ""
region: ""
http_timeout: "30s"
realtime_outbox_worker:
enabled: true
app_codes: []

View File

@ -169,6 +169,40 @@ CREATE TABLE IF NOT EXISTS wallet_outbox (
KEY idx_wallet_outbox_tx (app_code, transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包事件 outbox 表';
-- 只记录 data/manifest/_SUCCESS 均在私有 COS 读回校验成功的批次。
-- 当前表不授权删除 wallet_outboxnotice/statistics/gift-wall 等读者迁移前仅用于推进 archive-only 稳定游标。
CREATE TABLE IF NOT EXISTS wallet_outbox_archive_receipts (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
batch_id CHAR(64) NOT NULL COMMENT '归档批次内容指纹',
state VARCHAR(32) NOT NULL COMMENT '只有 VERIFIED 可作为后续归档游标,不代表已恢复演练',
data_object_key VARCHAR(512) NOT NULL,
manifest_object_key VARCHAR(512) NOT NULL,
success_object_key VARCHAR(512) NOT NULL,
schema_version INT NOT NULL,
row_count INT NOT NULL,
first_updated_at_ms BIGINT NOT NULL,
first_event_id VARCHAR(128) NOT NULL,
last_updated_at_ms BIGINT NOT NULL,
last_event_id VARCHAR(128) NOT NULL,
min_created_at_ms BIGINT NOT NULL,
max_created_at_ms BIGINT NOT NULL,
available_delta_total VARCHAR(80) NOT NULL,
frozen_delta_total VARCHAR(80) NOT NULL,
ndjson_sha256 CHAR(64) NOT NULL,
gzip_sha256 CHAR(64) NOT NULL,
data_crc64 VARCHAR(32) NOT NULL,
manifest_crc64 VARCHAR(32) NOT NULL,
success_crc64 VARCHAR(32) NOT NULL,
manifest_sha256 CHAR(64) NOT NULL,
uncompressed_bytes BIGINT NOT NULL,
compressed_bytes BIGINT NOT NULL,
verified_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, batch_id),
KEY idx_wallet_outbox_archive_cursor (app_code, state, last_updated_at_ms, last_event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='wallet outbox 外部归档读回验证回执';
-- 钱包运行侧收益政策实例:由 admin-server 发布写入wallet 送礼、提现和结算只读本库运行快照。
CREATE TABLE IF NOT EXISTS wallet_policy_instances (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',

View File

@ -0,0 +1,32 @@
-- wallet_outbox archive-only 回执表。本迁移不修改、不删除 wallet_outbox对热表无重写和锁表风险。
CREATE TABLE IF NOT EXISTS wallet_outbox_archive_receipts (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
batch_id CHAR(64) NOT NULL COMMENT '归档批次内容指纹',
state VARCHAR(32) NOT NULL COMMENT '只有 VERIFIED 可作为后续归档游标,不代表已恢复演练',
data_object_key VARCHAR(512) NOT NULL,
manifest_object_key VARCHAR(512) NOT NULL,
success_object_key VARCHAR(512) NOT NULL,
schema_version INT NOT NULL,
row_count INT NOT NULL,
first_updated_at_ms BIGINT NOT NULL,
first_event_id VARCHAR(128) NOT NULL,
last_updated_at_ms BIGINT NOT NULL,
last_event_id VARCHAR(128) NOT NULL,
min_created_at_ms BIGINT NOT NULL,
max_created_at_ms BIGINT NOT NULL,
available_delta_total VARCHAR(80) NOT NULL,
frozen_delta_total VARCHAR(80) NOT NULL,
ndjson_sha256 CHAR(64) NOT NULL,
gzip_sha256 CHAR(64) NOT NULL,
data_crc64 VARCHAR(32) NOT NULL,
manifest_crc64 VARCHAR(32) NOT NULL,
success_crc64 VARCHAR(32) NOT NULL,
manifest_sha256 CHAR(64) NOT NULL,
uncompressed_bytes BIGINT NOT NULL,
compressed_bytes BIGINT NOT NULL,
verified_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, batch_id),
KEY idx_wallet_outbox_archive_cursor (app_code, state, last_updated_at_ms, last_event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='wallet outbox 外部归档读回验证回执';

View File

@ -32,18 +32,22 @@ import (
// App 装配 wallet-service gRPC 入口。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
walletSvc *walletservice.Service
activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer
realtimeOutboxProducer *rocketmqx.Producer
projectionConsumer *rocketmqx.Consumer
outboxWorkerCfg config.OutboxWorkerConfig
realtimeOutboxWorkerCfg config.OutboxWorkerConfig
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
walletSvc *walletservice.Service
activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer
realtimeOutboxProducer *rocketmqx.Producer
projectionConsumer *rocketmqx.Consumer
outboxWorkerCfg config.OutboxWorkerConfig
outboxArchiveCfg config.OutboxArchiveConfig
outboxArchiveStore *walletOutboxArchiveCOSStore
// dry-run 游标只存内存,不会被误认为已上传回执;单归档 goroutine 顺序访问无需额外锁。
outboxArchiveDryRunCursors map[string]mysqlstorage.WalletOutboxArchiveCursor
realtimeOutboxWorkerCfg config.OutboxWorkerConfig
// 普通与实时 lane 共享同一张表但可独立配置事件类型;各自维护 App 轮转游标,避免一个 lane 的吞吐改变另一个 lane 的公平性。
outboxPartitions *outboxpartition.Partitions
realtimeOutboxPartitions *outboxpartition.Partitions
@ -69,6 +73,14 @@ func New(cfg config.Config) (*App, error) {
if err != nil {
return nil, err
}
var outboxArchiveStore *walletOutboxArchiveCOSStore
if cfg.OutboxArchive.Enabled && cfg.OutboxArchive.Mode == config.OutboxArchiveModeArchiveOnly {
outboxArchiveStore, err = newWalletOutboxArchiveCOSStore(cfg.OutboxArchive.COS, nil)
if err != nil {
_ = repository.Close()
return nil, err
}
}
listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
@ -207,6 +219,9 @@ func New(cfg config.Config) (*App, error) {
realtimeOutboxProducer: realtimeOutboxProducer,
projectionConsumer: projectionConsumer,
outboxWorkerCfg: cfg.OutboxWorker,
outboxArchiveCfg: cfg.OutboxArchive,
outboxArchiveStore: outboxArchiveStore,
outboxArchiveDryRunCursors: make(map[string]mysqlstorage.WalletOutboxArchiveCursor),
realtimeOutboxWorkerCfg: cfg.RealtimeOutboxWorker,
outboxPartitions: outboxpartition.New(cfg.OutboxWorker.AppCodes, outboxpartition.DefaultDiscoveryRefreshInterval),
realtimeOutboxPartitions: outboxpartition.New(cfg.RealtimeOutboxWorker.AppCodes, outboxpartition.DefaultDiscoveryRefreshInterval),

View File

@ -19,6 +19,10 @@ func (a *App) runBackgroundWorkers() {
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxPartitions, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, nil)
}
if a.outboxArchiveCfg.Enabled {
// archive-only 与 MQ fanout 独立dry_run 也持续用有界索引查询暴露容量,但两种模式都不删除热表行。
a.startWalletOutboxArchiveWorker(ctx)
}
if a.externalRechargeReconcileWorkerCfg.Enabled {
a.startExternalRechargeReconcileWorker(ctx)
}

View File

@ -0,0 +1,431 @@
package app
import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
"os"
"path"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/wallet-service/internal/config"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
)
const (
walletOutboxArchiveSchema = "hyapp.wallet_outbox.archive"
walletOutboxArchiveSchemaVersion = 1
walletOutboxArchiveManifestState = "DATA_VERIFIED"
walletOutboxArchiveVerifiedState = "VERIFIED"
)
type walletOutboxArchiveManifest struct {
Schema string `json:"schema"`
SchemaVersion int `json:"schema_version"`
State string `json:"state"`
AppCode string `json:"app_code"`
BatchID string `json:"batch_id"`
RowCount int `json:"row_count"`
FirstCursor mysqlstorage.WalletOutboxArchiveCursor `json:"first_cursor"`
LastCursor mysqlstorage.WalletOutboxArchiveCursor `json:"last_cursor"`
MinCreatedAtMS int64 `json:"min_created_at_ms"`
MaxCreatedAtMS int64 `json:"max_created_at_ms"`
MinUpdatedAtMS int64 `json:"min_updated_at_ms"`
MaxUpdatedAtMS int64 `json:"max_updated_at_ms"`
AvailableDeltaTotal string `json:"available_delta_total"`
FrozenDeltaTotal string `json:"frozen_delta_total"`
NDJSONSHA256 string `json:"ndjson_sha256"`
GzipSHA256 string `json:"gzip_sha256"`
DataCRC64 string `json:"data_crc64"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
DataObjectKey string `json:"data_object_key"`
}
type walletOutboxArchiveSuccess struct {
Schema string `json:"schema"`
SchemaVersion int `json:"schema_version"`
State string `json:"state"`
BatchID string `json:"batch_id"`
ManifestSHA256 string `json:"manifest_sha256"`
ManifestCRC64 string `json:"manifest_crc64"`
}
type walletOutboxArchiveArtifact struct {
manifest walletOutboxArchiveManifest
manifestBody []byte
manifestSHA256 string
successBody []byte
successSHA256 string
dataFilePath string
dataObjectKey string
manifestObjectKey string
successObjectKey string
}
func (a *App) startWalletOutboxArchiveWorker(ctx context.Context) {
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.outboxArchiveCfg.PollInterval)
defer ticker.Stop()
for {
if err := a.runWalletOutboxArchiveRound(ctx); err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "wallet_outbox_archive_round_failed", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
func (a *App) runWalletOutboxArchiveRound(ctx context.Context) error {
if a.mysqlRepo == nil {
return nil
}
queryCtx, cancel := context.WithTimeout(ctx, a.outboxArchiveCfg.QueryTimeout)
appCodes, err := a.mysqlRepo.ListWalletOutboxAppCodes(queryCtx)
cancel()
if err != nil {
return err
}
cutoffMS := time.Now().UTC().Add(-a.outboxArchiveCfg.MaxAge).UnixMilli()
for _, appCode := range appCodes {
var appErr error
if a.outboxArchiveCfg.Mode == config.OutboxArchiveModeDryRun {
appErr = a.inspectWalletOutboxArchivePage(ctx, appCode, cutoffMS)
} else {
appErr = a.archiveWalletOutboxAppRound(ctx, appCode, cutoffMS)
}
if appErr != nil {
// 单租户归档失败不得阻塞其他 App失败 App 不写 receipt下一轮仍从同一游标重试。
logx.Error(ctx, "wallet_outbox_archive_app_failed", appErr, slog.String("app_code", appCode))
}
}
return nil
}
func (a *App) inspectWalletOutboxArchivePage(ctx context.Context, targetAppCode string, cutoffMS int64) error {
tenantCtx := appcode.WithContext(ctx, targetAppCode)
cursor := a.outboxArchiveDryRunCursors[targetAppCode]
queryCtx, cancel := context.WithTimeout(tenantCtx, a.outboxArchiveCfg.QueryTimeout)
records, err := a.mysqlRepo.ListDeliveredWalletOutboxArchiveBatch(queryCtx, cursor, cutoffMS, a.outboxArchiveCfg.QueryBatchSize)
cancel()
if err != nil || len(records) == 0 {
return err
}
first := records[0]
last := records[len(records)-1]
a.outboxArchiveDryRunCursors[targetAppCode] = mysqlstorage.WalletOutboxArchiveCursor{UpdatedAtMS: last.UpdatedAtMS, EventID: last.EventID}
logx.Info(ctx, "wallet_outbox_archive_planned",
slog.String("app_code", targetAppCode),
slog.Int("row_count", len(records)),
slog.Int64("cutoff_ms", cutoffMS),
slog.Int64("first_updated_at_ms", first.UpdatedAtMS),
slog.Int64("last_updated_at_ms", last.UpdatedAtMS),
slog.String("state", "PLANNED"),
slog.Bool("purge_enabled", false),
)
return nil
}
func (a *App) archiveWalletOutboxAppRound(ctx context.Context, targetAppCode string, cutoffMS int64) error {
tenantCtx := appcode.WithContext(ctx, targetAppCode)
lockCtx, lockCancel := context.WithTimeout(tenantCtx, a.outboxArchiveCfg.QueryTimeout)
release, acquired, err := a.mysqlRepo.AcquireWalletOutboxArchiveAppLock(lockCtx)
lockCancel()
if err != nil || !acquired {
return err
}
defer release()
queryCtx, cancel := context.WithTimeout(tenantCtx, a.outboxArchiveCfg.QueryTimeout)
cursor, err := a.mysqlRepo.LatestVerifiedWalletOutboxArchiveCursor(queryCtx)
cancel()
if err != nil {
return err
}
archivedRows := 0
for archivedRows < a.outboxArchiveCfg.RoundMaxRows {
segmentLimit := min(a.outboxArchiveCfg.SegmentMaxRows, a.outboxArchiveCfg.RoundMaxRows-archivedRows)
records, err := a.collectWalletOutboxArchiveSegment(tenantCtx, cursor, cutoffMS, segmentLimit)
if err != nil || len(records) == 0 {
return err
}
if err := a.archiveWalletOutboxRecords(tenantCtx, targetAppCode, records); err != nil {
return err
}
last := records[len(records)-1]
cursor = mysqlstorage.WalletOutboxArchiveCursor{UpdatedAtMS: last.UpdatedAtMS, EventID: last.EventID}
archivedRows += len(records)
if len(records) < segmentLimit {
break
}
}
return nil
}
func (a *App) collectWalletOutboxArchiveSegment(ctx context.Context, cursor mysqlstorage.WalletOutboxArchiveCursor, cutoffMS int64, segmentLimit int) ([]mysqlstorage.WalletOutboxArchiveRecord, error) {
records := make([]mysqlstorage.WalletOutboxArchiveRecord, 0, segmentLimit)
partitionDay := ""
var uncompressedBytes int64
for len(records) < segmentLimit {
pageLimit := min(a.outboxArchiveCfg.QueryBatchSize, segmentLimit-len(records))
queryCtx, cancel := context.WithTimeout(ctx, a.outboxArchiveCfg.QueryTimeout)
page, err := a.mysqlRepo.ListDeliveredWalletOutboxArchiveBatch(queryCtx, cursor, cutoffMS, pageLimit)
cancel()
if err != nil {
return nil, err
}
if len(page) == 0 {
break
}
for _, record := range page {
recordDay := time.UnixMilli(record.UpdatedAtMS).UTC().Format("2006-01-02")
if partitionDay == "" {
partitionDay = recordDay
}
// 一个对象严格属于一个 UTC delivered 日,保证按日盘点/恢复不会遗漏跨日批次里的前半段。
if recordDay != partitionDay {
return records, nil
}
line, err := json.Marshal(record)
if err != nil {
return nil, err
}
recordBytes := int64(len(line) + 1)
// 至少允许单行进入批次;超大单行仍由 operation timeout 和临时盘容量形成外层保护。
if len(records) > 0 && uncompressedBytes+recordBytes > a.outboxArchiveCfg.SegmentMaxUncompressedBytes {
return records, nil
}
records = append(records, record)
uncompressedBytes += recordBytes
if len(records) == segmentLimit {
return records, nil
}
}
last := records[len(records)-1]
cursor = mysqlstorage.WalletOutboxArchiveCursor{UpdatedAtMS: last.UpdatedAtMS, EventID: last.EventID}
if len(page) < pageLimit {
break
}
if a.outboxArchiveCfg.BatchPause > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(a.outboxArchiveCfg.BatchPause):
}
}
}
return records, nil
}
func (a *App) archiveWalletOutboxRecords(tenantCtx context.Context, targetAppCode string, records []mysqlstorage.WalletOutboxArchiveRecord) error {
if a.outboxArchiveStore == nil {
return fmt.Errorf("wallet outbox archive store is not configured")
}
artifact, err := buildWalletOutboxArchiveArtifact(a.outboxArchiveCfg, records)
if err != nil {
return err
}
defer artifact.remove()
operationCtx, operationCancel := context.WithTimeout(tenantCtx, a.outboxArchiveCfg.OperationTimeout)
defer operationCancel()
// 只有 data 读回通过后才上传 manifestmanifest 读回通过后才写 _SUCCESS
// 任意中断都不会产生 MySQL verified receipt后续可用相同内容键幂等重试。
dataCRC64, err := a.outboxArchiveStore.putGzipAndVerify(operationCtx, artifact.dataObjectKey, artifact.dataFilePath, artifact.manifest.CompressedBytes, artifact.manifest.GzipSHA256, artifact.manifest.NDJSONSHA256)
if err != nil {
return err
}
if err := artifact.finalizeManifest(dataCRC64); err != nil {
return err
}
manifestCRC64, err := a.outboxArchiveStore.putBytesAndVerify(operationCtx, artifact.manifestObjectKey, artifact.manifestBody, "application/json", artifact.manifestSHA256)
if err != nil {
return err
}
if err := artifact.finalizeSuccess(manifestCRC64); err != nil {
return err
}
successCRC64, err := a.outboxArchiveStore.putBytesAndVerify(operationCtx, artifact.successObjectKey, artifact.successBody, "application/json", artifact.successSHA256)
if err != nil {
return err
}
nowMS := time.Now().UTC().UnixMilli()
receiptCtx, receiptCancel := context.WithTimeout(tenantCtx, a.outboxArchiveCfg.QueryTimeout)
err = a.mysqlRepo.RecordVerifiedWalletOutboxArchive(receiptCtx, mysqlstorage.WalletOutboxArchiveReceipt{
AppCode: targetAppCode, BatchID: artifact.manifest.BatchID, State: walletOutboxArchiveVerifiedState,
DataObjectKey: artifact.dataObjectKey, ManifestObjectKey: artifact.manifestObjectKey, SuccessObjectKey: artifact.successObjectKey,
SchemaVersion: artifact.manifest.SchemaVersion, RowCount: artifact.manifest.RowCount,
FirstCursor: artifact.manifest.FirstCursor, LastCursor: artifact.manifest.LastCursor,
MinCreatedAtMS: artifact.manifest.MinCreatedAtMS, MaxCreatedAtMS: artifact.manifest.MaxCreatedAtMS,
AvailableDeltaTotal: artifact.manifest.AvailableDeltaTotal, FrozenDeltaTotal: artifact.manifest.FrozenDeltaTotal,
NDJSONSHA256: artifact.manifest.NDJSONSHA256, GzipSHA256: artifact.manifest.GzipSHA256, ManifestSHA256: artifact.manifestSHA256,
DataCRC64: dataCRC64, ManifestCRC64: manifestCRC64, SuccessCRC64: successCRC64,
UncompressedBytes: artifact.manifest.UncompressedBytes, CompressedBytes: artifact.manifest.CompressedBytes, VerifiedAtMS: nowMS,
})
receiptCancel()
if err != nil {
return err
}
logx.Info(tenantCtx, "wallet_outbox_archive_verified",
slog.String("app_code", targetAppCode), slog.String("batch_id", artifact.manifest.BatchID),
slog.Int("row_count", artifact.manifest.RowCount), slog.String("state", walletOutboxArchiveVerifiedState),
slog.Bool("purge_enabled", false),
)
return nil
}
func buildWalletOutboxArchiveArtifact(cfg config.OutboxArchiveConfig, records []mysqlstorage.WalletOutboxArchiveRecord) (walletOutboxArchiveArtifact, error) {
if len(records) == 0 {
return walletOutboxArchiveArtifact{}, fmt.Errorf("wallet outbox archive batch is empty")
}
tempFile, err := os.CreateTemp(cfg.TempDir, "wallet-outbox-*.ndjson.gz")
if err != nil {
return walletOutboxArchiveArtifact{}, err
}
tempPath := tempFile.Name()
removeOnError := true
defer func() {
if removeOnError {
_ = tempFile.Close()
_ = os.Remove(tempPath)
}
}()
// CreateTemp 默认 0600仍显式收紧权限避免自定义 umask 导致账务快照被同机其他用户读取。
if err := tempFile.Chmod(0o600); err != nil {
return walletOutboxArchiveArtifact{}, err
}
gzipHash := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(tempFile, gzipHash))
gzipWriter.Header.ModTime = time.Unix(0, 0).UTC()
gzipWriter.Header.OS = 255
ndjsonHash := sha256.New()
countedNDJSON := &countingWriter{writer: io.MultiWriter(gzipWriter, ndjsonHash)}
availableTotal := new(big.Int)
frozenTotal := new(big.Int)
minCreatedAtMS, maxCreatedAtMS := records[0].CreatedAtMS, records[0].CreatedAtMS
partitionDay := time.UnixMilli(records[0].UpdatedAtMS).UTC().Format("2006-01-02")
for _, record := range records {
if record.AppCode != records[0].AppCode || record.Status != "delivered" {
return walletOutboxArchiveArtifact{}, fmt.Errorf("wallet outbox archive batch crosses app boundary or contains non-delivered row")
}
if time.UnixMilli(record.UpdatedAtMS).UTC().Format("2006-01-02") != partitionDay {
return walletOutboxArchiveArtifact{}, fmt.Errorf("wallet outbox archive batch crosses UTC delivered day")
}
line, err := json.Marshal(record)
if err != nil {
return walletOutboxArchiveArtifact{}, err
}
line = append(line, '\n')
if _, err := countedNDJSON.Write(line); err != nil {
return walletOutboxArchiveArtifact{}, err
}
availableTotal.Add(availableTotal, big.NewInt(record.AvailableDelta))
frozenTotal.Add(frozenTotal, big.NewInt(record.FrozenDelta))
if record.CreatedAtMS < minCreatedAtMS {
minCreatedAtMS = record.CreatedAtMS
}
if record.CreatedAtMS > maxCreatedAtMS {
maxCreatedAtMS = record.CreatedAtMS
}
}
if err := gzipWriter.Close(); err != nil {
return walletOutboxArchiveArtifact{}, err
}
if err := tempFile.Sync(); err != nil {
return walletOutboxArchiveArtifact{}, err
}
info, err := tempFile.Stat()
if err != nil {
return walletOutboxArchiveArtifact{}, err
}
if err := tempFile.Close(); err != nil {
return walletOutboxArchiveArtifact{}, err
}
first, last := records[0], records[len(records)-1]
firstCursor := mysqlstorage.WalletOutboxArchiveCursor{UpdatedAtMS: first.UpdatedAtMS, EventID: first.EventID}
lastCursor := mysqlstorage.WalletOutboxArchiveCursor{UpdatedAtMS: last.UpdatedAtMS, EventID: last.EventID}
ndjsonSHA256 := hex.EncodeToString(ndjsonHash.Sum(nil))
gzipSHA256 := hex.EncodeToString(gzipHash.Sum(nil))
batchHash := sha256.Sum256([]byte(strings.Join([]string{first.AppCode, fmt.Sprint(len(records)), fmt.Sprint(firstCursor.UpdatedAtMS), firstCursor.EventID, fmt.Sprint(lastCursor.UpdatedAtMS), lastCursor.EventID, ndjsonSHA256}, "\n")))
batchID := hex.EncodeToString(batchHash[:])
objectDirectory := path.Join(cfg.ObjectPrefix, "app_code="+first.AppCode, "delivered_day="+partitionDay, "batch_id="+batchID)
dataObjectKey := path.Join(objectDirectory, "data.ndjson.gz")
manifest := walletOutboxArchiveManifest{
Schema: walletOutboxArchiveSchema, SchemaVersion: walletOutboxArchiveSchemaVersion, State: walletOutboxArchiveManifestState,
AppCode: first.AppCode, BatchID: batchID, RowCount: len(records), FirstCursor: firstCursor, LastCursor: lastCursor,
MinCreatedAtMS: minCreatedAtMS, MaxCreatedAtMS: maxCreatedAtMS, MinUpdatedAtMS: first.UpdatedAtMS, MaxUpdatedAtMS: last.UpdatedAtMS,
AvailableDeltaTotal: availableTotal.String(), FrozenDeltaTotal: frozenTotal.String(), NDJSONSHA256: ndjsonSHA256, GzipSHA256: gzipSHA256,
UncompressedBytes: countedNDJSON.count, CompressedBytes: info.Size(), DataObjectKey: dataObjectKey,
}
removeOnError = false
return walletOutboxArchiveArtifact{
manifest: manifest, dataFilePath: tempPath,
dataObjectKey: dataObjectKey, manifestObjectKey: path.Join(objectDirectory, "manifest.json"), successObjectKey: path.Join(objectDirectory, "_SUCCESS"),
}, nil
}
func (a *walletOutboxArchiveArtifact) finalizeManifest(dataCRC64 string) error {
dataCRC64 = strings.TrimSpace(dataCRC64)
if dataCRC64 == "" {
return fmt.Errorf("wallet outbox archive data CRC64 is empty")
}
a.manifest.DataCRC64 = dataCRC64
manifestBody, err := json.Marshal(a.manifest)
if err != nil {
return err
}
a.manifestBody = append(manifestBody, '\n')
manifestHash := sha256.Sum256(a.manifestBody)
a.manifestSHA256 = hex.EncodeToString(manifestHash[:])
return nil
}
func (a *walletOutboxArchiveArtifact) finalizeSuccess(manifestCRC64 string) error {
manifestCRC64 = strings.TrimSpace(manifestCRC64)
if manifestCRC64 == "" {
return fmt.Errorf("wallet outbox archive manifest CRC64 is empty")
}
successBody, err := json.Marshal(walletOutboxArchiveSuccess{
Schema: walletOutboxArchiveSchema, SchemaVersion: walletOutboxArchiveSchemaVersion,
State: walletOutboxArchiveVerifiedState, BatchID: a.manifest.BatchID,
ManifestSHA256: a.manifestSHA256, ManifestCRC64: manifestCRC64,
})
if err != nil {
return err
}
a.successBody = append(successBody, '\n')
successHash := sha256.Sum256(a.successBody)
a.successSHA256 = hex.EncodeToString(successHash[:])
return nil
}
func (a walletOutboxArchiveArtifact) remove() {
if strings.TrimSpace(a.dataFilePath) != "" {
_ = os.Remove(a.dataFilePath)
}
}
type countingWriter struct {
writer io.Writer
count int64
}
func (w *countingWriter) Write(body []byte) (int, error) {
written, err := w.writer.Write(body)
w.count += int64(written)
return written, err
}

View File

@ -0,0 +1,192 @@
package app
import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
cos "github.com/tencentyun/cos-go-sdk-v5"
"hyapp/services/wallet-service/internal/config"
)
var errWalletOutboxArchiveObjectExists = errors.New("wallet outbox archive object already exists")
// walletOutboxArchiveCOSStore 只使用私有 bucket API不生成公网 URL也不在对象上设置 public-read ACL。
type walletOutboxArchiveCOSStore struct {
client *cos.Client
}
func newWalletOutboxArchiveCOSStore(cfg config.OutboxArchiveCOSConfig, transport http.RoundTripper) (*walletOutboxArchiveCOSStore, error) {
bucketURL, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", strings.TrimSpace(cfg.Bucket), strings.TrimSpace(cfg.Region)))
if err != nil {
return nil, fmt.Errorf("build wallet outbox archive COS url: %w", err)
}
if transport == nil {
transport = http.DefaultTransport
}
var authorizationTransport http.RoundTripper
if cfg.CredentialSource == "cvm_role" {
authorizationTransport = &cvmRoleAuthorizationTransport{provider: newCVMCAMCredentialProvider(cfg.CAMRoleName), transport: transport}
} else {
authorizationTransport = &cos.AuthorizationTransport{
SecretID: cfg.SecretID,
SecretKey: cfg.SecretKey,
SessionToken: cfg.SessionToken,
Transport: transport,
}
}
return &walletOutboxArchiveCOSStore{client: cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, &http.Client{
Timeout: cfg.HTTPTimeout,
Transport: authorizationTransport,
})}, nil
}
func (s *walletOutboxArchiveCOSStore) putGzipAndVerify(ctx context.Context, key string, filePath string, sizeBytes int64, gzipSHA256 string, ndjsonSHA256 string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
// 对象本体已经是 gzip 文件,不设置 Content-Encoding。Go HTTP Transport 会自动解压带
// Content-Encoding:gzip 的响应,导致后续无法同时核对“压缩对象 hash”和“解压 NDJSON hash”。
putCRC64, err := s.put(ctx, key, file, sizeBytes, "application/gzip", "", gzipSHA256)
if err != nil && !errors.Is(err, errWalletOutboxArchiveObjectExists) {
return "", err
}
crc64, err := s.verifyHead(ctx, key, sizeBytes, gzipSHA256, putCRC64)
if err != nil {
return "", err
}
if err := s.verifyGzipReadback(ctx, key, gzipSHA256, ndjsonSHA256); err != nil {
return "", err
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) putBytesAndVerify(ctx context.Context, key string, body []byte, contentType string, expectedSHA256 string) (string, error) {
putCRC64, err := s.put(ctx, key, strings.NewReader(string(body)), int64(len(body)), contentType, "", expectedSHA256)
if err != nil && !errors.Is(err, errWalletOutboxArchiveObjectExists) {
return "", err
}
crc64, err := s.verifyHead(ctx, key, int64(len(body)), expectedSHA256, putCRC64)
if err != nil {
return "", err
}
response, err := s.client.Object.Get(ctx, key, nil)
if err != nil {
return "", err
}
defer response.Body.Close()
hash := sha256.New()
if _, err := io.Copy(hash, response.Body); err != nil {
return "", err
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != expectedSHA256 {
return "", fmt.Errorf("wallet outbox archive COS readback hash mismatch for %s", key)
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) put(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string, contentEncoding string, contentSHA256 string) (string, error) {
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || reader == nil || sizeBytes <= 0 {
return "", fmt.Errorf("wallet outbox archive COS put input is invalid")
}
metadata := make(http.Header)
metadata.Set("x-cos-meta-sha256", contentSHA256)
optionHeaders := make(http.Header)
// 归档键由内容指纹决定;禁止覆盖可避免 bucket versioning 在并发/重试时生成重复版本。
optionHeaders.Set("x-cos-forbid-overwrite", "true")
options := &cos.ObjectPutOptions{ObjectPutHeaderOptions: &cos.ObjectPutHeaderOptions{
ContentLength: sizeBytes,
ContentType: contentType,
ContentEncoding: contentEncoding,
XCosMetaXXX: &metadata,
XOptionHeader: &optionHeaders,
XCosStorageClass: "STANDARD",
XCosServerSideEncryption: "AES256",
}}
response, err := s.client.Object.Put(ctx, strings.TrimLeft(key, "/"), reader, options)
crc64 := ""
if response != nil {
crc64 = strings.TrimSpace(response.Header.Get("x-cos-hash-crc64ecma"))
}
if response != nil && response.Body != nil {
_ = response.Body.Close()
}
if err != nil {
var cosErr *cos.ErrorResponse
if errors.As(err, &cosErr) && cosErr.Response != nil && (cosErr.Response.StatusCode == http.StatusConflict || cosErr.Response.StatusCode == http.StatusPreconditionFailed) {
return "", errWalletOutboxArchiveObjectExists
}
return "", err
}
if crc64 == "" {
return "", fmt.Errorf("wallet outbox archive COS PUT response has no CRC64 for %s", key)
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) verifyHead(ctx context.Context, key string, expectedSize int64, expectedSHA256 string, expectedCRC64 string) (string, error) {
response, err := s.client.Object.Head(ctx, key, nil)
if err != nil {
return "", err
}
if response.Body != nil {
defer response.Body.Close()
}
if response.ContentLength != expectedSize || response.Header.Get("x-cos-meta-sha256") != expectedSHA256 {
return "", fmt.Errorf("wallet outbox archive COS HEAD mismatch for %s", key)
}
crc64 := strings.TrimSpace(response.Header.Get("x-cos-hash-crc64ecma"))
if crc64 == "" || (expectedCRC64 != "" && crc64 != expectedCRC64) {
return "", fmt.Errorf("wallet outbox archive COS HEAD CRC64 mismatch for %s", key)
}
// SSE-COS 是归档对象的强制约束;响应缺失加密标记时不能写 verified receipt。
if !strings.EqualFold(response.Header.Get("x-cos-server-side-encryption"), "AES256") {
return "", fmt.Errorf("wallet outbox archive COS object is not SSE-COS encrypted: %s", key)
}
return crc64, nil
}
func (s *walletOutboxArchiveCOSStore) verifyGzipReadback(ctx context.Context, key string, expectedGzipSHA256 string, expectedNDJSONSHA256 string) error {
response, err := s.client.Object.Get(ctx, key, nil)
if err != nil {
return err
}
defer response.Body.Close()
gzipHash := sha256.New()
compressedReader := io.TeeReader(response.Body, gzipHash)
gzipReader, err := gzip.NewReader(compressedReader)
if err != nil {
return err
}
ndjsonHash := sha256.New()
_, copyErr := io.Copy(ndjsonHash, gzipReader)
closeErr := gzipReader.Close()
// gzip.Reader 可能在内部缓冲区之后留下响应尾部;继续读完才能验证整个压缩对象的 hash。
_, drainErr := io.Copy(io.Discard, compressedReader)
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if drainErr != nil {
return drainErr
}
if actual := hex.EncodeToString(gzipHash.Sum(nil)); actual != expectedGzipSHA256 {
return fmt.Errorf("wallet outbox archive COS gzip hash mismatch for %s", key)
}
if actual := hex.EncodeToString(ndjsonHash.Sum(nil)); actual != expectedNDJSONSHA256 {
return fmt.Errorf("wallet outbox archive COS ndjson hash mismatch for %s", key)
}
return nil
}

View File

@ -0,0 +1,102 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
cos "github.com/tencentyun/cos-go-sdk-v5"
)
const cvmCAMCredentialMetadataBaseURL = "http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials"
type cvmCAMCredential struct {
TmpSecretID string `json:"TmpSecretId"`
TmpSecretKey string `json:"TmpSecretKey"`
Token string `json:"Token"`
ExpiredTime int64 `json:"ExpiredTime"`
Code string `json:"Code"`
}
// cvmCAMCredentialProvider 在凭证过期前 5 分钟刷新,整个结构不暴露凭证方法,避免误打日志。
type cvmCAMCredentialProvider struct {
roleName string
client *http.Client
mu sync.Mutex
cached cvmCAMCredential
}
func newCVMCAMCredentialProvider(roleName string) *cvmCAMCredentialProvider {
return &cvmCAMCredentialProvider{
roleName: strings.TrimSpace(roleName),
// metadata 是启动/请求链路的外部依赖,必须在 2 秒内失败,不能把 COS 归档 goroutine 长时间卡死。
client: &http.Client{Timeout: 2 * time.Second},
}
}
func (p *cvmCAMCredentialProvider) get(ctx context.Context) (string, string, string, error) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now().UTC().Unix()
if p.cached.ExpiredTime > now+int64((5*time.Minute).Seconds()) {
return p.cached.TmpSecretID, p.cached.TmpSecretKey, p.cached.Token, nil
}
previous := p.cached
if p.roleName == "" {
return "", "", "", fmt.Errorf("wallet outbox archive CVM CAM role name is empty")
}
metadataURL := cvmCAMCredentialMetadataBaseURL + "/" + url.PathEscape(p.roleName)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
if err != nil {
return "", "", "", err
}
response, err := p.client.Do(request)
if err != nil {
if previous.ExpiredTime > now {
return previous.TmpSecretID, previous.TmpSecretKey, previous.Token, nil
}
return "", "", "", fmt.Errorf("load wallet outbox archive CVM role credential: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode > 299 {
// 不读取/回传 metadata 错误体,避免平台异常响应携带临时凭证。
if previous.ExpiredTime > now {
return previous.TmpSecretID, previous.TmpSecretKey, previous.Token, nil
}
return "", "", "", fmt.Errorf("load wallet outbox archive CVM role credential: metadata status %d", response.StatusCode)
}
var credential cvmCAMCredential
if err := json.NewDecoder(response.Body).Decode(&credential); err != nil {
return "", "", "", fmt.Errorf("decode wallet outbox archive CVM role credential: %w", err)
}
if credential.Code != "Success" || strings.TrimSpace(credential.TmpSecretID) == "" || strings.TrimSpace(credential.TmpSecretKey) == "" || strings.TrimSpace(credential.Token) == "" || credential.ExpiredTime <= now+30 {
return "", "", "", fmt.Errorf("wallet outbox archive CVM role returned incomplete or expired credential")
}
p.cached = credential
return credential.TmpSecretID, credential.TmpSecretKey, credential.Token, nil
}
// cvmRoleAuthorizationTransport 每次签名前从缓存 provider 取 STS只有刷新窗口才访问 metadata。
type cvmRoleAuthorizationTransport struct {
provider *cvmCAMCredentialProvider
transport http.RoundTripper
}
func (t *cvmRoleAuthorizationTransport) GetCredential() (string, string, string, error) {
return t.provider.get(context.Background())
}
func (t *cvmRoleAuthorizationTransport) RoundTrip(request *http.Request) (*http.Response, error) {
secretID, secretKey, token, err := t.provider.get(request.Context())
if err != nil {
return nil, err
}
return (&cos.AuthorizationTransport{
SecretID: secretID, SecretKey: secretKey, SessionToken: token, Transport: t.transport,
}).RoundTrip(request)
}

View File

@ -48,6 +48,8 @@ type Config struct {
ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"`
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// OutboxArchive 只把已投递事实备份到外部存储;当前不提供 purge避免破坏线上回放与修复读者。
OutboxArchive OutboxArchiveConfig `yaml:"outbox_archive"`
// RealtimeOutboxWorker 控制红包等实时 UI 事件到独立 MQ topic 的补偿投递。
RealtimeOutboxWorker OutboxWorkerConfig `yaml:"realtime_outbox_worker"`
// ProjectionWorker 控制 wallet_outbox MQ 到本服务读模型的投影消费。
@ -115,6 +117,46 @@ type OutboxWorkerConfig struct {
EventTypes []string `yaml:"event_types"`
}
// OutboxArchiveMode 区分无写入的容量评估与可验证的 COS 归档。
type OutboxArchiveMode string
const (
OutboxArchiveModeDryRun OutboxArchiveMode = "dry_run"
OutboxArchiveModeArchiveOnly OutboxArchiveMode = "archive_only"
)
// OutboxArchiveConfig 控制 delivered wallet_outbox 的 archive-only 备份链路。
type OutboxArchiveConfig struct {
Enabled bool `yaml:"enabled"`
Mode OutboxArchiveMode `yaml:"mode"`
PurgeEnabled bool `yaml:"purge_enabled"`
MaxAge time.Duration `yaml:"max_age"`
PollInterval time.Duration `yaml:"poll_interval"`
QueryBatchSize int `yaml:"query_batch_size"`
SegmentMaxRows int `yaml:"segment_max_rows"`
// SegmentMaxUncompressedBytes caps one in-memory/archive artifact independently of row count because payload sizes vary.
SegmentMaxUncompressedBytes int64 `yaml:"segment_max_uncompressed_bytes"`
RoundMaxRows int `yaml:"round_max_rows"`
BatchPause time.Duration `yaml:"batch_pause"`
QueryTimeout time.Duration `yaml:"query_timeout"`
OperationTimeout time.Duration `yaml:"operation_timeout"`
ObjectPrefix string `yaml:"object_prefix"`
TempDir string `yaml:"temp_dir"`
COS OutboxArchiveCOSConfig `yaml:"cos"`
}
// OutboxArchiveCOSConfig 只允许服务端环境注入私有桶凭证;归档对象不生成公网 URL。
type OutboxArchiveCOSConfig struct {
CredentialSource string `yaml:"credential_source"`
CAMRoleName string `yaml:"cam_role_name"`
SecretID string `yaml:"secret_id"`
SecretKey string `yaml:"secret_key"`
SessionToken string `yaml:"session_token"`
Bucket string `yaml:"bucket"`
Region string `yaml:"region"`
HTTPTimeout time.Duration `yaml:"http_timeout"`
}
// ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox MQ 投影的节奏。
type ProjectionWorkerConfig struct {
Enabled bool `yaml:"enabled"`
@ -349,6 +391,25 @@ func Default() Config {
InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute,
},
OutboxArchive: OutboxArchiveConfig{
Enabled: false,
Mode: OutboxArchiveModeDryRun,
PurgeEnabled: false,
MaxAge: 30 * 24 * time.Hour,
PollInterval: 5 * time.Minute,
QueryBatchSize: 500,
SegmentMaxRows: 10000,
SegmentMaxUncompressedBytes: 64 << 20,
RoundMaxRows: 50000,
BatchPause: 50 * time.Millisecond,
QueryTimeout: 10 * time.Second,
OperationTimeout: 2 * time.Minute,
ObjectPrefix: "wallet-outbox/v1",
COS: OutboxArchiveCOSConfig{
CredentialSource: "static",
HTTPTimeout: 30 * time.Second,
},
},
RealtimeOutboxWorker: OutboxWorkerConfig{
Enabled: false,
PollInterval: time.Second,
@ -517,6 +578,11 @@ func Load(path string) (Config, error) {
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled")
}
outboxArchive, err := normalizeOutboxArchiveConfig(cfg.OutboxArchive, Default().OutboxArchive)
if err != nil {
return Config{}, err
}
cfg.OutboxArchive = outboxArchive
cfg.RealtimeOutboxWorker = normalizeOutboxWorkerConfig(cfg.RealtimeOutboxWorker, Default().RealtimeOutboxWorker)
if len(cfg.RealtimeOutboxWorker.EventTypes) == 0 {
cfg.RealtimeOutboxWorker.EventTypes = defaultRealtimeOutboxEventTypes()
@ -847,6 +913,104 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig, defaults OutboxWorkerCo
return cfg
}
func normalizeOutboxArchiveConfig(cfg OutboxArchiveConfig, defaults OutboxArchiveConfig) (OutboxArchiveConfig, error) {
cfg.Mode = OutboxArchiveMode(strings.ToLower(strings.TrimSpace(string(cfg.Mode))))
if cfg.Mode == "" {
cfg.Mode = defaults.Mode
}
// wallet_outbox 仍是 GiftCatalogVersion、gift-wall/badge 回填、notice repair 和 statistics replay 的在线事实源。
// 在这些读者切到热表 + 归档恢复层之前,任何 purge 配置都必须启动失败,不允许静默降级为裸删。
if cfg.PurgeEnabled {
return OutboxArchiveConfig{}, errors.New("outbox_archive.purge_enabled is unavailable until wallet_outbox online readers use the archive restore path")
}
switch cfg.Mode {
case OutboxArchiveModeDryRun, OutboxArchiveModeArchiveOnly:
default:
return OutboxArchiveConfig{}, errors.New("outbox_archive.mode must be dry_run or archive_only; purge modes are intentionally unsupported")
}
if cfg.MaxAge <= 0 {
cfg.MaxAge = defaults.MaxAge
}
if cfg.Enabled && cfg.MaxAge < 24*time.Hour {
return OutboxArchiveConfig{}, errors.New("outbox_archive.max_age must be at least 24h")
}
if cfg.PollInterval <= 0 {
cfg.PollInterval = defaults.PollInterval
}
if cfg.QueryBatchSize <= 0 {
cfg.QueryBatchSize = defaults.QueryBatchSize
}
if cfg.QueryBatchSize > 500 {
cfg.QueryBatchSize = 500
}
if cfg.SegmentMaxRows <= 0 {
cfg.SegmentMaxRows = defaults.SegmentMaxRows
}
if cfg.SegmentMaxRows > 50000 {
cfg.SegmentMaxRows = 50000
}
if cfg.SegmentMaxUncompressedBytes <= 0 {
cfg.SegmentMaxUncompressedBytes = defaults.SegmentMaxUncompressedBytes
}
if cfg.SegmentMaxUncompressedBytes > 256<<20 {
cfg.SegmentMaxUncompressedBytes = 256 << 20
}
if cfg.RoundMaxRows <= 0 {
cfg.RoundMaxRows = defaults.RoundMaxRows
}
if cfg.RoundMaxRows < cfg.SegmentMaxRows {
cfg.RoundMaxRows = cfg.SegmentMaxRows
}
if cfg.RoundMaxRows > 250000 {
cfg.RoundMaxRows = 250000
}
if cfg.BatchPause < 0 {
cfg.BatchPause = defaults.BatchPause
}
if cfg.QueryTimeout <= 0 {
cfg.QueryTimeout = defaults.QueryTimeout
}
if cfg.OperationTimeout <= 0 {
cfg.OperationTimeout = defaults.OperationTimeout
}
cfg.ObjectPrefix = strings.Trim(strings.TrimSpace(cfg.ObjectPrefix), "/")
if cfg.ObjectPrefix == "" {
cfg.ObjectPrefix = defaults.ObjectPrefix
}
cfg.TempDir = strings.TrimSpace(cfg.TempDir)
cfg.COS.SecretID = strings.TrimSpace(cfg.COS.SecretID)
cfg.COS.SecretKey = strings.TrimSpace(cfg.COS.SecretKey)
cfg.COS.SessionToken = strings.TrimSpace(cfg.COS.SessionToken)
cfg.COS.Bucket = strings.TrimSpace(cfg.COS.Bucket)
cfg.COS.Region = strings.TrimSpace(cfg.COS.Region)
cfg.COS.CredentialSource = strings.ToLower(strings.TrimSpace(cfg.COS.CredentialSource))
if cfg.COS.CredentialSource == "" {
cfg.COS.CredentialSource = defaults.COS.CredentialSource
}
cfg.COS.CAMRoleName = strings.TrimSpace(cfg.COS.CAMRoleName)
if cfg.COS.HTTPTimeout <= 0 {
cfg.COS.HTTPTimeout = defaults.COS.HTTPTimeout
}
if cfg.Enabled && cfg.Mode == OutboxArchiveModeArchiveOnly {
if cfg.COS.Bucket == "" || cfg.COS.Region == "" {
return OutboxArchiveConfig{}, errors.New("outbox_archive archive_only mode requires private COS bucket and region")
}
switch cfg.COS.CredentialSource {
case "static":
if cfg.COS.SecretID == "" || cfg.COS.SecretKey == "" {
return OutboxArchiveConfig{}, errors.New("outbox_archive static COS credential source requires secret_id and secret_key")
}
case "cvm_role":
if cfg.COS.CAMRoleName == "" {
return OutboxArchiveConfig{}, errors.New("outbox_archive cvm_role COS credential source requires cam_role_name")
}
default:
return OutboxArchiveConfig{}, errors.New("outbox_archive.cos.credential_source must be static or cvm_role")
}
}
return cfg, nil
}
func normalizeStringSlice(values []string) []string {
normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))

View File

@ -0,0 +1,215 @@
package mysql
import (
"context"
"database/sql"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
)
const walletOutboxArchiveStateVerified = "VERIFIED"
// WalletOutboxArchiveCursor 是 delivered 时间 + 事件 ID 的稳定复合游标。
// delivered 后这两列不再变化,因此翻页不会因新写入而重复或跳行。
type WalletOutboxArchiveCursor struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
EventID string `json:"event_id"`
}
// WalletOutboxArchiveRecord 保留 wallet_outbox 的完整行快照,便于恢复工具按原始事实校验。
type WalletOutboxArchiveRecord struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TransactionID string `json:"transaction_id"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
AssetType string `json:"asset_type"`
AvailableDelta int64 `json:"available_delta"`
FrozenDelta int64 `json:"frozen_delta"`
PayloadJSON string `json:"payload_json"`
Status string `json:"status"`
WorkerID string `json:"worker_id"`
LockUntilMS *int64 `json:"lock_until_ms"`
RetryCount int `json:"retry_count"`
NextRetryAtMS *int64 `json:"next_retry_at_ms"`
LastError *string `json:"last_error"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// WalletOutboxArchiveReceipt 只在 COS data/manifest/_SUCCESS 都读回校验后写入。
// VERIFIED 只代表对象完整性,不代表已完成真实 MySQL 恢复演练。
type WalletOutboxArchiveReceipt struct {
AppCode string
BatchID string
State string
DataObjectKey string
ManifestObjectKey string
SuccessObjectKey string
SchemaVersion int
RowCount int
FirstCursor WalletOutboxArchiveCursor
LastCursor WalletOutboxArchiveCursor
MinCreatedAtMS int64
MaxCreatedAtMS int64
AvailableDeltaTotal string
FrozenDeltaTotal string
NDJSONSHA256 string
GzipSHA256 string
DataCRC64 string
ManifestCRC64 string
SuccessCRC64 string
ManifestSHA256 string
UncompressedBytes int64
CompressedBytes int64
VerifiedAtMS int64
}
// AcquireWalletOutboxArchiveAppLock 用 MySQL connection-scoped advisory lock 保证多副本下每个 App 只有一个归档者。
// release 必须 defer 调用;专用 Conn 在释放前不会回到连接池,避免锁跟随错误会话。
func (r *Repository) AcquireWalletOutboxArchiveAppLock(ctx context.Context) (release func(), acquired bool, err error) {
if r == nil || r.db == nil {
return nil, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
lockName := "wallet_outbox_archive:" + appcode.FromContext(ctx)
conn, err := r.db.Conn(ctx)
if err != nil {
return nil, false, err
}
var result sql.NullInt64
if err := conn.QueryRowContext(ctx, `SELECT GET_LOCK(?, 0)`, lockName).Scan(&result); err != nil {
_ = conn.Close()
return nil, false, err
}
if !result.Valid || result.Int64 != 1 {
_ = conn.Close()
return nil, false, nil
}
release = func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var released sql.NullInt64
// Close 同样会释放 connection-scoped lock即使显式 release 超时也不能把会话留在连接池。
_ = conn.QueryRowContext(releaseCtx, `SELECT RELEASE_LOCK(?)`, lockName).Scan(&released)
_ = conn.Close()
}
return release, true, nil
}
// LatestVerifiedWalletOutboxArchiveCursor 只信任持久化的 VERIFIED 回执,上传中断的对象不会推进游标。
func (r *Repository) LatestVerifiedWalletOutboxArchiveCursor(ctx context.Context) (WalletOutboxArchiveCursor, error) {
if r == nil || r.db == nil {
return WalletOutboxArchiveCursor{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
var cursor WalletOutboxArchiveCursor
err := r.db.QueryRowContext(ctx, `
SELECT last_updated_at_ms, last_event_id
FROM wallet_outbox_archive_receipts FORCE INDEX (idx_wallet_outbox_archive_cursor)
WHERE app_code = ? AND state = ?
ORDER BY last_updated_at_ms DESC, last_event_id DESC
LIMIT 1`, appcode.FromContext(ctx), walletOutboxArchiveStateVerified).Scan(&cursor.UpdatedAtMS, &cursor.EventID)
if err == sql.ErrNoRows {
return WalletOutboxArchiveCursor{}, nil
}
return cursor, err
}
// ListDeliveredWalletOutboxArchiveBatch 只读取一个 App 的旧 delivered 行。
// WHERE 完整命中 (app_code,status,updated_at_ms,event_id) 索引前缀,游标下界和 cutoff 上界把每次 IO 限制在小范围内。
func (r *Repository) ListDeliveredWalletOutboxArchiveBatch(ctx context.Context, cursor WalletOutboxArchiveCursor, cutoffMS int64, limit int) ([]WalletOutboxArchiveRecord, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if cutoffMS <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "archive cutoff_ms must be positive")
}
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, CAST(payload AS CHAR), status, worker_id,
lock_until_ms, retry_count, next_retry_at_ms, last_error, created_at_ms, updated_at_ms
FROM wallet_outbox FORCE INDEX (idx_wallet_outbox_retention)
WHERE app_code = ? AND status = ?
AND updated_at_ms >= ? AND updated_at_ms < ?
AND (updated_at_ms > ? OR (updated_at_ms = ? AND event_id > ?))
ORDER BY updated_at_ms ASC, event_id ASC
LIMIT ?`,
appcode.FromContext(ctx), outboxStatusDelivered,
cursor.UpdatedAtMS, cutoffMS,
cursor.UpdatedAtMS, cursor.UpdatedAtMS, strings.TrimSpace(cursor.EventID), limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
records := make([]WalletOutboxArchiveRecord, 0, limit)
for rows.Next() {
var record WalletOutboxArchiveRecord
var lockUntilMS, nextRetryAtMS sql.NullInt64
var lastError sql.NullString
if err := rows.Scan(
&record.AppCode, &record.EventID, &record.EventType, &record.TransactionID, &record.CommandID,
&record.UserID, &record.AssetType, &record.AvailableDelta, &record.FrozenDelta, &record.PayloadJSON,
&record.Status, &record.WorkerID, &lockUntilMS, &record.RetryCount, &nextRetryAtMS, &lastError,
&record.CreatedAtMS, &record.UpdatedAtMS,
); err != nil {
return nil, err
}
if lockUntilMS.Valid {
value := lockUntilMS.Int64
record.LockUntilMS = &value
}
if nextRetryAtMS.Valid {
value := nextRetryAtMS.Int64
record.NextRetryAtMS = &value
}
if lastError.Valid {
value := lastError.String
record.LastError = &value
}
records = append(records, record)
}
return records, rows.Err()
}
// RecordVerifiedWalletOutboxArchive 是归档链路的最后一步;调用方必须先完成 COS HEAD + GET 读回校验。
func (r *Repository) RecordVerifiedWalletOutboxArchive(ctx context.Context, receipt WalletOutboxArchiveReceipt) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if receipt.State != walletOutboxArchiveStateVerified || receipt.BatchID == "" || receipt.RowCount <= 0 ||
receipt.DataObjectKey == "" || receipt.ManifestObjectKey == "" || receipt.SuccessObjectKey == "" ||
receipt.NDJSONSHA256 == "" || receipt.GzipSHA256 == "" || receipt.ManifestSHA256 == "" ||
receipt.DataCRC64 == "" || receipt.ManifestCRC64 == "" || receipt.SuccessCRC64 == "" {
return xerr.New(xerr.InvalidArgument, "verified archive receipt is incomplete")
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO wallet_outbox_archive_receipts (
app_code, batch_id, state, data_object_key, manifest_object_key, success_object_key,
schema_version, row_count, first_updated_at_ms, first_event_id, last_updated_at_ms, last_event_id,
min_created_at_ms, max_created_at_ms, available_delta_total, frozen_delta_total,
ndjson_sha256, gzip_sha256, data_crc64, manifest_crc64, success_crc64, manifest_sha256, uncompressed_bytes, compressed_bytes,
verified_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
state = VALUES(state), data_object_key = VALUES(data_object_key), manifest_object_key = VALUES(manifest_object_key),
success_object_key = VALUES(success_object_key), verified_at_ms = VALUES(verified_at_ms), updated_at_ms = VALUES(updated_at_ms)`,
receipt.AppCode, receipt.BatchID, receipt.State, receipt.DataObjectKey, receipt.ManifestObjectKey, receipt.SuccessObjectKey,
receipt.SchemaVersion, receipt.RowCount, receipt.FirstCursor.UpdatedAtMS, receipt.FirstCursor.EventID,
receipt.LastCursor.UpdatedAtMS, receipt.LastCursor.EventID, receipt.MinCreatedAtMS, receipt.MaxCreatedAtMS,
receipt.AvailableDeltaTotal, receipt.FrozenDeltaTotal, receipt.NDJSONSHA256, receipt.GzipSHA256,
receipt.DataCRC64, receipt.ManifestCRC64, receipt.SuccessCRC64,
receipt.ManifestSHA256, receipt.UncompressedBytes, receipt.CompressedBytes,
receipt.VerifiedAtMS, receipt.VerifiedAtMS, receipt.VerifiedAtMS,
)
return err
}