2026-07-12 00:47:20 +08:00

270 lines
11 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"
"errors"
"fmt"
"strings"
mysqlerr "github.com/go-sql-driver/mysql"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
// EnsureGiftOperation 在第一次可能调用钱包前固化恢复输入;重复 command_id 只返回原记录,不能覆盖首次载荷。
func (r *Repository) EnsureGiftOperation(ctx context.Context, operation roomservice.GiftOperation) (roomservice.GiftOperation, bool, error) {
operation.AppCode = normalizedRecordAppCode(ctx, operation.AppCode)
if operation.Status == "" {
operation.Status = roomservice.GiftOperationStatusPending
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO room_gift_operations (
app_code, room_id, command_id, actor_user_id, command_payload, request_payload, status,
billing_payload, lucky_payload, result_payload, last_error, retry_count, next_retry_at_ms,
worker_id, lock_until_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, '', NULL, ?, ?)
`, operation.AppCode, operation.RoomID, operation.CommandID, operation.ActorUserID,
operation.CommandPayload, operation.RequestPayload, operation.Status,
nullableBytes(operation.BillingPayload), nullableBytes(operation.LuckyPayload), nullableBytes(operation.ResultPayload),
nullableString(operation.LastError), operation.NextRetryAtMS, operation.CreatedAtMS, operation.UpdatedAtMS)
if err == nil {
return operation, true, nil
}
var duplicate *mysqlerr.MySQLError
if !errors.As(err, &duplicate) || duplicate.Number != 1062 {
return roomservice.GiftOperation{}, false, err
}
existing, exists, err := r.GetGiftOperation(ctx, operation.RoomID, operation.CommandID)
if err != nil || !exists {
return roomservice.GiftOperation{}, false, err
}
// A duplicate command may carry a different gift/recipient/count. Do not reactivate compensated
// work here: the service must first compare normalized idempotency payloads, otherwise a rejected
// conflicting request could make the old stored request claimable and debit it in the background.
return existing, false, nil
}
// ReactivateCompensatedGiftOperation reopens a confirmed-identical request. The status predicate is
// the concurrency boundary: if another caller already moved it forward, return the authoritative row
// and let the shared command_id idempotency path converge instead of overwriting its progress.
func (r *Repository) ReactivateCompensatedGiftOperation(ctx context.Context, roomID string, commandID string, updatedAtMS int64) (roomservice.GiftOperation, bool, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE room_gift_operations
SET status = ?, last_error = NULL, retry_count = 0, next_retry_at_ms = 0,
worker_id = '', lock_until_ms = NULL, updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND command_id = ? AND status = ?
`, roomservice.GiftOperationStatusPending, updatedAtMS, appcode.FromContext(ctx), roomID, commandID,
roomservice.GiftOperationStatusCompensated)
if err != nil {
return roomservice.GiftOperation{}, false, err
}
affected, err := result.RowsAffected()
if err != nil {
return roomservice.GiftOperation{}, false, err
}
operation, exists, err := r.GetGiftOperation(ctx, roomID, commandID)
if err != nil {
return roomservice.GiftOperation{}, false, err
}
if !exists {
return roomservice.GiftOperation{}, false, fmt.Errorf("gift operation disappeared during reactivation: room_id=%s command_id=%s", roomID, commandID)
}
return operation, affected == 1, nil
}
func (r *Repository) GetGiftOperation(ctx context.Context, roomID string, commandID string) (roomservice.GiftOperation, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, room_id, command_id, actor_user_id, command_payload, request_payload, status,
billing_payload, lucky_payload, result_payload, COALESCE(last_error, ''), retry_count,
next_retry_at_ms, worker_id, lock_until_ms, created_at_ms, updated_at_ms
FROM room_gift_operations
WHERE app_code = ? AND room_id = ? AND command_id = ?
LIMIT 1
`, appcode.FromContext(ctx), strings.TrimSpace(roomID), strings.TrimSpace(commandID))
operation, err := scanGiftOperation(row)
if errors.Is(err, sql.ErrNoRows) {
return roomservice.GiftOperation{}, false, nil
}
if err != nil {
return roomservice.GiftOperation{}, false, err
}
return operation, true, nil
}
// SaveGiftOperationProgress 只允许 saga 向前推进;恢复重放 wallet 时写 debited 不能把已 drawn 的操作降级。
func (r *Repository) SaveGiftOperationProgress(ctx context.Context, progress roomservice.GiftOperationProgress) error {
statusRank, ok := giftOperationStatusRank(progress.Status)
if !ok || statusRank < giftOperationStatusRankMust(roomservice.GiftOperationStatusDebited) || statusRank > giftOperationStatusRankMust(roomservice.GiftOperationStatusDrawn) {
return fmt.Errorf("invalid gift operation progress status %q", progress.Status)
}
_, err := r.db.ExecContext(ctx, `
UPDATE room_gift_operations
SET status = CASE
WHEN status = ? THEN status
WHEN status = ? THEN status
WHEN status = ? AND ? = ? THEN status
ELSE ?
END,
billing_payload = COALESCE(?, billing_payload),
lucky_payload = COALESCE(?, lucky_payload),
last_error = NULL, next_retry_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND command_id = ?
AND status IN (?, ?, ?)
`, roomservice.GiftOperationStatusCommitted, roomservice.GiftOperationStatusCompensated,
roomservice.GiftOperationStatusDrawn, progress.Status, roomservice.GiftOperationStatusDebited, progress.Status,
nullableBytes(progress.BillingPayload), nullableBytes(progress.LuckyPayload), progress.NextRetryAtMS, progress.UpdatedAtMS,
appcode.FromContext(ctx), progress.RoomID, progress.CommandID,
roomservice.GiftOperationStatusPending, roomservice.GiftOperationStatusDebited, roomservice.GiftOperationStatusDrawn)
if err != nil {
return err
}
return nil
}
// ClaimRecoverableGiftOperations 用短租约分摊恢复任务command_id 幂等是最终防线,租约只避免多实例重复施压。
func (r *Repository) ClaimRecoverableGiftOperations(ctx context.Context, workerID string, limit int, nowMS int64, lockUntilMS int64) ([]roomservice.GiftOperation, error) {
if limit <= 0 {
return nil, nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
rows, err := tx.QueryContext(ctx, `
SELECT app_code, room_id, command_id, actor_user_id, command_payload, request_payload, status,
billing_payload, lucky_payload, result_payload, COALESCE(last_error, ''), retry_count,
next_retry_at_ms, worker_id, lock_until_ms, created_at_ms, updated_at_ms
FROM room_gift_operations
WHERE status IN (?, ?, ?)
AND next_retry_at_ms <= ?
AND (lock_until_ms IS NULL OR lock_until_ms <= ?)
ORDER BY updated_at_ms ASC, command_id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED
`, roomservice.GiftOperationStatusPending, roomservice.GiftOperationStatusDebited, roomservice.GiftOperationStatusDrawn, nowMS, nowMS, limit)
if err != nil {
_ = tx.Rollback()
return nil, err
}
operations := make([]roomservice.GiftOperation, 0, limit)
for rows.Next() {
operation, err := scanGiftOperation(rows)
if err != nil {
_ = rows.Close()
_ = tx.Rollback()
return nil, err
}
operations = append(operations, operation)
}
if err := rows.Close(); err != nil {
_ = tx.Rollback()
return nil, err
}
for index := range operations {
operation := &operations[index]
if _, err := tx.ExecContext(ctx, `
UPDATE room_gift_operations
SET worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND command_id = ?
`, workerID, lockUntilMS, nowMS, operation.AppCode, operation.RoomID, operation.CommandID); err != nil {
_ = tx.Rollback()
return nil, err
}
operation.WorkerID = workerID
operation.LockUntilMS = lockUntilMS
}
if err := tx.Commit(); err != nil {
return nil, err
}
return operations, nil
}
func (r *Repository) MarkGiftOperationRetry(ctx context.Context, roomID string, commandID string, lastError string, nextRetryAtMS int64, updatedAtMS int64, preserveLease bool) error {
_, err := r.db.ExecContext(ctx, `
UPDATE room_gift_operations
SET retry_count = retry_count + 1, last_error = ?, next_retry_at_ms = ?,
worker_id = CASE WHEN ? THEN worker_id ELSE '' END,
lock_until_ms = CASE WHEN ? THEN lock_until_ms ELSE NULL END,
updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND command_id = ? AND status IN (?, ?, ?)
`, nullableString(strings.TrimSpace(lastError)), nextRetryAtMS, preserveLease, preserveLease, updatedAtMS, appcode.FromContext(ctx), roomID, commandID,
roomservice.GiftOperationStatusPending, roomservice.GiftOperationStatusDebited, roomservice.GiftOperationStatusDrawn)
return err
}
func (r *Repository) MarkGiftOperationCompensated(ctx context.Context, roomID string, commandID string, lastError string, updatedAtMS int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE room_gift_operations
SET status = ?, last_error = ?, next_retry_at_ms = 0, worker_id = '', lock_until_ms = NULL, updated_at_ms = ?
WHERE app_code = ? AND room_id = ? AND command_id = ? AND status = ?
`, roomservice.GiftOperationStatusCompensated, nullableString(strings.TrimSpace(lastError)), updatedAtMS,
appcode.FromContext(ctx), roomID, commandID, roomservice.GiftOperationStatusPending)
return err
}
func (r *Repository) HasRecoverableGiftOperations(ctx context.Context, roomID string) (bool, error) {
var exists int
err := r.db.QueryRowContext(ctx, `
SELECT 1
FROM room_gift_operations
WHERE app_code = ? AND room_id = ? AND status IN (?, ?, ?)
LIMIT 1
`, appcode.FromContext(ctx), strings.TrimSpace(roomID), roomservice.GiftOperationStatusPending,
roomservice.GiftOperationStatusDebited, roomservice.GiftOperationStatusDrawn).Scan(&exists)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
type giftOperationScanner interface {
Scan(dest ...any) error
}
func scanGiftOperation(scanner giftOperationScanner) (roomservice.GiftOperation, error) {
var operation roomservice.GiftOperation
var billingPayload, luckyPayload, resultPayload []byte
var lockUntil sql.Null[int64]
err := scanner.Scan(&operation.AppCode, &operation.RoomID, &operation.CommandID, &operation.ActorUserID,
&operation.CommandPayload, &operation.RequestPayload, &operation.Status,
&billingPayload, &luckyPayload, &resultPayload, &operation.LastError, &operation.RetryCount,
&operation.NextRetryAtMS, &operation.WorkerID, &lockUntil, &operation.CreatedAtMS, &operation.UpdatedAtMS)
if err != nil {
return roomservice.GiftOperation{}, err
}
operation.BillingPayload = billingPayload
operation.LuckyPayload = luckyPayload
operation.ResultPayload = resultPayload
if lockUntil.Valid {
operation.LockUntilMS = lockUntil.V
}
return operation, nil
}
func giftOperationStatusRank(status string) (int, bool) {
switch status {
case roomservice.GiftOperationStatusPending:
return 1, true
case roomservice.GiftOperationStatusDebited:
return 2, true
case roomservice.GiftOperationStatusDrawn:
return 3, true
case roomservice.GiftOperationStatusCommitted:
return 4, true
case roomservice.GiftOperationStatusCompensated:
return 5, true
default:
return 0, false
}
}
func giftOperationStatusRankMust(status string) int {
rank, _ := giftOperationStatusRank(status)
return rank
}