216 lines
9.1 KiB
Go
216 lines
9.1 KiB
Go
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
|
||
}
|