Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 d12c340743 新功能(#77): 套餐列表 API 返回规格级促销信息
Build docker and publish / build (20.15.1) (push) Failing after 8m23s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m54s
将 promo 字段从 Subscribe 顶层移至 SubscribeDiscount(discount[] 项内),
促销查询按 subscribe_id + quantity 维度写入,每个规格独立命中最高优先级规则。

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 02:19:58 -07:00
6 changed files with 33 additions and 92 deletions
+1 -1
View File
@@ -229,6 +229,7 @@ type (
Quantity int64 `json:"quantity"` Quantity int64 `json:"quantity"`
Discount float64 `json:"discount"` Discount float64 `json:"discount"`
MapApple string `json:"map_apple"` MapApple string `json:"map_apple"`
Promo *SubscribePromo `json:"promo"`
} }
SubscribePromo { SubscribePromo {
RuleName string `json:"rule_name"` RuleName string `json:"rule_name"`
@@ -250,7 +251,6 @@ type (
UnitPrice int64 `json:"unit_price"` UnitPrice int64 `json:"unit_price"`
UnitTime string `json:"unit_time"` UnitTime string `json:"unit_time"`
Discount []SubscribeDiscount `json:"discount"` Discount []SubscribeDiscount `json:"discount"`
Promo *SubscribePromo `json:"promo"`
NodeCount int64 `json:"node_count"` NodeCount int64 `json:"node_count"`
Replacement int64 `json:"replacement"` Replacement int64 `json:"replacement"`
Inventory int64 `json:"inventory"` Inventory int64 `json:"inventory"`
+2 -20
View File
@@ -11,7 +11,6 @@ import (
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type PromoResult struct { type PromoResult struct {
@@ -149,12 +148,6 @@ func evaluateInactiveUserPromo(
err := db.WithContext(ctx). err := db.WithContext(ctx).
Model(&user.Subscribe{}). Model(&user.Subscribe{}).
Where("user_id = ?", userID). Where("user_id = ?", userID).
Order(clause.OrderBy{
Expression: clause.Expr{
SQL: "CASE WHEN expire_time = ? THEN 0 ELSE 1 END",
Vars: []interface{}{permanentSubscribeExpireTime()},
},
}).
Order("expire_time DESC"). Order("expire_time DESC").
Limit(1). Limit(1).
Take(&lastSub).Error Take(&lastSub).Error
@@ -165,19 +158,8 @@ func evaluateInactiveUserPromo(
return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed") return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed")
} }
return isInactivePromoEligible(lastSub.ExpireTime, now, params.InactiveMonths), ruleExpiresAt, nil threshold := now.AddDate(0, -params.InactiveMonths, 0)
} return lastSub.ExpireTime.Before(threshold) || lastSub.ExpireTime.Equal(threshold), ruleExpiresAt, nil
func isInactivePromoEligible(expireTime time.Time, now time.Time, inactiveMonths int) bool {
if expireTime.Equal(permanentSubscribeExpireTime()) {
return false
}
threshold := now.AddDate(0, -inactiveMonths, 0)
return expireTime.Before(threshold) || expireTime.Equal(threshold)
}
func permanentSubscribeExpireTime() time.Time {
return time.UnixMilli(0)
} }
func promoRuleExpiresAt(rule *promo.RuleWithPrice) time.Time { func promoRuleExpiresAt(rule *promo.RuleWithPrice) time.Time {
@@ -1,51 +0,0 @@
package common
import (
"testing"
"time"
)
func TestIsInactivePromoEligible(t *testing.T) {
now := time.Date(2026, time.May, 27, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
expireAt time.Time
want bool
}{
{
name: "expired before inactive threshold is eligible",
expireAt: now.AddDate(0, -4, 0),
want: true,
},
{
name: "expired exactly at inactive threshold is eligible",
expireAt: now.AddDate(0, -3, 0),
want: true,
},
{
name: "recently expired subscription is not eligible",
expireAt: now.AddDate(0, -2, 0),
want: false,
},
{
name: "active future subscription is not eligible",
expireAt: now.Add(time.Hour),
want: false,
},
{
name: "permanent subscription marker is not eligible",
expireAt: time.UnixMilli(0),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isInactivePromoEligible(tt.expireAt, now, 3)
if got != tt.want {
t.Fatalf("isInactivePromoEligible() = %v, want %v", got, tt.want)
}
})
}
}
+10 -5
View File
@@ -25,6 +25,7 @@ const (
type subscribePromoCandidate struct { type subscribePromoCandidate struct {
SubscribeId int64 `gorm:"column:subscribe_id"` SubscribeId int64 `gorm:"column:subscribe_id"`
Quantity int64 `gorm:"column:quantity"`
RuleName string `gorm:"column:rule_name"` RuleName string `gorm:"column:rule_name"`
RuleType string `gorm:"column:rule_type"` RuleType string `gorm:"column:rule_type"`
PromoPrice int64 `gorm:"column:promo_price"` PromoPrice int64 `gorm:"column:promo_price"`
@@ -38,8 +39,8 @@ type promoRuleParams struct {
InactiveMonths int `json:"inactive_months"` InactiveMonths int `json:"inactive_months"`
} }
func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]*types.SubscribePromo, error) { func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subscribeIDs []int64) (map[int64]map[int64]*types.SubscribePromo, error) {
result := make(map[int64]*types.SubscribePromo) result := make(map[int64]map[int64]*types.SubscribePromo)
if len(subscribeIDs) == 0 || svcCtx == nil || svcCtx.DB == nil { if len(subscribeIDs) == 0 || svcCtx == nil || svcCtx.DB == nil {
return result, nil return result, nil
} }
@@ -56,7 +57,7 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo} evaluator := promoEligibilityEvaluator{ctx: ctx, db: svcCtx.DB, userInfo: userInfo}
now := time.Now() now := time.Now()
for _, candidate := range candidates { for _, candidate := range candidates {
if _, exists := result[candidate.SubscribeId]; exists { if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
continue continue
} }
if !candidate.isActive(now) { if !candidate.isActive(now) {
@@ -69,7 +70,10 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
if !ok { if !ok {
continue continue
} }
result[candidate.SubscribeId] = &types.SubscribePromo{ if _, exists := result[candidate.SubscribeId]; !exists {
result[candidate.SubscribeId] = make(map[int64]*types.SubscribePromo)
}
result[candidate.SubscribeId][candidate.Quantity] = &types.SubscribePromo{
RuleName: candidate.RuleName, RuleName: candidate.RuleName,
RuleType: candidate.RuleType, RuleType: candidate.RuleType,
PromoPrice: candidate.PromoPrice, PromoPrice: candidate.PromoPrice,
@@ -84,7 +88,7 @@ func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceConte
var candidates []subscribePromoCandidate var candidates []subscribePromoCandidate
query := svcCtx.DB.WithContext(ctx). query := svcCtx.DB.WithContext(ctx).
Table("subscribe_promo AS sp"). 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"). 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) Where("sp.subscribe_id IN ? AND sp.promo_price > 0 AND pr.enabled = ?", subscribeIDs, true)
if !loggedIn { if !loggedIn {
@@ -92,6 +96,7 @@ func querySubscribePromoCandidates(ctx context.Context, svcCtx *svc.ServiceConte
} }
err := query. err := query.
Order("sp.subscribe_id ASC"). Order("sp.subscribe_id ASC").
Order("sp.quantity ASC").
Order("pr.priority DESC"). Order("pr.priority DESC").
Order("pr.id ASC"). Order("pr.id ASC").
Scan(&candidates).Error Scan(&candidates).Error
@@ -56,11 +56,19 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi
var discount []types.SubscribeDiscount var discount []types.SubscribeDiscount
_ = json.Unmarshal([]byte(item.Discount), &discount) _ = json.Unmarshal([]byte(item.Discount), &discount)
sub.Discount = discount sub.Discount = discount
list[i] = sub
} }
list[i] = sub list[i] = sub
} }
promos, err := loadSubscribePromoMap(l.ctx, l.svcCtx, subscribeIDs)
if err != nil {
l.Errorw("[QuerySubscribeListLogic] Query Promo Error", logger.Field("error", err.Error()))
return nil, err
}
for i := range list {
applySubscribeDiscountPromos(&list[i], promos[list[i].Id])
}
// 老版本客户端(无 X-App-Id)去掉每个套餐 discount 的最后一个 // 老版本客户端(无 X-App-Id)去掉每个套餐 discount 的最后一个
hasAppId, _ := l.ctx.Value(constant.CtxKeyHasAppId).(bool) hasAppId, _ := l.ctx.Value(constant.CtxKeyHasAppId).(bool)
if !hasAppId { if !hasAppId {
@@ -71,16 +79,13 @@ func (l *QuerySubscribeListLogic) QuerySubscribeList(req *types.QuerySubscribeLi
} }
} }
promos, err := loadSubscribePromoMap(l.ctx, l.svcCtx, subscribeIDs)
if err != nil {
l.Errorw("[QuerySubscribeListLogic] Query Promo Error", logger.Field("error", err.Error()))
return nil, err
}
for i := range list {
list[i].Promo = promos[list[i].Id]
}
resp.List = list resp.List = list
resp.Total = int64(len(list)) resp.Total = int64(len(list))
return return
} }
func applySubscribeDiscountPromos(subscribe *types.Subscribe, promoByQuantity map[int64]*types.SubscribePromo) {
for i := range subscribe.Discount {
subscribe.Discount[i].Promo = promoByQuantity[subscribe.Discount[i].Quantity]
}
}
+1 -1
View File
@@ -2789,7 +2789,6 @@ type Subscribe struct {
UnitPrice int64 `json:"unit_price"` UnitPrice int64 `json:"unit_price"`
UnitTime string `json:"unit_time"` UnitTime string `json:"unit_time"`
Discount []SubscribeDiscount `json:"discount"` Discount []SubscribeDiscount `json:"discount"`
Promo *SubscribePromo `json:"promo"`
NodeCount int64 `json:"node_count"` NodeCount int64 `json:"node_count"`
Replacement int64 `json:"replacement"` Replacement int64 `json:"replacement"`
Inventory int64 `json:"inventory"` Inventory int64 `json:"inventory"`
@@ -2854,6 +2853,7 @@ type SubscribeDiscount struct {
Discount float64 `json:"discount"` Discount float64 `json:"discount"`
NewUserOnly bool `json:"new_user_only"` NewUserOnly bool `json:"new_user_only"`
MapApple string `json:"map_apple"` MapApple string `json:"map_apple"`
Promo *SubscribePromo `json:"promo"`
} }
type SubscribeGroup struct { type SubscribeGroup struct {