tenant: add member management APIs

This commit is contained in:
2025-12-18 17:29:17 +08:00
parent 5029234e47
commit de574bbd9d
11 changed files with 815 additions and 14 deletions

View File

@@ -0,0 +1,26 @@
package dto
import (
"quyun/v2/app/requests"
"quyun/v2/database/models"
)
// AdminTenantUserJoinResponse 返回租户管理员添加成员后的结果。
type AdminTenantUserJoinResponse struct {
// TenantUser 租户成员关系记录。
TenantUser *models.TenantUser `json:"tenant_user,omitempty"`
}
// AdminTenantUserRoleUpdateForm 租户成员角色更新表单。
type AdminTenantUserRoleUpdateForm struct {
// Role 角色member/tenant_admin。
Role string `json:"role,omitempty"`
}
// AdminTenantUserListFilter 租户管理员查询成员列表的过滤条件。
type AdminTenantUserListFilter struct {
// Pagination 分页参数page/limit
requests.Pagination `json:",inline" query:",inline"`
// UserID 按用户ID过滤可选
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
}

View File

@@ -60,15 +60,17 @@ func Provide(opts ...opt.Option) error {
order *order,
orderAdmin *orderAdmin,
orderMe *orderMe,
tenantUserAdmin *tenantUserAdmin,
) (contracts.HttpRoute, error) {
obj := &Routes{
content: content,
contentAdmin: contentAdmin,
me: me,
middlewares: middlewares,
order: order,
orderAdmin: orderAdmin,
orderMe: orderMe,
content: content,
contentAdmin: contentAdmin,
me: me,
middlewares: middlewares,
order: order,
orderAdmin: orderAdmin,
orderMe: orderMe,
tenantUserAdmin: tenantUserAdmin,
}
if err := obj.Prepare(); err != nil {
return nil, err
@@ -78,5 +80,12 @@ func Provide(opts ...opt.Option) error {
}, atom.GroupRoutes); err != nil {
return err
}
if err := container.Container.Provide(func() (*tenantUserAdmin, error) {
obj := &tenantUserAdmin{}
return obj, nil
}); err != nil {
return err
}
return nil
}

View File

@@ -24,12 +24,13 @@ type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
content *content
contentAdmin *contentAdmin
me *me
order *order
orderAdmin *orderAdmin
orderMe *orderMe
content *content
contentAdmin *contentAdmin
me *me
order *order
orderAdmin *orderAdmin
orderMe *orderMe
tenantUserAdmin *tenantUserAdmin
}
// Prepare initializes the routes provider with logging configuration.
@@ -184,6 +185,29 @@ func (r *Routes) Register(router fiber.Router) {
Local[*models.User]("user"),
PathParam[int64]("orderID"),
))
// Register routes for controller: tenantUserAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/users -> tenantUserAdmin.adminTenantUsers")
router.Get("/t/:tenantCode/v1/admin/users"[len(r.Path()):], DataFunc3(
r.tenantUserAdmin.adminTenantUsers,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminTenantUserListFilter]("filter"),
))
r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/users/:userID/role -> tenantUserAdmin.adminSetUserRole")
router.Patch("/t/:tenantCode/v1/admin/users/:userID/role"[len(r.Path()):], DataFunc4(
r.tenantUserAdmin.adminSetUserRole,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("userID"),
Body[dto.AdminTenantUserRoleUpdateForm]("form"),
))
r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/users/:userID/join -> tenantUserAdmin.adminJoinUser")
router.Post("/t/:tenantCode/v1/admin/users/:userID/join"[len(r.Path()):], DataFunc3(
r.tenantUserAdmin.adminJoinUser,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("userID"),
))
r.log.Info("Successfully registered all routes")
}

View File

