Compare commits

...

11 Commits

Author SHA1 Message Date
06e4698888 Merge remote-tracking branch 'internal/main' into internal
Some checks failed
Build docker and publish / build (20.15.1) (push) Failing after 7m33s
2026-03-31 07:39:02 -07:00
e51dbea7c7 fix: getDiscount 新人取折扣最大档,非新人取折扣最小档
Some checks failed
Build docker and publish / build (20.15.1) (push) Failing after 7m31s
- 新人:所有匹配 quantity 的条目中取 discount 值最小的(折扣力度最大)
- 非新人:所有匹配 quantity 的条目中取 discount 值最大的(折扣力度最小,接近原价)
- 不再按 new_user_only 过滤,由 isNewUserOnlyForQuantity 在上层控制准入

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 07:22:12 -07:00
c8f172dc0e fix: getDiscount 新人取折扣最大档,非新人取最贵档
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
- 新人:遍历所有匹配 quantity 的条目,取 discount 值最小的(折扣力度最大)
- 非新人:仅看 new_user_only=false 的条目,取 discount 值最大的(最接近原价)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 07:18:04 -07:00
52aaaf4de5 fix: getDiscount 新人价取值错误 — 始终优先取 new_user_only=false 的实际折扣档
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
同一 quantity 有两条记录时:
- new_user_only=true (discount=99.64) 是新人限制标记,非实际折扣
- new_user_only=false (discount=35.36) 是实际折扣价格

原逻辑遍历到第一条即返回,导致新人拿到 99.64 而非 35.36。
修复后优先取 new_user_only=false 的条目,仅在无 false 条目时才降级到 new_user_only=true。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 07:14:37 -07:00
877a471e24 x
Some checks failed
Build docker and publish / build (20.15.1) (push) Failing after 7m46s
2026-03-31 06:57:57 -07:00
40cfe46ba8 fix: CountScopedSubscribePurchaseOrders 包含续费单并排除赠送单
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
- type IN (1, 2):同时统计首购和续费历史,防止用户用续费绕过新人价限制
- amount > 0:排除 admin 赠送的免费订单(amount=0),避免误判新人资格

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:56:35 -07:00
5733ebe40d fix: renewal logic 尝鲜价未生效 — 改为走 newUser 资格判断
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m17s
renewalLogic 在计算折扣时硬编码 isNewUser=false,
导致新用户在 24h 窗口内通过 renewal 接口购买时无法享受尝鲜价。
改为调用 resolveNewUserDiscountEligibility,与 purchaseLogic 保持一致。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:35:14 -07:00
0b1e6ce3c3 fix: 单订阅模式激活时赠送订阅也用受益者 ID 查找
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m21s
findGiftSubscription 同步改用 singleModeUserId
(家庭组场景为家庭主 ID),与锚点订阅查找保持一致。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:10:58 -07:00
3167465865 fix: 单订阅模式成员购买时用家庭主 ID 查锚点订阅
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
ResolvePurchaseRoute 传入 entitlement.EffectiveUserID
(家庭主 ID)而非 u.Id(成员 ID),确保成员购买时
能找到家庭主已有订阅并续费,而不是新建一条订阅。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:08:28 -07:00
8838fc51f8 fix: 单订阅模式家庭组购买时用 SubscriptionUserId 查锚点
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m28s
NewPurchase 中查 FindSingleModeAnchorSubscribe 时,家庭组
场景下应使用实际受益者 SubscriptionUserId(成员/主),
而非付款人 UserId,否则找不到已有订阅,会为成员创建新订阅。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 05:56:09 -07:00
ea34718a0b fix: 设备变更时同步清除用户缓存,修复 user_devices 为空问题
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m23s
Device.GetCacheKeys() 新增 cacheUserIdPrefix+UserId,
确保 InsertDevice/UpdateDevice/DeleteDevice 时同时失效
cache:user:id:{userId},避免下次 FindOne 读到不含
UserDevices 的旧缓存。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 05:46:48 -07:00
6 changed files with 42 additions and 16 deletions

View File

@ -21,7 +21,7 @@ env:
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }}
# TG通知
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
TG_CHAT_ID: "-4940243803"
TG_CHAT_ID: "-49402438031"
# Go构建变量
SERVICE: vpn
SERVICE_STYLE: vpn

View File

