diff --git a/backend/app/http/v1/creator.go b/backend/app/http/v1/creator.go index e4037bb..5a8322e 100644 --- a/backend/app/http/v1/creator.go +++ b/backend/app/http/v1/creator.go @@ -70,6 +70,91 @@ func (c *Creator) CreateMemberInvite( return services.Tenant.CreateInvite(ctx, tenantID, user.ID, form) } +// List tenant members +// +// @Router /t/:tenantCode/v1/creator/members [get] +// @Summary List tenant members +// @Description List tenant members with filters +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param filter query dto.TenantMemberListFilter false "Member list filter" +// @Success 200 {object} requests.Pager{items=[]dto.TenantMemberItem} +// @Bind user local key(__ctx_user) +// @Bind filter query +func (c *Creator) ListMembers(ctx fiber.Ctx, user *models.User, filter *dto.TenantMemberListFilter) (*requests.Pager, error) { + tenantID := getTenantID(ctx) + return services.Tenant.ListMembers(ctx, tenantID, user.ID, filter) +} + +// List member invites +// +// @Router /t/:tenantCode/v1/creator/members/invites [get] +// @Summary List member invites +// @Description List member invites with filters +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param filter query dto.TenantInviteListFilter false "Invite list filter" +// @Success 200 {object} requests.Pager{items=[]dto.TenantInviteListItem} +// @Bind user local key(__ctx_user) +// @Bind filter query +func (c *Creator) ListMemberInvites(ctx fiber.Ctx, user *models.User, filter *dto.TenantInviteListFilter) (*requests.Pager, error) { + tenantID := getTenantID(ctx) + return services.Tenant.ListInvites(ctx, tenantID, user.ID, filter) +} + +// Disable member invite +// +// @Router /t/:tenantCode/v1/creator/members/invites/:id [delete] +// @Summary Disable member invite +// @Description Disable a member invite by ID +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param id path int64 true "Invite ID" +// @Success 200 {string} string "Disabled" +// @Bind user local key(__ctx_user) +// @Bind id path +func (c *Creator) DisableMemberInvite(ctx fiber.Ctx, user *models.User, id int64) error { + tenantID := getTenantID(ctx) + return services.Tenant.DisableInvite(ctx, tenantID, user.ID, id) +} + +// List member join requests +// +// @Router /t/:tenantCode/v1/creator/members/join-requests [get] +// @Summary List member join requests +// @Description List tenant join requests +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param filter query dto.TenantJoinRequestListFilter false "Join request list filter" +// @Success 200 {object} requests.Pager{items=[]dto.TenantJoinRequestItem} +// @Bind user local key(__ctx_user) +// @Bind filter query +func (c *Creator) ListMemberJoinRequests(ctx fiber.Ctx, user *models.User, filter *dto.TenantJoinRequestListFilter) (*requests.Pager, error) { + tenantID := getTenantID(ctx) + return services.Tenant.ListJoinRequests(ctx, tenantID, user.ID, filter) +} + +// Remove tenant member +// +// @Router /t/:tenantCode/v1/creator/members/:id [delete] +// @Summary Remove tenant member +// @Description Remove a tenant member by relation ID +// @Tags CreatorCenter +// @Accept json +// @Produce json +// @Param id path int64 true "Member ID" +// @Success 200 {string} string "Removed" +// @Bind user local key(__ctx_user) +// @Bind id path +func (c *Creator) RemoveMember(ctx fiber.Ctx, user *models.User, id int64) error { + tenantID := getTenantID(ctx) + return services.Tenant.RemoveMember(ctx, tenantID, user.ID, id) +} + // Get report overview // // @Router /t/:tenantCode/v1/creator/reports/overview [get] diff --git a/backend/app/http/v1/dto/tenant_member.go b/backend/app/http/v1/dto/tenant_member.go index fc7671a..8707f60 100644 --- a/backend/app/http/v1/dto/tenant_member.go +++ b/backend/app/http/v1/dto/tenant_member.go @@ -1,5 +1,10 @@ package dto +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + type TenantJoinApplyForm struct { // Reason 申请加入原因(可选,空值会使用默认文案)。 Reason string `json:"reason"` @@ -44,3 +49,110 @@ type TenantInviteItem struct { // Remark 备注说明。 Remark string `json:"remark"` } + +type TenantMemberListFilter struct { + // Pagination 分页参数(page/limit)。 + requests.Pagination + // Keyword 关键词搜索(匹配用户名/昵称/手机号)。 + Keyword *string `query:"keyword"` + // Role 成员角色筛选(member/tenant_admin)。 + Role *consts.TenantUserRole `query:"role"` + // Status 成员状态筛选(active/verified/banned 等)。 + Status *consts.UserStatus `query:"status"` +} + +type TenantMemberUserLite struct { + // ID 用户ID。 + ID int64 `json:"id"` + // Username 用户名。 + Username string `json:"username"` + // Phone 手机号。 + Phone string `json:"phone"` + // Nickname 昵称。 + Nickname string `json:"nickname"` + // Avatar 头像URL。 + Avatar string `json:"avatar"` +} + +type TenantMemberItem struct { + // ID 成员关系记录ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // User 成员用户信息。 + User *TenantMemberUserLite `json:"user"` + // Role 成员角色列表。 + Role []consts.TenantUserRole `json:"role"` + // RoleDescription 角色描述列表。 + RoleDescription []string `json:"role_description"` + // Status 成员状态。 + Status consts.UserStatus `json:"status"` + // StatusDescription 成员状态描述。 + StatusDescription string `json:"status_description"` + // CreatedAt 加入时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} + +type TenantInviteListFilter struct { + // Pagination 分页参数(page/limit)。 + requests.Pagination + // Status 邀请状态筛选(active/disabled/expired)。 + Status *consts.TenantInviteStatus `query:"status"` +} + +type TenantInviteListItem struct { + // ID 邀请记录ID。 + ID int64 `json:"id"` + // Code 邀请码。 + Code string `json:"code"` + // Status 邀请状态(active/disabled/expired)。 + Status string `json:"status"` + // StatusDescription 状态描述。 + StatusDescription string `json:"status_description"` + // MaxUses 最大可使用次数。 + MaxUses int32 `json:"max_uses"` + // UsedCount 已使用次数。 + UsedCount int32 `json:"used_count"` + // ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。 + ExpiresAt string `json:"expires_at"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // Remark 备注说明。 + Remark string `json:"remark"` + // Creator 创建者信息(可选)。 + Creator *TenantMemberUserLite `json:"creator"` +} + +type TenantJoinRequestListFilter struct { + // Pagination 分页参数(page/limit)。 + requests.Pagination + // Status 申请状态筛选(pending/approved/rejected)。 + Status *consts.TenantJoinRequestStatus `query:"status"` + // Keyword 关键词搜索(匹配用户名/昵称/手机号)。 + Keyword *string `query:"keyword"` +} + +type TenantJoinRequestItem struct { + // ID 申请记录ID。 + ID int64 `json:"id"` + // User 申请用户信息。 + User *TenantMemberUserLite `json:"user"` + // Status 申请状态。 + Status string `json:"status"` + // StatusDescription 状态描述。 + StatusDescription string `json:"status_description"` + // Reason 申请说明。 + Reason string `json:"reason"` + // DecidedAt 审核时间(RFC3339)。 + DecidedAt string `json:"decided_at"` + // DecidedOperatorUserID 审核操作者ID。 + DecidedOperatorUserID int64 `json:"decided_operator_user_id"` + // DecidedReason 审核备注/原因。 + DecidedReason string `json:"decided_reason"` + // CreatedAt 申请时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} diff --git a/backend/app/http/v1/routes.gen.go b/backend/app/http/v1/routes.gen.go index f52050b..8e253a4 100644 --- a/backend/app/http/v1/routes.gen.go +++ b/backend/app/http/v1/routes.gen.go @@ -157,6 +157,18 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("__ctx_user"), PathParam[int64]("id"), )) + r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/creator/members/:id -> creator.RemoveMember") + router.Delete("/t/:tenantCode/v1/creator/members/:id"[len(r.Path()):], Func2( + r.creator.RemoveMember, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + )) + r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/creator/members/invites/:id -> creator.DisableMemberInvite") + router.Delete("/t/:tenantCode/v1/creator/members/invites/:id"[len(r.Path()):], Func2( + r.creator.DisableMemberInvite, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + )) r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/creator/payout-accounts -> creator.RemovePayoutAccount") router.Delete("/t/:tenantCode/v1/creator/payout-accounts"[len(r.Path()):], Func2( r.creator.RemovePayoutAccount, @@ -192,6 +204,24 @@ func (r *Routes) Register(router fiber.Router) { r.creator.Dashboard, Local[*models.User]("__ctx_user"), )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/members -> creator.ListMembers") + router.Get("/t/:tenantCode/v1/creator/members"[len(r.Path()):], DataFunc2( + r.creator.ListMembers, + Local[*models.User]("__ctx_user"), + Query[dto.TenantMemberListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/members/invites -> creator.ListMemberInvites") + router.Get("/t/:tenantCode/v1/creator/members/invites"[len(r.Path()):], DataFunc2( + r.creator.ListMemberInvites, + Local[*models.User]("__ctx_user"), + Query[dto.TenantInviteListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/members/join-requests -> creator.ListMemberJoinRequests") + router.Get("/t/:tenantCode/v1/creator/members/join-requests"[len(r.Path()):], DataFunc2( + r.creator.ListMemberJoinRequests, + Local[*models.User]("__ctx_user"), + Query[dto.TenantJoinRequestListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /t/:tenantCode/v1/creator/orders -> creator.ListOrders") router.Get("/t/:tenantCode/v1/creator/orders"[len(r.Path()):], DataFunc2( r.creator.ListOrders, diff --git a/backend/app/services/tenant_member.go b/backend/app/services/tenant_member.go index 40ce991..1c974ea 100644 --- a/backend/app/services/tenant_member.go +++ b/backend/app/services/tenant_member.go @@ -8,10 +8,12 @@ import ( "quyun/v2/app/errorx" tenant_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/requests" "quyun/v2/database/models" "quyun/v2/pkg/consts" "github.com/google/uuid" + "go.ipao.vip/gen/field" "go.ipao.vip/gen/types" "gorm.io/gorm" ) @@ -337,6 +339,346 @@ func (s *tenant) AcceptInvite(ctx context.Context, tenantID, userID int64, form }) } +func (s *tenant) ListMembers(ctx context.Context, tenantID, operatorID int64, filter *tenant_dto.TenantMemberListFilter) (*requests.Pager, error) { + if tenantID == 0 { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if filter == nil { + filter = &tenant_dto.TenantMemberListFilter{} + } + + // 校验操作者权限,避免非管理员查看成员详情。 + if _, err := s.ensureTenantAdmin(ctx, tenantID, operatorID); err != nil { + return nil, err + } + + tbl, q := models.TenantUserQuery.QueryContext(ctx) + q = q.Where(tbl.TenantID.Eq(tenantID)) + if filter.Role != nil && *filter.Role != "" { + q = q.Where(tbl.Role.Contains(types.Array[consts.TenantUserRole]{*filter.Role})) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Keyword) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Order(tbl.CreatedAt.Desc()). + Offset(int(filter.Pagination.Offset())). + Limit(int(filter.Pagination.Limit)). + Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + // 批量补齐成员用户信息,避免逐条查询。 + userIDSet := make(map[int64]struct{}) + for _, item := range list { + userIDSet[item.UserID] = struct{}{} + } + userIDs = make([]int64, 0, len(userIDSet)) + for id := range userIDSet { + userIDs = append(userIDs, id) + } + userMap, err := s.loadUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + + items := make([]tenant_dto.TenantMemberItem, 0, len(list)) + for _, member := range list { + roles := make([]consts.TenantUserRole, 0, len(member.Role)) + roleDescriptions := make([]string, 0, len(member.Role)) + for _, role := range member.Role { + roles = append(roles, role) + roleDescriptions = append(roleDescriptions, role.Description()) + } + + statusDescription := member.Status.Description() + if statusDescription == "" { + statusDescription = string(member.Status) + } + + items = append(items, tenant_dto.TenantMemberItem{ + ID: member.ID, + TenantID: member.TenantID, + User: s.toTenantMemberUserLite(userMap[member.UserID]), + Role: roles, + RoleDescription: roleDescriptions, + Status: member.Status, + StatusDescription: statusDescription, + CreatedAt: s.formatTime(member.CreatedAt), + UpdatedAt: s.formatTime(member.UpdatedAt), + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *tenant) ListInvites(ctx context.Context, tenantID, operatorID int64, filter *tenant_dto.TenantInviteListFilter) (*requests.Pager, error) { + if tenantID == 0 { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if filter == nil { + filter = &tenant_dto.TenantInviteListFilter{} + } + + // 校验操作者权限,避免越权读取邀请信息。 + if _, err := s.ensureTenantAdmin(ctx, tenantID, operatorID); err != nil { + return nil, err + } + + tbl, q := models.TenantInviteQuery.QueryContext(ctx) + q = q.Where(tbl.TenantID.Eq(tenantID)) + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(string(*filter.Status))) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Order(tbl.CreatedAt.Desc()). + Offset(int(filter.Pagination.Offset())). + Limit(int(filter.Pagination.Limit)). + Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + // 批量补齐邀请创建者信息,便于前端展示。 + userIDSet := make(map[int64]struct{}) + for _, invite := range list { + userIDSet[invite.UserID] = struct{}{} + } + userIDs := make([]int64, 0, len(userIDSet)) + for id := range userIDSet { + userIDs = append(userIDs, id) + } + userMap, err := s.loadUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + + items := make([]tenant_dto.TenantInviteListItem, 0, len(list)) + for _, invite := range list { + status := consts.TenantInviteStatus(invite.Status) + statusDescription := status.Description() + if statusDescription == "" { + statusDescription = invite.Status + } + + expiresAt := "" + if !invite.ExpiresAt.IsZero() { + expiresAt = invite.ExpiresAt.Format(time.RFC3339) + } + + items = append(items, tenant_dto.TenantInviteListItem{ + ID: invite.ID, + Code: invite.Code, + Status: invite.Status, + StatusDescription: statusDescription, + MaxUses: invite.MaxUses, + UsedCount: invite.UsedCount, + ExpiresAt: expiresAt, + CreatedAt: s.formatTime(invite.CreatedAt), + Remark: invite.Remark, + Creator: s.toTenantMemberUserLite(userMap[invite.UserID]), + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *tenant) ListJoinRequests(ctx context.Context, tenantID, operatorID int64, filter *tenant_dto.TenantJoinRequestListFilter) (*requests.Pager, error) { + if tenantID == 0 { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if filter == nil { + filter = &tenant_dto.TenantJoinRequestListFilter{} + } + + // 校验操作者权限,避免越权读取审核信息。 + if _, err := s.ensureTenantAdmin(ctx, tenantID, operatorID); err != nil { + return nil, err + } + + tbl, q := models.TenantJoinRequestQuery.QueryContext(ctx) + q = q.Where(tbl.TenantID.Eq(tenantID)) + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(string(*filter.Status))) + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Keyword) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Order(tbl.CreatedAt.Desc()). + Offset(int(filter.Pagination.Offset())). + Limit(int(filter.Pagination.Limit)). + Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + // 批量补齐申请用户信息。 + userIDSet := make(map[int64]struct{}) + for _, req := range list { + userIDSet[req.UserID] = struct{}{} + } + userIDs = make([]int64, 0, len(userIDSet)) + for id := range userIDSet { + userIDs = append(userIDs, id) + } + userMap, err := s.loadUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + + items := make([]tenant_dto.TenantJoinRequestItem, 0, len(list)) + for _, req := range list { + status := consts.TenantJoinRequestStatus(req.Status) + statusDescription := status.Description() + if statusDescription == "" { + statusDescription = req.Status + } + + items = append(items, tenant_dto.TenantJoinRequestItem{ + ID: req.ID, + User: s.toTenantMemberUserLite(userMap[req.UserID]), + Status: req.Status, + StatusDescription: statusDescription, + Reason: req.Reason, + DecidedAt: s.formatTime(req.DecidedAt), + DecidedOperatorUserID: req.DecidedOperatorUserID, + DecidedReason: req.DecidedReason, + CreatedAt: s.formatTime(req.CreatedAt), + UpdatedAt: s.formatTime(req.UpdatedAt), + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *tenant) DisableInvite(ctx context.Context, tenantID, operatorID, inviteID int64) error { + if tenantID == 0 { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if inviteID == 0 { + return errorx.ErrInvalidParameter.WithMsg("邀请记录不存在") + } + + // 校验操作者权限,避免越权撤销邀请。 + if _, err := s.ensureTenantAdmin(ctx, tenantID, operatorID); err != nil { + return err + } + + tbl, q := models.TenantInviteQuery.QueryContext(ctx) + invite, err := q.Where(tbl.ID.Eq(inviteID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("邀请记录不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if invite.TenantID != tenantID { + return errorx.ErrForbidden.WithMsg("租户不匹配") + } + if invite.Status != string(consts.TenantInviteStatusActive) { + return errorx.ErrBadRequest.WithMsg("邀请码不可撤销") + } + + now := time.Now() + _, err = q.Where( + tbl.ID.Eq(invite.ID), + tbl.Status.Eq(string(consts.TenantInviteStatusActive)), + ).UpdateSimple( + tbl.Status.Value(string(consts.TenantInviteStatusDisabled)), + tbl.DisabledAt.Value(now), + tbl.DisabledOperatorUserID.Value(operatorID), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil +} + +func (s *tenant) RemoveMember(ctx context.Context, tenantID, operatorID, memberID int64) error { + if tenantID == 0 { + return errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + if memberID == 0 { + return errorx.ErrInvalidParameter.WithMsg("成员不存在") + } + + // 校验操作者权限,并拿到租户信息用于保护主账号。 + tenant, err := s.ensureTenantAdmin(ctx, tenantID, operatorID) + if err != nil { + return err + } + + tbl, q := models.TenantUserQuery.QueryContext(ctx) + member, err := q.Where(tbl.ID.Eq(memberID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("成员不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if member.TenantID != tenantID { + return errorx.ErrForbidden.WithMsg("租户不匹配") + } + if tenant != nil && member.UserID == tenant.UserID { + return errorx.ErrBadRequest.WithMsg("无法移除租户主账号") + } + + if _, err := q.Where(tbl.ID.Eq(member.ID)).Delete(); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil +} + func (s *tenant) ensureTenantAdmin(ctx context.Context, tenantID, userID int64) (*models.Tenant, error) { if userID == 0 { return nil, errorx.ErrUnauthorized @@ -425,3 +767,64 @@ func (s *tenant) toTenantInviteItem(invite *models.TenantInvite) *tenant_dto.Ten Remark: invite.Remark, } } + +func (s *tenant) toTenantMemberUserLite(user *models.User) *tenant_dto.TenantMemberUserLite { + if user == nil { + return nil + } + return &tenant_dto.TenantMemberUserLite{ + ID: user.ID, + Username: user.Username, + Phone: user.Phone, + Nickname: user.Nickname, + Avatar: user.Avatar, + } +} + +func (s *tenant) loadUserMap(ctx context.Context, userIDs []int64) (map[int64]*models.User, error) { + userMap := make(map[int64]*models.User) + if len(userIDs) == 0 { + return userMap, nil + } + + tbl, q := models.UserQuery.QueryContext(ctx) + users, err := q.Where(tbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + return userMap, nil +} + +func (s *tenant) lookupUserIDs(ctx context.Context, keyword *string) ([]int64, bool, error) { + text := "" + if keyword != nil { + text = strings.TrimSpace(*keyword) + } + if text == "" { + return nil, false, nil + } + + tbl, q := models.UserQuery.QueryContext(ctx) + like := "%" + text + "%" + q = q.Where(field.Or(tbl.Username.Like(like), tbl.Nickname.Like(like), tbl.Phone.Like(like))) + users, err := q.Select(tbl.ID).Find() + if err != nil { + return nil, true, errorx.ErrDatabaseError.WithCause(err) + } + + ids := make([]int64, 0, len(users)) + for _, user := range users { + ids = append(ids, user.ID) + } + return ids, true, nil +} + +func (s *tenant) formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} diff --git a/backend/app/services/tenant_member_test.go b/backend/app/services/tenant_member_test.go index 502eaa0..cc42075 100644 --- a/backend/app/services/tenant_member_test.go +++ b/backend/app/services/tenant_member_test.go @@ -6,6 +6,7 @@ import ( "quyun/v2/app/errorx" tenant_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/requests" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -302,3 +303,142 @@ func (s *TenantTestSuite) Test_AcceptInvite() { So(invite.UsedCount, ShouldEqual, 1) }) } + +func (s *TenantTestSuite) Test_ListMembersAndRemove() { + Convey("ListMembers and RemoveMember", s.T(), func() { + ctx := s.T().Context() + + database.Truncate(ctx, s.DB, + models.TableNameTenantUser, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_member", Phone: "13900002001"} + member := &models.User{Username: "member_user", Phone: "13900002002"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + _ = models.UserQuery.WithContext(ctx).Create(member) + + tenant := &models.Tenant{ + Name: "Tenant Member", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + link := &models.TenantUser{ + TenantID: tenant.ID, + UserID: member.ID, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, + Status: consts.UserStatusVerified, + } + _ = models.TenantUserQuery.WithContext(ctx).Create(link) + + res, err := Tenant.ListMembers(ctx, tenant.ID, owner.ID, &tenant_dto.TenantMemberListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + }) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + + items := res.Items.([]tenant_dto.TenantMemberItem) + So(items[0].User.ID, ShouldEqual, member.ID) + + err = Tenant.RemoveMember(ctx, tenant.ID, owner.ID, items[0].ID) + So(err, ShouldBeNil) + + exists, err := models.TenantUserQuery.WithContext(ctx). + Where(models.TenantUserQuery.ID.Eq(items[0].ID)). + Exists() + So(err, ShouldBeNil) + So(exists, ShouldBeFalse) + }) +} + +func (s *TenantTestSuite) Test_ListInvitesAndDisable() { + Convey("ListInvites and DisableInvite", s.T(), func() { + ctx := s.T().Context() + + database.Truncate(ctx, s.DB, + models.TableNameTenantInvite, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_invite", Phone: "13900002003"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + + tenant := &models.Tenant{ + Name: "Tenant Invite", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + invite := &models.TenantInvite{ + TenantID: tenant.ID, + UserID: owner.ID, + Code: "invite_list", + Status: string(consts.TenantInviteStatusActive), + MaxUses: 2, + UsedCount: 0, + ExpiresAt: time.Now().Add(24 * time.Hour), + Remark: "测试邀请", + } + _ = models.TenantInviteQuery.WithContext(ctx).Create(invite) + + res, err := Tenant.ListInvites(ctx, tenant.ID, owner.ID, &tenant_dto.TenantInviteListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + }) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + + err = Tenant.DisableInvite(ctx, tenant.ID, owner.ID, invite.ID) + So(err, ShouldBeNil) + + updated, err := models.TenantInviteQuery.WithContext(ctx). + Where(models.TenantInviteQuery.ID.Eq(invite.ID)). + First() + So(err, ShouldBeNil) + So(updated.Status, ShouldEqual, string(consts.TenantInviteStatusDisabled)) + }) +} + +func (s *TenantTestSuite) Test_ListJoinRequests() { + Convey("ListJoinRequests", s.T(), func() { + ctx := s.T().Context() + + database.Truncate(ctx, s.DB, + models.TableNameTenantJoinRequest, + models.TableNameTenant, + models.TableNameUser, + ) + + owner := &models.User{Username: "owner_request", Phone: "13900002004"} + user := &models.User{Username: "request_user", Phone: "13900002005"} + _ = models.UserQuery.WithContext(ctx).Create(owner) + _ = models.UserQuery.WithContext(ctx).Create(user) + + tenant := &models.Tenant{ + Name: "Tenant Request", + UserID: owner.ID, + Status: consts.TenantStatusVerified, + } + _ = models.TenantQuery.WithContext(ctx).Create(tenant) + + req := &models.TenantJoinRequest{ + TenantID: tenant.ID, + UserID: user.ID, + Status: string(consts.TenantJoinRequestStatusPending), + Reason: "申请加入", + } + _ = models.TenantJoinRequestQuery.WithContext(ctx).Create(req) + + status := consts.TenantJoinRequestStatusPending + res, err := Tenant.ListJoinRequests(ctx, tenant.ID, owner.ID, &tenant_dto.TenantJoinRequestListFilter{ + Pagination: requests.Pagination{Page: 1, Limit: 10}, + Status: &status, + }) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + }) +} diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d66bd4f..db8523e 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -4617,6 +4617,98 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/creator/members": { + "get": { + "description": "List tenant members with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List tenant members", + "parameters": [ + { + "type": "string", + "description": "Keyword 关键词搜索(匹配用户名/昵称/手机号)。", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "member", + "tenant_admin" + ], + "type": "string", + "x-enum-varnames": [ + "TenantUserRoleMember", + "TenantUserRoleTenantAdmin" + ], + "description": "Role 成员角色筛选(member/tenant_admin)。", + "name": "role", + "in": "query" + }, + { + "enum": [ + "active", + "inactive", + "pending_verify", + "verified", + "banned" + ], + "type": "string", + "x-enum-varnames": [ + "UserStatusActive", + "UserStatusInactive", + "UserStatusPendingVerify", + "UserStatusVerified", + "UserStatusBanned" + ], + "description": "Status 成员状态筛选(active/verified/banned 等)。", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantMemberItem" + } + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/creator/members/invite": { "post": { "description": "Create an invite for tenant members", @@ -4651,6 +4743,214 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/creator/members/invites": { + "get": { + "description": "List member invites with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List member invites", + "parameters": [ + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "active", + "disabled", + "expired" + ], + "type": "string", + "x-enum-varnames": [ + "TenantInviteStatusActive", + "TenantInviteStatusDisabled", + "TenantInviteStatusExpired" + ], + "description": "Status 邀请状态筛选(active/disabled/expired)。", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantInviteListItem" + } + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/invites/{id}": { + "delete": { + "description": "Disable a member invite by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Disable member invite", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Invite ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Disabled", + "schema": { + "type": "string" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/join-requests": { + "get": { + "description": "List tenant join requests", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List member join requests", + "parameters": [ + { + "type": "string", + "description": "Keyword 关键词搜索(匹配用户名/昵称/手机号)。", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "x-enum-varnames": [ + "TenantJoinRequestStatusPending", + "TenantJoinRequestStatusApproved", + "TenantJoinRequestStatusRejected" + ], + "description": "Status 申请状态筛选(pending/approved/rejected)。", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantJoinRequestItem" + } + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/{id}": { + "delete": { + "description": "Remove a tenant member by relation ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Remove tenant member", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Removed", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/creator/members/{id}/review": { "post": { "description": "Approve or reject a tenant join request", @@ -6666,6 +6966,32 @@ const docTemplate = `{ "RoleCreator" ] }, + "consts.TenantInviteStatus": { + "type": "string", + "enum": [ + "active", + "disabled", + "expired" + ], + "x-enum-varnames": [ + "TenantInviteStatusActive", + "TenantInviteStatusDisabled", + "TenantInviteStatusExpired" + ] + }, + "consts.TenantJoinRequestStatus": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected" + ], + "x-enum-varnames": [ + "TenantJoinRequestStatusPending", + "TenantJoinRequestStatusApproved", + "TenantJoinRequestStatusRejected" + ] + }, "consts.TenantLedgerType": { "type": "string", "enum": [ @@ -10641,6 +10967,55 @@ const docTemplate = `{ } } }, + "dto.TenantInviteListItem": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "creator": { + "description": "Creator 创建者信息(可选)。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantMemberUserLite" + } + ] + }, + "expires_at": { + "description": "ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。", + "type": "string" + }, + "id": { + "description": "ID 邀请记录ID。", + "type": "integer" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注说明。", + "type": "string" + }, + "status": { + "description": "Status 邀请状态(active/disabled/expired)。", + "type": "string" + }, + "status_description": { + "description": "StatusDescription 状态描述。", + "type": "string" + }, + "used_count": { + "description": "UsedCount 已使用次数。", + "type": "integer" + } + } + }, "dto.TenantItem": { "type": "object", "properties": { @@ -10736,6 +11111,55 @@ const docTemplate = `{ } } }, + "dto.TenantJoinRequestItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 申请时间(RFC3339)。", + "type": "string" + }, + "decided_at": { + "description": "DecidedAt 审核时间(RFC3339)。", + "type": "string" + }, + "decided_operator_user_id": { + "description": "DecidedOperatorUserID 审核操作者ID。", + "type": "integer" + }, + "decided_reason": { + "description": "DecidedReason 审核备注/原因。", + "type": "string" + }, + "id": { + "description": "ID 申请记录ID。", + "type": "integer" + }, + "reason": { + "description": "Reason 申请说明。", + "type": "string" + }, + "status": { + "description": "Status 申请状态。", + "type": "string" + }, + "status_description": { + "description": "StatusDescription 状态描述。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "user": { + "description": "User 申请用户信息。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantMemberUserLite" + } + ] + } + } + }, "dto.TenantJoinReviewForm": { "type": "object", "properties": { @@ -10749,6 +11173,86 @@ const docTemplate = `{ } } }, + "dto.TenantMemberItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 加入时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 成员关系记录ID。", + "type": "integer" + }, + "role": { + "description": "Role 成员角色列表。", + "type": "array", + "items": { + "$ref": "#/definitions/consts.TenantUserRole" + } + }, + "role_description": { + "description": "RoleDescription 角色描述列表。", + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "description": "Status 成员状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 成员状态描述。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "user": { + "description": "User 成员用户信息。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantMemberUserLite" + } + ] + } + } + }, + "dto.TenantMemberUserLite": { + "type": "object", + "properties": { + "avatar": { + "description": "Avatar 头像URL。", + "type": "string" + }, + "id": { + "description": "ID 用户ID。", + "type": "integer" + }, + "nickname": { + "description": "Nickname 昵称。", + "type": "string" + }, + "phone": { + "description": "Phone 手机号。", + "type": "string" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "dto.TenantOwnerUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 5c27cab..ad102f4 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4611,6 +4611,98 @@ } } }, + "/t/{tenantCode}/v1/creator/members": { + "get": { + "description": "List tenant members with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List tenant members", + "parameters": [ + { + "type": "string", + "description": "Keyword 关键词搜索(匹配用户名/昵称/手机号)。", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "member", + "tenant_admin" + ], + "type": "string", + "x-enum-varnames": [ + "TenantUserRoleMember", + "TenantUserRoleTenantAdmin" + ], + "description": "Role 成员角色筛选(member/tenant_admin)。", + "name": "role", + "in": "query" + }, + { + "enum": [ + "active", + "inactive", + "pending_verify", + "verified", + "banned" + ], + "type": "string", + "x-enum-varnames": [ + "UserStatusActive", + "UserStatusInactive", + "UserStatusPendingVerify", + "UserStatusVerified", + "UserStatusBanned" + ], + "description": "Status 成员状态筛选(active/verified/banned 等)。", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantMemberItem" + } + } + } + } + ] + } + } + } + } + }, "/t/{tenantCode}/v1/creator/members/invite": { "post": { "description": "Create an invite for tenant members", @@ -4645,6 +4737,214 @@ } } }, + "/t/{tenantCode}/v1/creator/members/invites": { + "get": { + "description": "List member invites with filters", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List member invites", + "parameters": [ + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "active", + "disabled", + "expired" + ], + "type": "string", + "x-enum-varnames": [ + "TenantInviteStatusActive", + "TenantInviteStatusDisabled", + "TenantInviteStatusExpired" + ], + "description": "Status 邀请状态筛选(active/disabled/expired)。", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantInviteListItem" + } + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/invites/{id}": { + "delete": { + "description": "Disable a member invite by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Disable member invite", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Invite ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Disabled", + "schema": { + "type": "string" + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/join-requests": { + "get": { + "description": "List tenant join requests", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "List member join requests", + "parameters": [ + { + "type": "string", + "description": "Keyword 关键词搜索(匹配用户名/昵称/手机号)。", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "Limit is page size; only values in {10,20,50,100} are accepted (otherwise defaults to 10).", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Page is 1-based page index; values \u003c= 0 are normalized to 1.", + "name": "page", + "in": "query" + }, + { + "enum": [ + "pending", + "approved", + "rejected" + ], + "type": "string", + "x-enum-varnames": [ + "TenantJoinRequestStatusPending", + "TenantJoinRequestStatusApproved", + "TenantJoinRequestStatusRejected" + ], + "description": "Status 申请状态筛选(pending/approved/rejected)。", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.TenantJoinRequestItem" + } + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/creator/members/{id}": { + "delete": { + "description": "Remove a tenant member by relation ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CreatorCenter" + ], + "summary": "Remove tenant member", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Member ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Removed", + "schema": { + "type": "string" + } + } + } + } + }, "/t/{tenantCode}/v1/creator/members/{id}/review": { "post": { "description": "Approve or reject a tenant join request", @@ -6660,6 +6960,32 @@ "RoleCreator" ] }, + "consts.TenantInviteStatus": { + "type": "string", + "enum": [ + "active", + "disabled", + "expired" + ], + "x-enum-varnames": [ + "TenantInviteStatusActive", + "TenantInviteStatusDisabled", + "TenantInviteStatusExpired" + ] + }, + "consts.TenantJoinRequestStatus": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected" + ], + "x-enum-varnames": [ + "TenantJoinRequestStatusPending", + "TenantJoinRequestStatusApproved", + "TenantJoinRequestStatusRejected" + ] + }, "consts.TenantLedgerType": { "type": "string", "enum": [ @@ -10635,6 +10961,55 @@ } } }, + "dto.TenantInviteListItem": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "creator": { + "description": "Creator 创建者信息(可选)。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantMemberUserLite" + } + ] + }, + "expires_at": { + "description": "ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。", + "type": "string" + }, + "id": { + "description": "ID 邀请记录ID。", + "type": "integer" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注说明。", + "type": "string" + }, + "status": { + "description": "Status 邀请状态(active/disabled/expired)。", + "type": "string" + }, + "status_description": { + "description": "StatusDescription 状态描述。", + "type": "string" + }, + "used_count": { + "description": "UsedCount 已使用次数。", + "type": "integer" + } + } + }, "dto.TenantItem": { "type": "object", "properties": { @@ -10730,6 +11105,55 @@ } } }, + "dto.TenantJoinRequestItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 申请时间(RFC3339)。", + "type": "string" + }, + "decided_at": { + "description": "DecidedAt 审核时间(RFC3339)。", + "type": "string" + }, + "decided_operator_user_id": { + "description": "DecidedOperatorUserID 审核操作者ID。", + "type": "integer" + }, + "decided_reason": { + "description": "DecidedReason 审核备注/原因。", + "type": "string" + }, + "id": { + "description": "ID 申请记录ID。", + "type": "integer" + }, + "reason": { + "description": "Reason 申请说明。", + "type": "string" + }, + "status": { + "description": "Status 申请状态。", + "type": "string" + }, + "status_description": { + "description": "StatusDescription 状态描述。", + "type": "string" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "user": { + "description": "User 申请用户信息。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantMemberUserLite" + } + ] + } + } + }, "dto.TenantJoinReviewForm": { "type": "object", "properties": { @@ -10743,6 +11167,86 @@ } } }, + "dto.TenantMemberItem": { + "type": "object", + "properties": { + "created_at": { + "description": "CreatedAt 加入时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 成员关系记录ID。", + "type": "integer" + }, + "role": { + "description": "Role 成员角色列表。", + "type": "array", + "items": { + "$ref": "#/definitions/consts.TenantUserRole" + } + }, + "role_description": { + "description": "RoleDescription 角色描述列表。", + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "description": "Status 成员状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 成员状态描述。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "user": { + "description": "User 成员用户信息。", + "allOf": [ + { + "$ref": "#/definitions/dto.TenantMemberUserLite" + } + ] + } + } + }, + "dto.TenantMemberUserLite": { + "type": "object", + "properties": { + "avatar": { + "description": "Avatar 头像URL。", + "type": "string" + }, + "id": { + "description": "ID 用户ID。", + "type": "integer" + }, + "nickname": { + "description": "Nickname 昵称。", + "type": "string" + }, + "phone": { + "description": "Phone 手机号。", + "type": "string" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "dto.TenantOwnerUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 5c24dcd..e3e99ed 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -138,6 +138,26 @@ definitions: - RoleUser - RoleSuperAdmin - RoleCreator + consts.TenantInviteStatus: + enum: + - active + - disabled + - expired + type: string + x-enum-varnames: + - TenantInviteStatusActive + - TenantInviteStatusDisabled + - TenantInviteStatusExpired + consts.TenantJoinRequestStatus: + enum: + - pending + - approved + - rejected + type: string + x-enum-varnames: + - TenantJoinRequestStatusPending + - TenantJoinRequestStatusApproved + - TenantJoinRequestStatusRejected consts.TenantLedgerType: enum: - debit_purchase @@ -2929,6 +2949,40 @@ definitions: description: UsedCount 已使用次数。 type: integer type: object + dto.TenantInviteListItem: + properties: + code: + description: Code 邀请码。 + type: string + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + creator: + allOf: + - $ref: '#/definitions/dto.TenantMemberUserLite' + description: Creator 创建者信息(可选)。 + expires_at: + description: ExpiresAt 过期时间(RFC3339,空字符串表示不限制)。 + type: string + id: + description: ID 邀请记录ID。 + type: integer + max_uses: + description: MaxUses 最大可使用次数。 + type: integer + remark: + description: Remark 备注说明。 + type: string + status: + description: Status 邀请状态(active/disabled/expired)。 + type: string + status_description: + description: StatusDescription 状态描述。 + type: string + used_count: + description: UsedCount 已使用次数。 + type: integer + type: object dto.TenantItem: properties: admin_users: @@ -2994,6 +3048,40 @@ definitions: description: Reason 申请加入原因(可选,空值会使用默认文案)。 type: string type: object + dto.TenantJoinRequestItem: + properties: + created_at: + description: CreatedAt 申请时间(RFC3339)。 + type: string + decided_at: + description: DecidedAt 审核时间(RFC3339)。 + type: string + decided_operator_user_id: + description: DecidedOperatorUserID 审核操作者ID。 + type: integer + decided_reason: + description: DecidedReason 审核备注/原因。 + type: string + id: + description: ID 申请记录ID。 + type: integer + reason: + description: Reason 申请说明。 + type: string + status: + description: Status 申请状态。 + type: string + status_description: + description: StatusDescription 状态描述。 + type: string + updated_at: + description: UpdatedAt 更新时间(RFC3339)。 + type: string + user: + allOf: + - $ref: '#/definitions/dto.TenantMemberUserLite' + description: User 申请用户信息。 + type: object dto.TenantJoinReviewForm: properties: action: @@ -3003,6 +3091,60 @@ definitions: description: Reason 审核说明(可选,用于展示驳回原因或备注)。 type: string type: object + dto.TenantMemberItem: + properties: + created_at: + description: CreatedAt 加入时间(RFC3339)。 + type: string + id: + description: ID 成员关系记录ID。 + type: integer + role: + description: Role 成员角色列表。 + items: + $ref: '#/definitions/consts.TenantUserRole' + type: array + role_description: + description: RoleDescription 角色描述列表。 + items: + type: string + type: array + status: + allOf: + - $ref: '#/definitions/consts.UserStatus' + description: Status 成员状态。 + status_description: + description: StatusDescription 成员状态描述。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + updated_at: + description: UpdatedAt 更新时间(RFC3339)。 + type: string + user: + allOf: + - $ref: '#/definitions/dto.TenantMemberUserLite' + description: User 成员用户信息。 + type: object + dto.TenantMemberUserLite: + properties: + avatar: + description: Avatar 头像URL。 + type: string + id: + description: ID 用户ID。 + type: integer + nickname: + description: Nickname 昵称。 + type: string + phone: + description: Phone 手机号。 + type: string + username: + description: Username 用户名。 + type: string + type: object dto.TenantOwnerUserLite: properties: id: @@ -6503,6 +6645,90 @@ paths: summary: Dashboard stats tags: - CreatorCenter + /t/{tenantCode}/v1/creator/members: + get: + consumes: + - application/json + description: List tenant members with filters + parameters: + - description: Keyword 关键词搜索(匹配用户名/昵称/手机号)。 + in: query + name: keyword + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - description: Role 成员角色筛选(member/tenant_admin)。 + enum: + - member + - tenant_admin + in: query + name: role + type: string + x-enum-varnames: + - TenantUserRoleMember + - TenantUserRoleTenantAdmin + - description: Status 成员状态筛选(active/verified/banned 等)。 + enum: + - active + - inactive + - pending_verify + - verified + - banned + in: query + name: status + type: string + x-enum-varnames: + - UserStatusActive + - UserStatusInactive + - UserStatusPendingVerify + - UserStatusVerified + - UserStatusBanned + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.TenantMemberItem' + type: array + type: object + summary: List tenant members + tags: + - CreatorCenter + /t/{tenantCode}/v1/creator/members/{id}: + delete: + consumes: + - application/json + description: Remove a tenant member by relation ID + parameters: + - description: Member ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Removed + schema: + type: string + summary: Remove tenant member + tags: + - CreatorCenter /t/{tenantCode}/v1/creator/members/{id}/review: post: consumes: @@ -6553,6 +6779,120 @@ paths: summary: Create member invite tags: - CreatorCenter + /t/{tenantCode}/v1/creator/members/invites: + get: + consumes: + - application/json + description: List member invites with filters + parameters: + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - description: Status 邀请状态筛选(active/disabled/expired)。 + enum: + - active + - disabled + - expired + in: query + name: status + type: string + x-enum-varnames: + - TenantInviteStatusActive + - TenantInviteStatusDisabled + - TenantInviteStatusExpired + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.TenantInviteListItem' + type: array + type: object + summary: List member invites + tags: + - CreatorCenter + /t/{tenantCode}/v1/creator/members/invites/{id}: + delete: + consumes: + - application/json + description: Disable a member invite by ID + parameters: + - description: Invite ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: Disabled + schema: + type: string + summary: Disable member invite + tags: + - CreatorCenter + /t/{tenantCode}/v1/creator/members/join-requests: + get: + consumes: + - application/json + description: List tenant join requests + parameters: + - description: Keyword 关键词搜索(匹配用户名/昵称/手机号)。 + in: query + name: keyword + type: string + - description: Limit is page size; only values in {10,20,50,100} are accepted + (otherwise defaults to 10). + in: query + name: limit + type: integer + - description: Page is 1-based page index; values <= 0 are normalized to 1. + in: query + name: page + type: integer + - description: Status 申请状态筛选(pending/approved/rejected)。 + enum: + - pending + - approved + - rejected + in: query + name: status + type: string + x-enum-varnames: + - TenantJoinRequestStatusPending + - TenantJoinRequestStatusApproved + - TenantJoinRequestStatusRejected + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.TenantJoinRequestItem' + type: array + type: object + summary: List member join requests + tags: + - CreatorCenter /t/{tenantCode}/v1/creator/orders: get: consumes: diff --git a/docs/todo_list.md b/docs/todo_list.md index fb760a5..0820d8d 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -159,11 +159,28 @@ **测试方案** - 审计记录落库含 `operator_id`,并覆盖缺参错误。 +### 10) 创作者中心 - 团队成员管理(Portal UI)(已完成) +**需求目标** +- 补齐创作者侧成员列表/邀请/审核等管理能力,覆盖成员生命周期。 +- 角色/状态可视化,支持管理员移除或禁用成员。 + +**技术方案(前端/后端)** +- 前端:`frontend/portal/src/views/creator/` 新增成员管理页面(列表/邀请/审核)。 +- API(如缺失需补齐): + - 成员列表:`GET /t/:tenantCode/v1/creator/members` + - 邀请列表/撤销:`GET /t/:tenantCode/v1/creator/members/invites`、`DELETE /t/:tenantCode/v1/creator/members/invites/:id` + - 申请列表:`GET /t/:tenantCode/v1/creator/members/join-requests` + - 已有:`POST /t/:tenantCode/v1/creator/members/invite`、`POST /t/:tenantCode/v1/creator/members/:id/review` +- Service:复用 `services.Tenant` 现有邀请/审核逻辑,补齐列表查询与权限校验。 + +**测试方案** +- 列表分页/筛选;邀请创建/撤销;审核通过后成员列表可见;权限拦截。 + --- ## P2(中优先) -### 10) 运营统计报表(曝光/转化/订单/退款)(已完成) +### 11) 运营统计报表(曝光/转化/订单/退款)(已完成) **需求目标** - 提供租户维度与时间范围的核心指标统计与导出。 @@ -179,7 +196,7 @@ **测试方案** - 统计口径一致性;筛选组合;导出任务可用性。 -### 11) 超管后台治理能力(健康度/异常监控/内容审核)(已完成) +### 12) 超管后台治理能力(健康度/异常监控/内容审核)(已完成) **需求目标** - 提供超管对租户的健康指标、异常趋势、内容合规审核。 @@ -194,7 +211,7 @@ **测试方案** - 审核状态流转有效性;异常阈值命中结果。 -### 12) 性能优化(避免 N+1)(已完成) +### 13) 性能优化(避免 N+1)(已完成) **需求目标** - 列表/统计场景避免逐条查询。 @@ -205,11 +222,34 @@ **测试方案** - 对比查询次数/耗时(可选) + 数据正确性。 +### 14) 租户公开页完善(Portal) +**需求目标** +- 完善租户主页信息与内容聚合体验。 + +**技术方案(前端/后端)** +- 前端:`frontend/portal/src/views/tenant/` 增加简介/关于模块。 +- 若内容聚合维度不足,补齐筛选参数(如专辑/最新/最热)与接口字段。 + +**测试方案** +- 不同筛选维度下内容列表正确;空内容/无简介时展示兜底。 + +### 15) 微信生态清理(若仍存在) +**需求目标** +- 移除微信登录/分享/支付相关逻辑,降低维护成本。 + +**技术方案(后端/前端/文档)** +- 后端:清理 `auth` 路由与服务中的微信授权/回调逻辑(如存在)。 +- 前端:移除 Portal 登录页/分享中的 `wx.*` 依赖与 UI。 +- 配置/文档:清理 `config.toml`、`specs/*`、`docs/*` 的微信配置与说明;移除无用 SDK 依赖。 + +**测试方案** +- 登录、分享、支付路径不再引用微信 SDK;构建与 lint 通过。 + --- ## P3(延后) -### 13) 真实存储 Provider 接入(生产) +### 16) 真实存储 Provider 接入(生产) **需求目标** - 接入 OSS/云存储(生产环境),统一上传/访问路径策略。 @@ -219,7 +259,19 @@ **测试方案** - 本地 FS + MinIO + 真实 Provider 三套配置可用性。 -### 14) 支付集成 +### 17) 媒体处理管线适配对象存储(S3/MinIO) +**需求目标** +- 在对象存储模式下,媒体处理任务可完整执行并回传产物。 + +**技术方案(后端)** +- Worker:从对象存储下载源文件到临时目录 → FFmpeg 处理 → 结果上传回对象存储 → 清理临时文件。 +- 产物:封面/预览片段自动生成并回写 `media_assets`。 +- 本地 FS 仍保留兼容路径(开发/测试使用)。 + +**测试方案** +- 对象存储模式下上传视频触发处理,封面/预览可访问;任务异常可重试。 + +### 18) 支付集成 **需求目标** - 最终阶段对接真实支付。 diff --git a/frontend/portal/src/api/creator.js b/frontend/portal/src/api/creator.js index fcbdd94..5fcc342 100644 --- a/frontend/portal/src/api/creator.js +++ b/frontend/portal/src/api/creator.js @@ -31,6 +31,25 @@ export const creatorApi = { request(`/creator/coupons/${id}`, { method: "PUT", body: data }), grantCoupon: (id, data) => request(`/creator/coupons/${id}/grant`, { method: "POST", body: data }), + listMembers: (params) => { + const qs = new URLSearchParams(params).toString(); + return request(`/creator/members?${qs}`); + }, + removeMember: (id) => request(`/creator/members/${id}`, { method: "DELETE" }), + listMemberInvites: (params) => { + const qs = new URLSearchParams(params).toString(); + return request(`/creator/members/invites?${qs}`); + }, + createMemberInvite: (data) => + request("/creator/members/invite", { method: "POST", body: data }), + disableMemberInvite: (id) => + request(`/creator/members/invites/${id}`, { method: "DELETE" }), + listMemberJoinRequests: (params) => { + const qs = new URLSearchParams(params).toString(); + return request(`/creator/members/join-requests?${qs}`); + }, + reviewMemberJoinRequest: (id, data) => + request(`/creator/members/${id}/review`, { method: "POST", body: data }), getSettings: () => request("/creator/settings"), updateSettings: (data) => request("/creator/settings", { method: "PUT", body: data }), diff --git a/frontend/portal/src/layout/LayoutCreator.vue b/frontend/portal/src/layout/LayoutCreator.vue index ca604d3..468b7f9 100644 --- a/frontend/portal/src/layout/LayoutCreator.vue +++ b/frontend/portal/src/layout/LayoutCreator.vue @@ -108,6 +108,16 @@ const isFullWidth = computed(() => { 配置 + + + 成员管理 + import("../views/creator/OrdersView.vue"), }, + { + path: "members", + name: "creator-members", + component: () => import("../views/creator/MembersView.vue"), + }, { path: "coupons", name: "creator-coupons", diff --git a/frontend/portal/src/views/creator/MembersView.vue b/frontend/portal/src/views/creator/MembersView.vue new file mode 100644 index 0000000..7bc1cf4 --- /dev/null +++ b/frontend/portal/src/views/creator/MembersView.vue @@ -0,0 +1,706 @@ + + + diff --git a/implementation-plan.md b/implementation-plan.md deleted file mode 100644 index 4c2e2fe..0000000 --- a/implementation-plan.md +++ /dev/null @@ -1,76 +0,0 @@ -# QuyUn v2 Implementation Plan - -根据当前项目状态 review,以下是针对后续开发的实施计划,涵盖清理废弃功能与补全缺失业务模块。 - -## 1. 功能清理 (Cleanup: WeChat Removal) - -微信生态相关功能确认不再需要,需从代码库和文档中彻底移除,以降低维护成本和避免混淆。 - -### 1.1 API 接口 (Backend) -- [ ] 移除 `backend/app/http/v1/auth` 下微信授权登录相关路由 (`/auth/wechat`, `/auth/wechat/callback`)。 -- [ ] 移除 WeChat JS-SDK 签名接口及相关 Service 逻辑。 -- [ ] 清理 `go.mod` 中可能存在的无用微信 SDK 依赖。 - -### 1.2 前端代码 (Frontend) -- [ ] **Portal**: 修改 `frontend/portal/src/views/auth/LoginView.vue`,移除微信登录图标及相关 UI 占位。 -- [ ] **Portal**: 全局搜索并清理残留的 `wx.config`、`wx.ready` 及微信分享相关代码。 - -### 1.3 文档与配置 -- [ ] 更新 `specs/PRD.md` 和 `API.md`,删除关于微信登录、分享及支付的所有规格说明。 -- [ ] 更新 `backend/config.toml` (及模板),移除 `[wechat]` 相关配置项 (AppID, Secret 等)。 - ---- - -## 2. 缺失功能需求 (Missing Features) - -以下功能在后端 API 已有部分支持,但前端缺失页面,或业务逻辑需适配生产环境(特别是 S3)。 - -### 2.1 创作者中心 - 团队成员管理 (Creator Member Management) -**优先级**: High (P0) -**现状**: 后端已提供 `CreateInvite`, `ReviewMember`, `CreateMemberInvite` 等 API,但前端 `frontend/portal/src/views/creator/` 目录下完全缺失对应 UI。 - -**需求描述**: -1. **成员列表页 (Member List)** - - 展示当前租户下的所有成员。 - - 显示成员基本信息(头像、昵称、加入时间)及角色(Owner/Admin/Member)。 - - 提供移除成员的操作入口(仅 Admin/Owner 可见)。 - -2. **邀请功能 (Invite System)** - - **生成邀请**: 提供表单生成邀请链接或邀请码(设置有效期、最大使用次数)。 - - **邀请记录**: 展示当前有效的邀请列表,支持“撤销/禁用”邀请。 - -3. **审核功能 (Join Requests)** - - **待审核列表**: 展示用户主动发起的加入申请 (Reason, User Info)。 - - **审批操作**: 提供“通过”和“拒绝”按钮,调用后端 `ReviewMember` 接口。 - -### 2.2 媒体处理管线适配 (Media Pipeline for S3) -**优先级**: High (P1) -**现状**: `backend/app/jobs/media_process_job.go` 目前逻辑强依赖本地文件系统 (`Local Storage`),无法处理 S3 上的文件。 - -**需求描述**: -1. **处理流程重构**: - - 兼容 S3 存储模式:Worker 需先将源文件从 S3 下载到本地临时目录。 - - 执行 FFmpeg 处理(视频转码、截取封面、音频波形提取)。 - - 将处理后的产物(封面图、预览片段)上传回 S3。 - - 清理本地临时文件。 -2. **封面图自动生成**: - - 视频上传完成后,必须自动截取第一帧或指定帧作为封面 (`cover`),避免前端展示空白。 - -### 2.3 租户公开页完善 (Tenant Public Page) -**优先级**: Medium (P2) -**现状**: `frontend/portal/src/views/tenant/` 目前仅有基础 `HomeView`。 - -**需求描述**: -1. **关于/简介页**: - - 增加展示租户详细介绍 (`Description`, `Bio`) 的区域或模态框。 -2. **内容聚合优化**: - - 确认 `HomeView` 支持按“专辑/Topic”或“最新/最热”维度筛选展示内容。 - ---- - -## 3. 执行路线图 (Roadmap) - -1. **Phase 1 (Cleanup)**: 优先执行 [1. 功能清理],确保代码库整洁,去除干扰项。 -2. **Phase 2 (Frontend)**: 开发 Creator Portal 的 [2.1 团队成员管理] 模块,补全多租户协作能力。 -3. **Phase 3 (Backend)**: 配合 S3 调试进度,重构 [2.2 媒体处理管线],确保线上媒体资源可用。 -4. **Phase 4 (Polish)**: 完善租户公开页细节及最终文档更新。