各种配置项修复,优化到后台管理端配置
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled

This commit is contained in:
shanshanzhong 2026-03-04 17:58:40 -08:00
parent 149dfe1ac3
commit 4752f844ef
31 changed files with 1467 additions and 148 deletions

View File

@ -23,6 +23,10 @@ type (
SubscribeId *int64 `form:"subscribe_id,omitempty"` SubscribeId *int64 `form:"subscribe_id,omitempty"`
UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"`
ShortCode string `form:"short_code,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
GetUserListResponse { GetUserListResponse {
@ -181,7 +185,7 @@ type (
Total int64 `json:"total"` Total int64 `json:"total"`
} }
DeleteUserSubscribeRequest { DeleteUserSubscribeRequest {
UserSubscribeId int64 `json:"user_subscribe_id,string"` UserSubscribeId int64 `json:"user_subscribe_id" validate:"required,gt=0"`
} }
GetUserSubscribeByIdRequest { GetUserSubscribeByIdRequest {
Id int64 `form:"id" validate:"required"` Id int64 `form:"id" validate:"required"`
@ -192,6 +196,35 @@ type (
ResetUserSubscribeTrafficRequest { ResetUserSubscribeTrafficRequest {
UserSubscribeId int64 `json:"user_subscribe_id"` 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 ( @server (
@ -312,4 +345,24 @@ service ppanel {
@doc "Reset user subscribe traffic" @doc "Reset user subscribe traffic"
@handler ResetUserSubscribeTraffic @handler ResetUserSubscribeTraffic
post /subscribe/reset/traffic (ResetUserSubscribeTrafficRequest) 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)
} }

View File

@ -33,6 +33,16 @@ type (
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
DeletedAt int64 `json:"deleted_at,omitempty"` DeletedAt int64 `json:"deleted_at,omitempty"`
IsDel bool `json:"is_del,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 { Follow {
Id int64 `json:"id"` Id int64 `json:"id"`
@ -187,6 +197,7 @@ type (
ForcedInvite bool `json:"forced_invite"` ForcedInvite bool `json:"forced_invite"`
ReferralPercentage int64 `json:"referral_percentage"` ReferralPercentage int64 `json:"referral_percentage"`
OnlyFirstPurchase bool `json:"only_first_purchase"` OnlyFirstPurchase bool `json:"only_first_purchase"`
GiftDays int64 `json:"gift_days"`
} }
TelegramConfig { TelegramConfig {
TelegramBotToken string `json:"telegram_bot_token"` TelegramBotToken string `json:"telegram_bot_token"`
@ -530,6 +541,31 @@ type (
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_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 { UserAuthMethod {
AuthType string `json:"auth_type"` AuthType string `json:"auth_type"`
AuthIdentifier string `json:"auth_identifier"` AuthIdentifier string `json:"auth_identifier"`
@ -876,4 +912,3 @@ type (
UserSubscribeId int64 `json:"user_subscribe_id"` UserSubscribeId int64 `json:"user_subscribe_id"`
} }
) )

View File

@ -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. 写方法统一走 helperDB 写 + 缓存失效)
4. 避免在 handler/logic 直接操作缓存 key

View File

@ -12,7 +12,10 @@ import (
func DeleteUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { func DeleteUserSubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) { return func(c *gin.Context) {
var req types.DeleteUserSubscribeRequest var req types.DeleteUserSubscribeRequest
_ = c.ShouldBind(&req) if err := c.ShouldBind(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
validateErr := svcCtx.Validate(&req) validateErr := svcCtx.Validate(&req)
if validateErr != nil { if validateErr != nil {
result.ParamErrorResult(c, validateErr) result.ParamErrorResult(c, validateErr)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -623,6 +623,21 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get user subcribe traffic logs // Get user subcribe traffic logs
adminUserGroupRouter.GET("/subscribe/traffic_logs", adminUser.GetUserSubscribeTrafficLogsHandler(serverCtx)) 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") authGroupRouter := router.Group("/v1/auth")

View File

@ -3,11 +3,13 @@ package user
import ( import (
"context" "context"
userModel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm"
) )
type DeleteUserSubscribeLogic struct { type DeleteUserSubscribeLogic struct {
@ -27,13 +29,17 @@ func NewDeleteUserSubscribeLogic(ctx context.Context, svcCtx *svc.ServiceContext
func (l *DeleteUserSubscribeLogic) DeleteUserSubscribe(req *types.DeleteUserSubscribeRequest) error { func (l *DeleteUserSubscribeLogic) DeleteUserSubscribe(req *types.DeleteUserSubscribeRequest) error {
// find user subscribe by ID // 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 { if err != nil {
l.Errorw("failed to find user subscribe", logger.Field("error", err.Error()), logger.Field("userSubscribeId", req.UserSubscribeId)) 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()) 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 { if err != nil {
l.Errorw("failed to delete user subscribe", logger.Field("error", err.Error()), logger.Field("userSubscribeId", req.UserSubscribeId)) 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()) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseDeletedError), "failed to delete user subscribe: %v", err.Error())

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -3,6 +3,7 @@ package user
import ( import (
"context" "context"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger" "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()) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get user detail error: %v", err.Error())
} }
tool.DeepCopy(&resp, userInfo) 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 return &resp, nil
} }

View File

@ -2,6 +2,7 @@ package user
import ( import (
"context" "context"
"fmt"
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc" "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) { 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{ list, total, err := l.svcCtx.UserModel.QueryPageList(l.ctx, req.Page, req.Size, &user.UserFilterParams{
UserId: req.UserId, UserId: req.UserId,
Search: req.Search, Search: req.Search,
Unscoped: req.Unscoped, Unscoped: req.Unscoped,
SubscribeId: req.SubscribeId, SubscribeId: req.SubscribeId,
UserSubscribeId: req.UserSubscribeId, UserSubscribeId: req.UserSubscribeId,
ShortCode: req.ShortCode, ShortCode: req.ShortCode,
Order: "DESC", FamilyJoined: req.FamilyJoined,
FamilyStatus: req.FamilyStatus,
FamilyOwnerUserId: req.FamilyOwnerUserId,
FamilyId: req.FamilyId,
Order: "DESC",
}) })
if err != nil { if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "GetUserListLogic failed: %v", err.Error()) 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())) 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)) userRespList := make([]types.User, 0, len(list))
for _, item := range list { for _, item := range list {
@ -66,6 +129,7 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
if activeSubs != nil { if activeSubs != nil {
if info, ok := activeSubs[item.Id]; ok { if info, ok := activeSubs[item.Id]; ok {
u.MemberStatus = info.MemberStatus u.MemberStatus = info.MemberStatus
u.PurchasedPackage = info.PurchasedPackage
if info.LastTrafficAt != nil { if info.LastTrafficAt != nil {
trafficTime := info.LastTrafficAt.Unix() trafficTime := info.LastTrafficAt.Unix()
if trafficTime > u.LastLoginTime { if trafficTime > u.LastLoginTime {
@ -85,6 +149,29 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
} }
u.AuthMethods = authMethods 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) userRespList = append(userRespList, u)
} }

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"math" "math"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/pkg/tool" "github.com/perfect-panel/server/pkg/tool"
@ -52,25 +53,30 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
targetSubscribeID := req.SubscribeId targetSubscribeID := req.SubscribeId
isSingleModeRenewal := false isSingleModeRenewal := false
if l.svcCtx.Config.Subscribe.SingleModel { decision, routeErr := commonLogic.ResolvePurchaseRoute(
anchorSub, anchorErr := l.svcCtx.UserModel.FindSingleModeAnchorSubscribe(l.ctx, u.Id) l.ctx,
switch { l.svcCtx.Config.Subscribe.SingleModel,
case anchorErr == nil && anchorSub != nil: u.Id,
targetSubscribeID = anchorSub.SubscribeId req.SubscribeId,
isSingleModeRenewal = true l.svcCtx.UserModel.FindSingleModeAnchorSubscribe,
if req.SubscribeId != targetSubscribeID { )
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch") 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", l.Infow("[PreCreateOrder] single mode purchase routed to renewal preview",
logger.Field("mode", "single"), logger.Field("mode", "single"),
logger.Field("route", "purchase_to_renewal"), 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), 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())
} }
} }

View File

@ -6,6 +6,7 @@ import (
"math" "math"
"time" "time"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/constant"
@ -71,30 +72,35 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
subscribeToken := "" subscribeToken := ""
anchorUserSubscribeID := int64(0) anchorUserSubscribeID := int64(0)
isSingleModeRenewal := false isSingleModeRenewal := false
if l.svcCtx.Config.Subscribe.SingleModel { decision, routeErr := commonLogic.ResolvePurchaseRoute(
anchorSub, anchorErr := l.svcCtx.UserModel.FindSingleModeAnchorSubscribe(l.ctx, u.Id) l.ctx,
switch { l.svcCtx.Config.Subscribe.SingleModel,
case anchorErr == nil && anchorSub != nil: u.Id,
if req.SubscribeId != anchorSub.SubscribeId { req.SubscribeId,
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch") l.svcCtx.UserModel.FindSingleModeAnchorSubscribe,
} )
targetSubscribeID = anchorSub.SubscribeId 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 orderType = 2
parentOrderID = anchorSub.OrderId parentOrderID = decision.Anchor.OrderId
subscribeToken = anchorSub.Token subscribeToken = decision.Anchor.Token
anchorUserSubscribeID = anchorSub.Id anchorUserSubscribeID = decision.Anchor.Id
isSingleModeRenewal = true
l.Infow("[Purchase] single mode purchase routed to renewal", l.Infow("[Purchase] single mode purchase routed to renewal",
logger.Field("mode", "single"), logger.Field("mode", "single"),
logger.Field("route", "purchase_to_renewal"), 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("order_no", "pending"),
logger.Field("user_id", u.Id), 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())
} }
} }

View File

@ -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()) return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user auth error: %v", err.Error())
} }
if userAuth.UserId != 0 { 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") return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user already exists")
} }
// find subscribe plan // find subscribe plan

View File

@ -71,6 +71,7 @@ func (s *Subscribe) GetCacheKeys() []string {
} }
if s.UserId != 0 { if s.UserId != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, s.UserId)) 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 { if s.Id != 0 {
keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, s.Id)) keys = append(keys, fmt.Sprintf("%s%d", cacheUserSubscribeIdPrefix, s.Id))

