From 26f6400e74dd700a7faf5a07ac41fb9297b6221e Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 10 Mar 2026 19:53:19 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8B=B9=E6=9E=9C=E6=94=AF=E4=BB=98uui?= =?UTF-8?q?d=20=E5=8F=8A=E8=AE=BE=E5=A4=87=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apis/types.api | 6 +- .../public/user/unbindDeviceHandler.go | 5 +- internal/logic/public/order/purchaseLogic.go | 23 ++++-- internal/logic/public/order/renewalLogic.go | 10 ++- .../logic/public/user/deleteAccountLogic.go | 17 ++++ .../logic/public/user/unbindDeviceLogic.go | 79 ++++++++++++++++++- internal/model/order/order.go | 8 +- internal/model/user/family.go | 4 +- internal/types/types.go | 6 +- queue/logic/order/activateOrderLogic.go | 9 ++- 10 files changed, 142 insertions(+), 25 deletions(-) diff --git a/apis/types.api b/apis/types.api index 6543c3e..2c8a049 100644 --- a/apis/types.api +++ b/apis/types.api @@ -665,7 +665,8 @@ type ( FeeAmount int64 `json:"fee_amount"` } PurchaseOrderResponse { - OrderNo string `json:"order_no"` + OrderNo string `json:"order_no"` + AppAccountToken string `json:"app_account_token"` } RenewalOrderRequest { UserSubscribeID int64 `json:"user_subscribe_id"` @@ -674,7 +675,8 @@ type ( Coupon string `json:"coupon,omitempty"` } RenewalOrderResponse { - OrderNo string `json:"order_no"` + OrderNo string `json:"order_no"` + AppAccountToken string `json:"app_account_token"` } ResetTrafficOrderRequest { UserSubscribeID int64 `json:"user_subscribe_id"` diff --git a/internal/handler/public/user/unbindDeviceHandler.go b/internal/handler/public/user/unbindDeviceHandler.go index 9429d72..30314f5 100644 --- a/internal/handler/public/user/unbindDeviceHandler.go +++ b/internal/handler/public/user/unbindDeviceHandler.go @@ -12,7 +12,10 @@ import ( func UnbindDeviceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { var req types.UnbindDeviceRequest - _ = c.ShouldBind(&req) + if err := c.ShouldBindJSON(&req); err != nil { + result.ParamErrorResult(c, err) + return + } validateErr := svcCtx.Validate(&req) if validateErr != nil { result.ParamErrorResult(c, validateErr) diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index edce2b4..a8cb010 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -6,6 +6,8 @@ import ( "math" "time" + "github.com/google/uuid" + commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/pkg/constant" @@ -31,7 +33,7 @@ type PurchaseLogic struct { } const ( - CloseOrderTimeMinutes = 5 + CloseOrderTimeMinutes = 15 ) // NewPurchaseLogic creates a new purchase logic instance for subscription purchase operations. @@ -54,8 +56,10 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P logger.Error("current user is not found in context") return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") } - if err = commonLogic.DenyIfFamilyMemberReadonly(l.ctx, l.svcCtx.DB, u.Id); err != nil { - return nil, err + // Resolve entitlement: member's subscription goes to owner + entitlement, entErr := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, u.Id) + if entErr != nil { + return nil, entErr } if req.Quantity <= 0 { @@ -221,8 +225,9 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P } // create order orderInfo := &order.Order{ - UserId: u.Id, - ParentId: parentOrderID, + UserId: u.Id, + SubscriptionUserId: entitlement.EffectiveUserID, + ParentId: parentOrderID, OrderNo: tool.GenerateTradeNo(), Type: orderType, Quantity: req.Quantity, @@ -237,8 +242,9 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P FeeAmount: feeAmount, Status: 1, IsNew: isNew, - SubscribeId: targetSubscribeID, - SubscribeToken: subscribeToken, + SubscribeId: targetSubscribeID, + SubscribeToken: subscribeToken, + AppAccountToken: uuid.New().String(), } if isSingleModeRenewal { l.Infow("[Purchase] single mode purchase order created as renewal", @@ -368,6 +374,7 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P } return &types.PurchaseOrderResponse{ - OrderNo: orderInfo.OrderNo, + OrderNo: orderInfo.OrderNo, + AppAccountToken: orderInfo.AppAccountToken, }, nil } diff --git a/internal/logic/public/order/renewalLogic.go b/internal/logic/public/order/renewalLogic.go index 3643b7f..8f1c6d3 100644 --- a/internal/logic/public/order/renewalLogic.go +++ b/internal/logic/public/order/renewalLogic.go @@ -6,6 +6,8 @@ import ( "math" "time" + "github.com/google/uuid" + commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/pkg/constant" @@ -182,8 +184,9 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene Method: canonicalOrderMethod(payment.Platform), FeeAmount: feeAmount, Status: 1, - SubscribeId: userSubscribe.SubscribeId, - SubscribeToken: userSubscribe.Token, + SubscribeId: userSubscribe.SubscribeId, + SubscribeToken: userSubscribe.Token, + AppAccountToken: uuid.New().String(), } // Database transaction err = l.svcCtx.DB.Transaction(func(db *gorm.DB) error { @@ -239,6 +242,7 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene l.Infow("[Renewal] Enqueue task success", logger.Field("TaskID", taskInfo.ID)) } return &types.RenewalOrderResponse{ - OrderNo: orderInfo.OrderNo, + OrderNo: orderInfo.OrderNo, + AppAccountToken: orderInfo.AppAccountToken, }, nil } diff --git a/internal/logic/public/user/deleteAccountLogic.go b/internal/logic/public/user/deleteAccountLogic.go index 05581fb..b57b549 100644 --- a/internal/logic/public/user/deleteAccountLogic.go +++ b/internal/logic/public/user/deleteAccountLogic.go @@ -78,6 +78,23 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon l.clearAllSessions(currentUser.Id) + // Kick all affected family member devices + clear their sessions + for _, memberUserID := range affectedUserIDs { + if memberUserID == currentUser.Id { + continue + } + var memberDevices []user.Device + l.svcCtx.DB.WithContext(l.ctx). + Model(&user.Device{}). + Where("user_id = ?", memberUserID). + Find(&memberDevices) + + for _, d := range memberDevices { + l.svcCtx.DeviceManager.KickDevice(d.UserId, d.Identifier) + } + l.clearAllSessions(memberUserID) + } + // 主动清 auth method 相关缓存(含 email/mobile 等 key),避免缓存未命中时无法生成正确 key if len(authMethods) > 0 { var authCacheKeys []string diff --git a/internal/logic/public/user/unbindDeviceLogic.go b/internal/logic/public/user/unbindDeviceLogic.go index 48feae7..a286193 100644 --- a/internal/logic/public/user/unbindDeviceLogic.go +++ b/internal/logic/public/user/unbindDeviceLogic.go @@ -41,12 +41,29 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error { return errors.Wrapf(xerr.NewErrCode(xerr.DeviceNotExist), "find device") } - if device.UserId != userInfo.Id { - return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "device not belong to user") + isSelf := (device.UserId == userInfo.Id) + + if !isSelf { + // Not own device — check if in the same family + if !l.isInSameFamily(userInfo.Id, device.UserId) { + return errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "not in same family") + } + } + + targetUser, err := l.svcCtx.UserModel.FindOne(l.ctx, device.UserId) + if err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find target user") + } + + // If kicking another user (not self), check if subscription transfer is needed + if !isSelf { + if err := l.transferSubscriptionsIfNeeded(userInfo, targetUser); err != nil { + return err + } } currentSessionID, _ := l.ctx.Value(constant.CtxKeySessionID).(string) - return l.logoutUnbind(userInfo, device, currentSessionID) + return l.logoutUnbind(targetUser, device, currentSessionID) } func (l *UnbindDeviceLogic) logoutUnbind(userInfo *user.User, device *user.Device, currentSessionID string) error { @@ -177,3 +194,59 @@ func (l *UnbindDeviceLogic) clearAllSessions(userId int64) { logger.Field("count", len(sessions)), ) } + +// isInSameFamily checks whether two users belong to the same active family +func (l *UnbindDeviceLogic) isInSameFamily(userID1, userID2 int64) bool { + var relation1 struct{ FamilyId int64 } + err := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.UserFamilyMember{}). + Select("family_id"). + Where("user_id = ? AND status = ?", userID1, user.FamilyMemberActive). + First(&relation1).Error + if err != nil || relation1.FamilyId == 0 { + return false + } + + var count int64 + l.svcCtx.DB.WithContext(l.ctx). + Model(&user.UserFamilyMember{}). + Where("family_id = ? AND user_id = ? AND status = ?", + relation1.FamilyId, userID2, user.FamilyMemberActive). + Count(&count) + return count > 0 +} + +// transferSubscriptionsIfNeeded transfers subscriptions from the kicked user to the kicker +func (l *UnbindDeviceLogic) transferSubscriptionsIfNeeded(kicker *user.User, kicked *user.User) error { + // Query kicked user's subscriptions + var subscribes []user.Subscribe + if err := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", kicked.Id). + Find(&subscribes).Error; err != nil { + return nil // query error, skip transfer + } + if len(subscribes) == 0 { + return nil // no subscriptions to transfer + } + + // Transfer subscriptions: UPDATE user_subscribe SET user_id = kicker WHERE user_id = kicked + if err := l.svcCtx.DB.WithContext(l.ctx). + Model(&user.Subscribe{}). + Where("user_id = ?", kicked.Id). + Update("user_id", kicker.Id).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "transfer subscriptions failed") + } + + // Clear subscription caches for both users + for _, sub := range subscribes { + _ = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, &sub) + } + + l.Infow("subscriptions transferred", + logger.Field("from_user_id", kicked.Id), + logger.Field("to_user_id", kicker.Id), + logger.Field("count", len(subscribes)), + ) + return nil +} diff --git a/internal/model/order/order.go b/internal/model/order/order.go index 8e10b00..758cf1e 100644 --- a/internal/model/order/order.go +++ b/internal/model/order/order.go @@ -5,7 +5,8 @@ import "time" type Order struct { Id int64 `gorm:"primaryKey"` ParentId int64 `gorm:"type:bigint;default:null;comment:Parent Order Id"` - UserId int64 `gorm:"type:bigint;not null;default:0;comment:User Id"` + UserId int64 `gorm:"type:bigint;not null;default:0;comment:User Id"` + SubscriptionUserId int64 `gorm:"type:bigint;not null;default:0;comment:Target user ID for subscription (0=same as UserId)"` OrderNo string `gorm:"type:varchar(255);not null;default:'';unique;comment:Order No"` Type uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Type: 1: Subscribe, 2: Renewal, 3: ResetTraffic, 4: Recharge"` Quantity int64 `gorm:"type:bigint;not null;default:1;comment:Quantity"` @@ -22,8 +23,9 @@ type Order struct { TradeNo string `gorm:"type:varchar(255);default:null;comment:Trade No"` Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Order Status: 1: Pending, 2: Paid, 3:Close, 4: Failed, 5:Finished;"` SubscribeId int64 `gorm:"type:bigint;not null;default:0;comment:Subscribe Id"` - SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` - IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` + SubscribeToken string `gorm:"type:varchar(255);default:null;comment:Renewal Subscribe Token"` + AppAccountToken string `gorm:"type:varchar(36);default:null;comment:Apple IAP App Account Token (UUID)"` + IsNew bool `gorm:"type:tinyint(1);not null;default:0;comment:Is New Order"` CreatedAt time.Time `gorm:"<-:create;comment:Create Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` } diff --git a/internal/model/user/family.go b/internal/model/user/family.go index e5b752b..2c7f2eb 100644 --- a/internal/model/user/family.go +++ b/internal/model/user/family.go @@ -13,13 +13,13 @@ const ( FamilyMemberActive uint8 = 1 FamilyMemberLeft uint8 = 2 FamilyMemberRemoved uint8 = 3 - DefaultFamilyMaxSize int64 = 5 + DefaultFamilyMaxSize int64 = 2 ) type UserFamily struct { Id int64 `gorm:"primaryKey"` OwnerUserId int64 `gorm:"uniqueIndex:uniq_owner_user_id;not null;comment:Owner User ID"` - MaxMembers int64 `gorm:"not null;default:5;comment:Max members in family"` + MaxMembers int64 `gorm:"not null;default:2;comment:Max members in family"` Status uint8 `gorm:"type:tinyint(1);not null;default:1;comment:Status: 1=active, 0=disabled"` CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"` UpdatedAt time.Time `gorm:"comment:Update Time"` diff --git a/internal/types/types.go b/internal/types/types.go index 6dece23..d25cf79 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -1846,7 +1846,8 @@ type PurchaseOrderRequest struct { } type PurchaseOrderResponse struct { - OrderNo string `json:"order_no"` + OrderNo string `json:"order_no"` + AppAccountToken string `json:"app_account_token"` } type QueryAnnouncementRequest struct { @@ -2129,7 +2130,8 @@ type RenewalOrderRequest struct { } type RenewalOrderResponse struct { - OrderNo string `json:"order_no"` + OrderNo string `json:"order_no"` + AppAccountToken string `json:"app_account_token"` } type ResetAllSubscribeTokenResponse struct { diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index 0737d83..2ba039b 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -471,8 +471,15 @@ func (l *ActivateOrderLogic) getSubscribeInfo(ctx context.Context, subscribeId i // createUserSubscription creates a new user subscription record based on order and subscription plan details func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) { now := time.Now() + + // Determine subscription owner: use SubscriptionUserId if set, otherwise fall back to UserId + subscriptionUserId := orderInfo.UserId + if orderInfo.SubscriptionUserId > 0 { + subscriptionUserId = orderInfo.SubscriptionUserId + } + userSub := &user.Subscribe{ - UserId: orderInfo.UserId, + UserId: subscriptionUserId, OrderId: orderInfo.Id, SubscribeId: orderInfo.SubscribeId, StartTime: now,