@ -76,7 +76,7 @@ func CountScopedSubscribePurchaseOrders(
var count int64
query := db.WithContext(ctx).
Model(&modelOrder.Order{}).
Where("user_id IN ? AND subscribe_id = ? AND type = 1", scopeUserIDs, subscribeID)
Where("user_id IN ? AND subscribe_id = ? AND type IN ? AND amount > 0", scopeUserIDs, subscribeID, []int64{1, 2})
if len(statuses) > 0 {
query = query.Where("status IN ?", statuses)
}

View File

@ -2,17 +2,32 @@ package order
import "github.com/perfect-panel/server/internal/types"
// getDiscount returns the discount factor for the given quantity.
//
// - New user: pick the tier with the lowest discount value (biggest saving).
// - Non-new user: pick the tier with the highest discount value (least saving / closest to full price).
func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64, isNewUser bool) float64 {
for _, discount := range discounts {
if discount.NewUserOnly && !isNewUser {
best := float64(-1)
for _, d := range discounts {
if d.Quantity != inputMonths || d.Discount <= 0 || d.Discount >= 100 {
continue
}
if inputMonths == discount.Quantity && discount.Discount > 0 && discount.Discount < 100 {
return discount.Discount / float64(100)
if isNewUser {
// lowest discount value = biggest saving
if best < 0 || d.Discount < best {
best = d.Discount
}
} else {
// highest discount value = least saving (closest to original price)
if best < 0 || d.Discount > best {
best = d.Discount
}
}
}
return 1
if best < 0 {
return 1
}
return best / float64(100)
}
// isNewUserOnlyForQuantity checks whether the matched discount tier has new_user_only enabled.

View File

@ -82,7 +82,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
decision, routeErr := commonLogic.ResolvePurchaseRoute(
l.ctx,
l.svcCtx.Config.Subscribe.SingleModel,
u.Id,
entitlement.EffectiveUserID,
req.SubscribeId,
l.svcCtx.UserModel.FindSingleModeAnchorSubscribe,
)

View File

@ -81,11 +81,17 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
if !*sub.Sell {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "subscribe not sell")
}
newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, u.Id, sub.Id, req.Quantity, sub.Discount)
if err != nil {
l.Errorw("[Renewal] Database query error resolving new user eligibility",
logger.Field("error", err.Error()),
logger.Field("user_id", u.Id),
)
return nil, err
}
var discount float64 = 1
if sub.Discount != "" {
var dis []types.SubscribeDiscount
_ = json.Unmarshal([]byte(sub.Discount), &dis)
discount = getDiscount(dis, req.Quantity, false)
if len(newUserDiscount.Discounts) > 0 {
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
}
price := sub.UnitPrice * req.Quantity
amount := int64(math.Round(float64(price) * discount))

View File

@ -229,9 +229,14 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
var userSub *user.Subscribe
// 单订阅模式下,优先兜底为续费语义”:延长已购订阅,避免并发下重复创建 user_subscribe
// 单订阅模式下,优先兜底为续费语义”:延长已购订阅,避免并发下重复创建 user_subscribe
if l.svc.Config.Subscribe.SingleModel {
anchorSub, anchorErr := l.svc.UserModel.FindSingleModeAnchorSubscribe(ctx, orderInfo.UserId)
// 家庭组场景:订阅实际属于 SubscriptionUserId成员/主),而非付款人 UserId
singleModeUserId := orderInfo.UserId
if orderInfo.SubscriptionUserId > 0 {
singleModeUserId = orderInfo.SubscriptionUserId
}
anchorSub, anchorErr := l.svc.UserModel.FindSingleModeAnchorSubscribe(ctx, singleModeUserId)
switch {
case anchorErr == nil && anchorSub != nil:
if orderInfo.ParentId == 0 && anchorSub.OrderId > 0 && anchorSub.OrderId != orderInfo.Id {
@ -271,7 +276,7 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
// 如果没有合并已购订阅再尝试合并赠送订阅order_id=0
if userSub == nil {
giftSub, giftErr := l.findGiftSubscription(ctx, orderInfo.UserId, orderInfo.SubscribeId)
giftSub, giftErr := l.findGiftSubscription(ctx, singleModeUserId, orderInfo.SubscribeId)
if giftErr == nil && giftSub != nil {
// 在赠送订阅上延长时间,保持 token 不变
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)