修复(#85): 按数量匹配订阅促销价

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-27 00:36:24 -07:00
parent 48e507783e
commit ae64cc635d
15 changed files with 209 additions and 41 deletions
+3 -3
View File
@@ -27,13 +27,13 @@ type promoRuleParams struct {
InactiveMonths int `json:"inactive_months"`
}
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64) (*PromoResult, error) {
func EvaluatePromo(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, subscribeID int64, quantity int64) (*PromoResult, error) {
result := &PromoResult{}
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 {
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || userID <= 0 || subscribeID <= 0 || quantity <= 0 {
return result, nil
}
rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID)
rules, err := svcCtx.PromoModel.QueryEligibleRules(ctx, subscribeID, quantity)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo rules failed: %v", err.Error())
}
@@ -49,6 +49,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
}
targetSubscribeID := req.SubscribeId
orderType := uint8(1)
isSingleModeRenewal := false
decision, routeErr := commonLogic.ResolvePurchaseRoute(
l.ctx,
@@ -68,6 +69,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
targetSubscribeID = decision.ResolvedSubscribeID
isSingleModeRenewal = decision.Route == commonLogic.PurchaseRoutePurchaseToRenewal
if isSingleModeRenewal && decision.Anchor != nil {
orderType = 2
l.Infow("[PreCreateOrder] single mode purchase routed to renewal preview",
logger.Field("mode", "single"),
logger.Field("route", "purchase_to_renewal"),
@@ -77,6 +79,32 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
}
}
// Keep promo eligibility preview aligned with Purchase: an existing paid subscription
// routes the request to renewal semantics, where first-purchase promos are disabled.
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
var existSub user.Subscribe
if e := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Subscribe{}).
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", u.Id).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
orderType = 2
l.Infow("[PreCreateOrder] purchase preview routed to renewal because an existing subscription was found",
logger.Field("route_mode", "global_single_subscription"),
logger.Field("route", "purchase_to_existing_subscription"),
logger.Field("existing_subscribe_id", existSub.Id),
logger.Field("existing_status", existSub.Status),
logger.Field("user_id", u.Id),
logger.Field("resolved_subscribe_id", targetSubscribeID),
)
} else if e != nil && !errors.Is(e, gorm.ErrRecordNotFound) {
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find existing subscription error: %v", e.Error())
}
}
// find subscribe plan
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
if err != nil {
@@ -123,7 +151,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
req.Quantity,
newUserDiscount.Discounts,
newUserDiscount.EligibleForDiscount,
!isSingleModeRenewal,
orderType == 1,
)
if err != nil {
l.Errorw("[PreCreateOrder] Promo price calculation error",
+1 -1
View File
@@ -36,7 +36,7 @@ func calculatePurchasePrice(
}
if allowPromo {
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID)
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
if err != nil {
return nil, err
}
@@ -11,21 +11,30 @@ import (
)
type fakePromoModel struct {
rules []*promo.RuleWithPrice
rules []*promo.RuleWithPrice
lastSubscribeID int64
lastQuantity int64
requireQuantity int64
quantityMismatch []*promo.RuleWithPrice
}
func (m fakePromoModel) QueryEligibleRules(context.Context, int64) ([]*promo.RuleWithPrice, error) {
func (m *fakePromoModel) QueryEligibleRules(_ context.Context, subscribeID int64, quantity int64) ([]*promo.RuleWithPrice, error) {
m.lastSubscribeID = subscribeID
m.lastQuantity = quantity
if m.requireQuantity > 0 && quantity != m.requireQuantity {
return m.quantityMismatch, nil
}
return m.rules, nil
}
func (m fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error {
func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB) error {
return nil
}
func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) {
svcCtx := &svc.ServiceContext{
DB: &gorm.DB{},
PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{
PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 9,
@@ -73,7 +82,7 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) {
func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) {
svcCtx := &svc.ServiceContext{
DB: &gorm.DB{},
PromoModel: fakePromoModel{rules: []*promo.RuleWithPrice{
PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 10,
@@ -111,3 +120,52 @@ func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) {
t.Fatalf("promo fields = (%d, %d), want (0, 0)", result.PromoRuleId, result.PromoDiscount)
}
}
func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
promoModel := &fakePromoModel{
requireQuantity: 6,
rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 11,
Name: "quantity campaign",
Type: promo.RuleTypeCampaign,
Enabled: true,
},
PromoPrice: 500,
},
},
}
svcCtx := &svc.ServiceContext{
DB: &gorm.DB{},
PromoModel: promoModel,
}
result, err := calculatePurchasePrice(
context.Background(),
svcCtx,
1,
2,
1000,
6,
[]types.SubscribeDiscount{{Quantity: 6, Discount: 80}},
true,
true,
)
if err != nil {
t.Fatalf("calculatePurchasePrice returned error: %v", err)
}
if promoModel.lastSubscribeID != 2 {
t.Fatalf("lastSubscribeID = %d, want 2", promoModel.lastSubscribeID)
}
if promoModel.lastQuantity != 6 {
t.Fatalf("lastQuantity = %d, want 6", promoModel.lastQuantity)
}
if result.PayableBase != 3000 {
t.Fatalf("PayableBase = %d, want 3000", result.PayableBase)
}
if result.PromoRuleId != 11 {
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
}
}
+13 -5
View File
@@ -25,6 +25,7 @@ const (
type subscribePromoCandidate struct {
SubscribeId int64 `gorm:"column:subscribe_id"`
Quantity int64 `gorm:"column:quantity"`
RuleName string `gorm:"column:rule_name"`
RuleType string `gorm:"column:rule_type"`
PromoPrice int64 `gorm:"column:promo_price"`
@@ -38,8 +39,8 @@ type promoRuleParams struct {
InactiveMonths int `json:"inactive_months"`
}
func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]*types.SubscribePromo, error) {
result := make(map[int64]*types.SubscribePromo)
func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]map[int64]*types.SubscribePromo, error) {
result := make(map[int64]map[int64]*types.SubscribePromo)
if len(subscribeIDs) == 0 || svcCtx == nil || svcCtx.DB == nil {
return result, nil
}
@@ -56,7 +57,13 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo}
now := time.Now()
for _, candidate := range candidates {
if _, exists := result[candidate.SubscribeId]; exists {
if candidate.Quantity <= 0 {
continue
}
if result[candidate.SubscribeId] == nil {
result[candidate.SubscribeId] = make(map[int64]*types.SubscribePromo)
}
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
continue
}
if !candidate.isActive(now) {
@@ -69,7 +76,7 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
if !ok {
continue
}
result[candidate.SubscribeId] = &types.SubscribePromo{
result[candidate.SubscribeId][candidate.Quantity] = &types.SubscribePromo{
RuleName: candidate.RuleName,
RuleType: candidate.RuleType,
PromoPrice: candidate.PromoPrice,
@@ -84,7 +91,7 @@ func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceConte
var candidates []subscribePromoCandidate
query := svcCtx.DB.WithContext(ctx).
Table("subscribe_promo AS sp").
Select("sp.subscribe_id, sp.promo_price, pr.name AS rule_name, pr.type AS rule_type, pr.params, pr.start_time, pr.end_time").
Select("sp.subscribe_id, sp.quantity, sp.promo_price, pr.name AS rule_name, pr.type AS rule_type, pr.params, pr.start_time, pr.end_time").
Joins("JOIN promo_rule AS pr ON pr.id = sp.promo_rule_id AND pr.deleted_at IS NULL").
Where("sp.subscribe_id IN ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeIDs, true)
if !loggedIn {
@@ -92,6 +99,7 @@ func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceConte
}
err := query.
Order("sp.subscribe_id ASC").
Order("sp.quantity ASC").
Order("pr.priority DESC").
Order("pr.id ASC").
Scan(&candidates).Error
@@ -77,7 +77,13 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi
return nil, err
}
for i := range list {
list[i].Promo = promos[list[i].Id]
subscribePromos := promos[list[i].Id]
for j := range list[i].Discount {
if subscribePromos == nil {
continue
}
list[i].Discount[j].Promo = subscribePromos[list[i].Discount[j].Quantity]
}
}
resp.List = list