修复(#128): 按规则类型决定促销资格,让 InactiveUser/Campaign 对老用户生效
此前 calculatePurchasePrice 把 allowPromo 绑死在 orderType == 1,导致只要用户
有任何过往付费订阅(含已过期),就会被 paidSubscriptionQuery 路由为续费
(orderType=2),跳过所有促销评估。后果:
- InactiveUser 召回促销永远无法触发(其目标人群恰好就是有过期订阅的用户)
- Campaign 全员活动对老用户 / 升级加购场景完全失效
- 套餐列表(loadSubscribePromoMap)直接调 EvaluatePromo 不感知 orderType,
可能显示促销价但下单时却拿到原价
修复方式:把 isFirstPurchase 下放给 EvaluatePromo,由规则类型决定 gating:
- NewUser 要求 isFirstPurchase=true(保留首购语义)
- InactiveUser 由规则自身的"上次订阅过期 N 月以上"条件判定
- Campaign 时间窗内对任意用户生效
新增 common.HasPaidSubscription 助手,套餐列表与下单走同一份 isFirstPurchase
判定,确保展示价与实际下单价口径一致。
测试:补充 EvaluatePromo / calculatePurchasePrice 的 NewUser 屏蔽 + Campaign
放开用例;更新 loadSubscribePromoMap 测试覆盖新增 HasPaidSubscription 查询。
注:renewalLogic.go 仍未接入促销(属于方向 B 的彻底统一,本次未涵盖)。
This commit is contained in:
@@ -27,7 +27,7 @@ func calculatePurchasePrice(
|
||||
quantity int64,
|
||||
discounts []types.SubscribeDiscount,
|
||||
eligibleForDiscount bool,
|
||||
allowPromo bool,
|
||||
isFirstPurchase bool,
|
||||
) (*orderPriceResult, error) {
|
||||
originalPrice := unitPrice * quantity
|
||||
result := &orderPriceResult{
|
||||
@@ -35,21 +35,19 @@ func calculatePurchasePrice(
|
||||
PayableBase: originalPrice,
|
||||
}
|
||||
|
||||
if allowPromo {
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
||||
result.PayableBase = promoResult.PromoPrice
|
||||
result.PromoRuleId = promoResult.RuleID
|
||||
result.PromoDiscount = originalPrice - result.PayableBase
|
||||
result.PromoPrice = promoResult.PromoPrice
|
||||
if result.PromoDiscount < 0 {
|
||||
result.PromoDiscount = 0
|
||||
}
|
||||
return result, nil
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
||||
result.PayableBase = promoResult.PromoPrice
|
||||
result.PromoRuleId = promoResult.RuleID
|
||||
result.PromoDiscount = originalPrice - result.PayableBase
|
||||
result.PromoPrice = promoResult.PromoPrice
|
||||
if result.PromoDiscount < 0 {
|
||||
result.PromoDiscount = 0
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
discount := float64(1)
|
||||
|
||||
@@ -221,3 +221,79 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
|
||||
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePurchasePriceCampaignAppliesToReturningUsers(t *testing.T) {
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 12,
|
||||
Name: "campaign",
|
||||
Type: promo.RuleTypeCampaign,
|
||||
Enabled: true,
|
||||
},
|
||||
PromoPrice: 400,
|
||||
},
|
||||
}}
|
||||
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||
|
||||
// isFirstPurchase=false simulates a returning user routed to renewal; the
|
||||
// Campaign promo must still apply because it has no first-purchase gate.
|
||||
result, err := calculatePurchasePrice(
|
||||
context.Background(),
|
||||
svcCtx,
|
||||
42,
|
||||
2,
|
||||
1000,
|
||||
1,
|
||||
nil,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calculatePurchasePrice returned error: %v", err)
|
||||
}
|
||||
if result.PromoRuleId != 12 {
|
||||
t.Fatalf("PromoRuleId = %d, want 12 (campaign should apply to returning users)", result.PromoRuleId)
|
||||
}
|
||||
if result.PayableBase != 400 {
|
||||
t.Fatalf("PayableBase = %d, want 400", result.PayableBase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePurchasePriceNewUserGatedByFirstPurchase(t *testing.T) {
|
||||
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
|
||||
{
|
||||
Rule: promo.Rule{
|
||||
Id: 13,
|
||||
Name: "new user",
|
||||
Type: promo.RuleTypeNewUser,
|
||||
Enabled: true,
|
||||
Params: `{"window_hours": 72}`,
|
||||
},
|
||||
PromoPrice: 200,
|
||||
},
|
||||
}}
|
||||
svcCtx := &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}
|
||||
|
||||
// isFirstPurchase=false → NewUser rule must be skipped, regular discount applies.
|
||||
result, err := calculatePurchasePrice(
|
||||
context.Background(),
|
||||
svcCtx,
|
||||
42,
|
||||
2,
|
||||
1000,
|
||||
1,
|
||||
[]types.SubscribeDiscount{{Quantity: 1, Discount: 90}},
|
||||
true,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calculatePurchasePrice returned error: %v", err)
|
||||
}
|
||||
if result.PromoRuleId != 0 || result.PromoDiscount != 0 {
|
||||
t.Fatalf("promo fields = (%d, %d), want (0, 0) when isFirstPurchase=false", result.PromoRuleId, result.PromoDiscount)
|
||||
}
|
||||
if result.PayableBase != 900 {
|
||||
t.Fatalf("PayableBase = %d, want 900 (regular 90%% discount)", result.PayableBase)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,19 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
||||
|
||||
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
userID := int64(0)
|
||||
isFirstPurchase := true
|
||||
if userInfo != nil {
|
||||
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, svcCtx.DB, userInfo.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userID = entitlement.EffectiveUserID
|
||||
|
||||
hasPaid, err := commonLogic.HasPaidSubscription(ctx, svcCtx.DB, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isFirstPurchase = !hasPaid
|
||||
}
|
||||
|
||||
candidates, err := querySubscribePromoCandidates(ctx, svcCtx, subscribeIDs, userInfo != nil)
|
||||
@@ -68,7 +75,7 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
||||
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
|
||||
continue
|
||||
}
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity)
|
||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, candidate.SubscribeId, candidate.Quantity, isFirstPurchase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ func TestLoadSubscribePromoMapUsesFamilyOwnerForInactivePromo(t *testing.T) {
|
||||
WithArgs(memberUserID, user.FamilyMemberActive, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"role", "family_status", "owner_user_id"}).
|
||||
AddRow(user.FamilyRoleMember, user.FamilyStatusActive, ownerUserID))
|
||||
mock.ExpectQuery("SELECT count(*) FROM `user_subscribe`").
|
||||
WithArgs(ownerUserID, 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectQuery("FROM subscribe_promo AS sp").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"subscribe_id", "quantity", "rule_name", "rule_type", "promo_price", "params", "start_time", "end_time",
|
||||
|
||||
Reference in New Issue
Block a user