270 lines
11 KiB
Go
270 lines
11 KiB
Go
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
|
||
}
|