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