From 462bde351daf8a69b50f00398bade544019254f8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 18 Dec 2025 18:03:32 +0800 Subject: [PATCH] tenant: move admin member queries into service --- .../app/http/tenant/dto/tenant_user_admin.go | 24 ++++++ backend/app/http/tenant/routes.gen.go | 7 ++ backend/app/http/tenant/tenant_user_admin.go | 67 +++++++++------ backend/app/services/tenant.go | 80 ++++++++++++++++++ backend/app/services/tenant_test.go | 82 +++++++++++++++++++ backend/docs/docs.go | 37 ++++++++- backend/docs/swagger.json | 37 ++++++++- backend/docs/swagger.yaml | 23 +++++- 8 files changed, 331 insertions(+), 26 deletions(-) diff --git a/backend/app/http/tenant/dto/tenant_user_admin.go b/backend/app/http/tenant/dto/tenant_user_admin.go index 7e53649..b380d64 100644 --- a/backend/app/http/tenant/dto/tenant_user_admin.go +++ b/backend/app/http/tenant/dto/tenant_user_admin.go @@ -1,8 +1,11 @@ package dto import ( + "strings" + "quyun/v2/app/requests" "quyun/v2/database/models" + "quyun/v2/pkg/consts" ) // AdminTenantUserJoinResponse 返回租户管理员添加成员后的结果。 @@ -23,4 +26,25 @@ type AdminTenantUserListFilter struct { requests.Pagination `json:",inline" query:",inline"` // UserID 按用户ID过滤(可选)。 UserID *int64 `json:"user_id,omitempty" query:"user_id"` + // Role 按角色过滤(可选):member/tenant_admin。 + Role *consts.TenantUserRole `json:"role,omitempty" query:"role"` + // Status 按成员状态过滤(可选):pending_verify/verified/banned。 + Status *consts.UserStatus `json:"status,omitempty" query:"status"` + // Username 按用户名模糊查询(可选,支持包含匹配)。 + Username *string `json:"username,omitempty" query:"username"` +} + +// AdminTenantUserItem 为租户成员列表项(包含成员关系与用户基础信息)。 +type AdminTenantUserItem struct { + // TenantUser 租户成员关系记录。 + TenantUser *models.TenantUser `json:"tenant_user,omitempty"` + // User 用户基础信息(用于展示 username 等)。 + User *models.User `json:"user,omitempty"` +} + +func (f *AdminTenantUserListFilter) UsernameTrimmed() string { + if f == nil || f.Username == nil { + return "" + } + return strings.TrimSpace(*f.Username) } diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index 17def4b..ba30260 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -186,6 +186,13 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("orderID"), )) // Register routes for controller: tenantUserAdmin + r.log.Debugf("Registering route: Delete /t/:tenantCode/v1/admin/users/:userID -> tenantUserAdmin.adminRemoveUser") + router.Delete("/t/:tenantCode/v1/admin/users/:userID"[len(r.Path()):], Func3( + r.tenantUserAdmin.adminRemoveUser, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("userID"), + )) 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, diff --git a/backend/app/http/tenant/tenant_user_admin.go b/backend/app/http/tenant/tenant_user_admin.go index c2d0edf..fcb3966 100644 --- a/backend/app/http/tenant/tenant_user_admin.go +++ b/backend/app/http/tenant/tenant_user_admin.go @@ -12,7 +12,6 @@ import ( "github.com/gofiber/fiber/v3" log "github.com/sirupsen/logrus" - "go.ipao.vip/gen" ) // tenantUserAdmin provides tenant-admin member management endpoints. @@ -20,6 +19,44 @@ import ( // @provider type tenantUserAdmin struct{} +// adminRemoveUser +// +// @Summary 移除租户成员(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param userID path int64 true "UserID" +// @Success 200 {object} requests.Pager +// +// @Router /t/:tenantCode/v1/admin/users/:userID [delete] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind userID path +func (*tenantUserAdmin) adminRemoveUser( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + userID int64, +) error { + if err := requireTenantAdmin(tenantUser); err != nil { + return err + } + if userID <= 0 { + return 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.remove") + + // 关键语义:删除成员接口幂等化;目标用户不属于租户时也返回成功,便于后台重试与批量操作。 + return services.Tenant.RemoveUser(ctx.Context(), tenant.ID, userID) +} + // adminJoinUser // // @Summary 添加租户成员(租户管理) @@ -137,7 +174,7 @@ func (*tenantUserAdmin) adminSetUserRole( // @Produce json // @Param tenantCode path string true "Tenant Code" // @Param filter query dto.AdminTenantUserListFilter true "Filter" -// @Success 200 {object} requests.Pager{items=models.TenantUser} +// @Success 200 {object} requests.Pager{items=dto.AdminTenantUserItem} // // @Router /t/:tenantCode/v1/admin/users [get] // @Bind tenant local key(tenant) @@ -152,32 +189,16 @@ func (*tenantUserAdmin) adminTenantUsers( 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, + "role": filter.Role, + "status": filter.Status, + "username": filter.UsernameTrimmed(), }).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 + // 按 llm.txt 约束:HTTP 层不允许直接查询数据库,全部交由 services 层处理。 + return services.Tenant.AdminTenantUsersPage(ctx.Context(), tenant.ID, filter) } diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index b228a21..cf766c5 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -6,6 +6,7 @@ import ( "time" "quyun/v2/app/http/super/dto" + tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/database" "quyun/v2/database/models" @@ -24,6 +25,81 @@ import ( // @provider type tenant struct{} +// AdminTenantUsersPage 租户管理员分页查询成员列表(包含用户基础信息)。 +func (t *tenant) AdminTenantUsersPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errors.New("tenant_id must be > 0") + } + if filter == nil { + filter = &tenantdto.AdminTenantUserListFilter{} + } + + filter.Pagination.Format() + + tbl, query := models.TenantUserQuery.QueryContext(ctx) + conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} + if filter.UserID != nil && *filter.UserID > 0 { + conds = append(conds, tbl.UserID.Eq(*filter.UserID)) + } + if filter.Role != nil && *filter.Role != "" { + conds = append(conds, tbl.Role.Contains(string(*filter.Role))) + } + if filter.Status != nil && *filter.Status != "" { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + if username := filter.UsernameTrimmed(); username != "" { + uTbl, _ := models.UserQuery.QueryContext(ctx) + query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID)) + conds = append(conds, uTbl.Username.Like(database.WrapLike(username))) + } + + items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit)) + if err != nil { + return nil, err + } + + userIDs := make([]int64, 0, len(items)) + for _, tu := range items { + if tu == nil { + continue + } + userIDs = append(userIDs, tu.UserID) + } + + var users []*models.User + if len(userIDs) > 0 { + uTbl, uQuery := models.UserQuery.QueryContext(ctx) + users, err = uQuery.Where(uTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, err + } + } + userMap := make(map[int64]*models.User, len(users)) + for _, u := range users { + if u == nil { + continue + } + userMap[u.ID] = u + } + + out := make([]*tenantdto.AdminTenantUserItem, 0, len(items)) + for _, tu := range items { + if tu == nil { + continue + } + out = append(out, &tenantdto.AdminTenantUserItem{ + TenantUser: tu, + User: userMap[tu.UserID], + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: out, + }, nil +} + func (t *tenant) ContainsUserID(ctx context.Context, tenantID, userID int64) (*models.User, error) { tbl, query := models.TenantUserQuery.QueryContext(ctx) @@ -71,6 +147,10 @@ func (t *tenant) RemoveUser(ctx context.Context, tenantID, userID int64) error { tbl, query := models.TenantUserQuery.QueryContext(ctx) tenantUser, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First() if err != nil { + // 幂等:成员不存在时也返回成功,便于后台重试/批量移除。 + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } return errors.Wrapf(err, "RemoveUser failed to find, tenantID: %d, userID: %d", tenantID, userID) } diff --git a/backend/app/services/tenant_test.go b/backend/app/services/tenant_test.go index e1ce13e..ea2c485 100644 --- a/backend/app/services/tenant_test.go +++ b/backend/app/services/tenant_test.go @@ -5,6 +5,7 @@ import ( "testing" "quyun/v2/app/commands/testx" + tenantdto "quyun/v2/app/http/tenant/dto" "quyun/v2/database" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -15,6 +16,7 @@ import ( _ "go.ipao.vip/atom" "go.ipao.vip/atom/contracts" + "go.ipao.vip/gen/types" "go.uber.org/dig" ) @@ -96,5 +98,85 @@ func (t *TenantTestSuite) Test_SetUserRole() { So(len(m.Role), ShouldEqual, 1) So(m.Role[0], ShouldEqual, consts.TenantUserRoleTenantAdmin) }) + + Convey("设置为 member 成功", func() { + err := Tenant.SetUserRole(ctx, tenantID, userID, consts.TenantUserRoleMember) + 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.TenantUserRoleMember) + }) + }) +} + +func (t *TenantTestSuite) Test_RemoveUser() { + Convey("Tenant.RemoveUser", t.T(), func() { + ctx := t.T().Context() + tenantID := int64(1) + userID := int64(2) + + database.Truncate(ctx, t.DB, models.TableNameTenantUser) + + Convey("移除不存在成员应幂等返回成功", func() { + So(Tenant.RemoveUser(ctx, tenantID, userID), ShouldBeNil) + }) + + Convey("移除已存在成员成功", func() { + So(Tenant.AddUser(ctx, tenantID, userID), ShouldBeNil) + So(Tenant.RemoveUser(ctx, tenantID, userID), ShouldBeNil) + + _, err := Tenant.FindTenantUser(ctx, tenantID, userID) + So(err, ShouldNotBeNil) + }) + }) +} + +func (t *TenantTestSuite) Test_AdminTenantUsersPage() { + Convey("Tenant.AdminTenantUsersPage", t.T(), func() { + ctx := t.T().Context() + tenantID := int64(1) + + database.Truncate(ctx, t.DB, models.TableNameTenantUser, models.TableNameUser) + + u1 := &models.User{ + Username: "u1", + Password: "pw", + Roles: types.NewArray([]consts.Role{consts.RoleUser}), + Status: consts.UserStatusVerified, + } + So(u1.Create(ctx), ShouldBeNil) + + u2 := &models.User{ + Username: "u2", + Password: "pw", + Roles: types.NewArray([]consts.Role{consts.RoleUser}), + Status: consts.UserStatusVerified, + } + So(u2.Create(ctx), ShouldBeNil) + + So(Tenant.AddUser(ctx, tenantID, u1.ID), ShouldBeNil) + So(Tenant.AddUser(ctx, tenantID, u2.ID), ShouldBeNil) + So(Tenant.SetUserRole(ctx, tenantID, u2.ID, consts.TenantUserRoleTenantAdmin), ShouldBeNil) + + Convey("不加过滤应返回用户信息", func() { + pager, err := Tenant.AdminTenantUsersPage(ctx, tenantID, &tenantdto.AdminTenantUserListFilter{}) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 2) + + items, ok := pager.Items.([]*tenantdto.AdminTenantUserItem) + So(ok, ShouldBeTrue) + So(len(items), ShouldEqual, 2) + So(items[0].User, ShouldNotBeNil) + }) + + Convey("按 role=tenant_admin 过滤", func() { + role := consts.TenantUserRoleTenantAdmin + pager, err := Tenant.AdminTenantUsersPage(ctx, tenantID, &tenantdto.AdminTenantUserListFilter{Role: &role}) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 1) + }) }) } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 3d5a3e5..8f6c978 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -835,6 +835,20 @@ const docTemplate = `{ "name": "page", "in": "query" }, + { + "enum": [ + "member", + "tenant_admin" + ], + "type": "string", + "x-enum-varnames": [ + "TenantUserRoleMember", + "TenantUserRoleTenantAdmin" + ], + "description": "Role 按角色过滤(可选):member/tenant_admin。", + "name": "role", + "in": "query" + }, { "type": "integer", "description": "UserID 按用户ID过滤(可选)。", @@ -854,7 +868,7 @@ const docTemplate = `{ "type": "object", "properties": { "items": { - "$ref": "#/definitions/models.TenantUser" + "$ref": "#/definitions/dto.AdminTenantUserItem" } } } @@ -1701,6 +1715,27 @@ const docTemplate = `{ } } }, + "dto.AdminTenantUserItem": { + "type": "object", + "properties": { + "tenant_user": { + "description": "TenantUser 租户成员关系记录。", + "allOf": [ + { + "$ref": "#/definitions/models.TenantUser" + } + ] + }, + "user": { + "description": "User 用户基础信息(用于展示 username 等)。", + "allOf": [ + { + "$ref": "#/definitions/models.User" + } + ] + } + } + }, "dto.AdminTenantUserJoinResponse": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 6100227..a29cf50 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -829,6 +829,20 @@ "name": "page", "in": "query" }, + { + "enum": [ + "member", + "tenant_admin" + ], + "type": "string", + "x-enum-varnames": [ + "TenantUserRoleMember", + "TenantUserRoleTenantAdmin" + ], + "description": "Role 按角色过滤(可选):member/tenant_admin。", + "name": "role", + "in": "query" + }, { "type": "integer", "description": "UserID 按用户ID过滤(可选)。", @@ -848,7 +862,7 @@ "type": "object", "properties": { "items": { - "$ref": "#/definitions/models.TenantUser" + "$ref": "#/definitions/dto.AdminTenantUserItem" } } } @@ -1695,6 +1709,27 @@ } } }, + "dto.AdminTenantUserItem": { + "type": "object", + "properties": { + "tenant_user": { + "description": "TenantUser 租户成员关系记录。", + "allOf": [ + { + "$ref": "#/definitions/models.TenantUser" + } + ] + }, + "user": { + "description": "User 用户基础信息(用于展示 username 等)。", + "allOf": [ + { + "$ref": "#/definitions/models.User" + } + ] + } + } + }, "dto.AdminTenantUserJoinResponse": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f7e4276..171d38c 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -185,6 +185,17 @@ definitions: 退款原因:建议必填(由业务侧校验);用于审计与追责。 type: string type: object + dto.AdminTenantUserItem: + properties: + tenant_user: + allOf: + - $ref: '#/definitions/models.TenantUser' + description: TenantUser 租户成员关系记录。 + user: + allOf: + - $ref: '#/definitions/models.User' + description: User 用户基础信息(用于展示 username 等)。 + type: object dto.AdminTenantUserJoinResponse: properties: tenant_user: @@ -1543,6 +1554,16 @@ paths: 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: UserID 按用户ID过滤(可选)。 in: query name: user_id @@ -1557,7 +1578,7 @@ paths: - $ref: '#/definitions/requests.Pager' - properties: items: - $ref: '#/definitions/models.TenantUser' + $ref: '#/definitions/dto.AdminTenantUserItem' type: object summary: 成员列表(租户管理) tags: