修复(#85): 促销系统 quantity 设计修正补丁(跨任务统一修复)
Build docker and publish / build (20.15.1) (push) Failing after 8m50s
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m19s

- subscribe_promo 表加 quantity 字段,BIGINT NOT NULL DEFAULT 1
- EvaluatePromo 加 quantity 参数,按 subscribeID + quantity 精确匹配
- Promo 从 Subscribe 顶层移到 SubscribeDiscount
- 查询加 quantity,返回 map[subscribeID][quantity] 二级映射
- recordPromoUsage 错误向上传播,不再静默吞掉
- preCreate 和 purchase 的 allowPromo 判定统一为 orderType==1
- 迁移脚本增加幂等处理(guarded DROP/ADD/MODIFY)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-27 05:57:21 -07:00
parent d12c340743
commit 02b41e7a2c
12 changed files with 281 additions and 34 deletions
@@ -47,13 +47,18 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
l.Debugf("[PreCreateOrder] Quantity is less than or equal to 0, setting to 1")
req.Quantity = 1
}
entitlement, entErr := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, u.Id)
if entErr != nil {
return nil, entErr
}
targetSubscribeID := req.SubscribeId
orderType := uint8(1)
isSingleModeRenewal := false
decision, routeErr := commonLogic.ResolvePurchaseRoute(
l.ctx,
l.svcCtx.Config.Subscribe.SingleModel,
u.Id,
entitlement.EffectiveUserID,
req.SubscribeId,
l.svcCtx.UserModel.FindSingleModeAnchorSubscribe,
)
@@ -68,15 +73,44 @@ 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"),
logger.Field("anchor_user_subscribe_id", decision.Anchor.Id),
logger.Field("user_id", u.Id),
logger.Field("effective_user_id", entitlement.EffectiveUserID),
)
}
}
// 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:%')", entitlement.EffectiveUserID).
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("effective_user_id", entitlement.EffectiveUserID),
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 {
@@ -86,7 +120,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
// check subscribe plan quota limit for new purchase flow only
if !isSingleModeRenewal && sub.Quota > 0 {
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, u.Id)
userSub, err := l.svcCtx.UserModel.QueryUserSubscribe(l.ctx, entitlement.EffectiveUserID)
if err != nil {
l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user subscription error: %v", err.Error())
@@ -102,7 +136,7 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
}
}
newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, u.Id, targetSubscribeID, req.Quantity, sub.Discount)
newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, entitlement.EffectiveUserID, targetSubscribeID, req.Quantity, sub.Discount)
if err != nil {
l.Errorw("[PreCreateOrder] Database query error resolving new user eligibility",
logger.Field("error", err.Error()),
@@ -117,13 +151,13 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
priceResult, err := calculatePurchasePrice(
l.ctx,
l.svcCtx,
u.Id,
entitlement.EffectiveUserID,
targetSubscribeID,
sub.UnitPrice,
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)
}
}