From 4752f844ef6834746d0fdab1134bf44163f8f02e Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 4 Mar 2026 17:58:40 -0800 Subject: [PATCH] =?UTF-8?q?=E5=90=84=E7=A7=8D=E9=85=8D=E7=BD=AE=E9=A1=B9?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=8C=E4=BC=98=E5=8C=96=E5=88=B0=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=AE=A1=E7=90=86=E7=AB=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apis/admin/user.api | 55 ++++++- apis/types.api | 37 ++++- doc/cache-convention-zh.md | 58 +++++++ .../admin/user/deleteUserSubscribeHandler.go | 5 +- .../admin/user/dissolveFamilyHandler.go | 26 ++++ .../admin/user/getFamilyDetailHandler.go | 26 ++++ .../admin/user/getFamilyListHandler.go | 26 ++++ .../admin/user/removeFamilyMemberHandler.go | 26 ++++ .../user/updateFamilyMaxMembersHandler.go | 26 ++++ internal/handler/routes.go | 15 ++ .../admin/user/deleteUserSubscribeLogic.go | 10 +- .../logic/admin/user/dissolveFamilyLogic.go | 68 ++++++++ internal/logic/admin/user/familyCommon.go | 99 ++++++++++++ .../logic/admin/user/getFamilyDetailLogic.go | 106 +++++++++++++ .../logic/admin/user/getFamilyListLogic.go | 147 ++++++++++++++++++ .../logic/admin/user/getUserDetailLogic.go | 47 ++++++ internal/logic/admin/user/getUserListLogic.go | 101 +++++++++++- .../admin/user/removeFamilyMemberLogic.go | 71 +++++++++ .../admin/user/updateFamilyMaxMembersLogic.go | 60 +++++++ internal/logic/common/subscribeModeRoute.go | 63 ++++++++ .../logic/common/subscribeModeRoute_test.go | 82 ++++++++++ .../logic/public/order/preCreateOrderLogic.go | 34 ++-- internal/logic/public/order/purchaseLogic.go | 40 +++-- internal/logic/public/portal/purchaseLogic.go | 3 + internal/model/user/cache.go | 1 + internal/model/user/model.go | 84 ++++++++-- internal/model/user/model_ext.go | 66 +++++++- internal/model/user/subscribe.go | 69 +++----- internal/types/types.go | 96 ++++++++++-- pkg/xerr/errCode.go | 35 +++-- pkg/xerr/errMsg.go | 33 ++-- 31 files changed, 1467 insertions(+), 148 deletions(-) create mode 100644 doc/cache-convention-zh.md create mode 100644 internal/handler/admin/user/dissolveFamilyHandler.go create mode 100644 internal/handler/admin/user/getFamilyDetailHandler.go create mode 100644 internal/handler/admin/user/getFamilyListHandler.go create mode 100644 internal/handler/admin/user/removeFamilyMemberHandler.go create mode 100644 internal/handler/admin/user/updateFamilyMaxMembersHandler.go create mode 100644 internal/logic/admin/user/dissolveFamilyLogic.go create mode 100644 internal/logic/admin/user/familyCommon.go create mode 100644 internal/logic/admin/user/getFamilyDetailLogic.go create mode 100644 internal/logic/admin/user/getFamilyListLogic.go create mode 100644 internal/logic/admin/user/removeFamilyMemberLogic.go create mode 100644 internal/logic/admin/user/updateFamilyMaxMembersLogic.go create mode 100644 internal/logic/common/subscribeModeRoute.go create mode 100644 internal/logic/common/subscribeModeRoute_test.go diff --git a/apis/admin/user.api b/apis/admin/user.api index 1b69b37..54ebbdf 100644 --- a/apis/admin/user.api +++ b/apis/admin/user.api @@ -23,6 +23,10 @@ type ( SubscribeId *int64 `form:"subscribe_id,omitempty"` UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` ShortCode string `form:"short_code,omitempty"` + FamilyJoined *bool `form:"family_joined,omitempty"` + FamilyStatus string `form:"family_status,omitempty"` + FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"` + FamilyId *int64 `form:"family_id,omitempty"` } // GetUserListResponse GetUserListResponse { @@ -181,7 +185,7 @@ type ( Total int64 `json:"total"` } DeleteUserSubscribeRequest { - UserSubscribeId int64 `json:"user_subscribe_id,string"` + UserSubscribeId int64 `json:"user_subscribe_id" validate:"required,gt=0"` } GetUserSubscribeByIdRequest { Id int64 `form:"id" validate:"required"` @@ -192,6 +196,35 @@ type ( ResetUserSubscribeTrafficRequest { UserSubscribeId int64 `json:"user_subscribe_id"` } + GetFamilyListRequest { + Page int `form:"page"` + Size int `form:"size"` + Keyword string `form:"keyword,omitempty"` + Status string `form:"status,omitempty"` + OwnerUserId *int64 `form:"owner_user_id,omitempty"` + FamilyId *int64 `form:"family_id,omitempty"` + UserId *int64 `form:"user_id,omitempty"` + } + GetFamilyListResponse { + List []FamilySummary `json:"list"` + Total int64 `json:"total"` + } + GetFamilyDetailRequest { + Id int64 `form:"id" validate:"required"` + } + UpdateFamilyMaxMembersRequest { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + MaxMembers int64 `json:"max_members" validate:"required,gt=0"` + } + RemoveFamilyMemberRequest { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + UserId int64 `json:"user_id" validate:"required,gt=0"` + Reason string `json:"reason,omitempty"` + } + DissolveFamilyRequest { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + Reason string `json:"reason,omitempty"` + } ) @server ( @@ -312,4 +345,24 @@ service ppanel { @doc "Reset user subscribe traffic" @handler ResetUserSubscribeTraffic post /subscribe/reset/traffic (ResetUserSubscribeTrafficRequest) + + @doc "Get family list" + @handler GetFamilyList + get /family/list (GetFamilyListRequest) returns (GetFamilyListResponse) + + @doc "Get family detail" + @handler GetFamilyDetail + get /family/detail (GetFamilyDetailRequest) returns (FamilyDetail) + + @doc "Update family max members" + @handler UpdateFamilyMaxMembers + put /family/max_members (UpdateFamilyMaxMembersRequest) + + @doc "Remove family member" + @handler RemoveFamilyMember + put /family/member/remove (RemoveFamilyMemberRequest) + + @doc "Dissolve family" + @handler DissolveFamily + put /family/dissolve (DissolveFamilyRequest) } diff --git a/apis/types.api b/apis/types.api index 3b52c4a..0a3552d 100644 --- a/apis/types.api +++ b/apis/types.api @@ -33,6 +33,16 @@ type ( UpdatedAt int64 `json:"updated_at"` DeletedAt int64 `json:"deleted_at,omitempty"` IsDel bool `json:"is_del,omitempty"` + Remark string `json:"remark,omitempty"` + PurchasedPackage string `json:"purchased_package,omitempty"` + FamilyJoined bool `json:"family_joined,omitempty"` + FamilyId int64 `json:"family_id,omitempty"` + FamilyRole uint8 `json:"family_role,omitempty"` + FamilyRoleName string `json:"family_role_name,omitempty"` + FamilyOwnerUserId int64 `json:"family_owner_user_id,omitempty"` + FamilyStatus string `json:"family_status,omitempty"` + FamilyMemberCount int64 `json:"family_member_count,omitempty"` + FamilyMaxMembers int64 `json:"family_max_members,omitempty"` } Follow { Id int64 `json:"id"` @@ -187,6 +197,7 @@ type ( ForcedInvite bool `json:"forced_invite"` ReferralPercentage int64 `json:"referral_percentage"` OnlyFirstPurchase bool `json:"only_first_purchase"` + GiftDays int64 `json:"gift_days"` } TelegramConfig { TelegramBotToken string `json:"telegram_bot_token"` @@ -530,6 +541,31 @@ type ( CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } + FamilySummary { + FamilyId int64 `json:"family_id"` + OwnerUserId int64 `json:"owner_user_id"` + OwnerIdentifier string `json:"owner_identifier"` + Status string `json:"status"` + ActiveMemberCount int64 `json:"active_member_count"` + MaxMembers int64 `json:"max_members"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } + FamilyMemberItem { + UserId int64 `json:"user_id"` + Identifier string `json:"identifier"` + Role uint8 `json:"role"` + RoleName string `json:"role_name"` + Status uint8 `json:"status"` + StatusName string `json:"status_name"` + JoinSource string `json:"join_source"` + JoinedAt int64 `json:"joined_at"` + LeftAt int64 `json:"left_at,omitempty"` + } + FamilyDetail { + Summary FamilySummary `json:"summary"` + Members []FamilyMemberItem `json:"members"` + } UserAuthMethod { AuthType string `json:"auth_type"` AuthIdentifier string `json:"auth_identifier"` @@ -876,4 +912,3 @@ type ( UserSubscribeId int64 `json:"user_subscribe_id"` } ) - diff --git a/doc/cache-convention-zh.md b/doc/cache-convention-zh.md new file mode 100644 index 0000000..1662928 --- /dev/null +++ b/doc/cache-convention-zh.md @@ -0,0 +1,58 @@ +# GORM + Redis 缓存调用规范 + +本文档约定了项目内 GORM 与 Redis 缓存的推荐调用方式,目标是避免“有的地方自动清缓存,有的地方手动清缓存”的分散写法。 + +## 统一入口 + +项目缓存统一由 `pkg/cache/CachedConn` 提供,常用方法: + +- `QueryCtx`:读缓存,未命中回源 DB 并写缓存 +- `ExecCtx`:写 DB,成功后删除指定缓存 key +- `QueryNoCacheCtx`:只查 DB,不读写缓存 +- `ExecNoCacheCtx`:只写 DB,不自动清缓存 +- `TransactCtx`:事务执行 + +## 推荐使用规则 + +### 1) 主键/唯一键查询(有稳定 key) + +优先使用 `QueryCtx`,必须显式构建缓存 key。 + +### 2) 列表/复杂筛选查询 + +仅当 key 规则稳定时用 `QueryCtx`;否则用 `QueryNoCacheCtx`。 + +### 3) 写操作(新增/更新/删除) + +优先使用“统一 helper”封装为一条路径: + +1. 执行 DB 写入 +2. 按模型计算 key +3. 清理关联缓存 + +避免在业务逻辑层分散调用 `DelCache`。 + +## user_subscribe 规范落地 + +`internal/model/user/subscribe.go` 已提供统一 helper: + +- `execSubscribeMutation(...)` + +`InsertSubscribe / UpdateSubscribe / DeleteSubscribe / DeleteSubscribeById` 统一通过该 helper 执行,避免每个方法重复写“ExecNoCacheCtx + defer 清缓存”。 + +## key 生成约定 + +模型缓存 key 统一在模型侧定义,不在 handler/logic 手写: + +- `internal/model/user/cache.go` + - `(*Subscribe).GetCacheKeys()` + - `ClearSubscribeCacheByModels(...)` + +> 说明:`user_subscribe` 当前会同时清理普通列表 key 与 `:all` 列表 key,避免删改后列表残留旧缓存。 + +## 新增模型时的最小模板 + +1. 在 model 中定义 `getCacheKeys(...)` 或 `GetCacheKeys()` +2. 查询方法优先 `QueryCtx` +3. 写方法统一走 helper(DB 写 + 缓存失效) +4. 避免在 handler/logic 直接操作缓存 key diff --git a/internal/handler/admin/user/deleteUserSubscribeHandler.go b/internal/handler/admin/user/deleteUserSubscribeHandler.go index febcea0..8958992 100644 --- a/internal/handler/admin/user/deleteUserSubscribeHandler.go +++ b/internal/handler/admin/user/deleteUserSubscribeHandler.go @@ -12,7 +12,10 @@ import ( func DeleteUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { return func(c *gin.Context) { var req types.DeleteUserSubscribeRequest - _ = c.ShouldBind(&req) + if err := c.ShouldBind(&req); err != nil { + result.ParamErrorResult(c, err) + return + } validateErr := svcCtx.Validate(&req) if validateErr != nil { result.ParamErrorResult(c, validateErr) diff --git a/internal/handler/admin/user/dissolveFamilyHandler.go b/internal/handler/admin/user/dissolveFamilyHandler.go new file mode 100644 index 0000000..1e291b6 --- /dev/null +++ b/internal/handler/admin/user/dissolveFamilyHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Dissolve family +func DissolveFamilyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.DissolveFamilyRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewDissolveFamilyLogic(c.Request.Context(), svcCtx) + err := l.DissolveFamily(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/user/getFamilyDetailHandler.go b/internal/handler/admin/user/getFamilyDetailHandler.go new file mode 100644 index 0000000..c2362a5 --- /dev/null +++ b/internal/handler/admin/user/getFamilyDetailHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get family detail +func GetFamilyDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetFamilyDetailRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewGetFamilyDetailLogic(c.Request.Context(), svcCtx) + resp, err := l.GetFamilyDetail(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/user/getFamilyListHandler.go b/internal/handler/admin/user/getFamilyListHandler.go new file mode 100644 index 0000000..0ca5090 --- /dev/null +++ b/internal/handler/admin/user/getFamilyListHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Get family list +func GetFamilyListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.GetFamilyListRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewGetFamilyListLogic(c.Request.Context(), svcCtx) + resp, err := l.GetFamilyList(&req) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/admin/user/removeFamilyMemberHandler.go b/internal/handler/admin/user/removeFamilyMemberHandler.go new file mode 100644 index 0000000..c03fb22 --- /dev/null +++ b/internal/handler/admin/user/removeFamilyMemberHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Remove family member +func RemoveFamilyMemberHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.RemoveFamilyMemberRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewRemoveFamilyMemberLogic(c.Request.Context(), svcCtx) + err := l.RemoveFamilyMember(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/admin/user/updateFamilyMaxMembersHandler.go b/internal/handler/admin/user/updateFamilyMaxMembersHandler.go new file mode 100644 index 0000000..aa26cf2 --- /dev/null +++ b/internal/handler/admin/user/updateFamilyMaxMembersHandler.go @@ -0,0 +1,26 @@ +package user + +import ( + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/admin/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Update family max members +func UpdateFamilyMaxMembersHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.UpdateFamilyMaxMembersRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + l := user.NewUpdateFamilyMaxMembersLogic(c.Request.Context(), svcCtx) + err := l.UpdateFamilyMaxMembers(&req) + result.HttpResult(c, nil, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 16c9250..27b7248 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -623,6 +623,21 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get user subcribe traffic logs adminUserGroupRouter.GET("/subscribe/traffic_logs", adminUser.GetUserSubscribeTrafficLogsHandler(serverCtx)) + + // Get family list + adminUserGroupRouter.GET("/family/list", adminUser.GetFamilyListHandler(serverCtx)) + + // Get family detail + adminUserGroupRouter.GET("/family/detail", adminUser.GetFamilyDetailHandler(serverCtx)) + + // Update family max members + adminUserGroupRouter.PUT("/family/max_members", adminUser.UpdateFamilyMaxMembersHandler(serverCtx)) + + // Remove family member + adminUserGroupRouter.PUT("/family/member/remove", adminUser.RemoveFamilyMemberHandler(serverCtx)) + + // Dissolve family + adminUserGroupRouter.PUT("/family/dissolve", adminUser.DissolveFamilyHandler(serverCtx)) } authGroupRouter := router.Group("/v1/auth") diff --git a/internal/logic/admin/user/deleteUserSubscribeLogic.go b/internal/logic/admin/user/deleteUserSubscribeLogic.go index 209c23e..190052b 100644 --- a/internal/logic/admin/user/deleteUserSubscribeLogic.go +++ b/internal/logic/admin/user/deleteUserSubscribeLogic.go @@ -3,11 +3,13 @@ package user import ( "context" + userModel "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/xerr" "github.com/pkg/errors" + "gorm.io/gorm" ) type DeleteUserSubscribeLogic struct { @@ -27,13 +29,17 @@ func NewDeleteUserSubscribeLogic(ctx context.Context, svcCtx *svc.ServiceContext func (l *DeleteUserSubscribeLogic) DeleteUserSubscribe(req *types.DeleteUserSubscribeRequest) error { // find user subscribe by ID - userSubscribe, err := l.svcCtx.UserModel.FindOneSubscribe(l.ctx, req.UserSubscribeId) + userSubscribe := &userModel.Subscribe{} + err := l.svcCtx.DB.WithContext(l.ctx).Model(&userModel.Subscribe{}).Where("id = ?", req.UserSubscribeId).First(userSubscribe).Error if err != nil { l.Errorw("failed to find user subscribe", logger.Field("error", err.Error()), logger.Field("userSubscribeId", req.UserSubscribeId)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InvalidParams, "user subscribe not found"), "failed to find user subscribe: %v", err.Error()) + } return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "failed to find user subscribe: %v", err.Error()) } - err = l.svcCtx.UserModel.DeleteSubscribeById(l.ctx, req.UserSubscribeId) + err = l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.UserSubscribeId).Delete(&userModel.Subscribe{}).Error if err != nil { l.Errorw("failed to delete user subscribe", logger.Field("error", err.Error()), logger.Field("userSubscribeId", req.UserSubscribeId)) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "failed to delete user subscribe: %v", err.Error()) diff --git a/internal/logic/admin/user/dissolveFamilyLogic.go b/internal/logic/admin/user/dissolveFamilyLogic.go new file mode 100644 index 0000000..3abcc15 --- /dev/null +++ b/internal/logic/admin/user/dissolveFamilyLogic.go @@ -0,0 +1,68 @@ +package user + +import ( + "context" + "time" + + modelUser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type DissolveFamilyLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logger.Logger +} + +func NewDissolveFamilyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DissolveFamilyLogic { + return &DissolveFamilyLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logger.WithContext(ctx), + } +} + +func (l *DissolveFamilyLogic) DissolveFamily(req *types.DissolveFamilyRequest) error { + var family modelUser.UserFamily + err := l.svcCtx.DB.WithContext(l.ctx). + Where("id = ? AND deleted_at IS NULL", req.FamilyId). + First(&family).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyNotExist), "family does not exist") + } + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family failed") + } + if family.Status != modelUser.FamilyStatusActive { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyStatusInvalid), "family status is invalid") + } + + now := time.Now() + transactionErr := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { + if err = tx.Model(&modelUser.UserFamilyMember{}). + Where("family_id = ? AND deleted_at IS NULL AND status = ?", req.FamilyId, modelUser.FamilyMemberActive). + Updates(map[string]interface{}{ + "status": modelUser.FamilyMemberRemoved, + "left_at": now, + }).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "remove family members failed") + } + + if err = tx.Model(&modelUser.UserFamily{}). + Where("id = ?", req.FamilyId). + Update("status", 0).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "disable family failed") + } + return nil + }) + if transactionErr != nil { + return transactionErr + } + + return nil +} diff --git a/internal/logic/admin/user/familyCommon.go b/internal/logic/admin/user/familyCommon.go new file mode 100644 index 0000000..9e34456 --- /dev/null +++ b/internal/logic/admin/user/familyCommon.go @@ -0,0 +1,99 @@ +package user + +import ( + "context" + "fmt" + "strings" + + "github.com/perfect-panel/server/internal/model/user" + "gorm.io/gorm" +) + +func mapFamilyStatus(status uint8) string { + if status == user.FamilyStatusActive { + return "active" + } + return "disabled" +} + +func mapFamilyRoleName(role uint8) string { + switch role { + case user.FamilyRoleOwner: + return "owner" + case user.FamilyRoleMember: + return "member" + default: + return fmt.Sprintf("role_%d", role) + } +} + +func mapFamilyMemberStatusName(status uint8) string { + switch status { + case user.FamilyMemberActive: + return "active" + case user.FamilyMemberLeft: + return "left" + case user.FamilyMemberRemoved: + return "removed" + default: + return fmt.Sprintf("status_%d", status) + } +} + +func normalizeFamilyStatusInput(status string) (uint8, bool) { + switch strings.ToLower(strings.TrimSpace(status)) { + case "", "all": + return 0, false + case "active", "1": + return user.FamilyStatusActive, true + case "disabled", "0": + return 0, true + default: + return 0, false + } +} + +func findUserIdentifiers(ctx context.Context, db *gorm.DB, userIDs []int64) (map[int64]string, error) { + identifierMap := make(map[int64]string) + if len(userIDs) == 0 { + return identifierMap, nil + } + + type authRow struct { + UserId int64 + AuthType string + AuthIdentifier string + } + + var rows []authRow + err := db.WithContext(ctx). + Table("user_auth_methods"). + Select("user_id, auth_type, auth_identifier"). + Where("user_id IN ? AND deleted_at IS NULL", userIDs). + Scan(&rows).Error + if err != nil { + return nil, err + } + + priority := map[string]int{ + "email": 1, + "mobile": 2, + "telegram": 3, + "device": 4, + } + selectedPriority := make(map[int64]int, len(userIDs)) + + for _, row := range rows { + currentPriority := priority[row.AuthType] + if currentPriority == 0 { + currentPriority = 100 + } + if previous, exists := selectedPriority[row.UserId]; exists && previous <= currentPriority { + continue + } + selectedPriority[row.UserId] = currentPriority + identifierMap[row.UserId] = row.AuthIdentifier + } + + return identifierMap, nil +} diff --git a/internal/logic/admin/user/getFamilyDetailLogic.go b/internal/logic/admin/user/getFamilyDetailLogic.go new file mode 100644 index 0000000..0c08bd7 --- /dev/null +++ b/internal/logic/admin/user/getFamilyDetailLogic.go @@ -0,0 +1,106 @@ +package user + +import ( + "context" + "strconv" + + modelUser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type GetFamilyDetailLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logger.Logger +} + +func NewGetFamilyDetailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFamilyDetailLogic { + return &GetFamilyDetailLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logger.WithContext(ctx), + } +} + +func (l *GetFamilyDetailLogic) GetFamilyDetail(req *types.GetFamilyDetailRequest) (*types.FamilyDetail, error) { + var family modelUser.UserFamily + err := l.svcCtx.DB.WithContext(l.ctx). + Where("id = ? AND deleted_at IS NULL", req.Id). + First(&family).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.FamilyNotExist), "family does not exist") + } + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family detail failed") + } + + var members []modelUser.UserFamilyMember + if err = l.svcCtx.DB.WithContext(l.ctx). + Where("family_id = ? AND deleted_at IS NULL", family.Id). + Order("joined_at DESC"). + Find(&members).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family members failed") + } + + userIDs := make([]int64, 0, len(members)+1) + userIDs = append(userIDs, family.OwnerUserId) + activeMemberCount := int64(0) + for _, member := range members { + userIDs = append(userIDs, member.UserId) + if member.Status == modelUser.FamilyMemberActive { + activeMemberCount++ + } + } + + identifierMap, identifierErr := findUserIdentifiers(l.ctx, l.svcCtx.DB, userIDs) + if identifierErr != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family member identifiers failed") + } + + memberItems := make([]types.FamilyMemberItem, 0, len(members)) + for _, member := range members { + identifier := identifierMap[member.UserId] + if identifier == "" { + identifier = strconv.FormatInt(member.UserId, 10) + } + + memberItem := types.FamilyMemberItem{ + UserId: member.UserId, + Identifier: identifier, + Role: member.Role, + RoleName: mapFamilyRoleName(member.Role), + Status: member.Status, + StatusName: mapFamilyMemberStatusName(member.Status), + JoinSource: member.JoinSource, + JoinedAt: member.JoinedAt.Unix(), + } + if member.LeftAt != nil { + memberItem.LeftAt = member.LeftAt.Unix() + } + memberItems = append(memberItems, memberItem) + } + + ownerIdentifier := identifierMap[family.OwnerUserId] + if ownerIdentifier == "" { + ownerIdentifier = strconv.FormatInt(family.OwnerUserId, 10) + } + + return &types.FamilyDetail{ + Summary: types.FamilySummary{ + FamilyId: family.Id, + OwnerUserId: family.OwnerUserId, + OwnerIdentifier: ownerIdentifier, + Status: mapFamilyStatus(family.Status), + ActiveMemberCount: activeMemberCount, + MaxMembers: family.MaxMembers, + CreatedAt: family.CreatedAt.Unix(), + UpdatedAt: family.UpdatedAt.Unix(), + }, + Members: memberItems, + }, nil +} diff --git a/internal/logic/admin/user/getFamilyListLogic.go b/internal/logic/admin/user/getFamilyListLogic.go new file mode 100644 index 0000000..741edcb --- /dev/null +++ b/internal/logic/admin/user/getFamilyListLogic.go @@ -0,0 +1,147 @@ +package user + +import ( + "context" + "strconv" + "strings" + + modelUser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type GetFamilyListLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logger.Logger +} + +func NewGetFamilyListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetFamilyListLogic { + return &GetFamilyListLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logger.WithContext(ctx), + } +} + +func (l *GetFamilyListLogic) GetFamilyList(req *types.GetFamilyListRequest) (*types.GetFamilyListResponse, error) { + page := req.Page + size := req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + if size > 200 { + size = 200 + } + + query := l.svcCtx.DB.WithContext(l.ctx). + Model(&modelUser.UserFamily{}). + Where("user_family.deleted_at IS NULL") + + if req.OwnerUserId != nil { + query = query.Where("user_family.owner_user_id = ?", *req.OwnerUserId) + } + if req.FamilyId != nil { + query = query.Where("user_family.id = ?", *req.FamilyId) + } + if req.UserId != nil { + query = query.Where( + "EXISTS (SELECT 1 FROM user_family_member ufm WHERE ufm.family_id = user_family.id AND ufm.deleted_at IS NULL AND ufm.status = ? AND ufm.user_id = ?)", + modelUser.FamilyMemberActive, *req.UserId, + ) + } + if statusValue, ok := normalizeFamilyStatusInput(req.Status); ok { + query = query.Where("user_family.status = ?", statusValue) + } + + keyword := strings.TrimSpace(req.Keyword) + if keyword != "" { + keywordLike := "%" + keyword + "%" + query = query.Where( + "(CAST(user_family.id AS CHAR) LIKE ? OR CAST(user_family.owner_user_id AS CHAR) LIKE ? OR "+ + "EXISTS (SELECT 1 FROM user_auth_methods owner_auth WHERE owner_auth.user_id = user_family.owner_user_id AND owner_auth.deleted_at IS NULL AND owner_auth.auth_identifier LIKE ?) OR "+ + "EXISTS (SELECT 1 FROM user_family_member keyword_member JOIN user_auth_methods keyword_auth ON keyword_auth.user_id = keyword_member.user_id AND keyword_auth.deleted_at IS NULL WHERE keyword_member.family_id = user_family.id AND keyword_member.deleted_at IS NULL AND keyword_auth.auth_identifier LIKE ?))", + keywordLike, keywordLike, keywordLike, keywordLike, + ) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count family list failed") + } + + var families []modelUser.UserFamily + if err := query.Order("user_family.id DESC"). + Limit(size). + Offset((page - 1) * size). + Find(&families).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family list failed") + } + + if len(families) == 0 { + return &types.GetFamilyListResponse{ + Total: total, + List: []types.FamilySummary{}, + }, nil + } + + ownerIDs := make([]int64, 0, len(families)) + familyIDs := make([]int64, 0, len(families)) + for _, family := range families { + ownerIDs = append(ownerIDs, family.OwnerUserId) + familyIDs = append(familyIDs, family.Id) + } + + identifierMap, identifierErr := findUserIdentifiers(l.ctx, l.svcCtx.DB, ownerIDs) + if identifierErr != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family owner identifiers failed") + } + + type familyCount struct { + FamilyId int64 + Count int64 + } + var counts []familyCount + if err := l.svcCtx.DB.WithContext(l.ctx). + Table("user_family_member"). + Select("family_id, COUNT(1) as count"). + Where("family_id IN ? AND deleted_at IS NULL AND status = ?", familyIDs, modelUser.FamilyMemberActive). + Group("family_id"). + Scan(&counts).Error; err != nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family member counts failed") + } + countMap := make(map[int64]int64, len(counts)) + for _, count := range counts { + countMap[count.FamilyId] = count.Count + } + + list := make([]types.FamilySummary, 0, len(families)) + for _, family := range families { + ownerIdentifier := identifierMap[family.OwnerUserId] + if ownerIdentifier == "" { + ownerIdentifier = strconv.FormatInt(family.OwnerUserId, 10) + } + + list = append(list, types.FamilySummary{ + FamilyId: family.Id, + OwnerUserId: family.OwnerUserId, + OwnerIdentifier: ownerIdentifier, + Status: mapFamilyStatus(family.Status), + ActiveMemberCount: countMap[family.Id], + MaxMembers: family.MaxMembers, + CreatedAt: family.CreatedAt.Unix(), + UpdatedAt: family.UpdatedAt.Unix(), + }) + } + + return &types.GetFamilyListResponse{ + Total: total, + List: list, + }, nil +} diff --git a/internal/logic/admin/user/getUserDetailLogic.go b/internal/logic/admin/user/getUserDetailLogic.go index 11597d5..c95e6af 100644 --- a/internal/logic/admin/user/getUserDetailLogic.go +++ b/internal/logic/admin/user/getUserDetailLogic.go @@ -3,6 +3,7 @@ package user import ( "context" + "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/pkg/logger" @@ -32,5 +33,51 @@ func (l *GetUserDetailLogic) GetUserDetail(req *types.GetDetailRequest) (*types. return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get user detail error: %v", err.Error()) } tool.DeepCopy(&resp, userInfo) + + type familyRelation struct { + FamilyId int64 + Role uint8 + FamilyStatus uint8 + OwnerUserId int64 + MaxMembers int64 + } + + var relation familyRelation + relationErr := l.svcCtx.DB.WithContext(l.ctx). + Table("user_family_member"). + Select("user_family_member.family_id, user_family_member.role, user_family.status as family_status, user_family.owner_user_id, user_family.max_members"). + Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL"). + Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", req.Id, user.FamilyMemberActive). + First(&relation).Error + if relationErr == nil { + resp.FamilyJoined = true + resp.FamilyId = relation.FamilyId + resp.FamilyRole = relation.Role + resp.FamilyOwnerUserId = relation.OwnerUserId + resp.FamilyMaxMembers = relation.MaxMembers + if relation.FamilyStatus == user.FamilyStatusActive { + resp.FamilyStatus = "active" + } else { + resp.FamilyStatus = "disabled" + } + if relation.Role == user.FamilyRoleOwner { + resp.FamilyRoleName = "owner" + } else if relation.Role == user.FamilyRoleMember { + resp.FamilyRoleName = "member" + } + + type familyCount struct { + Count int64 + } + var count familyCount + if countErr := l.svcCtx.DB.WithContext(l.ctx). + Table("user_family_member"). + Select("COUNT(1) as count"). + Where("family_id = ? AND status = ? AND deleted_at IS NULL", relation.FamilyId, user.FamilyMemberActive). + Scan(&count).Error; countErr == nil { + resp.FamilyMemberCount = count.Count + } + } + return &resp, nil } diff --git a/internal/logic/admin/user/getUserListLogic.go b/internal/logic/admin/user/getUserListLogic.go index 49b27b3..ee6cf68 100644 --- a/internal/logic/admin/user/getUserListLogic.go +++ b/internal/logic/admin/user/getUserListLogic.go @@ -2,6 +2,7 @@ package user import ( "context" + "fmt" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -28,13 +29,17 @@ func NewGetUserListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUs } func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.GetUserListResponse, error) { list, total, err := l.svcCtx.UserModel.QueryPageList(l.ctx, req.Page, req.Size, &user.UserFilterParams{ - UserId: req.UserId, - Search: req.Search, - Unscoped: req.Unscoped, - SubscribeId: req.SubscribeId, - UserSubscribeId: req.UserSubscribeId, - ShortCode: req.ShortCode, - Order: "DESC", + UserId: req.UserId, + Search: req.Search, + Unscoped: req.Unscoped, + SubscribeId: req.SubscribeId, + UserSubscribeId: req.UserSubscribeId, + ShortCode: req.ShortCode, + FamilyJoined: req.FamilyJoined, + FamilyStatus: req.FamilyStatus, + FamilyOwnerUserId: req.FamilyOwnerUserId, + FamilyId: req.FamilyId, + Order: "DESC", }) if err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetUserListLogic failed: %v", err.Error()) @@ -51,6 +56,64 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge l.Logger.Error("FindActiveSubscribesByUserIds failed", logger.Field("error", err.Error())) } + type familyRelation struct { + UserId int64 + FamilyId int64 + Role uint8 + FamilyStatus uint8 + OwnerUserId int64 + MaxMembers int64 + } + + relationMap := map[int64]familyRelation{} + familyIds := make([]int64, 0) + if len(userIds) > 0 { + var relations []familyRelation + relationErr := l.svcCtx.DB.WithContext(l.ctx). + Table("user_family_member"). + Select("user_family_member.user_id, user_family_member.family_id, user_family_member.role, user_family.status as family_status, user_family.owner_user_id, user_family.max_members"). + Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL"). + Where("user_family_member.user_id IN ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userIds, user.FamilyMemberActive). + Scan(&relations).Error + if relationErr != nil { + l.Logger.Error("query family relations failed", logger.Field("error", relationErr.Error())) + } + + familyIdSet := make(map[int64]struct{}, len(relations)) + for _, relation := range relations { + if _, exists := relationMap[relation.UserId]; !exists { + relationMap[relation.UserId] = relation + } + if _, ok := familyIdSet[relation.FamilyId]; ok { + continue + } + familyIdSet[relation.FamilyId] = struct{}{} + familyIds = append(familyIds, relation.FamilyId) + } + } + + type familyCount struct { + FamilyId int64 + Count int64 + } + + familyCountMap := map[int64]int64{} + if len(familyIds) > 0 { + var counts []familyCount + countErr := l.svcCtx.DB.WithContext(l.ctx). + Table("user_family_member"). + Select("family_id, COUNT(1) as count"). + Where("family_id IN ? AND status = ? AND deleted_at IS NULL", familyIds, user.FamilyMemberActive). + Group("family_id"). + Scan(&counts).Error + if countErr != nil { + l.Logger.Error("query family member count failed", logger.Field("error", countErr.Error())) + } + for _, count := range counts { + familyCountMap[count.FamilyId] = count.Count + } + } + userRespList := make([]types.User, 0, len(list)) for _, item := range list { @@ -66,6 +129,7 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge if activeSubs != nil { if info, ok := activeSubs[item.Id]; ok { u.MemberStatus = info.MemberStatus + u.PurchasedPackage = info.PurchasedPackage if info.LastTrafficAt != nil { trafficTime := info.LastTrafficAt.Unix() if trafficTime > u.LastLoginTime { @@ -85,6 +149,29 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge } u.AuthMethods = authMethods + if relation, ok := relationMap[item.Id]; ok { + u.FamilyJoined = true + u.FamilyId = relation.FamilyId + u.FamilyRole = relation.Role + u.FamilyOwnerUserId = relation.OwnerUserId + u.FamilyMaxMembers = relation.MaxMembers + u.FamilyMemberCount = familyCountMap[relation.FamilyId] + switch relation.FamilyStatus { + case user.FamilyStatusActive: + u.FamilyStatus = "active" + default: + u.FamilyStatus = "disabled" + } + switch relation.Role { + case user.FamilyRoleOwner: + u.FamilyRoleName = "owner" + case user.FamilyRoleMember: + u.FamilyRoleName = "member" + default: + u.FamilyRoleName = fmt.Sprintf("role_%d", relation.Role) + } + } + userRespList = append(userRespList, u) } diff --git a/internal/logic/admin/user/removeFamilyMemberLogic.go b/internal/logic/admin/user/removeFamilyMemberLogic.go new file mode 100644 index 0000000..91cfee0 --- /dev/null +++ b/internal/logic/admin/user/removeFamilyMemberLogic.go @@ -0,0 +1,71 @@ +package user + +import ( + "context" + "time" + + modelUser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type RemoveFamilyMemberLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logger.Logger +} + +func NewRemoveFamilyMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RemoveFamilyMemberLogic { + return &RemoveFamilyMemberLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logger.WithContext(ctx), + } +} + +func (l *RemoveFamilyMemberLogic) RemoveFamilyMember(req *types.RemoveFamilyMemberRequest) error { + var family modelUser.UserFamily + err := l.svcCtx.DB.WithContext(l.ctx). + Where("id = ? AND deleted_at IS NULL", req.FamilyId). + First(&family).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyNotExist), "family does not exist") + } + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family failed") + } + if family.Status != modelUser.FamilyStatusActive { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyStatusInvalid), "family status is invalid") + } + + var member modelUser.UserFamilyMember + err = l.svcCtx.DB.WithContext(l.ctx). + Where("family_id = ? AND user_id = ? AND deleted_at IS NULL AND status = ?", req.FamilyId, req.UserId, modelUser.FamilyMemberActive). + First(&member).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyNotExist), "active family member does not exist") + } + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family member failed") + } + if member.Role == modelUser.FamilyRoleOwner { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyOwnerOperationForbidden), "cannot remove family owner") + } + + now := time.Now() + if err = l.svcCtx.DB.WithContext(l.ctx). + Model(&modelUser.UserFamilyMember{}). + Where("id = ?", member.Id). + Updates(map[string]interface{}{ + "status": modelUser.FamilyMemberRemoved, + "left_at": now, + }).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "remove family member failed") + } + + return nil +} diff --git a/internal/logic/admin/user/updateFamilyMaxMembersLogic.go b/internal/logic/admin/user/updateFamilyMaxMembersLogic.go new file mode 100644 index 0000000..b9d2397 --- /dev/null +++ b/internal/logic/admin/user/updateFamilyMaxMembersLogic.go @@ -0,0 +1,60 @@ +package user + +import ( + "context" + + modelUser "github.com/perfect-panel/server/internal/model/user" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type UpdateFamilyMaxMembersLogic struct { + ctx context.Context + svcCtx *svc.ServiceContext + logger.Logger +} + +func NewUpdateFamilyMaxMembersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateFamilyMaxMembersLogic { + return &UpdateFamilyMaxMembersLogic{ + ctx: ctx, + svcCtx: svcCtx, + Logger: logger.WithContext(ctx), + } +} + +func (l *UpdateFamilyMaxMembersLogic) UpdateFamilyMaxMembers(req *types.UpdateFamilyMaxMembersRequest) error { + var family modelUser.UserFamily + err := l.svcCtx.DB.WithContext(l.ctx). + Where("id = ? AND deleted_at IS NULL", req.FamilyId). + First(&family).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyNotExist), "family does not exist") + } + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family failed") + } + + var activeCount int64 + if err = l.svcCtx.DB.WithContext(l.ctx). + Table("user_family_member"). + Where("family_id = ? AND deleted_at IS NULL AND status = ?", req.FamilyId, modelUser.FamilyMemberActive). + Count(&activeCount).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count active members failed") + } + if req.MaxMembers < activeCount { + return errors.Wrapf(xerr.NewErrCode(xerr.FamilyMemberLimitExceeded), "max members below active member count") + } + + if err = l.svcCtx.DB.WithContext(l.ctx). + Model(&modelUser.UserFamily{}). + Where("id = ?", req.FamilyId). + Update("max_members", req.MaxMembers).Error; err != nil { + return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update family max members failed") + } + + return nil +} diff --git a/internal/logic/common/subscribeModeRoute.go b/internal/logic/common/subscribeModeRoute.go new file mode 100644 index 0000000..54dff89 --- /dev/null +++ b/internal/logic/common/subscribeModeRoute.go @@ -0,0 +1,63 @@ +package common + +import ( + "context" + "errors" + + "github.com/perfect-panel/server/internal/model/user" +) + +type PurchaseRoute string + +const ( + PurchaseRouteNewPurchase PurchaseRoute = "new_purchase" + PurchaseRoutePurchaseToRenewal PurchaseRoute = "purchase_to_renewal" +) + +var ( + ErrSingleModePlanMismatch = errors.New("single subscribe mode plan mismatch") +) + +type PurchaseRouteDecision struct { + SingleMode bool + Route PurchaseRoute + ResolvedSubscribeID int64 + Anchor *user.Subscribe +} + +type FindSingleModeAnchorFunc func(ctx context.Context, userID int64) (*user.Subscribe, error) + +func ResolvePurchaseRoute( + ctx context.Context, + singleMode bool, + userID int64, + requestedSubscribeID int64, + findAnchor FindSingleModeAnchorFunc, +) (*PurchaseRouteDecision, error) { + decision := &PurchaseRouteDecision{ + SingleMode: singleMode, + Route: PurchaseRouteNewPurchase, + ResolvedSubscribeID: requestedSubscribeID, + } + + if !singleMode || userID == 0 || findAnchor == nil { + return decision, nil + } + + anchorSub, err := findAnchor(ctx, userID) + if err != nil { + return nil, err + } + if anchorSub == nil { + return decision, nil + } + + if requestedSubscribeID != anchorSub.SubscribeId { + return nil, ErrSingleModePlanMismatch + } + + decision.Route = PurchaseRoutePurchaseToRenewal + decision.ResolvedSubscribeID = anchorSub.SubscribeId + decision.Anchor = anchorSub + return decision, nil +} diff --git a/internal/logic/common/subscribeModeRoute_test.go b/internal/logic/common/subscribeModeRoute_test.go new file mode 100644 index 0000000..ed8b3ac --- /dev/null +++ b/internal/logic/common/subscribeModeRoute_test.go @@ -0,0 +1,82 @@ +package common + +import ( + "context" + "errors" + "testing" + + "github.com/perfect-panel/server/internal/model/user" + "github.com/stretchr/testify/require" +) + +func TestResolvePurchaseRoute(t *testing.T) { + ctx := context.Background() + + t.Run("single mode disabled", func(t *testing.T) { + called := false + decision, err := ResolvePurchaseRoute(ctx, false, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) { + called = true + return nil, nil + }) + require.NoError(t, err) + require.NotNil(t, decision) + require.Equal(t, PurchaseRouteNewPurchase, decision.Route) + require.Equal(t, int64(100), decision.ResolvedSubscribeID) + require.False(t, called) + }) + + t.Run("single mode but empty user", func(t *testing.T) { + decision, err := ResolvePurchaseRoute(ctx, true, 0, 100, nil) + require.NoError(t, err) + require.NotNil(t, decision) + require.Equal(t, PurchaseRouteNewPurchase, decision.Route) + require.Equal(t, int64(100), decision.ResolvedSubscribeID) + }) + + t.Run("single mode no anchor", func(t *testing.T) { + decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) { + return nil, nil + }) + require.NoError(t, err) + require.NotNil(t, decision) + require.Equal(t, PurchaseRouteNewPurchase, decision.Route) + require.Equal(t, int64(100), decision.ResolvedSubscribeID) + }) + + t.Run("single mode routed to renewal", func(t *testing.T) { + decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) { + return &user.Subscribe{ + Id: 11, + SubscribeId: 100, + OrderId: 7, + Token: "token", + }, nil + }) + require.NoError(t, err) + require.NotNil(t, decision) + require.Equal(t, PurchaseRoutePurchaseToRenewal, decision.Route) + require.Equal(t, int64(100), decision.ResolvedSubscribeID) + require.NotNil(t, decision.Anchor) + require.Equal(t, int64(11), decision.Anchor.Id) + }) + + t.Run("single mode plan mismatch", func(t *testing.T) { + decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) { + return &user.Subscribe{ + Id: 11, + SubscribeId: 200, + }, nil + }) + require.ErrorIs(t, err, ErrSingleModePlanMismatch) + require.Nil(t, decision) + }) + + t.Run("single mode anchor query error", func(t *testing.T) { + queryErr := errors.New("query failed") + decision, err := ResolvePurchaseRoute(ctx, true, 1, 100, func(ctx context.Context, userID int64) (*user.Subscribe, error) { + return nil, queryErr + }) + require.ErrorIs(t, err, queryErr) + require.Nil(t, decision) + }) +} diff --git a/internal/logic/public/order/preCreateOrderLogic.go b/internal/logic/public/order/preCreateOrderLogic.go index 9abc37d..7b06174 100644 --- a/internal/logic/public/order/preCreateOrderLogic.go +++ b/internal/logic/public/order/preCreateOrderLogic.go @@ -5,6 +5,7 @@ import ( "encoding/json" "math" + commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/pkg/tool" @@ -52,25 +53,30 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r targetSubscribeID := req.SubscribeId isSingleModeRenewal := false - if l.svcCtx.Config.Subscribe.SingleModel { - anchorSub, anchorErr := l.svcCtx.UserModel.FindSingleModeAnchorSubscribe(l.ctx, u.Id) - switch { - case anchorErr == nil && anchorSub != nil: - targetSubscribeID = anchorSub.SubscribeId - isSingleModeRenewal = true - if req.SubscribeId != targetSubscribeID { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch") - } + decision, routeErr := commonLogic.ResolvePurchaseRoute( + l.ctx, + l.svcCtx.Config.Subscribe.SingleModel, + u.Id, + req.SubscribeId, + l.svcCtx.UserModel.FindSingleModeAnchorSubscribe, + ) + switch { + case errors.Is(routeErr, commonLogic.ErrSingleModePlanMismatch): + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch") + case errors.Is(routeErr, gorm.ErrRecordNotFound): + case routeErr != nil: + l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", routeErr.Error()), logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", routeErr.Error()) + case decision != nil: + targetSubscribeID = decision.ResolvedSubscribeID + isSingleModeRenewal = decision.Route == commonLogic.PurchaseRoutePurchaseToRenewal + if isSingleModeRenewal && decision.Anchor != nil { l.Infow("[PreCreateOrder] single mode purchase routed to renewal preview", logger.Field("mode", "single"), logger.Field("route", "purchase_to_renewal"), - logger.Field("anchor_user_subscribe_id", anchorSub.Id), + logger.Field("anchor_user_subscribe_id", decision.Anchor.Id), logger.Field("user_id", u.Id), ) - case errors.Is(anchorErr, gorm.ErrRecordNotFound): - case anchorErr != nil: - l.Errorw("[PreCreateOrder] Database query error", logger.Field("error", anchorErr.Error()), logger.Field("user_id", u.Id)) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", anchorErr.Error()) } } diff --git a/internal/logic/public/order/purchaseLogic.go b/internal/logic/public/order/purchaseLogic.go index 404eb96..a98f18f 100644 --- a/internal/logic/public/order/purchaseLogic.go +++ b/internal/logic/public/order/purchaseLogic.go @@ -6,6 +6,7 @@ import ( "math" "time" + commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/pkg/constant" @@ -71,30 +72,35 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P subscribeToken := "" anchorUserSubscribeID := int64(0) isSingleModeRenewal := false - if l.svcCtx.Config.Subscribe.SingleModel { - anchorSub, anchorErr := l.svcCtx.UserModel.FindSingleModeAnchorSubscribe(l.ctx, u.Id) - switch { - case anchorErr == nil && anchorSub != nil: - if req.SubscribeId != anchorSub.SubscribeId { - return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch") - } - targetSubscribeID = anchorSub.SubscribeId + decision, routeErr := commonLogic.ResolvePurchaseRoute( + l.ctx, + l.svcCtx.Config.Subscribe.SingleModel, + u.Id, + req.SubscribeId, + l.svcCtx.UserModel.FindSingleModeAnchorSubscribe, + ) + switch { + case errors.Is(routeErr, commonLogic.ErrSingleModePlanMismatch): + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch") + case errors.Is(routeErr, gorm.ErrRecordNotFound): + case routeErr != nil: + l.Errorw("[Purchase] Database query error", logger.Field("error", routeErr.Error()), logger.Field("user_id", u.Id)) + return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", routeErr.Error()) + case decision != nil: + targetSubscribeID = decision.ResolvedSubscribeID + isSingleModeRenewal = decision.Route == commonLogic.PurchaseRoutePurchaseToRenewal + if isSingleModeRenewal && decision.Anchor != nil { orderType = 2 - parentOrderID = anchorSub.OrderId - subscribeToken = anchorSub.Token - anchorUserSubscribeID = anchorSub.Id - isSingleModeRenewal = true + parentOrderID = decision.Anchor.OrderId + subscribeToken = decision.Anchor.Token + anchorUserSubscribeID = decision.Anchor.Id l.Infow("[Purchase] single mode purchase routed to renewal", logger.Field("mode", "single"), logger.Field("route", "purchase_to_renewal"), - logger.Field("anchor_user_subscribe_id", anchorSub.Id), + logger.Field("anchor_user_subscribe_id", decision.Anchor.Id), logger.Field("order_no", "pending"), logger.Field("user_id", u.Id), ) - case errors.Is(anchorErr, gorm.ErrRecordNotFound): - case anchorErr != nil: - l.Errorw("[Purchase] Database query error", logger.Field("error", anchorErr.Error()), logger.Field("user_id", u.Id)) - return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", anchorErr.Error()) } } diff --git a/internal/logic/public/portal/purchaseLogic.go b/internal/logic/public/portal/purchaseLogic.go index d8fb264..a7bfd42 100644 --- a/internal/logic/public/portal/purchaseLogic.go +++ b/internal/logic/public/portal/purchaseLogic.go @@ -48,6 +48,9 @@ func (l *PurchaseLogic) Purchase(req *types.PortalPurchaseRequest) (resp *types. return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user auth error: %v", err.Error()) } if userAuth.UserId != 0 { + if l.svcCtx.Config.Subscribe.SingleModel { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribeModeExceedsLimit), "single subscribe mode exceeds limit") + } return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user already exists") } // find subscribe plan diff --git a/internal/model/user/cache.go b/internal/model/user/cache.go index a39f748..88b0a89 100644 --- a/internal/model/user/cache.go +++ b/internal/model/user/cache.go @@ -71,6 +71,7 @@ func (s *Subscribe) GetCacheKeys() []string { } if s.UserId != 0 { keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, s.UserId)) + keys = append(keys, fmt.Sprintf("%s%d:all", cacheUserSubscribeUserPrefix, s.UserId)) } if s.Id != 0 { keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, s.Id)) diff --git a/internal/model/user/model.go b/internal/model/user/model.go index ec8f1b4..f70f905 100644 --- a/internal/model/user/model.go +++ b/internal/model/user/model.go @@ -3,6 +3,7 @@ package user import ( "context" "fmt" + "strings" "time" "github.com/perfect-panel/server/internal/model/order" @@ -57,13 +58,17 @@ type LoginLogFilterParams struct { } type UserFilterParams struct { - Search string - UserId *int64 - SubscribeId *int64 - UserSubscribeId *int64 - ShortCode string - Order string // Order by id, e.g., "desc" - Unscoped bool // Whether to include soft-deleted records + Search string + UserId *int64 + SubscribeId *int64 + UserSubscribeId *int64 + ShortCode string + FamilyJoined *bool + FamilyStatus string + FamilyOwnerUserId *int64 + FamilyId *int64 + Order string // Order by id, e.g., "desc" + Unscoped bool // Whether to include soft-deleted records } type customUserLogicModel interface { @@ -117,8 +122,9 @@ type customUserLogicModel interface { } type UserStatusInfo struct { - MemberStatus string - LastTrafficAt *time.Time + MemberStatus string + LastTrafficAt *time.Time + PurchasedPackage string } type UserStatisticsWithDate struct { @@ -140,6 +146,26 @@ func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, fil var list []*User var total int64 err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { + joinedFamily := false + joinFamily := func(c *gorm.DB) *gorm.DB { + if joinedFamily { + return c + } + joinedFamily = true + return c. + Joins("JOIN user_family_member ufm ON ufm.user_id = user.id AND ufm.deleted_at IS NULL AND ufm.status = ?", FamilyMemberActive). + Joins("JOIN user_family uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL") + } + joinFamilyLeft := func(c *gorm.DB) *gorm.DB { + if joinedFamily { + return c + } + joinedFamily = true + return c. + Joins("LEFT JOIN user_family_member ufm ON ufm.user_id = user.id AND ufm.deleted_at IS NULL AND ufm.status = ?", FamilyMemberActive). + Joins("LEFT JOIN user_family uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL") + } + if filter != nil { if filter.UserId != nil { conn = conn.Where("user.id =?", *filter.UserId) @@ -160,6 +186,46 @@ func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, fil conn = conn.Joins("LEFT JOIN user_device ON user.id = user_device.user_id"). Where("user_device.short_code LIKE ?", "%"+filter.ShortCode+"%") } + + if filter.FamilyJoined != nil { + if *filter.FamilyJoined { + conn = joinFamily(conn) + } else { + conn = joinFamilyLeft(conn) + conn = conn.Where("ufm.id IS NULL") + } + } + + if filter.FamilyOwnerUserId != nil { + conn = joinFamily(conn) + conn = conn.Where("uf.owner_user_id = ?", *filter.FamilyOwnerUserId) + } + + if filter.FamilyId != nil { + conn = joinFamily(conn) + conn = conn.Where("uf.id = ?", *filter.FamilyId) + } + + if filter.FamilyStatus != "" { + normalizedStatus := strings.ToLower(strings.TrimSpace(filter.FamilyStatus)) + var ( + statusValue uint8 + hasStatus bool + ) + switch normalizedStatus { + case "active", "1": + statusValue = FamilyStatusActive + hasStatus = true + case "disabled", "0": + statusValue = 0 + hasStatus = true + } + if hasStatus { + conn = joinFamily(conn) + conn = conn.Where("uf.status = ?", statusValue) + } + } + if filter.Order != "" { conn = conn.Order(fmt.Sprintf("user.id %s", filter.Order)) } diff --git a/internal/model/user/model_ext.go b/internal/model/user/model_ext.go index 2f4f84e..d5c61ca 100644 --- a/internal/model/user/model_ext.go +++ b/internal/model/user/model_ext.go @@ -2,6 +2,8 @@ package user import ( "context" + "fmt" + "strings" "time" "gorm.io/gorm" @@ -16,17 +18,19 @@ func (m *customUserModel) FindActiveSubscribesByUserIds(ctx context.Context, use type Result struct { UserId int64 Name string + Quantity int64 UpdatedAt *time.Time } var results []Result // Query latest active subscription for each user err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error { - return conn.Table("user_subscribe"). - Select("user_subscribe.user_id, subscribe.name, user_subscribe.updated_at"). - Joins("LEFT JOIN subscribe ON user_subscribe.subscribe_id = subscribe.id"). - Where("user_subscribe.user_id IN ? AND user_subscribe.status IN (0, 1) AND user_subscribe.expire_time > ?", userIds, time.Now()). - Order("user_subscribe.created_at ASC"). // Ascending so we can overwrite in map to get the latest + return conn.Table("user_subscribe us"). + Select("us.user_id, subscribe.name, COALESCE(o.quantity, 1) AS quantity, us.updated_at"). + Joins("LEFT JOIN subscribe ON us.subscribe_id = subscribe.id"). + Joins("LEFT JOIN `order` o ON o.id = (SELECT MAX(o2.id) FROM `order` o2 WHERE o2.user_id = us.user_id AND o2.subscribe_id = us.subscribe_id AND o2.status IN (2, 5))"). + Where("us.user_id IN ? AND us.status IN (0, 1) AND us.expire_time > ?", userIds, time.Now()). + Order("us.created_at ASC, us.id ASC"). // Ascending so we can overwrite in map to get the latest Scan(v).Error }) @@ -35,11 +39,57 @@ func (m *customUserModel) FindActiveSubscribesByUserIds(ctx context.Context, use } userMap := make(map[int64]*UserStatusInfo) + packageTotals := make(map[int64]map[string]int64) + packageOrder := make(map[int64][]string) for _, r := range results { - userMap[r.UserId] = &UserStatusInfo{ - MemberStatus: r.Name, - LastTrafficAt: r.UpdatedAt, + name := strings.TrimSpace(r.Name) + if name == "" { + name = "Unknown" + } + + quantity := r.Quantity + if quantity <= 0 { + quantity = 1 + } + + if _, ok := packageTotals[r.UserId]; !ok { + packageTotals[r.UserId] = make(map[string]int64) + } + if _, exists := packageTotals[r.UserId][name]; !exists { + packageOrder[r.UserId] = append(packageOrder[r.UserId], name) + } + packageTotals[r.UserId][name] += quantity + + info, ok := userMap[r.UserId] + if !ok { + info = &UserStatusInfo{} + userMap[r.UserId] = info + } + info.MemberStatus = formatPackageDisplay(name, quantity) + if r.UpdatedAt != nil && (info.LastTrafficAt == nil || r.UpdatedAt.After(*info.LastTrafficAt)) { + info.LastTrafficAt = r.UpdatedAt } } + + for userID, info := range userMap { + orderedNames := packageOrder[userID] + if len(orderedNames) == 0 { + continue + } + + parts := make([]string, 0, len(orderedNames)) + for _, name := range orderedNames { + parts = append(parts, formatPackageDisplay(name, packageTotals[userID][name])) + } + info.PurchasedPackage = strings.Join(parts, ", ") + } + return userMap, nil } + +func formatPackageDisplay(name string, quantity int64) string { + if quantity <= 1 { + return name + } + return fmt.Sprintf("%s*%d", name, quantity) +} diff --git a/internal/model/user/subscribe.go b/internal/model/user/subscribe.go index 3786fe3..8b786a4 100644 --- a/internal/model/user/subscribe.go +++ b/internal/model/user/subscribe.go @@ -9,6 +9,19 @@ import ( "gorm.io/gorm" ) +func (m *defaultUserModel) execSubscribeMutation(ctx context.Context, cacheModels []*Subscribe, execFn func(conn *gorm.DB) error, tx ...*gorm.DB) error { + defer func() { + _ = m.ClearSubscribeCacheByModels(ctx, cacheModels...) + }() + + return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error { + if len(tx) > 0 { + conn = tx[0] + } + return execFn(conn) + }) +} + func (m *defaultUserModel) UpdateUserSubscribeCache(ctx context.Context, data *Subscribe) error { return m.ClearSubscribeCacheByModels(ctx, data) } @@ -146,19 +159,9 @@ func (m *defaultUserModel) UpdateSubscribe(ctx context.Context, data *Subscribe, return err } - // 使用 defer 确保更新后清理缓存 - defer func() { - if clearErr := m.ClearSubscribeCacheByModels(ctx, old, data); clearErr != nil { - // 记录清理缓存错误 - } - }() - - return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error { - if len(tx) > 0 { - conn = tx[0] - } + return m.execSubscribeMutation(ctx, []*Subscribe{old, data}, func(conn *gorm.DB) error { return conn.Model(&Subscribe{}).Where("id = ?", data.Id).Save(data).Error - }) + }, tx...) } // DeleteSubscribe deletes a record. @@ -168,36 +171,16 @@ func (m *defaultUserModel) DeleteSubscribe(ctx context.Context, token string, tx return err } - // 使用 defer 确保删除后清理缓存 - defer func() { - if clearErr := m.ClearSubscribeCacheByModels(ctx, data); clearErr != nil { - // 记录清理缓存错误 - } - }() - - return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error { - if len(tx) > 0 { - conn = tx[0] - } + return m.execSubscribeMutation(ctx, []*Subscribe{data}, func(conn *gorm.DB) error { return conn.Where("token = ?", token).Delete(&Subscribe{}).Error - }) + }, tx...) } // InsertSubscribe insert Subscribe into the database. func (m *defaultUserModel) InsertSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error { - // 使用 defer 确保插入后清理相关缓存 - defer func() { - if clearErr := m.ClearSubscribeCacheByModels(ctx, data); clearErr != nil { - // 记录清理缓存错误 - } - }() - - return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error { - if len(tx) > 0 { - conn = tx[0] - } + return m.execSubscribeMutation(ctx, []*Subscribe{data}, func(conn *gorm.DB) error { return conn.Create(data).Error - }) + }, tx...) } func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx ...*gorm.DB) error { @@ -206,19 +189,9 @@ func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx return err } - // 使用 defer 确保删除后清理缓存 - defer func() { - if clearErr := m.ClearSubscribeCacheByModels(ctx, data); clearErr != nil { - // 记录清理缓存错误 - } - }() - - return m.ExecNoCacheCtx(ctx, func(conn *gorm.DB) error { - if len(tx) > 0 { - conn = tx[0] - } + return m.execSubscribeMutation(ctx, []*Subscribe{data}, func(conn *gorm.DB) error { return conn.Where("id = ?", id).Delete(&Subscribe{}).Error - }) + }, tx...) } func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error { diff --git a/internal/types/types.go b/internal/types/types.go index 0bfff14..e550b9f 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -558,7 +558,7 @@ type DeleteUserDeivceRequest struct { } type DeleteUserSubscribeRequest struct { - UserSubscribeId int64 `json:"user_subscribe_id,string"` + UserSubscribeId int64 `json:"user_subscribe_id" validate:"required,gt=0"` } type DeviceAuthticateConfig struct { @@ -1108,14 +1108,18 @@ type GetUserAuthMethodResponse struct { } type GetUserListRequest struct { - Page int `form:"page"` - Size int `form:"size"` - Search string `form:"search,omitempty"` - UserId *int64 `form:"user_id,omitempty"` - Unscoped bool `form:"unscoped,omitempty"` - SubscribeId *int64 `form:"subscribe_id,omitempty"` - UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` - ShortCode string `form:"short_code,omitempty"` + Page int `form:"page"` + Size int `form:"size"` + Search string `form:"search,omitempty"` + UserId *int64 `form:"user_id,omitempty"` + Unscoped bool `form:"unscoped,omitempty"` + SubscribeId *int64 `form:"subscribe_id,omitempty"` + UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` + ShortCode string `form:"short_code,omitempty"` + FamilyJoined *bool `form:"family_joined,omitempty"` + FamilyStatus string `form:"family_status,omitempty"` + FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"` + FamilyId *int64 `form:"family_id,omitempty"` } type GetUserListResponse struct { @@ -1184,6 +1188,41 @@ type GetUserSubscribeResetTrafficLogsResponse struct { Total int64 `json:"total"` } +type GetFamilyListRequest struct { + Page int `form:"page"` + Size int `form:"size"` + Keyword string `form:"keyword,omitempty"` + Status string `form:"status,omitempty"` + OwnerUserId *int64 `form:"owner_user_id,omitempty"` + FamilyId *int64 `form:"family_id,omitempty"` + UserId *int64 `form:"user_id,omitempty"` +} + +type GetFamilyListResponse struct { + List []FamilySummary `json:"list"` + Total int64 `json:"total"` +} + +type GetFamilyDetailRequest struct { + Id int64 `form:"id" validate:"required"` +} + +type UpdateFamilyMaxMembersRequest struct { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + MaxMembers int64 `json:"max_members" validate:"required,gt=0"` +} + +type RemoveFamilyMemberRequest struct { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + UserId int64 `json:"user_id" validate:"required,gt=0"` + Reason string `json:"reason,omitempty"` +} + +type DissolveFamilyRequest struct { + FamilyId int64 `json:"family_id" validate:"required,gt=0"` + Reason string `json:"reason,omitempty"` +} + type GetUserSubscribeTrafficLogsRequest struct { Page int `form:"page"` Size int `form:"size"` @@ -1252,6 +1291,7 @@ type InviteConfig struct { ForcedInvite bool `json:"forced_invite"` ReferralPercentage int64 `json:"referral_percentage"` OnlyFirstPurchase bool `json:"only_first_purchase"` + GiftDays int64 `json:"gift_days"` } type KickOfflineRequest struct { @@ -2674,8 +2714,18 @@ type User struct { UpdatedAt int64 `json:"updated_at"` DeletedAt int64 `json:"deleted_at,omitempty"` IsDel bool `json:"is_del,omitempty"` + Remark string `json:"remark,omitempty"` + PurchasedPackage string `json:"purchased_package,omitempty"` LastLoginTime int64 `json:"last_login_time"` MemberStatus string `json:"member_status"` + FamilyJoined bool `json:"family_joined,omitempty"` + FamilyId int64 `json:"family_id,omitempty"` + FamilyRole uint8 `json:"family_role,omitempty"` + FamilyRoleName string `json:"family_role_name,omitempty"` + FamilyOwnerUserId int64 `json:"family_owner_user_id,omitempty"` + FamilyStatus string `json:"family_status,omitempty"` + FamilyMemberCount int64 `json:"family_member_count,omitempty"` + FamilyMaxMembers int64 `json:"family_max_members,omitempty"` } type UserAffiliate struct { @@ -2711,6 +2761,34 @@ type UserLoginLog struct { Timestamp int64 `json:"timestamp"` } +type FamilySummary struct { + FamilyId int64 `json:"family_id"` + OwnerUserId int64 `json:"owner_user_id"` + OwnerIdentifier string `json:"owner_identifier"` + Status string `json:"status"` + ActiveMemberCount int64 `json:"active_member_count"` + MaxMembers int64 `json:"max_members"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type FamilyMemberItem struct { + UserId int64 `json:"user_id"` + Identifier string `json:"identifier"` + Role uint8 `json:"role"` + RoleName string `json:"role_name"` + Status uint8 `json:"status"` + StatusName string `json:"status_name"` + JoinSource string `json:"join_source"` + JoinedAt int64 `json:"joined_at"` + LeftAt int64 `json:"left_at,omitempty"` +} + +type FamilyDetail struct { + Summary FamilySummary `json:"summary"` + Members []FamilyMemberItem `json:"members"` +} + type UserLoginRequest struct { Identifier string `json:"identifier"` Email string `json:"email" validate:"required"` diff --git a/pkg/xerr/errCode.go b/pkg/xerr/errCode.go index 59d24cd..927b46f 100644 --- a/pkg/xerr/errCode.go +++ b/pkg/xerr/errCode.go @@ -18,22 +18,25 @@ const ( // User error const ( - UserExist uint32 = 20001 - UserNotExist uint32 = 20002 - UserPasswordError uint32 = 20003 - UserDisabled uint32 = 20004 - InsufficientBalance uint32 = 20005 - StopRegister uint32 = 20006 - TelegramNotBound uint32 = 20007 - UserNotBindOauth uint32 = 20008 - InviteCodeError uint32 = 20009 - UserCommissionNotEnough uint32 = 20010 - RegisterIPLimit uint32 = 20011 - EmailBindError uint32 = 20012 - UserBindInviteCodeExist uint32 = 20013 - FamilyMemberLimitExceeded uint32 = 20014 - FamilyAlreadyBound uint32 = 20015 - FamilyCrossBindForbidden uint32 = 20016 + UserExist uint32 = 20001 + UserNotExist uint32 = 20002 + UserPasswordError uint32 = 20003 + UserDisabled uint32 = 20004 + InsufficientBalance uint32 = 20005 + StopRegister uint32 = 20006 + TelegramNotBound uint32 = 20007 + UserNotBindOauth uint32 = 20008 + InviteCodeError uint32 = 20009 + UserCommissionNotEnough uint32 = 20010 + RegisterIPLimit uint32 = 20011 + EmailBindError uint32 = 20012 + UserBindInviteCodeExist uint32 = 20013 + FamilyMemberLimitExceeded uint32 = 20014 + FamilyAlreadyBound uint32 = 20015 + FamilyCrossBindForbidden uint32 = 20016 + FamilyNotExist uint32 = 20017 + FamilyStatusInvalid uint32 = 20018 + FamilyOwnerOperationForbidden uint32 = 20019 ) // Node error diff --git a/pkg/xerr/errMsg.go b/pkg/xerr/errMsg.go index 46d0c64..8709834 100644 --- a/pkg/xerr/errMsg.go +++ b/pkg/xerr/errMsg.go @@ -24,21 +24,24 @@ func init() { DatabaseDeletedError: "Database deleted error", // User error - UserExist: "User already exists", - UserNotExist: "User does not exist", - UserPasswordError: "User password error", - UserDisabled: "User disabled", - InsufficientBalance: "Insufficient balance", - StopRegister: "Stop register", - TelegramNotBound: "Telegram not bound ", - UserNotBindOauth: "User not bind oauth method", - InviteCodeError: "Invite code error", - RegisterIPLimit: "Too many registrations", - EmailBindError: "Email already bound", - UserBindInviteCodeExist: "Invite code already bound", - FamilyMemberLimitExceeded: "Family member limit exceeded", - FamilyAlreadyBound: "Family already bound", - FamilyCrossBindForbidden: "Cross-family binding is forbidden", + UserExist: "User already exists", + UserNotExist: "User does not exist", + UserPasswordError: "User password error", + UserDisabled: "User disabled", + InsufficientBalance: "Insufficient balance", + StopRegister: "Stop register", + TelegramNotBound: "Telegram not bound ", + UserNotBindOauth: "User not bind oauth method", + InviteCodeError: "Invite code error", + RegisterIPLimit: "Too many registrations", + EmailBindError: "Email already bound", + UserBindInviteCodeExist: "Invite code already bound", + FamilyMemberLimitExceeded: "Family member limit exceeded", + FamilyAlreadyBound: "Family already bound", + FamilyCrossBindForbidden: "Cross-family binding is forbidden", + FamilyNotExist: "Family does not exist", + FamilyStatusInvalid: "Family status is invalid", + FamilyOwnerOperationForbidden: "Owner operation is forbidden", // Node error NodeExist: "Node already exists",