From 79b922496899215875cac640414db8162766165e Mon Sep 17 00:00:00 2001 From: Qian <39391600+qianfree@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:07:36 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=8A=9F=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: qian --- api/admin/v1/tenant.go | 2 +- api/tenant/v1/auth.go | 18 +- api/tenant/v1/organization.go | 4 +- docs/reference/goframe-conventions.md | 26 +++ internal/consts/consts.go | 8 + internal/dao/internal/aud_request_logs.go | 2 - internal/dao/internal/mdl_model_groups.go | 4 +- internal/dao/internal/opn_apps.go | 62 +++---- internal/dao/internal/plg_example_logs.go | 87 +++++++++ internal/dao/internal/sys_admin_users.go | 60 ++++--- .../dao/internal/sys_agreement_acceptances.go | 17 +- internal/dao/internal/sys_agreements.go | 23 ++- internal/dao/internal/tnt_tenants.go | 2 + internal/dao/plg_example_logs.go | 22 +++ internal/logic/admin/tenant.go | 13 +- internal/logic/common/validation.go | 51 ++++++ internal/logic/relay/provider.go | 1 - internal/logic/tenant/auth.go | 64 +++++-- internal/logic/tenant/code_generator.go | 55 ++++++ internal/logic/tenant/invitation.go | 3 + internal/logic/tenant/member.go | 23 +++ internal/logic/tenant/member_import.go | 3 + internal/logic/tenant/member_model_scope.go | 3 + internal/logic/tenant/member_quota.go | 3 + internal/logic/tenant/organization.go | 95 +++++++--- internal/logic/tenant/team_guard.go | 33 ++++ internal/model/do/aud_request_logs.go | 1 - internal/model/do/mdl_model_groups.go | 2 +- internal/model/do/opn_apps.go | 33 ++-- internal/model/do/plg_example_logs.go | 20 +++ .../model/do/sys_agreement_acceptances.go | 10 +- internal/model/do/sys_agreements.go | 16 +- internal/model/do/tnt_tenants.go | 1 + internal/model/entity/aud_request_logs.go | 1 - internal/model/entity/mdl_model_groups.go | 2 +- internal/model/entity/opn_apps.go | 31 ++-- internal/model/entity/plg_example_logs.go | 18 ++ .../model/entity/sys_agreement_acceptances.go | 14 +- internal/model/entity/sys_agreements.go | 26 +-- internal/model/entity/tnt_tenants.go | 1 + migrations/000011_v0_1_13_version.sql | 48 +++++ .../components/common/TeamLockedBanner.vue | 19 ++ web/tenant/src/stores/tenant-auth.ts | 21 ++- web/tenant/src/views/Layout.vue | 6 +- web/tenant/src/views/auth/RegisterPage.vue | 133 +------------- .../src/views/settings/OrganizationPage.vue | 165 +++++++++++++++++- web/tenant/src/views/team/InvitationsPage.vue | 7 +- .../src/views/team/MemberDetailPage.vue | 7 +- web/tenant/src/views/team/MembersPage.vue | 9 +- 49 files changed, 932 insertions(+), 343 deletions(-) create mode 100644 internal/dao/internal/plg_example_logs.go create mode 100644 internal/dao/plg_example_logs.go create mode 100644 internal/logic/tenant/code_generator.go create mode 100644 internal/logic/tenant/team_guard.go create mode 100644 internal/model/do/plg_example_logs.go create mode 100644 internal/model/entity/plg_example_logs.go create mode 100644 migrations/000011_v0_1_13_version.sql create mode 100644 web/tenant/src/components/common/TeamLockedBanner.vue diff --git a/api/admin/v1/tenant.go b/api/admin/v1/tenant.go index 0edb53a..62898cd 100644 --- a/api/admin/v1/tenant.go +++ b/api/admin/v1/tenant.go @@ -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:"管理员邮箱"` diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go index 2b377a7..0e19822 100644 --- a/api/tenant/v1/auth.go +++ b/api/tenant/v1/auth.go @@ -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"` diff --git a/api/tenant/v1/organization.go b/api/tenant/v1/organization.go index d69512d..96b35ad 100644 --- a/api/tenant/v1/organization.go +++ b/api/tenant/v1/organization.go @@ -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"` } diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index f6a6a71..6e247fd 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -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)`** + diff --git a/internal/consts/consts.go b/internal/consts/consts.go index d3731f9..480fc00 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -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 = "仅草稿状态的协议可修改或删除" diff --git a/internal/dao/internal/aud_request_logs.go b/internal/dao/internal/aud_request_logs.go index 891cb4f..8cb94c2 100644 --- a/internal/dao/internal/aud_request_logs.go +++ b/internal/dao/internal/aud_request_logs.go @@ -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. diff --git a/internal/dao/internal/mdl_model_groups.go b/internal/dao/internal/mdl_model_groups.go index 5b895d6..cef799d 100644 --- a/internal/dao/internal/mdl_model_groups.go +++ b/internal/dao/internal/mdl_model_groups.go @@ -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. diff --git a/internal/dao/internal/opn_apps.go b/internal/dao/internal/opn_apps.go index 16d1398..bf9fbad 100644 --- a/internal/dao/internal/opn_apps.go +++ b/internal/dao/internal/opn_apps.go @@ -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. diff --git a/internal/dao/internal/plg_example_logs.go b/internal/dao/internal/plg_example_logs.go new file mode 100644 index 0000000..bb527de --- /dev/null +++ b/internal/dao/internal/plg_example_logs.go @@ -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) +} diff --git a/internal/dao/internal/sys_admin_users.go b/internal/dao/internal/sys_admin_users.go index a71c276..43d4f1b 100644 --- a/internal/dao/internal/sys_admin_users.go +++ b/internal/dao/internal/sys_admin_users.go @@ -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. diff --git a/internal/dao/internal/sys_agreement_acceptances.go b/internal/dao/internal/sys_agreement_acceptances.go index 3cede7e..e28e43a 100644 --- a/internal/dao/internal/sys_agreement_acceptances.go +++ b/internal/dao/internal/sys_agreement_acceptances.go @@ -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) } diff --git a/internal/dao/internal/sys_agreements.go b/internal/dao/internal/sys_agreements.go index c7559ec..e862273 100644 --- a/internal/dao/internal/sys_agreements.go +++ b/internal/dao/internal/sys_agreements.go @@ -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) } diff --git a/internal/dao/internal/tnt_tenants.go b/internal/dao/internal/tnt_tenants.go index 3cd9544..0fba4f9 100644 --- a/internal/dao/internal/tnt_tenants.go +++ b/internal/dao/internal/tnt_tenants.go @@ -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. diff --git a/internal/dao/plg_example_logs.go b/internal/dao/plg_example_logs.go new file mode 100644 index 0000000..65fd27a --- /dev/null +++ b/internal/dao/plg_example_logs.go @@ -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. diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 0acd8a6..b3d8de8 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -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 { diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go index 79000c8..2c13dfd 100644 --- a/internal/logic/common/validation.go +++ b/internal/logic/common/validation.go @@ -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) +} diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 4f54081..073572b 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -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, diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index bb17ada..76ad473 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -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-);用户显式传入才校验禁用词 + 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 diff --git a/internal/logic/tenant/code_generator.go b/internal/logic/tenant/code_generator.go new file mode 100644 index 0000000..2804a54 --- /dev/null +++ b/internal/logic/tenant/code_generator.go @@ -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-,满足正则 ^[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-" 形式的随机 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 +} diff --git a/internal/logic/tenant/invitation.go b/internal/logic/tenant/invitation.go index e95848b..c164979 100644 --- a/internal/logic/tenant/invitation.go +++ b/internal/logic/tenant/invitation.go @@ -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) diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 8d6e0f7..d7207f3 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -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{ diff --git a/internal/logic/tenant/member_import.go b/internal/logic/tenant/member_import.go index 78b7f0a..a6def2f 100644 --- a/internal/logic/tenant/member_import.go +++ b/internal/logic/tenant/member_import.go @@ -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) diff --git a/internal/logic/tenant/member_model_scope.go b/internal/logic/tenant/member_model_scope.go index 807aeca..daef672 100644 --- a/internal/logic/tenant/member_model_scope.go +++ b/internal/logic/tenant/member_model_scope.go @@ -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) // 验证用户属于当前租户 diff --git a/internal/logic/tenant/member_quota.go b/internal/logic/tenant/member_quota.go index a8d5ddb..a1c11aa 100644 --- a/internal/logic/tenant/member_quota.go +++ b/internal/logic/tenant/member_quota.go @@ -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 == "" { diff --git a/internal/logic/tenant/organization.go b/internal/logic/tenant/organization.go index df57143..fa9e3b4 100644 --- a/internal/logic/tenant/organization.go +++ b/internal/logic/tenant/organization.go @@ -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 diff --git a/internal/logic/tenant/team_guard.go b/internal/logic/tenant/team_guard.go new file mode 100644 index 0000000..511f1e6 --- /dev/null +++ b/internal/logic/tenant/team_guard.go @@ -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 +} diff --git a/internal/model/do/aud_request_logs.go b/internal/model/do/aud_request_logs.go index 60b7f18..6d6dbbd 100644 --- a/internal/model/do/aud_request_logs.go +++ b/internal/model/do/aud_request_logs.go @@ -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 中提取) } diff --git a/internal/model/do/mdl_model_groups.go b/internal/model/do/mdl_model_groups.go index 43e2579..55c4b8e 100644 --- a/internal/model/do/mdl_model_groups.go +++ b/internal/model/do/mdl_model_groups.go @@ -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 // 是否为新租户默认模型组,注册时自动关联 } diff --git a/internal/model/do/opn_apps.go b/internal/model/do/opn_apps.go index cb8caf8..0c4c008 100644 --- a/internal/model/do/opn_apps.go +++ b/internal/model/do/opn_apps.go @@ -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 } diff --git a/internal/model/do/plg_example_logs.go b/internal/model/do/plg_example_logs.go new file mode 100644 index 0000000..e846fff --- /dev/null +++ b/internal/model/do/plg_example_logs.go @@ -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 // +} diff --git a/internal/model/do/sys_agreement_acceptances.go b/internal/model/do/sys_agreement_acceptances.go index c95e300..1ff3233 100644 --- a/internal/model/do/sys_agreement_acceptances.go +++ b/internal/model/do/sys_agreement_acceptances.go @@ -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 // 接受时间 } diff --git a/internal/model/do/sys_agreements.go b/internal/model/do/sys_agreements.go index 2ff307f..8c43a1c 100644 --- a/internal/model/do/sys_agreements.go +++ b/internal/model/do/sys_agreements.go @@ -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 // 更新时间 } diff --git a/internal/model/do/tnt_tenants.go b/internal/model/do/tnt_tenants.go index d13ef3b..ec96349 100644 --- a/internal/model/do/tnt_tenants.go +++ b/internal/model/do/tnt_tenants.go @@ -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/邀请/额度) } diff --git a/internal/model/entity/aud_request_logs.go b/internal/model/entity/aud_request_logs.go index 6c30d51..bf20994 100644 --- a/internal/model/entity/aud_request_logs.go +++ b/internal/model/entity/aud_request_logs.go @@ -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 中提取) } diff --git a/internal/model/entity/mdl_model_groups.go b/internal/model/entity/mdl_model_groups.go index 6ffa199..a9d2909 100644 --- a/internal/model/entity/mdl_model_groups.go +++ b/internal/model/entity/mdl_model_groups.go @@ -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:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联 } diff --git a/internal/model/entity/opn_apps.go b/internal/model/entity/opn_apps.go index f99a09c..298c396 100644 --- a/internal/model/entity/opn_apps.go +++ b/internal/model/entity/opn_apps.go @@ -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 } diff --git a/internal/model/entity/plg_example_logs.go b/internal/model/entity/plg_example_logs.go new file mode 100644 index 0000000..937dc17 --- /dev/null +++ b/internal/model/entity/plg_example_logs.go @@ -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:""` // +} diff --git a/internal/model/entity/sys_agreement_acceptances.go b/internal/model/entity/sys_agreement_acceptances.go index 40babeb..d367a57 100644 --- a/internal/model/entity/sys_agreement_acceptances.go +++ b/internal/model/entity/sys_agreement_acceptances.go @@ -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:"接受时间"` // 接受时间 } diff --git a/internal/model/entity/sys_agreements.go b/internal/model/entity/sys_agreements.go index 836147d..88bfc6e 100644 --- a/internal/model/entity/sys_agreements.go +++ b/internal/model/entity/sys_agreements.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/tnt_tenants.go b/internal/model/entity/tnt_tenants.go index 12f4162..77adadc 100644 --- a/internal/model/entity/tnt_tenants.go +++ b/internal/model/entity/tnt_tenants.go @@ -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/邀请/额度) } diff --git a/migrations/000011_v0_1_13_version.sql b/migrations/000011_v0_1_13_version.sql new file mode 100644 index 0000000..92e7ea8 --- /dev/null +++ b/migrations/000011_v0_1_13_version.sql @@ -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; diff --git a/web/tenant/src/components/common/TeamLockedBanner.vue b/web/tenant/src/components/common/TeamLockedBanner.vue new file mode 100644 index 0000000..f208976 --- /dev/null +++ b/web/tenant/src/components/common/TeamLockedBanner.vue @@ -0,0 +1,19 @@ + + + diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts index a674e24..3fa90c0 100644 --- a/web/tenant/src/stores/tenant-auth.ts +++ b/web/tenant/src/stores/tenant-auth.ts @@ -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 { + 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 { try { await request.post('/tenant/auth/logout') @@ -197,6 +213,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { login, applyTokensFrom2FA, register, + refreshOrgInfo, logout, refreshTokens, loadFromStorage, diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index 0c3a845..6dce4b4 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -251,9 +251,9 @@ onBeforeUnmount(() => { {{ tenantInfo.name.charAt(0) || 'T' }} -
- {{ tenantInfo.name }} - {{ tenantInfo.code }} +
+ {{ tenantInfo.name }} + {{ tenantInfo.code }}
diff --git a/web/tenant/src/views/auth/RegisterPage.vue b/web/tenant/src/views/auth/RegisterPage.vue index b25d781..5650865 100644 --- a/web/tenant/src/views/auth/RegisterPage.vue +++ b/web/tenant/src/views/auth/RegisterPage.vue @@ -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 | null>(null) - const turnstileToken = ref('') let countdownTimer: ReturnType | null = null -const orgForm = reactive({ orgName: '', orgCode: '' }) const userForm = reactive({ email: '', code: '', @@ -36,11 +32,8 @@ const userForm = reactive({ agreed: false, }) -const orgErrors = reactive>({}) const userErrors = reactive>({}) -// 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() {
-

创建您的组织

-

几分钟即可开始使用 Team API

+

创建账号

+

几分钟即可开始使用,团队功能可后续开启

- -
-
-
- - {{ i + 1 }} -
- - {{ title }} - -
-
-
- - -
-
- - -

{{ orgErrors.orgName }}

-
- -
- - -

组织的唯一标识符,仅支持小写字母、数字和连字符

-

{{ orgErrors.orgCode }}

-
- - -
- - -
- -
-
-

{{ orgForm.orgName }}

-

{{ orgForm.orgCode }}

-
- -
- +
@@ -338,8 +221,8 @@ async function handleRegister() { placeholder="仅支持英文字母和数字,不能为纯数字" class="input pl-11" :class="{ 'input-error': userErrors.username }" - @input="validateUsernameRealtime" - /> + @input="validateUsernameRealtime" + />

{{ userErrors.username }}

@@ -441,7 +324,7 @@ async function handleRegister() {
diff --git a/web/tenant/src/views/settings/OrganizationPage.vue b/web/tenant/src/views/settings/OrganizationPage.vue index a9f3f94..3b92cb2 100644 --- a/web/tenant/src/views/settings/OrganizationPage.vue +++ b/web/tenant/src/views/settings/OrganizationPage.vue @@ -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(() => { + +
+
+
+
+

团队功能

+

启用后可邀请成员、创建 RAM 账号、分配成员额度

+
+ + {{ orgInfo.team_enabled ? '已启用' : '未启用' }} + +
+
+
+ + + + + +
+
+ -
+

组织详情

@@ -252,10 +398,11 @@ onMounted(() => {

组织代码

- - {{ orgInfo.code }} - - 只读 + {{ orgInfo.code }} +
diff --git a/web/tenant/src/views/team/InvitationsPage.vue b/web/tenant/src/views/team/InvitationsPage.vue index a7a526c..2a40383 100644 --- a/web/tenant/src/views/team/InvitationsPage.vue +++ b/web/tenant/src/views/team/InvitationsPage.vue @@ -1,11 +1,15 @@