合并 internal → main: 退款/提现/激活/CI 全面优化 #4
@@ -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
|
||||||
|
}
|
||||||
@@ -28,7 +28,16 @@ type promoRuleParams struct {
|
|||||||
InactiveMonths int `json:"inactive_months"`
|
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{}
|
result := &PromoResult{}
|
||||||
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 {
|
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 {
|
||||||
return result, nil
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -96,12 +105,13 @@ func evaluatePromoRule(
|
|||||||
rule *promo.RuleWithPrice,
|
rule *promo.RuleWithPrice,
|
||||||
params promoRuleParams,
|
params promoRuleParams,
|
||||||
userID int64,
|
userID int64,
|
||||||
|
isFirstPurchase bool,
|
||||||
currentUser *user.User,
|
currentUser *user.User,
|
||||||
now time.Time,
|
now time.Time,
|
||||||
) (bool, time.Time, error) {
|
) (bool, time.Time, error) {
|
||||||
switch rule.Type {
|
switch rule.Type {
|
||||||
case promo.RuleTypeNewUser:
|
case promo.RuleTypeNewUser:
|
||||||
if userID <= 0 {
|
if userID <= 0 || !isFirstPurchase {
|
||||||
return false, time.Time{}, nil
|
return false, time.Time{}, nil
|
||||||
}
|
}
|
||||||
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
|
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
|
||||||
|
|||||||
@@ -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 {
|
if err != nil {
|
||||||
t.Fatalf("EvaluatePromo returned error: %v", err)
|
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 {
|
type fakePromoModel struct {
|
||||||
rules []*promo.RuleWithPrice
|
rules []*promo.RuleWithPrice
|
||||||
lastSubscribeID int64
|
lastSubscribeID int64
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func calculatePurchasePrice(
|
|||||||
quantity int64,
|
quantity int64,
|
||||||
discounts []types.SubscribeDiscount,
|
discounts []types.SubscribeDiscount,
|
||||||
eligibleForDiscount bool,
|
eligibleForDiscount bool,
|
||||||
allowPromo bool,
|
isFirstPurchase bool,
|
||||||
) (*orderPriceResult, error) {
|
) (*orderPriceResult, error) {
|
||||||
originalPrice := unitPrice * quantity
|
originalPrice := unitPrice * quantity
|
||||||
result := &orderPriceResult{
|
result := &orderPriceResult{
|
||||||
@@ -35,21 +35,19 @@ func calculatePurchasePrice(
|
|||||||
PayableBase: originalPrice,
|
PayableBase: originalPrice,
|
||||||
}
|
}
|
||||||
|
|
||||||
if allowPromo {
|
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase)
|
||||||
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
|
if err != nil {
|
||||||
if err != nil {
|
return nil, err
|
||||||
return nil, err
|
}
|
||||||
}
|
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
||||||
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
|
result.PayableBase = promoResult.PromoPrice
|
||||||
result.PayableBase = promoResult.PromoPrice
|
result.PromoRuleId = promoResult.RuleID
|
||||||
result.PromoRuleId = promoResult.RuleID
|
result.PromoDiscount = originalPrice - result.PayableBase
|
||||||
result.PromoDiscount = originalPrice - result.PayableBase
|
result.PromoPrice = promoResult.PromoPrice
|
||||||
result.PromoPrice = promoResult.PromoPrice
|
if result.PromoDiscount < 0 {
|
||||||
if result.PromoDiscount < 0 {
|
result.PromoDiscount = 0
|
||||||
result.PromoDiscount = 0
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
discount := float64(1)
|
discount := float64(1)
|
||||||
|
|||||||
@@ -221,3 +221,79 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
|
|||||||
t.Fatalf("PromoRuleId = %d, want 11", result.PromoRuleId)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,12 +42,19 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
|
|||||||
|
|
||||||
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User)
|
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
userID := int64(0)
|
userID := int64(0)
|
||||||
|
isFirstPurchase := true
|
||||||
if userInfo != nil {
|
if userInfo != nil {
|
||||||
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, svcCtx.DB, userInfo.Id)
|
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, svcCtx.DB, userInfo.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
userID = entitlement.EffectiveUserID
|
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)
|
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 {
|
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ func TestLoadSubscribePromoMapUsesFamilyOwnerForInactivePromo(t *testing.T) {
|
|||||||
WithArgs(memberUserID, user.FamilyMemberActive, 1).
|
WithArgs(memberUserID, user.FamilyMemberActive, 1).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"role", "family_status", "owner_user_id"}).
|
WillReturnRows(sqlmock.NewRows([]string{"role", "family_status", "owner_user_id"}).
|
||||||
AddRow(user.FamilyRoleMember, user.FamilyStatusActive, ownerUserID))
|
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").
|
mock.ExpectQuery("FROM subscribe_promo AS sp").
|
||||||
WillReturnRows(sqlmock.NewRows([]string{
|
WillReturnRows(sqlmock.NewRows([]string{
|
||||||
"subscribe_id", "quantity", "rule_name", "rule_type", "promo_price", "params", "start_time", "end_time",
|
"subscribe_id", "quantity", "rule_name", "rule_type", "promo_price", "params", "start_time", "end_time",
|
||||||
|
|||||||
Reference in New Issue
Block a user