Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 878d5006f0 fix: move withdrawal commission deduction from application to approval
- commissionWithdrawLogic: remove upfront commission deduction;
  balance check now includes sum of all pending withdrawals to prevent
  double-spending; transaction only creates the withdrawal record (status=0)
- approveWithdrawal: add FOR UPDATE lock on user row, balance check before
  deducting, atomic commission decrement and commission log inside one
  transaction; clear user cache after commit
- rejectWithdrawal: remove commission refund and log — commission was never
  deducted on application under the new flow
- add migration 02150: refund commission for existing status=0 withdrawals
  that were deducted under the old logic; includes rollback script

Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 19:38:59 -07:00
9 changed files with 135 additions and 218 deletions
@@ -0,0 +1,19 @@
-- Rollback: re-deduct commission for users with pending (status=0) withdrawals.
-- This re-applies the OLD behaviour where commission is deducted on application.
-- Only run this if you are rolling back to the old code; do NOT run against
-- the new code or commission will be double-deducted on approval.
UPDATE `user` u
JOIN (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM user_withdrawal
WHERE status = 0
GROUP BY user_id
) p ON u.id = p.user_id
SET u.commission = u.commission - p.pending_total
WHERE p.pending_total > 0;
-- Remove the migration log entries written by the up migration.
DELETE FROM system_log
WHERE type = 3
AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%';
@@ -0,0 +1,45 @@
-- Migration: refund commission for existing pending (status=0) withdrawals
--
-- Under the old logic, commission was deducted when a withdrawal was submitted.
-- Under the new logic, commission is only deducted on approval.
-- This migration refunds the deducted amounts back to each user so that
-- the system is in a consistent state before the new code is deployed.
--
-- Idempotency: the UPDATE only touches rows whose commission would need
-- to increase, and each execution produces the same result because
-- COALESCE(SUM(amount),0) is deterministic given the same pending set.
-- Running this script multiple times is safe only if no new pending
-- withdrawals are created between runs; deploy new code immediately after.
-- Step 1: refund commission for all users with pending withdrawals.
UPDATE `user` u
JOIN (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM user_withdrawal
WHERE status = 0
GROUP BY user_id
) p ON u.id = p.user_id
SET u.commission = u.commission + p.pending_total
WHERE p.pending_total > 0;
-- Step 2: write a migration log entry for each refunded user.
INSERT INTO system_log (type, date, object_id, content, created_at)
SELECT
3 AS type,
DATE(NOW()) AS date,
p.user_id AS object_id,
JSON_OBJECT(
'type', 99,
'amount', p.pending_total,
'order_no', '',
'timestamp', UNIX_TIMESTAMP(NOW()) * 1000,
'note', 'migration: refund pending withdrawal commission (HIF-22)'
) AS content,
NOW() AS created_at
FROM (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM user_withdrawal
WHERE status = 0
GROUP BY user_id
HAVING pending_total > 0
) p;
+32 -15
View File
@@ -12,10 +12,12 @@ import (
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64) error { func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64) error {
return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var approvedUserID int64
err := svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID) withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID)
if err != nil { if err != nil {
if err.Error() == "withdrawal status invalid" { if err.Error() == "withdrawal status invalid" {
@@ -24,6 +26,16 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err)
} }
// Lock user row and verify sufficient balance before deducting.
var u usermodel.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", withdrawal.UserId).First(&u).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load user failed: %v", err)
}
if u.Commission < withdrawal.Amount {
return errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "user %d has insufficient commission balance", withdrawal.UserId)
}
if err := tx.Model(&usermodel.Withdrawal{}). if err := tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID). Where("id = ? AND status = 0", withdrawalID).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
@@ -33,17 +45,31 @@ func approveWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdraw
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "approve withdrawal failed: %v", err)
} }
// Deduct commission atomically inside the transaction.
if err := tx.Model(&usermodel.User{}).
Where("id = ?", withdrawal.UserId).
UpdateColumn("commission", gorm.Expr("commission - ?", withdrawal.Amount)).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "deduct commission failed: %v", err)
}
if err := logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdraw, withdrawal.Amount, ""); err != nil { if err := logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdraw, withdrawal.Amount, ""); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err)
} }
approvedUserID = withdrawal.UserId
return nil return nil
}) })
if err == nil && approvedUserID > 0 {
_ = svcCtx.UserModel.ClearUserCache(ctx, &usermodel.User{Id: approvedUserID})
}
return err
} }
func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64, reason string) error { func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawalID int64, reason string) error {
reason = strings.TrimSpace(reason) reason = strings.TrimSpace(reason)
return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return svcCtx.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
withdrawal, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID) _, err := logicCommon.LoadPendingWithdrawalForUpdate(ctx, tx, withdrawalID)
if err != nil { if err != nil {
if err.Error() == "withdrawal status invalid" { if err.Error() == "withdrawal status invalid" {
return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d already processed", withdrawalID) return errors.Wrapf(xerr.NewErrCode(xerr.WithdrawalStatusInvalid), "withdrawal %d already processed", withdrawalID)
@@ -51,23 +77,14 @@ func rejectWithdrawal(ctx context.Context, svcCtx *svc.ServiceContext, withdrawa
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "load withdrawal failed: %v", err)
} }
if err := tx.Model(&usermodel.Withdrawal{}). // Commission was NOT deducted at application time under the new logic,
// so rejection requires no refund — only a status update.
return tx.Model(&usermodel.Withdrawal{}).
Where("id = ? AND status = 0", withdrawalID). Where("id = ? AND status = 0", withdrawalID).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"status": 2, "status": 2,
"reason": reason, "reason": reason,
}).Error; err != nil { }).Error
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "reject withdrawal failed: %v", err)
}
if err := svcCtx.UserModel.UpdateCommission(ctx, withdrawal.UserId, withdrawal.Amount, tx); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", err)
}
if err := logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawReject, withdrawal.Amount, ""); err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", err)
}
return nil
}) })
} }
@@ -4,8 +4,6 @@ import (
"context" "context"
"time" "time"
logicCommon "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
@@ -38,48 +36,39 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
} }
if u.Commission < req.Amount { // Sum all pending (status=0) withdrawals to compute available balance.
logger.Errorf("User %d has insufficient commission balance: %.2f, requested: %.2f", u.Id, float64(u.Commission)/100, float64(req.Amount)/100) // Available = commission - pendingTotal; commission is only deducted on approval.
var pendingTotal int64
if err = l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Withdrawal{}).
Where("user_id = ? AND status = 0", u.Id).
Select("COALESCE(SUM(amount), 0)").
Scan(&pendingTotal).Error; err != nil {
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", u.Id)
}
if u.Commission < req.Amount+pendingTotal {
logger.Errorf("User %d insufficient available commission: total=%d pending=%d requested=%d",
u.Id, u.Commission, pendingTotal, req.Amount)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id) return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id)
} }
tx := l.svcCtx.DB.WithContext(l.ctx).Begin()
now := time.Now() now := time.Now()
var w user.Withdrawal
// Atomically deduct the requested amount so concurrent commission growth is preserved. err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
if err = l.svcCtx.DB.WithContext(l.ctx). w = user.Withdrawal{
Model(&user.User{}).
Where("id = ? AND commission >= ?", u.Id, req.Amount).
UpdateColumn("commission", gorm.Expr("commission - ?", req.Amount)).Error; err != nil {
tx.Rollback()
l.Errorf("Failed to update user %d commission balance: %v", u.Id, err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Failed to update user %d commission balance: %v", u.Id, err)
}
_ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u)
// create withdrawal log
if err = logicCommon.WriteCommissionLog(tx, u.Id, log.CommissionTypeConvertBalance, req.Amount, ""); err != nil {
tx.Rollback()
l.Errorf("Failed to create commission log for user %d: %v", u.Id, err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create commission log for user %d: %v", u.Id, err)
}
err = tx.Model(&user.Withdrawal{}).Create(&user.Withdrawal{
UserId: u.Id, UserId: u.Id,
Amount: req.Amount, Amount: req.Amount,
Content: req.Content, Content: req.Content,
Status: 0, Status: 0,
Reason: "", Reason: "",
}).Error
if err != nil {
tx.Rollback()
l.Errorf("Failed to create withdrawal log for user %d: %v", u.Id, err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal log for user %d: %v", u.Id, err)
} }
if err = tx.Commit().Error; err != nil { return tx.Create(&w).Error
l.Errorf("Transaction commit failed for user %d withdrawal: %v", u.Id, err) })
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Transaction commit failed for user %d withdrawal: %v", u.Id, err) if err != nil {
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err)
} }
return &types.WithdrawalLog{ return &types.WithdrawalLog{
-3
View File
@@ -48,7 +48,4 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
// Apple IAP 对账(第二层:5min 扫描 + 第三层:日终全量) // Apple IAP 对账(第二层:5min 扫描 + 第三层:日终全量)
mux.Handle(types.SchedulerIAPReconcile, iapLogic.NewReconcileLogic(serverCtx)) mux.Handle(types.SchedulerIAPReconcile, iapLogic.NewReconcileLogic(serverCtx))
mux.Handle(types.SchedulerIAPDailyReconcile, iapLogic.NewDailyReconcileLogic(serverCtx)) mux.Handle(types.SchedulerIAPDailyReconcile, iapLogic.NewDailyReconcileLogic(serverCtx))
// Stuck order recovery
mux.Handle(types.SchedulerStuckOrderRecovery, orderLogic.NewStuckOrderRecoveryLogic(serverCtx))
} }
+9 -48
View File
@@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/perfect-panel/server/internal/logic/admin/group" "github.com/perfect-panel/server/internal/logic/admin/group"
@@ -47,7 +46,7 @@ const (
OrderStatusPaid = 2 // Order paid and ready for processing OrderStatusPaid = 2 // Order paid and ready for processing
OrderStatusClose = 3 // Order closed/cancelled OrderStatusClose = 3 // Order closed/cancelled
OrderStatusFailed = 4 // Order processing failed OrderStatusFailed = 4 // Order processing failed
OrderStatusClaimed = 6 // Internal transient claim while a worker processes the order OrderStatusClaimed = 4 // Internal transient claim while a worker processes the order
OrderStatusFinished = 5 // Order successfully completed OrderStatusFinished = 5 // Order successfully completed
) )
@@ -93,12 +92,8 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
orderInfo, err := l.claimAndGetOrder(ctx, payload.OrderNo) orderInfo, err := l.claimAndGetOrder(ctx, payload.OrderNo)
if err != nil { if err != nil {
// 如果订单不存在或状态不对,不重试
if errors.Is(err, ErrInvalidOrderStatus) { if errors.Is(err, ErrInvalidOrderStatus) {
if strings.Contains(err.Error(), "stuck in claimed") {
logger.WithContext(ctx).Error("[ActivateOrderLogic] 订单卡在 claimed,将重试",
logger.Field("order_no", payload.OrderNo))
return err // 返回错误触发 asynq 重试
}
logger.WithContext(ctx).Info("[ActivateOrderLogic] 订单状态不是已支付,跳过", logger.WithContext(ctx).Info("[ActivateOrderLogic] 订单状态不是已支付,跳过",
logger.Field("order_no", payload.OrderNo)) logger.Field("order_no", payload.OrderNo))
return nil return nil
@@ -121,12 +116,7 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
) )
if err = l.processOrderByType(ctx, orderInfo, payload.IAPExpireAt); err != nil { if err = l.processOrderByType(ctx, orderInfo, payload.IAPExpireAt); err != nil {
if releaseErr := l.releaseClaim(ctx, orderInfo.OrderNo); releaseErr != nil { l.releaseClaim(ctx, orderInfo.OrderNo)
logger.WithContext(ctx).Error("[ActivateOrderLogic] releaseClaim also failed, stuck recovery will handle",
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("release_error", releaseErr.Error()),
)
}
logger.WithContext(ctx).Error("[ActivateOrderLogic] 处理订单失败,将重试", logger.WithContext(ctx).Error("[ActivateOrderLogic] 处理订单失败,将重试",
logger.Field("order_no", orderInfo.OrderNo), logger.Field("order_no", orderInfo.OrderNo),
logger.Field("order_type", orderInfo.Type), logger.Field("order_type", orderInfo.Type),
@@ -135,12 +125,7 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
} }
if err = l.reconcilePostOrderSubscriptions(ctx, orderInfo); err != nil { if err = l.reconcilePostOrderSubscriptions(ctx, orderInfo); err != nil {
if releaseErr := l.releaseClaim(ctx, orderInfo.OrderNo); releaseErr != nil { l.releaseClaim(ctx, orderInfo.OrderNo)
logger.WithContext(ctx).Error("[ActivateOrderLogic] releaseClaim also failed, stuck recovery will handle",
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("release_error", releaseErr.Error()),
)
}
logger.WithContext(ctx).Error("[ActivateOrderLogic] 订单订阅兜底合并失败,将重试", logger.WithContext(ctx).Error("[ActivateOrderLogic] 订单订阅兜底合并失败,将重试",
logger.Field("order_no", orderInfo.OrderNo), logger.Field("order_no", orderInfo.OrderNo),
logger.Field("order_type", orderInfo.Type), logger.Field("order_type", orderInfo.Type),
@@ -191,15 +176,6 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin
return nil, nil return nil, nil
} }
// Detect stuck claimed order — return retryable error so asynq re-tries
if orderInfo.Status == OrderStatusClaimed {
logger.WithContext(ctx).Error("Order stuck in claimed status",
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("status", orderInfo.Status),
)
return nil, fmt.Errorf("order %s stuck in claimed status: %w", orderNo, ErrInvalidOrderStatus)
}
if orderInfo.Status != OrderStatusPaid { if orderInfo.Status != OrderStatusPaid {
logger.WithContext(ctx).Error("Order status error", logger.WithContext(ctx).Error("Order status error",
logger.Field("order_no", orderInfo.OrderNo), logger.Field("order_no", orderInfo.OrderNo),
@@ -225,7 +201,7 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin
return &orderInfo, nil return &orderInfo, nil
} }
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error { func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) {
if err := l.svc.DB.WithContext(ctx). if err := l.svc.DB.WithContext(ctx).
Model(&order.Order{}). Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed). Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
@@ -234,9 +210,7 @@ func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) e
logger.Field("error", err.Error()), logger.Field("error", err.Error()),
logger.Field("order_no", orderNo), logger.Field("order_no", orderNo),
) )
return fmt.Errorf("release claim failed for order %s: %w", orderNo, err)
} }
return nil
} }
// processOrderByType routes order processing based on the order type // processOrderByType routes order processing based on the order type
@@ -589,27 +563,14 @@ func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderIn
} }
} }
// UpdateOrderStatus uses WHERE status < target, which blocks claimed(6)→finished(5). // Update order status using state-guarded UpdateOrderStatus to prevent double finalization
// Use a direct update matching the exact claimed status, then update the full record if err := l.svc.OrderModel.UpdateOrderStatus(ctx, orderInfo.OrderNo, OrderStatusFinished); err != nil {
// via the model layer to properly invalidate the cache. logger.WithContext(ctx).Error("Update order status failed",
result := l.svc.DB.WithContext(ctx).
Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderInfo.OrderNo, OrderStatusClaimed).
Update("status", OrderStatusFinished)
if result.Error != nil {
logger.WithContext(ctx).Error("Update order status from claimed to finished failed",
logger.Field("error", result.Error.Error()),
logger.Field("order_no", orderInfo.OrderNo),
)
}
// Invalidate order cache regardless of whether the DB update succeeded
orderInfo.Status = OrderStatusFinished
if err := l.svc.OrderModel.Update(ctx, orderInfo); err != nil {
logger.WithContext(ctx).Error("Update order cache after finalization failed",
logger.Field("error", err.Error()), logger.Field("error", err.Error()),
logger.Field("order_no", orderInfo.OrderNo), logger.Field("order_no", orderInfo.OrderNo),
) )
} }
orderInfo.Status = OrderStatusFinished
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "order_status_finished", commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "order_status_finished",
"[SubscriptionFlow] order status updated to finished", "[SubscriptionFlow] order status updated to finished",
commonLogic.OrderTraceFields(orderInfo)..., commonLogic.OrderTraceFields(orderInfo)...,
@@ -1,104 +0,0 @@
package orderLogic
import (
"context"
"encoding/json"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
queueTypes "github.com/perfect-panel/server/queue/types"
)
// StuckOrderRecoveryLogic scans orders stuck in claimed status and re-queues them for processing.
type StuckOrderRecoveryLogic struct {
svc *svc.ServiceContext
}
func NewStuckOrderRecoveryLogic(svc *svc.ServiceContext) *StuckOrderRecoveryLogic {
return &StuckOrderRecoveryLogic{svc: svc}
}
// ProcessTask scans for orders stuck in claimed status for over 10 minutes,
// resets them to paid, and re-enqueues the activate task so they are retried
// independently of asynq's original retry counter (which may be exhausted).
func (l *StuckOrderRecoveryLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error {
cutoff := time.Now().Add(-10 * time.Minute)
var stuckOrders []order.Order
if err := l.svc.DB.WithContext(ctx).
Model(&order.Order{}).
Where("status = ? AND updated_at < ?", OrderStatusClaimed, cutoff).
Find(&stuckOrders).Error; err != nil {
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to query stuck orders",
logger.Field("error", err.Error()),
)
return err
}
if len(stuckOrders) == 0 {
return nil
}
orderNos := make([]string, 0, len(stuckOrders))
for i := range stuckOrders {
orderNos = append(orderNos, stuckOrders[i].OrderNo)
}
logger.WithContext(ctx).Error("[StuckOrderRecovery] Found stuck claimed orders, recovering",
logger.Field("count", len(stuckOrders)),
logger.Field("order_nos", orderNos),
)
for i := range stuckOrders {
o := &stuckOrders[i]
result := l.svc.DB.WithContext(ctx).
Model(&order.Order{}).
Where("order_no = ? AND status = ?", o.OrderNo, OrderStatusClaimed).
Update("status", OrderStatusPaid)
if result.Error != nil {
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to reset order status",
logger.Field("order_no", o.OrderNo),
logger.Field("error", result.Error.Error()),
)
continue
}
if result.RowsAffected == 0 {
// Another process already handled this order
continue
}
// Invalidate order cache
o.Status = OrderStatusPaid
if err := l.svc.OrderModel.Update(ctx, o); err != nil {
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to update order cache",
logger.Field("order_no", o.OrderNo),
logger.Field("error", err.Error()),
)
}
// Re-enqueue activate task so the order gets processed regardless of asynq retry state
payload, err := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: o.OrderNo})
if err != nil {
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to marshal task payload",
logger.Field("order_no", o.OrderNo),
logger.Field("error", err.Error()),
)
continue
}
if _, err = l.svc.Queue.EnqueueContext(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
logger.WithContext(ctx).Error("[StuckOrderRecovery] Failed to re-enqueue activate task",
logger.Field("order_no", o.OrderNo),
logger.Field("error", err.Error()),
)
} else {
logger.WithContext(ctx).Info("[StuckOrderRecovery] Re-enqueued activate task",
logger.Field("order_no", o.OrderNo),
)
}
}
return nil
}
-1
View File
@@ -7,5 +7,4 @@ const (
SchedulerTrafficStat = "scheduler:traffic:stat" SchedulerTrafficStat = "scheduler:traffic:stat"
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单 SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账 SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
) )
-6
View File
@@ -64,12 +64,6 @@ func (m *Service) Start() {
logger.Errorf("register iap daily reconcile task failed: %s", err.Error()) logger.Errorf("register iap daily reconcile task failed: %s", err.Error())
} }
// schedule stuck order recovery: every 10 minutes
stuckOrderTask := asynq.NewTask(types.SchedulerStuckOrderRecovery, nil)
if _, err := m.server.Register("@every 10m", stuckOrderTask, asynq.MaxRetry(1)); err != nil {
logger.Errorf("register stuck order recovery task failed: %s", err.Error())
}
if err := m.server.Run(); err != nil { if err := m.server.Run(); err != nil {
logger.Errorf("run scheduler failed: %s", err.Error()) logger.Errorf("run scheduler failed: %s", err.Error())
} }