@@ -0,0 +1,183 @@
package tenant
import (
"strings"
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen"
)
// tenantUserAdmin provides tenant-admin member management endpoints.
//
// @provider
type tenantUserAdmin struct{}
// adminJoinUser
//
// @Summary 添加租户成员(租户管理)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param userID path int64 true "UserID"
// @Success 200 {object} dto.AdminTenantUserJoinResponse
//
// @Router /t/:tenantCode/v1/admin/users/:userID/join [post]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind userID path
func (*tenantUserAdmin) adminJoinUser(
ctx fiber.Ctx,
tenant *models.Tenant,
tenantUser *models.TenantUser,
userID int64,
) (*dto.AdminTenantUserJoinResponse, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
if userID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("user_id must be > 0")
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"operator_user_id": tenantUser.UserID,
"target_user_id": userID,
"operator_is_admin": true,
}).Info("tenant.admin.users.join")
// 关键逻辑:以 TenantUser 为准创建成员关系;服务层保证幂等(已存在则不重复创建)。
if err := services.Tenant.AddUser(ctx.Context(), tenant.ID, userID); err != nil {
return nil, err
}
m, err := services.Tenant.FindTenantUser(ctx.Context(), tenant.ID, userID)
if err != nil {
return nil, err
}
return &dto.AdminTenantUserJoinResponse{TenantUser: m}, nil
}
// adminSetUserRole
//
// @Summary 设置成员角色(租户管理)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param userID path int64 true "UserID"
// @Param form body dto.AdminTenantUserRoleUpdateForm true "Form"
// @Success 200 {object} dto.AdminTenantUserJoinResponse
//
// @Router /t/:tenantCode/v1/admin/users/:userID/role [patch]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind userID path
// @Bind form body
func (*tenantUserAdmin) adminSetUserRole(
ctx fiber.Ctx,
tenant *models.Tenant,
tenantUser *models.TenantUser,
userID int64,
form *dto.AdminTenantUserRoleUpdateForm,
) (*dto.AdminTenantUserJoinResponse, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
if userID <= 0 || form == nil {
return nil, errorx.ErrInvalidParameter
}
roleStr := strings.TrimSpace(form.Role)
if roleStr == "" {
return nil, errorx.ErrInvalidParameter.WithMsg("role is required")
}
var role consts.TenantUserRole
switch roleStr {
case string(consts.TenantUserRoleMember):
role = consts.TenantUserRoleMember
case string(consts.TenantUserRoleTenantAdmin):
role = consts.TenantUserRoleTenantAdmin
default:
return nil, errorx.ErrInvalidParameter.WithMsg("invalid role")
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"operator_user_id": tenantUser.UserID,
"target_user_id": userID,
"role": role,
}).Info("tenant.admin.users.set_role")
if err := services.Tenant.SetUserRole(ctx.Context(), tenant.ID, userID, role); err != nil {
return nil, err
}
m, err := services.Tenant.FindTenantUser(ctx.Context(), tenant.ID, userID)
if err != nil {
return nil, err
}
return &dto.AdminTenantUserJoinResponse{TenantUser: m}, nil
}
// adminTenantUsers
//
// @Summary 成员列表(租户管理)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param filter query dto.AdminTenantUserListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=models.TenantUser}
//
// @Router /t/:tenantCode/v1/admin/users [get]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind filter query
func (*tenantUserAdmin) adminTenantUsers(
ctx fiber.Ctx,
tenant *models.Tenant,
tenantUser *models.TenantUser,
filter *dto.AdminTenantUserListFilter,
) (*requests.Pager, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
if filter == nil {
filter = &dto.AdminTenantUserListFilter{}
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
"query_uid": filter.UserID,
}).Info("tenant.admin.users.list")
filter.Pagination.Format()
tbl, query := models.TenantUserQuery.QueryContext(ctx.Context())
conds := []gen.Condition{tbl.TenantID.Eq(tenant.ID)}
if filter.UserID != nil && *filter.UserID > 0 {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}

View File

@@ -15,6 +15,8 @@ import (
"github.com/samber/lo"
"github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// tenant implements tenant-related domain operations.
@@ -35,9 +37,27 @@ func (t *tenant) ContainsUserID(ctx context.Context, tenantID, userID int64) (*m
// AddUser
func (t *tenant) AddUser(ctx context.Context, tenantID, userID int64) error {
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
}).Info("services.tenant.add_user")
// 幂等:若成员关系已存在,则直接返回成功,避免重复插入触发唯一约束错误。
tbl, query := models.TenantUserQuery.QueryContext(ctx)
_, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First()
if err == nil {
return nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(err, "AddUser failed to query existing, tenantID: %d, userID: %d", tenantID, userID)
}
// 关键默认值:加入租户默认成为 member并设置为 verified避免 DB 默认值与枚举不一致导致脏数据)。
tenantUser := &models.TenantUser{
TenantID: tenantID,
UserID: userID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
}
if err := tenantUser.Create(ctx); err != nil {
@@ -69,7 +89,8 @@ func (t *tenant) SetUserRole(ctx context.Context, tenantID, userID int64, role .
return errors.Wrapf(err, "SetUserRole failed to find, tenantID: %d, userID: %d", tenantID, userID)
}
tenantUser.Role = role
// 角色更新:当前约定 role 数组通常只存一个主角色member/tenant_admin
tenantUser.Role = types.NewArray(role)
if _, err := tenantUser.Update(ctx); err != nil {
return errors.Wrapf(err, "SetUserRole failed to update, tenantID: %d, userID: %d", tenantID, userID)
}

View File

@@ -7,6 +7,7 @@ import (
"quyun/v2/app/commands/testx"
"quyun/v2/database"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"quyun/v2/pkg/utils"
. "github.com/smartystreets/goconvey/convey"
@@ -48,3 +49,52 @@ func (t *TenantTestSuite) Test_TenantUserCount() {
t.T().Logf("%s", utils.MustJsonString(result))
})
}
func (t *TenantTestSuite) Test_AddUser() {
Convey("Tenant.AddUser", t.T(), func() {
ctx := t.T().Context()
tenantID := int64(1)
userID := int64(2)
database.Truncate(ctx, t.DB, models.TableNameTenantUser)
Convey("首次添加成员成功", func() {
err := Tenant.AddUser(ctx, tenantID, userID)
So(err, ShouldBeNil)
m, err := Tenant.FindTenantUser(ctx, tenantID, userID)
So(err, ShouldBeNil)
So(m, ShouldNotBeNil)
So(m.TenantID, ShouldEqual, tenantID)
So(m.UserID, ShouldEqual, userID)
})
Convey("重复添加应幂等返回成功", func() {
So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil)
So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil)
})
})
}
func (t *TenantTestSuite) Test_SetUserRole() {
Convey("Tenant.SetUserRole", t.T(), func() {
ctx := t.T().Context()
tenantID := int64(1)
userID := int64(2)
database.Truncate(ctx, t.DB, models.TableNameTenantUser)
So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil)
Convey("设置为 tenant_admin 成功", func() {
err := Tenant.SetUserRole(ctx, tenantID, userID, consts.TenantUserRoleTenantAdmin)
So(err, ShouldBeNil)
m, err := Tenant.FindTenantUser(ctx, tenantID, userID)
So(err, ShouldBeNil)
So(m, ShouldNotBeNil)
So(len(m.Role), ShouldEqual, 1)
So(m.Role[0], ShouldEqual, consts.TenantUserRoleTenantAdmin)
})
})
}