From 7236ca4cf28e4fe45a850d76c2d62994fb760eba Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 31 May 2026 19:00:30 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(#128):=20=E6=8C=89=E8=A7=84?= =?UTF-8?q?=E5=88=99=E7=B1=BB=E5=9E=8B=E5=86=B3=E5=AE=9A=E4=BF=83=E9=94=80?= =?UTF-8?q?=E8=B5=84=E6=A0=BC=EF=BC=8C=E8=AE=A9=20InactiveUser/Campaign=20?= =?UTF-8?q?=E5=AF=B9=E8=80=81=E7=94=A8=E6=88=B7=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 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 的彻底统一,本次未涵盖)。 --- internal/logic/common/paidSubscription.go | 33 ++++++++ internal/logic/common/promoEligibility.go | 16 +++- .../logic/common/promoEligibility_test.go | 50 +++++++++++- internal/logic/public/order/promoPricing.go | 28 ++++--- .../logic/public/order/promoPricing_test.go | 76 +++++++++++++++++++ internal/logic/public/subscribe/promo.go | 9 ++- internal/logic/public/subscribe/promo_test.go | 3 + 7 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 internal/logic/common/paidSubscription.go diff --git a/internal/logic/common/paidSubscription.go b/internal/logic/common/paidSubscription.go new file mode 100644 index 0000000..00be8a7 --- /dev/null +++ b/internal/logic/common/paidSubscription.go @@ -0,0 +1,33 @@ +package common + +import ( + "context" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +// HasPaidSubscription reports whether the user owns at least one paid +// subscription record — order-backed (order_id > 0) or Apple-IAP-backed +// (token LIKE 'iap:%'). Returns false when userID or db are not usable so +// callers can fall back to the "first purchase" branch safely. +// +// This mirrors the predicate used to route /v1/public/order/purchase requests +// to renewal semantics. Keep both in sync. +func HasPaidSubscription(ctx context.Context, db *gorm.DB, userID int64) (bool, error) { + if userID <= 0 || db == nil { + return false, nil + } + + var count int64 + if err := db.WithContext(ctx). + Model(&user.Subscribe{}). + Where("user_id = ? AND (order_id > 0 OR token LIKE 'iap:%')", userID). + Limit(1). + Count(&count).Error; err != nil { + return false, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query paid subscription failed: %v", err.Error()) + } + return count > 0, nil +} diff --git a/internal/logic/common/promoEligibility.go b/internal/logic/common/promoEligibility.go index b13ede9..83da6e5 100644 --- a/internal/logic/common/promoEligibility.go +++ b/internal/logic/common/promoEligibility.go @@ -28,7 +28,16 @@ type promoRuleParams struct { InactiveMonths int `json:"inactive_months"` } -func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) { +// EvaluatePromo decides whether the given (user, subscribe, quantity) tuple +// qualifies for any active promo rule. Each rule type has its own gating: +// - new_user: requires isFirstPurchase=true (no prior paid subscription) +// - inactive_user: requires a previously expired subscription (rule self-check) +// - campaign: applies unconditionally within the configured time window +// +// Pass isFirstPurchase=true on order paths where the request is being routed +// as a brand-new purchase; pass false when the user already has a paid +// subscription (including expired ones) so NewUser cannot be reused. +func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64, isFirstPurchase bool) (*PromoResult, error) { result := &PromoResult{} if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 { return result, nil @@ -59,7 +68,7 @@ func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64 } } - eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, ¤tUser, now) + eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, isFirstPurchase, ¤tUser, now) if err != nil { return nil, err } @@ -96,12 +105,13 @@ func evaluatePromoRule( rule *promo.RuleWithPrice, params promoRuleParams, userID int64, + isFirstPurchase bool, currentUser *user.User, now time.Time, ) (bool, time.Time, error) { switch rule.Type { case promo.RuleTypeNewUser: - if userID <= 0 { + if userID <= 0 || !isFirstPurchase { return false, time.Time{}, nil } return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now) diff --git a/internal/logic/common/promoEligibility_test.go b/internal/logic/common/promoEligibility_test.go index c78b539..8476376 100644 --- a/internal/logic/common/promoEligibility_test.go +++ b/internal/logic/common/promoEligibility_test.go @@ -65,7 +65,7 @@ func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) { }, }} - got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12) + got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 0, 7, 12, true) if err != nil { t.Fatalf("EvaluatePromo returned error: %v", err) } @@ -86,6 +86,54 @@ func TestEvaluatePromoAllowsAnonymousCampaign(t *testing.T) { } } +func TestEvaluatePromoCampaignAppliesToReturningUsers(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 4, + Name: "campaign", + Type: promo.RuleTypeCampaign, + Enabled: true, + }, + PromoPrice: 299, + }, + }} + + got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false) + if err != nil { + t.Fatalf("EvaluatePromo returned error: %v", err) + } + if !got.Eligible { + t.Fatal("campaign promo should remain eligible for returning users (isFirstPurchase=false)") + } + if got.PromoPrice != 299 { + t.Fatalf("PromoPrice = %d, want 299", got.PromoPrice) + } +} + +func TestEvaluatePromoNewUserRequiresFirstPurchase(t *testing.T) { + model := &fakePromoModel{rules: []*promo.RuleWithPrice{ + { + Rule: promo.Rule{ + Id: 5, + Name: "new user", + Type: promo.RuleTypeNewUser, + Enabled: true, + Params: `{"window_hours": 72}`, + }, + PromoPrice: 99, + }, + }} + + got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: &gorm.DB{}, PromoModel: model}, 42, 7, 1, false) + if err != nil { + t.Fatalf("EvaluatePromo returned error: %v", err) + } + if got.Eligible { + t.Fatal("new-user promo must be gated out when isFirstPurchase=false (user already has paid subscriptions)") + } +} + type fakePromoModel struct { rules []*promo.RuleWithPrice lastSubscribeID int64 diff --git a/internal/logic/public/order/promoPricing.go b/internal/logic/public/order/promoPricing.go index a1d9133..431451d 100644 --- a/internal/logic/public/order/promoPricing.go +++ b/internal/logic/public/order/promoPricing.go @@ -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) diff --git a/internal/logic/public/order/promoPricing_test.go b/internal/logic/public/order/promoPricing_test.go index 6e8a5b9..1f84f37 100644 --- a/internal/logic/public/order/promoPricing_test.go +++ b/internal/logic/public/order/promoPricing_test.go @@ -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) + } +} diff --git a/internal/logic/public/subscribe/promo.go b/internal/logic/public/subscribe/promo.go index b9b529b..01532b3 100644 --- a/internal/logic/public/subscribe/promo.go +++ b/internal/logic/public/subscribe/promo.go @@ -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 } diff --git a/internal/logic/public/subscribe/promo_test.go b/internal/logic/public/subscribe/promo_test.go index 6a3511e..d1a7c43 100644 --- a/internal/logic/public/subscribe/promo_test.go +++ b/internal/logic/public/subscribe/promo_test.go @@ -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",