合并 internal → main: 退款/提现/激活/CI 全面优化 #4

Open
shanshanzhong147 wants to merge 76 commits from internal into main
7 changed files with 195 additions and 20 deletions
Showing only changes of commit 7236ca4cf2 - Show all commits
+33
View File
@@ -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
}
+13 -3
View File
@@ -28,7 +28,16 @@ type promoRuleParams struct {
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{}
if svcCtx == nil || svcCtx.PromoModel == nil || svcCtx.DB == nil || subscribeID <= 0 || quantity <= 0 {
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, &currentUser, now)
eligible, expiresAt, err := evaluatePromoRule(ctx, svcCtx.DB, rule, params, userID, isFirstPurchase, &currentUser, now)
if err != nil {
return nil, err
}
@@ -96,12 +105,13 @@ func evaluatePromoRule(
rule *promo.RuleWithPrice,
params promoRuleParams,
userID int64,
isFirstPurchase bool,
currentUser *user.User,
now time.Time,
) (bool, time.Time, error) {
switch rule.Type {
case promo.RuleTypeNewUser:
if userID <= 0 {
if userID <= 0 || !isFirstPurchase {
return false, time.Time{}, nil
}
return evaluateNewUserPromo(ctx, db, params, userID, currentUser, now)
+49 -1
View File
@@ -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 {
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 {
rules []*promo.RuleWithPrice
lastSubscribeID int64
+13 -15
View File
@@ -27,7 +27,7 @@ func calculatePurchasePrice(
quantity int64,
discounts []types.SubscribeDiscount,
eligibleForDiscount bool,
allowPromo bool,
isFirstPurchase bool,
) (*orderPriceResult, error) {
originalPrice := unitPrice * quantity
result := &orderPriceResult{
@@ -35,21 +35,19 @@ func calculatePurchasePrice(
PayableBase: originalPrice,
}
if allowPromo {
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity)
if err != nil {
return nil, err
}
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
result.PayableBase = promoResult.PromoPrice
result.PromoRuleId = promoResult.RuleID
result.PromoDiscount = originalPrice - result.PayableBase
result.PromoPrice = promoResult.PromoPrice
if result.PromoDiscount < 0 {
result.PromoDiscount = 0
}
return result, nil
promoResult, err := commonLogic.EvaluatePromo(ctx, svcCtx, userID, subscribeID, quantity, isFirstPurchase)
if err != nil {
return nil, err
}
if promoResult != nil && promoResult.Eligible && promoResult.PromoPrice < originalPrice {
result.PayableBase = promoResult.PromoPrice
result.PromoRuleId = promoResult.RuleID
result.PromoDiscount = originalPrice - result.PayableBase
result.PromoPrice = promoResult.PromoPrice
if result.PromoDiscount < 0 {
result.PromoDiscount = 0
}
return result, nil
}
discount := float64(1)
@@ -221,3 +221,79 @@ func TestCalculatePurchasePricePassesQuantityToPromoEvaluation(t *testing.T) {
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)
}
}
+8 -1
View File
@@ -42,12 +42,19 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
userInfo, _ := ctx.Value(constant.CtxKeyUser).(*user.User)
userID := int64(0)
isFirstPurchase := true
if userInfo != nil {
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, svcCtx.DB, userInfo.Id)
if err != nil {
return nil, err
}
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)
@@ -68,7 +75,7 @@ func loadSubscribePromoMap(ctx context.Context, svcCtx *svc.ServiceContext, subs
if _, exists := result[candidate.SubscribeId][candidate.Quantity]; exists {
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 {
return nil, err
}
@@ -105,6 +105,9 @@ func TestLoadSubscribePromoMapUsesFamilyOwnerForInactivePromo(t *testing.T) {
WithArgs(memberUserID, user.FamilyMemberActive, 1).
WillReturnRows(sqlmock.NewRows([]string{"role", "family_status", "owner_user_id"}).
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").
WillReturnRows(sqlmock.NewRows([]string{
"subscribe_id", "quantity", "rule_name", "rule_type", "promo_price", "params", "start_time", "end_time",