Compare commits

..

4 Commits

Author SHA1 Message Date
cf70838142 fix(order): restore expired subscribe for invite gifts
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-30 13:16:45 -07:00
280437be91 fix(order): guard renewal activation owner
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-30 12:54:02 -07:00
59b7056a20 fix family member renewal target
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-30 09:26:52 -07:00
769622f087 x
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-29 23:30:38 -07:00
4 changed files with 256 additions and 8 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

@ -699,6 +699,85 @@ func TestPurchase_NewUserOnly_BindEmailScopeSharesHistory(t *testing.T) {
assert.Equal(t, int64(0), newOrder.Discount)
}
func ensureRenewalSubscribeColumns(t *testing.T, db *gorm.DB) {
t.Helper()
for _, sql := range []string{
`ALTER TABLE "user_subscribe" ADD COLUMN node_group_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN group_locked TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_download INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE "user_subscribe" ADD COLUMN expired_upload INTEGER NOT NULL DEFAULT 0`,
} {
require.NoError(t, db.Exec(sql).Error)
}
}
func insertRenewalUserSubscribe(t *testing.T, db *gorm.DB, id, userID, orderID, subscribeID int64, token, uuid string, start, expire time.Time) {
t.Helper()
require.NoError(t, db.Exec(`INSERT INTO "user_subscribe"
(id, user_id, order_id, subscribe_id, start_time, expire_time, traffic, download, upload, token, uuid, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 1, ?, ?)`,
id,
userID,
orderID,
subscribeID,
start.UTC().Format("2006-01-02 15:04:05"),
expire.UTC().Format("2006-01-02 15:04:05"),
token,
uuid,
start.UTC().Format("2006-01-02 15:04:05"),
time.Now().UTC().Format("2006-01-02 15:04:05"),
).Error)
}
func TestRenewalMemberRequestRedirectsToOwnerSubscribe(t *testing.T) {
db := setupNewUserOnlyDB(t)
ensureRenewalSubscribeColumns(t, db)
rds, mr := setupNewUserOnlyRedis(t)
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
const (
planID = int64(1)
paymentID = int64(2)
ownerUserID = int64(29650)
memberID = int64(20003)
familyID = int64(88001)
memberSubID = int64(10013)
ownerSubID = int64(14074)
)
insertTestSubscribe(t, db, planID, false)
insertTestPayment(t, db, paymentID)
member := insertTestUser(t, db, memberID, time.Now().Add(-24*time.Hour))
insertTestUser(t, db, ownerUserID, time.Now().Add(-24*time.Hour))
insertTestFamily(t, db, familyID, ownerUserID)
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
insertTestFamilyMember(t, db, familyID, memberID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
insertRenewalUserSubscribe(t, db, memberSubID, memberID, 7448, planID, "member-token-10013", "member-uuid-10013",
time.Date(2026, 4, 23, 19, 6, 40, 0, time.UTC),
time.Date(2026, 4, 30, 19, 6, 40, 0, time.UTC),
)
insertRenewalUserSubscribe(t, db, ownerSubID, ownerUserID, 9999, planID, "owner-token-14074", "owner-uuid-14074",
time.Date(2026, 4, 30, 13, 39, 33, 0, time.UTC),
time.Date(2026, 5, 30, 16, 0, 0, 0, time.UTC),
)
resp, err := NewRenewalLogic(buildPurchaseCtx(member), svcCtx).Renewal(&types.RenewalOrderRequest{
UserSubscribeID: memberSubID,
Payment: paymentID,
Quantity: 30,
})
require.NoError(t, err)
require.NotNil(t, resp)
var created modelOrder.Order
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&created).Error)
assert.Equal(t, memberID, created.UserId)
assert.Equal(t, ownerUserID, created.SubscriptionUserId)
assert.Equal(t, int64(9999), created.ParentId)
assert.Equal(t, "owner-token-14074", created.SubscribeToken)
}
func TestPreCreateOrder_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
db := setupNewUserOnlyDB(t)
rds, mr := setupNewUserOnlyRedis(t)

View File

