注册功能优化 (#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:
1 parent
22ee0aa112
commit
79b9224968
49 files changed
+932
-343
No files matched your search
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)`**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 验证用户属于当前租户
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 中提取)
|
||||
}
|
||||
@@ -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 // 是否为新租户默认模型组,注册时自动关联
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 // 接受时间
|
||||
}
|
||||
@@ -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 // 更新时间
|
||||
}
|
||||
@@ -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 中提取)
|
||||
}
|
||||
@@ -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:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"接受时间"` // 接受时间
|
||||
}
|
||||
@@ -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:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -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/邀请/额度)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user