feat: portal tenant apply flow

This commit is contained in:
2025-12-25 11:12:11 +08:00
parent 81240fa0d1
commit 03117b827b
15 changed files with 691 additions and 12 deletions

View File

@@ -0,0 +1,33 @@
package dto
import (
"time"
"quyun/v2/pkg/consts"
)
// TenantApplyForm 申请创作者(创建租户申请)表单。
type TenantApplyForm struct {
// Code 租户 ID用于 URL/系统标识全局唯一tenants.code忽略大小写
Code string `json:"code,omitempty"`
// Name 租户名称(展示用)。
Name string `json:"name,omitempty"`
}
// TenantApplicationResponse 当前用户的租户申请信息(申请创作者)。
type TenantApplicationResponse struct {
// HasApplication 是否已提交过申请(或已成为创作者)。
HasApplication bool `json:"hasApplication"`
// TenantID 租户ID。
TenantID int64 `json:"tenantId,omitempty"`
// TenantCode 租户 Code。
TenantCode string `json:"tenantCode,omitempty"`
// TenantName 租户名称。
TenantName string `json:"tenantName,omitempty"`
// Status 租户状态pending_verify/verified/banned
Status consts.TenantStatus `json:"status,omitempty"`
// StatusDescription 状态描述(便于前端展示)。
StatusDescription string `json:"statusDescription,omitempty"`
// CreatedAt 申请创建时间(租户记录创建时间)。
CreatedAt time.Time `json:"createdAt,omitempty"`
}

View File

@@ -33,11 +33,13 @@ func Provide(opts ...opt.Option) error {
auth *auth,
me *me,
middlewares *middlewares.Middlewares,
tenantApply *tenantApply,
) (contracts.HttpRoute, error) {
obj := &Routes{
auth: auth,
me: me,
middlewares: middlewares,
tenantApply: tenantApply,
}
if err := obj.Prepare(); err != nil {
return nil, err
@@ -47,5 +49,12 @@ func Provide(opts ...opt.Option) error {
}, atom.GroupRoutes); err != nil {
return err
}
if err := container.Container.Provide(func() (*tenantApply, error) {
obj := &tenantApply{}
return obj, nil
}); err != nil {
return err
}
return nil
}

View File

@@ -23,8 +23,9 @@ type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
auth *auth
me *me
auth *auth
me *me
tenantApply *tenantApply
}
// Prepare initializes the routes provider with logging configuration.
@@ -81,6 +82,16 @@ func (r *Routes) Register(router fiber.Router) {
router.Get("/v1/me/tenants"[len(r.Path()):], DataFunc0(
r.me.myTenants,
))
// Register routes for controller: tenantApply
r.log.Debugf("Registering route: Get /v1/tenant/application -> tenantApply.application")
router.Get("/v1/tenant/application"[len(r.Path()):], DataFunc0(
r.tenantApply.application,
))
r.log.Debugf("Registering route: Post /v1/tenant/apply -> tenantApply.apply")
router.Post("/v1/tenant/apply"[len(r.Path()):], DataFunc1(
r.tenantApply.apply,
Body[dto.TenantApplyForm]("form"),
))
r.log.Info("Successfully registered all routes")
}

View File

