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 <github@multica.ai>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- Remove activation_context column from order table
|
||||||
|
ALTER TABLE `order` DROP COLUMN IF EXISTS `activation_context`;
|
||||||
@@ -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;
|
||||||
@@ -154,9 +154,12 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types.
|
|||||||
}
|
}
|
||||||
content, _ := tempOrder.Marshal()
|
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 {
|
// Persist activation context to DB so the worker can recover if Redis TTL expires.
|
||||||
l.Errorw("[Purchase] Redis set error", logger.Field("error", err.Error()), logger.Field("order_no", orderInfo.OrderNo))
|
orderInfo.ActivationContext = string(content)
|
||||||
return err
|
|
||||||
|
// 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))
|
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 {
|
if err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,34 +151,48 @@ func (l *RedeemCodeLogic) RedeemCode(req *types.RedeemCodeRequest) (resp *types.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建Order记录
|
// 创建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{
|
orderInfo := &order.Order{
|
||||||
UserId: u.Id,
|
UserId: u.Id,
|
||||||
OrderNo: tool.GenerateTradeNo(),
|
OrderNo: tool.GenerateTradeNo(),
|
||||||
Type: 5, // 兑换类型
|
Type: 5, // 兑换类型
|
||||||
Quantity: redemptionCode.Quantity,
|
Quantity: redemptionCode.Quantity,
|
||||||
Price: 0, // 兑换无价格
|
Price: 0, // 兑换无价格
|
||||||
Amount: 0, // 兑换无金额
|
Amount: 0, // 兑换无金额
|
||||||
Discount: 0,
|
Discount: 0,
|
||||||
GiftAmount: 0,
|
GiftAmount: 0,
|
||||||
Coupon: "",
|
Coupon: "",
|
||||||
CouponDiscount: 0,
|
CouponDiscount: 0,
|
||||||
PaymentId: 0,
|
PaymentId: 0,
|
||||||
Method: "redemption",
|
Method: "redemption",
|
||||||
FeeAmount: 0,
|
FeeAmount: 0,
|
||||||
Commission: 0,
|
Commission: 0,
|
||||||
Status: 2, // 直接设置为已支付
|
Status: 2, // 直接设置为已支付
|
||||||
SubscribeId: redemptionCode.SubscribePlan,
|
SubscribeId: redemptionCode.SubscribePlan,
|
||||||
IsNew: isNew,
|
IsNew: isNew,
|
||||||
|
ActivationContext: string(activationContextJSON),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存Order到数据库
|
// 保存Order到数据库(activation_context 同步写入,作为 Redis 的持久化兜底)
|
||||||
err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo)
|
err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[RedeemCode] Create order failed", logger.Field("error", err.Error()))
|
l.Errorw("[RedeemCode] Create order failed", logger.Field("error", err.Error()))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create order failed")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create order failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存兑换码信息到Redis(供队列任务使用)
|
// 缓存兑换码信息到Redis(热缓存,供队列任务快速读取,非关键路径)
|
||||||
cacheKey := fmt.Sprintf("redemption_order:%s", orderInfo.OrderNo)
|
cacheKey := fmt.Sprintf("redemption_order:%s", orderInfo.OrderNo)
|
||||||
cacheData := map[string]interface{}{
|
cacheData := map[string]interface{}{
|
||||||
"redemption_code_id": redemptionCode.Id,
|
"redemption_code_id": redemptionCode.Id,
|
||||||
@@ -186,16 +200,8 @@ func (l *RedeemCodeLogic) RedeemCode(req *types.RedeemCodeRequest) (resp *types.
|
|||||||
"quantity": redemptionCode.Quantity,
|
"quantity": redemptionCode.Quantity,
|
||||||
}
|
}
|
||||||
jsonData, _ := json.Marshal(cacheData)
|
jsonData, _ := json.Marshal(cacheData)
|
||||||
err = l.svcCtx.Redis.Set(l.ctx, cacheKey, jsonData, 2*time.Hour).Err()
|
if redisErr := l.svcCtx.Redis.Set(l.ctx, cacheKey, jsonData, 2*time.Hour).Err(); redisErr != nil {
|
||||||
if err != nil {
|
l.Infow("[RedeemCode] Cache redemption data failed (non-fatal, DB fallback available)", logger.Field("error", redisErr.Error()))
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发队列任务
|
// 触发队列任务
|
||||||
|
|||||||
@@ -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;"`
|
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"`
|
SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"`
|
||||||
SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"`
|
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)"`
|
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"`
|
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"`
|
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -863,26 +863,52 @@ func (l *ActivateOrderLogic) createGuestUser(ctx context.Context, orderInfo *ord
|
|||||||
return userInfo, nil
|
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) {
|
func (l *ActivateOrderLogic) getTempOrderInfo(ctx context.Context, orderNo string) (*constant.TemporaryOrderInfo, error) {
|
||||||
cacheKey := fmt.Sprintf(constant.TempOrderCacheKey, orderNo)
|
cacheKey := fmt.Sprintf(constant.TempOrderCacheKey, orderNo)
|
||||||
data, err := l.svc.Redis.Get(ctx, cacheKey).Result()
|
data, err := l.svc.Redis.Get(ctx, cacheKey).Result()
|
||||||
if err != nil {
|
if err == nil {
|
||||||
logger.WithContext(ctx).Error("Get temp order cache failed",
|
var tempOrder constant.TemporaryOrderInfo
|
||||||
logger.Field("error", err.Error()),
|
if unmarshalErr := tempOrder.Unmarshal([]byte(data)); unmarshalErr != nil {
|
||||||
logger.Field("cache_key", cacheKey),
|
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
|
var tempOrder constant.TemporaryOrderInfo
|
||||||
if err = tempOrder.Unmarshal([]byte(data)); err != nil {
|
if unmarshalErr := tempOrder.Unmarshal([]byte(orderInfo.ActivationContext)); unmarshalErr != nil {
|
||||||
logger.WithContext(ctx).Error("Unmarshal temp order cache failed",
|
logger.WithContext(ctx).Error("Unmarshal DB activation_context failed",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("order_no", orderNo),
|
||||||
logger.Field("cache_key", cacheKey),
|
logger.Field("error", unmarshalErr.Error()),
|
||||||
logger.Field("data", data),
|
|
||||||
)
|
)
|
||||||
return nil, err
|
return nil, unmarshalErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return &tempOrder, nil
|
return &tempOrder, nil
|
||||||
@@ -1782,25 +1808,50 @@ func (l *ActivateOrderLogic) RedemptionActivate(ctx context.Context, orderInfo *
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 从Redis获取兑换码信息
|
// 3. 从Redis获取兑换码信息,Redis缺失时回查DB
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var redemptionData struct {
|
var redemptionData struct {
|
||||||
RedemptionCodeId int64 `json:"redemption_code_id"`
|
RedemptionCodeId int64 `json:"redemption_code_id"`
|
||||||
UnitTime string `json:"unit_time"`
|
UnitTime string `json:"unit_time"`
|
||||||
Quantity int64 `json:"quantity"`
|
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()))
|
cacheKey := fmt.Sprintf("redemption_order:%s", orderInfo.OrderNo)
|
||||||
return err
|
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. 幂等性检查:查询是否已有兑换记录
|
// 4. 幂等性检查:查询是否已有兑换记录
|
||||||
|
|||||||
Reference in New Issue
Block a user