View File

@ -3,6 +3,7 @@ package user
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/order"
@ -57,13 +58,17 @@ type LoginLogFilterParams struct {
} }
type UserFilterParams struct { type UserFilterParams struct {
Search string Search string
UserId *int64 UserId *int64
SubscribeId *int64 SubscribeId *int64
UserSubscribeId *int64 UserSubscribeId *int64
ShortCode string ShortCode string
Order string // Order by id, e.g., "desc" FamilyJoined *bool
Unscoped bool // Whether to include soft-deleted records 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 { type customUserLogicModel interface {
@ -117,8 +122,9 @@ type customUserLogicModel interface {
} }
type UserStatusInfo struct { type UserStatusInfo struct {
MemberStatus string MemberStatus string
LastTrafficAt *time.Time LastTrafficAt *time.Time
PurchasedPackage string
} }
type UserStatisticsWithDate struct { type UserStatisticsWithDate struct {
@ -140,6 +146,26 @@ func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, fil
var list []*User var list []*User
var total int64 var total int64
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error { 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 != nil {
if filter.UserId != nil { if filter.UserId != nil {
conn = conn.Where("user.id =?", *filter.UserId) 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"). conn = conn.Joins("LEFT JOIN user_device ON user.id = user_device.user_id").
Where("user_device.short_code LIKE ?", "%"+filter.ShortCode+"%") 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 != "" { if filter.Order != "" {
conn = conn.Order(fmt.Sprintf("user.id %s", filter.Order)) conn = conn.Order(fmt.Sprintf("user.id %s", filter.Order))
} }

View File

@ -2,6 +2,8 @@ package user
import ( import (
"context" "context"
"fmt"
"strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@ -16,17 +18,19 @@ func (m *customUserModel) FindActiveSubscribesByUserIds(ctx context.Context, use
type Result struct { type Result struct {
UserId int64 UserId int64
Name string Name string
Quantity int64
UpdatedAt *time.Time UpdatedAt *time.Time
} }
var results []Result var results []Result
// Query latest active subscription for each user // Query latest active subscription for each user
err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error { err := m.QueryNoCacheCtx(ctx, &results, func(conn *gorm.DB, v interface{}) error {
return conn.Table("user_subscribe"). return conn.Table("user_subscribe us").
Select("user_subscribe.user_id, subscribe.name, user_subscribe.updated_at"). Select("us.user_id, subscribe.name, COALESCE(o.quantity, 1) AS quantity, us.updated_at").
Joins("LEFT JOIN subscribe ON user_subscribe.subscribe_id = subscribe.id"). Joins("LEFT JOIN subscribe ON us.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()). 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))").
Order("user_subscribe.created_at ASC"). // Ascending so we can overwrite in map to get the latest 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 Scan(v).Error
}) })
@ -35,11 +39,57 @@ func (m *customUserModel) FindActiveSubscribesByUserIds(ctx context.Context, use
} }
userMap := make(map[int64]*UserStatusInfo) userMap := make(map[int64]*UserStatusInfo)
packageTotals := make(map[int64]map[string]int64)
packageOrder := make(map[int64][]string)
for _, r := range results { for _, r := range results {
userMap[r.UserId] = &UserStatusInfo{ name := strings.TrimSpace(r.Name)
MemberStatus: r.Name, if name == "" {
LastTrafficAt: r.UpdatedAt, 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 return userMap, nil
} }
func formatPackageDisplay(name string, quantity int64) string {
if quantity <= 1 {
return name
}
return fmt.Sprintf("%s*%d", name, quantity)
}

View File

@ -9,6 +9,19 @@ import (
"gorm.io/gorm" "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 { func (m *defaultUserModel) UpdateUserSubscribeCache(ctx context.Context, data *Subscribe) error {
return m.ClearSubscribeCacheByModels(ctx, data) return m.ClearSubscribeCacheByModels(ctx, data)
} }
@ -146,19 +159,9 @@ func (m *defaultUserModel) UpdateSubscribe(ctx context.Context, data *Subscribe,
return err return err
} }
// 使用 defer 确保更新后清理缓存 return m.execSubscribeMutation(ctx, []*Subscribe{old, data}, func(conn *gorm.DB) error {
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 conn.Model(&Subscribe{}).Where("id = ?", data.Id).Save(data).Error return conn.Model(&Subscribe{}).Where("id = ?", data.Id).Save(data).Error
}) }, tx...)
} }
// DeleteSubscribe deletes a record. // DeleteSubscribe deletes a record.
@ -168,36 +171,16 @@ func (m *defaultUserModel) DeleteSubscribe(ctx context.Context, token string, tx
return err return err
} }
// 使用 defer 确保删除后清理缓存 return m.execSubscribeMutation(ctx, []*Subscribe{data}, func(conn *gorm.DB) error {
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 conn.Where("token = ?", token).Delete(&Subscribe{}).Error return conn.Where("token = ?", token).Delete(&Subscribe{}).Error
}) }, tx...)
} }
// InsertSubscribe insert Subscribe into the database. // InsertSubscribe insert Subscribe into the database.
func (m *defaultUserModel) InsertSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error { func (m *defaultUserModel) InsertSubscribe(ctx context.Context, data *Subscribe, tx ...*gorm.DB) error {
// 使用 defer 确保插入后清理相关缓存 return m.execSubscribeMutation(ctx, []*Subscribe{data}, func(conn *gorm.DB) error {
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 conn.Create(data).Error return conn.Create(data).Error
}) }, tx...)
} }
func (m *defaultUserModel) DeleteSubscribeById(ctx context.Context, id int64, tx ...*gorm.DB) error { 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 return err
} }
// 使用 defer 确保删除后清理缓存 return m.execSubscribeMutation(ctx, []*Subscribe{data}, func(conn *gorm.DB) error {
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 conn.Where("id = ?", id).Delete(&Subscribe{}).Error return conn.Where("id = ?", id).Delete(&Subscribe{}).Error
}) }, tx...)
} }
func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error { func (m *defaultUserModel) ClearSubscribeCache(ctx context.Context, data ...*Subscribe) error {

View File

@ -558,7 +558,7 @@ type DeleteUserDeivceRequest struct {
} }
type DeleteUserSubscribeRequest struct { type DeleteUserSubscribeRequest struct {
UserSubscribeId int64 `json:"user_subscribe_id,string"` UserSubscribeId int64 `json:"user_subscribe_id" validate:"required,gt=0"`
} }
type DeviceAuthticateConfig struct { type DeviceAuthticateConfig struct {
@ -1108,14 +1108,18 @@ type GetUserAuthMethodResponse struct {
} }
type GetUserListRequest struct { type GetUserListRequest struct {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`
Search string `form:"search,omitempty"` Search string `form:"search,omitempty"`
UserId *int64 `form:"user_id,omitempty"` UserId *int64 `form:"user_id,omitempty"`
Unscoped bool `form:"unscoped,omitempty"` Unscoped bool `form:"unscoped,omitempty"`
SubscribeId *int64 `form:"subscribe_id,omitempty"` SubscribeId *int64 `form:"subscribe_id,omitempty"`
UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"`
ShortCode string `form:"short_code,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 { type GetUserListResponse struct {
@ -1184,6 +1188,41 @@ type GetUserSubscribeResetTrafficLogsResponse struct {
Total int64 `json:"total"` 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 { type GetUserSubscribeTrafficLogsRequest struct {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`
@ -1252,6 +1291,7 @@ type InviteConfig struct {
ForcedInvite bool `json:"forced_invite"` ForcedInvite bool `json:"forced_invite"`
ReferralPercentage int64 `json:"referral_percentage"` ReferralPercentage int64 `json:"referral_percentage"`
OnlyFirstPurchase bool `json:"only_first_purchase"` OnlyFirstPurchase bool `json:"only_first_purchase"`
GiftDays int64 `json:"gift_days"`
} }
type KickOfflineRequest struct { type KickOfflineRequest struct {
@ -2674,8 +2714,18 @@ type User struct {
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
DeletedAt int64 `json:"deleted_at,omitempty"` DeletedAt int64 `json:"deleted_at,omitempty"`
IsDel bool `json:"is_del,omitempty"` IsDel bool `json:"is_del,omitempty"`
Remark string `json:"remark,omitempty"`
PurchasedPackage string `json:"purchased_package,omitempty"`
LastLoginTime int64 `json:"last_login_time"` LastLoginTime int64 `json:"last_login_time"`
MemberStatus string `json:"member_status"` 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 { type UserAffiliate struct {
@ -2711,6 +2761,34 @@ type UserLoginLog struct {
Timestamp int64 `json:"timestamp"` 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 { type UserLoginRequest struct {
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"` Email string `json:"email" validate:"required"`

View File

@ -18,22 +18,25 @@ const (
// User error // User error
const ( const (
UserExist uint32 = 20001 UserExist uint32 = 20001
UserNotExist uint32 = 20002 UserNotExist uint32 = 20002
UserPasswordError uint32 = 20003 UserPasswordError uint32 = 20003
UserDisabled uint32 = 20004 UserDisabled uint32 = 20004
InsufficientBalance uint32 = 20005 InsufficientBalance uint32 = 20005
StopRegister uint32 = 20006 StopRegister uint32 = 20006
TelegramNotBound uint32 = 20007 TelegramNotBound uint32 = 20007
UserNotBindOauth uint32 = 20008 UserNotBindOauth uint32 = 20008
InviteCodeError uint32 = 20009 InviteCodeError uint32 = 20009
UserCommissionNotEnough uint32 = 20010 UserCommissionNotEnough uint32 = 20010
RegisterIPLimit uint32 = 20011 RegisterIPLimit uint32 = 20011
EmailBindError uint32 = 20012 EmailBindError uint32 = 20012
UserBindInviteCodeExist uint32 = 20013 UserBindInviteCodeExist uint32 = 20013
FamilyMemberLimitExceeded uint32 = 20014 FamilyMemberLimitExceeded uint32 = 20014
FamilyAlreadyBound uint32 = 20015 FamilyAlreadyBound uint32 = 20015
FamilyCrossBindForbidden uint32 = 20016 FamilyCrossBindForbidden uint32 = 20016
FamilyNotExist uint32 = 20017
FamilyStatusInvalid uint32 = 20018
FamilyOwnerOperationForbidden uint32 = 20019
) )
// Node error // Node error

View File

@ -24,21 +24,24 @@ func init() {
DatabaseDeletedError: "Database deleted error", DatabaseDeletedError: "Database deleted error",
// User error // User error
UserExist: "User already exists", UserExist: "User already exists",
UserNotExist: "User does not exist", UserNotExist: "User does not exist",
UserPasswordError: "User password error", UserPasswordError: "User password error",
UserDisabled: "User disabled", UserDisabled: "User disabled",
InsufficientBalance: "Insufficient balance", InsufficientBalance: "Insufficient balance",
StopRegister: "Stop register", StopRegister: "Stop register",
TelegramNotBound: "Telegram not bound ", TelegramNotBound: "Telegram not bound ",
UserNotBindOauth: "User not bind oauth method", UserNotBindOauth: "User not bind oauth method",
InviteCodeError: "Invite code error", InviteCodeError: "Invite code error",
RegisterIPLimit: "Too many registrations", RegisterIPLimit: "Too many registrations",
EmailBindError: "Email already bound", EmailBindError: "Email already bound",
UserBindInviteCodeExist: "Invite code already bound", UserBindInviteCodeExist: "Invite code already bound",
FamilyMemberLimitExceeded: "Family member limit exceeded", FamilyMemberLimitExceeded: "Family member limit exceeded",
FamilyAlreadyBound: "Family already bound", FamilyAlreadyBound: "Family already bound",
FamilyCrossBindForbidden: "Cross-family binding is forbidden", FamilyCrossBindForbidden: "Cross-family binding is forbidden",
FamilyNotExist: "Family does not exist",
FamilyStatusInvalid: "Family status is invalid",
FamilyOwnerOperationForbidden: "Owner operation is forbidden",
// Node error // Node error
NodeExist: "Node already exists", NodeExist: "Node already exists",