Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28ad97def4 |
+5
-5
@@ -41,17 +41,17 @@ type (
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Password string `json:"password"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balance *int64 `json:"balance"`
|
||||
Commission *int64 `json:"commission"`
|
||||
Balance int64 `json:"balance"`
|
||||
Commission int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount *int64 `json:"gift_amount"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId *int64 `json:"referer_id"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark *string `json:"remark"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
UpdateUserNotifySettingRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -151,6 +151,19 @@ 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(),
|
||||
@@ -169,16 +182,17 @@ func (l *RedeemCodeLogic) RedeemCode(req *types.RedeemCodeRequest) (resp *types.
|
||||
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()))
|
||||
}
|
||||
|
||||
// 触发队列任务
|
||||
|
||||
@@ -25,6 +25,7 @@ type Order struct {
|
||||
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)"`
|
||||
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"`
|
||||
|
||||
@@ -3226,17 +3226,17 @@ 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"`
|
||||
Balance int64 `json:"balance"`
|
||||
Commission int64 `json:"commission"`
|
||||
ReferralPercentage uint8 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase *bool `json:"only_first_purchase"`
|
||||
GiftAmount *int64 `json:"gift_amount"`
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId *int64 `json:"referer_id"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable *bool `json:"enable"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Remark *string `json:"remark"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateUserNotifyRequest struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -824,26 +824,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),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
var tempOrder constant.TemporaryOrderInfo
|
||||
if err = tempOrder.Unmarshal([]byte(data)); err != nil {
|
||||
if unmarshalErr := tempOrder.Unmarshal([]byte(data)); unmarshalErr != nil {
|
||||
logger.WithContext(ctx).Error("Unmarshal temp order cache failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("error", unmarshalErr.Error()),
|
||||
logger.Field("cache_key", cacheKey),
|
||||
logger.Field("data", data),
|
||||
)
|
||||
return nil, err
|
||||
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, 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 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, unmarshalErr
|
||||
}
|
||||
|
||||
return &tempOrder, nil
|
||||
@@ -1743,26 +1769,51 @@ 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"`
|
||||
}
|
||||
|
||||
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. 幂等性检查:查询是否已有兑换记录
|
||||
existingRecords, err := l.svc.RedemptionRecordModel.FindByUserId(ctx, userInfo.Id)
|
||||
|
||||
Reference in New Issue
Block a user