Compare commits

..

4 Commits

Author SHA1 Message Date
shanshanzhong147 e28a69c08a Merge branch 'agent/agent/0e0c2bfa' into fix/combined-hif-28-30-27 2026-05-25 00:58:27 -07:00
shanshanzhong147 b66e488143 Merge branch 'agent/agent/068c82b4' into fix/combined-hif-28-30-27 2026-05-25 00:58:21 -07:00
shanshanzhong147 05ee5b9d2e fix: P1 activation path hardening - Bug 4-9
Bug 4: resolveRenewalActivationSubscription - add fallback by user_id+subscribe_id
  with SELECT FOR UPDATE when token lookup fails

Bug 5: appleIAPNotifyLogic - return error on product ID mapping failure instead of
  silently dropping the notification

Bug 6: NewPurchase fallback query - wrap in transaction with SELECT FOR UPDATE to
  prevent concurrent duplicate subscription creation

Bug 7: appleIAPNotifyLogic - fix UserId=0 by reverse-lookup from original purchase
  order; create renewal audit order record for DID_RENEW/SUBSCRIBED notifications

Bug 8: UpdateOrderStatus - pre-delete cache before DB write (double-delete) to
  close TOCTOU window between DB update and cache invalidation

Bug 9: validateNewUserOnlyEligibilityAtActivation - add Redis distributed lock on
  user_id to serialise concurrent new-user-only order activations

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 00:35:54 -07:00
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
8 changed files with 317 additions and 21 deletions
+86 -1
View File
@@ -3,17 +3,20 @@ package notify
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"gorm.io/gorm"
)
@@ -84,6 +87,7 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
l.Errorw("iap notify insert transaction error", logger.Field("error", e.Error()), logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
return e
}
existing = rec
} else {
if txPayload.RevocationDate != nil {
// 撤销场景:更新 revocation_at
@@ -96,6 +100,32 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
}
}
}
// Fix Bug 7: resolve userId when the IAP transaction record has no associated user.
// This can happen if the first notification for a subscription arrived before the
// in-app purchase flow created the order (race) or the order was made in a previous
// build that did not write user_id to the IAP transaction table.
if existing != nil && existing.UserId == 0 {
var origOrder order.Order
if lookupErr := db.Model(&order.Order{}).
Where("trade_no = ? AND method = ?", txPayload.OriginalTransactionId, "apple_iap").
Order("id ASC").
First(&origOrder).Error; lookupErr == nil && origOrder.UserId > 0 {
existing.UserId = origOrder.UserId
_ = db.Model(&iapmodel.Transaction{}).
Where("id = ?", existing.Id).
Update("user_id", origOrder.UserId).Error
l.Infow("iap notify resolved zero userId from original purchase order",
logger.Field("userId", origOrder.UserId),
logger.Field("originalTransactionId", txPayload.OriginalTransactionId),
)
} else {
l.Errorw("CRITICAL: iap notify UserId=0 and cannot resolve from order, notification dropped",
logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
return fmt.Errorf("iap notify: UserId=0 and cannot resolve from order for original_transaction_id=%s", txPayload.OriginalTransactionId)
}
}
var days int64
{
pid := strings.ToLower(txPayload.ProductId)
@@ -169,7 +199,12 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
}
}
if days == 0 {
l.Errorw("iap notify product mapping missing", logger.Field("productId", txPayload.ProductId))
// Both string-parse and DB fallback failed to map the product to days.
// Return an error so Apple retries the notification; silently ignoring
// this would cause the subscriber's renewal to be lost.
l.Errorw("CRITICAL: iap notify product mapping missing, returning error to trigger retry",
logger.Field("productId", txPayload.ProductId))
return fmt.Errorf("iap product id %s could not be mapped to subscription days", txPayload.ProductId)
}
token := "iap:" + txPayload.OriginalTransactionId
sub, e := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
@@ -216,6 +251,11 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
logger.Field("product_id", txPayload.ProductId),
)...,
)
// Create audit order record for renewal notifications (Bug 7)
if err := l.createIAPRenewalAuditOrder(db, ntype, txPayload.TransactionId, candidate.UserId, candidate.SubscribeId, candidate.Token); err != nil {
l.Errorw("iap notify fallback create renewal order error", logger.Field("error", err.Error()))
// Non-fatal: subscription already updated; order creation failure is logged only
}
break
}
}
@@ -248,7 +288,52 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
logger.Field("product_id", txPayload.ProductId),
)...,
)
// Create audit order record for renewal notifications (Bug 7)
if err := l.createIAPRenewalAuditOrder(db, ntype, txPayload.TransactionId, sub.UserId, sub.SubscribeId, sub.Token); err != nil {
l.Errorw("iap notify create renewal order error", logger.Field("error", err.Error()))
// Non-fatal: subscription already updated; order creation failure is logged only
}
}
return nil
})
}
// createIAPRenewalAuditOrder creates a finished renewal order record for DID_RENEW and SUBSCRIBED
// Apple SSNS notifications so that the order table has a complete financial audit trail.
// The operation is idempotent: if an order with the same trade_no already exists it is skipped.
func (l *AppleIAPNotifyLogic) createIAPRenewalAuditOrder(db *gorm.DB, ntype, transactionId string, userId, subscribeId int64, subscribeToken string) error {
if ntype != "DID_RENEW" && ntype != "SUBSCRIBED" {
return nil
}
// Idempotency check
var count int64
if err := db.Model(&order.Order{}).
Where("trade_no = ? AND method = ?", transactionId, "apple_iap").
Count(&count).Error; err != nil {
return fmt.Errorf("check existing iap renewal order: %w", err)
}
if count > 0 {
return nil
}
rec := &order.Order{
UserId: userId,
OrderNo: tool.GenerateTradeNo(),
Type: 2, // OrderTypeRenewal
Status: 5, // OrderStatusFinished
Method: "apple_iap",
TradeNo: transactionId,
SubscribeId: subscribeId,
SubscribeToken: subscribeToken,
Quantity: 1,
IsNew: false,
}
if err := db.Model(&order.Order{}).Create(rec).Error; err != nil {
return fmt.Errorf("create iap renewal audit order: %w", err)
}
l.Infow("iap notify created renewal audit order",
logger.Field("orderNo", rec.OrderNo),
logger.Field("transactionId", transactionId),
logger.Field("userId", userId),
)
return nil
}
+5 -1
View File
@@ -110,6 +110,10 @@ func (m *customOrderModel) UpdateOrderStatus(ctx context.Context, orderNo string
if err != nil {
return err
}
keys := m.getCacheKeys(orderInfo)
// Pre-delete: evict cache before the DB write so concurrent reads during the update
// window go to DB instead of getting a stale cached status (double-delete pattern).
_ = m.DelCacheCtx(ctx, keys...)
return m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
@@ -123,7 +127,7 @@ func (m *customOrderModel) UpdateOrderStatus(ctx context.Context, orderNo string
return nil
}
return nil
}, m.getCacheKeys(orderInfo)...)
}, keys...)
}
// FindOneDetailsByOrderNo Find order details by order number
+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))
}
+94 -17
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)...,
@@ -597,7 +636,7 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
return err
}
if err = validateNewUserOnlyEligibilityAtActivation(ctx, l.svc.DB, orderInfo, sub); err != nil {
if err = validateNewUserOnlyEligibilityAtActivation(ctx, l.svc.DB, l.svc.Redis, orderInfo, sub); err != nil {
return err
}
@@ -675,18 +714,23 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
// 兜底:创建新订阅前,查找用户是否已有同套餐的订阅记录(含过期/赠送),
// 有则复用旧记录续期,避免出现重复订阅。
// 需要同时检查 UserId 和 SubscriptionUserId,因为家庭组绑定前后 owner 可能不同。
// 使用 SELECT ... FOR UPDATE 防止并发下多个 worker 同时选中同一订阅并创建重复记录。
if userSub == nil {
candidateUserIds := []int64{orderInfo.UserId}
if orderInfo.SubscriptionUserId > 0 && orderInfo.SubscriptionUserId != orderInfo.UserId {
candidateUserIds = append(candidateUserIds, orderInfo.SubscriptionUserId)
}
var existingSub user.Subscribe
if findErr := l.svc.DB.Model(&user.Subscribe{}).
Where("user_id IN ? AND token != ''", candidateUserIds).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&existingSub).Error; findErr == nil {
findErr := l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&user.Subscribe{}).
Where("user_id IN ? AND token != ''", candidateUserIds).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&existingSub).Error
})
if findErr == nil {
// 家庭组场景:订阅 owner 可能变更(如成员注册的试用 → 被家主收归),
// 续期前把 user_id 校正为当前订单的 SubscriptionUserId
effectiveOwner := orderInfo.UserId
@@ -1473,7 +1517,40 @@ func (l *ActivateOrderLogic) resolveRenewalActivationSubscription(ctx context.Co
}
userSub, err := l.getUserSubscription(ctx, orderInfo.SubscribeToken)
if err != nil {
return nil, err
// Fallback: token may have been lost (subscription deleted or owner changed).
// Locate the most recent subscription by user_id + subscribe_id with FOR UPDATE
// to prevent a concurrent worker from renewing the same record twice.
targetUserID := orderInfo.UserId
if orderInfo.SubscriptionUserId > 0 {
targetUserID = orderInfo.SubscriptionUserId
}
var fallbackSub user.Subscribe
txErr := l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Model(&user.Subscribe{}).
Where("user_id = ? AND subscribe_id = ?", targetUserID, orderInfo.SubscribeId).
Where("status IN ?", []int64{0, 1, 2, 3}).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&fallbackSub).Error
})
if txErr != nil {
logger.WithContext(ctx).Error("CRITICAL: Renewal activation token and fallback lookup both failed",
logger.Field("token_error", err.Error()),
logger.Field("fallback_error", txErr.Error()),
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("target_user_id", targetUserID),
logger.Field("subscribe_id", orderInfo.SubscribeId),
)
return nil, fmt.Errorf("renewal activation subscription not found by token or user_id+subscribe_id for order %s: %w", orderInfo.OrderNo, err)
}
logger.WithContext(ctx).Info("Renewal token lookup failed; found subscription via fallback user_id+subscribe_id",
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("fallback_subscribe_id", fallbackSub.Id),
logger.Field("target_user_id", targetUserID),
)
userSub = &fallbackSub
}
if orderInfo.UserId <= 0 {
return userSub, nil
+16
View File
@@ -10,12 +10,14 @@ import (
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
internaltypes "github.com/perfect-panel/server/internal/types"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
func validateNewUserOnlyEligibilityAtActivation(
ctx context.Context,
db *gorm.DB,
rdb *redis.Client,
orderInfo *order.Order,
sub *subscribe.Subscribe,
) error {
@@ -31,6 +33,20 @@ func validateNewUserOnlyEligibilityAtActivation(
return nil
}
// Acquire a per-user distributed lock so concurrent new-user-only activations
// for the same account are serialised. Without this, two workers can both read
// historyCount=0 and both pass the check before either has written the order.
lockKey := fmt.Sprintf("new_user_only_activate:%d", orderInfo.UserId)
const lockTTL = 30 * time.Second
acquired, lockErr := rdb.SetNX(ctx, lockKey, orderInfo.OrderNo, lockTTL).Result()
if lockErr != nil {
return fmt.Errorf("new user only: acquire lock error: %w", lockErr)
}
if !acquired {
return fmt.Errorf("new user only: another activation is in progress for user %d", orderInfo.UserId)
}
defer rdb.Del(ctx, lockKey)
eligibility, err := commonLogic.ResolveNewUserEligibility(ctx, db, orderInfo.UserId)
if err != nil {
return err
@@ -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())
}