@ -41,6 +41,67 @@ func NewRenewalLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RenewalLo
}
}
func (l *RenewalLogic) resolveRenewalTargetSubscribe(requested *user.SubscribeDetails, entitlement *commonLogic.EntitlementContext, currentUserID int64) (*user.SubscribeDetails, error) {
if requested == nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "requested user subscribe is empty")
}
effectiveUserID := currentUserID
if entitlement != nil && entitlement.EffectiveUserID > 0 {
effectiveUserID = entitlement.EffectiveUserID
}
if effectiveUserID == currentUserID {
if requested.UserId != currentUserID {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "user subscribe does not belong to current user")
}
return requested, nil
}
if requested.UserId != currentUserID && requested.UserId != effectiveUserID {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "user subscribe does not belong to current family entitlement")
}
if requested.UserId == effectiveUserID {
return requested, nil
}
ownerSubscribe, err := l.findOwnerRenewalSubscribe(effectiveUserID, requested.SubscribeId)
if err != nil {
return nil, err
}
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "renewal_target_resolved",
"[SubscriptionFlow] renewal target redirected to family owner subscription",
logger.Field("user_id", currentUserID),
logger.Field("effective_user_id", effectiveUserID),
logger.Field("requested_user_subscribe_id", requested.Id),
logger.Field("requested_subscribe_owner_user_id", requested.UserId),
logger.Field("resolved_user_subscribe_id", ownerSubscribe.Id),
logger.Field("resolved_subscribe_owner_user_id", ownerSubscribe.UserId),
logger.Field("subscribe_id", requested.SubscribeId),
)
return ownerSubscribe, nil
}
func (l *RenewalLogic) findOwnerRenewalSubscribe(ownerUserID, subscribeID int64) (*user.SubscribeDetails, error) {
var target user.SubscribeDetails
err := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Subscribe{}).
Preload("Subscribe").
Where("user_id = ? AND subscribe_id = ? AND token != ''", ownerUserID, subscribeID).
Where("status IN ?", []int64{0, 1, 2, 3}).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&target).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "owner subscribe not found for renewal")
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find owner subscribe error: %v", err.Error())
}
return &target, nil
}
// Renewal processes subscription renewal orders including discount calculation,
// coupon validation, gift amount deduction, fee calculation, and order creation
func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.RenewalOrderResponse, err error) {
@ -77,11 +138,15 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
}
orderNo := tool.GenerateTradeNo()
// find user subscribe
userSubscribe, err := l.svcCtx.UserModel.FindOneUserSubscribe(l.ctx, req.UserSubscribeID)
// find requested user subscribe, then resolve it to the real entitlement owner.
requestedSubscribe, err := l.svcCtx.UserModel.FindOneUserSubscribe(l.ctx, req.UserSubscribeID)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user subscribe error: %v", err.Error())
}
userSubscribe, err := l.resolveRenewalTargetSubscribe(requestedSubscribe, entitlement, u.Id)
if err != nil {
return nil, err
}
// find subscription
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, userSubscribe.SubscribeId)
if err != nil {
@ -251,7 +316,9 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
"[SubscriptionFlow] renewal order persisted",
append(commonLogic.OrderTraceFields(&orderInfo),
logger.Field("requested_user_subscribe_id", req.UserSubscribeID),
logger.Field("requested_subscribe_owner_user_id", requestedSubscribe.UserId),
logger.Field("resolved_user_subscribe_id", userSubscribe.Id),
logger.Field("resolved_subscribe_owner_user_id", userSubscribe.UserId),
)...,
)
// Deferred task

View File

