From a0f2d8a7b85bfe559d0327e2e811a719c72d2143 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Mon, 25 May 2026 00:13:59 -0700 Subject: [PATCH] fix: persist guest/redemption activation context to DB to survive Redis TTL expiry (Bug 1 + Bug 10) - Add `activation_context` TEXT column to `order` table (migration 02150) - purchaseLogic: write TemporaryOrderInfo JSON to order.ActivationContext in the same insert transaction; Redis write is now best-effort (non-fatal) - redeemCodeLogic: write redemption {type, redemption_code_id, unit_time, quantity} JSON to order.ActivationContext at order creation; Redis write is now best-effort (non-fatal) - getTempOrderInfo: on Redis miss, fall back to order.ActivationContext from DB; logs CRITICAL and returns error if both are missing (old orders with no DB record) - RedemptionActivate: on Redis miss, fall back to order.ActivationContext from DB; same CRITICAL log path for legacy orders Co-authored-by: multica-agent --- .../02150_order_activation_context.down.sql | 2 + .../02150_order_activation_context.up.sql | 6 + internal/logic/public/portal/purchaseLogic.go | 11 +- .../public/redemption/redeemCodeLogic.go | 64 ++++++----- internal/model/order/order.go | 5 +- queue/logic/order/activateOrderLogic.go | 103 +++++++++++++----- 6 files changed, 130 insertions(+), 61 deletions(-) create mode 100644 initialize/migrate/database/02150_order_activation_context.down.sql create mode 100644 initialize/migrate/database/02150_order_activation_context.up.sql diff --git a/initialize/migrate/database/02150_order_activation_context.down.sql b/initialize/migrate/database/02150_order_activation_context.down.sql new file mode 100644 index 0000000..8c88c95 --- /dev/null +++ b/initialize/migrate/database/02150_order_activation_context.down.sql @@ -0,0 +1,2 @@ +-- Remove activation_context column from order table +ALTER TABLE `order` DROP COLUMN IF EXISTS `activation_context`; diff --git a/initialize/migrate/database/02150_order_activation_context.up.sql b/initialize/migrate/database/02150_order_activation_context.up.sql new file mode 100644 index 0000000..bbe7bb1 --- /dev/null +++ b/initialize/migrate/database/02150_order_activation_context.up.sql @@ -0,0 +1,6 @@ +-- Add activation_context column to order table for Redis fallback persistence (idempotent) +SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'activation_context'); +SET @sql = IF(@col_exists = 0, 'ALTER TABLE `order` ADD COLUMN `activation_context` TEXT DEFAULT NULL COMMENT ''Activation context JSON (guest/redemption info for DB fallback)'' AFTER `app_account_token`', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/internal/logic/public/portal/purchaseLogic.go b/internal/logic/public/portal/purchaseLogic.go index 3431599..a901ff1 100644 --- a/internal/logic/public/portal/purchaseLogic.go +++ b/internal/logic/public/portal/purchaseLogic.go @@ -154,9 +154,12 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types. } content, _ := tempOrder.Marshal() - if _, err = l.svcCtx.Redis.Set(l.ctx, fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo), string(content), CloseOrderTimeMinutes*time.Minute).Result(); err != nil { - l.Errorw("[Purchase] Redis set error", logger.Field("error", err.Error()), logger.Field("order_no", orderInfo.OrderNo)) - return err + // Persist activation context to DB so the worker can recover if Redis TTL expires. + orderInfo.ActivationContext = string(content) + + // Write to Redis as a hot cache (best-effort; non-fatal on failure). + if _, redisErr := l.svcCtx.Redis.Set(l.ctx, fmt.Sprintf(constant.TempOrderCacheKey, orderInfo.OrderNo), string(content), CloseOrderTimeMinutes*time.Minute).Result(); redisErr != nil { + l.Infow("[Purchase] Redis set error (non-fatal, DB fallback available)", logger.Field("error", redisErr.Error()), logger.Field("order_no", orderInfo.OrderNo)) } l.Infow("[Purchase] Guest order", logger.Field("order_no", orderInfo.OrderNo), logger.Field("identifier", req.Identifier)) @@ -169,7 +172,7 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types. } } - // save guest order + // save guest order (activation_context is included) if err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo, tx); err != nil { return err } diff --git a/internal/logic/public/redemption/redeemCodeLogic.go b/internal/logic/public/redemption/redeemCodeLogic.go index fb3cc2f..f722447 100644 --- a/internal/logic/public/redemption/redeemCodeLogic.go +++ b/internal/logic/public/redemption/redeemCodeLogic.go @@ -151,34 +151,48 @@ func (l *RedeemCodeLogic) RedeemCode(req *types.RedeemCodeRequest) (resp *types. } // 创建Order记录 + redemptionContext := struct { + Type string `json:"type"` + RedemptionCodeId int64 `json:"redemption_code_id"` + UnitTime string `json:"unit_time"` + Quantity int64 `json:"quantity"` + }{ + Type: "redemption", + RedemptionCodeId: redemptionCode.Id, + UnitTime: redemptionCode.UnitTime, + Quantity: redemptionCode.Quantity, + } + activationContextJSON, _ := json.Marshal(redemptionContext) + orderInfo := &order.Order{ - UserId: u.Id, - OrderNo: tool.GenerateTradeNo(), - Type: 5, // 兑换类型 - Quantity: redemptionCode.Quantity, - Price: 0, // 兑换无价格 - Amount: 0, // 兑换无金额 - Discount: 0, - GiftAmount: 0, - Coupon: "", - CouponDiscount: 0, - PaymentId: 0, - Method: "redemption", - FeeAmount: 0, - Commission: 0, - Status: 2, // 直接设置为已支付 - SubscribeId: redemptionCode.SubscribePlan, - IsNew: isNew, + UserId: u.Id, + OrderNo: tool.GenerateTradeNo(), + Type: 5, // 兑换类型 + Quantity: redemptionCode.Quantity, + Price: 0, // 兑换无价格 + Amount: 0, // 兑换无金额 + Discount: 0, + GiftAmount: 0, + Coupon: "", + CouponDiscount: 0, + PaymentId: 0, + Method: "redemption", + FeeAmount: 0, + Commission: 0, + Status: 2, // 直接设置为已支付 + SubscribeId: redemptionCode.SubscribePlan, + IsNew: isNew, + ActivationContext: string(activationContextJSON), } - // 保存Order到数据库 + // 保存Order到数据库(activation_context 同步写入,作为 Redis 的持久化兜底) err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo) if err != nil { l.Errorw("[RedeemCode] Create order failed", logger.Field("error", err.Error())) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create order failed") } - // 缓存兑换码信息到Redis(供队列任务使用) + // 缓存兑换码信息到Redis(热缓存,供队列任务快速读取,非关键路径) cacheKey := fmt.Sprintf("redemption_order:%s", orderInfo.OrderNo) cacheData := map[string]interface{}{ "redemption_code_id": redemptionCode.Id, @@ -186,16 +200,8 @@ func (l *RedeemCodeLogic) RedeemCode(req *types.RedeemCodeRequest) (resp *types. "quantity": redemptionCode.Quantity, } jsonData, _ := json.Marshal(cacheData) - err = l.svcCtx.Redis.Set(l.ctx, cacheKey, jsonData, 2*time.Hour).Err() - if err != nil { - l.Errorw("[RedeemCode] Cache redemption data failed", logger.Field("error", err.Error())) - // 缓存失败,删除已创建的Order避免孤儿记录 - if delErr := l.svcCtx.OrderModel.Delete(l.ctx, orderInfo.Id); delErr != nil { - l.Errorw("[RedeemCode] Delete order failed after cache error", - logger.Field("order_id", orderInfo.Id), - logger.Field("error", delErr.Error())) - } - return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "cache redemption data failed") + if redisErr := l.svcCtx.Redis.Set(l.ctx, cacheKey, jsonData, 2*time.Hour).Err(); redisErr != nil { + l.Infow("[RedeemCode] Cache redemption data failed (non-fatal, DB fallback available)", logger.Field("error", redisErr.Error())) } // 触发队列任务 diff --git a/internal/model/order/order.go b/internal/model/order/order.go index 758cf1e..cd90153 100644 --- a/internal/model/order/order.go +++ b/internal/model/order/order.go @@ -24,8 +24,9 @@ type Order struct { Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` - AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` - IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` + AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` + ActivationContext string `gorm:"type:text;default:null;comment:Activation context JSON (guest/redemption info for DB fallback)"` + IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 84ec550..20b4a40 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -863,26 +863,52 @@ func (l *ActivateOrderLogic) createGuestUser(ctx context.Context, orderInfo *ord return userInfo, nil } -// getTempOrderInfo retrieves temporary order information from Redis cache +// getTempOrderInfo retrieves temporary order information from Redis cache with DB fallback. func (l *ActivateOrderLogic) getTempOrderInfo(ctx context.Context, orderNo string) (*constant.TemporaryOrderInfo, error) { cacheKey := fmt.Sprintf(constant.TempOrderCacheKey, orderNo) data, err := l.svc.Redis.Get(ctx, cacheKey).Result() - if err != nil { - logger.WithContext(ctx).Error("Get temp order cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), + if err == nil { + var tempOrder constant.TemporaryOrderInfo + if unmarshalErr := tempOrder.Unmarshal([]byte(data)); unmarshalErr != nil { + logger.WithContext(ctx).Error("Unmarshal temp order cache failed", + logger.Field("error", unmarshalErr.Error()), + logger.Field("cache_key", cacheKey), + logger.Field("data", data), + ) + return nil, unmarshalErr + } + return &tempOrder, nil + } + + // Redis miss — fall back to DB activation_context field. + logger.WithContext(ctx).Infow("Redis cache miss for temp order, falling back to DB", + logger.Field("cache_key", cacheKey), + logger.Field("error", err.Error()), + ) + + orderInfo, dbErr := l.svc.OrderModel.FindOneByOrderNo(ctx, orderNo) + if dbErr != nil { + logger.WithContext(ctx).Error("DB fallback for temp order failed", + logger.Field("order_no", orderNo), + logger.Field("error", dbErr.Error()), ) - return nil, err + return nil, dbErr + } + + if orderInfo.ActivationContext == "" { + logger.WithContext(ctx).Error("CRITICAL: activation_context missing in DB and Redis expired; cannot recover guest order", + logger.Field("order_no", orderNo), + ) + return nil, errors.New("activation context not found: Redis expired and no DB fallback available") } var tempOrder constant.TemporaryOrderInfo - if err = tempOrder.Unmarshal([]byte(data)); err != nil { - logger.WithContext(ctx).Error("Unmarshal temp order cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - logger.Field("data", data), + if unmarshalErr := tempOrder.Unmarshal([]byte(orderInfo.ActivationContext)); unmarshalErr != nil { + logger.WithContext(ctx).Error("Unmarshal DB activation_context failed", + logger.Field("order_no", orderNo), + logger.Field("error", unmarshalErr.Error()), ) - return nil, err + return nil, unmarshalErr } return &tempOrder, nil @@ -1782,25 +1808,50 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo * return err } - // 3. 从Redis获取兑换码信息 - cacheKey := fmt.Sprintf("redemption_order:%s", orderInfo.OrderNo) - data, err := l.svc.Redis.Get(ctx, cacheKey).Result() - if err != nil { - logger.WithContext(ctx).Error("Get redemption cache failed", - logger.Field("error", err.Error()), - logger.Field("cache_key", cacheKey), - ) - return err - } - + // 3. 从Redis获取兑换码信息,Redis缺失时回查DB var redemptionData struct { RedemptionCodeId int64 `json:"redemption_code_id"` UnitTime string `json:"unit_time"` Quantity int64 `json:"quantity"` } - if err = json.Unmarshal([]byte(data), &redemptionData); err != nil { - logger.WithContext(ctx).Error("Unmarshal redemption cache failed", logger.Field("error", err.Error())) - return err + + cacheKey := fmt.Sprintf("redemption_order:%s", orderInfo.OrderNo) + data, redisErr := l.svc.Redis.Get(ctx, cacheKey).Result() + if redisErr == nil { + if err = json.Unmarshal([]byte(data), &redemptionData); err != nil { + logger.WithContext(ctx).Error("Unmarshal redemption cache failed", logger.Field("error", err.Error())) + return err + } + } else { + // Redis miss — fall back to DB activation_context field. + logger.WithContext(ctx).Infow("Redis cache miss for redemption order, falling back to DB", + logger.Field("cache_key", cacheKey), + logger.Field("error", redisErr.Error()), + ) + + if orderInfo.ActivationContext == "" { + logger.WithContext(ctx).Error("CRITICAL: activation_context missing in DB and Redis expired; cannot recover redemption order", + logger.Field("order_no", orderInfo.OrderNo), + ) + return errors.New("activation context not found: Redis expired and no DB fallback available") + } + + var fullContext struct { + Type string `json:"type"` + RedemptionCodeId int64 `json:"redemption_code_id"` + UnitTime string `json:"unit_time"` + Quantity int64 `json:"quantity"` + } + if err = json.Unmarshal([]byte(orderInfo.ActivationContext), &fullContext); err != nil { + logger.WithContext(ctx).Error("Unmarshal DB activation_context failed", + logger.Field("order_no", orderInfo.OrderNo), + logger.Field("error", err.Error()), + ) + return err + } + redemptionData.RedemptionCodeId = fullContext.RedemptionCodeId + redemptionData.UnitTime = fullContext.UnitTime + redemptionData.Quantity = fullContext.Quantity } // 4. 幂等性检查:查询是否已有兑换记录