From ec4506fd2d06d1d9558703dbad1b7a544b413d22 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 18 Dec 2025 18:27:23 +0800 Subject: [PATCH] tenant: add invites and join requests --- .../app/http/tenant/dto/tenant_join_admin.go | 66 ++ backend/app/http/tenant/provider.gen.go | 34 +- backend/app/http/tenant/routes.gen.go | 63 +- .../app/http/tenant/tenant_invite_admin.go | 134 ++++ backend/app/http/tenant/tenant_join_admin.go | 142 ++++ backend/app/http/tenantjoin/dto/join.go | 13 + backend/app/http/tenantjoin/join.go | 87 +++ backend/app/http/tenantjoin/provider.gen.go | 37 + backend/app/http/tenantjoin/routes.gen.go | 63 ++ backend/app/http/tenantjoin/routes.manual.go | 12 + backend/app/services/tenant_join.go | 511 +++++++++++++ backend/app/services/tenant_join_test.go | 369 ++++++++++ backend/database/.transform.yaml | 4 + ...218190000_tenant_invites_join_requests.sql | 81 +++ backend/database/models/query.gen.go | 178 ++--- backend/database/models/tenant_invites.gen.go | 69 ++ .../models/tenant_invites.query.gen.go | 509 +++++++++++++ .../models/tenant_join_requests.gen.go | 66 ++ .../models/tenant_join_requests.query.gen.go | 497 +++++++++++++ backend/database/models/tenant_users.gen.go | 2 +- backend/database/models/users.gen.go | 2 +- backend/database/models/users.query.gen.go | 206 +++--- backend/docs/docs.go | 670 ++++++++++++++++++ backend/docs/swagger.json | 670 ++++++++++++++++++ backend/docs/swagger.yaml | 451 ++++++++++++ backend/pkg/consts/tenant_join.gen.go | 344 +++++++++ backend/pkg/consts/tenant_join.go | 59 ++ backend/tests/tenant.http | 68 ++ 28 files changed, 5206 insertions(+), 201 deletions(-) create mode 100644 backend/app/http/tenant/dto/tenant_join_admin.go create mode 100644 backend/app/http/tenant/tenant_invite_admin.go create mode 100644 backend/app/http/tenant/tenant_join_admin.go create mode 100644 backend/app/http/tenantjoin/dto/join.go create mode 100644 backend/app/http/tenantjoin/join.go create mode 100755 backend/app/http/tenantjoin/provider.gen.go create mode 100644 backend/app/http/tenantjoin/routes.gen.go create mode 100644 backend/app/http/tenantjoin/routes.manual.go create mode 100644 backend/app/services/tenant_join.go create mode 100644 backend/app/services/tenant_join_test.go create mode 100644 backend/database/migrations/20251218190000_tenant_invites_join_requests.sql create mode 100644 backend/database/models/tenant_invites.gen.go create mode 100644 backend/database/models/tenant_invites.query.gen.go create mode 100644 backend/database/models/tenant_join_requests.gen.go create mode 100644 backend/database/models/tenant_join_requests.query.gen.go create mode 100644 backend/pkg/consts/tenant_join.gen.go create mode 100644 backend/pkg/consts/tenant_join.go diff --git a/backend/app/http/tenant/dto/tenant_join_admin.go b/backend/app/http/tenant/dto/tenant_join_admin.go new file mode 100644 index 0000000..c04fe35 --- /dev/null +++ b/backend/app/http/tenant/dto/tenant_join_admin.go @@ -0,0 +1,66 @@ +package dto + +import ( + "strings" + "time" + + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +// AdminTenantInviteCreateForm 租户管理员创建邀请码的请求参数。 +type AdminTenantInviteCreateForm struct { + // Code 邀请码(可选):为空时由后端生成;建议只包含数字/字母,便于人工输入。 + Code string `json:"code"` + + // MaxUses 最大可使用次数(可选):0 表示不限次数;大于 0 时用尽后自动失效。 + MaxUses *int `json:"max_uses"` + + // ExpiresAt 过期时间(可选):为空表示不过期;到期后不可再使用。 + ExpiresAt *time.Time `json:"expires_at"` + + // Remark 备注(可选):用于审计记录生成目的/投放渠道等。 + Remark string `json:"remark"` +} + +// AdminTenantInviteDisableForm 租户管理员禁用邀请码的请求参数。 +type AdminTenantInviteDisableForm struct { + // Reason 禁用原因(可选):用于审计与追溯。 + Reason string `json:"reason"` +} + +// AdminTenantInviteListFilter 租户管理员分页查询邀请码列表的过滤条件。 +type AdminTenantInviteListFilter struct { + requests.Pagination + + // Status 按状态过滤(可选):active/disabled/expired。 + Status *consts.TenantInviteStatus `query:"status" json:"status"` + + // Code 按邀请码模糊过滤(可选):支持部分匹配(like)。 + Code *string `query:"code" json:"code"` +} + +// CodeTrimmed 对 code 进行空白与大小写处理,便于统一查询。 +func (f *AdminTenantInviteListFilter) CodeTrimmed() string { + if f == nil || f.Code == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*f.Code)) +} + +// AdminTenantJoinRequestListFilter 租户管理员分页查询加入申请的过滤条件。 +type AdminTenantJoinRequestListFilter struct { + requests.Pagination + + // UserID 按申请人用户ID过滤(可选)。 + UserID *int64 `query:"user_id" json:"user_id"` + + // Status 按申请状态过滤(可选):pending/approved/rejected。 + Status *consts.TenantJoinRequestStatus `query:"status" json:"status"` +} + +// AdminTenantJoinRequestDecideForm 租户管理员通过/拒绝加入申请的请求参数。 +type AdminTenantJoinRequestDecideForm struct { + // Reason 审核说明(可选):用于审计记录通过/拒绝原因。 + Reason string `json:"reason"` +} diff --git a/backend/app/http/tenant/provider.gen.go b/backend/app/http/tenant/provider.gen.go index 26f24ad..19fbd7a 100755 --- a/backend/app/http/tenant/provider.gen.go +++ b/backend/app/http/tenant/provider.gen.go @@ -60,17 +60,21 @@ func Provide(opts ...opt.Option) error { order *order, orderAdmin *orderAdmin, orderMe *orderMe, + tenantInviteAdmin *tenantInviteAdmin, + tenantJoinAdmin *tenantJoinAdmin, tenantUserAdmin *tenantUserAdmin, ) (contracts.HttpRoute, error) { obj := &Routes{ - content: content, - contentAdmin: contentAdmin, - me: me, - middlewares: middlewares, - order: order, - orderAdmin: orderAdmin, - orderMe: orderMe, - tenantUserAdmin: tenantUserAdmin, + content: content, + contentAdmin: contentAdmin, + me: me, + middlewares: middlewares, + order: order, + orderAdmin: orderAdmin, + orderMe: orderMe, + tenantInviteAdmin: tenantInviteAdmin, + tenantJoinAdmin: tenantJoinAdmin, + tenantUserAdmin: tenantUserAdmin, } if err := obj.Prepare(); err != nil { return nil, err @@ -80,6 +84,20 @@ func Provide(opts ...opt.Option) error { }, atom.GroupRoutes); err != nil { return err } + if err := container.Container.Provide(func() (*tenantInviteAdmin, error) { + obj := &tenantInviteAdmin{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func() (*tenantJoinAdmin, error) { + obj := &tenantJoinAdmin{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*tenantUserAdmin, error) { obj := &tenantUserAdmin{} diff --git a/backend/app/http/tenant/routes.gen.go b/backend/app/http/tenant/routes.gen.go index ba30260..ad60076 100644 --- a/backend/app/http/tenant/routes.gen.go +++ b/backend/app/http/tenant/routes.gen.go @@ -24,13 +24,15 @@ 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 - tenantUserAdmin *tenantUserAdmin + content *content + contentAdmin *contentAdmin + me *me + order *order + orderAdmin *orderAdmin + orderMe *orderMe + tenantInviteAdmin *tenantInviteAdmin + tenantJoinAdmin *tenantJoinAdmin + tenantUserAdmin *tenantUserAdmin } // Prepare initializes the routes provider with logging configuration. @@ -185,6 +187,53 @@ func (r *Routes) Register(router fiber.Router) { Local[*models.User]("user"), PathParam[int64]("orderID"), )) + // Register routes for controller: tenantInviteAdmin + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/invites -> tenantInviteAdmin.adminInviteList") + router.Get("/t/:tenantCode/v1/admin/invites"[len(r.Path()):], DataFunc3( + r.tenantInviteAdmin.adminInviteList, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Query[dto.AdminTenantInviteListFilter]("filter"), + )) + r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/invites/:inviteID/disable -> tenantInviteAdmin.adminDisableInvite") + router.Patch("/t/:tenantCode/v1/admin/invites/:inviteID/disable"[len(r.Path()):], DataFunc4( + r.tenantInviteAdmin.adminDisableInvite, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("inviteID"), + Body[dto.AdminTenantInviteDisableForm]("form"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/invites -> tenantInviteAdmin.adminCreateInvite") + router.Post("/t/:tenantCode/v1/admin/invites"[len(r.Path()):], DataFunc3( + r.tenantInviteAdmin.adminCreateInvite, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Body[dto.AdminTenantInviteCreateForm]("form"), + )) + // Register routes for controller: tenantJoinAdmin + r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/join-requests -> tenantJoinAdmin.adminJoinRequests") + router.Get("/t/:tenantCode/v1/admin/join-requests"[len(r.Path()):], DataFunc3( + r.tenantJoinAdmin.adminJoinRequests, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + Query[dto.AdminTenantJoinRequestListFilter]("filter"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/join-requests/:requestID/approve -> tenantJoinAdmin.adminApproveJoinRequest") + router.Post("/t/:tenantCode/v1/admin/join-requests/:requestID/approve"[len(r.Path()):], DataFunc4( + r.tenantJoinAdmin.adminApproveJoinRequest, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("requestID"), + Body[dto.AdminTenantJoinRequestDecideForm]("form"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/admin/join-requests/:requestID/reject -> tenantJoinAdmin.adminRejectJoinRequest") + router.Post("/t/:tenantCode/v1/admin/join-requests/:requestID/reject"[len(r.Path()):], DataFunc4( + r.tenantJoinAdmin.adminRejectJoinRequest, + Local[*models.Tenant]("tenant"), + Local[*models.TenantUser]("tenant_user"), + PathParam[int64]("requestID"), + Body[dto.AdminTenantJoinRequestDecideForm]("form"), + )) // 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( diff --git a/backend/app/http/tenant/tenant_invite_admin.go b/backend/app/http/tenant/tenant_invite_admin.go new file mode 100644 index 0000000..4dbf56c --- /dev/null +++ b/backend/app/http/tenant/tenant_invite_admin.go @@ -0,0 +1,134 @@ +package tenant + +import ( + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// tenantInviteAdmin 提供“租户管理员管理邀请码”的相关接口。 +// +// @provider +type tenantInviteAdmin struct{} + +// adminCreateInvite +// +// @Summary 创建邀请码(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param form body dto.AdminTenantInviteCreateForm true "Form" +// @Success 200 {object} models.TenantInvite +// +// @Router /t/:tenantCode/v1/admin/invites [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind form body +func (*tenantInviteAdmin) adminCreateInvite( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + form *dto.AdminTenantInviteCreateForm, +) (*models.TenantInvite, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + }).Info("tenant.admin.invites.create") + + return services.Tenant.AdminCreateInvite(ctx.Context(), tenant.ID, tenantUser.UserID, form) +} + +// adminInviteList +// +// @Summary 邀请码列表(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query dto.AdminTenantInviteListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=models.TenantInvite} +// +// @Router /t/:tenantCode/v1/admin/invites [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind filter query +func (*tenantInviteAdmin) adminInviteList( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + filter *dto.AdminTenantInviteListFilter, +) (*requests.Pager, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if filter == nil { + filter = &dto.AdminTenantInviteListFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "status": filter.Status, + "code": filter.CodeTrimmed(), + }).Info("tenant.admin.invites.list") + + return services.Tenant.AdminInvitePage(ctx.Context(), tenant.ID, filter) +} + +// adminDisableInvite +// +// @Summary 禁用邀请码(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param inviteID path int64 true "InviteID" +// @Param form body dto.AdminTenantInviteDisableForm true "Form" +// @Success 200 {object} models.TenantInvite +// +// @Router /t/:tenantCode/v1/admin/invites/:inviteID/disable [patch] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind inviteID path +// @Bind form body +func (*tenantInviteAdmin) adminDisableInvite( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + inviteID int64, + form *dto.AdminTenantInviteDisableForm, +) (*models.TenantInvite, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if inviteID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("invite_id must be > 0") + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "invite_id": inviteID, + "disable_time": time.Now(), + }).Info("tenant.admin.invites.disable") + + return services.Tenant.AdminDisableInvite(ctx.Context(), tenant.ID, tenantUser.UserID, inviteID, form.Reason) +} diff --git a/backend/app/http/tenant/tenant_join_admin.go b/backend/app/http/tenant/tenant_join_admin.go new file mode 100644 index 0000000..a8ac766 --- /dev/null +++ b/backend/app/http/tenant/tenant_join_admin.go @@ -0,0 +1,142 @@ +package tenant + +import ( + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + "quyun/v2/database/models" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// tenantJoinAdmin 提供“租户管理员审核加入申请”的相关接口。 +// +// @provider +type tenantJoinAdmin struct{} + +// adminJoinRequests +// +// @Summary 加入申请列表(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param filter query dto.AdminTenantJoinRequestListFilter true "Filter" +// @Success 200 {object} requests.Pager{items=models.TenantJoinRequest} +// +// @Router /t/:tenantCode/v1/admin/join-requests [get] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind filter query +func (*tenantJoinAdmin) adminJoinRequests( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + filter *dto.AdminTenantJoinRequestListFilter, +) (*requests.Pager, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if filter == nil { + filter = &dto.AdminTenantJoinRequestListFilter{} + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "status": filter.Status, + "query_uid": filter.UserID, + }).Info("tenant.admin.join_requests.list") + + return services.Tenant.AdminJoinRequestPage(ctx.Context(), tenant.ID, filter) +} + +// adminApproveJoinRequest +// +// @Summary 通过加入申请(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param requestID path int64 true "RequestID" +// @Param form body dto.AdminTenantJoinRequestDecideForm true "Form" +// @Success 200 {object} models.TenantJoinRequest +// +// @Router /t/:tenantCode/v1/admin/join-requests/:requestID/approve [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind requestID path +// @Bind form body +func (*tenantJoinAdmin) adminApproveJoinRequest( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + requestID int64, + form *dto.AdminTenantJoinRequestDecideForm, +) (*models.TenantJoinRequest, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if requestID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("request_id must be > 0") + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "request_id": requestID, + "decide_time": time.Now(), + }).Info("tenant.admin.join_requests.approve") + + return services.Tenant.AdminApproveJoinRequest(ctx.Context(), tenant.ID, tenantUser.UserID, requestID, form.Reason) +} + +// adminRejectJoinRequest +// +// @Summary 拒绝加入申请(租户管理) +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param requestID path int64 true "RequestID" +// @Param form body dto.AdminTenantJoinRequestDecideForm true "Form" +// @Success 200 {object} models.TenantJoinRequest +// +// @Router /t/:tenantCode/v1/admin/join-requests/:requestID/reject [post] +// @Bind tenant local key(tenant) +// @Bind tenantUser local key(tenant_user) +// @Bind requestID path +// @Bind form body +func (*tenantJoinAdmin) adminRejectJoinRequest( + ctx fiber.Ctx, + tenant *models.Tenant, + tenantUser *models.TenantUser, + requestID int64, + form *dto.AdminTenantJoinRequestDecideForm, +) (*models.TenantJoinRequest, error) { + if err := requireTenantAdmin(tenantUser); err != nil { + return nil, err + } + if requestID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("request_id must be > 0") + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": tenantUser.UserID, + "request_id": requestID, + "decide_time": time.Now(), + }).Info("tenant.admin.join_requests.reject") + + return services.Tenant.AdminRejectJoinRequest(ctx.Context(), tenant.ID, tenantUser.UserID, requestID, form.Reason) +} diff --git a/backend/app/http/tenantjoin/dto/join.go b/backend/app/http/tenantjoin/dto/join.go new file mode 100644 index 0000000..2892baa --- /dev/null +++ b/backend/app/http/tenantjoin/dto/join.go @@ -0,0 +1,13 @@ +package dto + +// JoinByInviteForm 用户通过邀请码加入租户的请求参数。 +type JoinByInviteForm struct { + // InviteCode 邀请码:由租户管理员生成;用户提交后加入对应租户。 + InviteCode string `json:"invite_code"` +} + +// JoinRequestCreateForm 用户提交加入租户申请的请求参数(无邀请码场景)。 +type JoinRequestCreateForm struct { + // Reason 申请原因(可选):用于向租户管理员说明申请加入的目的。 + Reason string `json:"reason"` +} diff --git a/backend/app/http/tenantjoin/join.go b/backend/app/http/tenantjoin/join.go new file mode 100644 index 0000000..8621f56 --- /dev/null +++ b/backend/app/http/tenantjoin/join.go @@ -0,0 +1,87 @@ +package tenantjoin + +import ( + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenantjoin/dto" + "quyun/v2/app/services" + "quyun/v2/database/models" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +// join 提供“非成员加入租户”的相关接口(邀请码加入 / 申请加入)。 +// +// @provider +type join struct{} + +// joinByInvite +// +// @Summary 通过邀请码加入租户 +// @Tags TenantJoin +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param form body dto.JoinByInviteForm true "Form" +// @Success 200 {object} models.TenantUser +// +// @Router /t/:tenantCode/v1/join/invite [post] +// @Bind tenant local key(tenant) +// @Bind claims local key(claims) +// @Bind form body +func (*join) joinByInvite( + ctx fiber.Ctx, + tenant *models.Tenant, + claims *jwt.Claims, + form *dto.JoinByInviteForm, +) (*models.TenantUser, error) { + if tenant == nil || claims == nil { + return nil, errorx.ErrInternalError.WithMsg("context missing") + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": claims.UserID, + }).Info("tenantjoin.join_by_invite") + + return services.Tenant.JoinByInvite(ctx.Context(), tenant.ID, claims.UserID, form.InviteCode) +} + +// createJoinRequest +// +// @Summary 提交加入租户申请 +// @Tags TenantJoin +// @Accept json +// @Produce json +// @Param tenantCode path string true "Tenant Code" +// @Param form body dto.JoinRequestCreateForm true "Form" +// @Success 200 {object} models.TenantJoinRequest +// +// @Router /t/:tenantCode/v1/join/request [post] +// @Bind tenant local key(tenant) +// @Bind claims local key(claims) +// @Bind form body +func (*join) createJoinRequest( + ctx fiber.Ctx, + tenant *models.Tenant, + claims *jwt.Claims, + form *dto.JoinRequestCreateForm, +) (*models.TenantJoinRequest, error) { + if tenant == nil || claims == nil { + return nil, errorx.ErrInternalError.WithMsg("context missing") + } + if form == nil { + return nil, errorx.ErrInvalidParameter + } + + log.WithFields(log.Fields{ + "tenant_id": tenant.ID, + "user_id": claims.UserID, + }).Info("tenantjoin.create_join_request") + + return services.Tenant.CreateJoinRequest(ctx.Context(), tenant.ID, claims.UserID, form) +} diff --git a/backend/app/http/tenantjoin/provider.gen.go b/backend/app/http/tenantjoin/provider.gen.go new file mode 100755 index 0000000..e5231ea --- /dev/null +++ b/backend/app/http/tenantjoin/provider.gen.go @@ -0,0 +1,37 @@ +package tenantjoin + +import ( + "quyun/v2/app/middlewares" + + "go.ipao.vip/atom" + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/contracts" + "go.ipao.vip/atom/opt" +) + +func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func() (*join, error) { + obj := &join{} + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func( + join *join, + middlewares *middlewares.Middlewares, + ) (contracts.HttpRoute, error) { + obj := &Routes{ + join: join, + middlewares: middlewares, + } + if err := obj.Prepare(); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupRoutes); err != nil { + return err + } + return nil +} diff --git a/backend/app/http/tenantjoin/routes.gen.go b/backend/app/http/tenantjoin/routes.gen.go new file mode 100644 index 0000000..b81a93f --- /dev/null +++ b/backend/app/http/tenantjoin/routes.gen.go @@ -0,0 +1,63 @@ +// Code generated by atomctl. DO NOT EDIT. + +// Package tenantjoin provides HTTP route definitions and registration +// for the quyun/v2 application. +package tenantjoin + +import ( + "quyun/v2/app/http/tenantjoin/dto" + "quyun/v2/app/middlewares" + "quyun/v2/database/models" + "quyun/v2/providers/jwt" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + _ "go.ipao.vip/atom/contracts" + . "go.ipao.vip/atom/fen" +) + +// Routes implements the HttpRoute contract and provides route registration +// for all controllers in the tenantjoin module. +// +// @provider contracts.HttpRoute atom.GroupRoutes +type Routes struct { + log *log.Entry `inject:"false"` + middlewares *middlewares.Middlewares + // Controller instances + join *join +} + +// Prepare initializes the routes provider with logging configuration. +func (r *Routes) Prepare() error { + r.log = log.WithField("module", "routes.tenantjoin") + r.log.Info("Initializing routes module") + return nil +} + +// Name returns the unique identifier for this routes provider. +func (r *Routes) Name() string { + return "tenantjoin" +} + +// Register registers all HTTP routes with the provided fiber router. +// Each route is registered with its corresponding controller action and parameter bindings. +func (r *Routes) Register(router fiber.Router) { + // Register routes for controller: join + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/join/invite -> join.joinByInvite") + router.Post("/t/:tenantCode/v1/join/invite"[len(r.Path()):], DataFunc3( + r.join.joinByInvite, + Local[*models.Tenant]("tenant"), + Local[*jwt.Claims]("claims"), + Body[dto.JoinByInviteForm]("form"), + )) + r.log.Debugf("Registering route: Post /t/:tenantCode/v1/join/request -> join.createJoinRequest") + router.Post("/t/:tenantCode/v1/join/request"[len(r.Path()):], DataFunc3( + r.join.createJoinRequest, + Local[*models.Tenant]("tenant"), + Local[*jwt.Claims]("claims"), + Body[dto.JoinRequestCreateForm]("form"), + )) + + r.log.Info("Successfully registered all routes") +} diff --git a/backend/app/http/tenantjoin/routes.manual.go b/backend/app/http/tenantjoin/routes.manual.go new file mode 100644 index 0000000..13ef92b --- /dev/null +++ b/backend/app/http/tenantjoin/routes.manual.go @@ -0,0 +1,12 @@ +package tenantjoin + +func (r *Routes) Path() string { + return "/t/:tenantCode/v1" +} + +func (r *Routes) Middlewares() []any { + return []any{ + r.middlewares.TenantResolve, + r.middlewares.TenantAuth, + } +} diff --git a/backend/app/services/tenant_join.go b/backend/app/services/tenant_join.go new file mode 100644 index 0000000..7418619 --- /dev/null +++ b/backend/app/services/tenant_join.go @@ -0,0 +1,511 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/base32" + "strings" + "time" + + "quyun/v2/app/errorx" + "quyun/v2/app/http/tenant/dto" + tenantjoindto "quyun/v2/app/http/tenantjoin/dto" + "quyun/v2/app/requests" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + "go.ipao.vip/gen" + "go.ipao.vip/gen/types" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return errors.Is(err, gorm.ErrDuplicatedKey) +} + +func newInviteCode() (string, error) { + // 邀请码为安全敏感值:使用强随机数,避免可预测性导致被撞库加入租户。 + buf := make([]byte, 10) // 80-bit + if _, err := rand.Read(buf); err != nil { + return "", err + } + // base32(去掉 padding)便于输入,统一转小写存储与比较。 + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)), nil +} + +// AdminCreateInvite 租户管理员创建邀请(用于用户通过邀请码加入租户)。 +func (t *tenant) AdminCreateInvite(ctx context.Context, tenantID, operatorUserID int64, form *dto.AdminTenantInviteCreateForm) (*models.TenantInvite, error) { + if tenantID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") + } + if operatorUserID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("operator_user_id must be > 0") + } + if form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("form is nil") + } + + now := time.Now().UTC() + code := strings.ToLower(strings.TrimSpace(form.Code)) + if code == "" { + var err error + code, err = newInviteCode() + if err != nil { + return nil, err + } + } + if form.ExpiresAt != nil && !form.ExpiresAt.IsZero() && form.ExpiresAt.Before(now) { + return nil, errorx.ErrInvalidParameter.WithMsg("expires_at must be in future") + } + if form.MaxUses != nil && *form.MaxUses < 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("max_uses must be >= 0") + } + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "code": code, + "max_uses": form.MaxUses, + "expires_at_present": form.ExpiresAt != nil, + }).Info("services.tenant.admin.create_invite") + + invite := &models.TenantInvite{ + TenantID: tenantID, + UserID: operatorUserID, + Code: code, + Status: consts.TenantInviteStatusActive, + MaxUses: 0, + UsedCount: 0, + Remark: strings.TrimSpace(form.Remark), + CreatedAt: now, + UpdatedAt: now, + } + if form.MaxUses != nil { + invite.MaxUses = int32(*form.MaxUses) + } + if form.ExpiresAt != nil && !form.ExpiresAt.IsZero() { + invite.ExpiresAt = form.ExpiresAt.UTC() + } + + // 关键点:expires_at/disabled_at 允许为空,避免写入 0001-01-01 造成误判。 + db := models.Q.TenantInvite.WithContext(ctx).UnderlyingDB().Omit("disabled_at", "disabled_operator_user_id") + if invite.ExpiresAt.IsZero() { + db = db.Omit("expires_at") + } + if err := db.Create(invite).Error; err != nil { + if isUniqueViolation(err) { + return nil, errorx.ErrRecordDuplicated.WithMsg("邀请码已存在,请重试") + } + return nil, err + } + return invite, nil +} + +// AdminDisableInvite 租户管理员禁用邀请(幂等)。 +func (t *tenant) AdminDisableInvite(ctx context.Context, tenantID, operatorUserID, inviteID int64, reason string) (*models.TenantInvite, error) { + if tenantID <= 0 || operatorUserID <= 0 || inviteID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/invite_id") + } + + now := time.Now().UTC() + reason = strings.TrimSpace(reason) + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "invite_id": inviteID, + }).Info("services.tenant.admin.disable_invite") + + var out models.TenantInvite + err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var inv models.TenantInvite + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND tenant_id = ?", inviteID, tenantID). + First(&inv).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("邀请码不存在") + } + return err + } + + // 幂等:重复禁用直接返回当前状态。 + if inv.Status == consts.TenantInviteStatusDisabled || inv.Status == consts.TenantInviteStatusExpired { + out = inv + return nil + } + + inv.Status = consts.TenantInviteStatusDisabled + inv.DisabledOperatorUserID = operatorUserID + inv.DisabledAt = now + if reason != "" { + inv.Remark = reason + } + inv.UpdatedAt = now + + // 关键点:disabled_at/disabled_operator_user_id 允许为空,但禁用时必须落审计信息。 + if err := tx.Save(&inv).Error; err != nil { + return err + } + + out = inv + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// AdminInvitePage 租户管理员分页查询邀请列表。 +func (t *tenant) AdminInvitePage(ctx context.Context, tenantID int64, filter *dto.AdminTenantInviteListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") + } + if filter == nil { + filter = &dto.AdminTenantInviteListFilter{} + } + filter.Pagination.Format() + + tbl, query := models.TenantInviteQuery.QueryContext(ctx) + conds := []gen.Condition{tbl.TenantID.Eq(tenantID)} + if filter.Status != nil && *filter.Status != "" { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + if code := filter.CodeTrimmed(); code != "" { + conds = append(conds, tbl.Code.Like("%"+strings.ToLower(code)+"%")) + } + + 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 +} + +// JoinByInvite 用户通过邀请码加入租户(无须已是租户成员)。 +func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, inviteCode string) (*models.TenantUser, error) { + if tenantID <= 0 || userID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/user_id") + } + inviteCode = strings.ToLower(strings.TrimSpace(inviteCode)) + if inviteCode == "" { + return nil, errorx.ErrInvalidParameter.WithMsg("invite_code is empty") + } + + now := time.Now().UTC() + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + "invite_code": inviteCode, + "invite_token": "[masked]", + }).Info("services.tenant.join_by_invite") + + var out models.TenantUser + err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 关键前置条件:已经是成员时直接成功返回,不消耗邀请码使用次数。 + var existingTU models.TenantUser + if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&existingTU).Error; err == nil { + out = existingTU + return nil + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + // 邀请校验必须加行锁,避免并发超发 used_count。 + var inv models.TenantInvite + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("tenant_id = ? AND code = ?", tenantID, inviteCode). + First(&inv).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("邀请码不存在") + } + return err + } + + // 关键规则:禁用/过期的邀请码不可使用。 + if inv.Status != consts.TenantInviteStatusActive { + return errorx.ErrPreconditionFailed.WithMsg("邀请码不可用") + } + if !inv.ExpiresAt.IsZero() && inv.ExpiresAt.Before(now) { + // 业务侧保持状态一致:过期时顺手标记 expired,避免后续误用。 + _ = tx.Model(&inv).Updates(map[string]any{ + "status": consts.TenantInviteStatusExpired, + "updated_at": now, + }).Error + return errorx.ErrPreconditionFailed.WithMsg("邀请码已过期") + } + if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses { + _ = tx.Model(&inv).Updates(map[string]any{ + "status": consts.TenantInviteStatusExpired, + "updated_at": now, + }).Error + return errorx.ErrPreconditionFailed.WithMsg("邀请码已用尽") + } + + // 加入租户:默认 member + verified;与 tenant.AddUser 保持一致。 + tu := &models.TenantUser{ + TenantID: tenantID, + UserID: userID, + Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), + Status: consts.UserStatusVerified, + Balance: 0, + BalanceFrozen: 0, + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(tu).Error; err != nil { + if isUniqueViolation(err) { + // 并发幂等:重复插入按已加入处理,不消耗邀请码次数。 + if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&out).Error; err != nil { + return err + } + return nil + } + return err + } + out = *tu + + // 只有在“新加入”成功时才消耗邀请码次数。 + updates := map[string]any{ + "used_count": inv.UsedCount + 1, + "updated_at": now, + } + if inv.MaxUses > 0 && inv.UsedCount+1 >= inv.MaxUses { + updates["status"] = consts.TenantInviteStatusExpired + } + return tx.Model(&inv).Updates(updates).Error + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// CreateJoinRequest 用户提交加入租户申请(无邀请码场景)。 +func (t *tenant) CreateJoinRequest(ctx context.Context, tenantID, userID int64, form *tenantjoindto.JoinRequestCreateForm) (*models.TenantJoinRequest, error) { + if tenantID <= 0 || userID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/user_id") + } + if form == nil { + return nil, errorx.ErrInvalidParameter.WithMsg("form is nil") + } + + now := time.Now().UTC() + reason := strings.TrimSpace(form.Reason) + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "user_id": userID, + }).Info("services.tenant.create_join_request") + + // 关键前置条件:已是成员则不允许重复申请。 + var existingTU models.TenantUser + if err := _db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&existingTU).Error; err == nil { + return nil, errorx.ErrPreconditionFailed.WithMsg("已是该租户成员") + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + req := &models.TenantJoinRequest{ + TenantID: tenantID, + UserID: userID, + Status: consts.TenantJoinRequestStatusPending, + Reason: reason, + CreatedAt: now, + UpdatedAt: now, + DecidedAt: time.Time{}, + DecidedReason: "", + } + + // 关键点:decided_at/decided_operator_user_id 允许为空,避免写入 0001-01-01 造成误判。 + db := models.Q.TenantJoinRequest.WithContext(ctx).UnderlyingDB().Omit("decided_at", "decided_operator_user_id") + if err := db.Create(req).Error; err != nil { + if isUniqueViolation(err) { + // 幂等:重复提交时返回现有 pending 申请。 + tbl, query := models.TenantJoinRequestQuery.QueryContext(ctx) + existing, qErr := query.Where( + tbl.TenantID.Eq(tenantID), + tbl.UserID.Eq(userID), + tbl.Status.Eq(consts.TenantJoinRequestStatusPending), + ).First() + if qErr == nil { + return existing, nil + } + return nil, err + } + return nil, err + } + + return req, nil +} + +// AdminJoinRequestPage 租户管理员分页查询加入申请列表。 +func (t *tenant) AdminJoinRequestPage(ctx context.Context, tenantID int64, filter *dto.AdminTenantJoinRequestListFilter) (*requests.Pager, error) { + if tenantID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0") + } + if filter == nil { + filter = &dto.AdminTenantJoinRequestListFilter{} + } + filter.Pagination.Format() + + tbl, query := models.TenantJoinRequestQuery.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.Status != nil && *filter.Status != "" { + conds = append(conds, tbl.Status.Eq(*filter.Status)) + } + + 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 +} + +// AdminApproveJoinRequest 租户管理员通过加入申请(幂等)。 +func (t *tenant) AdminApproveJoinRequest(ctx context.Context, tenantID, operatorUserID, requestID int64, reason string) (*models.TenantJoinRequest, error) { + if tenantID <= 0 || operatorUserID <= 0 || requestID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/request_id") + } + + now := time.Now().UTC() + reason = strings.TrimSpace(reason) + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "request_id": requestID, + }).Info("services.tenant.admin.approve_join_request") + + var out models.TenantJoinRequest + err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var req models.TenantJoinRequest + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND tenant_id = ?", requestID, tenantID). + First(&req).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("申请不存在") + } + return err + } + + // 幂等:已通过则直接返回。 + if req.Status == consts.TenantJoinRequestStatusApproved { + out = req + return nil + } + if req.Status != consts.TenantJoinRequestStatusPending { + return errorx.ErrPreconditionFailed.WithMsg("申请状态不可通过") + } + + // 先落成员关系,再更新申请状态,保证“通过后一定能成为成员”(至少幂等)。 + tu := &models.TenantUser{ + TenantID: tenantID, + UserID: req.UserID, + Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), + Status: consts.UserStatusVerified, + Balance: 0, + BalanceFrozen: 0, + CreatedAt: now, + UpdatedAt: now, + } + if err := tx.Create(tu).Error; err != nil && !isUniqueViolation(err) { + return err + } + + req.Status = consts.TenantJoinRequestStatusApproved + req.DecidedAt = now + req.DecidedOperatorUserID = operatorUserID + req.DecidedReason = reason + req.UpdatedAt = now + if err := tx.Save(&req).Error; err != nil { + return err + } + + out = req + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// AdminRejectJoinRequest 租户管理员拒绝加入申请(幂等)。 +func (t *tenant) AdminRejectJoinRequest(ctx context.Context, tenantID, operatorUserID, requestID int64, reason string) (*models.TenantJoinRequest, error) { + if tenantID <= 0 || operatorUserID <= 0 || requestID <= 0 { + return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/request_id") + } + + now := time.Now().UTC() + reason = strings.TrimSpace(reason) + + logrus.WithFields(logrus.Fields{ + "tenant_id": tenantID, + "operator_user_id": operatorUserID, + "request_id": requestID, + }).Info("services.tenant.admin.reject_join_request") + + var out models.TenantJoinRequest + err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var req models.TenantJoinRequest + if err := tx. + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND tenant_id = ?", requestID, tenantID). + First(&req).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("申请不存在") + } + return err + } + + // 幂等:已拒绝则直接返回。 + if req.Status == consts.TenantJoinRequestStatusRejected { + out = req + return nil + } + if req.Status != consts.TenantJoinRequestStatusPending { + return errorx.ErrPreconditionFailed.WithMsg("申请状态不可拒绝") + } + + req.Status = consts.TenantJoinRequestStatusRejected + req.DecidedAt = now + req.DecidedOperatorUserID = operatorUserID + req.DecidedReason = reason + req.UpdatedAt = now + if err := tx.Save(&req).Error; err != nil { + return err + } + + out = req + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/backend/app/services/tenant_join_test.go b/backend/app/services/tenant_join_test.go new file mode 100644 index 0000000..7e758b1 --- /dev/null +++ b/backend/app/services/tenant_join_test.go @@ -0,0 +1,369 @@ +package services + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "quyun/v2/app/commands/testx" + "quyun/v2/app/errorx" + tenantdto "quyun/v2/app/http/tenant/dto" + tenantjoindto "quyun/v2/app/http/tenantjoin/dto" + "quyun/v2/database" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" + + . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/suite" + + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" + "go.ipao.vip/gen/types" + "go.uber.org/dig" +) + +type TenantJoinTestSuiteInjectParams struct { + dig.In + + DB *sql.DB + Initials []contracts.Initial `group:"initials"` // nolint:structcheck +} + +type TenantJoinTestSuite struct { + suite.Suite + + TenantJoinTestSuiteInjectParams +} + +func Test_TenantJoin(t *testing.T) { + providers := testx.Default().With(Provide) + testx.Serve(providers, t, func(p TenantJoinTestSuiteInjectParams) { + suite.Run(t, &TenantJoinTestSuite{TenantJoinTestSuiteInjectParams: p}) + }) +} + +func (s *TenantJoinTestSuite) truncateAll(ctx context.Context) { + So(database.Truncate(ctx, s.DB, + models.TableNameTenantInvite, + models.TableNameTenantJoinRequest, + models.TableNameTenantUser, + ), ShouldBeNil) +} + +func (s *TenantJoinTestSuite) seedInvite(ctx context.Context, tenantID, creatorUserID int64, code string, status consts.TenantInviteStatus, maxUses, usedCount int32, expiresAt time.Time) *models.TenantInvite { + inv := &models.TenantInvite{ + TenantID: tenantID, + UserID: creatorUserID, + Code: code, + Status: status, + MaxUses: maxUses, + UsedCount: usedCount, + ExpiresAt: expiresAt, + Remark: "seed", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + So(_db.WithContext(ctx).Create(inv).Error, ShouldBeNil) + return inv +} + +func (s *TenantJoinTestSuite) seedJoinRequest(ctx context.Context, tenantID, userID int64, status consts.TenantJoinRequestStatus) *models.TenantJoinRequest { + req := &models.TenantJoinRequest{ + TenantID: tenantID, + UserID: userID, + Status: status, + Reason: "seed", + DecidedAt: time.Time{}, + DecidedReason: "", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + So(_db.WithContext(ctx).Omit("decided_at", "decided_operator_user_id").Create(req).Error, ShouldBeNil) + return req +} + +func (s *TenantJoinTestSuite) Test_AdminCreateInvite() { + Convey("Tenant.AdminCreateInvite", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + adminUserID := int64(10) + + Convey("参数非法应返回参数错误", func() { + _, err := Tenant.AdminCreateInvite(ctx, 0, adminUserID, &tenantdto.AdminTenantInviteCreateForm{}) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.CodeInvalidParameter) + + _, err = Tenant.AdminCreateInvite(ctx, tenantID, 0, &tenantdto.AdminTenantInviteCreateForm{}) + So(err, ShouldNotBeNil) + }) + + Convey("code 为空应自动生成并创建成功", func() { + out, err := Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ + Code: "", + MaxUses: loToPtr(0), + Remark: "test", + }) + So(err, ShouldBeNil) + So(out, ShouldNotBeNil) + So(out.ID, ShouldBeGreaterThan, 0) + So(out.Code, ShouldNotBeBlank) + So(out.Status, ShouldEqual, consts.TenantInviteStatusActive) + }) + + Convey("重复 code 应返回重复错误", func() { + _, err := Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ + Code: "dup_code", + MaxUses: loToPtr(0), + }) + So(err, ShouldBeNil) + + _, err = Tenant.AdminCreateInvite(ctx, tenantID, adminUserID, &tenantdto.AdminTenantInviteCreateForm{ + Code: "dup_code", + MaxUses: loToPtr(0), + }) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.CodeRecordDuplicated) + }) + }) +} + +func (s *TenantJoinTestSuite) Test_AdminDisableInvite() { + Convey("Tenant.AdminDisableInvite", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + adminUserID := int64(10) + + Convey("邀请码不存在应返回记录不存在", func() { + _, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, 999, "x") + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) + }) + + Convey("禁用应成功且幂等", func() { + inv := s.seedInvite(ctx, tenantID, adminUserID, "c1", consts.TenantInviteStatusActive, 0, 0, time.Time{}) + + out, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, inv.ID, "reason") + So(err, ShouldBeNil) + So(out.Status, ShouldEqual, consts.TenantInviteStatusDisabled) + So(out.DisabledOperatorUserID, ShouldEqual, adminUserID) + So(out.DisabledAt.IsZero(), ShouldBeFalse) + + out2, err := Tenant.AdminDisableInvite(ctx, tenantID, adminUserID, inv.ID, "reason2") + So(err, ShouldBeNil) + So(out2.Status, ShouldEqual, consts.TenantInviteStatusDisabled) + }) + }) +} + +func (s *TenantJoinTestSuite) Test_AdminInvitePage() { + Convey("Tenant.AdminInvitePage", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + adminUserID := int64(10) + + s.seedInvite(ctx, tenantID, adminUserID, "aaa", consts.TenantInviteStatusActive, 0, 0, time.Time{}) + s.seedInvite(ctx, tenantID, adminUserID, "bbb", consts.TenantInviteStatusDisabled, 0, 0, time.Time{}) + s.seedInvite(ctx, tenantID, adminUserID, "ccc", consts.TenantInviteStatusActive, 0, 0, time.Time{}) + + Convey("按 status 过滤应只返回匹配项", func() { + st := consts.TenantInviteStatusActive + pager, err := Tenant.AdminInvitePage(ctx, tenantID, &tenantdto.AdminTenantInviteListFilter{ + Status: &st, + }) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 2) + }) + + Convey("按 code 模糊过滤应生效", func() { + code := "bb" + pager, err := Tenant.AdminInvitePage(ctx, tenantID, &tenantdto.AdminTenantInviteListFilter{ + Code: &code, + }) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 1) + }) + }) +} + +func (s *TenantJoinTestSuite) Test_JoinByInvite() { + Convey("Tenant.JoinByInvite", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + adminUserID := int64(10) + userID := int64(20) + + Convey("邀请码不存在应返回记录不存在", func() { + _, err := Tenant.JoinByInvite(ctx, tenantID, userID, "not_exist") + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) + }) + + Convey("成功加入应创建成员并消耗邀请码次数", func() { + inv := s.seedInvite(ctx, tenantID, adminUserID, "code1", consts.TenantInviteStatusActive, 1, 0, time.Now().UTC().Add(10*time.Minute)) + + tu, err := Tenant.JoinByInvite(ctx, tenantID, userID, "code1") + So(err, ShouldBeNil) + So(tu, ShouldNotBeNil) + So(tu.TenantID, ShouldEqual, tenantID) + So(tu.UserID, ShouldEqual, userID) + + var inv2 models.TenantInvite + So(_db.WithContext(ctx).Where("id = ?", inv.ID).First(&inv2).Error, ShouldBeNil) + So(inv2.UsedCount, ShouldEqual, 1) + So(inv2.Status, ShouldEqual, consts.TenantInviteStatusExpired) + + Convey("重复加入应幂等且不再消耗次数", func() { + _, err := Tenant.JoinByInvite(ctx, tenantID, userID, "code1") + So(err, ShouldBeNil) + + var inv3 models.TenantInvite + So(_db.WithContext(ctx).Where("id = ?", inv.ID).First(&inv3).Error, ShouldBeNil) + So(inv3.UsedCount, ShouldEqual, 1) + }) + }) + }) +} + +func (s *TenantJoinTestSuite) Test_CreateJoinRequest() { + Convey("Tenant.CreateJoinRequest", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + userID := int64(20) + + Convey("已是成员应返回前置条件失败", func() { + So(_db.WithContext(ctx).Create(&models.TenantUser{ + TenantID: tenantID, + UserID: userID, + Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}), + Status: consts.UserStatusVerified, + }).Error, ShouldBeNil) + + _, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenantjoindto.JoinRequestCreateForm{Reason: "x"}) + So(err, ShouldNotBeNil) + + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.CodePreconditionFailed) + }) + + Convey("重复提交应返回同一个 pending 申请(幂等)", func() { + s.truncateAll(ctx) + + out1, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenantjoindto.JoinRequestCreateForm{Reason: "a"}) + So(err, ShouldBeNil) + So(out1, ShouldNotBeNil) + + out2, err := Tenant.CreateJoinRequest(ctx, tenantID, userID, &tenantjoindto.JoinRequestCreateForm{Reason: "b"}) + So(err, ShouldBeNil) + So(out2, ShouldNotBeNil) + So(out2.ID, ShouldEqual, out1.ID) + So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusPending) + }) + }) +} + +func (s *TenantJoinTestSuite) Test_AdminJoinRequestPage() { + Convey("Tenant.AdminJoinRequestPage", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + + s.seedJoinRequest(ctx, tenantID, 11, consts.TenantJoinRequestStatusPending) + s.seedJoinRequest(ctx, tenantID, 22, consts.TenantJoinRequestStatusRejected) + s.seedJoinRequest(ctx, tenantID, 33, consts.TenantJoinRequestStatusPending) + + Convey("按 status 过滤应生效", func() { + st := consts.TenantJoinRequestStatusPending + pager, err := Tenant.AdminJoinRequestPage(ctx, tenantID, &tenantdto.AdminTenantJoinRequestListFilter{Status: &st}) + So(err, ShouldBeNil) + So(pager.Total, ShouldEqual, 2) + }) + }) +} + +func (s *TenantJoinTestSuite) Test_AdminApproveJoinRequest() { + Convey("Tenant.AdminApproveJoinRequest", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + adminUserID := int64(10) + userID := int64(20) + + Convey("申请不存在应返回记录不存在", func() { + _, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, 999, "x") + So(err, ShouldNotBeNil) + var appErr *errorx.AppError + So(errors.As(err, &appErr), ShouldBeTrue) + So(appErr.Code, ShouldEqual, errorx.CodeRecordNotFound) + }) + + Convey("通过 pending 申请应成功且幂等", func() { + req := s.seedJoinRequest(ctx, tenantID, userID, consts.TenantJoinRequestStatusPending) + + out, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, req.ID, "ok") + So(err, ShouldBeNil) + So(out.Status, ShouldEqual, consts.TenantJoinRequestStatusApproved) + So(out.DecidedOperatorUserID, ShouldEqual, adminUserID) + So(out.DecidedAt.IsZero(), ShouldBeFalse) + + var tu models.TenantUser + So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&tu).Error, ShouldBeNil) + + out2, err := Tenant.AdminApproveJoinRequest(ctx, tenantID, adminUserID, req.ID, "ok2") + So(err, ShouldBeNil) + So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusApproved) + }) + }) +} + +func (s *TenantJoinTestSuite) Test_AdminRejectJoinRequest() { + Convey("Tenant.AdminRejectJoinRequest", s.T(), func() { + ctx := s.T().Context() + s.truncateAll(ctx) + + tenantID := int64(1) + adminUserID := int64(10) + userID := int64(20) + + Convey("拒绝 pending 申请应成功且幂等", func() { + req := s.seedJoinRequest(ctx, tenantID, userID, consts.TenantJoinRequestStatusPending) + + out, err := Tenant.AdminRejectJoinRequest(ctx, tenantID, adminUserID, req.ID, "no") + So(err, ShouldBeNil) + So(out.Status, ShouldEqual, consts.TenantJoinRequestStatusRejected) + So(out.DecidedOperatorUserID, ShouldEqual, adminUserID) + + out2, err := Tenant.AdminRejectJoinRequest(ctx, tenantID, adminUserID, req.ID, "no2") + So(err, ShouldBeNil) + So(out2.Status, ShouldEqual, consts.TenantJoinRequestStatusRejected) + }) + }) +} + +func loToPtr[T any](v T) *T { return &v } diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index 78cc00c..a69c750 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -38,6 +38,10 @@ field_type: currency: consts.Currency tenant_ledgers: type: consts.TenantLedgerType + tenant_invites: + status: consts.TenantInviteStatus + tenant_join_requests: + status: consts.TenantJoinRequestStatus field_relate: users: OwnedTenant: diff --git a/backend/database/migrations/20251218190000_tenant_invites_join_requests.sql b/backend/database/migrations/20251218190000_tenant_invites_join_requests.sql new file mode 100644 index 0000000..a9b53ed --- /dev/null +++ b/backend/database/migrations/20251218190000_tenant_invites_join_requests.sql @@ -0,0 +1,81 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS tenant_invites( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + code varchar(64) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'active', + max_uses int NOT NULL DEFAULT 0, + used_count int NOT NULL DEFAULT 0, + expires_at timestamptz, + disabled_at timestamptz, + disabled_operator_user_id bigint, + remark varchar(255) NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, code) +); + +-- tenant_invites:租户邀请(用于用户通过邀请码加入租户) +COMMENT ON TABLE tenant_invites IS '租户邀请:租户管理员生成的邀请码;用户可通过 code 加入租户;支持禁用、过期、使用次数限制;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN tenant_invites.id IS '主键ID:自增'; +COMMENT ON COLUMN tenant_invites.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN tenant_invites.user_id IS '创建人用户ID:生成邀请码的租户管理员(审计用)'; +COMMENT ON COLUMN tenant_invites.code IS '邀请码:用户加入租户时提交;同一租户内唯一'; +COMMENT ON COLUMN tenant_invites.status IS '邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致'; +COMMENT ON COLUMN tenant_invites.max_uses IS '最大可使用次数:0 表示不限制;>0 时 used_count 达到该值后视为失效'; +COMMENT ON COLUMN tenant_invites.used_count IS '已使用次数:每次成功加入时 +1;需事务保证并发下不超发'; +COMMENT ON COLUMN tenant_invites.expires_at IS '过期时间:到期后不可再使用(UTC);为空表示不过期'; +COMMENT ON COLUMN tenant_invites.disabled_at IS '禁用时间:租户管理员禁用该邀请的时间(UTC)'; +COMMENT ON COLUMN tenant_invites.disabled_operator_user_id IS '禁用操作人用户ID:租户管理员(审计用)'; +COMMENT ON COLUMN tenant_invites.remark IS '备注:生成/禁用原因等(审计用)'; +COMMENT ON COLUMN tenant_invites.created_at IS '创建时间:默认 now()'; +COMMENT ON COLUMN tenant_invites.updated_at IS '更新时间:默认 now()'; + +CREATE INDEX IF NOT EXISTS ix_tenant_invites_tenant_status ON tenant_invites(tenant_id, status); +CREATE INDEX IF NOT EXISTS ix_tenant_invites_tenant_expires_at ON tenant_invites(tenant_id, expires_at); + +CREATE TABLE IF NOT EXISTS tenant_join_requests( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + status varchar(32) NOT NULL DEFAULT 'pending', + reason varchar(255) NOT NULL DEFAULT '', + decided_at timestamptz, + decided_operator_user_id bigint, + decided_reason varchar(255) NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +-- tenant_join_requests:加入租户申请(用于无邀请码场景的人工审核) +COMMENT ON TABLE tenant_join_requests IS '加入申请:用户申请加入租户,租户管理员审核通过/拒绝;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN tenant_join_requests.id IS '主键ID:自增'; +COMMENT ON COLUMN tenant_join_requests.tenant_id IS '租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id'; +COMMENT ON COLUMN tenant_join_requests.user_id IS '申请人用户ID:发起加入申请的用户'; +COMMENT ON COLUMN tenant_join_requests.status IS '申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id'; +COMMENT ON COLUMN tenant_join_requests.reason IS '申请原因:用户填写的加入说明(可选)'; +COMMENT ON COLUMN tenant_join_requests.decided_at IS '处理时间:审核通过/拒绝时记录(UTC)'; +COMMENT ON COLUMN tenant_join_requests.decided_operator_user_id IS '处理人用户ID:租户管理员(审计用)'; +COMMENT ON COLUMN tenant_join_requests.decided_reason IS '处理说明:管理员通过/拒绝的原因(可选,审计用)'; +COMMENT ON COLUMN tenant_join_requests.created_at IS '创建时间:默认 now()'; +COMMENT ON COLUMN tenant_join_requests.updated_at IS '更新时间:默认 now()'; + +-- 约束:同一用户同一租户同一时间仅允许存在一个 pending 申请,避免重复提交淹没审核队列。 +CREATE UNIQUE INDEX IF NOT EXISTS ux_tenant_join_requests_tenant_user_pending ON tenant_join_requests(tenant_id, user_id) WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS ix_tenant_join_requests_tenant_status ON tenant_join_requests(tenant_id, status); +CREATE INDEX IF NOT EXISTS ix_tenant_join_requests_tenant_created_at ON tenant_join_requests(tenant_id, created_at); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS ix_tenant_join_requests_tenant_created_at; +DROP INDEX IF EXISTS ix_tenant_join_requests_tenant_status; +DROP INDEX IF EXISTS ux_tenant_join_requests_tenant_user_pending; +DROP TABLE IF EXISTS tenant_join_requests; + +DROP INDEX IF EXISTS ix_tenant_invites_tenant_expires_at; +DROP INDEX IF EXISTS ix_tenant_invites_tenant_status; +DROP TABLE IF EXISTS tenant_invites; +-- +goose StatementEnd diff --git a/backend/database/models/query.gen.go b/backend/database/models/query.gen.go index bce92a0..639fa93 100644 --- a/backend/database/models/query.gen.go +++ b/backend/database/models/query.gen.go @@ -16,18 +16,20 @@ import ( ) var ( - Q = new(Query) - ContentQuery *contentQuery - ContentAccessQuery *contentAccessQuery - ContentAssetQuery *contentAssetQuery - ContentPriceQuery *contentPriceQuery - MediaAssetQuery *mediaAssetQuery - OrderQuery *orderQuery - OrderItemQuery *orderItemQuery - TenantQuery *tenantQuery - TenantLedgerQuery *tenantLedgerQuery - TenantUserQuery *tenantUserQuery - UserQuery *userQuery + Q = new(Query) + ContentQuery *contentQuery + ContentAccessQuery *contentAccessQuery + ContentAssetQuery *contentAssetQuery + ContentPriceQuery *contentPriceQuery + MediaAssetQuery *mediaAssetQuery + OrderQuery *orderQuery + OrderItemQuery *orderItemQuery + TenantQuery *tenantQuery + TenantInviteQuery *tenantInviteQuery + TenantJoinRequestQuery *tenantJoinRequestQuery + TenantLedgerQuery *tenantLedgerQuery + TenantUserQuery *tenantUserQuery + UserQuery *userQuery ) func SetDefault(db *gorm.DB, opts ...gen.DOOption) { @@ -40,6 +42,8 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { OrderQuery = &Q.Order OrderItemQuery = &Q.OrderItem TenantQuery = &Q.Tenant + TenantInviteQuery = &Q.TenantInvite + TenantJoinRequestQuery = &Q.TenantJoinRequest TenantLedgerQuery = &Q.TenantLedger TenantUserQuery = &Q.TenantUser UserQuery = &Q.User @@ -47,53 +51,59 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { func Use(db *gorm.DB, opts ...gen.DOOption) *Query { return &Query{ - db: db, - Content: newContent(db, opts...), - ContentAccess: newContentAccess(db, opts...), - ContentAsset: newContentAsset(db, opts...), - ContentPrice: newContentPrice(db, opts...), - MediaAsset: newMediaAsset(db, opts...), - Order: newOrder(db, opts...), - OrderItem: newOrderItem(db, opts...), - Tenant: newTenant(db, opts...), - TenantLedger: newTenantLedger(db, opts...), - TenantUser: newTenantUser(db, opts...), - User: newUser(db, opts...), + db: db, + Content: newContent(db, opts...), + ContentAccess: newContentAccess(db, opts...), + ContentAsset: newContentAsset(db, opts...), + ContentPrice: newContentPrice(db, opts...), + MediaAsset: newMediaAsset(db, opts...), + Order: newOrder(db, opts...), + OrderItem: newOrderItem(db, opts...), + Tenant: newTenant(db, opts...), + TenantInvite: newTenantInvite(db, opts...), + TenantJoinRequest: newTenantJoinRequest(db, opts...), + TenantLedger: newTenantLedger(db, opts...), + TenantUser: newTenantUser(db, opts...), + User: newUser(db, opts...), } } type Query struct { db *gorm.DB - Content contentQuery - ContentAccess contentAccessQuery - ContentAsset contentAssetQuery - ContentPrice contentPriceQuery - MediaAsset mediaAssetQuery - Order orderQuery - OrderItem orderItemQuery - Tenant tenantQuery - TenantLedger tenantLedgerQuery - TenantUser tenantUserQuery - User userQuery + Content contentQuery + ContentAccess contentAccessQuery + ContentAsset contentAssetQuery + ContentPrice contentPriceQuery + MediaAsset mediaAssetQuery + Order orderQuery + OrderItem orderItemQuery + Tenant tenantQuery + TenantInvite tenantInviteQuery + TenantJoinRequest tenantJoinRequestQuery + TenantLedger tenantLedgerQuery + TenantUser tenantUserQuery + User userQuery } func (q *Query) Available() bool { return q.db != nil } func (q *Query) clone(db *gorm.DB) *Query { return &Query{ - db: db, - Content: q.Content.clone(db), - ContentAccess: q.ContentAccess.clone(db), - ContentAsset: q.ContentAsset.clone(db), - ContentPrice: q.ContentPrice.clone(db), - MediaAsset: q.MediaAsset.clone(db), - Order: q.Order.clone(db), - OrderItem: q.OrderItem.clone(db), - Tenant: q.Tenant.clone(db), - TenantLedger: q.TenantLedger.clone(db), - TenantUser: q.TenantUser.clone(db), - User: q.User.clone(db), + db: db, + Content: q.Content.clone(db), + ContentAccess: q.ContentAccess.clone(db), + ContentAsset: q.ContentAsset.clone(db), + ContentPrice: q.ContentPrice.clone(db), + MediaAsset: q.MediaAsset.clone(db), + Order: q.Order.clone(db), + OrderItem: q.OrderItem.clone(db), + Tenant: q.Tenant.clone(db), + TenantInvite: q.TenantInvite.clone(db), + TenantJoinRequest: q.TenantJoinRequest.clone(db), + TenantLedger: q.TenantLedger.clone(db), + TenantUser: q.TenantUser.clone(db), + User: q.User.clone(db), } } @@ -107,48 +117,54 @@ func (q *Query) WriteDB() *Query { func (q *Query) ReplaceDB(db *gorm.DB) *Query { return &Query{ - db: db, - Content: q.Content.replaceDB(db), - ContentAccess: q.ContentAccess.replaceDB(db), - ContentAsset: q.ContentAsset.replaceDB(db), - ContentPrice: q.ContentPrice.replaceDB(db), - MediaAsset: q.MediaAsset.replaceDB(db), - Order: q.Order.replaceDB(db), - OrderItem: q.OrderItem.replaceDB(db), - Tenant: q.Tenant.replaceDB(db), - TenantLedger: q.TenantLedger.replaceDB(db), - TenantUser: q.TenantUser.replaceDB(db), - User: q.User.replaceDB(db), + db: db, + Content: q.Content.replaceDB(db), + ContentAccess: q.ContentAccess.replaceDB(db), + ContentAsset: q.ContentAsset.replaceDB(db), + ContentPrice: q.ContentPrice.replaceDB(db), + MediaAsset: q.MediaAsset.replaceDB(db), + Order: q.Order.replaceDB(db), + OrderItem: q.OrderItem.replaceDB(db), + Tenant: q.Tenant.replaceDB(db), + TenantInvite: q.TenantInvite.replaceDB(db), + TenantJoinRequest: q.TenantJoinRequest.replaceDB(db), + TenantLedger: q.TenantLedger.replaceDB(db), + TenantUser: q.TenantUser.replaceDB(db), + User: q.User.replaceDB(db), } } type queryCtx struct { - Content *contentQueryDo - ContentAccess *contentAccessQueryDo - ContentAsset *contentAssetQueryDo - ContentPrice *contentPriceQueryDo - MediaAsset *mediaAssetQueryDo - Order *orderQueryDo - OrderItem *orderItemQueryDo - Tenant *tenantQueryDo - TenantLedger *tenantLedgerQueryDo - TenantUser *tenantUserQueryDo - User *userQueryDo + Content *contentQueryDo + ContentAccess *contentAccessQueryDo + ContentAsset *contentAssetQueryDo + ContentPrice *contentPriceQueryDo + MediaAsset *mediaAssetQueryDo + Order *orderQueryDo + OrderItem *orderItemQueryDo + Tenant *tenantQueryDo + TenantInvite *tenantInviteQueryDo + TenantJoinRequest *tenantJoinRequestQueryDo + TenantLedger *tenantLedgerQueryDo + TenantUser *tenantUserQueryDo + User *userQueryDo } func (q *Query) WithContext(ctx context.Context) *queryCtx { return &queryCtx{ - Content: q.Content.WithContext(ctx), - ContentAccess: q.ContentAccess.WithContext(ctx), - ContentAsset: q.ContentAsset.WithContext(ctx), - ContentPrice: q.ContentPrice.WithContext(ctx), - MediaAsset: q.MediaAsset.WithContext(ctx), - Order: q.Order.WithContext(ctx), - OrderItem: q.OrderItem.WithContext(ctx), - Tenant: q.Tenant.WithContext(ctx), - TenantLedger: q.TenantLedger.WithContext(ctx), - TenantUser: q.TenantUser.WithContext(ctx), - User: q.User.WithContext(ctx), + Content: q.Content.WithContext(ctx), + ContentAccess: q.ContentAccess.WithContext(ctx), + ContentAsset: q.ContentAsset.WithContext(ctx), + ContentPrice: q.ContentPrice.WithContext(ctx), + MediaAsset: q.MediaAsset.WithContext(ctx), + Order: q.Order.WithContext(ctx), + OrderItem: q.OrderItem.WithContext(ctx), + Tenant: q.Tenant.WithContext(ctx), + TenantInvite: q.TenantInvite.WithContext(ctx), + TenantJoinRequest: q.TenantJoinRequest.WithContext(ctx), + TenantLedger: q.TenantLedger.WithContext(ctx), + TenantUser: q.TenantUser.WithContext(ctx), + User: q.User.WithContext(ctx), } } diff --git a/backend/database/models/tenant_invites.gen.go b/backend/database/models/tenant_invites.gen.go new file mode 100644 index 0000000..1d61582 --- /dev/null +++ b/backend/database/models/tenant_invites.gen.go @@ -0,0 +1,69 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + "time" + + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen" +) + +const TableNameTenantInvite = "tenant_invites" + +// TenantInvite mapped from table +type TenantInvite struct { + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:创建人用户ID:生成邀请码的租户管理员(审计用)" json:"user_id"` // 创建人用户ID:生成邀请码的租户管理员(审计用) + Code string `gorm:"column:code;type:character varying(64);not null;comment:邀请码:用户加入租户时提交;同一租户内唯一" json:"code"` // 邀请码:用户加入租户时提交;同一租户内唯一 + Status consts.TenantInviteStatus `gorm:"column:status;type:character varying(32);not null;default:active;comment:邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致" json:"status"` // 邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致 + MaxUses int32 `gorm:"column:max_uses;type:integer;not null;comment:最大可使用次数:0 表示不限制;>0 时 used_count 达到该值后视为失效" json:"max_uses"` // 最大可使用次数:0 表示不限制;>0 时 used_count 达到该值后视为失效 + UsedCount int32 `gorm:"column:used_count;type:integer;not null;comment:已使用次数:每次成功加入时 +1;需事务保证并发下不超发" json:"used_count"` // 已使用次数:每次成功加入时 +1;需事务保证并发下不超发 + ExpiresAt time.Time `gorm:"column:expires_at;type:timestamp with time zone;comment:过期时间:到期后不可再使用(UTC);为空表示不过期" json:"expires_at"` // 过期时间:到期后不可再使用(UTC);为空表示不过期 + DisabledAt time.Time `gorm:"column:disabled_at;type:timestamp with time zone;comment:禁用时间:租户管理员禁用该邀请的时间(UTC)" json:"disabled_at"` // 禁用时间:租户管理员禁用该邀请的时间(UTC) + DisabledOperatorUserID int64 `gorm:"column:disabled_operator_user_id;type:bigint;comment:禁用操作人用户ID:租户管理员(审计用)" json:"disabled_operator_user_id"` // 禁用操作人用户ID:租户管理员(审计用) + Remark string `gorm:"column:remark;type:character varying(255);not null;comment:备注:生成/禁用原因等(审计用)" json:"remark"` // 备注:生成/禁用原因等(审计用) + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() +} + +// Quick operations without importing query package +// Update applies changed fields to the database using the default DB. +func (m *TenantInvite) Update(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantInvite.WithContext(ctx).Updates(m) +} + +// Save upserts the model using the default DB. +func (m *TenantInvite) Save(ctx context.Context) error { + return Q.TenantInvite.WithContext(ctx).Save(m) +} + +// Create inserts the model using the default DB. +func (m *TenantInvite) Create(ctx context.Context) error { + return Q.TenantInvite.WithContext(ctx).Create(m) +} + +// Delete removes the row represented by the model using the default DB. +func (m *TenantInvite) Delete(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantInvite.WithContext(ctx).Delete(m) +} + +// ForceDelete permanently deletes the row (ignores soft delete) using the default DB. +func (m *TenantInvite) ForceDelete(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantInvite.WithContext(ctx).Unscoped().Delete(m) +} + +// Reload reloads the model from database by its primary key and overwrites current fields. +func (m *TenantInvite) Reload(ctx context.Context) error { + fresh, err := Q.TenantInvite.WithContext(ctx).GetByID(m.ID) + if err != nil { + return err + } + *m = *fresh + return nil +} diff --git a/backend/database/models/tenant_invites.query.gen.go b/backend/database/models/tenant_invites.query.gen.go new file mode 100644 index 0000000..464d880 --- /dev/null +++ b/backend/database/models/tenant_invites.query.gen.go @@ -0,0 +1,509 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + + "gorm.io/plugin/dbresolver" +) + +func newTenantInvite(db *gorm.DB, opts ...gen.DOOption) tenantInviteQuery { + _tenantInviteQuery := tenantInviteQuery{} + + _tenantInviteQuery.tenantInviteQueryDo.UseDB(db, opts...) + _tenantInviteQuery.tenantInviteQueryDo.UseModel(&TenantInvite{}) + + tableName := _tenantInviteQuery.tenantInviteQueryDo.TableName() + _tenantInviteQuery.ALL = field.NewAsterisk(tableName) + _tenantInviteQuery.ID = field.NewInt64(tableName, "id") + _tenantInviteQuery.TenantID = field.NewInt64(tableName, "tenant_id") + _tenantInviteQuery.UserID = field.NewInt64(tableName, "user_id") + _tenantInviteQuery.Code = field.NewString(tableName, "code") + _tenantInviteQuery.Status = field.NewField(tableName, "status") + _tenantInviteQuery.MaxUses = field.NewInt32(tableName, "max_uses") + _tenantInviteQuery.UsedCount = field.NewInt32(tableName, "used_count") + _tenantInviteQuery.ExpiresAt = field.NewTime(tableName, "expires_at") + _tenantInviteQuery.DisabledAt = field.NewTime(tableName, "disabled_at") + _tenantInviteQuery.DisabledOperatorUserID = field.NewInt64(tableName, "disabled_operator_user_id") + _tenantInviteQuery.Remark = field.NewString(tableName, "remark") + _tenantInviteQuery.CreatedAt = field.NewTime(tableName, "created_at") + _tenantInviteQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + + _tenantInviteQuery.fillFieldMap() + + return _tenantInviteQuery +} + +type tenantInviteQuery struct { + tenantInviteQueryDo tenantInviteQueryDo + + ALL field.Asterisk + ID field.Int64 // 主键ID:自增 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID field.Int64 // 创建人用户ID:生成邀请码的租户管理员(审计用) + Code field.String // 邀请码:用户加入租户时提交;同一租户内唯一 + Status field.Field // 邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致 + MaxUses field.Int32 // 最大可使用次数:0 表示不限制;>0 时 used_count 达到该值后视为失效 + UsedCount field.Int32 // 已使用次数:每次成功加入时 +1;需事务保证并发下不超发 + ExpiresAt field.Time // 过期时间:到期后不可再使用(UTC);为空表示不过期 + DisabledAt field.Time // 禁用时间:租户管理员禁用该邀请的时间(UTC) + DisabledOperatorUserID field.Int64 // 禁用操作人用户ID:租户管理员(审计用) + Remark field.String // 备注:生成/禁用原因等(审计用) + CreatedAt field.Time // 创建时间:默认 now() + UpdatedAt field.Time // 更新时间:默认 now() + + fieldMap map[string]field.Expr +} + +func (t tenantInviteQuery) Table(newTableName string) *tenantInviteQuery { + t.tenantInviteQueryDo.UseTable(newTableName) + return t.updateTableName(newTableName) +} + +func (t tenantInviteQuery) As(alias string) *tenantInviteQuery { + t.tenantInviteQueryDo.DO = *(t.tenantInviteQueryDo.As(alias).(*gen.DO)) + return t.updateTableName(alias) +} + +func (t *tenantInviteQuery) updateTableName(table string) *tenantInviteQuery { + t.ALL = field.NewAsterisk(table) + t.ID = field.NewInt64(table, "id") + t.TenantID = field.NewInt64(table, "tenant_id") + t.UserID = field.NewInt64(table, "user_id") + t.Code = field.NewString(table, "code") + t.Status = field.NewField(table, "status") + t.MaxUses = field.NewInt32(table, "max_uses") + t.UsedCount = field.NewInt32(table, "used_count") + t.ExpiresAt = field.NewTime(table, "expires_at") + t.DisabledAt = field.NewTime(table, "disabled_at") + t.DisabledOperatorUserID = field.NewInt64(table, "disabled_operator_user_id") + t.Remark = field.NewString(table, "remark") + t.CreatedAt = field.NewTime(table, "created_at") + t.UpdatedAt = field.NewTime(table, "updated_at") + + t.fillFieldMap() + + return t +} + +func (t *tenantInviteQuery) QueryContext(ctx context.Context) (*tenantInviteQuery, *tenantInviteQueryDo) { + return t, t.tenantInviteQueryDo.WithContext(ctx) +} + +func (t *tenantInviteQuery) WithContext(ctx context.Context) *tenantInviteQueryDo { + return t.tenantInviteQueryDo.WithContext(ctx) +} + +func (t tenantInviteQuery) TableName() string { return t.tenantInviteQueryDo.TableName() } + +func (t tenantInviteQuery) Alias() string { return t.tenantInviteQueryDo.Alias() } + +func (t tenantInviteQuery) Columns(cols ...field.Expr) gen.Columns { + return t.tenantInviteQueryDo.Columns(cols...) +} + +func (t *tenantInviteQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := t.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (t *tenantInviteQuery) fillFieldMap() { + t.fieldMap = make(map[string]field.Expr, 13) + t.fieldMap["id"] = t.ID + t.fieldMap["tenant_id"] = t.TenantID + t.fieldMap["user_id"] = t.UserID + t.fieldMap["code"] = t.Code + t.fieldMap["status"] = t.Status + t.fieldMap["max_uses"] = t.MaxUses + t.fieldMap["used_count"] = t.UsedCount + t.fieldMap["expires_at"] = t.ExpiresAt + t.fieldMap["disabled_at"] = t.DisabledAt + t.fieldMap["disabled_operator_user_id"] = t.DisabledOperatorUserID + t.fieldMap["remark"] = t.Remark + t.fieldMap["created_at"] = t.CreatedAt + t.fieldMap["updated_at"] = t.UpdatedAt +} + +func (t tenantInviteQuery) clone(db *gorm.DB) tenantInviteQuery { + t.tenantInviteQueryDo.ReplaceConnPool(db.Statement.ConnPool) + return t +} + +func (t tenantInviteQuery) replaceDB(db *gorm.DB) tenantInviteQuery { + t.tenantInviteQueryDo.ReplaceDB(db) + return t +} + +type tenantInviteQueryDo struct{ gen.DO } + +func (t tenantInviteQueryDo) Debug() *tenantInviteQueryDo { + return t.withDO(t.DO.Debug()) +} + +func (t tenantInviteQueryDo) WithContext(ctx context.Context) *tenantInviteQueryDo { + return t.withDO(t.DO.WithContext(ctx)) +} + +func (t tenantInviteQueryDo) ReadDB() *tenantInviteQueryDo { + return t.Clauses(dbresolver.Read) +} + +func (t tenantInviteQueryDo) WriteDB() *tenantInviteQueryDo { + return t.Clauses(dbresolver.Write) +} + +func (t tenantInviteQueryDo) Session(config *gorm.Session) *tenantInviteQueryDo { + return t.withDO(t.DO.Session(config)) +} + +func (t tenantInviteQueryDo) Clauses(conds ...clause.Expression) *tenantInviteQueryDo { + return t.withDO(t.DO.Clauses(conds...)) +} + +func (t tenantInviteQueryDo) Returning(value interface{}, columns ...string) *tenantInviteQueryDo { + return t.withDO(t.DO.Returning(value, columns...)) +} + +func (t tenantInviteQueryDo) Not(conds ...gen.Condition) *tenantInviteQueryDo { + return t.withDO(t.DO.Not(conds...)) +} + +func (t tenantInviteQueryDo) Or(conds ...gen.Condition) *tenantInviteQueryDo { + return t.withDO(t.DO.Or(conds...)) +} + +func (t tenantInviteQueryDo) Select(conds ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.Select(conds...)) +} + +func (t tenantInviteQueryDo) Where(conds ...gen.Condition) *tenantInviteQueryDo { + return t.withDO(t.DO.Where(conds...)) +} + +func (t tenantInviteQueryDo) Order(conds ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.Order(conds...)) +} + +func (t tenantInviteQueryDo) Distinct(cols ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.Distinct(cols...)) +} + +func (t tenantInviteQueryDo) Omit(cols ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.Omit(cols...)) +} + +func (t tenantInviteQueryDo) Join(table schema.Tabler, on ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.Join(table, on...)) +} + +func (t tenantInviteQueryDo) LeftJoin(table schema.Tabler, on ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.LeftJoin(table, on...)) +} + +func (t tenantInviteQueryDo) RightJoin(table schema.Tabler, on ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.RightJoin(table, on...)) +} + +func (t tenantInviteQueryDo) Group(cols ...field.Expr) *tenantInviteQueryDo { + return t.withDO(t.DO.Group(cols...)) +} + +func (t tenantInviteQueryDo) Having(conds ...gen.Condition) *tenantInviteQueryDo { + return t.withDO(t.DO.Having(conds...)) +} + +func (t tenantInviteQueryDo) Limit(limit int) *tenantInviteQueryDo { + return t.withDO(t.DO.Limit(limit)) +} + +func (t tenantInviteQueryDo) Offset(offset int) *tenantInviteQueryDo { + return t.withDO(t.DO.Offset(offset)) +} + +func (t tenantInviteQueryDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *tenantInviteQueryDo { + return t.withDO(t.DO.Scopes(funcs...)) +} + +func (t tenantInviteQueryDo) Unscoped() *tenantInviteQueryDo { + return t.withDO(t.DO.Unscoped()) +} + +func (t tenantInviteQueryDo) Create(values ...*TenantInvite) error { + if len(values) == 0 { + return nil + } + return t.DO.Create(values) +} + +func (t tenantInviteQueryDo) CreateInBatches(values []*TenantInvite, batchSize int) error { + return t.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (t tenantInviteQueryDo) Save(values ...*TenantInvite) error { + if len(values) == 0 { + return nil + } + return t.DO.Save(values) +} + +func (t tenantInviteQueryDo) First() (*TenantInvite, error) { + if result, err := t.DO.First(); err != nil { + return nil, err + } else { + return result.(*TenantInvite), nil + } +} + +func (t tenantInviteQueryDo) Take() (*TenantInvite, error) { + if result, err := t.DO.Take(); err != nil { + return nil, err + } else { + return result.(*TenantInvite), nil + } +} + +func (t tenantInviteQueryDo) Last() (*TenantInvite, error) { + if result, err := t.DO.Last(); err != nil { + return nil, err + } else { + return result.(*TenantInvite), nil + } +} + +func (t tenantInviteQueryDo) Find() ([]*TenantInvite, error) { + result, err := t.DO.Find() + return result.([]*TenantInvite), err +} + +func (t tenantInviteQueryDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*TenantInvite, err error) { + buf := make([]*TenantInvite, 0, batchSize) + err = t.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (t tenantInviteQueryDo) FindInBatches(result *[]*TenantInvite, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return t.DO.FindInBatches(result, batchSize, fc) +} + +func (t tenantInviteQueryDo) Attrs(attrs ...field.AssignExpr) *tenantInviteQueryDo { + return t.withDO(t.DO.Attrs(attrs...)) +} + +func (t tenantInviteQueryDo) Assign(attrs ...field.AssignExpr) *tenantInviteQueryDo { + return t.withDO(t.DO.Assign(attrs...)) +} + +func (t tenantInviteQueryDo) Joins(fields ...field.RelationField) *tenantInviteQueryDo { + for _, _f := range fields { + t = *t.withDO(t.DO.Joins(_f)) + } + return &t +} + +func (t tenantInviteQueryDo) Preload(fields ...field.RelationField) *tenantInviteQueryDo { + for _, _f := range fields { + t = *t.withDO(t.DO.Preload(_f)) + } + return &t +} + +func (t tenantInviteQueryDo) FirstOrInit() (*TenantInvite, error) { + if result, err := t.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*TenantInvite), nil + } +} + +func (t tenantInviteQueryDo) FirstOrCreate() (*TenantInvite, error) { + if result, err := t.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*TenantInvite), nil + } +} + +func (t tenantInviteQueryDo) FindByPage(offset int, limit int) (result []*TenantInvite, count int64, err error) { + result, err = t.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = t.Offset(-1).Limit(-1).Count() + return +} + +func (t tenantInviteQueryDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = t.Count() + if err != nil { + return + } + + err = t.Offset(offset).Limit(limit).Scan(result) + return +} + +func (t tenantInviteQueryDo) Scan(result interface{}) (err error) { + return t.DO.Scan(result) +} + +func (t tenantInviteQueryDo) Delete(models ...*TenantInvite) (result gen.ResultInfo, err error) { + return t.DO.Delete(models) +} + +// ForceDelete performs a permanent delete (ignores soft-delete) for current scope. +func (t tenantInviteQueryDo) ForceDelete() (gen.ResultInfo, error) { + return t.Unscoped().Delete() +} + +// Inc increases the given column by step for current scope. +func (t tenantInviteQueryDo) Inc(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column + step + e := field.NewUnsafeFieldRaw("?+?", column.RawExpr(), step) + return t.DO.UpdateColumn(column, e) +} + +// Dec decreases the given column by step for current scope. +func (t tenantInviteQueryDo) Dec(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column - step + e := field.NewUnsafeFieldRaw("?-?", column.RawExpr(), step) + return t.DO.UpdateColumn(column, e) +} + +// Sum returns SUM(column) for current scope. +func (t tenantInviteQueryDo) Sum(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("SUM(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Avg returns AVG(column) for current scope. +func (t tenantInviteQueryDo) Avg(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("AVG(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Min returns MIN(column) for current scope. +func (t tenantInviteQueryDo) Min(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MIN(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Max returns MAX(column) for current scope. +func (t tenantInviteQueryDo) Max(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MAX(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// PluckMap returns a map[key]value for selected key/value expressions within current scope. +func (t tenantInviteQueryDo) PluckMap(key, val field.Expr) (map[interface{}]interface{}, error) { + do := t.Select(key, val) + rows, err := do.DO.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + mm := make(map[interface{}]interface{}) + for rows.Next() { + var k interface{} + var v interface{} + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + mm[k] = v + } + return mm, rows.Err() +} + +// Exists returns true if any record matches the given conditions. +func (t tenantInviteQueryDo) Exists(conds ...gen.Condition) (bool, error) { + cnt, err := t.Where(conds...).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// PluckIDs returns all primary key values under current scope. +func (t tenantInviteQueryDo) PluckIDs() ([]int64, error) { + ids := make([]int64, 0, 16) + pk := field.NewInt64(t.TableName(), "id") + if err := t.DO.Pluck(pk, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// GetByID finds a single record by primary key. +func (t tenantInviteQueryDo) GetByID(id int64) (*TenantInvite, error) { + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.Eq(id)).First() +} + +// GetByIDs finds records by primary key list. +func (t tenantInviteQueryDo) GetByIDs(ids ...int64) ([]*TenantInvite, error) { + if len(ids) == 0 { + return []*TenantInvite{}, nil + } + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.In(ids...)).Find() +} + +// DeleteByID deletes records by primary key. +func (t tenantInviteQueryDo) DeleteByID(id int64) (gen.ResultInfo, error) { + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.Eq(id)).Delete() +} + +// DeleteByIDs deletes records by a list of primary keys. +func (t tenantInviteQueryDo) DeleteByIDs(ids ...int64) (gen.ResultInfo, error) { + if len(ids) == 0 { + return gen.ResultInfo{RowsAffected: 0, Error: nil}, nil + } + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.In(ids...)).Delete() +} + +func (t *tenantInviteQueryDo) withDO(do gen.Dao) *tenantInviteQueryDo { + t.DO = *do.(*gen.DO) + return t +} diff --git a/backend/database/models/tenant_join_requests.gen.go b/backend/database/models/tenant_join_requests.gen.go new file mode 100644 index 0000000..7c9a77c --- /dev/null +++ b/backend/database/models/tenant_join_requests.gen.go @@ -0,0 +1,66 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + "time" + + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen" +) + +const TableNameTenantJoinRequest = "tenant_join_requests" + +// TenantJoinRequest mapped from table +type TenantJoinRequest struct { + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:申请人用户ID:发起加入申请的用户" json:"user_id"` // 申请人用户ID:发起加入申请的用户 + Status consts.TenantJoinRequestStatus `gorm:"column:status;type:character varying(32);not null;default:pending;comment:申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id" json:"status"` // 申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id + Reason string `gorm:"column:reason;type:character varying(255);not null;comment:申请原因:用户填写的加入说明(可选)" json:"reason"` // 申请原因:用户填写的加入说明(可选) + DecidedAt time.Time `gorm:"column:decided_at;type:timestamp with time zone;comment:处理时间:审核通过/拒绝时记录(UTC)" json:"decided_at"` // 处理时间:审核通过/拒绝时记录(UTC) + DecidedOperatorUserID int64 `gorm:"column:decided_operator_user_id;type:bigint;comment:处理人用户ID:租户管理员(审计用)" json:"decided_operator_user_id"` // 处理人用户ID:租户管理员(审计用) + DecidedReason string `gorm:"column:decided_reason;type:character varying(255);not null;comment:处理说明:管理员通过/拒绝的原因(可选,审计用)" json:"decided_reason"` // 处理说明:管理员通过/拒绝的原因(可选,审计用) + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() +} + +// Quick operations without importing query package +// Update applies changed fields to the database using the default DB. +func (m *TenantJoinRequest) Update(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantJoinRequest.WithContext(ctx).Updates(m) +} + +// Save upserts the model using the default DB. +func (m *TenantJoinRequest) Save(ctx context.Context) error { + return Q.TenantJoinRequest.WithContext(ctx).Save(m) +} + +// Create inserts the model using the default DB. +func (m *TenantJoinRequest) Create(ctx context.Context) error { + return Q.TenantJoinRequest.WithContext(ctx).Create(m) +} + +// Delete removes the row represented by the model using the default DB. +func (m *TenantJoinRequest) Delete(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantJoinRequest.WithContext(ctx).Delete(m) +} + +// ForceDelete permanently deletes the row (ignores soft delete) using the default DB. +func (m *TenantJoinRequest) ForceDelete(ctx context.Context) (gen.ResultInfo, error) { + return Q.TenantJoinRequest.WithContext(ctx).Unscoped().Delete(m) +} + +// Reload reloads the model from database by its primary key and overwrites current fields. +func (m *TenantJoinRequest) Reload(ctx context.Context) error { + fresh, err := Q.TenantJoinRequest.WithContext(ctx).GetByID(m.ID) + if err != nil { + return err + } + *m = *fresh + return nil +} diff --git a/backend/database/models/tenant_join_requests.query.gen.go b/backend/database/models/tenant_join_requests.query.gen.go new file mode 100644 index 0000000..dd60551 --- /dev/null +++ b/backend/database/models/tenant_join_requests.query.gen.go @@ -0,0 +1,497 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + + "gorm.io/plugin/dbresolver" +) + +func newTenantJoinRequest(db *gorm.DB, opts ...gen.DOOption) tenantJoinRequestQuery { + _tenantJoinRequestQuery := tenantJoinRequestQuery{} + + _tenantJoinRequestQuery.tenantJoinRequestQueryDo.UseDB(db, opts...) + _tenantJoinRequestQuery.tenantJoinRequestQueryDo.UseModel(&TenantJoinRequest{}) + + tableName := _tenantJoinRequestQuery.tenantJoinRequestQueryDo.TableName() + _tenantJoinRequestQuery.ALL = field.NewAsterisk(tableName) + _tenantJoinRequestQuery.ID = field.NewInt64(tableName, "id") + _tenantJoinRequestQuery.TenantID = field.NewInt64(tableName, "tenant_id") + _tenantJoinRequestQuery.UserID = field.NewInt64(tableName, "user_id") + _tenantJoinRequestQuery.Status = field.NewField(tableName, "status") + _tenantJoinRequestQuery.Reason = field.NewString(tableName, "reason") + _tenantJoinRequestQuery.DecidedAt = field.NewTime(tableName, "decided_at") + _tenantJoinRequestQuery.DecidedOperatorUserID = field.NewInt64(tableName, "decided_operator_user_id") + _tenantJoinRequestQuery.DecidedReason = field.NewString(tableName, "decided_reason") + _tenantJoinRequestQuery.CreatedAt = field.NewTime(tableName, "created_at") + _tenantJoinRequestQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + + _tenantJoinRequestQuery.fillFieldMap() + + return _tenantJoinRequestQuery +} + +type tenantJoinRequestQuery struct { + tenantJoinRequestQueryDo tenantJoinRequestQueryDo + + ALL field.Asterisk + ID field.Int64 // 主键ID:自增 + TenantID field.Int64 // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID field.Int64 // 申请人用户ID:发起加入申请的用户 + Status field.Field // 申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id + Reason field.String // 申请原因:用户填写的加入说明(可选) + DecidedAt field.Time // 处理时间:审核通过/拒绝时记录(UTC) + DecidedOperatorUserID field.Int64 // 处理人用户ID:租户管理员(审计用) + DecidedReason field.String // 处理说明:管理员通过/拒绝的原因(可选,审计用) + CreatedAt field.Time // 创建时间:默认 now() + UpdatedAt field.Time // 更新时间:默认 now() + + fieldMap map[string]field.Expr +} + +func (t tenantJoinRequestQuery) Table(newTableName string) *tenantJoinRequestQuery { + t.tenantJoinRequestQueryDo.UseTable(newTableName) + return t.updateTableName(newTableName) +} + +func (t tenantJoinRequestQuery) As(alias string) *tenantJoinRequestQuery { + t.tenantJoinRequestQueryDo.DO = *(t.tenantJoinRequestQueryDo.As(alias).(*gen.DO)) + return t.updateTableName(alias) +} + +func (t *tenantJoinRequestQuery) updateTableName(table string) *tenantJoinRequestQuery { + t.ALL = field.NewAsterisk(table) + t.ID = field.NewInt64(table, "id") + t.TenantID = field.NewInt64(table, "tenant_id") + t.UserID = field.NewInt64(table, "user_id") + t.Status = field.NewField(table, "status") + t.Reason = field.NewString(table, "reason") + t.DecidedAt = field.NewTime(table, "decided_at") + t.DecidedOperatorUserID = field.NewInt64(table, "decided_operator_user_id") + t.DecidedReason = field.NewString(table, "decided_reason") + t.CreatedAt = field.NewTime(table, "created_at") + t.UpdatedAt = field.NewTime(table, "updated_at") + + t.fillFieldMap() + + return t +} + +func (t *tenantJoinRequestQuery) QueryContext(ctx context.Context) (*tenantJoinRequestQuery, *tenantJoinRequestQueryDo) { + return t, t.tenantJoinRequestQueryDo.WithContext(ctx) +} + +func (t *tenantJoinRequestQuery) WithContext(ctx context.Context) *tenantJoinRequestQueryDo { + return t.tenantJoinRequestQueryDo.WithContext(ctx) +} + +func (t tenantJoinRequestQuery) TableName() string { return t.tenantJoinRequestQueryDo.TableName() } + +func (t tenantJoinRequestQuery) Alias() string { return t.tenantJoinRequestQueryDo.Alias() } + +func (t tenantJoinRequestQuery) Columns(cols ...field.Expr) gen.Columns { + return t.tenantJoinRequestQueryDo.Columns(cols...) +} + +func (t *tenantJoinRequestQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := t.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (t *tenantJoinRequestQuery) fillFieldMap() { + t.fieldMap = make(map[string]field.Expr, 10) + t.fieldMap["id"] = t.ID + t.fieldMap["tenant_id"] = t.TenantID + t.fieldMap["user_id"] = t.UserID + t.fieldMap["status"] = t.Status + t.fieldMap["reason"] = t.Reason + t.fieldMap["decided_at"] = t.DecidedAt + t.fieldMap["decided_operator_user_id"] = t.DecidedOperatorUserID + t.fieldMap["decided_reason"] = t.DecidedReason + t.fieldMap["created_at"] = t.CreatedAt + t.fieldMap["updated_at"] = t.UpdatedAt +} + +func (t tenantJoinRequestQuery) clone(db *gorm.DB) tenantJoinRequestQuery { + t.tenantJoinRequestQueryDo.ReplaceConnPool(db.Statement.ConnPool) + return t +} + +func (t tenantJoinRequestQuery) replaceDB(db *gorm.DB) tenantJoinRequestQuery { + t.tenantJoinRequestQueryDo.ReplaceDB(db) + return t +} + +type tenantJoinRequestQueryDo struct{ gen.DO } + +func (t tenantJoinRequestQueryDo) Debug() *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Debug()) +} + +func (t tenantJoinRequestQueryDo) WithContext(ctx context.Context) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.WithContext(ctx)) +} + +func (t tenantJoinRequestQueryDo) ReadDB() *tenantJoinRequestQueryDo { + return t.Clauses(dbresolver.Read) +} + +func (t tenantJoinRequestQueryDo) WriteDB() *tenantJoinRequestQueryDo { + return t.Clauses(dbresolver.Write) +} + +func (t tenantJoinRequestQueryDo) Session(config *gorm.Session) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Session(config)) +} + +func (t tenantJoinRequestQueryDo) Clauses(conds ...clause.Expression) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Clauses(conds...)) +} + +func (t tenantJoinRequestQueryDo) Returning(value interface{}, columns ...string) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Returning(value, columns...)) +} + +func (t tenantJoinRequestQueryDo) Not(conds ...gen.Condition) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Not(conds...)) +} + +func (t tenantJoinRequestQueryDo) Or(conds ...gen.Condition) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Or(conds...)) +} + +func (t tenantJoinRequestQueryDo) Select(conds ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Select(conds...)) +} + +func (t tenantJoinRequestQueryDo) Where(conds ...gen.Condition) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Where(conds...)) +} + +func (t tenantJoinRequestQueryDo) Order(conds ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Order(conds...)) +} + +func (t tenantJoinRequestQueryDo) Distinct(cols ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Distinct(cols...)) +} + +func (t tenantJoinRequestQueryDo) Omit(cols ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Omit(cols...)) +} + +func (t tenantJoinRequestQueryDo) Join(table schema.Tabler, on ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Join(table, on...)) +} + +func (t tenantJoinRequestQueryDo) LeftJoin(table schema.Tabler, on ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.LeftJoin(table, on...)) +} + +func (t tenantJoinRequestQueryDo) RightJoin(table schema.Tabler, on ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.RightJoin(table, on...)) +} + +func (t tenantJoinRequestQueryDo) Group(cols ...field.Expr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Group(cols...)) +} + +func (t tenantJoinRequestQueryDo) Having(conds ...gen.Condition) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Having(conds...)) +} + +func (t tenantJoinRequestQueryDo) Limit(limit int) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Limit(limit)) +} + +func (t tenantJoinRequestQueryDo) Offset(offset int) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Offset(offset)) +} + +func (t tenantJoinRequestQueryDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Scopes(funcs...)) +} + +func (t tenantJoinRequestQueryDo) Unscoped() *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Unscoped()) +} + +func (t tenantJoinRequestQueryDo) Create(values ...*TenantJoinRequest) error { + if len(values) == 0 { + return nil + } + return t.DO.Create(values) +} + +func (t tenantJoinRequestQueryDo) CreateInBatches(values []*TenantJoinRequest, batchSize int) error { + return t.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (t tenantJoinRequestQueryDo) Save(values ...*TenantJoinRequest) error { + if len(values) == 0 { + return nil + } + return t.DO.Save(values) +} + +func (t tenantJoinRequestQueryDo) First() (*TenantJoinRequest, error) { + if result, err := t.DO.First(); err != nil { + return nil, err + } else { + return result.(*TenantJoinRequest), nil + } +} + +func (t tenantJoinRequestQueryDo) Take() (*TenantJoinRequest, error) { + if result, err := t.DO.Take(); err != nil { + return nil, err + } else { + return result.(*TenantJoinRequest), nil + } +} + +func (t tenantJoinRequestQueryDo) Last() (*TenantJoinRequest, error) { + if result, err := t.DO.Last(); err != nil { + return nil, err + } else { + return result.(*TenantJoinRequest), nil + } +} + +func (t tenantJoinRequestQueryDo) Find() ([]*TenantJoinRequest, error) { + result, err := t.DO.Find() + return result.([]*TenantJoinRequest), err +} + +func (t tenantJoinRequestQueryDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*TenantJoinRequest, err error) { + buf := make([]*TenantJoinRequest, 0, batchSize) + err = t.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (t tenantJoinRequestQueryDo) FindInBatches(result *[]*TenantJoinRequest, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return t.DO.FindInBatches(result, batchSize, fc) +} + +func (t tenantJoinRequestQueryDo) Attrs(attrs ...field.AssignExpr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Attrs(attrs...)) +} + +func (t tenantJoinRequestQueryDo) Assign(attrs ...field.AssignExpr) *tenantJoinRequestQueryDo { + return t.withDO(t.DO.Assign(attrs...)) +} + +func (t tenantJoinRequestQueryDo) Joins(fields ...field.RelationField) *tenantJoinRequestQueryDo { + for _, _f := range fields { + t = *t.withDO(t.DO.Joins(_f)) + } + return &t +} + +func (t tenantJoinRequestQueryDo) Preload(fields ...field.RelationField) *tenantJoinRequestQueryDo { + for _, _f := range fields { + t = *t.withDO(t.DO.Preload(_f)) + } + return &t +} + +func (t tenantJoinRequestQueryDo) FirstOrInit() (*TenantJoinRequest, error) { + if result, err := t.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*TenantJoinRequest), nil + } +} + +func (t tenantJoinRequestQueryDo) FirstOrCreate() (*TenantJoinRequest, error) { + if result, err := t.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*TenantJoinRequest), nil + } +} + +func (t tenantJoinRequestQueryDo) FindByPage(offset int, limit int) (result []*TenantJoinRequest, count int64, err error) { + result, err = t.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = t.Offset(-1).Limit(-1).Count() + return +} + +func (t tenantJoinRequestQueryDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = t.Count() + if err != nil { + return + } + + err = t.Offset(offset).Limit(limit).Scan(result) + return +} + +func (t tenantJoinRequestQueryDo) Scan(result interface{}) (err error) { + return t.DO.Scan(result) +} + +func (t tenantJoinRequestQueryDo) Delete(models ...*TenantJoinRequest) (result gen.ResultInfo, err error) { + return t.DO.Delete(models) +} + +// ForceDelete performs a permanent delete (ignores soft-delete) for current scope. +func (t tenantJoinRequestQueryDo) ForceDelete() (gen.ResultInfo, error) { + return t.Unscoped().Delete() +} + +// Inc increases the given column by step for current scope. +func (t tenantJoinRequestQueryDo) Inc(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column + step + e := field.NewUnsafeFieldRaw("?+?", column.RawExpr(), step) + return t.DO.UpdateColumn(column, e) +} + +// Dec decreases the given column by step for current scope. +func (t tenantJoinRequestQueryDo) Dec(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column - step + e := field.NewUnsafeFieldRaw("?-?", column.RawExpr(), step) + return t.DO.UpdateColumn(column, e) +} + +// Sum returns SUM(column) for current scope. +func (t tenantJoinRequestQueryDo) Sum(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("SUM(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Avg returns AVG(column) for current scope. +func (t tenantJoinRequestQueryDo) Avg(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("AVG(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Min returns MIN(column) for current scope. +func (t tenantJoinRequestQueryDo) Min(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MIN(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Max returns MAX(column) for current scope. +func (t tenantJoinRequestQueryDo) Max(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MAX(?)", column.RawExpr()) + if err := t.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// PluckMap returns a map[key]value for selected key/value expressions within current scope. +func (t tenantJoinRequestQueryDo) PluckMap(key, val field.Expr) (map[interface{}]interface{}, error) { + do := t.Select(key, val) + rows, err := do.DO.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + mm := make(map[interface{}]interface{}) + for rows.Next() { + var k interface{} + var v interface{} + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + mm[k] = v + } + return mm, rows.Err() +} + +// Exists returns true if any record matches the given conditions. +func (t tenantJoinRequestQueryDo) Exists(conds ...gen.Condition) (bool, error) { + cnt, err := t.Where(conds...).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// PluckIDs returns all primary key values under current scope. +func (t tenantJoinRequestQueryDo) PluckIDs() ([]int64, error) { + ids := make([]int64, 0, 16) + pk := field.NewInt64(t.TableName(), "id") + if err := t.DO.Pluck(pk, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// GetByID finds a single record by primary key. +func (t tenantJoinRequestQueryDo) GetByID(id int64) (*TenantJoinRequest, error) { + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.Eq(id)).First() +} + +// GetByIDs finds records by primary key list. +func (t tenantJoinRequestQueryDo) GetByIDs(ids ...int64) ([]*TenantJoinRequest, error) { + if len(ids) == 0 { + return []*TenantJoinRequest{}, nil + } + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.In(ids...)).Find() +} + +// DeleteByID deletes records by primary key. +func (t tenantJoinRequestQueryDo) DeleteByID(id int64) (gen.ResultInfo, error) { + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.Eq(id)).Delete() +} + +// DeleteByIDs deletes records by a list of primary keys. +func (t tenantJoinRequestQueryDo) DeleteByIDs(ids ...int64) (gen.ResultInfo, error) { + if len(ids) == 0 { + return gen.ResultInfo{RowsAffected: 0, Error: nil}, nil + } + pk := field.NewInt64(t.TableName(), "id") + return t.Where(pk.In(ids...)).Delete() +} + +func (t *tenantJoinRequestQueryDo) withDO(do gen.Dao) *tenantJoinRequestQueryDo { + t.DO = *do.(*gen.DO) + return t +} diff --git a/backend/database/models/tenant_users.gen.go b/backend/database/models/tenant_users.gen.go index f06398d..27713ca 100644 --- a/backend/database/models/tenant_users.gen.go +++ b/backend/database/models/tenant_users.gen.go @@ -23,7 +23,7 @@ type TenantUser struct { UserID int64 `gorm:"column:user_id;type:bigint;not null" json:"user_id"` Role types.Array[consts.TenantUserRole] `gorm:"column:role;type:text[];not null;default:ARRAY['member" json:"role"` Balance int64 `gorm:"column:balance;type:bigint;not null" json:"balance"` - Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"` + Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:verified" json:"status"` CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now()" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now()" json:"updated_at"` BalanceFrozen int64 `gorm:"column:balance_frozen;type:bigint;not null;comment:冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0" json:"balance_frozen"` // 冻结余额:分/最小货币单位;下单冻结时从可用余额转入,最终扣款或回滚时转出;默认 0 diff --git a/backend/database/models/users.gen.go b/backend/database/models/users.gen.go index a21ab32..72844a7 100644 --- a/backend/database/models/users.gen.go +++ b/backend/database/models/users.gen.go @@ -29,8 +29,8 @@ type User struct { Status consts.UserStatus `gorm:"column:status;type:character varying(50);not null;default:active" json:"status"` Metas types.JSON `gorm:"column:metas;type:jsonb;not null;default:{}" json:"metas"` VerifiedAt time.Time `gorm:"column:verified_at;type:timestamp with time zone" json:"verified_at"` - Tenants []*Tenant `gorm:"joinForeignKey:UserID;joinReferences:TenantID;many2many:tenant_users" json:"tenants,omitempty"` OwnedTenant *Tenant `json:"owned,omitempty"` + Tenants []*Tenant `gorm:"joinForeignKey:UserID;joinReferences:TenantID;many2many:tenant_users" json:"tenants,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/users.query.gen.go b/backend/database/models/users.query.gen.go index c84903f..5a1f7f5 100644 --- a/backend/database/models/users.query.gen.go +++ b/backend/database/models/users.query.gen.go @@ -35,18 +35,18 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery { _userQuery.Status = field.NewField(tableName, "status") _userQuery.Metas = field.NewJSONB(tableName, "metas") _userQuery.VerifiedAt = field.NewTime(tableName, "verified_at") - _userQuery.Tenants = userQueryManyToManyTenants{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Tenants", "Tenant"), - } - _userQuery.OwnedTenant = userQueryBelongsToOwnedTenant{ db: db.Session(&gorm.Session{}), RelationField: field.NewRelation("OwnedTenant", "Tenant"), } + _userQuery.Tenants = userQueryManyToManyTenants{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Tenants", "Tenant"), + } + _userQuery.fillFieldMap() return _userQuery @@ -55,21 +55,21 @@ func newUser(db *gorm.DB, opts ...gen.DOOption) userQuery { type userQuery struct { userQueryDo userQueryDo - ALL field.Asterisk - ID field.Int64 - CreatedAt field.Time - UpdatedAt field.Time - DeletedAt field.Field - Username field.String - Password field.String - Roles field.Array - Status field.Field - Metas field.JSONB - VerifiedAt field.Time - Tenants userQueryManyToManyTenants - + ALL field.Asterisk + ID field.Int64 + CreatedAt field.Time + UpdatedAt field.Time + DeletedAt field.Field + Username field.String + Password field.String + Roles field.Array + Status field.Field + Metas field.JSONB + VerifiedAt field.Time OwnedTenant userQueryBelongsToOwnedTenant + Tenants userQueryManyToManyTenants + fieldMap map[string]field.Expr } @@ -141,101 +141,20 @@ func (u *userQuery) fillFieldMap() { func (u userQuery) clone(db *gorm.DB) userQuery { u.userQueryDo.ReplaceConnPool(db.Statement.ConnPool) - u.Tenants.db = db.Session(&gorm.Session{Initialized: true}) - u.Tenants.db.Statement.ConnPool = db.Statement.ConnPool u.OwnedTenant.db = db.Session(&gorm.Session{Initialized: true}) u.OwnedTenant.db.Statement.ConnPool = db.Statement.ConnPool + u.Tenants.db = db.Session(&gorm.Session{Initialized: true}) + u.Tenants.db.Statement.ConnPool = db.Statement.ConnPool return u } func (u userQuery) replaceDB(db *gorm.DB) userQuery { u.userQueryDo.ReplaceDB(db) - u.Tenants.db = db.Session(&gorm.Session{}) u.OwnedTenant.db = db.Session(&gorm.Session{}) + u.Tenants.db = db.Session(&gorm.Session{}) return u } -type userQueryManyToManyTenants struct { - db *gorm.DB - - field.RelationField -} - -func (a userQueryManyToManyTenants) Where(conds ...field.Expr) *userQueryManyToManyTenants { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a userQueryManyToManyTenants) WithContext(ctx context.Context) *userQueryManyToManyTenants { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a userQueryManyToManyTenants) Session(session *gorm.Session) *userQueryManyToManyTenants { - a.db = a.db.Session(session) - return &a -} - -func (a userQueryManyToManyTenants) Model(m *User) *userQueryManyToManyTenantsTx { - return &userQueryManyToManyTenantsTx{a.db.Model(m).Association(a.Name())} -} - -func (a userQueryManyToManyTenants) Unscoped() *userQueryManyToManyTenants { - a.db = a.db.Unscoped() - return &a -} - -type userQueryManyToManyTenantsTx struct{ tx *gorm.Association } - -func (a userQueryManyToManyTenantsTx) Find() (result []*Tenant, err error) { - return result, a.tx.Find(&result) -} - -func (a userQueryManyToManyTenantsTx) Append(values ...*Tenant) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a userQueryManyToManyTenantsTx) Replace(values ...*Tenant) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a userQueryManyToManyTenantsTx) Delete(values ...*Tenant) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a userQueryManyToManyTenantsTx) Clear() error { - return a.tx.Clear() -} - -func (a userQueryManyToManyTenantsTx) Count() int64 { - return a.tx.Count() -} - -func (a userQueryManyToManyTenantsTx) Unscoped() *userQueryManyToManyTenantsTx { - a.tx = a.tx.Unscoped() - return &a -} - type userQueryBelongsToOwnedTenant struct { db *gorm.DB @@ -317,6 +236,87 @@ func (a userQueryBelongsToOwnedTenantTx) Unscoped() *userQueryBelongsToOwnedTena return &a } +type userQueryManyToManyTenants struct { + db *gorm.DB + + field.RelationField +} + +func (a userQueryManyToManyTenants) Where(conds ...field.Expr) *userQueryManyToManyTenants { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a userQueryManyToManyTenants) WithContext(ctx context.Context) *userQueryManyToManyTenants { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a userQueryManyToManyTenants) Session(session *gorm.Session) *userQueryManyToManyTenants { + a.db = a.db.Session(session) + return &a +} + +func (a userQueryManyToManyTenants) Model(m *User) *userQueryManyToManyTenantsTx { + return &userQueryManyToManyTenantsTx{a.db.Model(m).Association(a.Name())} +} + +func (a userQueryManyToManyTenants) Unscoped() *userQueryManyToManyTenants { + a.db = a.db.Unscoped() + return &a +} + +type userQueryManyToManyTenantsTx struct{ tx *gorm.Association } + +func (a userQueryManyToManyTenantsTx) Find() (result []*Tenant, err error) { + return result, a.tx.Find(&result) +} + +func (a userQueryManyToManyTenantsTx) Append(values ...*Tenant) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a userQueryManyToManyTenantsTx) Replace(values ...*Tenant) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a userQueryManyToManyTenantsTx) Delete(values ...*Tenant) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a userQueryManyToManyTenantsTx) Clear() error { + return a.tx.Clear() +} + +func (a userQueryManyToManyTenantsTx) Count() int64 { + return a.tx.Count() +} + +func (a userQueryManyToManyTenantsTx) Unscoped() *userQueryManyToManyTenantsTx { + a.tx = a.tx.Unscoped() + return &a +} + type userQueryDo struct{ gen.DO } func (u userQueryDo) Debug() *userQueryDo { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 8f6c978..e6ae5a9 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -603,6 +603,342 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/invites": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "邀请码列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Code 按邀请码模糊过滤(可选):支持部分匹配(like)。", + "name": "code", + "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": [ + "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": { + "$ref": "#/definitions/models.TenantInvite" + } + } + } + ] + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "创建邀请码(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantInviteCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantInvite" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/invites/{inviteID}/disable": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "禁用邀请码(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "InviteID", + "name": "inviteID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantInviteDisableForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantInvite" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/join-requests": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "加入申请列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "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" + }, + { + "type": "integer", + "description": "UserID 按申请人用户ID过滤(可选)。", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/join-requests/{requestID}/approve": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "通过加入申请(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "RequestID", + "name": "requestID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantJoinRequestDecideForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/join-requests/{requestID}/reject": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "拒绝加入申请(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "RequestID", + "name": "requestID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantJoinRequestDecideForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/orders": { "get": { "consumes": [ @@ -849,11 +1185,33 @@ const docTemplate = `{ "name": "role", "in": "query" }, + { + "enum": [ + "pending_verify", + "verified", + "banned" + ], + "type": "string", + "x-enum-varnames": [ + "UserStatusPendingVerify", + "UserStatusVerified", + "UserStatusBanned" + ], + "description": "Status 按成员状态过滤(可选):pending_verify/verified/banned。", + "name": "status", + "in": "query" + }, { "type": "integer", "description": "UserID 按用户ID过滤(可选)。", "name": "user_id", "in": "query" + }, + { + "type": "string", + "description": "Username 按用户名模糊查询(可选,支持包含匹配)。", + "name": "username", + "in": "query" } ], "responses": { @@ -878,6 +1236,45 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/admin/users/{userID}": { + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "移除租户成员(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "UserID", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/requests.Pager" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/users/{userID}/join": { "post": { "consumes": [ @@ -1239,6 +1636,86 @@ const docTemplate = `{ } } }, + "/t/{tenantCode}/v1/join/invite": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantJoin" + ], + "summary": "通过邀请码加入租户", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.JoinByInviteForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantUser" + } + } + } + } + }, + "/t/{tenantCode}/v1/join/request": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantJoin" + ], + "summary": "提交加入租户申请", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.JoinRequestCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + } + }, "/t/{tenantCode}/v1/me": { "get": { "consumes": [ @@ -1629,6 +2106,32 @@ const docTemplate = `{ "RoleSuperAdmin" ] }, + "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": [ @@ -1715,6 +2218,45 @@ const docTemplate = `{ } } }, + "dto.AdminTenantInviteCreateForm": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码(可选):为空时由后端生成;建议只包含数字/字母,便于人工输入。", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt 过期时间(可选):为空表示不过期;到期后不可再使用。", + "type": "string" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数(可选):0 表示不限次数;大于 0 时用尽后自动失效。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注(可选):用于审计记录生成目的/投放渠道等。", + "type": "string" + } + } + }, + "dto.AdminTenantInviteDisableForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 禁用原因(可选):用于审计与追溯。", + "type": "string" + } + } + }, + "dto.AdminTenantJoinRequestDecideForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 审核说明(可选):用于审计记录通过/拒绝原因。", + "type": "string" + } + } + }, "dto.AdminTenantUserItem": { "type": "object", "properties": { @@ -1965,6 +2507,24 @@ const docTemplate = `{ } } }, + "dto.JoinByInviteForm": { + "type": "object", + "properties": { + "invite_code": { + "description": "InviteCode 邀请码:由租户管理员生成;用户提交后加入对应租户。", + "type": "string" + } + } + }, + "dto.JoinRequestCreateForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 申请原因(可选):用于向租户管理员说明申请加入的目的。", + "type": "string" + } + } + }, "dto.LoginForm": { "type": "object", "properties": { @@ -2769,6 +3329,116 @@ const docTemplate = `{ } } }, + "models.TenantInvite": { + "type": "object", + "properties": { + "code": { + "description": "邀请码:用户加入租户时提交;同一租户内唯一", + "type": "string" + }, + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "disabled_at": { + "description": "禁用时间:租户管理员禁用该邀请的时间(UTC)", + "type": "string" + }, + "disabled_operator_user_id": { + "description": "禁用操作人用户ID:租户管理员(审计用)", + "type": "integer" + }, + "expires_at": { + "description": "过期时间:到期后不可再使用(UTC);为空表示不过期", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "max_uses": { + "description": "最大可使用次数:0 表示不限制;\u003e0 时 used_count 达到该值后视为失效", + "type": "integer" + }, + "remark": { + "description": "备注:生成/禁用原因等(审计用)", + "type": "string" + }, + "status": { + "description": "邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantInviteStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "used_count": { + "description": "已使用次数:每次成功加入时 +1;需事务保证并发下不超发", + "type": "integer" + }, + "user_id": { + "description": "创建人用户ID:生成邀请码的租户管理员(审计用)", + "type": "integer" + } + } + }, + "models.TenantJoinRequest": { + "type": "object", + "properties": { + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "decided_at": { + "description": "处理时间:审核通过/拒绝时记录(UTC)", + "type": "string" + }, + "decided_operator_user_id": { + "description": "处理人用户ID:租户管理员(审计用)", + "type": "integer" + }, + "decided_reason": { + "description": "处理说明:管理员通过/拒绝的原因(可选,审计用)", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "reason": { + "description": "申请原因:用户填写的加入说明(可选)", + "type": "string" + }, + "status": { + "description": "申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantJoinRequestStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "user_id": { + "description": "申请人用户ID:发起加入申请的用户", + "type": "integer" + } + } + }, "models.TenantLedger": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index a29cf50..9e12eff 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -597,6 +597,342 @@ } } }, + "/t/{tenantCode}/v1/admin/invites": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "邀请码列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Code 按邀请码模糊过滤(可选):支持部分匹配(like)。", + "name": "code", + "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": [ + "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": { + "$ref": "#/definitions/models.TenantInvite" + } + } + } + ] + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "创建邀请码(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantInviteCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantInvite" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/invites/{inviteID}/disable": { + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "禁用邀请码(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "InviteID", + "name": "inviteID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantInviteDisableForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantInvite" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/join-requests": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "加入申请列表(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "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" + }, + { + "type": "integer", + "description": "UserID 按申请人用户ID过滤(可选)。", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + ] + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/join-requests/{requestID}/approve": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "通过加入申请(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "RequestID", + "name": "requestID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantJoinRequestDecideForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + } + }, + "/t/{tenantCode}/v1/admin/join-requests/{requestID}/reject": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "拒绝加入申请(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "RequestID", + "name": "requestID", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.AdminTenantJoinRequestDecideForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/orders": { "get": { "consumes": [ @@ -843,11 +1179,33 @@ "name": "role", "in": "query" }, + { + "enum": [ + "pending_verify", + "verified", + "banned" + ], + "type": "string", + "x-enum-varnames": [ + "UserStatusPendingVerify", + "UserStatusVerified", + "UserStatusBanned" + ], + "description": "Status 按成员状态过滤(可选):pending_verify/verified/banned。", + "name": "status", + "in": "query" + }, { "type": "integer", "description": "UserID 按用户ID过滤(可选)。", "name": "user_id", "in": "query" + }, + { + "type": "string", + "description": "Username 按用户名模糊查询(可选,支持包含匹配)。", + "name": "username", + "in": "query" } ], "responses": { @@ -872,6 +1230,45 @@ } } }, + "/t/{tenantCode}/v1/admin/users/{userID}": { + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tenant" + ], + "summary": "移除租户成员(租户管理)", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "UserID", + "name": "userID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/requests.Pager" + } + } + } + } + }, "/t/{tenantCode}/v1/admin/users/{userID}/join": { "post": { "consumes": [ @@ -1233,6 +1630,86 @@ } } }, + "/t/{tenantCode}/v1/join/invite": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantJoin" + ], + "summary": "通过邀请码加入租户", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.JoinByInviteForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantUser" + } + } + } + } + }, + "/t/{tenantCode}/v1/join/request": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "TenantJoin" + ], + "summary": "提交加入租户申请", + "parameters": [ + { + "type": "string", + "description": "Tenant Code", + "name": "tenantCode", + "in": "path", + "required": true + }, + { + "description": "Form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.JoinRequestCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.TenantJoinRequest" + } + } + } + } + }, "/t/{tenantCode}/v1/me": { "get": { "consumes": [ @@ -1623,6 +2100,32 @@ "RoleSuperAdmin" ] }, + "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": [ @@ -1709,6 +2212,45 @@ } } }, + "dto.AdminTenantInviteCreateForm": { + "type": "object", + "properties": { + "code": { + "description": "Code 邀请码(可选):为空时由后端生成;建议只包含数字/字母,便于人工输入。", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt 过期时间(可选):为空表示不过期;到期后不可再使用。", + "type": "string" + }, + "max_uses": { + "description": "MaxUses 最大可使用次数(可选):0 表示不限次数;大于 0 时用尽后自动失效。", + "type": "integer" + }, + "remark": { + "description": "Remark 备注(可选):用于审计记录生成目的/投放渠道等。", + "type": "string" + } + } + }, + "dto.AdminTenantInviteDisableForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 禁用原因(可选):用于审计与追溯。", + "type": "string" + } + } + }, + "dto.AdminTenantJoinRequestDecideForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 审核说明(可选):用于审计记录通过/拒绝原因。", + "type": "string" + } + } + }, "dto.AdminTenantUserItem": { "type": "object", "properties": { @@ -1959,6 +2501,24 @@ } } }, + "dto.JoinByInviteForm": { + "type": "object", + "properties": { + "invite_code": { + "description": "InviteCode 邀请码:由租户管理员生成;用户提交后加入对应租户。", + "type": "string" + } + } + }, + "dto.JoinRequestCreateForm": { + "type": "object", + "properties": { + "reason": { + "description": "Reason 申请原因(可选):用于向租户管理员说明申请加入的目的。", + "type": "string" + } + } + }, "dto.LoginForm": { "type": "object", "properties": { @@ -2763,6 +3323,116 @@ } } }, + "models.TenantInvite": { + "type": "object", + "properties": { + "code": { + "description": "邀请码:用户加入租户时提交;同一租户内唯一", + "type": "string" + }, + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "disabled_at": { + "description": "禁用时间:租户管理员禁用该邀请的时间(UTC)", + "type": "string" + }, + "disabled_operator_user_id": { + "description": "禁用操作人用户ID:租户管理员(审计用)", + "type": "integer" + }, + "expires_at": { + "description": "过期时间:到期后不可再使用(UTC);为空表示不过期", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "max_uses": { + "description": "最大可使用次数:0 表示不限制;\u003e0 时 used_count 达到该值后视为失效", + "type": "integer" + }, + "remark": { + "description": "备注:生成/禁用原因等(审计用)", + "type": "string" + }, + "status": { + "description": "邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantInviteStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "used_count": { + "description": "已使用次数:每次成功加入时 +1;需事务保证并发下不超发", + "type": "integer" + }, + "user_id": { + "description": "创建人用户ID:生成邀请码的租户管理员(审计用)", + "type": "integer" + } + } + }, + "models.TenantJoinRequest": { + "type": "object", + "properties": { + "created_at": { + "description": "创建时间:默认 now()", + "type": "string" + }, + "decided_at": { + "description": "处理时间:审核通过/拒绝时记录(UTC)", + "type": "string" + }, + "decided_operator_user_id": { + "description": "处理人用户ID:租户管理员(审计用)", + "type": "integer" + }, + "decided_reason": { + "description": "处理说明:管理员通过/拒绝的原因(可选,审计用)", + "type": "string" + }, + "id": { + "description": "主键ID:自增", + "type": "integer" + }, + "reason": { + "description": "申请原因:用户填写的加入说明(可选)", + "type": "string" + }, + "status": { + "description": "申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id", + "allOf": [ + { + "$ref": "#/definitions/consts.TenantJoinRequestStatus" + } + ] + }, + "tenant_id": { + "description": "租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id", + "type": "integer" + }, + "updated_at": { + "description": "更新时间:默认 now()", + "type": "string" + }, + "user_id": { + "description": "申请人用户ID:发起加入申请的用户", + "type": "integer" + } + } + }, "models.TenantLedger": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 171d38c..8248d78 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -116,6 +116,26 @@ definitions: x-enum-varnames: - RoleUser - RoleSuperAdmin + 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: - credit_topup @@ -185,6 +205,33 @@ definitions: 退款原因:建议必填(由业务侧校验);用于审计与追责。 type: string type: object + dto.AdminTenantInviteCreateForm: + properties: + code: + description: Code 邀请码(可选):为空时由后端生成;建议只包含数字/字母,便于人工输入。 + type: string + expires_at: + description: ExpiresAt 过期时间(可选):为空表示不过期;到期后不可再使用。 + type: string + max_uses: + description: MaxUses 最大可使用次数(可选):0 表示不限次数;大于 0 时用尽后自动失效。 + type: integer + remark: + description: Remark 备注(可选):用于审计记录生成目的/投放渠道等。 + type: string + type: object + dto.AdminTenantInviteDisableForm: + properties: + reason: + description: Reason 禁用原因(可选):用于审计与追溯。 + type: string + type: object + dto.AdminTenantJoinRequestDecideForm: + properties: + reason: + description: Reason 审核说明(可选):用于审计记录通过/拒绝原因。 + type: string + type: object dto.AdminTenantUserItem: properties: tenant_user: @@ -345,6 +392,18 @@ definitions: - $ref: '#/definitions/consts.ContentVisibility' description: Visibility updates the visibility when provided. type: object + dto.JoinByInviteForm: + properties: + invite_code: + description: InviteCode 邀请码:由租户管理员生成;用户提交后加入对应租户。 + type: string + type: object + dto.JoinRequestCreateForm: + properties: + reason: + description: Reason 申请原因(可选):用于向租户管理员说明申请加入的目的。 + type: string + type: object dto.LoginForm: properties: password: @@ -875,6 +934,83 @@ definitions: uuid: type: string type: object + models.TenantInvite: + properties: + code: + description: 邀请码:用户加入租户时提交;同一租户内唯一 + type: string + created_at: + description: 创建时间:默认 now() + type: string + disabled_at: + description: 禁用时间:租户管理员禁用该邀请的时间(UTC) + type: string + disabled_operator_user_id: + description: 禁用操作人用户ID:租户管理员(审计用) + type: integer + expires_at: + description: 过期时间:到期后不可再使用(UTC);为空表示不过期 + type: string + id: + description: 主键ID:自增 + type: integer + max_uses: + description: 最大可使用次数:0 表示不限制;>0 时 used_count 达到该值后视为失效 + type: integer + remark: + description: 备注:生成/禁用原因等(审计用) + type: string + status: + allOf: + - $ref: '#/definitions/consts.TenantInviteStatus' + description: 邀请状态:active/disabled/expired;expired 也可由 expires_at 推导,业务侧需保持一致 + tenant_id: + description: 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + type: integer + updated_at: + description: 更新时间:默认 now() + type: string + used_count: + description: 已使用次数:每次成功加入时 +1;需事务保证并发下不超发 + type: integer + user_id: + description: 创建人用户ID:生成邀请码的租户管理员(审计用) + type: integer + type: object + models.TenantJoinRequest: + properties: + created_at: + description: 创建时间:默认 now() + type: string + decided_at: + description: 处理时间:审核通过/拒绝时记录(UTC) + type: string + decided_operator_user_id: + description: 处理人用户ID:租户管理员(审计用) + type: integer + decided_reason: + description: 处理说明:管理员通过/拒绝的原因(可选,审计用) + type: string + id: + description: 主键ID:自增 + type: integer + reason: + description: 申请原因:用户填写的加入说明(可选) + type: string + status: + allOf: + - $ref: '#/definitions/consts.TenantJoinRequestStatus' + description: 申请状态:pending/approved/rejected;状态变更需记录 decided_at 与 decided_operator_user_id + tenant_id: + description: 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + type: integer + updated_at: + description: 更新时间:默认 now() + type: string + user_id: + description: 申请人用户ID:发起加入申请的用户 + type: integer + type: object models.TenantLedger: properties: amount: @@ -1400,6 +1536,227 @@ paths: summary: 设置内容价格与折扣 tags: - Tenant + /t/{tenantCode}/v1/admin/invites: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Code 按邀请码模糊过滤(可选):支持部分匹配(like)。 + in: query + name: code + 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 按状态过滤(可选):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: + $ref: '#/definitions/models.TenantInvite' + type: object + summary: 邀请码列表(租户管理) + tags: + - Tenant + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminTenantInviteCreateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.TenantInvite' + summary: 创建邀请码(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/invites/{inviteID}/disable: + patch: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: InviteID + format: int64 + in: path + name: inviteID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminTenantInviteDisableForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.TenantInvite' + summary: 禁用邀请码(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/join-requests: + get: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + 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 + - description: UserID 按申请人用户ID过滤(可选)。 + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + $ref: '#/definitions/models.TenantJoinRequest' + type: object + summary: 加入申请列表(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/join-requests/{requestID}/approve: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: RequestID + format: int64 + in: path + name: requestID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminTenantJoinRequestDecideForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.TenantJoinRequest' + summary: 通过加入申请(租户管理) + tags: + - Tenant + /t/{tenantCode}/v1/admin/join-requests/{requestID}/reject: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: RequestID + format: int64 + in: path + name: requestID + required: true + type: integer + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.AdminTenantJoinRequestDecideForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.TenantJoinRequest' + summary: 拒绝加入申请(租户管理) + tags: + - Tenant /t/{tenantCode}/v1/admin/orders: get: consumes: @@ -1564,10 +1921,26 @@ paths: x-enum-varnames: - TenantUserRoleMember - TenantUserRoleTenantAdmin + - description: Status 按成员状态过滤(可选):pending_verify/verified/banned。 + enum: + - pending_verify + - verified + - banned + in: query + name: status + type: string + x-enum-varnames: + - UserStatusPendingVerify + - UserStatusVerified + - UserStatusBanned - description: UserID 按用户ID过滤(可选)。 in: query name: user_id type: integer + - description: Username 按用户名模糊查询(可选,支持包含匹配)。 + in: query + name: username + type: string produces: - application/json responses: @@ -1583,6 +1956,32 @@ paths: summary: 成员列表(租户管理) tags: - Tenant + /t/{tenantCode}/v1/admin/users/{userID}: + delete: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: UserID + format: int64 + in: path + name: userID + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/requests.Pager' + summary: 移除租户成员(租户管理) + tags: + - Tenant /t/{tenantCode}/v1/admin/users/{userID}/join: post: consumes: @@ -1821,6 +2220,58 @@ paths: summary: 购买内容(余额支付) tags: - Tenant + /t/{tenantCode}/v1/join/invite: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.JoinByInviteForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.TenantUser' + summary: 通过邀请码加入租户 + tags: + - TenantJoin + /t/{tenantCode}/v1/join/request: + post: + consumes: + - application/json + parameters: + - description: Tenant Code + in: path + name: tenantCode + required: true + type: string + - description: Form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.JoinRequestCreateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.TenantJoinRequest' + summary: 提交加入租户申请 + tags: + - TenantJoin /t/{tenantCode}/v1/me: get: consumes: diff --git a/backend/pkg/consts/tenant_join.gen.go b/backend/pkg/consts/tenant_join.gen.go new file mode 100644 index 0000000..4fadee3 --- /dev/null +++ b/backend/pkg/consts/tenant_join.gen.go @@ -0,0 +1,344 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: - +// Revision: - +// Build Date: - +// Built By: - + +package consts + +import ( + "database/sql/driver" + "errors" + "fmt" + "strings" +) + +const ( + // TenantInviteStatusActive is a TenantInviteStatus of type active. + TenantInviteStatusActive TenantInviteStatus = "active" + // TenantInviteStatusDisabled is a TenantInviteStatus of type disabled. + TenantInviteStatusDisabled TenantInviteStatus = "disabled" + // TenantInviteStatusExpired is a TenantInviteStatus of type expired. + TenantInviteStatusExpired TenantInviteStatus = "expired" +) + +var ErrInvalidTenantInviteStatus = fmt.Errorf("not a valid TenantInviteStatus, try [%s]", strings.Join(_TenantInviteStatusNames, ", ")) + +var _TenantInviteStatusNames = []string{ + string(TenantInviteStatusActive), + string(TenantInviteStatusDisabled), + string(TenantInviteStatusExpired), +} + +// TenantInviteStatusNames returns a list of possible string values of TenantInviteStatus. +func TenantInviteStatusNames() []string { + tmp := make([]string, len(_TenantInviteStatusNames)) + copy(tmp, _TenantInviteStatusNames) + return tmp +} + +// TenantInviteStatusValues returns a list of the values for TenantInviteStatus +func TenantInviteStatusValues() []TenantInviteStatus { + return []TenantInviteStatus{ + TenantInviteStatusActive, + TenantInviteStatusDisabled, + TenantInviteStatusExpired, + } +} + +// String implements the Stringer interface. +func (x TenantInviteStatus) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x TenantInviteStatus) IsValid() bool { + _, err := ParseTenantInviteStatus(string(x)) + return err == nil +} + +var _TenantInviteStatusValue = map[string]TenantInviteStatus{ + "active": TenantInviteStatusActive, + "disabled": TenantInviteStatusDisabled, + "expired": TenantInviteStatusExpired, +} + +// ParseTenantInviteStatus attempts to convert a string to a TenantInviteStatus. +func ParseTenantInviteStatus(name string) (TenantInviteStatus, error) { + if x, ok := _TenantInviteStatusValue[name]; ok { + return x, nil + } + return TenantInviteStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidTenantInviteStatus) +} + +var errTenantInviteStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *TenantInviteStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = TenantInviteStatus("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseTenantInviteStatus(v) + case []byte: + *x, err = ParseTenantInviteStatus(string(v)) + case TenantInviteStatus: + *x = v + case *TenantInviteStatus: + if v == nil { + return errTenantInviteStatusNilPtr + } + *x = *v + case *string: + if v == nil { + return errTenantInviteStatusNilPtr + } + *x, err = ParseTenantInviteStatus(*v) + default: + return errors.New("invalid type for TenantInviteStatus") + } + + return +} + +// Value implements the driver Valuer interface. +func (x TenantInviteStatus) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *TenantInviteStatus) Set(val string) error { + v, err := ParseTenantInviteStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *TenantInviteStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *TenantInviteStatus) Type() string { + return "TenantInviteStatus" +} + +type NullTenantInviteStatus struct { + TenantInviteStatus TenantInviteStatus + Valid bool +} + +func NewNullTenantInviteStatus(val interface{}) (x NullTenantInviteStatus) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullTenantInviteStatus) Scan(value interface{}) (err error) { + if value == nil { + x.TenantInviteStatus, x.Valid = TenantInviteStatus(""), false + return + } + + err = x.TenantInviteStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullTenantInviteStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.TenantInviteStatus), nil +} + +type NullTenantInviteStatusStr struct { + NullTenantInviteStatus +} + +func NewNullTenantInviteStatusStr(val interface{}) (x NullTenantInviteStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullTenantInviteStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.TenantInviteStatus.String(), nil +} + +const ( + // TenantJoinRequestStatusPending is a TenantJoinRequestStatus of type pending. + TenantJoinRequestStatusPending TenantJoinRequestStatus = "pending" + // TenantJoinRequestStatusApproved is a TenantJoinRequestStatus of type approved. + TenantJoinRequestStatusApproved TenantJoinRequestStatus = "approved" + // TenantJoinRequestStatusRejected is a TenantJoinRequestStatus of type rejected. + TenantJoinRequestStatusRejected TenantJoinRequestStatus = "rejected" +) + +var ErrInvalidTenantJoinRequestStatus = fmt.Errorf("not a valid TenantJoinRequestStatus, try [%s]", strings.Join(_TenantJoinRequestStatusNames, ", ")) + +var _TenantJoinRequestStatusNames = []string{ + string(TenantJoinRequestStatusPending), + string(TenantJoinRequestStatusApproved), + string(TenantJoinRequestStatusRejected), +} + +// TenantJoinRequestStatusNames returns a list of possible string values of TenantJoinRequestStatus. +func TenantJoinRequestStatusNames() []string { + tmp := make([]string, len(_TenantJoinRequestStatusNames)) + copy(tmp, _TenantJoinRequestStatusNames) + return tmp +} + +// TenantJoinRequestStatusValues returns a list of the values for TenantJoinRequestStatus +func TenantJoinRequestStatusValues() []TenantJoinRequestStatus { + return []TenantJoinRequestStatus{ + TenantJoinRequestStatusPending, + TenantJoinRequestStatusApproved, + TenantJoinRequestStatusRejected, + } +} + +// String implements the Stringer interface. +func (x TenantJoinRequestStatus) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x TenantJoinRequestStatus) IsValid() bool { + _, err := ParseTenantJoinRequestStatus(string(x)) + return err == nil +} + +var _TenantJoinRequestStatusValue = map[string]TenantJoinRequestStatus{ + "pending": TenantJoinRequestStatusPending, + "approved": TenantJoinRequestStatusApproved, + "rejected": TenantJoinRequestStatusRejected, +} + +// ParseTenantJoinRequestStatus attempts to convert a string to a TenantJoinRequestStatus. +func ParseTenantJoinRequestStatus(name string) (TenantJoinRequestStatus, error) { + if x, ok := _TenantJoinRequestStatusValue[name]; ok { + return x, nil + } + return TenantJoinRequestStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidTenantJoinRequestStatus) +} + +var errTenantJoinRequestStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *TenantJoinRequestStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = TenantJoinRequestStatus("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseTenantJoinRequestStatus(v) + case []byte: + *x, err = ParseTenantJoinRequestStatus(string(v)) + case TenantJoinRequestStatus: + *x = v + case *TenantJoinRequestStatus: + if v == nil { + return errTenantJoinRequestStatusNilPtr + } + *x = *v + case *string: + if v == nil { + return errTenantJoinRequestStatusNilPtr + } + *x, err = ParseTenantJoinRequestStatus(*v) + default: + return errors.New("invalid type for TenantJoinRequestStatus") + } + + return +} + +// Value implements the driver Valuer interface. +func (x TenantJoinRequestStatus) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *TenantJoinRequestStatus) Set(val string) error { + v, err := ParseTenantJoinRequestStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *TenantJoinRequestStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *TenantJoinRequestStatus) Type() string { + return "TenantJoinRequestStatus" +} + +type NullTenantJoinRequestStatus struct { + TenantJoinRequestStatus TenantJoinRequestStatus + Valid bool +} + +func NewNullTenantJoinRequestStatus(val interface{}) (x NullTenantJoinRequestStatus) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullTenantJoinRequestStatus) Scan(value interface{}) (err error) { + if value == nil { + x.TenantJoinRequestStatus, x.Valid = TenantJoinRequestStatus(""), false + return + } + + err = x.TenantJoinRequestStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullTenantJoinRequestStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.TenantJoinRequestStatus), nil +} + +type NullTenantJoinRequestStatusStr struct { + NullTenantJoinRequestStatus +} + +func NewNullTenantJoinRequestStatusStr(val interface{}) (x NullTenantJoinRequestStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullTenantJoinRequestStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.TenantJoinRequestStatus.String(), nil +} diff --git a/backend/pkg/consts/tenant_join.go b/backend/pkg/consts/tenant_join.go new file mode 100644 index 0000000..7f23ede --- /dev/null +++ b/backend/pkg/consts/tenant_join.go @@ -0,0 +1,59 @@ +package consts + +import "quyun/v2/app/requests" + +// swagger:enum TenantInviteStatus +// ENUM( active, disabled, expired ) +type TenantInviteStatus string + +// Description 返回枚举值的中文含义,用于前端展示与审计日志输出。 +func (t TenantInviteStatus) Description() string { + switch t { + case TenantInviteStatusActive: + return "可用" + case TenantInviteStatusDisabled: + return "已禁用" + case TenantInviteStatusExpired: + return "已过期" + default: + return "未知状态" + } +} + +// TenantInviteStatusItems 返回前端下拉选项(Key=枚举字符串,Value=中文描述)。 +func TenantInviteStatusItems() []requests.KV { + values := TenantInviteStatusValues() + items := make([]requests.KV, 0, len(values)) + for _, v := range values { + items = append(items, requests.NewKV(string(v), v.Description())) + } + return items +} + +// swagger:enum TenantJoinRequestStatus +// ENUM( pending, approved, rejected ) +type TenantJoinRequestStatus string + +// Description 返回枚举值的中文含义,用于前端展示与审计日志输出。 +func (t TenantJoinRequestStatus) Description() string { + switch t { + case TenantJoinRequestStatusPending: + return "待审核" + case TenantJoinRequestStatusApproved: + return "已通过" + case TenantJoinRequestStatusRejected: + return "已拒绝" + default: + return "未知状态" + } +} + +// TenantJoinRequestStatusItems 返回前端下拉选项(Key=枚举字符串,Value=中文描述)。 +func TenantJoinRequestStatusItems() []requests.KV { + values := TenantJoinRequestStatusValues() + items := make([]requests.KV, 0, len(values)) + for _, v := range values { + items = append(items, requests.NewKV(string(v), v.Description())) + } + return items +} diff --git a/backend/tests/tenant.http b/backend/tests/tenant.http index b8a6b11..5b4805c 100644 --- a/backend/tests/tenant.http +++ b/backend/tests/tenant.http @@ -6,6 +6,24 @@ # - Get a token via `tests/super.http` (`/super/v1/auth/token`) then paste it below. @token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoyLCJpc3MiOiJ2MiIsImV4cCI6MTc2NjYzMzAyMSwibmJmIjoxNzY2MDI4MjExfQ.RjLVil6EnbPi4LMPyVBzR2vFaeXelypk5fKInsAzqc8 +### Tenant Join - Join by invite (JWT required, membership NOT required) +POST {{ host }}/t/{{ tenantCode }}/v1/join/invite +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "invite_code": "paste_invite_code_here" +} + +### Tenant Join - Create join request (JWT required, membership NOT required) +POST {{ host }}/t/{{ tenantCode }}/v1/join/request +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "reason": "申请加入租户用于联调" +} + ### Tenant - Me (resolved tenant/user/tenant_user) GET {{ host }}/t/{{ tenantCode }}/v1/me @@ -171,3 +189,53 @@ Authorization: Bearer {{ token }} GET {{ host }}/t/{{ tenantCode }}/v1/admin/users?page=1&limit=20 Content-Type: application/json Authorization: Bearer {{ token }} + +### Tenant Admin - Create invite +POST {{ host }}/t/{{ tenantCode }}/v1/admin/invites +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "code": "", + "max_uses": 1, + "remark": "联调用邀请码" +} + +### Tenant Admin - Invite list +GET {{ host }}/t/{{ tenantCode }}/v1/admin/invites?page=1&limit=20 +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant Admin - Disable invite +@inviteID = 1 +PATCH {{ host }}/t/{{ tenantCode }}/v1/admin/invites/{{ inviteID }}/disable +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "reason": "联调禁用" +} + +### Tenant Admin - Join requests list +GET {{ host }}/t/{{ tenantCode }}/v1/admin/join-requests?page=1&limit=20&status=pending +Content-Type: application/json +Authorization: Bearer {{ token }} + +### Tenant Admin - Approve join request +@requestID = 1 +POST {{ host }}/t/{{ tenantCode }}/v1/admin/join-requests/{{ requestID }}/approve +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "reason": "联调通过" +} + +### Tenant Admin - Reject join request +POST {{ host }}/t/{{ tenantCode }}/v1/admin/join-requests/{{ requestID }}/reject +Content-Type: application/json +Authorization: Bearer {{ token }} + +{ + "reason": "联调拒绝" +}