修复(#75): 按数量精确匹配套餐列表促销
Build docker and publish / build (20.15.1) (push) Failing after 9m2s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m58s

- loadSubscribePromoMap 返回 map[subscribeID]map[quantity]*SubscribePromo 二级映射
- 候选查询按 subscribe_id、quantity、priority DESC 排序,一次取出所有 quantity 档位
- 下单路径 QueryEligibleRules 将 quantity 条件移至 JOIN,精确匹配
- 永久订阅(expire_time=0)用 CASE WHEN 排序兜底,不再误判为回归促销
- 新增 promoEligibility、model、subscribe promo 单元测试

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-27 06:22:49 -07:00
parent 02b41e7a2c
commit b5e50d1ee5
7 changed files with 253 additions and 47 deletions
@@ -32,19 +32,20 @@ func (m *fakePromoModel) InsertUsage(context.Context, *promo.Usage, ...*gorm.DB)
}
func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) {
svcCtx := &svc.ServiceContext{
DB: &gorm.DB{},
PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 9,
Name: "campaign",
Type: promo.RuleTypeCampaign,
Enabled: true,
},
PromoPrice: 600,
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 9,
Name: "campaign",
Type: promo.RuleTypeCampaign,
Enabled: true,
},
}},
PromoPrice: 600,
},
}}
svcCtx := &svc.ServiceContext{
DB: &gorm.DB{},
PromoModel: model,
}
result, err := calculatePurchasePrice(
@@ -77,22 +78,26 @@ func TestCalculatePurchasePricePromoSkipsPercentDiscount(t *testing.T) {
if result.PromoDiscount != 1200 {
t.Fatalf("PromoDiscount = %d, want 1200", result.PromoDiscount)
}
if model.lastQuantity != 3 {
t.Fatalf("promo query quantity = %d, want 3", model.lastQuantity)
}
}
func TestCalculatePurchasePriceIgnoresInvalidPromoPrice(t *testing.T) {
svcCtx := &svc.ServiceContext{
DB: &gorm.DB{},
PromoModel: &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 10,
Name: "invalid campaign",
Type: promo.RuleTypeCampaign,
Enabled: true,
},
PromoPrice: 1000,
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 10,
Name: "invalid campaign",
Type: promo.RuleTypeCampaign,
Enabled: true,
},
}},
PromoPrice: 1000,
},
}}
svcCtx := &svc.ServiceContext{
DB: &gorm.DB{},
PromoModel: model,
}
result, err := calculatePurchasePrice(
+26 -13
View File
@@ -15,6 +15,7 @@ import (
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
@@ -89,7 +90,16 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64, loggedIn bool) ([]subscribePromoCandidate, error) {
var candidates []subscribePromoCandidate
query := svcCtx.DB.WithContext(ctx).
err := subscribePromoCandidatesQuery(ctx, svcCtx.DB, subscribeIDs, loggedIn).
Scan(&candidates).Error
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promo candidates failed: %v", err)
}
return candidates, nil
}
func subscribePromoCandidatesQuery(ctx context.Context, db *gorm.DB, subscribeIDs []int64, loggedIn bool) *gorm.DB {
query := db.WithContext(ctx).
Table("subscribe_promo AS sp").
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").
@@ -97,16 +107,11 @@ func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceConte
if !loggedIn {
query = query.Where("pr.type = ?", promoRuleTypeCampaign)
}
err := query.
return query.
Order("sp.subscribe_id ASC").
Order("sp.quantity ASC").
Order("pr.priority DESC").
Order("pr.id ASC").
Scan(&candidates).Error
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query subscribe promo candidates failed: %v", err)
}
return candidates, nil
Order("pr.id ASC")
}
func (c subscribePromoCandidate) isActive(now time.Time) bool {
@@ -179,11 +184,7 @@ func (e *promoEligibilityEvaluator) lastSubscribeExpireAt() (time.Time, error) {
return *e.lastExpire, nil
}
var item user.Subscribe
err := e.db.WithContext(e.ctx).
Model(&user.Subscribe{}).
Where("user_id = ?", e.userInfo.Id).
Where("expire_time != ?", time.UnixMilli(0)).
Order("expire_time DESC").
err := e.lastSubscribeExpireQuery().
Limit(1).
Take(&item).Error
if err != nil {
@@ -198,6 +199,18 @@ func (e *promoEligibilityEvaluator) lastSubscribeExpireAt() (time.Time, error) {
return item.ExpireTime, nil
}
func (e *promoEligibilityEvaluator) lastSubscribeExpireQuery() *gorm.DB {
return e.db.WithContext(e.ctx).
Model(&user.Subscribe{}).
Where("user_id = ?", e.userInfo.Id).
Order(clause.OrderBy{
Expression: clause.Expr{
SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC",
Vars: []interface{}{time.UnixMilli(0)},
},
})
}
func (c subscribePromoCandidate) expiresAt() time.Time {
if c.EndTime == nil {
return time.Time{}
@@ -1,10 +1,15 @@
package subscribe
import (
"context"
"strings"
"testing"
"time"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/types"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func TestPromoEligibilityEvaluatorMatch(t *testing.T) {
@@ -73,3 +78,83 @@ func TestSubscribePromoCandidateActiveWindow(t *testing.T) {
t.Fatal("candidate after end time should not be active")
}
}
func TestLastSubscribeExpireAtPrioritizesPermanentSubscription(t *testing.T) {
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local",
SkipInitializeWithVersion: true,
}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
if err != nil {
t.Fatalf("open dry-run db: %v", err)
}
evaluator := &promoEligibilityEvaluator{
db: db,
userInfo: &user.User{Id: 7},
}
var item user.Subscribe
tx := evaluator.lastSubscribeExpireQuery().Limit(1).Take(&item)
sql := tx.Statement.SQL.String()
if !strings.Contains(sql, "CASE WHEN expire_time = ? THEN 0 ELSE 1 END") {
t.Fatalf("SQL missing permanent subscription priority order: %s", sql)
}
if strings.Contains(sql, "expire_time !=") {
t.Fatalf("SQL should not filter out permanent subscriptions: %s", sql)
}
if len(tx.Statement.Vars) < 2 {
t.Fatalf("SQL vars length = %d, want at least 2; vars=%v", len(tx.Statement.Vars), tx.Statement.Vars)
}
if got, want := tx.Statement.Vars[1], time.UnixMilli(0); got != want {
t.Fatalf("permanent subscription order var = %v, want %v; vars=%v", got, want, tx.Statement.Vars)
}
}
func TestQuerySubscribePromoCandidatesIncludesQuantity(t *testing.T) {
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: "gorm:gorm@tcp(localhost:9910)/gorm?charset=utf8&parseTime=True&loc=Local",
SkipInitializeWithVersion: true,
}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
if err != nil {
t.Fatalf("open dry-run db: %v", err)
}
var candidates []subscribePromoCandidate
tx := subscribePromoCandidatesQuery(context.Background(), db, []int64{11, 12}, true).Scan(&candidates)
stmt := tx.Statement
sql := stmt.SQL.String()
if !strings.Contains(sql, "sp.subscribe_id, sp.quantity, sp.promo_price") {
t.Fatalf("SQL missing quantity select: %s", sql)
}
if !strings.Contains(sql, "ORDER BY sp.subscribe_id ASC,sp.quantity ASC,pr.priority DESC,pr.id ASC") {
t.Fatalf("SQL missing quantity order: %s", sql)
}
}
func TestApplySubscribeDiscountPromosMatchesQuantity(t *testing.T) {
subscribe := types.Subscribe{Discount: []types.SubscribeDiscount{
{Quantity: 1},
{Quantity: 12},
}}
promos := map[int64]*types.SubscribePromo{
3: {RuleName: "季度优惠", PromoPrice: 2900},
12: {RuleName: "年度优惠", PromoPrice: 9900},
}
applySubscribeDiscountPromos(&subscribe, promos)
if subscribe.Discount[0].Promo != nil {
t.Fatalf("quantity 1 promo should be nil, got %+v", subscribe.Discount[0].Promo)
}
if subscribe.Discount[1].Promo == nil {
t.Fatal("quantity 12 promo should match")
}
if got, want := subscribe.Discount[1].Promo.RuleName, "年度优惠"; got != want {
t.Fatalf("promo rule name = %q, want %q", got, want)
}
subscribe = types.Subscribe{Discount: []types.SubscribeDiscount{{Quantity: 6}}}
applySubscribeDiscountPromos(&subscribe, promos)
if subscribe.Discount[0].Promo != nil {
t.Fatalf("promo should be nil when quantity does not match, got %+v", subscribe.Discount[0].Promo)
}
}