@@ -0,0 +1,146 @@
package web
import (
"errors"
"regexp"
"strings"
"quyun/v2/app/errorx"
"quyun/v2/app/http/web/dto"
"quyun/v2/app/services"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
)
// @provider
type tenantApply struct{}
var reTenantCode = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,63}$`)
// Application 获取当前用户的租户申请信息(申请创作者)。
//
// @Summary 获取租户申请信息(申请创作者)
// @Tags Web
// @Accept json
// @Produce json
// @Success 200 {object} dto.TenantApplicationResponse "成功"
// @Router /v1/tenant/application [get]
func (ctl *tenantApply) application(ctx fiber.Ctx) (*dto.TenantApplicationResponse, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
m, err := services.Tenant.FindOwnedByUserID(ctx, claims.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &dto.TenantApplicationResponse{HasApplication: false}, nil
}
return nil, errorx.Wrap(err).WithMsg("查询申请信息失败,请稍后再试")
}
return &dto.TenantApplicationResponse{
HasApplication: true,
TenantID: m.ID,
TenantCode: m.Code,
TenantName: m.Name,
Status: m.Status,
StatusDescription: m.Status.Description(),
CreatedAt: m.CreatedAt,
}, nil
}
// Apply 申请创作者(创建租户申请)。
//
// @Summary 提交租户申请(申请创作者)
// @Tags Web
// @Accept json
// @Produce json
// @Param form body dto.TenantApplyForm true "form"
// @Success 200 {object} dto.TenantApplicationResponse "成功"
// @Router /v1/tenant/apply [post]
// @Bind form body
func (ctl *tenantApply) apply(ctx fiber.Ctx, form *dto.TenantApplyForm) (*dto.TenantApplicationResponse, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
code := strings.ToLower(strings.TrimSpace(form.Code))
if code == "" {
return nil, errorx.ErrMissingParameter.WithMsg("请填写租户 ID")
}
if !reTenantCode.MatchString(code) {
return nil, errorx.ErrInvalidParameter.WithMsg("租户 ID 需为 3-64 位小写字母/数字/下划线/短横线,且以字母或数字开头")
}
name := strings.TrimSpace(form.Name)
if name == "" {
return nil, errorx.ErrMissingParameter.WithMsg("请填写租户名称")
}
// 一个用户仅可申请为一个租户创作者:若已存在 owned tenant直接返回当前申请信息。
existing, err := services.Tenant.FindOwnedByUserID(ctx, claims.UserID)
if err == nil && existing != nil && existing.ID > 0 {
return &dto.TenantApplicationResponse{
HasApplication: true,
TenantID: existing.ID,
TenantCode: existing.Code,
TenantName: existing.Name,
Status: existing.Status,
StatusDescription: existing.Status.Description(),
CreatedAt: existing.CreatedAt,
}, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.Wrap(err).WithMsg("申请校验失败,请稍后再试")
}
tenant := &models.Tenant{
UserID: claims.UserID,
Code: code,
UUID: types.NewUUIDv4(),
Name: name,
Status: consts.TenantStatusPendingVerify,
Config: types.JSON([]byte(`{}`)),
}
db := models.Q.Tenant.WithContext(ctx).UnderlyingDB()
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(tenant).Error; err != nil {
return err
}
tenantUser := &models.TenantUser{
TenantID: tenant.ID,
UserID: claims.UserID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin}),
Status: consts.UserStatusVerified,
}
if err := tx.Create(tenantUser).Error; err != nil {
return err
}
return tx.First(tenant, tenant.ID).Error
})
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, errorx.ErrRecordDuplicated.WithMsg("租户 ID 已被占用,请换一个试试")
}
return nil, errorx.Wrap(err).WithMsg("提交申请失败,请稍后再试")
}
return &dto.TenantApplicationResponse{
HasApplication: true,
TenantID: tenant.ID,
TenantCode: tenant.Code,
TenantName: tenant.Name,
Status: tenant.Status,
StatusDescription: tenant.Status.Description(),
CreatedAt: tenant.CreatedAt,
}, nil
}

View File

@@ -842,6 +842,19 @@ func (t *tenant) FindByCode(ctx context.Context, code string) (*models.Tenant, e
return &m, nil
}
// FindOwnedByUserID 查询用户创建/拥有的租户(一个用户仅允许拥有一个租户)。
func (t *tenant) FindOwnedByUserID(ctx context.Context, userID int64) (*models.Tenant, error) {
if userID <= 0 {
return nil, errors.New("user_id must be > 0")
}
tbl, query := models.TenantQuery.QueryContext(ctx)
m, err := query.Where(tbl.UserID.Eq(userID)).Order(tbl.ID.Desc()).First()
if err != nil {
return nil, errors.Wrapf(err, "find owned tenant failed, user_id: %d", userID)
}
return m, nil
}
func (t *tenant) FindTenantUser(ctx context.Context, tenantID, userID int64) (*models.TenantUser, error) {
logrus.WithField("tenant_id", tenantID).WithField("user_id", userID).Info("find tenant user")
tbl, query := models.TenantUserQuery.QueryContext(ctx)

View File

@@ -52,10 +52,10 @@ func (s *TenantJoinTestSuite) truncateAll(ctx context.Context) {
), ShouldBeNil)
}
func (s *TenantJoinTestSuite) seedInvite(ctx context.Context, tenantID, creatorUserID int64, code string, status consts.TenantInviteStatus, maxUses, usedCount int32, expiresAt time.Time) *models.TenantInvite {
func (s *TenantJoinTestSuite) seedInvite(ctx context.Context, tenantID, inviterUserID int64, code string, status consts.TenantInviteStatus, maxUses, usedCount int32, expiresAt time.Time) *models.TenantInvite {
inv := &models.TenantInvite{
TenantID: tenantID,
UserID: creatorUserID,
UserID: inviterUserID,
Code: code,
Status: status,
MaxUses: maxUses,

View File

@@ -4085,6 +4085,61 @@ const docTemplate = `{
}
}
}
},
"/v1/tenant/application": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "获取租户申请信息(申请创作者)",
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.TenantApplicationResponse"
}
}
}
}
},
"/v1/tenant/apply": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "提交租户申请(申请创作者)",
"parameters": [
{
"description": "form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.TenantApplyForm"
}
}
],
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.TenantApplicationResponse"
}
}
}
}
}
},
"definitions": {
@@ -5338,6 +5393,56 @@ const docTemplate = `{
}
}
},
"dto.TenantApplicationResponse": {
"type": "object",
"properties": {
"createdAt": {
"description": "CreatedAt 申请创建时间(租户记录创建时间)。",
"type": "string"
},
"hasApplication": {
"description": "HasApplication 是否已提交过申请(或已成为创作者)。",
"type": "boolean"
},
"status": {
"description": "Status 租户状态pending_verify/verified/banned。",
"allOf": [
{
"$ref": "#/definitions/consts.TenantStatus"
}
]
},
"statusDescription": {
"description": "StatusDescription 状态描述(便于前端展示)。",
"type": "string"
},
"tenantCode": {
"description": "TenantCode 租户 Code。",
"type": "string"
},
"tenantId": {
"description": "TenantID 租户ID。",
"type": "integer"
},
"tenantName": {
"description": "TenantName 租户名称。",
"type": "string"
}
}
},
"dto.TenantApplyForm": {
"type": "object",
"properties": {
"code": {
"description": "Code 租户 ID用于 URL/系统标识全局唯一tenants.code忽略大小写。",
"type": "string"
},
"name": {
"description": "Name 租户名称(展示用)。",
"type": "string"
}
}
},
"dto.TenantCreateForm": {
"type": "object",
"required": [

View File

@@ -4079,6 +4079,61 @@
}
}
}
},
"/v1/tenant/application": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "获取租户申请信息(申请创作者)",
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.TenantApplicationResponse"
}
}
}
}
},
"/v1/tenant/apply": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Web"
],
"summary": "提交租户申请(申请创作者)",
"parameters": [
{
"description": "form",
"name": "form",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.TenantApplyForm"
}
}
],
"responses": {
"200": {
"description": "成功",
"schema": {
"$ref": "#/definitions/dto.TenantApplicationResponse"
}
}
}
}
}
},
"definitions": {
@@ -5332,6 +5387,56 @@
}
}
},
"dto.TenantApplicationResponse": {
"type": "object",
"properties": {
"createdAt": {
"description": "CreatedAt 申请创建时间(租户记录创建时间)。",
"type": "string"
},
"hasApplication": {
"description": "HasApplication 是否已提交过申请(或已成为创作者)。",
"type": "boolean"
},
"status": {
"description": "Status 租户状态pending_verify/verified/banned。",
"allOf": [
{
"$ref": "#/definitions/consts.TenantStatus"
}
]
},
"statusDescription": {
"description": "StatusDescription 状态描述(便于前端展示)。",
"type": "string"
},
"tenantCode": {
"description": "TenantCode 租户 Code。",
"type": "string"
},
"tenantId": {
"description": "TenantID 租户ID。",
"type": "integer"
},
"tenantName": {
"description": "TenantName 租户名称。",
"type": "string"
}
}
},
"dto.TenantApplyForm": {
"type": "object",
"properties": {
"code": {
"description": "Code 租户 ID用于 URL/系统标识全局唯一tenants.code忽略大小写。",
"type": "string"
},
"name": {
"description": "Name 租户名称(展示用)。",
"type": "string"
}
}
},
"dto.TenantCreateForm": {
"type": "object",
"required": [

View File

@@ -885,6 +885,40 @@ definitions:
username:
type: string
type: object
dto.TenantApplicationResponse:
properties:
createdAt:
description: CreatedAt 申请创建时间(租户记录创建时间)。
type: string
hasApplication:
description: HasApplication 是否已提交过申请(或已成为创作者)。
type: boolean
status:
allOf:
- $ref: '#/definitions/consts.TenantStatus'
description: Status 租户状态pending_verify/verified/banned
statusDescription:
description: StatusDescription 状态描述(便于前端展示)。
type: string
tenantCode:
description: TenantCode 租户 Code。
type: string
tenantId:
description: TenantID 租户ID。
type: integer
tenantName:
description: TenantName 租户名称。
type: string
type: object
dto.TenantApplyForm:
properties:
code:
description: Code 租户 ID用于 URL/系统标识全局唯一tenants.code忽略大小写
type: string
name:
description: Name 租户名称(展示用)。
type: string
type: object
dto.TenantCreateForm:
properties:
admin_user_id:
@@ -4399,6 +4433,41 @@ paths:
summary: 我的租户列表
tags:
- Web
/v1/tenant/application:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: 成功
schema:
$ref: '#/definitions/dto.TenantApplicationResponse'
summary: 获取租户申请信息(申请创作者)
tags:
- Web
/v1/tenant/apply:
post:
consumes:
- application/json
parameters:
- description: form
in: body
name: form
required: true
schema:
$ref: '#/definitions/dto.TenantApplyForm'
produces:
- application/json
responses:
"200":
description: 成功
schema:
$ref: '#/definitions/dto.TenantApplicationResponse'
summary: 提交租户申请(申请创作者)
tags:
- Web
securityDefinitions:
BasicAuth:
type: basic