feat: add creator member management

This commit is contained in:
2026-01-17 20:42:43 +08:00
parent 984a404b5f
commit 7fca7a40e7
14 changed files with 2915 additions and 81 deletions

View File

@@ -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<int> [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<int> [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]

View File

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

View File

@@ -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<int> -> creator.RemoveMember")
router.Delete("/t/:tenantCode/v1/creator/members/:id<int>"[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<int> -> creator.DisableMemberInvite")
router.Delete("/t/:tenantCode/v1/creator/members/invites/:id<int>"[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,

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

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

View File

@@ -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<int>`
- 申请列表:`GET /t/:tenantCode/v1/creator/members/join-requests`
- 已有:`POST /t/:tenantCode/v1/creator/members/invite``POST /t/:tenantCode/v1/creator/members/:id<int>/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) 支付集成
**需求目标**
- 最终阶段对接真实支付。

View File

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

View File

@@ -108,6 +108,16 @@ const isFullWidth = computed(() => {
配置
</div>
<router-link
:to="tenantRoute('/creator/members')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
>
<i
class="pi pi-users text-lg group-hover:scale-110 transition-transform"
></i>
<span class="font-medium">成员管理</span>
</router-link>
<router-link
:to="tenantRoute('/creator/settings')"
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"

View File

@@ -149,6 +149,11 @@ const router = createRouter({
name: "creator-orders",
component: () => import("../views/creator/OrdersView.vue"),
},
{
path: "members",
name: "creator-members",
component: () => import("../views/creator/MembersView.vue"),
},
{
path: "coupons",
name: "creator-coupons",

View File

@@ -0,0 +1,706 @@
<script setup>
import Dialog from "primevue/dialog";
import Toast from "primevue/toast";
import { useToast } from "primevue/usetoast";
import { computed, onMounted, reactive, ref } from "vue";
import { creatorApi } from "../../api/creator";
const toast = useToast();
const activeTab = ref("members");
const loading = reactive({
members: false,
invites: false,
requests: false,
});
const memberKeyword = ref("");
const memberRole = ref("");
const memberStatus = ref("");
const members = ref([]);
const memberPager = ref({ page: 1, limit: 10, total: 0 });
const inviteStatus = ref("");
const invites = ref([]);
const invitePager = ref({ page: 1, limit: 10, total: 0 });
const inviteDialog = ref(false);
const inviteForm = reactive({
max_uses: 1,
expires_at: "",
remark: "",
});
const requestKeyword = ref("");
const requestStatus = ref("pending");
const requests = ref([]);
const requestPager = ref({ page: 1, limit: 10, total: 0 });
const reviewDialog = ref(false);
const reviewAction = ref("approve");
const reviewReason = ref("");
const reviewTarget = ref(null);
const tabs = [
{ key: "members", label: "成员列表" },
{ key: "requests", label: "加入申请" },
{ key: "invites", label: "邀请记录" },
];
const fetchMembers = async () => {
loading.members = true;
try {
const res = await creatorApi.listMembers({
page: memberPager.value.page,
limit: memberPager.value.limit,
keyword: memberKeyword.value,
role: memberRole.value,
status: memberStatus.value,
});
members.value = res.items || [];
memberPager.value.total = res.total || 0;
} catch (e) {
console.error(e);
} finally {
loading.members = false;
}
};
const fetchInvites = async () => {
loading.invites = true;
try {
const res = await creatorApi.listMemberInvites({
page: invitePager.value.page,
limit: invitePager.value.limit,
status: inviteStatus.value,
});
invites.value = res.items || [];
invitePager.value.total = res.total || 0;
} catch (e) {
console.error(e);
} finally {
loading.invites = false;
}
};
const fetchJoinRequests = async () => {
loading.requests = true;
try {
const res = await creatorApi.listMemberJoinRequests({
page: requestPager.value.page,
limit: requestPager.value.limit,
status: requestStatus.value,
keyword: requestKeyword.value,
});
requests.value = res.items || [];
requestPager.value.total = res.total || 0;
} catch (e) {
console.error(e);
} finally {
loading.requests = false;
}
};
onMounted(() => {
fetchMembers();
fetchInvites();
fetchJoinRequests();
});
const memberStatusStyle = (status) => {
switch (status) {
case "verified":
return { bg: "bg-emerald-50", text: "text-emerald-600" };
case "banned":
return { bg: "bg-rose-50", text: "text-rose-600" };
default:
return { bg: "bg-slate-100", text: "text-slate-500" };
}
};
const inviteStatusStyle = (status) => {
switch (status) {
case "active":
return { bg: "bg-emerald-50", text: "text-emerald-600" };
case "disabled":
return { bg: "bg-slate-100", text: "text-slate-500" };
case "expired":
return { bg: "bg-amber-50", text: "text-amber-700" };
default:
return { bg: "bg-slate-100", text: "text-slate-500" };
}
};
const requestStatusStyle = (status) => {
switch (status) {
case "pending":
return { bg: "bg-amber-50", text: "text-amber-700" };
case "approved":
return { bg: "bg-emerald-50", text: "text-emerald-600" };
case "rejected":
return { bg: "bg-rose-50", text: "text-rose-600" };
default:
return { bg: "bg-slate-100", text: "text-slate-500" };
}
};
const openInviteDialog = () => {
inviteForm.max_uses = 1;
inviteForm.expires_at = "";
inviteForm.remark = "";
inviteDialog.value = true;
};
const createInvite = async () => {
try {
const payload = {
max_uses: Number(inviteForm.max_uses) || 1,
remark: inviteForm.remark,
};
if (inviteForm.expires_at) {
payload.expires_at = new Date(inviteForm.expires_at).toISOString();
}
await creatorApi.createMemberInvite(payload);
inviteDialog.value = false;
toast.add({ severity: "success", summary: "已创建邀请", life: 2000 });
fetchInvites();
} catch (e) {
toast.add({
severity: "error",
summary: "创建失败",
detail: e.message,
life: 3000,
});
}
};
const disableInvite = async (invite) => {
if (!invite || !invite.id) return;
if (!window.confirm("确认撤销该邀请?")) return;
try {
await creatorApi.disableMemberInvite(invite.id);
toast.add({ severity: "success", summary: "已撤销", life: 2000 });
fetchInvites();
} catch (e) {
toast.add({
severity: "error",
summary: "撤销失败",
detail: e.message,
life: 3000,
});
}
};
const removeMember = async (member) => {
if (!member || !member.id) return;
if (!window.confirm("确认移除该成员?")) return;
try {
await creatorApi.removeMember(member.id);
toast.add({ severity: "success", summary: "成员已移除", life: 2000 });
fetchMembers();
} catch (e) {
toast.add({
severity: "error",
summary: "移除失败",
detail: e.message,
life: 3000,
});
}
};
const openReviewDialog = (item, action) => {
reviewTarget.value = item;
reviewAction.value = action;
reviewReason.value = "";
reviewDialog.value = true;
};
const submitReview = async () => {
if (!reviewTarget.value) return;
try {
await creatorApi.reviewMemberJoinRequest(reviewTarget.value.id, {
action: reviewAction.value,
reason: reviewReason.value,
});
reviewDialog.value = false;
toast.add({ severity: "success", summary: "已处理申请", life: 2000 });
fetchJoinRequests();
fetchMembers();
} catch (e) {
toast.add({
severity: "error",
summary: "处理失败",
detail: e.message,
life: 3000,
});
}
};
const tabClass = (key) =>
key === activeTab.value
? "bg-slate-900 text-white"
: "bg-white text-slate-600 border border-slate-200 hover:bg-slate-50";
const pendingRequests = computed(() =>
requests.value.filter((item) => item.status === "pending"),
);
</script>
<template>
<div>
<Toast />
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-900">成员管理</h1>
<div class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.key"
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors"
:class="tabClass(tab.key)"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
</div>
<div v-if="activeTab === 'members'" class="space-y-6">
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 flex flex-wrap gap-4 items-center"
>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">角色:</span>
<select
v-model="memberRole"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="">全部</option>
<option value="member">成员</option>
<option value="tenant_admin">管理员</option>
</select>
</div>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">状态:</span>
<select
v-model="memberStatus"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="">全部</option>
<option value="verified">已审核</option>
<option value="banned">已封禁</option>
<option value="active">正常</option>
</select>
</div>
<div class="ml-auto relative w-72">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
type="text"
v-model="memberKeyword"
@keyup.enter="fetchMembers"
placeholder="搜索昵称/手机号..."
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="fetchMembers"
>
筛选
</button>
</div>
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
>
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
>
<tr>
<th class="px-6 py-4">成员</th>
<th class="px-6 py-4">角色</th>
<th class="px-6 py-4">状态</th>
<th class="px-6 py-4">加入时间</th>
<th class="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="loading.members">
<td colspan="5" class="px-6 py-6 text-center text-slate-500">
加载中...
</td>
</tr>
<tr v-else-if="members.length === 0">
<td colspan="5" class="px-6 py-8 text-center text-slate-400">
暂无成员数据
</td>
</tr>
<tr
v-for="member in members"
:key="member.id"
class="hover:bg-slate-50"
>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<img
:src="
member.user?.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${member.user?.id}`
"
class="w-10 h-10 rounded-full object-cover"
/>
<div>
<div class="font-semibold text-slate-900">
{{
member.user?.nickname || member.user?.username || "-"
}}
</div>
<div class="text-xs text-slate-400">
{{ member.user?.phone || "--" }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-slate-700">
{{ member.role_description?.join(" / ") || "成员" }}
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
:class="`${memberStatusStyle(member.status).bg} ${memberStatusStyle(member.status).text}`"
>
{{ member.status_description || member.status }}
</span>
</td>
<td class="px-6 py-4 text-slate-500">
{{ member.created_at || "--" }}
</td>
<td class="px-6 py-4 text-right">
<button
class="text-rose-600 text-sm font-semibold hover:text-rose-700"
@click="removeMember(member)"
>
移除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="activeTab === 'requests'" class="space-y-6">
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 flex flex-wrap gap-4 items-center"
>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">状态:</span>
<select
v-model="requestStatus"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
<option value="">全部</option>
</select>
</div>
<div class="ml-auto relative w-72">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
></i>
<input
type="text"
v-model="requestKeyword"
@keyup.enter="fetchJoinRequests"
placeholder="搜索昵称/手机号..."
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="fetchJoinRequests"
>
筛选
</button>
</div>
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
>
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
>
<tr>
<th class="px-6 py-4">申请人</th>
<th class="px-6 py-4">申请理由</th>
<th class="px-6 py-4">状态</th>
<th class="px-6 py-4">申请时间</th>
<th class="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="loading.requests">
<td colspan="5" class="px-6 py-6 text-center text-slate-500">
加载中...
</td>
</tr>
<tr v-else-if="requests.length === 0">
<td colspan="5" class="px-6 py-8 text-center text-slate-400">
暂无申请记录
</td>
</tr>
<tr v-for="req in requests" :key="req.id" class="hover:bg-slate-50">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<img
:src="
req.user?.avatar ||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${req.user?.id}`
"
class="w-10 h-10 rounded-full object-cover"
/>
<div>
<div class="font-semibold text-slate-900">
{{ req.user?.nickname || req.user?.username || "-" }}
</div>
<div class="text-xs text-slate-400">
{{ req.user?.phone || "--" }}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-slate-600">
{{ req.reason || "-" }}
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
:class="`${requestStatusStyle(req.status).bg} ${requestStatusStyle(req.status).text}`"
>
{{ req.status_description || req.status }}
</span>
</td>
<td class="px-6 py-4 text-slate-500">
{{ req.created_at || "--" }}
</td>
<td class="px-6 py-4 text-right">
<div
v-if="req.status === 'pending'"
class="flex justify-end gap-2"
>
<button
class="px-3 py-1 text-sm rounded-lg bg-emerald-500 text-white"
@click="openReviewDialog(req, 'approve')"
>
通过
</button>
<button
class="px-3 py-1 text-sm rounded-lg bg-rose-500 text-white"
@click="openReviewDialog(req, 'reject')"
>
拒绝
</button>
</div>
<span v-else class="text-slate-400 text-sm">已处理</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="pendingRequests.length === 0" class="text-sm text-slate-500">
当前暂无待审核申请
</div>
</div>
<div v-else class="space-y-6">
<div class="flex items-center justify-between">
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 flex gap-4 items-center"
>
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-slate-500">状态:</span>
<select
v-model="inviteStatus"
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
>
<option value="">全部</option>
<option value="active">可用</option>
<option value="disabled">已撤销</option>
<option value="expired">已过期</option>
</select>
</div>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="fetchInvites"
>
筛选
</button>
</div>
<button
class="px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-semibold shadow-sm"
@click="openInviteDialog"
>
<i class="pi pi-plus mr-1"></i> 新建邀请
</button>
</div>
<div
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
>
<table class="w-full text-left text-sm">
<thead
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
>
<tr>
<th class="px-6 py-4">邀请码</th>
<th class="px-6 py-4">状态</th>
<th class="px-6 py-4">使用情况</th>
<th class="px-6 py-4">过期时间</th>
<th class="px-6 py-4">备注</th>
<th class="px-6 py-4 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-if="loading.invites">
<td colspan="6" class="px-6 py-6 text-center text-slate-500">
加载中...
</td>
</tr>
<tr v-else-if="invites.length === 0">
<td colspan="6" class="px-6 py-8 text-center text-slate-400">
暂无邀请记录
</td>
</tr>
<tr
v-for="invite in invites"
:key="invite.id"
class="hover:bg-slate-50"
>
<td class="px-6 py-4 font-mono text-slate-700">
{{ invite.code }}
</td>
<td class="px-6 py-4">
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
:class="`${inviteStatusStyle(invite.status).bg} ${inviteStatusStyle(invite.status).text}`"
>
{{ invite.status_description || invite.status }}
</span>
</td>
<td class="px-6 py-4 text-slate-600">
{{ invite.used_count }} / {{ invite.max_uses }}
</td>
<td class="px-6 py-4 text-slate-500">
{{ invite.expires_at || "长期有效" }}
</td>
<td class="px-6 py-4 text-slate-500">
{{ invite.remark || "-" }}
</td>
<td class="px-6 py-4 text-right">
<button
v-if="invite.status === 'active'"
class="text-rose-600 text-sm font-semibold hover:text-rose-700"
@click="disableInvite(invite)"
>
撤销
</button>
<span v-else class="text-slate-400 text-sm">--</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Dialog
v-model:visible="inviteDialog"
modal
header="新建邀请"
class="w-[420px]"
>
<div class="space-y-4">
<div>
<label class="block text-sm text-slate-600 mb-2">最大使用次数</label>
<input
type="number"
min="1"
v-model="inviteForm.max_uses"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<div>
<label class="block text-sm text-slate-600 mb-2">过期时间</label>
<input
type="datetime-local"
v-model="inviteForm.expires_at"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
<div>
<label class="block text-sm text-slate-600 mb-2">备注</label>
<input
type="text"
v-model="inviteForm.remark"
placeholder="可选"
class="w-full h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
/>
</div>
</div>
<template #footer>
<button
class="px-4 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 mr-2"
@click="inviteDialog = false"
>
取消
</button>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="createInvite"
>
创建
</button>
</template>
</Dialog>
<Dialog
v-model:visible="reviewDialog"
modal
header="审核申请"
class="w-[420px]"
>
<div class="space-y-4">
<div class="text-sm text-slate-600">
当前操作
<span class="font-semibold text-slate-900">{{
reviewAction === "approve" ? "通过" : "拒绝"
}}</span>
</div>
<div>
<label class="block text-sm text-slate-600 mb-2">备注说明</label>
<textarea
v-model="reviewReason"
rows="3"
placeholder="可选"
class="w-full px-3 py-2 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
></textarea>
</div>
</div>
<template #footer>
<button
class="px-4 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 mr-2"
@click="reviewDialog = false"
>
取消
</button>
<button
class="px-4 py-2 rounded-lg bg-slate-900 text-white text-sm font-semibold"
@click="submitReview"
>
确认
</button>
</template>
</Dialog>
</div>
</template>

View File

@@ -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)**: 完善租户公开页细节及最终文档更新。