@ -1193,7 +1193,7 @@ func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, da
return nil
}
activeSubscribe, err := l.svc.UserModel.FindActiveSubscribe(ctx, u.Id)
activeSubscribe, err := l.findGiftDaysSubscription(ctx, u.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
giftLog := &log.Gift{
@ -1215,9 +1215,18 @@ func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, da
}
return err
}
now := time.Now()
if !activeSubscribe.ExpireTime.Equal(time.UnixMilli(0)) {
activeSubscribe.ExpireTime = activeSubscribe.ExpireTime.Add(time.Duration(days) * 24 * time.Hour)
if activeSubscribe.ExpireTime.Before(now) {
activeSubscribe.ExpireTime = now.Add(time.Duration(days) * 24 * time.Hour)
} else {
activeSubscribe.ExpireTime = activeSubscribe.ExpireTime.Add(time.Duration(days) * 24 * time.Hour)
}
}
activeSubscribe.Status = 1
activeSubscribe.FinishedAt = nil
activeSubscribe.ExpiredDownload = 0
activeSubscribe.ExpiredUpload = 0
err = l.svc.UserModel.UpdateSubscribe(ctx, activeSubscribe)
if err != nil {
return err
@ -1242,6 +1251,30 @@ func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, da
})
}
func (l *ActivateOrderLogic) findGiftDaysSubscription(ctx context.Context, userID int64) (*user.Subscribe, error) {
activeSubscribe, err := l.svc.UserModel.FindActiveSubscribe(ctx, userID)
if err == nil {
return activeSubscribe, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var fallback user.Subscribe
err = l.svc.DB.WithContext(ctx).
Model(&user.Subscribe{}).
Where("user_id = ? AND token != ''", userID).
Where("status IN ?", []int64{0, 1, 2, 3}).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&fallback).Error
if err != nil {
return nil, err
}
return &fallback, nil
}
// shouldProcessCommission determines if commission should be processed based on
// referrer existence, commission settings, and order type
func (l *ActivateOrderLogic) shouldProcessCommission(userInfo *user.User, isFirstPurchase bool) bool {
@ -1356,7 +1389,7 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
return err
}
userSub, err := l.getUserSubscription(ctx, orderInfo.SubscribeToken)
userSub, err := l.resolveRenewalActivationSubscription(ctx, orderInfo)
if err != nil {
return err
}
@ -1378,7 +1411,7 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
}
// Trigger user group recalculation (needed when renewing an expired subscription)
l.triggerUserGroupRecalculation(ctx, userInfo.Id)
l.triggerUserGroupRecalculation(ctx, userSub.UserId)
// Clear user subscription cache
err = l.svc.UserModel.ClearSubscribeCache(ctx, userSub)
@ -1386,7 +1419,7 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
logger.WithContext(ctx).Error("Clear user subscribe cache failed",
logger.Field("error", err.Error()),
logger.Field("subscribe_id", userSub.Id),
logger.Field("user_id", userInfo.Id),
logger.Field("user_id", userSub.UserId),
)
}
@ -1408,6 +1441,75 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
return nil
}
func (l *ActivateOrderLogic) resolveRenewalActivationSubscription(ctx context.Context, orderInfo *order.Order) (*user.Subscribe, error) {
if orderInfo == nil {
return nil, fmt.Errorf("renewal activation order is nil")
}
userSub, err := l.getUserSubscription(ctx, orderInfo.SubscribeToken)
if err != nil {
return nil, err
}
if orderInfo.UserId <= 0 {
return userSub, nil
}
expectedUserID := orderInfo.SubscriptionUserId
entitlement, err := commonLogic.ResolveEntitlementUser(ctx, l.svc.DB, orderInfo.UserId)
if err != nil {
return nil, err
}
if entitlement != nil && entitlement.Source == commonLogic.EntitlementSourceFamilyOwner && entitlement.EffectiveUserID > 0 {
expectedUserID = entitlement.EffectiveUserID
}
if expectedUserID <= 0 {
if entitlement != nil && entitlement.EffectiveUserID > 0 {
expectedUserID = entitlement.EffectiveUserID
} else {
expectedUserID = orderInfo.UserId
}
}
if userSub.UserId == expectedUserID {
return userSub, nil
}
var target user.Subscribe
err = l.svc.DB.WithContext(ctx).
Model(&user.Subscribe{}).
Where("user_id = ? AND subscribe_id = ? AND token != ''", expectedUserID, orderInfo.SubscribeId).
Where("status IN ?", []int64{0, 1, 2, 3}).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&target).Error
if err != nil {
logger.WithContext(ctx).Error("Resolve renewal activation target failed",
logger.Field("error", err.Error()),
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("order_user_id", orderInfo.UserId),
logger.Field("expected_user_id", expectedUserID),
logger.Field("token_owner_user_id", userSub.UserId),
logger.Field("token_user_subscribe_id", userSub.Id),
logger.Field("subscribe_id", orderInfo.SubscribeId),
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("renewal activation target mismatch: expected user %d subscription for subscribe %d not found", expectedUserID, orderInfo.SubscribeId)
}
return nil, err
}
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "renewal_activation_target_redirected",
"[SubscriptionFlow] renewal activation redirected to entitlement owner subscription",
append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("expected_user_id", expectedUserID),
logger.Field("original_user_subscribe_id", userSub.Id),
logger.Field("original_owner_user_id", userSub.UserId),
logger.Field("resolved_user_subscribe_id", target.Id),
logger.Field("resolved_owner_user_id", target.UserId),
)...,
)
return &target, nil
}
// getUserSubscription retrieves user subscription by token
func (l *ActivateOrderLogic) getUserSubscription(ctx context.Context, token string) (*user.Subscribe, error) {
userSub, err := l.svc.UserModel.FindOneSubscribeByToken(ctx, token)