diff --git a/backend/app/http/web/dto/tenant_apply.go b/backend/app/http/web/dto/tenant_apply.go new file mode 100644 index 0000000..ab1b872 --- /dev/null +++ b/backend/app/http/web/dto/tenant_apply.go @@ -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"` +} diff --git a/backend/app/http/web/provider.gen.go b/backend/app/http/web/provider.gen.go index feae2af..e869726 100755 --- a/backend/app/http/web/provider.gen.go +++ b/backend/app/http/web/provider.gen.go @@ -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 } diff --git a/backend/app/http/web/routes.gen.go b/backend/app/http/web/routes.gen.go index 3e51012..49f8840 100644 --- a/backend/app/http/web/routes.gen.go +++ b/backend/app/http/web/routes.gen.go @@ -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") } diff --git a/backend/app/http/web/tenant_apply.go b/backend/app/http/web/tenant_apply.go new file mode 100644 index 0000000..ad6cb63 --- /dev/null +++ b/backend/app/http/web/tenant_apply.go @@ -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 +} diff --git a/backend/app/services/tenant.go b/backend/app/services/tenant.go index 9bbf652..58429d7 100644 --- a/backend/app/services/tenant.go +++ b/backend/app/services/tenant.go @@ -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) diff --git a/backend/app/services/tenant_join_test.go b/backend/app/services/tenant_join_test.go index affee0d..c12b1a3 100644 --- a/backend/app/services/tenant_join_test.go +++ b/backend/app/services/tenant_join_test.go @@ -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, diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d316d3f..ecdcfbf 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -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": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 2b42c17..e977ca5 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -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": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 1ee88fb..ae9ac97 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -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 diff --git a/frontend/portal/src/assets/layout/_topbar.scss b/frontend/portal/src/assets/layout/_topbar.scss index 1644234..d695721 100644 --- a/frontend/portal/src/assets/layout/_topbar.scss +++ b/frontend/portal/src/assets/layout/_topbar.scss @@ -101,6 +101,10 @@ .p-avatar { flex: 0 0 auto; + width: 2.5rem; + height: 2.5rem; + min-width: 2.5rem; + min-height: 2.5rem; } } } diff --git a/frontend/portal/src/layout/AppTopbar.vue b/frontend/portal/src/layout/AppTopbar.vue index b240984..ee44f01 100644 --- a/frontend/portal/src/layout/AppTopbar.vue +++ b/frontend/portal/src/layout/AppTopbar.vue @@ -11,7 +11,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout(); const toast = useToast(); const router = useRouter(); -const { state: sessionState, isLoggedIn, username } = useSession(); +const { state: sessionState, isLoggedIn, username, isTenantApproved } = useSession(); const userMenuRef = ref(); @@ -20,6 +20,24 @@ const displayName = computed(() => { 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(() => [ { label: '个人中心', icon: 'pi pi-user', command: () => router.push('/me') }, { separator: true }, @@ -94,8 +112,12 @@ onMounted(() => {