feat: portal tenant apply flow
This commit is contained in:
33
backend/app/http/web/dto/tenant_apply.go
Normal file
33
backend/app/http/web/dto/tenant_apply.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -33,11 +33,13 @@ func Provide(opts ...opt.Option) error {
|
|||||||
auth *auth,
|
auth *auth,
|
||||||
me *me,
|
me *me,
|
||||||
middlewares *middlewares.Middlewares,
|
middlewares *middlewares.Middlewares,
|
||||||
|
tenantApply *tenantApply,
|
||||||
) (contracts.HttpRoute, error) {
|
) (contracts.HttpRoute, error) {
|
||||||
obj := &Routes{
|
obj := &Routes{
|
||||||
auth: auth,
|
auth: auth,
|
||||||
me: me,
|
me: me,
|
||||||
middlewares: middlewares,
|
middlewares: middlewares,
|
||||||
|
tenantApply: tenantApply,
|
||||||
}
|
}
|
||||||
if err := obj.Prepare(); err != nil {
|
if err := obj.Prepare(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -47,5 +49,12 @@ func Provide(opts ...opt.Option) error {
|
|||||||
}, atom.GroupRoutes); err != nil {
|
}, atom.GroupRoutes); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := container.Container.Provide(func() (*tenantApply, error) {
|
||||||
|
obj := &tenantApply{}
|
||||||
|
|
||||||
|
return obj, nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ type Routes struct {
|
|||||||
log *log.Entry `inject:"false"`
|
log *log.Entry `inject:"false"`
|
||||||
middlewares *middlewares.Middlewares
|
middlewares *middlewares.Middlewares
|
||||||
// Controller instances
|
// Controller instances
|
||||||
auth *auth
|
auth *auth
|
||||||
me *me
|
me *me
|
||||||
|
tenantApply *tenantApply
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare initializes the routes provider with logging configuration.
|
// 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(
|
router.Get("/v1/me/tenants"[len(r.Path()):], DataFunc0(
|
||||||
r.me.myTenants,
|
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")
|
r.log.Info("Successfully registered all routes")
|
||||||
}
|
}
|
||||||
|
|||||||
146
backend/app/http/web/tenant_apply.go
Normal file
146
backend/app/http/web/tenant_apply.go
Normal 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
|
||||||
|
}
|
||||||
@@ -842,6 +842,19 @@ func (t *tenant) FindByCode(ctx context.Context, code string) (*models.Tenant, e
|
|||||||
return &m, nil
|
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) {
|
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")
|
logrus.WithField("tenant_id", tenantID).WithField("user_id", userID).Info("find tenant user")
|
||||||
tbl, query := models.TenantUserQuery.QueryContext(ctx)
|
tbl, query := models.TenantUserQuery.QueryContext(ctx)
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ func (s *TenantJoinTestSuite) truncateAll(ctx context.Context) {
|
|||||||
), ShouldBeNil)
|
), 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{
|
inv := &models.TenantInvite{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
UserID: creatorUserID,
|
UserID: inviterUserID,
|
||||||
Code: code,
|
Code: code,
|
||||||
Status: status,
|
Status: status,
|
||||||
MaxUses: maxUses,
|
MaxUses: maxUses,
|
||||||
|
|||||||
@@ -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": {
|
"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": {
|
"dto.TenantCreateForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -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": {
|
"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": {
|
"dto.TenantCreateForm": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -885,6 +885,40 @@ definitions:
|
|||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
dto.TenantCreateForm:
|
||||||
properties:
|
properties:
|
||||||
admin_user_id:
|
admin_user_id:
|
||||||
@@ -4399,6 +4433,41 @@ paths:
|
|||||||
summary: 我的租户列表
|
summary: 我的租户列表
|
||||||
tags:
|
tags:
|
||||||
- Web
|
- 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:
|
securityDefinitions:
|
||||||
BasicAuth:
|
BasicAuth:
|
||||||
type: basic
|
type: basic
|
||||||
|
|||||||
@@ -101,6 +101,10 @@
|
|||||||
|
|
||||||
.p-avatar {
|
.p-avatar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { state: sessionState, isLoggedIn, username } = useSession();
|
const { state: sessionState, isLoggedIn, username, isTenantApproved } = useSession();
|
||||||
|
|
||||||
const userMenuRef = ref();
|
const userMenuRef = ref();
|
||||||
|
|
||||||
@@ -20,6 +20,24 @@ const displayName = computed(() => {
|
|||||||
return username.value || '用户';
|
return username.value || '用户';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tenantApplyAction = computed(() => {
|
||||||
|
const app = sessionState.tenantApplication;
|
||||||
|
if (!isLoggedIn.value) return null;
|
||||||
|
if (isTenantApproved.value) return null;
|
||||||
|
|
||||||
|
if (app?.hasApplication) {
|
||||||
|
if (app.status === 'pending_verify') {
|
||||||
|
return { label: '创作者审核中', to: '/tenant/apply/status', icon: 'pi pi-hourglass' };
|
||||||
|
}
|
||||||
|
if (app.status === 'banned') {
|
||||||
|
return { label: '创作者申请结果', to: '/tenant/apply/status', icon: 'pi pi-info-circle' };
|
||||||
|
}
|
||||||
|
return { label: '创作者申请', to: '/tenant/apply/status', icon: 'pi pi-info-circle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: '申请创作者', to: '/tenant/apply', icon: 'pi pi-star' };
|
||||||
|
});
|
||||||
|
|
||||||
const userMenuItems = computed(() => [
|
const userMenuItems = computed(() => [
|
||||||
{ label: '个人中心', icon: 'pi pi-user', command: () => router.push('/me') },
|
{ label: '个人中心', icon: 'pi pi-user', command: () => router.push('/me') },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
@@ -94,8 +112,12 @@ onMounted(() => {
|
|||||||
<div class="layout-topbar-menu hidden lg:block">
|
<div class="layout-topbar-menu hidden lg:block">
|
||||||
<div class="layout-topbar-menu-content">
|
<div class="layout-topbar-menu-content">
|
||||||
<template v-if="isLoggedIn">
|
<template v-if="isLoggedIn">
|
||||||
|
<router-link v-if="tenantApplyAction" :to="tenantApplyAction.to" class="layout-topbar-action layout-topbar-action-text">
|
||||||
|
<i :class="tenantApplyAction.icon"></i>
|
||||||
|
<span>{{ tenantApplyAction.label }}</span>
|
||||||
|
</router-link>
|
||||||
<button type="button" class="layout-topbar-action layout-topbar-user" @click="toggleUserMenu">
|
<button type="button" class="layout-topbar-action layout-topbar-user" @click="toggleUserMenu">
|
||||||
<Avatar shape="circle" class="bg-surface-200 dark:bg-surface-700" />
|
<Avatar shape="circle" size="large" class="bg-surface-200 dark:bg-surface-700" />
|
||||||
<span>{{ displayName }}</span>
|
<span>{{ displayName }}</span>
|
||||||
<i class="pi pi-angle-down"></i>
|
<i class="pi pi-angle-down"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ const router = createRouter({
|
|||||||
|
|
||||||
{ path: 'settings/account/close', name: 'closeAccount', component: TitlePage, meta: { title: '账号注销' } },
|
{ path: 'settings/account/close', name: 'closeAccount', component: TitlePage, meta: { title: '账号注销' } },
|
||||||
|
|
||||||
{ path: 'tenant/apply', name: 'tenantApply', component: TitlePage, meta: { title: '租户创建申请' } },
|
{ path: 'tenant/apply', name: 'tenantApply', component: () => import('@/views/tenant/TenantApply.vue'), meta: { title: '申请创作者' } },
|
||||||
{ path: 'tenant/apply/status', name: 'tenantApplyStatus', component: TitlePage, meta: { title: '租户申请进度/状态' } },
|
{ path: 'tenant/apply/status', name: 'tenantApplyStatus', component: () => import('@/views/tenant/TenantApply.vue'), meta: { title: '创作者申请状态' } },
|
||||||
{ path: 'tenant/switch', name: 'tenantSwitch', component: TitlePage, meta: { title: '租户切换' } },
|
{ path: 'tenant/switch', name: 'tenantSwitch', component: TitlePage, meta: { title: '租户切换' } },
|
||||||
|
|
||||||
{ path: 'admin', name: 'adminDashboard', component: TitlePage, meta: { title: '管理概览(仪表盘)' } },
|
{ path: 'admin', name: 'adminDashboard', component: TitlePage, meta: { title: '管理概览(仪表盘)' } },
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { getPortalAuthToken, requestJson, setPortalAuthToken } from './apiClient
|
|||||||
const state = reactive({
|
const state = reactive({
|
||||||
token: getPortalAuthToken(),
|
token: getPortalAuthToken(),
|
||||||
me: null,
|
me: null,
|
||||||
loadingMe: false
|
loadingMe: false,
|
||||||
|
tenantApplication: null,
|
||||||
|
loadingTenantApplication: false
|
||||||
});
|
});
|
||||||
|
|
||||||
let initPromise = null;
|
let initPromise = null;
|
||||||
@@ -14,14 +16,15 @@ export function useSession() {
|
|||||||
const isLoggedIn = computed(() => Boolean(state.token));
|
const isLoggedIn = computed(() => Boolean(state.token));
|
||||||
const username = computed(() => {
|
const username = computed(() => {
|
||||||
const raw = state.me?.username ?? state.me?.Username ?? '';
|
const raw = state.me?.username ?? state.me?.Username ?? '';
|
||||||
console.log("Computed username:", state.me?.username);
|
|
||||||
return String(raw || '').trim();
|
return String(raw || '').trim();
|
||||||
});
|
});
|
||||||
|
const isTenantApproved = computed(() => state.tenantApplication?.hasApplication && state.tenantApplication?.status === 'verified');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
username
|
username,
|
||||||
|
isTenantApproved
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +44,22 @@ export async function fetchMe() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantApplication() {
|
||||||
|
if (!state.token) {
|
||||||
|
state.tenantApplication = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loadingTenantApplication = true;
|
||||||
|
try {
|
||||||
|
const data = await requestJson('/v1/tenant/application', { auth: true });
|
||||||
|
state.tenantApplication = data;
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
state.loadingTenantApplication = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setToken(token) {
|
export function setToken(token) {
|
||||||
const normalized = String(token || '').trim();
|
const normalized = String(token || '').trim();
|
||||||
state.token = normalized;
|
state.token = normalized;
|
||||||
@@ -49,12 +68,15 @@ export function setToken(token) {
|
|||||||
|
|
||||||
export async function setTokenAndLoadMe(token) {
|
export async function setTokenAndLoadMe(token) {
|
||||||
setToken(token);
|
setToken(token);
|
||||||
return await fetchMe();
|
await fetchMe();
|
||||||
|
await fetchTenantApplication();
|
||||||
|
return state.me;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
setToken('');
|
setToken('');
|
||||||
state.me = null;
|
state.me = null;
|
||||||
|
state.tenantApplication = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initSession() {
|
export async function initSession() {
|
||||||
@@ -65,6 +87,7 @@ export async function initSession() {
|
|||||||
if (state.token) {
|
if (state.token) {
|
||||||
try {
|
try {
|
||||||
await fetchMe();
|
await fetchMe();
|
||||||
|
await fetchTenantApplication();
|
||||||
} catch {
|
} catch {
|
||||||
// token 可能过期或无效:清理并让 UI 回到未登录态
|
// token 可能过期或无效:清理并让 UI 回到未登录态
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
17
frontend/portal/src/service/tenantApply.js
Normal file
17
frontend/portal/src/service/tenantApply.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { requestJson } from './apiClient';
|
||||||
|
import { fetchTenantApplication } from './session';
|
||||||
|
|
||||||
|
export async function getTenantApplication() {
|
||||||
|
return await fetchTenantApplication();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyTenantApplication({ code, name }) {
|
||||||
|
const data = await requestJson('/v1/tenant/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: { code, name }
|
||||||
|
});
|
||||||
|
await fetchTenantApplication();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
122
frontend/portal/src/views/tenant/TenantApply.vue
Normal file
122
frontend/portal/src/views/tenant/TenantApply.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup>
|
||||||
|
import { applyTenantApplication, getTenantApplication } from '@/service/tenantApply';
|
||||||
|
import { useSession } from '@/service/session';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { isLoggedIn, isTenantApproved, state: sessionState } = useSession();
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const name = ref('');
|
||||||
|
const code = ref('');
|
||||||
|
|
||||||
|
const application = computed(() => sessionState.tenantApplication);
|
||||||
|
const hasApplication = computed(() => Boolean(application.value?.hasApplication));
|
||||||
|
|
||||||
|
const statusLabel = computed(() => application.value?.statusDescription || '');
|
||||||
|
const statusSeverity = computed(() => {
|
||||||
|
const s = application.value?.status;
|
||||||
|
if (s === 'verified') return 'success';
|
||||||
|
if (s === 'pending_verify') return 'warn';
|
||||||
|
if (s === 'banned') return 'danger';
|
||||||
|
return 'secondary';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await getTenantApplication();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (submitting.value) return;
|
||||||
|
if (!name.value.trim() || !code.value.trim()) {
|
||||||
|
toast.add({ severity: 'warn', summary: '请完善信息', detail: '请填写租户名称和租户 ID', life: 2500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
submitting.value = true;
|
||||||
|
await applyTenantApplication({ name: name.value.trim(), code: code.value.trim() });
|
||||||
|
toast.add({ severity: 'success', summary: '申请已提交', detail: '等待管理员审核', life: 2000 });
|
||||||
|
await router.push('/tenant/apply/status');
|
||||||
|
} catch (err) {
|
||||||
|
const payload = err?.payload;
|
||||||
|
const message = String(payload?.message || err?.message || '').trim();
|
||||||
|
toast.add({ severity: 'error', summary: '提交失败', detail: message || '请稍后重试', life: 3500 });
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
const redirect = typeof route.fullPath === 'string' ? route.fullPath : '/tenant/apply';
|
||||||
|
await router.push(`/auth/login?redirect=${encodeURIComponent(redirect)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold">申请创作者</h1>
|
||||||
|
<div class="text-muted-color mt-2">提交租户信息后,等待后台管理员审核;一个用户仅可申请一个租户创作者。</div>
|
||||||
|
</div>
|
||||||
|
<Button label="刷新" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="refresh" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider class="my-6" />
|
||||||
|
|
||||||
|
<div v-if="isTenantApproved" class="flex flex-col gap-3">
|
||||||
|
<Message severity="success">你已成为创作者,无需重复申请。</Message>
|
||||||
|
<div v-if="application?.hasApplication" class="text-sm">
|
||||||
|
<div>租户名称:{{ application.tenantName }}</div>
|
||||||
|
<div>租户 ID:{{ application.tenantCode }}</div>
|
||||||
|
<div>状态:<Tag :severity="statusSeverity" :value="statusLabel" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="hasApplication" class="flex flex-col gap-4">
|
||||||
|
<Message :severity="statusSeverity">
|
||||||
|
<span v-if="application?.status === 'pending_verify'">申请已提交,正在审核中。</span>
|
||||||
|
<span v-else-if="application?.status === 'verified'">审核已通过,你已成为创作者。</span>
|
||||||
|
<span v-else>申请状态:{{ statusLabel }}</span>
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div class="text-sm flex flex-col gap-2">
|
||||||
|
<div>租户名称:{{ application.tenantName }}</div>
|
||||||
|
<div>租户 ID:{{ application.tenantCode }}</div>
|
||||||
|
<div>状态:<Tag :severity="statusSeverity" :value="statusLabel" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="max-w-2xl flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="tenantName" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">租户名称</label>
|
||||||
|
<InputText id="tenantName" v-model="name" size="large" class="w-full text-xl py-3" placeholder="请输入租户名称" autocomplete="organization" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="tenantCode" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">租户 ID</label>
|
||||||
|
<InputText id="tenantCode" v-model="code" size="large" class="w-full text-xl py-3" placeholder="3-64 位小写字母/数字/_/-" autocapitalize="off" autocomplete="off" />
|
||||||
|
<small class="text-muted-color">将用于 URL(例如 `/t/<tenantCode>/...`),提交后不可随意变更。</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button label="提交申请" icon="pi pi-send" size="large" class="w-full" :loading="submitting" @click="submit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user