fix: 修复订单状态机 claim 机制的三个 Bug 并增加 stuck 订单恢复
- 将 OrderStatusClaimed 从 4 改为 6,消除与 OrderStatusFailed 的值冲突 - finalizeCouponAndOrder 改用直接 DB 更新(WHERE status=6→SET status=5), 绕过 UpdateOrderStatus 的 status<target 守卫,同时用 model.Update 刷新缓存 - releaseClaim 返回 error,调用处检查并记录日志;releaseClaim 失败由 stuck recovery 定时任务兜底 - claimAndGetOrder 对 status=claimed 返回可重试错误而非静默跳过; ProcessTask 区分 "stuck in claimed" 与 "非 paid 跳过" 两种场景 - 新增 StuckOrderRecoveryLogic:每 10 分钟扫描超时 claimed 订单, 重置 status=paid 并重新入队 ForthwithActivateOrder,确保不依赖 asynq 原始重试(可能已超 maxRetry) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/admin/group"
|
||||
@@ -46,7 +47,7 @@ const (
|
||||
OrderStatusPaid = 2 // Order paid and ready for processing
|
||||
OrderStatusClose = 3 // Order closed/cancelled
|
||||
OrderStatusFailed = 4 // Order processing failed
|
||||
OrderStatusClaimed = 4 // Internal transient claim while a worker processes the order
|
||||
OrderStatusClaimed = 6 // Internal transient claim while a worker processes the order
|
||||
OrderStatusFinished = 5 // Order successfully completed
|
||||
)
|
||||
|
||||
@@ -92,8 +93,12 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
||||
|
||||
orderInfo, err := l.claimAndGetOrder(ctx, payload.OrderNo)
|
||||
if err != nil {
|
||||
// 如果订单不存在或状态不对,不重试
|
||||
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.Field("order_no", payload.OrderNo))
|
||||
return nil
|
||||
@@ -116,7 +121,12 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
||||
)
|
||||
|
||||
if err = l.processOrderByType(ctx, orderInfo, payload.IAPExpireAt); err != nil {
|
||||
l.releaseClaim(ctx, orderInfo.OrderNo)
|
||||
if releaseErr := l.releaseClaim(ctx, orderInfo.OrderNo); releaseErr != nil {
|
||||
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.Field("order_no", orderInfo.OrderNo),
|
||||
logger.Field("order_type", orderInfo.Type),
|
||||
@@ -125,7 +135,12 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
||||
}
|
||||
|
||||
if err = l.reconcilePostOrderSubscriptions(ctx, orderInfo); err != nil {
|
||||
l.releaseClaim(ctx, orderInfo.OrderNo)
|
||||
if releaseErr := l.releaseClaim(ctx, orderInfo.OrderNo); releaseErr != nil {
|
||||
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.Field("order_no", orderInfo.OrderNo),
|
||||
logger.Field("order_type", orderInfo.Type),
|
||||
@@ -176,6 +191,15 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin
|
||||
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 {
|
||||
logger.WithContext(ctx).Error("Order status error",
|
||||
logger.Field("order_no", orderInfo.OrderNo),
|
||||
@@ -201,7 +225,7 @@ func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo strin
|
||||
return &orderInfo, nil
|
||||
}
|
||||
|
||||
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) {
|
||||
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) error {
|
||||
if err := l.svc.DB.WithContext(ctx).
|
||||
Model(&order.Order{}).
|
||||
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
|
||||
@@ -210,7 +234,9 @@ func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) {
|
||||
logger.Field("error", err.Error()),
|
||||
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
|
||||
@@ -563,14 +589,27 @@ func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderIn
|
||||
}
|
||||
}
|
||||
|
||||
// Update order status using state-guarded UpdateOrderStatus to prevent double finalization
|
||||
if err := l.svc.OrderModel.UpdateOrderStatus(ctx, orderInfo.OrderNo, OrderStatusFinished); err != nil {
|
||||
logger.WithContext(ctx).Error("Update order status failed",
|
||||
// UpdateOrderStatus uses WHERE status < target, which blocks claimed(6)→finished(5).
|
||||
// Use a direct update matching the exact claimed status, then update the full record
|
||||
// via the model layer to properly invalidate the cache.
|
||||
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("order_no", orderInfo.OrderNo),
|
||||
)
|
||||
}
|
||||
orderInfo.Status = OrderStatusFinished
|
||||
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "order_status_finished",
|
||||
"[SubscriptionFlow] order status updated to finished",
|
||||
commonLogic.OrderTraceFields(orderInfo)...,
|
||||
|
||||
Reference in New Issue
Block a user