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 }