注册功能优化 (#10)

* fix:修复权限问题

* fix:修复项目key类型错误问题

* fix:修复rbac权限问题

* fix:修复api密钥未及时失效的问题

* improve:优化渠道调度稳定性

* improve:优化路由

* fix:修复项目预算控制缺陷

* fix:加强限流等安全措施

* feat:添加限流相关字段的前端展示及接口字段

* improve:优化注册时密码要求,只需要字母和数字即可

* feat(setting):添加注册限流设置

* improve:优化前端数字类型转换

* chore:rbac权限放行

* feat:团队功能作为可选项,允许不设置团队的情况下进行注册

* feat:团队code只能设置一次

* improve:优化组织名称限制

* chore:合并数据库版本文件

---------

Co-authored-by: chengqian <wuchengqian@kkidc.com>
Co-authored-by: qian <wuchengqian5@163.com>
This commit is contained in:
authored and GitHub committed 2026-06-23 23:07:36 +08:00
1 parent 22ee0aa112
commit 79b9224968
49 files changed
+932 -343

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ import "github.com/gogf/gf/v2/frame/g"
// TenantCreateReq 创建租户请求
type TenantCreateReq struct {
g.Meta `path:"/tenants" method:"post" mime:"json" tags:"管理后台-租户管理" summary:"创建租户"`
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入租户名称|租户名称长度为2-100位" dc:"租户名称"`
TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"`
Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"`
+10 -8
View File
@@ -8,8 +8,8 @@ type TenantRegisterReq struct {
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"`
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"`
TenantName string `json:"tenant_name" dc:"组织名称(可选,留空自动生成;汉字最多8个,字母最多16个)"`
TenantCode string `json:"tenant_code" v:"length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#组织代码长度为3-30位|组织代码格式不正确" dc:"组织代码(可选,留空自动生成)"`
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(Turnstile关闭时必填)"`
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"`
@@ -21,9 +21,10 @@ type TenantRegisterRes struct {
RefreshToken string `json:"refresh_token"`
ExpiresAt string `json:"expires_at"`
Tenant struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
} `json:"tenant"`
User struct {
ID int64 `json:"id"`
@@ -51,9 +52,10 @@ type TenantLoginRes struct {
TotpRequired bool `json:"totp_required"` // 是否需要 2FA 验证
ProvisionalToken string `json:"provisional_token,omitempty"` // 2FA 临时令牌
Tenant struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
} `json:"tenant"`
User struct {
ID int64 `json:"id"`
+3 -1
View File
@@ -11,6 +11,7 @@ type TenantOrgInfoRes struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
LogoURL string `json:"logo_url"`
Status string `json:"status"`
Level int `json:"level"`
@@ -23,7 +24,8 @@ type TenantOrgInfoRes struct {
// TenantOrgUpdateReq 更新组织信息请求
type TenantOrgUpdateReq struct {
g.Meta `path:"/organization" method:"put" mime:"json" tags:"租户控制台-组织" summary:"更新组织信息"`
Name *string `json:"name" v:"length:2,100#组织名称长度为2-100位" dc:"组织名称"`
Name *string `json:"name" dc:"组织名称(汉字最多8个,字母最多16个)"`
Code *string `json:"code" v:"length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#组织代码长度为3-30位|组织代码格式不正确" dc:"组织代码(首次设置即激活团队功能)"`
LogoURL *string `json:"logo_url" dc:"Logo URL"`
}
+26
View File
@@ -505,3 +505,29 @@ return gerror.NewCodef(gcode.New(...), "%s包含禁用词「%s」,请修改后
**正确做法**:构造错误时区分 `NewXxx`(字面文本)与 `NewXxxf`(格式化)两个系列。凡消息含 `%s`/`%v`/`%d` 等占位符且需填入变量,必须用 `f` 变体(`gerror.Newf`、`gerror.NewCodef`、`gerror.NewWrapf`、`gerror.NewSkipf`);把格式串当字面文本传入 `NewXxx` 系列,占位符不会替换,多余实参还会被拼接到消息末尾。
### 2026-06-23:Scan 查询单标量值(`*string`/`*int`)报参数类型错误
**问题描述**:`organization.go` 的 `GetOrgInfo` 查询租户等级名称时,`var levelName *string; dao.Xxx.Scan(&levelName)` 触发 WARN:`element of parameter "pointer" for function Scan should type of struct/*struct/[]struct/[]*struct`,等级名查不到(降级为空)。
**原因**:GoFrame 的 `Scan` **仅支持 struct / *struct / []struct / []*struct**,不支持标量类型(string/int/bool 等)的指针。`levelName` 声明为 `*string`,`&levelName` 是 `**string`,不在支持范围内,直接报参数类型错误。注意这和 2026-05-14 记录的「Scan 值类型 vs 指针类型」是两回事——那个讲的是 struct 查询无行时的行为差异,本次是 **Scan 根本不接受标量**。
**修复方式**:查询单个标量值改用 `Value()`,返回 `*gvar.Var`,再用 `.String()`/`.Int64()`/`.Bool()` 取值:
```go
// 错误 — Scan 不支持标量类型指针
var levelName *string
dao.Xxx.Ctx(ctx).Where(...).Fields("name").Scan(&levelName) // 报参数类型错误
// 正确 — 单标量值用 Value()
v, err := dao.Xxx.Ctx(ctx).Where(...).Fields("name").Value()
if err != nil {
g.Log().Warningf(ctx, "查询失败: %v", err)
} else {
levelName = v.String() // 无记录时返回 "",不会报错
}
```
**正确做法**:按查询意图选择 API:
- 查询单行记录 → `Scan(&structPtr)`(指针类型,无行返回 nil,见 2026-05-14 记录)
- 查询多行记录 → `Scan(&[]struct{})`
- 查询单个标量值(`COUNT`、`SUM`、单个字段)→ `Value()` 返回 `*gvar.Var`,用 `.String()/.Int64()/.Bool()` 取值,**不要用 `Scan(&scalar)` 或 `Scan(&scalarPtr)`**
+8
View File
@@ -179,6 +179,7 @@ const (
CodeGlobalRateLimitExceeded = 10091 // 全局限流
CodeForbiddenWord = 10096 // 名称包含禁用词
CodeEmailExists = 10097 // 邮箱已被使用
CodeInvalidTenantName = 10098 // 组织名称长度不合法
// Setup errors
CodeSetupCompleted = 10078
@@ -227,6 +228,9 @@ const (
CodeUpdateReplaceFailed = 10415
CodeUpdateRollbackFailed = 10416
CodeUpdateNotSupported = 10417
// Team feature gating
CodeTeamNotEnabled = 10418
)
// Common error messages
@@ -257,6 +261,7 @@ const (
MsgUsernameExists = "用户名已存在"
MsgEmailExists = "该邮箱已被使用"
MsgTenantCodeExists = "租户代码已存在"
MsgTeamNotEnabled = "团队功能未启用,请先设置组织代码"
MsgSessionExpired = "会话已过期"
MsgTokenExpired = "Token 已过期"
MsgTokenRevoked = "Token 已被撤销"
@@ -346,6 +351,9 @@ const (
// Forbidden word
MsgForbiddenWord = "名称包含禁用词,请修改后重试"
// Tenant name validation:汉字最多8个、字母最多16个(汉字按2计、其余按1计,总宽度≤16)
MsgInvalidTenantName = "组织名称长度超出限制(汉字最多8个,字母最多16个)"
// Agreement errors
MsgAgreementNotFound = "协议版本不存在"
MsgAgreementNotDraft = "仅草稿状态的协议可修改或删除"
@@ -52,7 +52,6 @@ type AudRequestLogsColumns struct {
TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt string // 异步任务达到终态的时间
AttachmentIds string // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
Model string // 请求使用的模型名称(从请求体或 URL 中提取)
}
// audRequestLogsColumns holds the columns for the table aud_request_logs.
@@ -88,7 +87,6 @@ var audRequestLogsColumns = AudRequestLogsColumns{
TaskUpstreamHeaders: "task_upstream_headers",
TaskCompletedAt: "task_completed_at",
AttachmentIds: "attachment_ids",
Model: "model",
}
// NewAudRequestLogsDao creates and returns a new DAO object for table data access.
+2 -2
View File
@@ -26,9 +26,9 @@ type MdlModelGroupsColumns struct {
Code string // 分组唯一标识(如 full_access、basic_chat)
Description string // 分组描述
Status string // 状态:active(启用)/ disabled(禁用)
IsDefault string // 是否为新租户默认模型组,注册时自动关联
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
IsDefault string // 是否为新租户默认模型组,注册时自动关联
}
// mdlModelGroupsColumns holds the columns for the table mdl_model_groups.
@@ -38,9 +38,9 @@ var mdlModelGroupsColumns = MdlModelGroupsColumns{
Code: "code",
Description: "description",
Status: "status",
IsDefault: "is_default",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
IsDefault: "is_default",
}
// NewMdlModelGroupsDao creates and returns a new DAO object for table data access.
+32 -30
View File
@@ -21,40 +21,42 @@ type OpnAppsDao struct {
// OpnAppsColumns defines and stores column names for the table opn_apps.
type OpnAppsColumns struct {
Id string // 主键ID
TenantId string // 所属租户ID
Name string // 应用名称
Description string // 应用描述
AppId string // 应用标识(opn_xxx 格式)
AppSecretHash string // App Secret 哈希(bcrypt)
Permissions string // 权限范围(JSON 数组)
IpWhitelist string // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string // OAuth 回调 URL
IsSandbox string // 是否沙箱应用
Status string // 状态:active(启用)/ disabled(禁用)
RateLimit string // 每分钟请求上限
LastUsedAt string // 最后使用时间
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
Id string // 主键ID
TenantId string // 所属租户ID
Name string // 应用名称
Description string // 应用描述
AppId string // 应用标识(opn_xxx 格式)
AppSecretHash string // App Secret 哈希(bcrypt)
Permissions string // 权限范围(JSON 数组)
IpWhitelist string // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string // OAuth 回调 URL
IsSandbox string // 是否沙箱应用
Status string // 状态:active(启用)/ disabled(禁用)
RateLimit string // 每分钟请求上限
LastUsedAt string // 最后使用时间
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
EncryptedSecret string // AES-256 encrypted App Secret for HMAC verification
}
// opnAppsColumns holds the columns for the table opn_apps.
var opnAppsColumns = OpnAppsColumns{
Id: "id",
TenantId: "tenant_id",
Name: "name",
Description: "description",
AppId: "app_id",
AppSecretHash: "app_secret_hash",
Permissions: "permissions",
IpWhitelist: "ip_whitelist",
CallbackUrl: "callback_url",
IsSandbox: "is_sandbox",
Status: "status",
RateLimit: "rate_limit",
LastUsedAt: "last_used_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
Id: "id",
TenantId: "tenant_id",
Name: "name",
Description: "description",
AppId: "app_id",
AppSecretHash: "app_secret_hash",
Permissions: "permissions",
IpWhitelist: "ip_whitelist",
CallbackUrl: "callback_url",
IsSandbox: "is_sandbox",
Status: "status",
RateLimit: "rate_limit",
LastUsedAt: "last_used_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
EncryptedSecret: "encrypted_secret",
}
// NewOpnAppsDao creates and returns a new DAO object for table data access.
+87
View File
@@ -0,0 +1,87 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// PlgExampleLogsDao is the data access object for the table plg_example_logs.
type PlgExampleLogsDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns PlgExampleLogsColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// PlgExampleLogsColumns defines and stores column names for the table plg_example_logs.
type PlgExampleLogsColumns struct {
Id string //
TenantId string //
Action string //
Detail string //
CreatedAt string //
}
// plgExampleLogsColumns holds the columns for the table plg_example_logs.
var plgExampleLogsColumns = PlgExampleLogsColumns{
Id: "id",
TenantId: "tenant_id",
Action: "action",
Detail: "detail",
CreatedAt: "created_at",
}
// NewPlgExampleLogsDao creates and returns a new DAO object for table data access.
func NewPlgExampleLogsDao(handlers ...gdb.ModelHandler) *PlgExampleLogsDao {
return &PlgExampleLogsDao{
group: "default",
table: "plg_example_logs",
columns: plgExampleLogsColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *PlgExampleLogsDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *PlgExampleLogsDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *PlgExampleLogsDao) Columns() PlgExampleLogsColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *PlgExampleLogsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *PlgExampleLogsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *PlgExampleLogsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+32 -28
View File
@@ -21,38 +21,42 @@ type SysAdminUsersDao struct {
// SysAdminUsersColumns defines and stores column names for the table sys_admin_users.
type SysAdminUsersColumns struct {
Id string // 主键ID
Username string // 登录用户名
PasswordHash string // 密码哈希(bcrypt)
Email string // 邮箱地址(可选,可为NULL)
DisplayName string // 显示名称
Role string // 角色:super_admin(全权限)/ admin(可配置权限)
Status string // 状态:active(启用)/ disabled(禁用)
LastLoginAt string // 最后登录时间
LastLoginIp string // 最后登录IP
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
TotpSecret string // TOTP 密钥(AES-256 加密存储)
TotpEnabled string // 是否启用双因素认证
BackupCodes string // 备用恢复码(bcrypt 哈希存储)
Id string // 主键ID
Username string // 登录用户名
PasswordHash string // 密码哈希(bcrypt)
Email string // 邮箱地址(可选,可为NULL)
DisplayName string // 显示名称
Role string // 角色:super_admin(全权限)/ admin(可配置权限)
Status string // 状态:active(启用)/ disabled(禁用)
LastLoginAt string // 最后登录时间
LastLoginIp string // 最后登录IP
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
TotpSecret string // TOTP 密钥(AES-256 加密存储)
TotpEnabled string // 是否启用双因素认证
BackupCodes string // 备用恢复码(bcrypt 哈希存储)
FailedAttempts string // 连续登录失败次数(成功登录后归零)
LockedUntil string // 锁定截止时间(连续5次失败后锁定30分钟)
}
// sysAdminUsersColumns holds the columns for the table sys_admin_users.
var sysAdminUsersColumns = SysAdminUsersColumns{
Id: "id",
Username: "username",
PasswordHash: "password_hash",
Email: "email",
DisplayName: "display_name",
Role: "role",
Status: "status",
LastLoginAt: "last_login_at",
LastLoginIp: "last_login_ip",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
TotpSecret: "totp_secret",
TotpEnabled: "totp_enabled",
BackupCodes: "backup_codes",
Id: "id",
Username: "username",
PasswordHash: "password_hash",
Email: "email",
DisplayName: "display_name",
Role: "role",
Status: "status",
LastLoginAt: "last_login_at",
LastLoginIp: "last_login_ip",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
TotpSecret: "totp_secret",
TotpEnabled: "totp_enabled",
BackupCodes: "backup_codes",
FailedAttempts: "failed_attempts",
LockedUntil: "locked_until",
}
// NewSysAdminUsersDao creates and returns a new DAO object for table data access.
@@ -21,13 +21,13 @@ type SysAgreementAcceptancesDao struct {
// SysAgreementAcceptancesColumns defines and stores column names for the table sys_agreement_acceptances.
type SysAgreementAcceptancesColumns struct {
Id string //
Id string // 主键ID
AgreementId string // 关联协议版本ID
UserType string // 用户类型
UserType string // 用户类型:admin(管理员) / tenant(租户用户)
UserId string // 用户ID
IpAddress string // IP地址
UserAgent string // User-Agent
CreatedAt string //
IpAddress string // 接受时的IP地址
UserAgent string // 接受时的浏览器User-Agent
CreatedAt string // 接受时间
}
// sysAgreementAcceptancesColumns holds the columns for the table sys_agreement_acceptances.
@@ -71,7 +71,7 @@ func (dao *SysAgreementAcceptancesDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO.
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
@@ -81,6 +81,11 @@ func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model {
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysAgreementAcceptancesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+14 -9
View File
@@ -21,19 +21,19 @@ type SysAgreementsDao struct {
// SysAgreementsColumns defines and stores column names for the table sys_agreements.
type SysAgreementsColumns struct {
Id string //
Code string // 协议标识码
Version string // 版本号
Id string // 主键ID
Code string // 协议标识码:terms(用户协议) / privacy(隐私政策)
Version string // 版本号,如 1.0、2.0
Title string // 协议标题
Content string // 协议正文(Markdown)
Summary string // 版本变更摘要
Status string // 状态
IsCurrent string // 是否为当前生效版本
ForceAccept string // 是否强制用户接受
Status string // 状态:draft(草稿) / published(已发布) / archived(已归档)
IsCurrent string // 是否为该标识码的当前生效版本(每个code仅一条)
ForceAccept string // 是否强制用户接受(true=登录后必须接受才能继续)
PublishedAt string // 发布时间
CreatedBy string // 创建的管理员ID
CreatedAt string //
UpdatedAt string //
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
}
// sysAgreementsColumns holds the columns for the table sys_agreements.
@@ -83,7 +83,7 @@ func (dao *SysAgreementsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO.
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
@@ -93,6 +93,11 @@ func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model {
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysAgreementsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+2
View File
@@ -39,6 +39,7 @@ type TntTenantsColumns struct {
MaxConcurrency string // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
DefaultChannelScope string // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level string // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled string // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
}
// tntTenantsColumns holds the columns for the table tnt_tenants.
@@ -61,6 +62,7 @@ var tntTenantsColumns = TntTenantsColumns{
MaxConcurrency: "max_concurrency",
DefaultChannelScope: "default_channel_scope",
Level: "level",
TeamEnabled: "team_enabled",
}
// NewTntTenantsDao creates and returns a new DAO object for table data access.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// plgExampleLogsDao is the data access object for the table plg_example_logs.
// You can define custom methods on it to extend its functionality as needed.
type plgExampleLogsDao struct {
*internal.PlgExampleLogsDao
}
var (
// PlgExampleLogs is a globally accessible object for table plg_example_logs operations.
PlgExampleLogs = plgExampleLogsDao{internal.NewPlgExampleLogsDao()}
)
// Add your custom methods and functionality below.
+11 -2
View File
@@ -81,6 +81,11 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
}
tenantName := strings.TrimSpace(req.TenantName)
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
@@ -122,7 +127,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
Name: strings.TrimSpace(req.TenantName),
Name: tenantName,
Code: tenantCode,
MaxMembers: maxMembersVal,
MaxConcurrency: maxConcurrencyVal,
@@ -470,7 +475,11 @@ func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1
data := do.TntTenants{}
if req.Name != "" {
data.Name = req.Name
tenantName := strings.TrimSpace(req.Name)
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
data.Name = tenantName
}
if req.MaxMembers != nil {
if *req.MaxMembers < 1 {
+51
View File
@@ -100,3 +100,54 @@ func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error
}
return nil
}
// TenantNameMaxDisplayWidth 组织名称最大显示宽度。
// 采用“显示宽度”模型:1 个汉字算 2、其余字符算 1,总宽度上限 16。
// 等价于“汉字最多 8 个(8×2=16)”或“字母最多 16 个(16×1=16)”,中英文混排按比例折算。
const TenantNameMaxDisplayWidth = 16
// displayWidth 计算“显示宽度”:CJK 汉字(unicode.Han)每个算 2,其余字符每个算 1。
// 用于组织名称等中英文混排场景的长度衡量,使 8 个汉字与 16 个字母视觉等宽。
func displayWidth(s string) int {
width := 0
for _, r := range s {
if unicode.Is(unicode.Han, r) {
width += 2
} else {
width += 1
}
}
return width
}
// ValidateTenantName 校验组织名称:显示宽度需在 2~TenantNameMaxDisplayWidth 之间。
// 配合 API 层使用,规则为“汉字最多 8 个、字母最多 16 个”。
func ValidateTenantName(name string) error {
w := displayWidth(name)
if w < 2 {
return gerror.New("组织名称长度不能少于 2 个字符")
}
if w > TenantNameMaxDisplayWidth {
return gerror.New(consts.MsgInvalidTenantName)
}
return nil
}
// TruncateToDisplayWidth 按显示宽度截断字符串,使结果宽度不超过 max。
// 用于自动生成的组织名称兜底(如“xxx 的组织”),避免用户名过长导致存入超长数据。
func TruncateToDisplayWidth(s string, max int) string {
width := 0
out := make([]rune, 0, len(s))
for _, r := range s {
rw := 1
if unicode.Is(unicode.Han, r) {
rw = 2
}
if width+rw > max {
break
}
width += rw
out = append(out, r)
}
return string(out)
}
-1
View File
@@ -543,7 +543,6 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit
StatusCode: record.StatusCode,
ClientIp: record.ClientIP,
UserAgent: record.UserAgent,
Model: record.Model,
RequestBody: sysReq,
ResponseBody: sysResp,
TenantRequestBody: tntReq,
+47 -17
View File
@@ -36,16 +36,40 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
username := strings.TrimSpace(req.Username)
tenantName := strings.TrimSpace(req.TenantName)
// 先校验禁用词:命中禁用词时优先返回更准确的提示,
// 简化注册:组织信息可选,留空时后端自动生成(个人模式,team_enabled=false)
userProvidedCode := tenantCode != ""
userProvidedName := tenantName != ""
// 先校验用户名禁用词:命中禁用词时优先返回更准确的提示,
// 避免被用户名格式校验的「不能包含特殊字符或中文」遮盖真实原因
if err := common.ValidateForbiddenWords(ctx, username, "用户名"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
// 组织代码:留空则自动生成(org-<base36>);用户显式传入才校验禁用词
if !userProvidedCode {
generated, err := generateUniqueTenantCode(ctx)
if err != nil {
return nil, err
}
tenantCode = generated
} else {
if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
}
if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
// 组织名称:留空则用用户名派生默认值;用户显式传入才校验禁用词
if !userProvidedName {
// 用户名较长时“xxx 的组织”可能超出组织名称显示宽度上限,按宽度截断兜底
tenantName = common.TruncateToDisplayWidth(fmt.Sprintf("%s 的组织", username), common.TenantNameMaxDisplayWidth)
} else {
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
}
// Validate username format
@@ -84,14 +108,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
}
}
// Check tenant code uniqueness
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
// Check tenant code uniqueness(仅用户显式传入时;自动生成的已在生成时查重)
if userProvidedCode {
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
}
}
// Validate password policy
@@ -109,12 +135,13 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Create tenant
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
Name: strings.TrimSpace(req.TenantName),
Name: tenantName,
Code: tenantCode,
// MaxMembers/MaxConcurrency: nil = 跟随等级配置
// Level 1 defaults will apply
Level: 1,
Settings: "{}",
Level: 1,
Settings: "{}",
TeamEnabled: false, // 新注册默认个人模式,设置自定义 code 后才激活团队功能
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create tenant")
@@ -214,8 +241,9 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339),
}
res.Tenant.ID = tenantID
res.Tenant.Name = strings.TrimSpace(req.TenantName)
res.Tenant.Name = tenantName
res.Tenant.Code = tenantCode
res.Tenant.TeamEnabled = false
res.User.ID = ownerUserID
res.User.Username = username
res.User.Role = "owner"
@@ -408,6 +436,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
res.Tenant.ID = tenant.Id
res.Tenant.Name = tenant.Name
res.Tenant.Code = tenant.Code
res.Tenant.TeamEnabled = tenant.TeamEnabled
res.User.ID = user.Id
res.User.Username = user.Username
res.User.Role = user.Role
@@ -455,6 +484,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
res.Tenant.ID = tenant.Id
res.Tenant.Name = tenant.Name
res.Tenant.Code = tenant.Code
res.Tenant.TeamEnabled = tenant.TeamEnabled
res.User.ID = user.Id
res.User.Username = user.Username
res.User.Role = user.Role
+55
View File
@@ -0,0 +1,55 @@
package tenant
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/dao"
)
// generateUniqueTenantCode 生成一个不冲突的默认租户 code。
// 格式:org-<base36>,满足正则 ^[a-z0-9][a-z0-9-]*[a-z0-9]$,长度 3-30。
// 用 crypto/rand 保证不可预测,循环查重最多 5 次避免唯一索引冲突。
func generateUniqueTenantCode(ctx context.Context) (string, error) {
const maxAttempts = 5
for i := 0; i < maxAttempts; i++ {
code, err := randomTenantCode()
if err != nil {
return "", err
}
count, err := dao.TntTenants.Ctx(ctx).Where("code", code).Count()
if err != nil {
return "", err
}
if count == 0 {
return code, nil
}
g.Log().Warningf(ctx, "生成的租户 code 冲突,重试: %s", code)
}
return "", fmt.Errorf("生成唯一租户 code 失败(重试 %d 次)", maxAttempts)
}
// randomTenantCode 生成单个 "org-<base36>" 形式的随机 code。
func randomTenantCode() (string, error) {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "", err
}
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
var chars []byte
tmp := new(big.Int).SetBytes(b)
base := big.NewInt(36)
mod := new(big.Int)
for tmp.Sign() > 0 && len(chars) < 16 {
tmp.DivMod(tmp, base, mod)
chars = append([]byte{alphabet[mod.Int64()]}, chars...)
}
if len(chars) == 0 {
chars = []byte{'0'}
}
return "org-" + string(chars), nil
}
+3
View File
@@ -109,6 +109,9 @@ func (s *sTenant) RevokeInvitation(ctx context.Context, req *v1.TenantInvitation
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
+23
View File
@@ -90,6 +90,9 @@ func (s *sTenant) InviteMember(ctx context.Context, req *v1.TenantMemberInviteRe
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
creatorID := middleware.GetUserID(ctx)
@@ -224,6 +227,11 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
return nil, common.NewBusinessError(consts.CodeInvitationExpired, consts.MsgInvitationExpired)
}
// 团队功能门控:目标租户必须已启用团队功能(公开接口,ctx 无 tenant_id,用目标租户 ID 校验)
if err := requireTeamEnabledForTenant(ctx, invitation.TenantID); err != nil {
return nil, err
}
email := strings.TrimSpace(strings.ToLower(req.Email))
// Validate password
@@ -384,6 +392,9 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
// Validate role
@@ -501,6 +512,9 @@ func (s *sTenant) RemoveMember(ctx context.Context, req *v1.TenantMemberRemoveRe
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
currentUserID := middleware.GetUserID(ctx)
memberID := req.Id
@@ -670,6 +684,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
currentUserID := middleware.GetUserID(ctx)
memberID := req.Id
@@ -719,6 +736,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda
// ResetMemberPassword resets a member's password. Only admins can reset other members' passwords.
func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) {
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
currentUserID := middleware.GetUserID(ctx)
memberID := req.Id
@@ -932,6 +952,9 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
columns := []export.Column{
+3
View File
@@ -35,6 +35,9 @@ type ImportResult struct {
// MemberImport parses CSV content, validates, creates an import record.
func (s *sTenant) MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) {
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
creatorID := middleware.GetUserID(ctx)
@@ -59,6 +59,9 @@ var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second)
// MemberModelScopesSet sets the available models for a member (full replace).
func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) {
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
// 验证用户属于当前租户
+3
View File
@@ -59,6 +59,9 @@ func (s *sTenant) MemberQuotaSet(ctx context.Context, req *v1.TenantMemberQuotaS
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
if req.QuotaType == "periodic" && req.Period == "" {
+70 -25
View File
@@ -2,9 +2,11 @@ package tenant
import (
"context"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
@@ -31,14 +33,15 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
tenantID := middleware.GetTenantID(ctx)
var tenant *struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoUrl string `json:"logo_url"`
Status string `json:"status"`
Level int `json:"level"`
MaxMembers *int `json:"max_members"`
CreatedAt string `json:"created_at"`
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
LogoUrl string `json:"logo_url"`
Status string `json:"status"`
Level int `json:"level"`
MaxMembers *int `json:"max_members"`
CreatedAt string `json:"created_at"`
}
err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Scan(&tenant)
@@ -57,15 +60,17 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
return nil, err
}
// Look up level name
var levelName *string
// Look up level name(单值查询用 Value(),Scan 仅支持 struct/*struct)
levelName := ""
if tenant.Level > 0 {
err = dao.TntTenantLevelConfigs.Ctx(ctx).
v, err := dao.TntTenantLevelConfigs.Ctx(ctx).
Where("level", tenant.Level).
Fields("name").
Scan(&levelName)
Value()
if err != nil {
g.Log().Warningf(ctx, "查询租户等级名称失败: %v", err)
} else {
levelName = v.String()
}
}
@@ -73,18 +78,14 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
effectiveMaxMembers, _, _ := billing.GetTenantEffectiveLimits(ctx, tenantID)
return &v1.TenantOrgInfoRes{
ID: tenant.Id,
Name: tenant.Name,
Code: tenant.Code,
LogoURL: tenant.LogoUrl,
Status: tenant.Status,
Level: tenant.Level,
LevelName: func() string {
if levelName != nil {
return *levelName
}
return ""
}(),
ID: tenant.Id,
Name: tenant.Name,
Code: tenant.Code,
TeamEnabled: tenant.TeamEnabled,
LogoURL: tenant.LogoUrl,
Status: tenant.Status,
Level: tenant.Level,
LevelName: levelName,
MaxMembers: effectiveMaxMembers,
MemberCount: int(memberCount),
CreatedAt: tenant.CreatedAt,
@@ -92,20 +93,64 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
}
// UpdateOrgInfo updates tenant organization info.
// 组织代码仅可在未启用团队功能时设置一次(设置即激活),启用后不可修改。
func (s *sTenant) UpdateOrgInfo(ctx context.Context, req *v1.TenantOrgUpdateReq) (*v1.TenantOrgUpdateRes, error) {
if err := ownerOnly(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
// 规整 code(小写化);留空表示不修改 code
var newCode *string
if req.Code != nil {
c := strings.TrimSpace(strings.ToLower(*req.Code))
if c == "" {
return nil, common.NewBadRequestError("组织代码不能为空")
}
newCode = &c
}
data := do.TntTenants{}
if req.Name != nil {
data.Name = *req.Name
name := strings.TrimSpace(*req.Name)
if err := common.ValidateTenantName(name); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
data.Name = name
}
if req.LogoURL != nil {
data.LogoUrl = *req.LogoURL
}
// 处理组织代码设置:启用团队功能后不可修改(只能设置一次)
if newCode != nil {
// 已启用团队功能 → 拒绝修改 code
current, err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Fields("team_enabled").Value()
if err != nil {
return nil, err
}
if current.Bool() {
return nil, common.NewBadRequestError("组织代码启用团队功能后不可修改")
}
// 首次设置:校验禁用词 + 唯一性(排除自己)
if err := common.ValidateForbiddenWords(ctx, *newCode, "组织代码"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
count, err := dao.TntTenants.Ctx(ctx).
Where("code", *newCode).
WhereNot("id", tenantID).
Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
}
data.Code = *newCode
data.TeamEnabled = true
}
_, err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Data(data).Update()
if err != nil {
return nil, err
+33
View File
@@ -0,0 +1,33 @@
package tenant
import (
"context"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/middleware"
)
// requireTeamEnabled 校验当前认证用户所属租户已启用团队功能。
// 用于 ctx 中携带 tenant_id 的团队写操作接口。
func requireTeamEnabled(ctx context.Context) error {
return requireTeamEnabledForTenant(ctx, middleware.GetTenantID(ctx))
}
// requireTeamEnabledForTenant 校验指定租户已启用团队功能。
// 用于 JoinByInvite 等公开接口:ctx 无 tenant_id,需在查到目标 tenant_id 后调用。
func requireTeamEnabledForTenant(ctx context.Context, tenantID int64) error {
if tenantID <= 0 {
return common.NewForbiddenError("租户不存在")
}
enabled, err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Fields("team_enabled").Value()
if err != nil {
return err
}
if !enabled.Bool() {
return common.NewBusinessError(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled)
}
return nil
}
-1
View File
@@ -43,5 +43,4 @@ type AudRequestLogs struct {
TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt *gtime.Time // 异步任务达到终态的时间
AttachmentIds any // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
Model any // 请求使用的模型名称(从请求体或 URL 中提取)
}
+1 -1
View File
@@ -17,7 +17,7 @@ type MdlModelGroups struct {
Code any // 分组唯一标识(如 full_access、basic_chat)
Description any // 分组描述
Status any // 状态:active(启用)/ disabled(禁用)
IsDefault any // 是否为新租户默认模型组,注册时自动关联
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
IsDefault any // 是否为新租户默认模型组,注册时自动关联
}
+17 -16
View File
@@ -11,20 +11,21 @@ import (
// OpnApps is the golang structure of table opn_apps for DAO operations like Where/Data.
type OpnApps struct {
g.Meta `orm:"table:opn_apps, do:true"`
Id any // 主键ID
TenantId any // 所属租户ID
Name any // 应用名称
Description any // 应用描述
AppId any // 应用标识(opn_xxx 格式)
AppSecretHash any // App Secret 哈希(bcrypt)
Permissions any // 权限范围(JSON 数组)
IpWhitelist any // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl any // OAuth 回调 URL
IsSandbox any // 是否沙箱应用
Status any // 状态:active(启用)/ disabled(禁用)
RateLimit any // 每分钟请求上限
LastUsedAt *gtime.Time // 最后使用时间
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
g.Meta `orm:"table:opn_apps, do:true"`
Id any // 主键ID
TenantId any // 所属租户ID
Name any // 应用名称
Description any // 应用描述
AppId any // 应用标识(opn_xxx 格式)
AppSecretHash any // App Secret 哈希(bcrypt)
Permissions any // 权限范围(JSON 数组)
IpWhitelist any // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl any // OAuth 回调 URL
IsSandbox any // 是否沙箱应用
Status any // 状态:active(启用)/ disabled(禁用)
RateLimit any // 每分钟请求上限
LastUsedAt *gtime.Time // 最后使用时间
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
EncryptedSecret any // AES-256 encrypted App Secret for HMAC verification
}
+20
View File
@@ -0,0 +1,20 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// PlgExampleLogs is the golang structure of table plg_example_logs for DAO operations like Where/Data.
type PlgExampleLogs struct {
g.Meta `orm:"table:plg_example_logs, do:true"`
Id any //
TenantId any //
Action any //
Detail any //
CreatedAt *gtime.Time //
}
@@ -12,11 +12,11 @@ import (
// SysAgreementAcceptances is the golang structure of table sys_agreement_acceptances for DAO operations like Where/Data.
type SysAgreementAcceptances struct {
g.Meta `orm:"table:sys_agreement_acceptances, do:true"`
Id any //
Id any // 主键ID
AgreementId any // 关联协议版本ID
UserType any // 用户类型:admin / tenant
UserType any // 用户类型:admin(管理员) / tenant(租户用户)
UserId any // 用户ID
IpAddress any // IP地址
UserAgent any // User-Agent
CreatedAt *gtime.Time //
IpAddress any // 接受时的IP地址
UserAgent any // 接受时的浏览器User-Agent
CreatedAt *gtime.Time // 接受时间
}
+8 -8
View File
@@ -12,17 +12,17 @@ import (
// SysAgreements is the golang structure of table sys_agreements for DAO operations like Where/Data.
type SysAgreements struct {
g.Meta `orm:"table:sys_agreements, do:true"`
Id any //
Code any // 协议标识码
Version any // 版本号
Id any // 主键ID
Code any // 协议标识码:terms(用户协议) / privacy(隐私政策)
Version any // 版本号,如 1.0、2.0
Title any // 协议标题
Content any // 协议正文(Markdown)
Summary any // 版本变更摘要
Status any // 状态:draft / published / archived
IsCurrent any // 是否为当前生效版本
ForceAccept any // 是否强制用户接受
Status any // 状态:draft(草稿) / published(已发布) / archived(已归档)
IsCurrent any // 是否为该标识码的当前生效版本(每个code仅一条)
ForceAccept any // 是否强制用户接受(true=登录后必须接受才能继续)
PublishedAt *gtime.Time // 发布时间
CreatedBy any // 创建的管理员ID
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
}
+1
View File
@@ -30,4 +30,5 @@ type TntTenants struct {
MaxConcurrency any // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
DefaultChannelScope any // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level any // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled any // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
}
@@ -41,5 +41,4 @@ type AudRequestLogs struct {
TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间
AttachmentIds string `json:"attachment_ids" orm:"attachment_ids" description:"提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])"` // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
Model string `json:"model" orm:"model" description:"请求使用的模型名称(从请求体或 URL 中提取)"` // 请求使用的模型名称(从请求体或 URL 中提取)
}
+1 -1
View File
@@ -15,7 +15,7 @@ type MdlModelGroups struct {
Code string `json:"code" orm:"code" description:"分组唯一标识(如 full_access、basic_chat)"` // 分组唯一标识(如 full_access、basic_chat)
Description string `json:"description" orm:"description" description:"分组描述"` // 分组描述
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联
}
+16 -15
View File
@@ -10,19 +10,20 @@ import (
// OpnApps is the golang structure for table opn_apps.
type OpnApps struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称
Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述
AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式)
AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt)
Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组)
IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL
IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限
LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称
Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述
AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式)
AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt)
Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组)
IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL
IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限
LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
EncryptedSecret string `json:"encrypted_secret" orm:"encrypted_secret" description:"AES-256 encrypted App Secret for HMAC verification"` // AES-256 encrypted App Secret for HMAC verification
}
+18
View File
@@ -0,0 +1,18 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// PlgExampleLogs is the golang structure for table plg_example_logs.
type PlgExampleLogs struct {
Id int64 `json:"id" orm:"id" description:""` //
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
Action string `json:"action" orm:"action" description:""` //
Detail string `json:"detail" orm:"detail" description:""` //
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
}
@@ -10,11 +10,11 @@ import (
// SysAgreementAcceptances is the golang structure for table sys_agreement_acceptances.
type SysAgreementAcceptances struct {
Id int64 `json:"id" orm:"id"`
AgreementId int64 `json:"agreement_id" orm:"agreement_id"`
UserType string `json:"user_type" orm:"user_type"`
UserId int64 `json:"user_id" orm:"user_id"`
IpAddress string `json:"ip_address" orm:"ip_address"`
UserAgent string `json:"user_agent" orm:"user_agent"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
AgreementId int64 `json:"agreement_id" orm:"agreement_id" description:"关联协议版本ID"` // 关联协议版本ID
UserType string `json:"user_type" orm:"user_type" description:"用户类型:admin(管理员) / tenant(租户用户)"` // 用户类型:admin(管理员) / tenant(租户用户)
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
IpAddress string `json:"ip_address" orm:"ip_address" description:"接受时的IP地址"` // 接受时的IP地址
UserAgent string `json:"user_agent" orm:"user_agent" description:"接受时的浏览器User-Agent"` // 接受时的浏览器User-Agent
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"接受时间"` // 接受时间
}
+13 -13
View File
@@ -10,17 +10,17 @@ import (
// SysAgreements is the golang structure for table sys_agreements.
type SysAgreements struct {
Id int64 `json:"id" orm:"id"`
Code string `json:"code" orm:"code"`
Version string `json:"version" orm:"version"`
Title string `json:"title" orm:"title"`
Content string `json:"content" orm:"content"`
Summary string `json:"summary" orm:"summary"`
Status string `json:"status" orm:"status"`
IsCurrent bool `json:"is_current" orm:"is_current"`
ForceAccept bool `json:"force_accept" orm:"force_accept"`
PublishedAt *gtime.Time `json:"published_at" orm:"published_at"`
CreatedBy int64 `json:"created_by" orm:"created_by"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
Code string `json:"code" orm:"code" description:"协议标识码:terms(用户协议) / privacy(隐私政策)"` // 协议标识码:terms(用户协议) / privacy(隐私政策)
Version string `json:"version" orm:"version" description:"版本号,如 1.0、2.0"` // 版本号,如 1.0、2.0
Title string `json:"title" orm:"title" description:"协议标题"` // 协议标题
Content string `json:"content" orm:"content" description:"协议正文(Markdown)"` // 协议正文(Markdown)
Summary string `json:"summary" orm:"summary" description:"版本变更摘要"` // 版本变更摘要
Status string `json:"status" orm:"status" description:"状态:draft(草稿) / published(已发布) / archived(已归档)"` // 状态:draft(草稿) / published(已发布) / archived(已归档)
IsCurrent bool `json:"is_current" orm:"is_current" description:"是否为该标识码的当前生效版本(每个code仅一条)"` // 是否为该标识码的当前生效版本(每个code仅一条)
ForceAccept bool `json:"force_accept" orm:"force_accept" description:"是否强制用户接受(true=登录后必须接受才能继续)"` // 是否强制用户接受(true=登录后必须接受才能继续)
PublishedAt *gtime.Time `json:"published_at" orm:"published_at" description:"发布时间"` // 发布时间
CreatedBy int64 `json:"created_by" orm:"created_by" description:"创建的管理员ID"` // 创建的管理员ID
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
}
+1
View File
@@ -28,4 +28,5 @@ type TntTenants struct {
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"租户总并发上限(NULL表示跟随等级配置,0表示不限制)"` // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope" description:"默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)"` // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level int `json:"level" orm:"level" description:"当前等级(对应 tnt_tenant_level_configs.level)"` // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled bool `json:"team_enabled" orm:"team_enabled" description:"团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)"` // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
}
+48
View File
@@ -0,0 +1,48 @@
-- +goose Up
-- P1-1: 管理员暴力破解防护 — 新增失败计数和锁定字段
ALTER TABLE sys_admin_users
ADD COLUMN IF NOT EXISTS failed_attempts INT DEFAULT 0 NOT NULL,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
COMMENT ON COLUMN sys_admin_users.failed_attempts IS '连续登录失败次数(成功登录后归零)';
COMMENT ON COLUMN sys_admin_users.locked_until IS '锁定截止时间(连续5次失败后锁定30分钟)';
-- P1-3: TOTP 密钥列宽度不足 — AES-GCM 加密 + base64 编码后约 80 字符,原 VARCHAR(64) 溢出
ALTER TABLE sys_admin_users ALTER COLUMN totp_secret TYPE VARCHAR(255);
ALTER TABLE tnt_users ALTER COLUMN totp_secret TYPE VARCHAR(255);
-- 开放平台应用密钥加密存储(HMAC 校验用)
ALTER TABLE opn_apps ADD COLUMN IF NOT EXISTS encrypted_secret TEXT;
COMMENT ON COLUMN opn_apps.encrypted_secret IS 'AES-256 encrypted App Secret for HMAC verification';
-- 修正项目 Key 类型:绑定项目的 Key 应为 project 类型而非 personal
UPDATE api_keys
SET key_type = 'project'
WHERE project_id IS NOT NULL
AND key_type = 'personal';
-- 团队功能开关:false=个人模式(默认),true=已激活团队
ALTER TABLE tnt_tenants
ADD COLUMN IF NOT EXISTS team_enabled BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN tnt_tenants.team_enabled IS '团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)';
-- 存量租户均通过旧注册流程设置过自定义 code,视为团队已启用
UPDATE tnt_tenants SET team_enabled = TRUE WHERE team_enabled = FALSE;
-- +goose Down
-- 团队功能开关(team_enabled 的存量数据回填不可逆,仅回滚结构)
ALTER TABLE tnt_tenants DROP COLUMN IF EXISTS team_enabled;
-- 开放平台应用密钥(api_keys.key_type 的数据修正是不可逆的数据变更,仅回滚结构)
ALTER TABLE opn_apps DROP COLUMN IF EXISTS encrypted_secret;
-- TOTP 密钥列宽度还原
ALTER TABLE sys_admin_users ALTER COLUMN totp_secret TYPE VARCHAR(64);
ALTER TABLE tnt_users ALTER COLUMN totp_secret TYPE VARCHAR(64);
-- 管理员暴力破解防护字段
ALTER TABLE sys_admin_users
DROP COLUMN IF EXISTS failed_attempts,
DROP COLUMN IF EXISTS locked_until;
@@ -0,0 +1,19 @@
<script setup lang="ts">
import Icon from '@/components/common/Icon.vue'
</script>
<template>
<div class="card p-10 flex flex-col items-center justify-center text-center animate-slide-up">
<div class="h-16 w-16 rounded-2xl bg-gray-100 flex items-center justify-center mb-4">
<Icon name="lock" size="lg" class="text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">团队功能未启用</h3>
<p class="text-sm text-gray-500 max-w-sm mb-6 leading-relaxed">
当前为个人模式。请先设置组织代码,即可启用成员管理、RAM 账号、邀请、成员额度等团队功能。
</p>
<router-link to="/tenant/organization" class="btn btn-primary">
<Icon name="cog" size="sm" />
前往设置组织代码
</router-link>
</div>
</template>
+19 -2
View File
@@ -7,6 +7,7 @@ export interface TenantInfo {
id: number
name: string
code: string
team_enabled: boolean
}
export interface TenantUser {
@@ -33,14 +34,15 @@ interface AuthResponse {
}
export interface RegisterPayload {
tenant_name: string
tenant_code: string
username: string
password: string
email: string
code?: string
captcha_key?: string
captcha_x?: number
// 组织信息可选(留空由后端自动生成),保留以兼容显式传入场景
tenant_name?: string
tenant_code?: string
}
const STORE_KEY = 'tenant_auth'
@@ -143,6 +145,20 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
return data.data
}
// 拉取最新组织信息,更新 store.tenant(激活团队功能后调用,驱动菜单/页面响应)
async function refreshOrgInfo(): Promise<void> {
const { data } = await request.get<{ data: { id: number; name: string; code: string; team_enabled: boolean } }>('/tenant/organization')
if (tenant.value && data.data) {
tenant.value = {
id: data.data.id,
name: data.data.name,
code: data.data.code,
team_enabled: data.data.team_enabled,
}
persist()
}
}
async function logout(): Promise<void> {
try {
await request.post('/tenant/auth/logout')
@@ -197,6 +213,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => {
login,
applyTokensFrom2FA,
register,
refreshOrgInfo,
logout,
refreshTokens,
loadFromStorage,
+3 -3
View File
@@ -251,9 +251,9 @@ onBeforeUnmount(() => {
<span class="text-white font-bold text-sm">{{ tenantInfo.name.charAt(0) || 'T' }}</span>
</div>
<transition name="fade">
<div v-if="!sidebarCollapsed" class="flex flex-col">
<span class="text-lg font-bold text-gray-900 truncate">{{ tenantInfo.name }}</span>
<span class="text-xs text-gray-400 font-mono">{{ tenantInfo.code }}</span>
<div v-if="!sidebarCollapsed" class="flex min-w-0 flex-col overflow-hidden">
<span class="text-lg font-bold text-gray-900 truncate" :title="tenantInfo.name">{{ tenantInfo.name }}</span>
<span class="text-xs text-gray-400 font-mono truncate">{{ tenantInfo.code }}</span>
</div>
</transition>
</div>
+8 -125
View File
@@ -5,7 +5,6 @@ import { useTenantAuthStore } from '@/stores/tenant-auth'
import { usePublicSettings } from '@/composables/usePublicSettings'
import AuthLayout from '@/components/layout/AuthLayout.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue'
import Icon from '@/components/common/Icon.vue'
import AgreementViewModal from '@/components/common/AgreementViewModal.vue'
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
@@ -16,17 +15,14 @@ const router = useRouter()
const authStore = useTenantAuthStore()
const { settings, fetchSettings } = usePublicSettings()
const step = ref(1)
const loading = ref(false)
const codeSending = ref(false)
const countdown = ref(0)
const emailVerification = ref(true)
const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 })
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
const turnstileToken = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null
const orgForm = reactive({ orgName: '', orgCode: '' })
const userForm = reactive({
email: '',
code: '',
@@ -36,11 +32,8 @@ const userForm = reactive({
agreed: false,
})
const orgErrors = reactive<Record<string, string>>({})
const userErrors = reactive<Record<string, string>>({})
// Pending agreements
// Agreement view modal
const showAgreementModal = ref(false)
const agreementModalCode = ref('')
@@ -54,29 +47,11 @@ function proceedAfterRegister() {
router.push('/tenant/dashboard')
}
const stepTitles = ['组织信息', '管理员信息']
onMounted(async () => {
await fetchSettings()
emailVerification.value = settings.value.register_email_verification === true
})
function validateOrg(): boolean {
Object.keys(orgErrors).forEach((k) => delete orgErrors[k])
if (!orgForm.orgName.trim()) {
orgErrors.orgName = '请输入组织名称'
}
if (!orgForm.orgCode.trim()) {
orgErrors.orgCode = '请输入组织代码'
} else if (!/^[a-z][a-z0-9-]{2,28}[a-z0-9]$/.test(orgForm.orgCode)) {
orgErrors.orgCode = '仅支持小写字母、数字和连字符(3-30 位)'
}
return Object.keys(orgErrors).length === 0
}
function validateUser(): boolean {
Object.keys(userErrors).forEach((k) => delete userErrors[k])
@@ -138,15 +113,6 @@ function validateUsernameRealtime() {
}
}
function goNext() {
if (!validateOrg()) return
step.value = 2
}
function goBack() {
step.value = 1
}
async function sendCode() {
if (!userForm.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userForm.email)) {
userErrors.email = '请先输入有效的邮箱地址'
@@ -186,11 +152,10 @@ async function handleRegister() {
loading.value = true
try {
// 简化注册:仅提交账号信息,组织信息由后端自动生成(个人模式,团队功能后续开启)
const payload = {
email: userForm.email,
password: userForm.password,
tenant_name: orgForm.orgName,
tenant_code: orgForm.orgCode,
username: userForm.username,
code: emailVerification.value ? userForm.code : undefined,
captcha_key: emailVerification.value ? undefined : captcha.value.captchaKey,
@@ -200,7 +165,7 @@ async function handleRegister() {
await authStore.register(payload)
proceedAfterRegister()
} catch (err: any) {
captchaRef.value?.resetCaptcha()
captchaRef.value?.resetCaptcha()
const apiErr = extractApiError(err)
const msg = apiErr?.message || '注册失败'
if (msg.includes('邮箱') || msg.includes('email')) {
@@ -209,9 +174,6 @@ async function handleRegister() {
userErrors.code = msg
} else if (msg.includes('密码') || msg.includes('password')) {
userErrors.password = msg
} else if (msg.includes('组织') || msg.includes('tenant')) {
orgErrors.orgName = msg
step.value = 1
} else if (msg.includes('用户名') || msg.includes('username')) {
userErrors.username = msg
} else {
@@ -241,90 +203,11 @@ async function handleRegister() {
<div v-else class="animate-slide-up">
<!-- Header -->
<div class="mb-6 text-center">
<h2 class="text-xl font-bold text-gray-900">创建您的组织</h2>
<p class="mt-1.5 text-sm text-gray-500">几分钟即可开始使用 Team API</p>
<h2 class="text-xl font-bold text-gray-900">创建账号</h2>
<p class="mt-1.5 text-sm text-gray-500">几分钟即可开始使用,团队功能可后续开启</p>
</div>
<!-- Step Indicator -->
<div class="mb-6 flex items-center gap-3">
<div
v-for="(title, i) in stepTitles"
:key="i"
class="flex flex-1 items-center gap-2"
>
<div
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-semibold transition-all duration-300"
:class="[
step > i + 1
? 'bg-primary-500 text-white'
: step === i + 1
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-400',
]"
>
<Icon v-if="step > i + 1" name="check" size="xs" />
<span v-else>{{ i + 1 }}</span>
</div>
<span
class="text-xs font-medium transition-colors duration-300"
:class="step >= i + 1 ? 'text-gray-900' : 'text-gray-400'"
>
{{ title }}
</span>
<div
v-if="i < stepTitles.length - 1"
class="h-px flex-1 transition-colors duration-300"
:class="step > i + 1 ? 'bg-primary-500' : 'bg-gray-200'"
/>
</div>
</div>
<!-- Step 1: Organization Info -->
<form v-if="step === 1" @submit.prevent="goNext" class="space-y-4">
<div>
<label class="input-label">组织名称</label>
<input
v-model="orgForm.orgName"
type="text"
placeholder="例如:某某科技"
class="input"
:class="{ 'input-error': orgErrors.orgName }"
/>
<p v-if="orgErrors.orgName" class="input-error-text">{{ orgErrors.orgName }}</p>
</div>
<div>
<label class="input-label">组织代码</label>
<input
v-model="orgForm.orgCode"
type="text"
placeholder="例如:acme-corp"
class="input"
:class="{ 'input-error': orgErrors.orgCode }"
/>
<p class="input-hint">组织的唯一标识符,仅支持小写字母、数字和连字符</p>
<p v-if="orgErrors.orgCode" class="input-error-text">{{ orgErrors.orgCode }}</p>
</div>
<button type="submit" class="btn btn-primary btn-lg w-full">
下一步
<Icon name="arrowRight" size="sm" />
</button>
</form>
<!-- Step 2: Personal Info -->
<form v-else @submit.prevent="handleRegister" class="space-y-4">
<!-- Org Summary -->
<div class="rounded-xl bg-gray-50 px-4 py-3 flex items-center justify-between">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ orgForm.orgName }}</p>
<p class="text-xs text-gray-500">{{ orgForm.orgCode }}</p>
</div>
<button type="button" @click="goBack" class="text-xs text-primary-600 font-medium hover:text-primary-700 transition-colors flex-shrink-0 ml-3">
修改
</button>
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<!-- Username -->
<div>
<label class="input-label">用户名</label>
@@ -338,8 +221,8 @@ async function handleRegister() {
placeholder="仅支持英文字母和数字,不能为纯数字"
class="input pl-11"
:class="{ 'input-error': userErrors.username }"
@input="validateUsernameRealtime"
/>
@input="validateUsernameRealtime"
/>
</div>
<p v-if="userErrors.username" class="input-error-text">{{ userErrors.username }}</p>
</div>
@@ -441,7 +324,7 @@ async function handleRegister() {
<!-- Submit -->
<button type="submit" :disabled="loading || !userForm.agreed" class="btn btn-primary btn-lg w-full disabled:opacity-50 disabled:cursor-not-allowed">
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
{{ loading ? '创建中...' : '创建组织' }}
{{ loading ? '创建中...' : '创建账号' }}
</button>
</form>
</div>
@@ -17,6 +17,15 @@ const nameForm = reactive({
})
const nameSaving = ref(false)
// 团队功能激活/管理
const teamForm = reactive({
name: '',
code: '',
})
const teamSaving = ref(false)
const codeError = ref('')
const teamNameError = ref('')
const showTransferModal = ref(false)
const transferForm = reactive({
new_owner_id: '',
@@ -54,6 +63,8 @@ async function fetchOrgInfo() {
const res: any = await request.get('/tenant/organization')
orgInfo.value = res.data.data
nameForm.name = res.data?.data?.name || ''
teamForm.name = res.data?.data?.name || ''
teamForm.code = res.data?.data?.code || ''
} catch {
// silent
} finally {
@@ -62,15 +73,21 @@ async function fetchOrgInfo() {
}
async function saveName() {
if (!nameForm.name.trim()) return
const name = nameForm.name.trim()
if (!name) return
if (calcOrgNameWidth(name) > ORG_NAME_MAX_WIDTH) {
toast.error('组织名称长度超出限制(汉字最多 8 个,字母最多 16 个)')
return
}
nameSaving.value = true
try {
await request.put('/tenant/organization', { name: nameForm.name })
await request.put('/tenant/organization', { name })
if (orgInfo.value) {
orgInfo.value.name = nameForm.name
authStore.tenant!.name = nameForm.name
orgInfo.value.name = name
}
// 同步 store 并持久化(refreshOrgInfo 内部 persist),否则刷新页面会回退到旧名称
await authStore.refreshOrgInfo()
editingName.value = false
} catch {
} finally {
@@ -83,6 +100,55 @@ function cancelEditName() {
editingName.value = false
}
// 组织名称显示宽度:汉字算 2、其余算 1,上限 16(与后端一致:汉字最多 8 个,字母最多 16 个)
const ORG_NAME_MAX_WIDTH = 16
function calcOrgNameWidth(val: string): number {
let w = 0
for (const ch of val) {
w += /[一-鿿]/.test(ch) ? 2 : 1
}
return w
}
// 组织代码格式校验:3-30 位,小写字母/数字/连字符,字母数字开头结尾
function validateTeamCode(val: string): boolean {
if (!val.trim()) {
codeError.value = '请输入组织代码'
return false
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(val) || val.length < 3 || val.length > 30) {
codeError.value = '3-30 位,小写字母、数字、连字符,字母数字开头结尾'
return false
}
codeError.value = ''
return true
}
// 启用团队功能:同时设置组织名称和组织代码(首次激活,后端置 team_enabled=true)
async function enableTeam() {
const name = teamForm.name.trim()
if (!name) {
toast.error('请输入组织名称')
return
}
if (calcOrgNameWidth(name) > ORG_NAME_MAX_WIDTH) {
teamNameError.value = '汉字最多 8 个,字母最多 16 个'
return
}
teamNameError.value = ''
if (!validateTeamCode(teamForm.code)) return
teamSaving.value = true
try {
await request.put('/tenant/organization', { name: teamForm.name, code: teamForm.code })
toast.success('团队功能已启用')
await fetchOrgInfo()
await authStore.refreshOrgInfo()
} catch {
} finally {
teamSaving.value = false
}
}
async function handleTransferOwnership() {
if (!transferForm.new_owner_id.trim() || !transferForm.password.trim()) return
@@ -206,8 +272,88 @@ onMounted(() => {
</div>
</div>
<!-- Team Feature Card(团队功能激活/管理)-->
<div v-if="orgInfo" class="card">
<div class="card-header">
<div class="flex items-center justify-between">
<div>
<h2 class="font-semibold text-gray-900">团队功能</h2>
<p class="text-sm text-gray-500 mt-0.5">启用后可邀请成员、创建 RAM 账号、分配成员额度</p>
</div>
<span class="badge" :class="orgInfo.team_enabled ? 'badge-success' : 'badge-gray'">
{{ orgInfo.team_enabled ? '已启用' : '未启用' }}
</span>
</div>
</div>
<div class="card-body space-y-4">
<!-- 个人模式:激活表单 -->
<template v-if="!orgInfo.team_enabled">
<div class="rounded-xl bg-amber-50 border border-amber-200 p-4 flex items-start gap-3">
<Icon name="exclamationTriangle" size="md" class="text-amber-600 flex-shrink-0 mt-0.5" />
<div class="text-sm text-amber-800">
当前为个人模式。设置组织名称和组织代码后即可启用团队功能,成员可通过
<code class="code">用户名@组织代码</code> 登录。
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="input-label">组织名称</label>
<input
v-model="teamForm.name"
type="text"
placeholder="例如:某某科技"
class="input"
:class="{ 'input-error': teamNameError }"
@input="teamNameError = ''"
/>
<p class="input-hint">汉字最多 8 个,字母最多 16 个</p>
<p v-if="teamNameError" class="input-error-text">{{ teamNameError }}</p>
</div>
<div>
<label class="input-label">组织代码</label>
<input
v-model="teamForm.code"
type="text"
placeholder="例如:my-team"
class="input"
:class="{ 'input-error': codeError }"
@input="codeError = ''"
/>
<p class="input-hint">3-30 位,小写字母、数字、连字符,字母数字开头结尾</p>
<p v-if="codeError" class="input-error-text">{{ codeError }}</p>
</div>
</div>
<div class="flex justify-end">
<button class="btn btn-primary" :disabled="teamSaving" @click="enableTeam">
{{ teamSaving ? '启用中...' : '启用团队功能' }}
</button>
</div>
</template>
<!-- 团队模式:组织代码只读(启用后不可修改) -->
<template v-else>
<div class="rounded-xl bg-emerald-50 border border-emerald-200 p-4 flex items-start gap-3">
<Icon name="checkCircle" size="md" class="text-emerald-600 flex-shrink-0 mt-0.5" />
<div class="text-sm text-emerald-800">
团队功能已启用。成员通过 <code class="code">用户名@{{ orgInfo.code }}</code> 登录。
</div>
</div>
<div>
<p class="input-label">组织代码</p>
<div class="flex items-center gap-2">
<span class="badge badge-gray font-mono">{{ orgInfo.code }}</span>
<span class="input-hint">启用后不可修改</span>
</div>
</div>
</template>
</div>
</div>
<!-- Organization Info Card -->
<div v-else-if="orgInfo" class="card">
<div v-if="orgInfo" class="card">
<div class="card-header">
<h2 class="font-semibold text-gray-900">组织详情</h2>
</div>
@@ -252,10 +398,11 @@ onMounted(() => {
<div>
<p class="input-label">组织代码</p>
<div class="flex items-center gap-2">
<span class="badge badge-gray font-mono">
{{ orgInfo.code }}
</span>
<span class="input-hint">只读</span>
<span v-if="orgInfo.team_enabled" class="badge badge-gray font-mono">{{ orgInfo.code }}</span>
<template v-else>
<span class="badge badge-gray">未设置</span>
<span class="input-hint">个人模式</span>
</template>
</div>
</div>
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import Icon from '@/components/common/Icon.vue'
import TeamLockedBanner from '@/components/common/TeamLockedBanner.vue'
import request from '@/utils/request'
import { toast } from '@/utils/toast'
const router = useRouter()
const authStore = useTenantAuthStore()
const teamEnabled = computed(() => !!authStore.tenant?.team_enabled)
interface InvitationItem {
id: number
@@ -92,7 +96,8 @@ onMounted(() => {
</script>
<template>
<div class="space-y-6">
<TeamLockedBanner v-if="!teamEnabled" />
<div v-else class="space-y-6">
<!-- Page Header -->
<div class="page-header flex items-center justify-between">
<div>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import BaseModal from '@/components/common/BaseModal.vue'
import TeamLockedBanner from '@/components/common/TeamLockedBanner.vue'
import Icon from '@/components/common/Icon.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import request from '@/utils/request'
@@ -9,6 +11,8 @@ import { toast } from '@/utils/toast'
const router = useRouter()
const route = useRoute()
const authStore = useTenantAuthStore()
const teamEnabled = computed(() => !!authStore.tenant?.team_enabled)
const memberId = computed(() => Number(route.params.id))
@@ -365,7 +369,8 @@ onMounted(() => {
</script>
<template>
<div class="space-y-8">
<TeamLockedBanner v-if="!teamEnabled" />
<div v-else class="space-y-8">
<!-- Back navigation -->
<button
@click="goBack"
+7 -2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useTenantAuthStore } from '@/stores/tenant-auth'
import BaseModal from '@/components/common/BaseModal.vue'
import TeamLockedBanner from '@/components/common/TeamLockedBanner.vue'
import BaseSelect from '../../components/common/BaseSelect.vue'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
@@ -9,6 +11,8 @@ import { toast } from '@/utils/toast'
import { useExport } from '@/composables/useExport'
const router = useRouter()
const authStore = useTenantAuthStore()
const teamEnabled = computed(() => !!authStore.tenant?.team_enabled)
const showExportDropdown = ref(false)
const { exporting, exportFile } = useExport({
@@ -209,7 +213,8 @@ onMounted(() => {
</script>
<template>
<div class="space-y-6">
<TeamLockedBanner v-if="!teamEnabled" />
<div v-else class="space-y-6">
<!-- Page Header -->
<div class="page-header flex items-center justify-between">
<div>