Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 294bdf4577 fix: 修复订单状态机 claim 机制的三个 Bug 并增加 stuck 订单恢复
Build docker and publish / build (20.15.1) (pull_request) Failing after 8m41s
- 将 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>
2026-05-24 23:42:41 -07:00
9 changed files with 208 additions and 94 deletions
+14 -14
View File
@@ -38,20 +38,20 @@ type (
Id int64 `form:"id" validate:"required"`
}
UpdateUserBasiceInfoRequest {
UserId int64 `json:"user_id" validate:"required"`
Password string `json:"password"`
Avatar string `json:"avatar"`
Balance *int64 `json:"balance"`
Commission *int64 `json:"commission"`
ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase *bool `json:"only_first_purchase"`
GiftAmount *int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"`
RefererId *int64 `json:"referer_id"`
Enable *bool `json:"enable"`
IsAdmin *bool `json:"is_admin"`
Remark *string `json:"remark"`
UserId int64 `json:"user_id" validate:"required"`
Password string `json:"password"`
Avatar string `json:"avatar"`
Balance int64 `json:"balance"`
Commission int64 `json:"commission"`
ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase *bool `json:"only_first_purchase"`
GiftAmount int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"`
RefererId int64 `json:"referer_id"`
Enable *bool `json:"enable"`
IsAdmin *bool `json:"is_admin"`
Remark string `json:"remark"`
}
UpdateUserNotifySettingRequest {
UserId int64 `json:"user_id" validate:"required"`
@@ -46,13 +46,13 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
}
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
if req.Balance != nil && userInfo.Balance != *req.Balance {
change := *req.Balance - userInfo.Balance
if userInfo.Balance != req.Balance {
change := req.Balance - userInfo.Balance
balanceLog := log.Balance{
Type: log.BalanceTypeAdjust,
Amount: change,
OrderNo: "",
Balance: *req.Balance,
Balance: req.Balance,
Timestamp: time.Now().UnixMilli(),
}
content, _ := balanceLog.Marshal()
@@ -66,14 +66,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if err != nil {
return err
}
userInfo.Balance = *req.Balance
userInfo.Balance = req.Balance
}
if req.GiftAmount != nil && userInfo.GiftAmount != *req.GiftAmount {
change := *req.GiftAmount - userInfo.GiftAmount
if userInfo.GiftAmount != req.GiftAmount {
change := req.GiftAmount - userInfo.GiftAmount
if change != 0 {
var changeType uint16
if userInfo.GiftAmount < *req.GiftAmount {
if userInfo.GiftAmount < req.GiftAmount {
changeType = log.GiftTypeIncrease
} else {
changeType = log.GiftTypeReduce
@@ -81,7 +81,7 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
giftLog := log.Gift{
Type: changeType,
Amount: change,
Balance: *req.GiftAmount,
Balance: req.GiftAmount,
Remark: "Admin adjustment",
Timestamp: time.Now().UnixMilli(),
}
@@ -96,27 +96,23 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if err != nil {
return err
}
userInfo.GiftAmount = *req.GiftAmount
userInfo.GiftAmount = req.GiftAmount
}
}
if req.Commission != nil && *req.Commission != userInfo.Commission {
remark := ""
if req.Remark != nil {
remark = *req.Remark
}
if isWithdrawalScene(remark) {
if req.Commission != userInfo.Commission {
if isWithdrawalScene(req.Remark) {
logWithdrawalGuard(l.Logger, userInfo.Id)
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "commission overwrite is blocked in withdrawal scene")
}
change := *req.Commission - userInfo.Commission
change := req.Commission - userInfo.Commission
if err = l.svcCtx.UserModel.UpdateCommission(l.ctx, userInfo.Id, change, tx); err != nil {
return err
}
if err = logicCommon.WriteCommissionLog(tx, userInfo.Id, log.CommissionTypeAdjust, change, ""); err != nil {
return err
}
userInfo.Commission = *req.Commission
userInfo.Commission = req.Commission
}
if req.Avatar != "" {
userInfo.Avatar = req.Avatar
@@ -124,17 +120,15 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if req.ReferCode != "" {
userInfo.ReferCode = req.ReferCode
}
if req.RefererId != nil {
userInfo.RefererId = *req.RefererId
}
userInfo.RefererId = req.RefererId
if req.Enable != nil {
userInfo.Enable = req.Enable
}
if req.IsAdmin != nil {
userInfo.IsAdmin = req.IsAdmin
}
if req.Remark != nil {
userInfo.Remark = *req.Remark
if req.Remark != "" {
userInfo.Remark = req.Remark
}
if req.OnlyFirstPurchase != nil {
userInfo.OnlyFirstPurchase = req.OnlyFirstPurchase
+14 -14
View File
@@ -3223,20 +3223,20 @@ type UpdateUserAuthMethodRequest struct {
}
type UpdateUserBasiceInfoRequest struct {
UserId int64 `json:"user_id" validate:"required"`
Password string `json:"password"`
Avatar string `json:"avatar"`
Balance *int64 `json:"balance"`
Commission *int64 `json:"commission"`
ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase *bool `json:"only_first_purchase"`
GiftAmount *int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"`
RefererId *int64 `json:"referer_id"`
Enable *bool `json:"enable"`
IsAdmin *bool `json:"is_admin"`
Remark *string `json:"remark"`
UserId int64 `json:"user_id" validate:"required"`
Password string `json:"password"`
Avatar string `json:"avatar"`
Balance int64 `json:"balance"`
Commission int64 `json:"commission"`
ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase *bool `json:"only_first_purchase"`
GiftAmount int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"`
RefererId int64 `json:"referer_id"`
Enable *bool `json:"enable"`
IsAdmin *bool `json:"is_admin"`
Remark string `json:"remark"`
}
type UpdateUserNotifyRequest struct {
-33
View File
@@ -1,33 +0,0 @@
package types
import (
"encoding/json"
"testing"
)
func TestUpdateUserBasiceInfoRequestDistinguishesOmittedAndEmptyRemark(t *testing.T) {
var omitted UpdateUserBasiceInfoRequest
if err := json.Unmarshal([]byte(`{"user_id":1001}`), &omitted); err != nil {
t.Fatalf("unmarshal omitted remark: %v", err)
}
if omitted.Remark != nil {
t.Fatalf("omitted remark should stay nil, got %q", *omitted.Remark)
}
if omitted.RefererId != nil {
t.Fatalf("omitted referer_id should stay nil, got %d", *omitted.RefererId)
}
if omitted.Balance != nil || omitted.GiftAmount != nil || omitted.Commission != nil {
t.Fatalf("omitted money fields should stay nil, got balance=%v gift=%v commission=%v", omitted.Balance, omitted.GiftAmount, omitted.Commission)
}
var cleared UpdateUserBasiceInfoRequest
if err := json.Unmarshal([]byte(`{"user_id":1001,"remark":""}`), &cleared); err != nil {
t.Fatalf("unmarshal empty remark: %v", err)
}
if cleared.Remark == nil {
t.Fatal("explicit empty remark should be present")
}
if *cleared.Remark != "" {
t.Fatalf("explicit empty remark should decode to empty string, got %q", *cleared.Remark)
}
}
+3
View File
@@ -48,4 +48,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
// Apple IAP 对账(第二层:5min 扫描 + 第三层:日终全量)
mux.Handle(types.SchedulerIAPReconcile, iapLogic.NewReconcileLogic(serverCtx))
mux.Handle(types.SchedulerIAPDailyReconcile, iapLogic.NewDailyReconcileLogic(serverCtx))
// Stuck order recovery
mux.Handle(types.SchedulerStuckOrderRecovery, orderLogic.NewStuckOrderRecoveryLogic(serverCtx))
}
+48 -9
View File
@@ -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)...,
@@ -0,0 +1,104 @@
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
}
+3 -2
View File
@@ -5,6 +5,7 @@ const (
SchedulerTotalServerData = "scheduler:total:server"
SchedulerResetTraffic = "scheduler:reset:traffic"
SchedulerTrafficStat = "scheduler:traffic:stat"
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
)
+6
View File
@@ -64,6 +64,12 @@ func (m *Service) Start() {
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 {
logger.Errorf("run scheduler failed: %s", err.Error())
}