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/api_key.go b/api/tenant/v1/api_key.go index c4175f8..b57aa15 100644 --- a/api/tenant/v1/api_key.go +++ b/api/tenant/v1/api_key.go @@ -23,13 +23,17 @@ type TenantApiKeyListRes struct { } type TenantApiKeyCreateReq struct { - g.Meta `path:"/api-keys" method:"post" mime:"json" tags:"租户控制台-API Key" summary:"创建API Key"` - Name string `json:"name" v:"required"` - Scope string `json:"scope"` - KeyType string `json:"key_type"` - ProjectID int64 `json:"project_id"` - ExpiresAt *gtime.Time `json:"expires_at"` - ModelNames []string `json:"model_names"` + g.Meta `path:"/api-keys" method:"post" mime:"json" tags:"租户控制台-API Key" summary:"创建API Key"` + Name string `json:"name" v:"required"` + Scope string `json:"scope"` + KeyType string `json:"key_type"` + ProjectID int64 `json:"project_id"` + ExpiresAt *gtime.Time `json:"expires_at"` + RateLimitQps *int `json:"rate_limit_qps"` + RateLimitConcurrency *int `json:"rate_limit_concurrency"` + IpWhitelist *[]string `json:"ip_whitelist"` + TotalQuota *float64 `json:"total_quota"` + ModelNames []string `json:"model_names"` } type TenantApiKeyCreateRes struct { @@ -57,6 +61,7 @@ type TenantApiKeyUpdateReq struct { ExpiresAt *gtime.Time `json:"expires_at"` RateLimitQps *int `json:"rate_limit_qps"` RateLimitConcurrency *int `json:"rate_limit_concurrency"` + IpWhitelist *[]string `json:"ip_whitelist"` TotalQuota *float64 `json:"total_quota"` ModelNames []string `json:"model_names"` } 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/member.go b/api/tenant/v1/member.go index e4c8b47..3e610cb 100644 --- a/api/tenant/v1/member.go +++ b/api/tenant/v1/member.go @@ -81,7 +81,7 @@ type TenantInvitationRevokeRes struct{} // TenantInviteInfoReq 查询邀请信息(公开接口,免认证) type TenantInviteInfoReq struct { - g.Meta `path:"/members/invite-info" method:"get" mime:"json" tags:"租户控制台-成员管理" summary:"查询邀请信息"` + g.Meta `path:"/members/invite-info" method:"get" mime:"json" tags:"租户控制台-成员管理" summary:"查询邀请信息" group:"public" middleware:"-"` Code string `json:"code" in:"query" v:"required#请提供邀请码" dc:"邀请码"` } 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/api/tenant/v1/project_tenant.go b/api/tenant/v1/project_tenant.go index 062861f..d1e5707 100644 --- a/api/tenant/v1/project_tenant.go +++ b/api/tenant/v1/project_tenant.go @@ -90,12 +90,16 @@ type TenantProjectApiKeyListRes struct { } type TenantProjectApiKeyCreateReq struct { - g.Meta `path:"/projects/{id}/api-keys" method:"post" mime:"json" tags:"租户控制台-项目" summary:"项目密钥创建"` - Id int64 `json:"id" in:"path" v:"required|min:1"` - Name string `json:"name" v:"required"` - Scope string `json:"scope" d:"full"` - ExpiresAt *gtime.Time `json:"expires_at"` - ModelNames []string `json:"model_names"` + g.Meta `path:"/projects/{id}/api-keys" method:"post" mime:"json" tags:"租户控制台-项目" summary:"项目密钥创建"` + Id int64 `json:"id" in:"path" v:"required|min:1"` + Name string `json:"name" v:"required"` + Scope string `json:"scope" d:"full"` + ExpiresAt *gtime.Time `json:"expires_at"` + RateLimitQps *int `json:"rate_limit_qps"` + RateLimitConcurrency *int `json:"rate_limit_concurrency"` + IpWhitelist *[]string `json:"ip_whitelist"` + TotalQuota *float64 `json:"total_quota"` + ModelNames []string `json:"model_names"` } type TenantProjectApiKeyCreateRes struct { 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/cmd/cmd.go b/internal/cmd/cmd.go index ceb530d..d6c0059 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -144,6 +144,9 @@ var ( task.RunPendingTasks(ctx) return nil }) + cs.Register("project_budget_check", "*/5 * * * *", func(ctx context.Context) error { + return tenant.CheckBudgetExhausted(ctx) + }) cs.Register("usage_log_cleanup", "0 3 * * *", func(ctx context.Context) error { retentionDays := common.Config().GetInt(ctx, "usage_log_retention_days") if retentionDays == 0 { @@ -255,7 +258,7 @@ var ( // Admin — public endpoints use g.Meta middleware:"-" to skip auth group.Group("/admin", func(g *ghttp.RouterGroup) { - g.Middleware(middleware.DemoMode, middleware.AdminAuth, middleware.OperationLog) + g.Middleware(middleware.DemoMode, middleware.AdminAuth, middleware.AdminPermissionGuard, middleware.OperationLog) g.Bind(adminController.NewV1()) }) diff --git a/internal/cmd/cmd_reset_pwd.go b/internal/cmd/cmd_reset_pwd.go index 5e6c2ca..128cf18 100644 --- a/internal/cmd/cmd_reset_pwd.go +++ b/internal/cmd/cmd_reset_pwd.go @@ -24,7 +24,7 @@ var resetPwdCmd = gcmd.Command{ if password == "" { fmt.Println("用法: team-api reset-pwd -u <用户名> -p <新密码>") fmt.Println(" -u 管理员用户名(默认 admin)") - fmt.Println(" -p 新密码(必填,至少8位,需含大小写字母和数字)") + fmt.Println(" -p 新密码(必填,至少8位,需含字母和数字)") return nil } diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 10f152e..480fc00 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -57,6 +57,7 @@ var ( ErrKeyExpired = gerror.NewCode(gcode.New(CodeKeyExpired, MsgKeyExpired, nil), MsgKeyExpired) ErrKeyDisabled = gerror.NewCode(gcode.New(CodeKeyDisabled, "API Key 已禁用", nil), "API Key 已禁用") ErrTenantSuspended = gerror.NewCode(gcode.New(CodeTenantSuspended, MsgTenantSuspended, nil), MsgTenantSuspended) + ErrProjectNotActive = gerror.NewCode(gcode.New(CodeProjectNotActive, MsgProjectNotActive, nil), MsgProjectNotActive) ErrChannelUnavailable = gerror.NewCode(gcode.New(CodeChannelUnavailable, MsgChannelUnavailable, nil), MsgChannelUnavailable) ErrModelDisabled = gerror.NewCode(gcode.New(CodeModelDisabled, MsgModelDisabled, nil), MsgModelDisabled) ErrTotpRequired = gerror.NewCode(gcode.New(CodeTotpRequired, MsgTotpRequired, nil), MsgTotpRequired) @@ -178,6 +179,7 @@ const ( CodeGlobalRateLimitExceeded = 10091 // 全局限流 CodeForbiddenWord = 10096 // 名称包含禁用词 CodeEmailExists = 10097 // 邮箱已被使用 + CodeInvalidTenantName = 10098 // 组织名称长度不合法 // Setup errors CodeSetupCompleted = 10078 @@ -226,6 +228,9 @@ const ( CodeUpdateReplaceFailed = 10415 CodeUpdateRollbackFailed = 10416 CodeUpdateNotSupported = 10417 + + // Team feature gating + CodeTeamNotEnabled = 10418 ) // Common error messages @@ -256,6 +261,7 @@ const ( MsgUsernameExists = "用户名已存在" MsgEmailExists = "该邮箱已被使用" MsgTenantCodeExists = "租户代码已存在" + MsgTeamNotEnabled = "团队功能未启用,请先设置组织代码" MsgSessionExpired = "会话已过期" MsgTokenExpired = "Token 已过期" MsgTokenRevoked = "Token 已被撤销" @@ -345,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/handler/relay/relay.go b/internal/handler/relay/relay.go index 13378d2..780ce98 100644 --- a/internal/handler/relay/relay.go +++ b/internal/handler/relay/relay.go @@ -14,6 +14,7 @@ import ( "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/middleware" relay_common "github.com/qianfree/team-api/relay/common" + relay_constant "github.com/qianfree/team-api/relay/constant" "github.com/qianfree/team-api/relay/handler" ) @@ -323,6 +324,15 @@ func HandleGeminiGenerateContent(r *ghttp.Request) { func HandleModels(r *ghttp.Request) { rc := buildRelayContext(r) // 获取 TenantID + if !billingProvider.CheckScope(rc.Scope, "") { + handler.WriteRelayError(r.Response.Writer, relay_constant.NewAuthError("API key scope denied")) + return + } + if !billingProvider.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + handler.WriteRelayError(r.Response.Writer, relay_constant.NewAuthError("IP address is not allowed")) + return + } + resp, err := handler.HandleModels(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, dataProvider) if err != nil { r.Response.WriteJsonExit(g.Map{ @@ -339,6 +349,15 @@ func HandleModelDetail(r *ghttp.Request) { rc := buildRelayContext(r) modelName := r.Get("model_id").String() + if !billingProvider.CheckScope(rc.Scope, "") { + handler.WriteRelayError(r.Response.Writer, relay_constant.NewAuthError("API key scope denied")) + return + } + if !billingProvider.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + handler.WriteRelayError(r.Response.Writer, relay_constant.NewAuthError("IP address is not allowed")) + return + } + if modelName == "" { r.Response.WriteJsonExit(g.Map{ "error": g.Map{"type": "invalid_request_error", "message": "model_id is required"}, @@ -377,6 +396,15 @@ func HandleModelDetail(r *ghttp.Request) { func HandleGeminiModels(r *ghttp.Request) { rc := buildRelayContext(r) + if !billingProvider.CheckScope(rc.Scope, "") { + handler.WriteGeminiRelayError(r.Response.Writer, relay_constant.NewAuthError("API key scope denied")) + return + } + if !billingProvider.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + handler.WriteGeminiRelayError(r.Response.Writer, relay_constant.NewAuthError("IP address is not allowed")) + return + } + resp, err := handler.HandleGeminiModels(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, dataProvider) if err != nil { handler.WriteGeminiRelayError(r.Response.Writer, err) @@ -391,6 +419,15 @@ func HandleGeminiModelDetail(r *ghttp.Request) { rc := buildRelayContext(r) modelName := r.Get("model").String() + if !billingProvider.CheckScope(rc.Scope, "") { + handler.WriteGeminiRelayError(r.Response.Writer, relay_constant.NewAuthError("API key scope denied")) + return + } + if !billingProvider.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + handler.WriteGeminiRelayError(r.Response.Writer, relay_constant.NewAuthError("IP address is not allowed")) + return + } + if modelName == "" { r.Response.WriteHeader(400) r.Response.WriteJson(g.Map{ @@ -411,14 +448,19 @@ func HandleGeminiModelDetail(r *ghttp.Request) { // buildRelayContext 从 GoFrame 请求中构建 relay 上下文 func buildRelayContext(r *ghttp.Request) *handler.RelayContext { return &handler.RelayContext{ - TenantID: middleware.GetTenantID(r.Context()), - UserID: middleware.GetUserID(r.Context()), - ApiKeyID: middleware.GetApiKeyID(r.Context()), - ProjectID: middleware.GetProjectID(r.Context()), - RequestID: r.GetCtxVar("RequestId").String(), - Writer: r.Response.Writer, - Scope: r.GetCtxVar("ApiKeyScope").String(), - ClientIP: r.GetClientIp(), + TenantID: middleware.GetTenantID(r.Context()), + UserID: middleware.GetUserID(r.Context()), + ApiKeyID: middleware.GetApiKeyID(r.Context()), + ProjectID: middleware.GetProjectID(r.Context()), + RequestID: r.GetCtxVar("RequestId").String(), + Writer: r.Response.Writer, + Scope: r.GetCtxVar("ApiKeyScope").String(), + ClientIP: r.GetClientIp(), + KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(), + KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(), + KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(), + KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(), + KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(), } } @@ -645,12 +687,18 @@ func HandleRerank(r *ghttp.Request) { // HandleRealtime 处理 /v1/realtime(WebSocket) func HandleRealtime(r *ghttp.Request) { rc := &handler.RealtimeContext{ - TenantID: middleware.GetTenantID(r.Context()), - UserID: middleware.GetUserID(r.Context()), - ApiKeyID: middleware.GetApiKeyID(r.Context()), - ProjectID: middleware.GetProjectID(r.Context()), - RequestID: r.GetCtxVar("RequestId").String(), - ClientIP: r.GetClientIp(), + TenantID: middleware.GetTenantID(r.Context()), + UserID: middleware.GetUserID(r.Context()), + ApiKeyID: middleware.GetApiKeyID(r.Context()), + ProjectID: middleware.GetProjectID(r.Context()), + RequestID: r.GetCtxVar("RequestId").String(), + ClientIP: r.GetClientIp(), + Scope: r.GetCtxVar("ApiKeyScope").String(), + KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(), + KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(), + KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(), + KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(), + KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(), } _, _, err := handler.HandleRealtime(r.Response.Writer, r.Request, rc, dataProvider, billingProvider) diff --git a/internal/handler/relay/task.go b/internal/handler/relay/task.go index fcbb64f..39f53bd 100644 --- a/internal/handler/relay/task.go +++ b/internal/handler/relay/task.go @@ -36,14 +36,19 @@ func HandleTaskSubmit(r *ghttp.Request) { } rc := &relay_handler.TaskRelayContext{ - TenantID: middleware.GetTenantID(r.Context()), - UserID: middleware.GetUserID(r.Context()), - ApiKeyID: middleware.GetApiKeyID(r.Context()), - ProjectID: middleware.GetProjectID(r.Context()), - RequestID: r.GetCtxVar("RequestId").String(), - Writer: r.Response.Writer, - Scope: r.GetCtxVar("ApiKeyScope").String(), - ClientIP: r.GetClientIp(), + TenantID: middleware.GetTenantID(r.Context()), + UserID: middleware.GetUserID(r.Context()), + ApiKeyID: middleware.GetApiKeyID(r.Context()), + ProjectID: middleware.GetProjectID(r.Context()), + RequestID: r.GetCtxVar("RequestId").String(), + Writer: r.Response.Writer, + Scope: r.GetCtxVar("ApiKeyScope").String(), + ClientIP: r.GetClientIp(), + KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(), + KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(), + KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(), + KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(), + KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(), } // 选择渠道 @@ -206,14 +211,19 @@ func HandleMjSubmit(r *ghttp.Request) { } rc := &relay_handler.TaskRelayContext{ - TenantID: middleware.GetTenantID(r.Context()), - UserID: middleware.GetUserID(r.Context()), - ApiKeyID: middleware.GetApiKeyID(r.Context()), - ProjectID: middleware.GetProjectID(r.Context()), - RequestID: r.GetCtxVar("RequestId").String(), - Writer: r.Response.Writer, - Scope: r.GetCtxVar("ApiKeyScope").String(), - ClientIP: r.GetClientIp(), + TenantID: middleware.GetTenantID(r.Context()), + UserID: middleware.GetUserID(r.Context()), + ApiKeyID: middleware.GetApiKeyID(r.Context()), + ProjectID: middleware.GetProjectID(r.Context()), + RequestID: r.GetCtxVar("RequestId").String(), + Writer: r.Response.Writer, + Scope: r.GetCtxVar("ApiKeyScope").String(), + ClientIP: r.GetClientIp(), + KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(), + KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(), + KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(), + KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(), + KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(), } channelMeta, err := selectTaskChannel(r, body) @@ -256,13 +266,19 @@ func HandleAliImageSubmit(r *ghttp.Request) { } rc := &relay_handler.TaskRelayContext{ - TenantID: middleware.GetTenantID(r.Context()), - UserID: middleware.GetUserID(r.Context()), - ApiKeyID: middleware.GetApiKeyID(r.Context()), - RequestID: r.GetCtxVar("RequestId").String(), - Writer: r.Response.Writer, - Scope: r.GetCtxVar("ApiKeyScope").String(), - ClientIP: r.GetClientIp(), + TenantID: middleware.GetTenantID(r.Context()), + UserID: middleware.GetUserID(r.Context()), + ApiKeyID: middleware.GetApiKeyID(r.Context()), + ProjectID: middleware.GetProjectID(r.Context()), + RequestID: r.GetCtxVar("RequestId").String(), + Writer: r.Response.Writer, + Scope: r.GetCtxVar("ApiKeyScope").String(), + ClientIP: r.GetClientIp(), + KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(), + KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(), + KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(), + KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(), + KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(), } channelMeta, err := selectTaskChannel(r, body) diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index 4035b36..c25b639 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -3,10 +3,12 @@ package admin import ( "context" "encoding/json" + "fmt" do "github.com/qianfree/team-api/internal/model/do" "strings" "time" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -55,12 +57,58 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } + // Check account lockout + if user.LockedUntil != nil && time.Now().Before(user.LockedUntil.Time) { + remaining := time.Until(user.LockedUntil.Time).Minutes() + _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "账号已锁定") + return nil, common.NewBusinessError(consts.CodeAccountLocked, + fmt.Sprintf("账号已被锁定,%d 分钟后重试", int(remaining))) + } + // Verify password if !crypto.VerifyPassword(req.Password, user.PasswordHash) { _ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "密码错误") + + // 原子递增失败次数 + updateData := do.SysAdminUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")} + failedAttempts := user.FailedAttempts + 1 + + if failedAttempts >= 5 { + lockedUntil := time.Now().Add(30 * time.Minute) + updateData.LockedUntil = gtime.NewFromTime(lockedUntil) + _, err := dao.SysAdminUsers.Ctx(ctx). + Where("id", user.Id). + Data(updateData).Update() + if err != nil { + g.Log().Errorf(ctx, "更新管理员账号锁定状态失败: %v", err) + } + return nil, common.NewBusinessError(consts.CodeAccountLocked, + "连续 5 次密码错误,账号已锁定 30 分钟") + } + + _, err := dao.SysAdminUsers.Ctx(ctx). + Where("id", user.Id). + Data(updateData).Update() + if err != nil { + g.Log().Errorf(ctx, "更新管理员登录失败次数失败: %v", err) + } + return nil, common.NewBusinessError(consts.CodeInvalidCredentials, consts.MsgInvalidCredentials) } + // Reset failed attempts on successful login + if user.FailedAttempts > 0 { + _, err := dao.SysAdminUsers.Ctx(ctx). + Where("id", user.Id). + Data(do.SysAdminUsers{ + FailedAttempts: 0, + LockedUntil: nil, + }).Update() + if err != nil { + g.Log().Errorf(ctx, "重置管理员登录失败次数失败: %v", err) + } + } + // Check if 2FA is enabled if user.TotpEnabled { provisionalToken, err := common.GenerateProvisionalToken(ctx, user.Id, "admin", user.Role, 0) diff --git a/internal/logic/admin/permission.go b/internal/logic/admin/permission.go index 63a4c8e..364b7f1 100644 --- a/internal/logic/admin/permission.go +++ b/internal/logic/admin/permission.go @@ -72,11 +72,46 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ "audit:view", "audit:export", "audit:read_sensitive", }, }, + { + Name: "operation", + Label: "内容运营", + Permissions: []string{ + "operation:view", "operation:edit", + }, + }, + { + Name: "support", + Label: "客户支持", + Permissions: []string{ + "support:view", "support:reply", "support:edit", + }, + }, + { + Name: "monitor", + Label: "监控告警", + Permissions: []string{ + "monitor:view", "monitor:edit", + }, + }, { Name: "system", Label: "系统设置", Permissions: []string{ - "system:view", "system:edit", + "system:view", "system:edit", "system:update", "system:plugin", + }, + }, + { + Name: "dashboard", + Label: "仪表盘", + Permissions: []string{ + "dashboard:view", + }, + }, + { + Name: "task", + Label: "任务管理", + Permissions: []string{ + "task:view", "task:edit", }, }, { @@ -104,7 +139,7 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ Name: "redemption", Label: "兑换码管理", Permissions: []string{ - "redemption:view", "redemption:create", + "redemption:view", "redemption:create", "redemption:edit", }, }, } 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/billing/api_key_quota.go b/internal/logic/billing/api_key_quota.go new file mode 100644 index 0000000..0f36dc8 --- /dev/null +++ b/internal/logic/billing/api_key_quota.go @@ -0,0 +1,57 @@ +package billing + +import ( + "context" + "time" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/internal/dao" +) + +// CheckApiKeyQuota checks whether an API key has enough remaining quota. +// total_quota <= 0 means unlimited. +func CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float64) error { + if apiKeyID <= 0 { + return nil + } + + var row *struct { + TotalQuota float64 `json:"total_quota"` + UsedQuota float64 `json:"used_quota"` + } + err := dao.ApiKeys.Ctx(ctx). + Where("id", apiKeyID). + Fields("COALESCE(total_quota, 0) AS total_quota, COALESCE(used_quota, 0) AS used_quota"). + Scan(&row) + if err != nil { + g.Log().Warningf(ctx, "api_key_quota: load failed apiKey=%d: %v, skipping check", apiKeyID, err) + return nil + } + if row == nil || row.TotalQuota <= 0 { + return nil + } + if row.UsedQuota+preDeductAmount > row.TotalQuota { + return gerror.New("API key quota exceeded") + } + + return nil +} + +// IncrApiKeyQuotaUsed increments an API key's used quota after settlement. +func IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount float64) { + if apiKeyID <= 0 || amount <= 0 { + return + } + + go func() { + bgCtx := context.Background() + _, err := g.DB().Exec(bgCtx, + "UPDATE api_keys SET used_quota = COALESCE(used_quota, 0) + $1, updated_at = $2 WHERE id = $3", + amount, time.Now(), apiKeyID) + if err != nil { + g.Log().Errorf(bgCtx, "api_key_quota: incr db failed apiKey=%d amount=%f: %v", apiKeyID, amount, err) + } + }() +} diff --git a/internal/logic/billing/provider.go b/internal/logic/billing/provider.go index 4779090..f1eb753 100644 --- a/internal/logic/billing/provider.go +++ b/internal/logic/billing/provider.go @@ -28,14 +28,14 @@ func (b *BillingProviderImpl) PreDeduct(ctx context.Context, tenantID int64, mod return amount, nil } -func (b *BillingProviderImpl) Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) error { +func (b *BillingProviderImpl) Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) (*common.SettlementResult, error) { inputTokens, outputTokens := 0, 0 if usage != nil { inputTokens = usage.PromptTokens outputTokens = usage.CompletionTokens } - _, err := Settle(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, inputTokens, outputTokens, preDeductAmount, projectID) - return err + result, err := Settle(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, inputTokens, outputTokens, preDeductAmount, projectID) + return toCommonSettlementResult(result), err } func (b *BillingProviderImpl) SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, @@ -46,6 +46,72 @@ func (b *BillingProviderImpl) SettleWithUsage(ctx context.Context, tenantID, use if err != nil { return nil } + return toCommonSettlementResult(result) +} + +func (b *BillingProviderImpl) SettleFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error { + return SettleFailed(ctx, tenantID, requestID, preDeductAmount) +} + +func (b *BillingProviderImpl) SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) (*common.SettlementResult, error) { + confirmedInput, confirmedOutput := 0, 0 + if usage != nil { + confirmedInput = usage.PromptTokens + confirmedOutput = usage.CompletionTokens + } + result, err := SettleStreamInterrupted(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, confirmedInput, confirmedOutput, preDeductAmount, projectID) + return toCommonSettlementResult(result), err +} + +func (b *BillingProviderImpl) CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (bool, string, int, int, int64) { + result := CheckRateLimitWithKeyLimit(ctx, LoadRateLimitConfig(ctx), tenantID, userID, apiKeyID, keyQPS) + return result.Allowed, result.LimitLevel, result.Limit, result.Remaining, result.ResetAt +} + +func (b *BillingProviderImpl) AcquireConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) bool { + return AcquireConcurrent(ctx, LoadRateLimitConfig(ctx), tenantID, userID, apiKeyID, modelName) +} + +func (b *BillingProviderImpl) ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) { + ReleaseConcurrent(ctx, tenantID, userID, apiKeyID, modelName) +} + +func (b *BillingProviderImpl) AcquireApiKeyConcurrent(ctx context.Context, apiKeyID int64, limit int) bool { + return AcquireApiKeyConcurrent(ctx, apiKeyID, limit) +} + +func (b *BillingProviderImpl) ReleaseApiKeyConcurrent(ctx context.Context, apiKeyID int64) { + ReleaseApiKeyConcurrent(ctx, apiKeyID) +} + +func (b *BillingProviderImpl) CheckScope(scope string, relayMode string) bool { + return CheckScope(scope, relayMode) +} + +func (b *BillingProviderImpl) CheckIPWhitelist(whitelist string, clientIP string) bool { + return CheckIPWhitelist(whitelist, clientIP) +} + +func (b *BillingProviderImpl) CheckMemberQuota(ctx context.Context, tenantID, userID int64, preDeductAmount float64) error { + return CheckMemberQuota(ctx, tenantID, userID, preDeductAmount) +} + +func (b *BillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float64) error { + return CheckApiKeyQuota(ctx, apiKeyID, preDeductAmount) +} + +func (b *BillingProviderImpl) IncrMemberQuotaUsed(ctx context.Context, tenantID, userID int64, amount float64) { + IncrMemberQuotaUsed(ctx, tenantID, userID, amount) +} + +func (b *BillingProviderImpl) IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount float64) { + IncrApiKeyQuotaUsed(ctx, apiKeyID, amount) +} + +func toCommonSettlementResult(result *SettlementResult) *common.SettlementResult { + if result == nil { + return nil + } var inputCost, outputCost, cacheCreationCost, cacheReadCost float64 if result.CostBreakdown != nil { inputCost = result.CostBreakdown.InputCost @@ -72,46 +138,3 @@ func (b *BillingProviderImpl) SettleWithUsage(ctx context.Context, tenantID, use RateMultiplier: result.RateMultiplier, } } - -func (b *BillingProviderImpl) SettleFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error { - return SettleFailed(ctx, tenantID, requestID, preDeductAmount) -} - -func (b *BillingProviderImpl) SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) error { - confirmedInput, confirmedOutput := 0, 0 - if usage != nil { - confirmedInput = usage.PromptTokens - confirmedOutput = usage.CompletionTokens - } - _, err := SettleStreamInterrupted(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, confirmedInput, confirmedOutput, preDeductAmount, projectID) - return err -} - -func (b *BillingProviderImpl) CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64) (bool, string, int, int64) { - result := CheckRateLimit(ctx, LoadRateLimitConfig(ctx), tenantID, userID, apiKeyID) - return result.Allowed, result.LimitLevel, result.Remaining, result.ResetAt -} - -func (b *BillingProviderImpl) AcquireConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) bool { - return AcquireConcurrent(ctx, LoadRateLimitConfig(ctx), tenantID, userID, apiKeyID, modelName) -} - -func (b *BillingProviderImpl) ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) { - ReleaseConcurrent(ctx, tenantID, userID, apiKeyID, modelName) -} - -func (b *BillingProviderImpl) CheckScope(scope string, relayMode string) bool { - return CheckScope(scope, relayMode) -} - -func (b *BillingProviderImpl) CheckIPWhitelist(whitelist string, clientIP string) bool { - return CheckIPWhitelist(whitelist, clientIP) -} - -func (b *BillingProviderImpl) CheckMemberQuota(ctx context.Context, tenantID, userID int64, preDeductAmount float64) error { - return CheckMemberQuota(ctx, tenantID, userID, preDeductAmount) -} - -func (b *BillingProviderImpl) IncrMemberQuotaUsed(ctx context.Context, tenantID, userID int64, amount float64) { - IncrMemberQuotaUsed(ctx, tenantID, userID, amount) -} diff --git a/internal/logic/billing/ratelimit.go b/internal/logic/billing/ratelimit.go index 157146d..1143deb 100644 --- a/internal/logic/billing/ratelimit.go +++ b/internal/logic/billing/ratelimit.go @@ -109,6 +109,13 @@ var levelNames = [5]string{"", "system", "tenant", "user", "key"} // CheckRateLimit 检查 QPS 限流(四级:系统→租户→用户→Key) // 使用单个 Lua 脚本在一次 EVAL 中完成所有检查,替代原来的 4 次串行 INCR。 func CheckRateLimit(ctx context.Context, config RateLimitConfig, tenantID, userID, apiKeyID int64) *RateLimitResult { + return CheckRateLimitWithKeyLimit(ctx, config, tenantID, userID, apiKeyID, 0) +} + +// CheckRateLimitWithKeyLimit 检查 QPS 限流,可用 keyQPS 覆盖全局 Key 级默认值。 +func CheckRateLimitWithKeyLimit(ctx context.Context, config RateLimitConfig, tenantID, userID, apiKeyID int64, keyQPS int) *RateLimitResult { + config = withKeyQPS(config, keyQPS) + now := time.Now() windowStart := now.Unix() @@ -169,6 +176,13 @@ func CheckRateLimit(ctx context.Context, config RateLimitConfig, tenantID, userI } } +func withKeyQPS(config RateLimitConfig, keyQPS int) RateLimitConfig { + if keyQPS > 0 { + config.KeyQPS = keyQPS + } + return config +} + // --------------------------------------------------------------------------- // 并发限流:单个 Lua 脚本完成 2 级获取 + 失败回滚(租户 + 模型) // --------------------------------------------------------------------------- @@ -259,6 +273,56 @@ func ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, mo 300) // EXPIRE 秒数 } +var acquireApiKeyConcurrentLua = ` +local limit = tonumber(ARGV[1]) +local expire = tonumber(ARGV[2]) +if limit <= 0 then return 1 end +local count = redis.call("INCR", KEYS[1]) +if count == 1 then + redis.call("EXPIRE", KEYS[1], expire) +end +if count > limit then + local v = redis.call("DECR", KEYS[1]) + if v < 0 then + redis.call("SET", KEYS[1], 0) + redis.call("EXPIRE", KEYS[1], expire) + end + return 0 +end +return 1 +` + +var releaseApiKeyConcurrentLua = ` +local expire = tonumber(ARGV[1]) +local v = redis.call("DECR", KEYS[1]) +if v < 0 then + redis.call("SET", KEYS[1], 0) + redis.call("EXPIRE", KEYS[1], expire) +end +return 1 +` + +// AcquireApiKeyConcurrent 获取 API Key 级并发许可。limit <= 0 表示不限制。 +func AcquireApiKeyConcurrent(ctx context.Context, apiKeyID int64, limit int) bool { + if limit <= 0 { + return true + } + + key := fmt.Sprintf("conc:apikey:%d", apiKeyID) + result, err := g.Redis().Do(ctx, "EVAL", acquireApiKeyConcurrentLua, 1, key, limit, 300) + if err != nil { + return true + } + + return result.Int() == 1 +} + +// ReleaseApiKeyConcurrent 释放 API Key 级并发许可。 +func ReleaseApiKeyConcurrent(ctx context.Context, apiKeyID int64) { + key := fmt.Sprintf("conc:apikey:%d", apiKeyID) + g.Redis().Do(ctx, "EVAL", releaseApiKeyConcurrentLua, 1, key, 300) +} + // getTenantConcurrencyLimit 获取租户并发上限。 // 优先从 Redis 缓存读取 tnt_tenants.max_concurrency,缓存 300 秒; // 缓存未命中则查询数据库并回填。0 表示不限。 diff --git a/internal/logic/billing/ratelimit_test.go b/internal/logic/billing/ratelimit_test.go index f13a0bb..c96a93f 100644 --- a/internal/logic/billing/ratelimit_test.go +++ b/internal/logic/billing/ratelimit_test.go @@ -73,6 +73,19 @@ func TestRateLimitHeaders_Nil(t *testing.T) { } } +func TestRateLimitConfigWithKeyLimit(t *testing.T) { + cfg := DefaultRateLimitConfig + cfg = withKeyQPS(cfg, 7) + if cfg.KeyQPS != 7 { + t.Errorf("expected KeyQPS override 7, got %d", cfg.KeyQPS) + } + + cfg = withKeyQPS(cfg, 0) + if cfg.KeyQPS != 7 { + t.Errorf("expected KeyQPS unchanged for zero override, got %d", cfg.KeyQPS) + } +} + func TestBillingProviderImpl_NilPreDeduct(t *testing.T) { // BillingProviderImpl.SettleFailed with 0 amount should not panic provider := &BillingProviderImpl{} @@ -92,7 +105,11 @@ func TestBillingProviderImpl_ScopeCheck(t *testing.T) { }{ {"full", "chat_completions", true}, {"chat_only", "embeddings", false}, + {"chat_only", "responses", true}, + {"chat_only", "gemini_generate_content", true}, {"embeddings_only", "embeddings", true}, + {"images_only", "images_edits", true}, + {"audio_only", "audio_speech", true}, } for _, tt := range tests { diff --git a/internal/logic/billing/scope.go b/internal/logic/billing/scope.go index 34bef8b..56024b5 100644 --- a/internal/logic/billing/scope.go +++ b/internal/logic/billing/scope.go @@ -26,13 +26,23 @@ func CheckScope(scope string, relayMode string) bool { // read_only 仅允许 GET /models return relayMode == "" case ScopeChatOnly, "chat": - return relayMode == "chat_completions" || relayMode == "completions" || relayMode == "claude_messages" + return relayMode == "chat_completions" || + relayMode == "completions" || + relayMode == "responses" || + relayMode == "claude_messages" || + relayMode == "gemini_generate_content" || + relayMode == "realtime" case ScopeEmbeddingsOnly, "embedding": return relayMode == "embeddings" case ScopeImagesOnly, "image": - return relayMode == "images_generations" + return relayMode == "images_generations" || relayMode == "images_edits" case ScopeAudioOnly, "audio": - return relayMode == "audio" || relayMode == "tts" || relayMode == "stt" + return relayMode == "audio" || + relayMode == "tts" || + relayMode == "stt" || + relayMode == "audio_speech" || + relayMode == "audio_transcriptions" || + relayMode == "audio_translations" default: // custom scope: 以逗号分隔的模式列表 allowed := strings.Split(scope, ",") diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index b473f7f..251ab8a 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -79,6 +79,23 @@ func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID in return estimatedCost, nil } +func (b *TaskBillingProviderImpl) CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (bool, string, int, int, int64) { + result := CheckRateLimitWithKeyLimit(ctx, LoadRateLimitConfig(ctx), tenantID, userID, apiKeyID, keyQPS) + return result.Allowed, result.LimitLevel, result.Limit, result.Remaining, result.ResetAt +} + +func (b *TaskBillingProviderImpl) AcquireApiKeyConcurrent(ctx context.Context, apiKeyID int64, limit int) bool { + return AcquireApiKeyConcurrent(ctx, apiKeyID, limit) +} + +func (b *TaskBillingProviderImpl) ReleaseApiKeyConcurrent(ctx context.Context, apiKeyID int64) { + ReleaseApiKeyConcurrent(ctx, apiKeyID) +} + +func (b *TaskBillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float64) error { + return CheckApiKeyQuota(ctx, apiKeyID, preDeductAmount) +} + // SettleTaskSuccess 任务成功结算(含计费快照) // totalTokens/completionTokens: 上游返回的 token 用量 // ratios: 提交时保存的计费比率(如 video_input 折扣) @@ -303,6 +320,10 @@ func (b *TaskBillingProviderImpl) SettleTaskFailed(ctx context.Context, tenantID return SettleFailed(ctx, tenantID, requestID, preDeductAmount) } +func (b *TaskBillingProviderImpl) IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount float64) { + IncrApiKeyQuotaUsed(ctx, apiKeyID, amount) +} + // AdjustTaskBilling 调整预扣金额 func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost float64) (float64, error) { diff := newEstimatedCost - preDeductAmount diff --git a/internal/logic/common/agreement.go b/internal/logic/common/agreement.go index 7c9a33b..9a2e4a9 100644 --- a/internal/logic/common/agreement.go +++ b/internal/logic/common/agreement.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/model/do" @@ -158,10 +157,9 @@ func PublishAgreementTx(ctx context.Context, tx gdb.TX, agreementID int64, code Where("code", code). Where("is_current", true). Where("id !=", agreementID). - Data(g.Map{ - "is_current": false, - "status": "archived", - "updated_at": gtime.Now(), + Data(do.SysAgreements{ + IsCurrent: false, + Status: "archived", }). Update() if err != nil { @@ -171,11 +169,10 @@ func PublishAgreementTx(ctx context.Context, tx gdb.TX, agreementID int64, code // 新版本发布 _, err = dao.SysAgreements.Ctx(ctx). Where("id", agreementID). - Data(g.Map{ - "status": "published", - "is_current": true, - "published_at": gtime.Now(), - "updated_at": gtime.Now(), + Data(do.SysAgreements{ + Status: "published", + IsCurrent: true, + PublishedAt: gtime.Now(), }). Update() return err diff --git a/internal/logic/common/register_rate_limit_test.go b/internal/logic/common/register_rate_limit_test.go index 3c29ae3..eaab68a 100644 --- a/internal/logic/common/register_rate_limit_test.go +++ b/internal/logic/common/register_rate_limit_test.go @@ -10,9 +10,30 @@ import ( "github.com/qianfree/team-api/internal/consts" ) +func requireRegisterRateLimitRedis(t *testing.T, ctx context.Context) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Skipf("Redis adapter is not configured: %v", r) + } + }() + if _, err := g.Redis().Do(ctx, "PING"); err != nil { + t.Skipf("Redis is not available: %v", err) + } +} + +func setRegisterRateLimitConfig(ctx context.Context, t *testing.T, key string, value string) { + t.Helper() + Config().cache.Set(ctx, key, value) + t.Cleanup(func() { + Config().cache.Delete(ctx, key) + }) +} + // TestCheckRegisterRateLimit 测试注册速率限制 func TestCheckRegisterRateLimit(t *testing.T) { ctx := context.Background() + requireRegisterRateLimitRedis(t, ctx) // 测试IP级别限流 t.Run("IP hourly limit", func(t *testing.T) { @@ -23,7 +44,7 @@ func TestCheckRegisterRateLimit(t *testing.T) { g.Redis().Do(ctx, "DEL", hourlyKey) // 设置配置为每小时最多3次 - g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "3") + setRegisterRateLimitConfig(ctx, t, "register_ip_limit_per_hour", "3") // 前3次应该成功 for i := 0; i < 3; i++ { @@ -52,11 +73,9 @@ func TestCheckRegisterRateLimit(t *testing.T) { dailyKey := "register:ip:daily:" + testIP g.Redis().Do(ctx, "DEL", dailyKey) - // 设置配置为每天最多5次 - g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "5") - - // 禁用每小时限制 - g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0") + // 设置配置为每天最多5次,并禁用每小时限制 + setRegisterRateLimitConfig(ctx, t, "register_ip_limit_per_day", "5") + setRegisterRateLimitConfig(ctx, t, "register_ip_limit_per_hour", "0") // 前5次应该成功 for i := 0; i < 5; i++ { @@ -82,18 +101,17 @@ func TestCheckRegisterRateLimit(t *testing.T) { // TestGlobalRateLimit 测试全局限流 func TestGlobalRateLimit(t *testing.T) { ctx := context.Background() + requireRegisterRateLimitRedis(t, ctx) // 清理测试数据 now := time.Now() minuteKey := fmt.Sprintf("register:global:%d", now.Unix()/60) g.Redis().Do(ctx, "DEL", minuteKey) - // 设置配置为每分钟最多2次 - g.Redis().Do(ctx, "SETEX", "config:register_global_limit_per_minute", 60, "2") - - // 禁用IP限制 - g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0") - g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "0") + // 设置配置为每分钟最多2次,并禁用IP限制 + setRegisterRateLimitConfig(ctx, t, "register_global_limit_per_minute", "2") + setRegisterRateLimitConfig(ctx, t, "register_ip_limit_per_hour", "0") + setRegisterRateLimitConfig(ctx, t, "register_ip_limit_per_day", "0") // 前2次应该成功(使用不同IP) for i := 0; i < 2; i++ { @@ -119,6 +137,7 @@ func TestGlobalRateLimit(t *testing.T) { // TestGetRegisterRateLimitStatus 测试获取限流状态 func TestGetRegisterRateLimitStatus(t *testing.T) { ctx := context.Background() + requireRegisterRateLimitRedis(t, ctx) testIP := "192.168.1.102" // 清理测试数据 @@ -128,8 +147,8 @@ func TestGetRegisterRateLimitStatus(t *testing.T) { g.Redis().Do(ctx, "DEL", dailyKey) // 设置配置 - g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "5") - g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "20") + setRegisterRateLimitConfig(ctx, t, "register_ip_limit_per_hour", "5") + setRegisterRateLimitConfig(ctx, t, "register_ip_limit_per_day", "20") // 注册2次 g.Redis().Do(ctx, "SETEX", hourlyKey, 3600, "2") diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 45eec16..7583131 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -59,12 +59,12 @@ var Registry = []SettingDef{ Label: "开放注册", Description: "是否允许新用户注册", IsPublic: true}, {Key: "register_email_verification", Type: SettingTypeBool, Default: "false", Category: "general", Label: "注册邮箱验证", Description: "注册时是否需要邮箱验证码,关闭时使用滑块验证", IsPublic: true}, - {Key: "register_ip_limit_per_hour", Type: SettingTypeInt, Default: "3", Category: "general", - Label: "IP每小时注册限制", Description: "同一IP每小时最多注册次数(0表示不限制)", Validation: "min:0,max:20"}, - {Key: "register_ip_limit_per_day", Type: SettingTypeInt, Default: "10", Category: "general", - Label: "IP每天注册限制", Description: "同一IP每天最多注册次数(0表示不限制)", Validation: "min:0,max:100"}, - {Key: "register_global_limit_per_minute", Type: SettingTypeInt, Default: "10", Category: "general", - Label: "全局每分钟注册限制", Description: "全系统每分钟最多注册次数(0表示不限制)", Validation: "min:0,max:100"}, + {Key: "register_ip_limit_per_hour", Type: SettingTypeInt, Default: "50", Category: "general", + Label: "IP每小时注册限制", Description: "同一IP每小时最多注册次数(0表示不限制)", Validation: "min:0,max:200"}, + {Key: "register_ip_limit_per_day", Type: SettingTypeInt, Default: "500", Category: "general", + Label: "IP每天注册限制", Description: "同一IP每天最多注册次数(0表示不限制)", Validation: "min:0,max:1000"}, + {Key: "register_global_limit_per_minute", Type: SettingTypeInt, Default: "50", Category: "general", + Label: "全局每分钟注册限制", Description: "全系统每分钟最多注册次数(0表示不限制)", Validation: "min:0,max:200"}, {Key: "maintenance_mode", Type: SettingTypeBool, Default: "false", Category: "general", Label: "维护模式", Description: "开启后控制台显示维护提示", IsPublic: true}, {Key: "maintenance_message", Type: SettingTypeString, Default: "", Category: "general", diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go index fc9fc0d..2c13dfd 100644 --- a/internal/logic/common/validation.go +++ b/internal/logic/common/validation.go @@ -4,7 +4,6 @@ import ( "context" "strings" "time" - "unicode" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" @@ -33,24 +32,22 @@ func ValidateUsername(username string) error { return nil } -// ValidatePassword 校验密码强度:至少8位,包含大写、小写、数字 +// ValidatePassword 校验密码强度:至少8位,且同时包含字母(不区分大小写)和数字 func ValidatePassword(password string) error { if len(password) < 8 { return gerror.New("密码长度不能少于8位") } - var hasUpper, hasLower, hasDigit bool + var hasLetter, hasDigit bool for _, c := range password { switch { - case unicode.IsUpper(c): - hasUpper = true - case unicode.IsLower(c): - hasLower = true - case unicode.IsDigit(c): + case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'): + hasLetter = true + case c >= '0' && c <= '9': hasDigit = true } } - if !hasUpper || !hasLower || !hasDigit { - return gerror.New("密码必须包含大写字母、小写字母和数字") + if !hasLetter || !hasDigit { + return gerror.New("密码必须同时包含字母和数字") } return nil } @@ -103,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/open/open_api.go b/internal/logic/open/open_api.go index 10444eb..61ddf9e 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -27,6 +27,34 @@ type sOpen struct{} var openMemberModelCache = common.NewCache("member_model", 60*time.Second) +func getProjectApiKeyPrefixes(ctx context.Context, tenantID, projectID int64) ([]string, error) { + type keyRow struct { + KeyPrefix string `json:"key_prefix"` + } + var keys []keyRow + err := dao.ApiKeys.Ctx(ctx). + Where("tenant_id", tenantID). + Where("project_id", projectID). + Fields("key_prefix"). + Scan(&keys) + if err != nil { + return nil, err + } + prefixes := make([]string, 0, len(keys)) + for _, key := range keys { + if key.KeyPrefix != "" { + prefixes = append(prefixes, key.KeyPrefix) + } + } + return prefixes, nil +} + +func invalidateApiKeyPrefixes(ctx context.Context, prefixes []string) { + for _, prefix := range prefixes { + relay.InvalidateApiKey(ctx, prefix) + } +} + func New() *sOpen { return &sOpen{} } @@ -471,7 +499,25 @@ func (s *sOpen) OpenKeyDelete(ctx context.Context, req *v1.OpenKeyDeleteReq) (*v return nil, err } - _, err := dao.ApiKeys.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete() + var key *struct { + KeyPrefix string `json:"key_prefix"` + } + err := dao.ApiKeys.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Fields("key_prefix"). + Scan(&key) + if err != nil { + return nil, err + } + if key == nil { + return nil, errOpen(consts.CodeNotFound, "密钥不存在") + } + + _, err = dao.ApiKeys.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete() + if err == nil { + relay.InvalidateApiKey(ctx, key.KeyPrefix) + } return nil, err } @@ -892,6 +938,11 @@ func (s *sOpen) OpenProjectArchive(ctx context.Context, req *v1.OpenProjectArchi return nil, errOpen(consts.CodeBadRequest, "项目已归档") } + keyPrefixes, err := getProjectApiKeyPrefixes(ctx, tenantID, req.Id) + if err != nil { + return nil, err + } + // 吊销项目下所有 active Key _, err = dao.ApiKeys.Ctx(ctx). Where("tenant_id", tenantID). @@ -904,6 +955,9 @@ func (s *sOpen) OpenProjectArchive(ctx context.Context, req *v1.OpenProjectArchi _, err = dao.TntProjects.Ctx(ctx).Where("id", req.Id). Data(do.TntProjects{Status: "archived"}).Update() + if err == nil { + invalidateApiKeyPrefixes(ctx, keyPrefixes) + } return nil, err } @@ -1072,15 +1126,19 @@ func (s *sOpen) OpenProjectKeyDelete(ctx context.Context, req *v1.OpenProjectKey } // 验证密钥属于该项目和租户 - count, err := dao.ApiKeys.Ctx(ctx). + var key *struct { + KeyPrefix string `json:"key_prefix"` + } + err := dao.ApiKeys.Ctx(ctx). Where("id", req.KeyId). Where("tenant_id", tenantID). Where("project_id", req.Id). - Count() + Fields("key_prefix"). + Scan(&key) if err != nil { return nil, err } - if count == 0 { + if key == nil { return nil, errOpen(consts.CodeNotFound, "密钥不存在") } @@ -1088,6 +1146,9 @@ func (s *sOpen) OpenProjectKeyDelete(ctx context.Context, req *v1.OpenProjectKey Where("id", req.KeyId). Where("tenant_id", tenantID). Data(do.ApiKeys{Status: "revoked"}).Update() + if err == nil { + relay.InvalidateApiKey(ctx, key.KeyPrefix) + } return nil, err } diff --git a/internal/logic/relay/apikey.go b/internal/logic/relay/apikey.go index 0002211..bb64ddb 100644 --- a/internal/logic/relay/apikey.go +++ b/internal/logic/relay/apikey.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/model/do" "time" "github.com/gogf/gf/v2/frame/g" @@ -19,19 +20,31 @@ import ( // ApiKeyInfo API Key 验证结果(本地定义,避免导入 relay/common) type ApiKeyInfo struct { - ID int64 - TenantID int64 - UserID int64 - Scope string - Status string - KeyType string // personal 或 project - ProjectID int64 // 项目密钥关联的项目 ID,个人密钥为 0 - KeyHash string // SHA-256(rawKey),缓存命中时用于全键校验 + ID int64 + TenantID int64 + UserID int64 + Scope string + Status string + KeyType string // personal 或 project + ProjectID int64 // 项目密钥关联的项目 ID,个人密钥为 0 + KeyHash string // SHA-256(rawKey),缓存命中时用于全键校验 + RateLimitQps int // 0 表示使用全局默认 + RateLimitConcurrency int // 0 表示不限制 + IpWhitelist string // 逗号分隔的 IP/CIDR 白名单 + TotalQuota float64 // 0 表示不限制 + UsedQuota float64 } // apiKeyCache API Key 缓存实例(TTL 60s) var apiKeyCache = lcommon.NewCache("apikey", 60*time.Second) +// InvalidateApiKey removes cached authentication info for an API key prefix. +func InvalidateApiKey(ctx context.Context, prefix string) { + if prefix != "" { + apiKeyCache.Delete(ctx, prefix) + } +} + // DefaultChannelSettings 返回默认渠道配置 func DefaultChannelSettings() common.ChannelSettings { return common.ChannelSettings{ @@ -101,9 +114,14 @@ func migrateTable(ctx context.Context, table string, oldKey, newKey []byte) { g.Log().Warningf(ctx, "[KeyMigration] re-encrypt %s id=%d failed: %v", table, r.Id, err) continue } + data, ok := encryptedKeyUpdateData(table, newEncrypted) + if !ok { + g.Log().Warningf(ctx, "[KeyMigration] unsupported table %s", table) + continue + } _, err = g.DB().Model(table).Ctx(ctx). Where("id", r.Id). - Data(g.Map{"encrypted_key": newEncrypted}). + Data(data). Update() if err != nil { g.Log().Warningf(ctx, "[KeyMigration] update %s id=%d failed: %v", table, r.Id, err) @@ -117,6 +135,17 @@ func migrateTable(ctx context.Context, table string, oldKey, newKey []byte) { } } +func encryptedKeyUpdateData(table string, encryptedKey string) (any, bool) { + switch table { + case "chn_channel_keys": + return do.ChnChannelKeys{EncryptedKey: encryptedKey}, true + case "api_keys": + return do.ApiKeys{EncryptedKey: encryptedKey}, true + default: + return nil, false + } +} + // apiKeyHash 计算 API Key 的 SHA-256 哈希 func apiKeyHash(rawKey string) string { h := sha256.Sum256([]byte(rawKey)) @@ -147,23 +176,28 @@ func ValidateApiKey(ctx context.Context, rawKey string) (*ApiKeyInfo, error) { // 查数据库 type apiKeyRow struct { - ID int64 `json:"id"` - TenantID int64 `json:"tenant_id"` - UserID int64 `json:"user_id"` - EncryptedKey string `json:"encrypted_key"` - KeyPrefix string `json:"key_prefix"` - Scope string `json:"scope"` - Status string `json:"status"` - KeyType string `json:"key_type"` - ProjectID int64 `json:"project_id"` - ExpiresAt *time.Time `json:"expires_at"` + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + UserID int64 `json:"user_id"` + EncryptedKey string `json:"encrypted_key"` + KeyPrefix string `json:"key_prefix"` + Scope string `json:"scope"` + Status string `json:"status"` + KeyType string `json:"key_type"` + ProjectID int64 `json:"project_id"` + ExpiresAt *time.Time `json:"expires_at"` + RateLimitQps int `json:"rate_limit_qps"` + RateLimitConcurrency int `json:"rate_limit_concurrency"` + IpWhitelist string `json:"ip_whitelist"` + TotalQuota float64 `json:"total_quota"` + UsedQuota float64 `json:"used_quota"` } var keys []apiKeyRow err := dao.ApiKeys.Ctx(ctx). Where("key_prefix", prefix). Where("status", "active"). - Fields("id, tenant_id, user_id, encrypted_key, key_prefix, scope, status, key_type, project_id, expires_at"). + Fields("id, tenant_id, user_id, encrypted_key, key_prefix, scope, status, key_type, project_id, expires_at, COALESCE(rate_limit_qps, 0) AS rate_limit_qps, COALESCE(rate_limit_concurrency, 0) AS rate_limit_concurrency, COALESCE(array_to_string(ip_whitelist, ','), '') AS ip_whitelist, COALESCE(total_quota, 0) AS total_quota, COALESCE(used_quota, 0) AS used_quota"). Scan(&keys) if err != nil { g.Log().Errorf(ctx, "[ApiKeyAuth] DB query failed: prefix=%s, error=%v", prefix, err) @@ -185,14 +219,19 @@ func ValidateApiKey(ctx context.Context, rawKey string) (*ApiKeyInfo, error) { // 检查过期 if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) { info := &ApiKeyInfo{ - ID: k.ID, - TenantID: k.TenantID, - UserID: k.UserID, - Scope: k.Scope, - Status: "expired", - KeyType: k.KeyType, - ProjectID: k.ProjectID, - KeyHash: keyHash, + ID: k.ID, + TenantID: k.TenantID, + UserID: k.UserID, + Scope: k.Scope, + Status: "expired", + KeyType: k.KeyType, + ProjectID: k.ProjectID, + KeyHash: keyHash, + RateLimitQps: k.RateLimitQps, + RateLimitConcurrency: k.RateLimitConcurrency, + IpWhitelist: k.IpWhitelist, + TotalQuota: k.TotalQuota, + UsedQuota: k.UsedQuota, } apiKeyCache.Set(ctx, prefix, info) return nil, consts.ErrKeyExpired @@ -210,15 +249,35 @@ func ValidateApiKey(ctx context.Context, rawKey string) (*ApiKeyInfo, error) { return nil, consts.ErrTenantSuspended } + // Project keys are only valid while their project remains active. + if k.ProjectID > 0 { + var project *struct { + Status string `json:"status"` + } + err = dao.TntProjects.Ctx(ctx). + Where("id", k.ProjectID). + Where("tenant_id", k.TenantID). + Fields("status"). + Scan(&project) + if err != nil || project == nil || project.Status != "active" { + return nil, consts.ErrProjectNotActive + } + } + info := &ApiKeyInfo{ - ID: k.ID, - TenantID: k.TenantID, - UserID: k.UserID, - Scope: k.Scope, - Status: "active", - KeyType: k.KeyType, - ProjectID: k.ProjectID, - KeyHash: keyHash, + ID: k.ID, + TenantID: k.TenantID, + UserID: k.UserID, + Scope: k.Scope, + Status: "active", + KeyType: k.KeyType, + ProjectID: k.ProjectID, + KeyHash: keyHash, + RateLimitQps: k.RateLimitQps, + RateLimitConcurrency: k.RateLimitConcurrency, + IpWhitelist: k.IpWhitelist, + TotalQuota: k.TotalQuota, + UsedQuota: k.UsedQuota, } apiKeyCache.Set(ctx, prefix, info) diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index ac1ccf7..073572b 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -42,12 +42,17 @@ func (p *DataProviderImpl) ValidateApiKey(ctx context.Context, rawKey string) (* return nil, err } return &common.ApiKeyInfo{ - ID: info.ID, - TenantID: info.TenantID, - UserID: info.UserID, - ProjectID: info.ProjectID, - Scope: info.Scope, - Status: info.Status, + ID: info.ID, + TenantID: info.TenantID, + UserID: info.UserID, + ProjectID: info.ProjectID, + Scope: info.Scope, + Status: info.Status, + RateLimitQps: info.RateLimitQps, + RateLimitConcurrency: info.RateLimitConcurrency, + IpWhitelist: info.IpWhitelist, + TotalQuota: info.TotalQuota, + UsedQuota: info.UsedQuota, }, nil } @@ -538,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/task/async_polling.go b/internal/logic/task/async_polling.go index 4e5fba1..2f59222 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -177,6 +177,7 @@ func handleUnsettledTasks(ctx context.Context) { } else { t.BillingSettled = true t.ActualCost = actualCost + taskBilling.IncrApiKeyQuotaUsed(ctx, t.ApiKeyID, actualCost) DefaultAsyncProvider.UpdateTask(ctx, t) g.Log().Infof(ctx, "poll: retried settlement for task %s", t.PublicTaskID) } @@ -339,6 +340,7 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co g.Log().Warningf(ctx, "poll: settle task %s: %v", task.PublicTaskID, err) } else { task.BillingSettled = true + taskBilling.IncrApiKeyQuotaUsed(ctx, task.ApiKeyID, actualCost) DefaultAsyncProvider.UpdateTask(ctx, task) } billing.CleanupPreDeduct(ctx, task.TenantID, task.RequestID+"_adjust") diff --git a/internal/logic/tenant/api_key.go b/internal/logic/tenant/api_key.go index b0ff35f..ba615a2 100644 --- a/internal/logic/tenant/api_key.go +++ b/internal/logic/tenant/api_key.go @@ -3,6 +3,7 @@ package tenant import ( "context" "fmt" + "strings" "time" "github.com/gogf/gf/v2/database/gdb" @@ -60,6 +61,7 @@ func (s *sTenant) ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) ( ExpiresAt *time.Time `json:"expires_at"` RateLimitQps *int `json:"rate_limit_qps"` RateLimitConcurrency *int `json:"rate_limit_concurrency"` + IpWhitelist []string `json:"ip_whitelist"` TotalQuota *float64 `json:"total_quota"` UsedQuota *float64 `json:"used_quota"` CreatedAt *time.Time `json:"created_at"` @@ -69,7 +71,7 @@ func (s *sTenant) ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) ( var keys []keyRow var err error var total int - err = query.Fields("id, name, key_prefix, scope, status, key_type, project_id, expires_at, rate_limit_qps, rate_limit_concurrency, total_quota, used_quota, created_at, updated_at"). + err = query.Fields("id, name, key_prefix, scope, status, key_type, project_id, expires_at, rate_limit_qps, rate_limit_concurrency, ip_whitelist, total_quota, used_quota, created_at, updated_at"). OrderDesc("created_at"). Page(page, pageSize). ScanAndCount(&keys, &total, false) @@ -118,6 +120,7 @@ func (s *sTenant) ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) ( "expires_at": k.ExpiresAt, "rate_limit_qps": k.RateLimitQps, "rate_limit_concurrency": k.RateLimitConcurrency, + "ip_whitelist": k.IpWhitelist, "total_quota": k.TotalQuota, "used_quota": k.UsedQuota, "created_at": k.CreatedAt, @@ -133,6 +136,24 @@ func (s *sTenant) ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) ( }, nil } +func normalizeIPWhitelist(items []string) []string { + if len(items) == 0 { + return []string{} + } + + result := make([]string, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + result = append(result, item) + } + return result +} + // ApiKeyCreate 创建新的 API Key func (s *sTenant) ApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateReq) (*v1.TenantApiKeyCreateRes, error) { tenantID := middleware.GetTenantID(ctx) @@ -187,6 +208,7 @@ func (s *sTenant) ApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateRe EncryptedKey: encryptedKey, KeyPrefix: prefix, Scope: req.Scope, + KeyType: keyType, Status: "active", } @@ -197,6 +219,27 @@ func (s *sTenant) ApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateRe if req.ExpiresAt != nil { data.ExpiresAt = req.ExpiresAt } + if req.RateLimitQps != nil { + if *req.RateLimitQps < 0 { + return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "QPS 限制不能小于 0") + } + data.RateLimitQps = *req.RateLimitQps + } + if req.RateLimitConcurrency != nil { + if *req.RateLimitConcurrency < 0 { + return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "并发限制不能小于 0") + } + data.RateLimitConcurrency = *req.RateLimitConcurrency + } + if req.IpWhitelist != nil { + data.IpWhitelist = normalizeIPWhitelist(*req.IpWhitelist) + } + if req.TotalQuota != nil { + if *req.TotalQuota < 0 { + return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于 0") + } + data.TotalQuota = *req.TotalQuota + } result, err := dao.ApiKeys.Ctx(ctx).Insert(data) if err != nil { @@ -277,9 +320,7 @@ func (s *sTenant) ApiKeyDelete(ctx context.Context, req *v1.TenantApiKeyDeleteRe return nil, lcommon.NewNotFoundError("API key") } - if info.KeyPrefix != "" { - g.Log().Infof(ctx, "API key %d disabled, cache invalidation needed for prefix %s", keyID, info.KeyPrefix) - } + relay.InvalidateApiKey(ctx, info.KeyPrefix) return &v1.TenantApiKeyDeleteRes{}, nil } @@ -371,14 +412,27 @@ func (s *sTenant) ApiKeyUpdate(ctx context.Context, req *v1.TenantApiKeyUpdateRe hasUpdate = true } if req.RateLimitQps != nil { + if *req.RateLimitQps < 0 { + return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "QPS 限制不能小于 0") + } data.RateLimitQps = *req.RateLimitQps hasUpdate = true } if req.RateLimitConcurrency != nil { + if *req.RateLimitConcurrency < 0 { + return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "并发限制不能小于 0") + } data.RateLimitConcurrency = *req.RateLimitConcurrency hasUpdate = true } + if req.IpWhitelist != nil { + data.IpWhitelist = normalizeIPWhitelist(*req.IpWhitelist) + hasUpdate = true + } if req.TotalQuota != nil { + if *req.TotalQuota < 0 { + return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于 0") + } if *req.TotalQuota > 0 && *req.TotalQuota < info.UsedQuota { return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于已用额度") } @@ -405,8 +459,8 @@ func (s *sTenant) ApiKeyUpdate(ctx context.Context, req *v1.TenantApiKeyUpdateRe } } - if info.KeyPrefix != "" { - g.Log().Infof(ctx, "API key %d updated, cache invalidation needed for prefix %s", keyID, info.KeyPrefix) + if hasUpdate || req.ModelNames != nil { + relay.InvalidateApiKey(ctx, info.KeyPrefix) } return &v1.TenantApiKeyUpdateRes{}, nil @@ -421,13 +475,14 @@ func (s *sTenant) ApiKeyUpdateScopes(ctx context.Context, req *v1.TenantApiKeyUp // 先查询密钥信息以判断类型 type keyInfo struct { - KeyType string `json:"key_type"` + KeyType string `json:"key_type"` + KeyPrefix string `json:"key_prefix"` } var info *keyInfo err := dao.ApiKeys.Ctx(ctx). Where("id", keyID). Where("tenant_id", tenantID). - Fields("key_type"). + Fields("key_type, key_prefix"). Scan(&info) if err != nil { return nil, err @@ -460,6 +515,8 @@ func (s *sTenant) ApiKeyUpdateScopes(ctx context.Context, req *v1.TenantApiKeyUp return nil, err } + relay.InvalidateApiKey(ctx, info.KeyPrefix) + return &v1.TenantApiKeyUpdateScopesRes{}, nil } @@ -554,6 +611,7 @@ func listProjectApiKeys(ctx context.Context, tenantID, projectID int64, page, pa ExpiresAt *time.Time `json:"expires_at"` RateLimitQps *int `json:"rate_limit_qps"` RateLimitConcurrency *int `json:"rate_limit_concurrency"` + IpWhitelist []string `json:"ip_whitelist"` TotalQuota *float64 `json:"total_quota"` UsedQuota *float64 `json:"used_quota"` CreatedAt *time.Time `json:"created_at"` @@ -562,7 +620,7 @@ func listProjectApiKeys(ctx context.Context, tenantID, projectID int64, page, pa var keys []keyRow var total int - err = query.Fields("id, name, key_prefix, scope, status, expires_at, rate_limit_qps, rate_limit_concurrency, total_quota, used_quota, created_at, updated_at"). + err = query.Fields("id, name, key_prefix, scope, status, expires_at, rate_limit_qps, rate_limit_concurrency, ip_whitelist, total_quota, used_quota, created_at, updated_at"). OrderDesc("created_at"). Page(page, pageSize). ScanAndCount(&keys, &total, false) @@ -584,6 +642,7 @@ func listProjectApiKeys(ctx context.Context, tenantID, projectID int64, page, pa "expires_at": k.ExpiresAt, "rate_limit_qps": k.RateLimitQps, "rate_limit_concurrency": k.RateLimitConcurrency, + "ip_whitelist": k.IpWhitelist, "total_quota": k.TotalQuota, "used_quota": k.UsedQuota, "created_at": k.CreatedAt, 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/open_platform.go b/internal/logic/tenant/open_platform.go index 2f6230e..8e51587 100644 --- a/internal/logic/tenant/open_platform.go +++ b/internal/logic/tenant/open_platform.go @@ -10,6 +10,7 @@ import ( "fmt" "strings" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -26,6 +27,15 @@ import ( // 开放平台应用管理 // ============================================================ +type opnAppEncryptedSecretData struct { + EncryptedSecret any `json:"encrypted_secret"` +} + +type opnAppSecretUpdateData struct { + AppSecretHash any `json:"app_secret_hash"` + EncryptedSecret any `json:"encrypted_secret"` +} + func (s *sTenant) OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1.OpenAppListRes, error) { role := middleware.GetUserRole(ctx) if role != "owner" && role != "admin" { @@ -82,6 +92,25 @@ func (s *sTenant) OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1. return &v1.OpenAppListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil } +func getTenantOpenAppID(ctx context.Context, tenantID, id int64) (string, error) { + type row struct { + AppId string `json:"app_id"` + } + var app *row + err := dao.OpnApps.Ctx(ctx). + Where("id", id). + Where("tenant_id", tenantID). + Fields("app_id"). + Scan(&app) + if err != nil { + return "", err + } + if app == nil { + return "", common.NewNotFoundError("开放平台应用") + } + return app.AppId, nil +} + func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) (*v1.OpenAppCreateRes, error) { role := middleware.GetUserRole(ctx) if role != "owner" && role != "admin" { @@ -109,7 +138,7 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) ( return nil, err } - // Store raw secret in Redis (encrypted) for HMAC verification + // Encrypt the one-time secret for DB persistence and Redis cache. encKey := getEncKey(ctx) encryptedSecret, err := crypto.EncryptString(encKey, appSecret) if err != nil { @@ -127,30 +156,38 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) ( rateLimit = 60 } - result, err := dao.OpnApps.Ctx(ctx).Data(do.OpnApps{ - TenantId: tenantID, - Name: req.Name, - Description: req.Description, - AppId: appID, - AppSecretHash: secretHash, - Permissions: string(permsJSON), - IpWhitelist: string(ipJSON), - RateLimit: rateLimit, - Status: "active", - }).Insert() + var id int64 + err = dao.OpnApps.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + result, err := tx.Model("opn_apps").Ctx(ctx).Data(do.OpnApps{ + TenantId: tenantID, + Name: req.Name, + Description: req.Description, + AppId: appID, + AppSecretHash: secretHash, + Permissions: string(permsJSON), + IpWhitelist: string(ipJSON), + RateLimit: rateLimit, + Status: "active", + }).Insert() + if err != nil { + return err + } + id, err = result.LastInsertId() + if err != nil { + return err + } + _, err = tx.Model("opn_apps").Ctx(ctx). + Where("id", id). + Data(opnAppEncryptedSecretData{EncryptedSecret: encryptedSecret}). + Update() + return err + }) if err != nil { return nil, err } - id, _ := result.LastInsertId() - - // Store encrypted secret in Redis(30 天 TTL,过期后从 DB 重新加载)— 失败时回滚 DB 记录 - _, err = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", id), encryptedSecret, "EX", 30*86400) - if err != nil { - // 回滚:删除已插入的 DB 记录 - _, _ = dao.OpnApps.Ctx(ctx).Where("id", id).Delete() - return nil, fmt.Errorf("store app secret in Redis failed: %w", err) - } + // Redis is a cache; DB encrypted_secret is the source of truth. + _, _ = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", id), encryptedSecret, "EX", 30*86400) return &v1.OpenAppCreateRes{ID: id, AppID: appID, AppSecret: appSecret}, nil } @@ -191,7 +228,14 @@ func (s *sTenant) OpenAppUpdate(ctx context.Context, req *v1.OpenAppUpdateReq) ( return nil, nil } - _, err := dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(data).Update() + appID, err := getTenantOpenAppID(ctx, tenantID, req.Id) + if err != nil { + return nil, err + } + _, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(data).Update() + if err == nil { + middleware.InvalidateOpenAppCache(ctx, appID) + } return nil, err } @@ -201,12 +245,17 @@ func (s *sTenant) OpenAppDelete(ctx context.Context, req *v1.OpenAppDeleteReq) ( return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } tenantID := middleware.GetTenantID(ctx) - _, err := dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete() + appID, err := getTenantOpenAppID(ctx, tenantID, req.Id) + if err != nil { + return nil, err + } + _, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete() if err != nil { return nil, err } // Clean up secret from Redis _, _ = g.Redis().Do(ctx, "DEL", fmt.Sprintf("open:secret:%d", req.Id)) + middleware.InvalidateOpenAppCache(ctx, appID) return nil, nil } @@ -216,6 +265,10 @@ func (s *sTenant) OpenAppResetSecret(ctx context.Context, req *v1.OpenAppResetSe return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } tenantID := middleware.GetTenantID(ctx) + appID, err := getTenantOpenAppID(ctx, tenantID, req.Id) + if err != nil { + return nil, err + } // Generate new secret secretBytes := make([]byte, 24) @@ -229,28 +282,27 @@ func (s *sTenant) OpenAppResetSecret(ctx context.Context, req *v1.OpenAppResetSe return nil, err } - // 先加密新 secret 准备写入 Redis encKey := getEncKey(ctx) encryptedSecret, err := crypto.EncryptString(encKey, appSecret) if err != nil { return nil, err } - // 先写 Redis(30 天 TTL),成功后再更新 DB - _, err = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", req.Id), encryptedSecret, "EX", 30*86400) + _, err = dao.OpnApps.Ctx(ctx). + Where("id", req.Id). + Where("tenant_id", tenantID). + Data(opnAppSecretUpdateData{ + AppSecretHash: secretHash, + EncryptedSecret: encryptedSecret, + }). + Update() if err != nil { - return nil, fmt.Errorf("update app secret in Redis failed: %w", err) - } - - _, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(do.OpnApps{ - AppSecretHash: secretHash, - }).Update() - if err != nil { - // DB 更新失败,尝试回滚 Redis - _, _ = g.Redis().Do(ctx, "DEL", fmt.Sprintf("open:secret:%d", req.Id)) return nil, err } + _, _ = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", req.Id), encryptedSecret, "EX", 30*86400) + middleware.InvalidateOpenAppCache(ctx, appID) + return &v1.OpenAppResetSecretRes{AppSecret: appSecret}, nil } @@ -260,9 +312,16 @@ func (s *sTenant) OpenAppToggleStatus(ctx context.Context, req *v1.OpenAppToggle return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } tenantID := middleware.GetTenantID(ctx) - _, err := dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(do.OpnApps{ + appID, err := getTenantOpenAppID(ctx, tenantID, req.Id) + if err != nil { + return nil, err + } + _, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(do.OpnApps{ Status: req.Status, }).Update() + if err == nil { + middleware.InvalidateOpenAppCache(ctx, appID) + } return nil, err } 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/project.go b/internal/logic/tenant/project.go index 8ab11ab..0c94e26 100644 --- a/internal/logic/tenant/project.go +++ b/internal/logic/tenant/project.go @@ -3,10 +3,12 @@ package tenant import ( "context" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" + "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/logic/relay" @@ -28,12 +30,18 @@ type projectRow struct { } type apiKeyRow struct { - Id int64 `json:"id"` - Name string `json:"name"` - KeyPrefix string `json:"key_prefix"` - Status string `json:"status"` - CreatedAt *gtime.Time `json:"created_at"` - ExpiresAt *gtime.Time `json:"expires_at"` + Id int64 `json:"id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + Scope string `json:"scope"` + Status string `json:"status"` + RateLimitQps *int `json:"rate_limit_qps"` + RateLimitConcurrency *int `json:"rate_limit_concurrency"` + IpWhitelist []string `json:"ip_whitelist"` + TotalQuota *float64 `json:"total_quota"` + UsedQuota *float64 `json:"used_quota"` + CreatedAt *gtime.Time `json:"created_at"` + ExpiresAt *gtime.Time `json:"expires_at"` } type usageLogRow struct { @@ -49,6 +57,124 @@ type usageLogRow struct { CreatedAt *gtime.Time `json:"created_at"` } +type projectStatusRow struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + Status string `json:"status"` + Budget float64 `json:"budget"` +} + +func invalidateApiKeysByProject(ctx context.Context, tenantID, projectID int64) error { + type keyRow struct { + KeyPrefix string `json:"key_prefix"` + } + var keys []keyRow + err := dao.ApiKeys.Ctx(ctx). + Where("tenant_id", tenantID). + Where("project_id", projectID). + Fields("key_prefix"). + Scan(&keys) + if err != nil { + return err + } + for _, key := range keys { + relay.InvalidateApiKey(ctx, key.KeyPrefix) + } + return nil +} + +func ensureProjectActiveForKey(ctx context.Context, tenantID, projectID int64) error { + project, err := getProjectStatus(ctx, tenantID, projectID) + if err != nil { + return err + } + if project == nil { + return common.NewNotFoundError("项目") + } + if project.Status != "active" { + return common.NewBusinessError(consts.CodeProjectNotActive, consts.MsgProjectNotActive) + } + return nil +} + +func getProjectStatus(ctx context.Context, tenantID, projectID int64) (*projectStatusRow, error) { + var project *projectStatusRow + err := dao.TntProjects.Ctx(ctx). + Where("id", projectID). + Where("tenant_id", tenantID). + Fields("id, tenant_id, status, COALESCE(budget, 0) as budget"). + Scan(&project) + if err != nil { + return nil, err + } + return project, nil +} + +func projectTotalCost(ctx context.Context, tenantID, projectID int64) (float64, error) { + var usage struct { + TotalCost float64 `json:"total_cost"` + } + err := dao.BilTransactions.Ctx(ctx). + Where("tenant_id", tenantID). + Where("project_id", projectID). + Where("type", "consume"). + Fields("COALESCE(SUM(-amount), 0) as total_cost"). + Scan(&usage) + if err != nil { + return 0, err + } + return usage.TotalCost, nil +} + +func exhaustProjectBudget(ctx context.Context, tenantID, projectID int64) error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + _, err := tx.Model("tnt_projects").Ctx(ctx). + Where("id", projectID). + Where("tenant_id", tenantID). + Where("status", "active"). + Data(do.TntProjects{Status: "budget_exhausted"}). + Update() + if err != nil { + return err + } + + _, err = tx.Model("api_keys").Ctx(ctx). + Where("tenant_id", tenantID). + Where("project_id", projectID). + Where("status", "active"). + Data(do.ApiKeys{Status: "revoked"}). + Update() + return err + }) +} + +func CheckProjectBudget(ctx context.Context, tenantID, projectID int64) error { + if tenantID <= 0 || projectID <= 0 { + return nil + } + + project, err := getProjectStatus(ctx, tenantID, projectID) + if err != nil { + return err + } + if project == nil || project.Status != "active" || project.Budget <= 0 { + return nil + } + + totalCost, err := projectTotalCost(ctx, tenantID, projectID) + if err != nil { + return err + } + if totalCost < project.Budget { + return nil + } + + if err = exhaustProjectBudget(ctx, tenantID, projectID); err != nil { + return err + } + return invalidateApiKeysByProject(ctx, tenantID, projectID) +} + // ProjectList returns a paginated list of projects for a tenant. func (s *sTenant) ProjectList(ctx context.Context, req *v1.TenantProjectListReq) (*v1.TenantProjectListRes, error) { tenantID := middleware.GetTenantID(ctx) @@ -206,6 +332,7 @@ func (s *sTenant) ProjectArchive(ctx context.Context, req *v1.TenantProjectArchi if err != nil { return nil, err } + _ = invalidateApiKeysByProject(ctx, tenantID, req.Id) _, err = dao.TntProjects.Ctx(ctx). Where("id", req.Id). @@ -269,33 +396,8 @@ func CheckBudgetExhausted(ctx context.Context) error { } for _, p := range projects { - // Sum consumption for this project - var usage struct { - TotalCost float64 `json:"total_cost"` - } - dao.BilUsageLogs.Ctx(ctx). - Where("tenant_id", p.TenantID). - Where("project_id", p.ID). - Fields("COALESCE(SUM(total_cost), 0) as total_cost"). - Scan(&usage) - - if usage.TotalCost >= p.Budget { - // Mark as budget_exhausted - dao.TntProjects.Ctx(ctx). - Where("id", p.ID). - Where("status", "active"). - Data(do.TntProjects{ - Status: "budget_exhausted", - }).Update() - - // Revoke all active keys for this project - dao.ApiKeys.Ctx(ctx). - Where("tenant_id", p.TenantID). - Where("project_id", p.ID). - Where("status", "active"). - Data(do.ApiKeys{ - Status: "revoked", - }).Update() + if err = CheckProjectBudget(ctx, p.TenantID, p.ID); err != nil { + g.Log().Warningf(ctx, "检查项目预算失败: tenant=%d project=%d err=%v", p.TenantID, p.ID, err) } } @@ -379,7 +481,7 @@ func (s *sTenant) ProjectApiKeyList(ctx context.Context, req *v1.TenantProjectAp err = dao.ApiKeys.Ctx(ctx). Where("tenant_id", tenantID). Where("project_id", req.Id). - Fields("id, name, key_prefix, status, created_at, expires_at"). + Fields("id, name, key_prefix, scope, status, rate_limit_qps, rate_limit_concurrency, ip_whitelist, total_quota, used_quota, created_at, expires_at"). OrderDesc("id"). Page(page, pageSize). ScanAndCount(&keys, &total, false) @@ -408,21 +510,9 @@ func (s *sTenant) ProjectApiKeyCreate(ctx context.Context, req *v1.TenantProject tenantID := middleware.GetTenantID(ctx) userID := middleware.GetUserID(ctx) - // Verify project exists and belongs to tenant - var project *struct { - ID int64 `json:"id"` - } - err := dao.TntProjects.Ctx(ctx). - Where("id", req.Id). - Where("tenant_id", tenantID). - Fields("id"). - Scan(&project) - if err != nil { + if err := ensureProjectActiveForKey(ctx, tenantID, req.Id); err != nil { return nil, err } - if project == nil { - return nil, common.NewNotFoundError("项目") - } // Generate API key rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx) @@ -436,6 +526,7 @@ func (s *sTenant) ProjectApiKeyCreate(ctx context.Context, req *v1.TenantProject Name: req.Name, EncryptedKey: encryptedKey, KeyPrefix: prefix, + KeyType: "project", Status: "active", UserId: userID, Scope: req.Scope, @@ -444,6 +535,27 @@ func (s *sTenant) ProjectApiKeyCreate(ctx context.Context, req *v1.TenantProject if req.ExpiresAt != nil { insertData.ExpiresAt = req.ExpiresAt } + if req.RateLimitQps != nil { + if *req.RateLimitQps < 0 { + return nil, common.NewBadRequestError("QPS 限制不能小于 0") + } + insertData.RateLimitQps = *req.RateLimitQps + } + if req.RateLimitConcurrency != nil { + if *req.RateLimitConcurrency < 0 { + return nil, common.NewBadRequestError("并发限制不能小于 0") + } + insertData.RateLimitConcurrency = *req.RateLimitConcurrency + } + if req.IpWhitelist != nil { + insertData.IpWhitelist = normalizeIPWhitelist(*req.IpWhitelist) + } + if req.TotalQuota != nil { + if *req.TotalQuota < 0 { + return nil, common.NewBadRequestError("总额度不能小于 0") + } + insertData.TotalQuota = *req.TotalQuota + } result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert() if err != nil { @@ -482,13 +594,14 @@ func (s *sTenant) ProjectApiKeyDelete(ctx context.Context, req *v1.TenantProject // Verify the key belongs to the project and tenant var key *struct { - ID int64 `json:"id"` + ID int64 `json:"id"` + KeyPrefix string `json:"key_prefix"` } err := dao.ApiKeys.Ctx(ctx). Where("id", req.KeyId). Where("tenant_id", tenantID). Where("project_id", req.Id). - Fields("id"). + Fields("id, key_prefix"). Scan(&key) if err != nil { return nil, err @@ -506,6 +619,7 @@ func (s *sTenant) ProjectApiKeyDelete(ctx context.Context, req *v1.TenantProject if err != nil { return nil, gerror.Wrapf(err, "删除密钥") } + relay.InvalidateApiKey(ctx, key.KeyPrefix) return nil, nil } @@ -719,11 +833,17 @@ func convertApiKeyRowsToMaps(ctx context.Context, rows any) []map[string]any { for _, r := range v { m := map[string]any{ - "id": r.Id, - "name": r.Name, - "key_prefix": r.KeyPrefix, - "status": r.Status, - "model_count": modelCountMap[r.Id], + "id": r.Id, + "name": r.Name, + "key_prefix": r.KeyPrefix, + "scope": r.Scope, + "status": r.Status, + "rate_limit_qps": r.RateLimitQps, + "rate_limit_concurrency": r.RateLimitConcurrency, + "ip_whitelist": r.IpWhitelist, + "total_quota": r.TotalQuota, + "used_quota": r.UsedQuota, + "model_count": modelCountMap[r.Id], } if r.CreatedAt != nil { m["created_at"] = r.CreatedAt.String() 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/middleware/admin_auth.go b/internal/middleware/admin_auth.go index 13a6819..52158cc 100644 --- a/internal/middleware/admin_auth.go +++ b/internal/middleware/admin_auth.go @@ -16,28 +16,38 @@ import ( // AuthContextKey is the key used to store auth info in context. const ( - CtxKeyUserID = "userId" - CtxKeyUserType = "userType" - CtxKeyRole = "role" - CtxKeyTenantID = "tenantId" - CtxKeySessionID = "sessionId" - CtxKeyJti = "jti" - CtxKeyApiKeyID = "apiKeyId" - CtxKeyProjectID = "projectId" + CtxKeyUserID = "userId" + CtxKeyUserType = "userType" + CtxKeyRole = "role" + CtxKeyTenantID = "tenantId" + CtxKeySessionID = "sessionId" + CtxKeyJti = "jti" + CtxKeyApiKeyID = "apiKeyId" + CtxKeyProjectID = "projectId" + CtxKeyApiKeyRateLimitQps = "apiKeyRateLimitQps" + CtxKeyApiKeyRateLimitConcurrency = "apiKeyRateLimitConcurrency" + CtxKeyApiKeyIpWhitelist = "apiKeyIpWhitelist" + CtxKeyApiKeyTotalQuota = "apiKeyTotalQuota" + CtxKeyApiKeyUsedQuota = "apiKeyUsedQuota" ) // adminPublicPaths lists admin routes that skip JWT auth. // Keep in sync with api/admin/v1/ structs tagged group:"public" middleware:"-". var adminPublicPaths = map[string]bool{ - "/api/admin/auth/login": true, - "/api/admin/auth/refresh": true, + "/api/admin/auth/login": true, + "/api/admin/auth/refresh": true, + "/api/admin/auth/2fa/verify": true, +} + +func isAdminPublicPath(path string) bool { + return adminPublicPaths[path] } // AdminAuth is JWT authentication middleware for admin backend. func AdminAuth(r *ghttp.Request) { // g.Meta middleware:"-" only skips service middleware, not group middleware. // Public endpoints must be checked explicitly here. - if adminPublicPaths[r.URL.Path] { + if isAdminPublicPath(r.URL.Path) { r.Middleware.Next() return } diff --git a/internal/middleware/api_key_auth.go b/internal/middleware/api_key_auth.go index 5e16478..aef1fb1 100644 --- a/internal/middleware/api_key_auth.go +++ b/internal/middleware/api_key_auth.go @@ -55,6 +55,10 @@ func ApiKeyAuth(r *ghttp.Request) { code = http.StatusForbidden errType = "permission_error" msg = "tenant is suspended" + case consts.ErrProjectNotActive: + code = http.StatusForbidden + errType = "permission_error" + msg = "project is not active" } writeRelayAuthError(r, code, errType, msg) @@ -67,6 +71,11 @@ func ApiKeyAuth(r *ghttp.Request) { r.SetCtxVar(CtxKeyApiKeyID, apiKeyInfo.ID) r.SetCtxVar(CtxKeyProjectID, apiKeyInfo.ProjectID) r.SetCtxVar("ApiKeyScope", apiKeyInfo.Scope) + r.SetCtxVar(CtxKeyApiKeyRateLimitQps, apiKeyInfo.RateLimitQps) + r.SetCtxVar(CtxKeyApiKeyRateLimitConcurrency, apiKeyInfo.RateLimitConcurrency) + r.SetCtxVar(CtxKeyApiKeyIpWhitelist, apiKeyInfo.IpWhitelist) + r.SetCtxVar(CtxKeyApiKeyTotalQuota, apiKeyInfo.TotalQuota) + r.SetCtxVar(CtxKeyApiKeyUsedQuota, apiKeyInfo.UsedQuota) r.Middleware.Next() } diff --git a/internal/middleware/demo.go b/internal/middleware/demo.go index c8694ca..a97bc44 100644 --- a/internal/middleware/demo.go +++ b/internal/middleware/demo.go @@ -11,13 +11,15 @@ import ( ) var demoWhitelistPaths = map[string]bool{ - "/api/admin/auth/login": true, - "/api/admin/auth/refresh": true, - "/api/admin/auth/logout": true, - "/api/tenant/auth/login": true, - "/api/tenant/auth/refresh": true, - "/api/tenant/auth/logout": true, - "/api/tenant/auth/register": true, + "/api/admin/auth/login": true, + "/api/admin/auth/refresh": true, + "/api/admin/auth/logout": true, + "/api/admin/auth/2fa/verify": true, + "/api/tenant/auth/login": true, + "/api/tenant/auth/refresh": true, + "/api/tenant/auth/logout": true, + "/api/tenant/auth/register": true, + "/api/tenant/auth/2fa/verify": true, } // DemoMode blocks all write operations when demo mode is enabled. diff --git a/internal/middleware/open_auth.go b/internal/middleware/open_auth.go index 6bf631b..11f2994 100644 --- a/internal/middleware/open_auth.go +++ b/internal/middleware/open_auth.go @@ -43,6 +43,13 @@ const ( var openAppCache = lcommon.NewCache("open_app", 60*time.Second) +// InvalidateOpenAppCache removes the cached app metadata used by OpenPlatformAuth. +func InvalidateOpenAppCache(ctx context.Context, appID string) { + if appID != "" { + openAppCache.Delete(ctx, appID) + } +} + // OpenPlatformAuth is the HMAC-SHA256 authentication middleware for /open/* endpoints. func OpenPlatformAuth(r *ghttp.Request) { appID := r.GetHeader(openPlatformHeaderAppID) @@ -161,14 +168,34 @@ func checkOpenAppIPWhitelist(r *ghttp.Request, app entity.OpnApps) error { func getOpenAppSecret(ctx context.Context, appID int64) (string, error) { val, err := g.Redis().Do(ctx, "GET", fmt.Sprintf("open:secret:%d", appID)) + encKey := getEncKey(ctx) + if err == nil && !val.IsNil() && !val.IsEmpty() { + return crypto.DecryptString(encKey, val.String()) + } + if err != nil { + g.Log().Warningf(ctx, "[OpenAuth] Redis secret lookup failed app=%d: %v", appID, err) + } + + type secretRow struct { + EncryptedSecret string `json:"encrypted_secret"` + } + var row *secretRow + err = dao.OpnApps.Ctx(ctx). + Where("id", appID). + Fields("encrypted_secret"). + Scan(&row) if err != nil { return "", err } - if val.IsNil() || val.IsEmpty() { - return "", fmt.Errorf("secret not found") + if row == nil || row.EncryptedSecret == "" { + return "", fmt.Errorf("secret not persisted; reset app secret required") } - encKey := getEncKey(ctx) - return crypto.DecryptString(encKey, val.String()) + secret, err := crypto.DecryptString(encKey, row.EncryptedSecret) + if err != nil { + return "", err + } + _, _ = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", appID), row.EncryptedSecret, "EX", 30*86400) + return secret, nil } func getEncKey(ctx context.Context) []byte { diff --git a/internal/middleware/public_routes_test.go b/internal/middleware/public_routes_test.go new file mode 100644 index 0000000..f346ea2 --- /dev/null +++ b/internal/middleware/public_routes_test.go @@ -0,0 +1,127 @@ +package middleware + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +type publicRoutePattern struct { + path string + dynamic bool +} + +func TestPublicRouteWhitelistsMatchMetaTags(t *testing.T) { + adminRoutes := scanPublicMetaRoutes(t, filepath.Join("..", "..", "api", "admin", "v1"), "/api/admin") + assertExactPublicRoutes(t, "admin", adminRoutes, adminPublicPaths) + + tenantRoutes := scanPublicMetaRoutes(t, filepath.Join("..", "..", "api", "tenant", "v1"), "/api/tenant") + for _, route := range tenantRoutes { + if !tenantPublicRouteAllowed(route.path) { + t.Fatalf("tenant public route %q is tagged middleware:\"-\" but is missing from tenant auth whitelist", route.path) + } + } + assertNoStaleTenantPublicWhitelist(t, tenantRoutes) +} + +func scanPublicMetaRoutes(t *testing.T, dir string, basePath string) []publicRoutePattern { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read api dir %s: %v", dir, err) + } + + metaPattern := regexp.MustCompile("`([^`]+)`") + pathPattern := regexp.MustCompile(`path:"([^"]+)"`) + routes := make([]publicRoutePattern, 0) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + continue + } + content, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + t.Fatalf("read api file %s: %v", entry.Name(), err) + } + for _, match := range metaPattern.FindAllStringSubmatch(string(content), -1) { + meta := match[1] + if !strings.Contains(meta, `middleware:"-"`) { + continue + } + pathMatch := pathPattern.FindStringSubmatch(meta) + if len(pathMatch) != 2 { + t.Fatalf("public meta in %s missing path: %s", entry.Name(), meta) + } + path := basePath + pathMatch[1] + routes = append(routes, publicRoutePattern{ + path: path, + dynamic: strings.Contains(path, "{"), + }) + } + } + return routes +} + +func assertExactPublicRoutes(t *testing.T, name string, routes []publicRoutePattern, whitelist map[string]bool) { + t.Helper() + + found := make(map[string]bool, len(routes)) + for _, route := range routes { + if route.dynamic { + t.Fatalf("%s public route %q is dynamic; add prefix/suffix whitelist support before exposing it", name, route.path) + } + if !whitelist[route.path] { + t.Fatalf("%s public route %q is tagged middleware:\"-\" but is missing from whitelist", name, route.path) + } + found[route.path] = true + } + + for path := range whitelist { + if !found[path] { + t.Fatalf("%s whitelist contains stale path %q without matching middleware:\"-\" API tag", name, path) + } + } +} + +func tenantPublicRouteAllowed(path string) bool { + if tenantPublicPaths[path] { + return true + } + for _, prefix := range tenantPublicPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + return strings.HasPrefix(path, "/api/tenant/oauth/") && strings.HasSuffix(path, "/callback") +} + +func assertNoStaleTenantPublicWhitelist(t *testing.T, routes []publicRoutePattern) { + t.Helper() + + foundExact := make(map[string]bool, len(routes)) + for _, route := range routes { + if !route.dynamic { + foundExact[route.path] = true + } + } + for path := range tenantPublicPaths { + if !foundExact[path] { + t.Fatalf("tenant whitelist contains stale exact path %q without matching middleware:\"-\" API tag", path) + } + } + + for _, prefix := range tenantPublicPrefixes { + matched := false + for _, route := range routes { + if strings.HasPrefix(route.path, prefix) { + matched = true + break + } + } + if !matched { + t.Fatalf("tenant whitelist contains stale prefix %q without matching middleware:\"-\" API tag", prefix) + } + } +} diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index ed14161..528c09a 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -1,6 +1,8 @@ package middleware import ( + "strings" + "github.com/gogf/gf/v2/net/ghttp" "github.com/qianfree/team-api/internal/consts" @@ -28,7 +30,7 @@ func RequirePermission(permission string) func(r *ghttp.Request) { } } -// RequireRole returns middleware that checks if the tenant user has the required role. +// RequireTenantRole returns middleware that checks if the tenant user has the required role. func RequireTenantRole(roles ...string) func(r *ghttp.Request) { roleSet := make(map[string]bool) for _, r := range roles { @@ -44,3 +46,351 @@ func RequireTenantRole(roles ...string) func(r *ghttp.Request) { r.Middleware.Next() } } + +// adminPermissionRules defines the mapping from route patterns to permission points. +// Unmapped admin routes are denied by default in AdminPermissionGuard. +var adminPermissionRules = []permissionRule{ + // ── tenant 租户管理 ── + {method: "GET", path: "/api/admin/tenants", perm: "tenant:view"}, + {method: "GET", prefix: "/api/admin/tenants/", perm: "tenant:view"}, + {method: "POST", path: "/api/admin/tenants", perm: "tenant:create"}, + {method: "PUT", prefix: "/api/admin/tenants/", suffix: "/status", perm: "tenant:suspend"}, + {method: "PUT", prefix: "/api/admin/tenants/", suffix: "/channel-scope", perm: "tenant:edit"}, + {method: "PUT", prefix: "/api/admin/tenants/", perm: "tenant:edit"}, + {method: "GET", path: "/api/admin/tenants/export", perm: "tenant:view"}, + {method: "GET", path: "/api/admin/tenants/select", perm: "tenant:view"}, + {method: "GET", path: "/api/admin/tenants/members/export", perm: "member:view"}, + + // ── user 用户管理(管理员) ── + {method: "GET", path: "/api/admin/users", perm: "user:view"}, + {method: "GET", path: "/api/admin/users/export", perm: "user:view"}, + {method: "POST", path: "/api/admin/users", perm: "user:create"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/status", perm: "user:edit"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/reset-password", perm: "user:edit"}, + {method: "PUT", prefix: "/api/admin/users/", perm: "user:edit"}, + {method: "DELETE", prefix: "/api/admin/users/", perm: "user:delete"}, + + // ── channel 渠道管理 ── + {method: "GET", path: "/api/admin/channels", perm: "channel:view"}, + {method: "GET", path: "/api/admin/channels/export", perm: "channel:view"}, + {method: "GET", path: "/api/admin/channels/provider-default-urls", perm: "channel:view"}, + {method: "GET", prefix: "/api/admin/channels/", perm: "channel:view"}, + {method: "POST", path: "/api/admin/channels", perm: "channel:create"}, + {method: "POST", prefix: "/api/admin/channels/", suffix: "/test", perm: "channel:test"}, + {method: "POST", prefix: "/api/admin/channels/", suffix: "/clone", perm: "channel:create"}, + {method: "POST", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:edit"}, + {method: "DELETE", prefix: "/api/admin/channels/", suffix: "/keys/", perm: "channel:edit"}, + {method: "GET", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:view"}, + {method: "GET", prefix: "/api/admin/channels/", suffix: "/abilities", perm: "channel:view"}, + {method: "PUT", prefix: "/api/admin/channels/", suffix: "/abilities", perm: "channel:edit"}, + {method: "GET", prefix: "/api/admin/channels/", suffix: "/health_trend", perm: "channel:view"}, + {method: "PUT", prefix: "/api/admin/channels/", perm: "channel:edit"}, + {method: "DELETE", prefix: "/api/admin/channels/", perm: "channel:delete"}, + // Channel OAuth helper endpoints + {method: "POST", path: "/api/admin/channels/oauth/auth-url", perm: "channel:edit"}, + {method: "POST", path: "/api/admin/channels/oauth/exchange", perm: "channel:edit"}, + {method: "POST", path: "/api/admin/channels/oauth/refresh", perm: "channel:edit"}, + + // ── model 模型管理 ── + {method: "GET", path: "/api/admin/models", perm: "model:view"}, + {method: "GET", path: "/api/admin/models/options", perm: "model:view"}, + {method: "GET", path: "/api/admin/models/export", perm: "model:view"}, + {method: "GET", path: "/api/admin/models/official-info", perm: "model:view"}, + {method: "GET", prefix: "/api/admin/models/", suffix: "/pricing", perm: "model:view"}, + {method: "PUT", prefix: "/api/admin/models/", suffix: "/pricing", perm: "model:edit"}, + {method: "GET", prefix: "/api/admin/models/", suffix: "/official-pricing", perm: "model:view"}, + {method: "GET", prefix: "/api/admin/models/", perm: "model:view"}, + {method: "POST", path: "/api/admin/models", perm: "model:create"}, + {method: "POST", path: "/api/admin/models/export-json", perm: "model:view"}, + {method: "POST", path: "/api/admin/models/import-preview", perm: "model:create"}, + {method: "POST", path: "/api/admin/models/import", perm: "model:create"}, + {method: "PUT", prefix: "/api/admin/models/", perm: "model:edit"}, + {method: "DELETE", prefix: "/api/admin/models/", perm: "model:delete"}, + // Model groups + {method: "GET", path: "/api/admin/model-groups", perm: "model:view"}, + {method: "GET", path: "/api/admin/model-groups/options", perm: "model:view"}, + {method: "GET", prefix: "/api/admin/model-groups/", suffix: "/models", perm: "model:view"}, + {method: "PUT", prefix: "/api/admin/model-groups/", suffix: "/models", perm: "model:edit"}, + {method: "GET", prefix: "/api/admin/model-groups/", perm: "model:view"}, + {method: "POST", path: "/api/admin/model-groups", perm: "model:create"}, + {method: "PUT", prefix: "/api/admin/model-groups/", perm: "model:edit"}, + {method: "DELETE", prefix: "/api/admin/model-groups/", perm: "model:delete"}, + // Tenant model assignments + {method: "GET", prefix: "/api/admin/tenants/", suffix: "/models", perm: "model:view"}, + {method: "GET", prefix: "/api/admin/tenants/", suffix: "/available-models", perm: "model:view"}, + {method: "GET", prefix: "/api/admin/tenants/", suffix: "/groups", perm: "model:view"}, + {method: "POST", prefix: "/api/admin/tenants/", suffix: "/models", perm: "model:edit"}, + {method: "PUT", prefix: "/api/admin/tenants/", suffix: "/groups", perm: "model:edit"}, + {method: "PUT", prefix: "/api/admin/tenants/", suffix: "/models/", perm: "model:edit"}, + {method: "DELETE", prefix: "/api/admin/tenants/", suffix: "/models/", perm: "model:delete"}, + + // ── billing 计费管理 ── + {method: "GET", path: "/api/admin/billing-records", perm: "billing:view"}, + {method: "GET", path: "/api/admin/billing-records/export", perm: "billing:export"}, + {method: "GET", path: "/api/admin/usage-logs", perm: "billing:view"}, + {method: "GET", path: "/api/admin/usage-logs/export", perm: "billing:export"}, + {method: "GET", path: "/api/admin/transactions", perm: "billing:view"}, + {method: "GET", path: "/api/admin/wallets", perm: "billing:view"}, + {method: "GET", prefix: "/api/admin/wallets/", perm: "billing:view"}, + {method: "POST", prefix: "/api/admin/wallets/", suffix: "/adjust", perm: "billing:refund"}, + {method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:view"}, + + // ── plan 套餐管理 ── + {method: "GET", path: "/api/admin/plans", perm: "plan:view"}, + {method: "GET", path: "/api/admin/plans/export", perm: "plan:view"}, + {method: "GET", prefix: "/api/admin/plans/", perm: "plan:view"}, + {method: "POST", path: "/api/admin/plans", perm: "plan:create"}, + {method: "PUT", prefix: "/api/admin/plans/", perm: "plan:edit"}, + {method: "DELETE", prefix: "/api/admin/plans/", perm: "plan:delete"}, + + // ── order 订单管理 ── + {method: "GET", path: "/api/admin/orders", perm: "order:view"}, + {method: "GET", path: "/api/admin/orders/export", perm: "order:view"}, + {method: "GET", prefix: "/api/admin/orders/", perm: "order:view"}, + {method: "POST", prefix: "/api/admin/orders/", suffix: "/refund", perm: "order:refund"}, + {method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:view"}, + + // ── operation 内容运营 ── + {method: "GET", path: "/api/admin/changelogs", perm: "operation:view"}, + {method: "POST", path: "/api/admin/changelogs", perm: "operation:edit"}, + {method: "PUT", prefix: "/api/admin/changelogs/", perm: "operation:edit"}, + {method: "DELETE", prefix: "/api/admin/changelogs/", perm: "operation:edit"}, + {method: "POST", prefix: "/api/admin/changelogs/", suffix: "/publish", perm: "operation:edit"}, + {method: "GET", path: "/api/admin/announcements", perm: "operation:view"}, + {method: "POST", path: "/api/admin/announcements", perm: "operation:edit"}, + {method: "PUT", prefix: "/api/admin/announcements/", perm: "operation:edit"}, + {method: "GET", path: "/api/admin/notification/templates", perm: "operation:view"}, + {method: "GET", prefix: "/api/admin/notification/templates/", perm: "operation:view"}, + {method: "PUT", prefix: "/api/admin/notification/templates/", perm: "operation:edit"}, + {method: "POST", prefix: "/api/admin/notification/templates/", suffix: "/test", perm: "operation:edit"}, + {method: "GET", path: "/api/admin/notification/messages", perm: "operation:view"}, + {method: "POST", path: "/api/admin/notification/messages/send", perm: "operation:edit"}, + {method: "POST", path: "/api/admin/notification/messages/broadcast", perm: "operation:edit"}, + {method: "GET", path: "/api/admin/feedbacks", perm: "support:view"}, + {method: "GET", path: "/api/admin/feedbacks/stats", perm: "support:view"}, + {method: "POST", prefix: "/api/admin/feedbacks/", suffix: "/reply", perm: "support:reply"}, + {method: "PUT", prefix: "/api/admin/feedbacks/", suffix: "/status", perm: "support:edit"}, + {method: "GET", path: "/api/admin/tickets", perm: "support:view"}, + {method: "GET", prefix: "/api/admin/tickets/", perm: "support:view"}, + {method: "PUT", prefix: "/api/admin/tickets/", suffix: "/assign", perm: "support:edit"}, + {method: "POST", prefix: "/api/admin/tickets/", suffix: "/reply", perm: "support:reply"}, + {method: "PUT", prefix: "/api/admin/tickets/", suffix: "/status", perm: "support:edit"}, + + // ── help center 帮助中心 ── + {method: "GET", path: "/api/admin/help-categories", perm: "support:view"}, + {method: "POST", path: "/api/admin/help-categories", perm: "support:edit"}, + {method: "PUT", prefix: "/api/admin/help-categories/", perm: "support:edit"}, + {method: "DELETE", prefix: "/api/admin/help-categories/", perm: "support:edit"}, + {method: "GET", path: "/api/admin/help-articles", perm: "support:view"}, + {method: "GET", prefix: "/api/admin/help-articles/", perm: "support:view"}, + {method: "POST", path: "/api/admin/help-articles", perm: "support:edit"}, + {method: "PUT", prefix: "/api/admin/help-articles/", perm: "support:edit"}, + {method: "DELETE", prefix: "/api/admin/help-articles/", perm: "support:edit"}, + + // ── audit 审计日志 ── + {method: "GET", path: "/api/admin/audit/config", perm: "audit:view"}, + {method: "PUT", path: "/api/admin/audit/config", perm: "audit:view"}, + {method: "GET", path: "/api/admin/audit/operation-logs", perm: "audit:view"}, + {method: "GET", path: "/api/admin/audit/operation-logs/export", perm: "audit:export"}, + {method: "GET", path: "/api/admin/audit/request-logs", perm: "audit:view"}, + {method: "GET", prefix: "/api/admin/audit/request-logs/", perm: "audit:view"}, + {method: "GET", path: "/api/admin/audit/sensitive-logs", perm: "audit:read_sensitive"}, + {method: "GET", path: "/api/admin/audit/content-filter-logs", perm: "audit:view"}, + + // ── monitor 监控告警 ── + {method: "GET", prefix: "/api/admin/monitor/", perm: "monitor:view"}, + {method: "GET", path: "/api/admin/alert/rules", perm: "monitor:view"}, + {method: "GET", path: "/api/admin/alert/options", perm: "monitor:view"}, + {method: "POST", path: "/api/admin/alert/rules", perm: "monitor:edit"}, + {method: "PUT", prefix: "/api/admin/alert/rules/", perm: "monitor:edit"}, + {method: "DELETE", prefix: "/api/admin/alert/rules/", perm: "monitor:edit"}, + {method: "POST", prefix: "/api/admin/alert/rules/", suffix: "/test", perm: "monitor:edit"}, + {method: "GET", path: "/api/admin/alert/events", perm: "monitor:view"}, + {method: "PUT", prefix: "/api/admin/alert/events/", perm: "monitor:edit"}, + {method: "GET", path: "/api/admin/error-logs", perm: "monitor:view"}, + {method: "GET", prefix: "/api/admin/error-logs/", perm: "monitor:view"}, + {method: "PUT", prefix: "/api/admin/error-logs/", perm: "monitor:edit"}, + {method: "PUT", path: "/api/admin/error-logs/batch-resolve", perm: "monitor:edit"}, + {method: "GET", path: "/api/admin/error-logs/stats", perm: "monitor:view"}, + {method: "GET", path: "/api/admin/cron-jobs", perm: "system:view"}, + {method: "POST", prefix: "/api/admin/cron-jobs/", suffix: "/trigger", perm: "system:edit"}, + + // ── system 系统设置 ── + {method: "GET", path: "/api/admin/settings/categories", perm: "system:view"}, + {method: "GET", prefix: "/api/admin/settings/", perm: "system:view"}, + {method: "PUT", prefix: "/api/admin/settings/", perm: "system:edit"}, + // Payment settings & channels (system scope) + {method: "GET", path: "/api/admin/payment-channels", perm: "system:view"}, + {method: "PUT", prefix: "/api/admin/payment-channels/", perm: "system:edit"}, + {method: "GET", path: "/api/admin/payment-settings", perm: "system:view"}, + {method: "PUT", path: "/api/admin/payment-settings", perm: "system:edit"}, + {method: "GET", prefix: "/api/admin/update/", perm: "system:update"}, + {method: "POST", prefix: "/api/admin/update/", perm: "system:update"}, + {method: "GET", path: "/api/admin/data-governance/settings", perm: "system:view"}, + {method: "PUT", path: "/api/admin/data-governance/settings", perm: "system:edit"}, + {method: "POST", prefix: "/api/admin/data-governance/", perm: "system:edit"}, + {method: "GET", prefix: "/api/admin/plugins", perm: "system:plugin"}, + {method: "POST", prefix: "/api/admin/plugins/", perm: "system:plugin"}, + {method: "PUT", prefix: "/api/admin/plugins/", perm: "system:plugin"}, + {method: "GET", prefix: "/api/admin/email/", perm: "system:view"}, + + // ── tenant level config 租户等级配置 ── + {method: "GET", path: "/api/admin/tenant-level-configs", perm: "tenant:view"}, + {method: "POST", path: "/api/admin/tenant-level-configs", perm: "tenant:create"}, + {method: "PUT", prefix: "/api/admin/tenant-level-configs/", perm: "tenant:edit"}, + {method: "DELETE", prefix: "/api/admin/tenant-level-configs/", perm: "tenant:delete"}, + + // ── promo 优惠码管理 ── + {method: "GET", path: "/api/admin/promo-codes", perm: "promo:view"}, + {method: "GET", path: "/api/admin/promo-codes/export", perm: "promo:view"}, + {method: "GET", prefix: "/api/admin/promo-codes/", perm: "promo:view"}, + {method: "POST", path: "/api/admin/promo-codes", perm: "promo:create"}, + {method: "PUT", prefix: "/api/admin/promo-codes/", perm: "promo:edit"}, + + // ── member 成员管理(管理后台维度) ── + {method: "GET", path: "/api/admin/members", perm: "member:view"}, + {method: "POST", path: "/api/admin/members", perm: "member:import"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:view"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:view"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:view"}, + + // ── redemption 兑换码管理 ── + {method: "GET", path: "/api/admin/redemptions", perm: "redemption:view"}, + {method: "GET", path: "/api/admin/redemptions/usages", perm: "redemption:view"}, + {method: "GET", path: "/api/admin/redemptions/export", perm: "redemption:view"}, + {method: "POST", path: "/api/admin/redemptions", perm: "redemption:create"}, + {method: "PUT", prefix: "/api/admin/redemptions/", perm: "redemption:edit"}, + + // ── permission 权限管理(仅 user 组) ── + {method: "GET", path: "/api/admin/permissions", perm: "user:view"}, + {method: "GET", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:view"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:edit"}, + {method: "PUT", prefix: "/api/admin/users/", suffix: "/data-scopes", perm: "user:edit"}, + + // ── admin session and security ── + {method: "POST", path: "/api/admin/auth/logout", perm: "self:access"}, + {method: "GET", path: "/api/admin/auth/sessions", perm: "self:access"}, + {method: "DELETE", prefix: "/api/admin/auth/sessions/user/", perm: "user:edit"}, + {method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "self:access"}, + {method: "PUT", path: "/api/admin/auth/change-password", perm: "self:access"}, + {method: "POST", prefix: "/api/admin/security/2fa/", perm: "self:access"}, + {method: "GET", path: "/api/admin/security/login-history", perm: "audit:view"}, + {method: "GET", path: "/api/admin/security/tenant-login-history", perm: "audit:view"}, + {method: "GET", prefix: "/api/admin/agreements/", suffix: "/acceptances", perm: "audit:view"}, + {method: "GET", path: "/api/admin/agreements/pending", perm: "self:access"}, + {method: "POST", path: "/api/admin/agreements/accept", perm: "self:access"}, + {method: "GET", path: "/api/admin/agreements", perm: "system:view"}, + {method: "GET", prefix: "/api/admin/agreements/", perm: "system:view"}, + {method: "POST", path: "/api/admin/agreements", perm: "system:edit"}, + {method: "PUT", prefix: "/api/admin/agreements/", perm: "system:edit"}, + {method: "DELETE", prefix: "/api/admin/agreements/", perm: "system:edit"}, + {method: "POST", prefix: "/api/admin/agreements/", suffix: "/publish", perm: "system:edit"}, + + // ── dashboard and async task management ── + {method: "GET", prefix: "/api/admin/dashboard", perm: "dashboard:view"}, + {method: "GET", path: "/api/admin/tasks", perm: "task:view"}, + {method: "GET", prefix: "/api/admin/tasks/", perm: "task:view"}, + {method: "POST", prefix: "/api/admin/tasks/", suffix: "/cancel", perm: "task:edit"}, + {method: "POST", path: "/api/admin/usage-logs/cleanup", perm: "system:edit"}, + {method: "GET", path: "/api/admin/usage-logs/cleanup/tasks", perm: "system:view"}, + {method: "POST", prefix: "/api/admin/usage-logs/cleanup/tasks/", suffix: "/cancel", perm: "system:edit"}, +} + +type permissionRule struct { + method string // HTTP method + path string // exact path match (mutually exclusive with prefix) + prefix string // path prefix match + suffix string // path suffix match (combined with prefix) + perm string // required permission point +} + +// AdminPermissionGuard enforces RBAC permission checks for admin routes. +// 当前策略:未匹配到权限规则的接口默认放行(仅对已认证 admin)。 +// 已配置规则的接口仍按权限点鉴权;漏配的接口不至于直接 403,降低上线回归风险。 +// 待 adminPermissionRules 全量补齐后,可将下方 perm=="" 分支改回返回 403 以收紧权限。 +func AdminPermissionGuard(r *ghttp.Request) { + if isAdminPublicPath(r.URL.Path) { + r.Middleware.Next() + return + } + + role := GetUserRole(r.Context()) + if role == "super_admin" { + r.Middleware.Next() + return + } + + perm := matchPermission(r.Method, r.URL.Path) + if perm == "" { + // 默认放行:未配置权限规则的接口,对已认证 admin 放行 + r.Middleware.Next() + return + } + + if perm == "self:access" { + r.Middleware.Next() + return + } + + userID := GetUserID(r.Context()) + if !admin.HasPermission(r.Context(), userID, role, perm) { + response.ErrorMsg(r, consts.CodeForbidden, "缺少权限:"+perm) + return + } + + r.Middleware.Next() +} + +// matchPermission finds the permission required for the given method and path. +// Matching priority: exact path > prefix+suffix > prefix-only (catch-all). +// This three-phase approach eliminates rule ordering dependencies. +func matchPermission(method, path string) string { + // Phase 1: exact path match (highest priority) + for _, rule := range adminPermissionRules { + if rule.method != method || rule.path == "" { + continue + } + if path == rule.path { + return rule.perm + } + } + + // Phase 2: prefix+suffix match (more specific) + var bestPrefixSuffix *permissionRule + for _, rule := range adminPermissionRules { + if rule.method != method || rule.prefix == "" || rule.suffix == "" { + continue + } + if strings.HasPrefix(path, rule.prefix) { + remainder := path[len(rule.prefix):] + if strings.HasSuffix(path, rule.suffix) || strings.Contains(remainder, strings.TrimPrefix(rule.suffix, "/")) { + if bestPrefixSuffix == nil || len(rule.prefix)+len(rule.suffix) > len(bestPrefixSuffix.prefix)+len(bestPrefixSuffix.suffix) { + r := rule + bestPrefixSuffix = &r + } + } + } + } + if bestPrefixSuffix != nil { + return bestPrefixSuffix.perm + } + + // Phase 3: prefix-only match (catch-all, lowest priority) + var bestPrefixOnly *permissionRule + for _, rule := range adminPermissionRules { + if rule.method != method || rule.prefix == "" || rule.suffix != "" { + continue + } + if strings.HasPrefix(path, rule.prefix) { + if bestPrefixOnly == nil || len(rule.prefix) > len(bestPrefixOnly.prefix) { + r := rule + bestPrefixOnly = &r + } + } + } + if bestPrefixOnly != nil { + return bestPrefixOnly.perm + } + + return "" +} diff --git a/internal/middleware/rbac_test.go b/internal/middleware/rbac_test.go new file mode 100644 index 0000000..04165f9 --- /dev/null +++ b/internal/middleware/rbac_test.go @@ -0,0 +1,20 @@ +package middleware + +import "testing" + +func TestAdminPublicPathsSkipPermissionMapping(t *testing.T) { + for path := range adminPublicPaths { + if !isAdminPublicPath(path) { + t.Fatalf("admin public path %q is not recognized by shared public path helper", path) + } + if perm := matchPermission("POST", path); perm != "" { + t.Fatalf("admin public path %q unexpectedly maps to permission %q", path, perm) + } + } +} + +func TestMatchPermissionForProtectedAdminRoute(t *testing.T) { + if perm := matchPermission("GET", "/api/admin/users"); perm != "user:view" { + t.Fatalf("matchPermission(GET /api/admin/users) = %q, want user:view", perm) + } +} diff --git a/internal/middleware/tenant_auth.go b/internal/middleware/tenant_auth.go index 4138408..e063109 100644 --- a/internal/middleware/tenant_auth.go +++ b/internal/middleware/tenant_auth.go @@ -19,16 +19,22 @@ var tenantPublicPaths = map[string]bool{ "/api/tenant/auth/register": true, "/api/tenant/auth/login": true, "/api/tenant/auth/refresh": true, + "/api/tenant/auth/2fa/verify": true, "/api/tenant/email/send-code": true, "/api/tenant/email/reset-password": true, "/api/tenant/members/join": true, "/api/tenant/members/invite-info": true, "/api/tenant/agreements/current": true, + "/api/tenant/oauth/authorize": true, + "/api/tenant/help/categories": true, + "/api/tenant/help/search": true, } // tenantPublicPrefixes lists path prefixes that skip JWT auth (for dynamic routes like /current/{code}). var tenantPublicPrefixes = []string{ "/api/tenant/agreements/current/", + "/api/tenant/help/categories/", + "/api/tenant/help/articles/", } // TenantAuth is JWT authentication middleware for tenant console. @@ -45,6 +51,11 @@ func TenantAuth(r *ghttp.Request) { return } } + // OAuth callback: /api/tenant/oauth/{provider}/callback + if strings.HasPrefix(r.URL.Path, "/api/tenant/oauth/") && strings.HasSuffix(r.URL.Path, "/callback") { + r.Middleware.Next() + return + } tokenStr := extractBearerToken(r) if tokenStr == "" { 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_admin_users.go b/internal/model/do/sys_admin_users.go index 0e9dbdd..61168ef 100644 --- a/internal/model/do/sys_admin_users.go +++ b/internal/model/do/sys_admin_users.go @@ -11,19 +11,21 @@ import ( // SysAdminUsers is the golang structure of table sys_admin_users for DAO operations like Where/Data. type SysAdminUsers struct { - g.Meta `orm:"table:sys_admin_users, do:true"` - Id any // 主键ID - Username any // 登录用户名 - PasswordHash any // 密码哈希(bcrypt) - Email any // 邮箱地址(可选,可为NULL) - DisplayName any // 显示名称 - Role any // 角色:super_admin(全权限)/ admin(可配置权限) - Status any // 状态:active(启用)/ disabled(禁用) - LastLoginAt *gtime.Time // 最后登录时间 - LastLoginIp any // 最后登录IP - CreatedAt *gtime.Time // 创建时间 - UpdatedAt *gtime.Time // 更新时间 - TotpSecret any // TOTP 密钥(AES-256 加密存储) - TotpEnabled any // 是否启用双因素认证 - BackupCodes any // 备用恢复码(bcrypt 哈希存储) + g.Meta `orm:"table:sys_admin_users, do:true"` + Id any // 主键ID + Username any // 登录用户名 + PasswordHash any // 密码哈希(bcrypt) + Email any // 邮箱地址(可选,可为NULL) + DisplayName any // 显示名称 + Role any // 角色:super_admin(全权限)/ admin(可配置权限) + Status any // 状态:active(启用)/ disabled(禁用) + LastLoginAt *gtime.Time // 最后登录时间 + LastLoginIp any // 最后登录IP + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 + TotpSecret any // TOTP 密钥(AES-256 加密存储) + TotpEnabled any // 是否启用双因素认证 + BackupCodes any // 备用恢复码(bcrypt 哈希存储) + FailedAttempts any // 连续登录失败次数(成功登录后归零) + LockedUntil *gtime.Time // 锁定截止时间(连续5次失败后锁定30分钟) } 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_admin_users.go b/internal/model/entity/sys_admin_users.go index 6d5be4d..5839ac5 100644 --- a/internal/model/entity/sys_admin_users.go +++ b/internal/model/entity/sys_admin_users.go @@ -10,18 +10,20 @@ import ( // SysAdminUsers is the golang structure for table sys_admin_users. type SysAdminUsers struct { - Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID - Username string `json:"username" orm:"username" description:"登录用户名"` // 登录用户名 - PasswordHash string `json:"password_hash" orm:"password_hash" description:"密码哈希(bcrypt)"` // 密码哈希(bcrypt) - Email string `json:"email" orm:"email" description:"邮箱地址(可选,可为NULL)"` // 邮箱地址(可选,可为NULL) - DisplayName string `json:"display_name" orm:"display_name" description:"显示名称"` // 显示名称 - Role string `json:"role" orm:"role" description:"角色:super_admin(全权限)/ admin(可配置权限)"` // 角色:super_admin(全权限)/ admin(可配置权限) - Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用) - LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at" description:"最后登录时间"` // 最后登录时间 - LastLoginIp string `json:"last_login_ip" orm:"last_login_ip" description:"最后登录IP"` // 最后登录IP - CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 - UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 - TotpSecret string `json:"totp_secret" orm:"totp_secret" description:"TOTP 密钥(AES-256 加密存储)"` // TOTP 密钥(AES-256 加密存储) - TotpEnabled bool `json:"totp_enabled" orm:"totp_enabled" description:"是否启用双因素认证"` // 是否启用双因素认证 - BackupCodes string `json:"backup_codes" orm:"backup_codes" description:"备用恢复码(bcrypt 哈希存储)"` // 备用恢复码(bcrypt 哈希存储) + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + Username string `json:"username" orm:"username" description:"登录用户名"` // 登录用户名 + PasswordHash string `json:"password_hash" orm:"password_hash" description:"密码哈希(bcrypt)"` // 密码哈希(bcrypt) + Email string `json:"email" orm:"email" description:"邮箱地址(可选,可为NULL)"` // 邮箱地址(可选,可为NULL) + DisplayName string `json:"display_name" orm:"display_name" description:"显示名称"` // 显示名称 + Role string `json:"role" orm:"role" description:"角色:super_admin(全权限)/ admin(可配置权限)"` // 角色:super_admin(全权限)/ admin(可配置权限) + Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用) + LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at" description:"最后登录时间"` // 最后登录时间 + LastLoginIp string `json:"last_login_ip" orm:"last_login_ip" description:"最后登录IP"` // 最后登录IP + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 + TotpSecret string `json:"totp_secret" orm:"totp_secret" description:"TOTP 密钥(AES-256 加密存储)"` // TOTP 密钥(AES-256 加密存储) + TotpEnabled bool `json:"totp_enabled" orm:"totp_enabled" description:"是否启用双因素认证"` // 是否启用双因素认证 + BackupCodes string `json:"backup_codes" orm:"backup_codes" description:"备用恢复码(bcrypt 哈希存储)"` // 备用恢复码(bcrypt 哈希存储) + FailedAttempts int `json:"failed_attempts" orm:"failed_attempts" description:"连续登录失败次数(成功登录后归零)"` // 连续登录失败次数(成功登录后归零) + LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until" description:"锁定截止时间(连续5次失败后锁定30分钟)"` // 锁定截止时间(连续5次失败后锁定30分钟) } 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/internal/packed/setup.html b/internal/packed/setup.html index 6ac7bb3..dd29ad3 100644 --- a/internal/packed/setup.html +++ b/internal/packed/setup.html @@ -144,7 +144,7 @@ + placeholder="至少8位,含字母和数字" autocomplete="new-password"> diff --git a/migrations/000011_add_admin_lockout_and_totp_fix.sql b/migrations/000011_add_admin_lockout_and_totp_fix.sql new file mode 100644 index 0000000..4f93f23 --- /dev/null +++ b/migrations/000011_add_admin_lockout_and_totp_fix.sql @@ -0,0 +1,22 @@ +-- +goose Up + +-- P1-1: 管理员暴力破解防护 — 新增失败计数和锁定字段 +ALTER TABLE sys_admin_users + ADD COLUMN failed_attempts INT DEFAULT 0 NOT NULL, + ADD COLUMN 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); + +-- +goose Down + +ALTER TABLE sys_admin_users + DROP COLUMN IF EXISTS failed_attempts, + DROP COLUMN IF EXISTS locked_until; + +ALTER TABLE sys_admin_users ALTER COLUMN totp_secret TYPE VARCHAR(64); +ALTER TABLE tnt_users ALTER COLUMN totp_secret TYPE VARCHAR(64); 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/migrations/000012_fix_project_keys_and_open_secret.sql b/migrations/000012_fix_project_keys_and_open_secret.sql new file mode 100644 index 0000000..325edb6 --- /dev/null +++ b/migrations/000012_fix_project_keys_and_open_secret.sql @@ -0,0 +1,11 @@ +-- +goose Up +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'; + +UPDATE api_keys +SET key_type = 'project' +WHERE project_id IS NOT NULL + AND key_type = 'personal'; + +-- +goose Down +ALTER TABLE opn_apps DROP COLUMN IF EXISTS encrypted_secret; diff --git a/relay/common/billing.go b/relay/common/billing.go index fe8d256..bcdebc2 100644 --- a/relay/common/billing.go +++ b/relay/common/billing.go @@ -10,7 +10,7 @@ type BillingProvider interface { PreDeduct(ctx context.Context, tenantID int64, modelName string, inputTokens, maxTokens int, isStream bool, requestID string) (preDeductAmount float64, err error) // Settle 结算费用(成功请求) - Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) error + Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error) // SettleWithUsage 完整 Usage 结算(含 cache token + 计费快照) SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, @@ -21,10 +21,10 @@ type BillingProvider interface { SettleFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error // SettleStreamInterrupted 流式中断结算 - SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) error + SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error) // CheckRateLimit QPS 限流检查 - CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64) (allowed bool, limitLevel string, remaining int, resetAt int64) + CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (allowed bool, limitLevel string, limit int, remaining int, resetAt int64) // AcquireConcurrent 并发限制检查(含租户级 + 模型级) AcquireConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) bool @@ -32,6 +32,12 @@ type BillingProvider interface { // ReleaseConcurrent 释放并发限制(含租户级 + 模型级) ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) + // AcquireApiKeyConcurrent 获取 API Key 级并发许可 + AcquireApiKeyConcurrent(ctx context.Context, apiKeyID int64, limit int) bool + + // ReleaseApiKeyConcurrent 释放 API Key 级并发许可 + ReleaseApiKeyConcurrent(ctx context.Context, apiKeyID int64) + // CheckScope 检查 API Key scope CheckScope(scope string, relayMode string) bool @@ -41,8 +47,14 @@ type BillingProvider interface { // CheckMemberQuota 检查成员额度是否足够 CheckMemberQuota(ctx context.Context, tenantID, userID int64, preDeductAmount float64) error + // CheckApiKeyQuota 检查 API Key 额度是否足够 + CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float64) error + // IncrMemberQuotaUsed 结算后累加成员已用额度 IncrMemberQuotaUsed(ctx context.Context, tenantID, userID int64, amount float64) + + // IncrApiKeyQuotaUsed 结算后累加 API Key 已用额度 + IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount float64) } // RateLimitInfo 限流信息(用于设置响应头) diff --git a/relay/common/provider.go b/relay/common/provider.go index d096a0c..0355bbc 100644 --- a/relay/common/provider.go +++ b/relay/common/provider.go @@ -98,12 +98,17 @@ type DataProvider interface { // ApiKeyInfo API Key 验证结果 type ApiKeyInfo struct { - ID int64 - TenantID int64 - UserID int64 - ProjectID int64 // 项目密钥关联的项目 ID,个人密钥为 0 - Scope string // full / chat_only / embeddings_only / images_only / read_only / custom - Status string // active / disabled / expired + ID int64 + TenantID int64 + UserID int64 + ProjectID int64 // 项目密钥关联的项目 ID,个人密钥为 0 + Scope string // full / chat_only / embeddings_only / images_only / read_only / custom + Status string // active / disabled / expired + RateLimitQps int // 0 表示使用全局默认 + RateLimitConcurrency int // 0 表示不限制 + IpWhitelist string // 逗号分隔 IP/CIDR 白名单 + TotalQuota float64 // 0 表示不限制 + UsedQuota float64 } // ChannelSelection 渠道选择结果 diff --git a/relay/common/task_billing.go b/relay/common/task_billing.go index 25138cc..6cef9d0 100644 --- a/relay/common/task_billing.go +++ b/relay/common/task_billing.go @@ -11,12 +11,27 @@ type TaskBillingProvider interface { // PreDeductTask 预扣任务费用 PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error) + // CheckRateLimit QPS 限流检查 + CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (allowed bool, limitLevel string, limit int, remaining int, resetAt int64) + + // AcquireApiKeyConcurrent 获取 API Key 级并发许可 + AcquireApiKeyConcurrent(ctx context.Context, apiKeyID int64, limit int) bool + + // ReleaseApiKeyConcurrent 释放 API Key 级并发许可 + ReleaseApiKeyConcurrent(ctx context.Context, apiKeyID int64) + + // CheckApiKeyQuota 检查 API Key 额度是否足够 + CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float64) error + // SettleTaskSuccess 任务成功结算(含计费快照) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount float64, totalTokens, completionTokens int, ratios map[string]float64, taskID string) (*SettlementResult, error) // SettleTaskFailed 任务失败退还预扣 SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error + // IncrApiKeyQuotaUsed 结算后累加 API Key 已用额度 + IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount float64) + // AdjustTaskBilling 调整预扣金额(提交后上游确认了新参数) AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost float64) (float64, error) diff --git a/relay/handler/realtime_handler.go b/relay/handler/realtime_handler.go index a236955..cd7ec10 100644 --- a/relay/handler/realtime_handler.go +++ b/relay/handler/realtime_handler.go @@ -24,12 +24,18 @@ var websocketUpgrader = websocket.Upgrader{ // RealtimeContext Realtime 请求上下文 type RealtimeContext struct { - TenantID int64 - UserID int64 - ApiKeyID int64 - ProjectID int64 // 通过 API Key 关联的项目 ID - RequestID string - ClientIP string + TenantID int64 + UserID int64 + ApiKeyID int64 + ProjectID int64 // 通过 API Key 关联的项目 ID + RequestID string + ClientIP string + Scope string + KeyRateLimitQps int + KeyConcurrency int + KeyIpWhitelist string + KeyTotalQuota float64 + KeyUsedQuota float64 } // HandleRealtime 处理 /v1/realtime WebSocket 请求 @@ -65,6 +71,31 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext, return nil, nil, fmt.Errorf("model not found in first websocket message") } + billingResult := &BillingResult{} + if billing != nil { + if !billing.CheckScope(rc.Scope, "realtime") { + return nil, billingResult, constant.NewAuthError("API key scope denied") + } + if !billing.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + return nil, billingResult, constant.NewAuthError("IP address is not allowed") + } + allowed, limitLevel, limit, remaining, resetAt := billing.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps) + if !allowed { + return nil, billingResult, &RelayErrorWithRateLimit{ + StatusCode: 429, + Message: fmt.Sprintf("rate limit exceeded at %s level", limitLevel), + LimitLevel: limitLevel, + Remaining: remaining, + ResetAt: resetAt, + } + } + billingResult.RateLimitInfo = &common.RateLimitInfo{ + Limit: limit, + Remaining: remaining, + ResetAt: resetAt, + } + } + // 3. 验证模型 _, _, err = provider.GetModelMapping(ctx, modelName) if err != nil { @@ -77,6 +108,11 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext, } else if !allowed { return nil, nil, constant.NewAuthError("model not allowed for this member") } + if allowed, err := provider.CheckApiKeyModelAccess(ctx, rc.ApiKeyID, modelName); err != nil { + return nil, nil, err + } else if !allowed { + return nil, nil, constant.NewAuthError("model not allowed for this API key") + } // 4. 渠道选择 selection, err := provider.GetChannelForModel(ctx, rc.TenantID, rc.UserID, modelName, nil) @@ -121,14 +157,35 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext, // 7. 预扣费用(Realtime 使用 0 input tokens 作为初始估算) var preDeductAmount float64 if billing != nil { + if !billing.AcquireConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName) { + return nil, billingResult, constant.NewRateLimitError("concurrent request limit exceeded") + } + defer billing.ReleaseConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, modelName) + + if !billing.AcquireApiKeyConcurrent(ctx, rc.ApiKeyID, rc.KeyConcurrency) { + return nil, billingResult, constant.NewRateLimitError("API key concurrent request limit exceeded") + } + defer billing.ReleaseApiKeyConcurrent(ctx, rc.ApiKeyID) + + if err := billing.CheckMemberQuota(ctx, rc.TenantID, rc.UserID, 0); err != nil { + return nil, billingResult, constant.NewQuotaError("member quota exceeded", err) + } + if err := billing.CheckApiKeyQuota(ctx, rc.ApiKeyID, 0); err != nil { + return nil, billingResult, constant.NewQuotaError("API key quota exceeded", err) + } + amt, billErr := billing.PreDeduct(ctx, rc.TenantID, modelName, 0, 0, false, rc.RequestID) if billErr != nil { return nil, nil, constant.NewQuotaError("insufficient balance", billErr) } preDeductAmount = amt + if err := billing.CheckApiKeyQuota(ctx, rc.ApiKeyID, amt); err != nil { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, amt) + return nil, billingResult, constant.NewQuotaError("API key quota exceeded", err) + } } - billingResult := &BillingResult{PreDeductAmount: preDeductAmount} + billingResult.PreDeductAmount = preDeductAmount // 8. 建立 Realtime 代理 proxy := openai.NewRealtimeProxy(info) @@ -158,11 +215,19 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext, if streamUsage == nil { streamUsage = &common.Usage{} } - _ = billing.SettleStreamInterrupted(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, + settleResult, _ := billing.SettleStreamInterrupted(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, modelName, rc.RequestID, "realtime", streamUsage, preDeductAmount, rc.ProjectID) + if settleResult != nil && settleResult.ActualCost > 0 { + billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost) + billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost) + } } else if usage != nil { - _ = billing.Settle(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, + settleResult, _ := billing.Settle(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, modelName, rc.RequestID, "realtime", usage, preDeductAmount, rc.ProjectID) + if settleResult != nil && settleResult.ActualCost > 0 { + billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost) + billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost) + } } } diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index a768270..268d275 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -15,6 +15,7 @@ import ( commonlogic "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/monitor" + tenantlogic "github.com/qianfree/team-api/internal/logic/tenant" "github.com/qianfree/team-api/relay/channel" "github.com/qianfree/team-api/relay/common" "github.com/qianfree/team-api/relay/constant" @@ -33,6 +34,11 @@ type RelayContext struct { Writer http.ResponseWriter Scope string // API Key scope ClientIP string // 客户端 IP + KeyRateLimitQps int // API Key QPS 覆盖值,0 表示使用全局默认 + KeyConcurrency int // API Key 并发限制,0 表示不限制 + KeyIpWhitelist string // API Key IP 白名单 + KeyTotalQuota float64 // API Key 总额度,0 表示不限制 + KeyUsedQuota float64 // API Key 已用额度(仅展示/审计,额度检查会鲜读 DB) ForwardingTrace *common.ForwardingTrace // 转发路径追踪(仅管理员可见) } @@ -77,6 +83,14 @@ func validateRelayRequest( } relayModeStr := relayModeString(relayMode) + if billing != nil { + if !billing.CheckScope(rc.Scope, relayModeStr) { + return nil, constant.NewAuthError("API key scope denied") + } + if !billing.CheckIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + return nil, constant.NewAuthError("IP address is not allowed") + } + } // 2. 解析请求体获取模型名 var rawRequest map[string]json.RawMessage @@ -145,7 +159,7 @@ func validateRelayRequest( // 5. QPS 限流检查 billingResult := &BillingResult{Deprecation: depInfo} if billing != nil { - allowed, limitLevel, remaining, resetAt := billing.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID) + allowed, limitLevel, limit, remaining, resetAt := billing.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps) if !allowed { return nil, &RelayErrorWithRateLimit{ StatusCode: 429, @@ -156,7 +170,7 @@ func validateRelayRequest( } } billingResult.RateLimitInfo = &common.RateLimitInfo{ - Limit: 0, // 由 middleware 设置具体值 + Limit: limit, Remaining: remaining, ResetAt: resetAt, } @@ -231,6 +245,13 @@ func settleSuccessfulRequest( // 16.5 累加成员已用额度 if billing != nil && settleResult != nil && settleResult.ActualCost > 0 { billing.IncrMemberQuotaUsed(postCtx, rc.TenantID, rc.UserID, settleResult.ActualCost) + billing.IncrApiKeyQuotaUsed(postCtx, rc.ApiKeyID, settleResult.ActualCost) + } + if settleResult != nil && settleResult.ActualCost > 0 && rc.ProjectID > 0 { + if err := tenantlogic.CheckProjectBudget(postCtx, rc.TenantID, rc.ProjectID); err != nil { + g.Log().Warningf(postCtx, "[RelayHandler] Check project budget failed: tenant=%d project=%d request=%s err=%v", + rc.TenantID, rc.ProjectID, rc.RequestID, err) + } } // 17. 更新健康度 + 记录用量 + 更新亲和性 @@ -339,6 +360,11 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He return nil, nil, constant.NewRateLimitError("concurrent request limit exceeded") } defer billing.ReleaseConcurrent(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, v.modelName) + + if !billing.AcquireApiKeyConcurrent(ctx, rc.ApiKeyID, rc.KeyConcurrency) { + return nil, nil, constant.NewRateLimitError("API key concurrent request limit exceeded") + } + defer billing.ReleaseApiKeyConcurrent(ctx, rc.ApiKeyID) } monitor.RegisterRequest(&monitor.TrackedRequest{ @@ -357,6 +383,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He if err := billing.CheckMemberQuota(ctx, rc.TenantID, rc.UserID, 0); err != nil { return nil, nil, constant.NewQuotaError("member quota exceeded", err) } + if err := billing.CheckApiKeyQuota(ctx, rc.ApiKeyID, 0); err != nil { + return nil, nil, constant.NewQuotaError("API key quota exceeded", err) + } } var preDeductAmount float64 @@ -367,6 +396,10 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } preDeductAmount = amt v.billingResult.PreDeductAmount = amt + if err := billing.CheckApiKeyQuota(ctx, rc.ApiKeyID, amt); err != nil { + _ = billing.SettleFailed(ctx, rc.TenantID, rc.RequestID, amt) + return nil, nil, constant.NewQuotaError("API key quota exceeded", err) + } } // Phase 3: 带重试的渠道调度与请求执行 @@ -469,8 +502,21 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He streamUsage = &common.Usage{} } if billing != nil && preDeductAmount > 0 { - _ = billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, + settleResult, err := billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID, v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID) + if err != nil { + g.Log().Warningf(settleCtx, "[RelayHandler] Stream interrupted settlement failed: tenant=%d project=%d request=%s err=%v", + rc.TenantID, rc.ProjectID, rc.RequestID, err) + } else if rc.ProjectID > 0 { + if err := tenantlogic.CheckProjectBudget(settleCtx, rc.TenantID, rc.ProjectID); err != nil { + g.Log().Warningf(settleCtx, "[RelayHandler] Check project budget failed: tenant=%d project=%d request=%s err=%v", + rc.TenantID, rc.ProjectID, rc.RequestID, err) + } + } + if settleResult != nil && settleResult.ActualCost > 0 { + billing.IncrMemberQuotaUsed(settleCtx, rc.TenantID, rc.UserID, settleResult.ActualCost) + billing.IncrApiKeyQuotaUsed(settleCtx, rc.ApiKeyID, settleResult.ActualCost) + } } recordFailedUsage(provider, rc, selection.ChannelID, v.modelName, v.relayMode, v.isStream, err) finalizeTrace(trace, rc, hop, false, attempt, selection, err.Error(), info.LatencyMs()) diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index fcfc968..73bccf3 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -7,7 +7,9 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "strings" "time" "github.com/gogf/gf/v2/frame/g" @@ -34,9 +36,49 @@ type TaskRelayContext struct { Writer http.ResponseWriter Scope string ClientIP string + KeyRateLimitQps int + KeyConcurrency int + KeyIpWhitelist string + KeyTotalQuota float64 + KeyUsedQuota float64 ForwardingTrace *common.ForwardingTrace } +func checkTaskIPWhitelist(whitelist string, clientIP string) bool { + if whitelist == "" { + return true + } + + host, _, err := net.SplitHostPort(clientIP) + if err != nil { + host = clientIP + } + host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + + parsedIP := net.ParseIP(host) + if parsedIP == nil { + return false + } + + for _, item := range strings.Split(whitelist, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if item == host { + return true + } + if strings.Contains(item, "/") { + _, cidr, err := net.ParseCIDR(item) + if err == nil && cidr.Contains(parsedIP) { + return true + } + } + } + + return false +} + // HandleTaskSubmit 异步任务提交管线 func HandleTaskSubmit( ctx context.Context, @@ -69,6 +111,25 @@ func HandleTaskSubmit( return } + if rc.Scope == "read_only" { + writeTaskError(rc.Writer, http.StatusForbidden, "API key scope denied", "") + return + } + if rc.KeyIpWhitelist != "" && !checkTaskIPWhitelist(rc.KeyIpWhitelist, rc.ClientIP) { + writeTaskError(rc.Writer, http.StatusForbidden, "IP address is not allowed", "") + return + } + allowed, limitLevel, _, _, _ := billingProvider.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps) + if !allowed { + writeTaskError(rc.Writer, http.StatusTooManyRequests, fmt.Sprintf("rate limit exceeded at %s level", limitLevel), "") + return + } + if !billingProvider.AcquireApiKeyConcurrent(ctx, rc.ApiKeyID, rc.KeyConcurrency) { + writeTaskError(rc.Writer, http.StatusTooManyRequests, "API key concurrent request limit exceeded", "") + return + } + defer billingProvider.ReleaseApiKeyConcurrent(ctx, rc.ApiKeyID) + // 2. 确定任务平台 providerType := constant.ProviderType(channelMeta.ChannelType) platform, ok := constant.ProviderTypeToTaskPlatform(providerType) @@ -121,6 +182,11 @@ func HandleTaskSubmit( } g.Log().Debugf(ctx, "HandleTaskSubmit: estimatedCost=%.4f", estimatedCost) + if err := billingProvider.CheckApiKeyQuota(ctx, rc.ApiKeyID, estimatedCost); err != nil { + writeTaskError(rc.Writer, http.StatusPaymentRequired, "API key quota exceeded", "") + return + } + preDeductAmount, err := billingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost, modelName) if err != nil { g.Log().Warningf(ctx, "HandleTaskSubmit: pre-deduct failed, model=%s, err=%v", modelName, err) diff --git a/relay/scheduler/scheduler.go b/relay/scheduler/scheduler.go index b6fd310..44aabaf 100644 --- a/relay/scheduler/scheduler.go +++ b/relay/scheduler/scheduler.go @@ -107,19 +107,7 @@ func groupByPriority(candidates []ChannelCandidate) [][]ChannelCandidate { func weightedRandomSelect(candidates []ChannelCandidate) ChannelCandidate { totalWeight := 0 for _, c := range candidates { - w := c.Weight - if w <= 0 { - w = 1 - } - - // 健康度降权:50-79 权重减半,20-49 权重降到 1/4 - if c.HealthScore < 80 { - if c.HealthScore >= 50 { - w = w / 2 - } else { - w = w / 4 - } - } + w := effectiveWeight(c) totalWeight += w } @@ -130,17 +118,7 @@ func weightedRandomSelect(candidates []ChannelCandidate) ChannelCandidate { r := rand.IntN(totalWeight) cumWeight := 0 for _, c := range candidates { - w := c.Weight - if w <= 0 { - w = 1 - } - if c.HealthScore < 80 { - if c.HealthScore >= 50 { - w = w / 2 - } else { - w = w / 4 - } - } + w := effectiveWeight(c) cumWeight += w if r < cumWeight { return c @@ -150,6 +128,26 @@ func weightedRandomSelect(candidates []ChannelCandidate) ChannelCandidate { return candidates[len(candidates)-1] } +func effectiveWeight(c ChannelCandidate) int { + w := c.Weight + if w <= 0 { + w = 1 + } + + // 健康度降权:50-79 权重减半,20-49 权重降到 1/4 + if c.HealthScore < 80 { + if c.HealthScore >= 50 { + w = w / 2 + } else { + w = w / 4 + } + } + if w <= 0 { + return 1 + } + return w +} + func candidateToResult(c ChannelCandidate) *SchedulerResult { return &SchedulerResult{ ChannelID: c.ChannelID, diff --git a/relay/scheduler/scheduler_test.go b/relay/scheduler/scheduler_test.go index 4ab71c2..203ff9b 100644 --- a/relay/scheduler/scheduler_test.go +++ b/relay/scheduler/scheduler_test.go @@ -95,21 +95,44 @@ func TestSelect_WeightedRandom(t *testing.T) { } func TestSelect_HealthDegradation(t *testing.T) { - // Both same priority, but one has lower health (50-79 range → weight halved) - candidates := []ChannelCandidate{ - {ChannelID: 1, Priority: 10, Weight: 100, HealthScore: 80}, // full weight - {ChannelID: 2, Priority: 10, Weight: 100, HealthScore: 60}, // weight halved + tests := []struct { + name string + input ChannelCandidate + expect int + }{ + { + name: "healthy full weight", + input: ChannelCandidate{Weight: 100, HealthScore: 80}, + expect: 100, + }, + { + name: "degraded half weight", + input: ChannelCandidate{Weight: 100, HealthScore: 60}, + expect: 50, + }, + { + name: "poor quarter weight", + input: ChannelCandidate{Weight: 100, HealthScore: 40}, + expect: 25, + }, + { + name: "zero weight defaults to selectable", + input: ChannelCandidate{Weight: 0, HealthScore: 80}, + expect: 1, + }, + { + name: "degraded low weight remains selectable", + input: ChannelCandidate{Weight: 1, HealthScore: 40}, + expect: 1, + }, } - counts := map[int64]int{} - for i := 0; i < 100; i++ { - result := Select(candidates) - counts[result.ChannelID]++ - } - - // Channel 1 should be selected more often due to higher effective weight - if counts[1] <= counts[2] { - t.Errorf("expected healthy channel to be selected more often: ch1=%d, ch2=%d", counts[1], counts[2]) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := effectiveWeight(tt.input); got != tt.expect { + t.Fatalf("effectiveWeight() = %d, want %d", got, tt.expect) + } + }) } } diff --git a/scripts/clear_channel_test_cache.go b/scripts/clear_channel_test_cache.go new file mode 100644 index 0000000..715ad74 --- /dev/null +++ b/scripts/clear_channel_test_cache.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + + _ "github.com/gogf/gf/contrib/drivers/pgsql/v2" + _ "github.com/gogf/gf/contrib/nosql/redis/v2" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcache" + "github.com/gogf/gf/v2/os/gctx" +) + +// 临时脚本:手动清除渠道探测配置的所有缓存层 +func main() { + ctx := gctx.New() + + fmt.Println("🔧 清除渠道探测配置缓存...") + + // 1. 清除本地内存缓存 + gcache.Remove(ctx, "opt:channel_auto_test_enabled") + fmt.Println("✅ 清除本地内存缓存") + + // 2. 清除 Redis L2 缓存 + _, err := g.Redis().Do(ctx, "DEL", "opt:channel_auto_test_enabled") + if err != nil { + fmt.Printf("⚠️ Redis DEL 失败: %v\n", err) + } else { + fmt.Println("✅ 清除 Redis L2 缓存") + } + + // 3. 发布缓存失效消息(通知其他实例) + _, err = g.Redis().Do(ctx, "PUBLISH", "settings:changed", "channel_auto_test_enabled") + if err != nil { + fmt.Printf("⚠️ 发布失效消息失败: %v\n", err) + } else { + fmt.Println("✅ 发布跨实例失效消息") + } + + // 4. 清除分类缓存 + gcache.Remove(ctx, "opt:category:channel") + _, _ = g.Redis().Do(ctx, "DEL", "opt:category:channel") + fmt.Println("✅ 清除分类缓存") + + // 5. 验证数据库中的值 + val, err := g.DB().Ctx(ctx).Model("sys_options"). + Where("key", "channel_auto_test_enabled"). + Value("value") + if err != nil { + fmt.Printf("⚠️ 查询数据库失败: %v\n", err) + } else { + dbValue := val.String() + fmt.Printf("📊 数据库当前值: %s\n", dbValue) + + if dbValue == "false" || dbValue == "0" { + fmt.Println("\n✅ 数据库配置正确(已关闭)") + } else { + fmt.Println("\n⚠️ 注意:数据库中配置仍为开启状态") + fmt.Println(" 请在管理后台确认是否已保存配置更改") + } + } + + fmt.Println("\n✅ 缓存清除完成!") + fmt.Println(" - 如果应用已更新代码,下次定时任务执行时会读取最新配置") + fmt.Println(" - 如果应用未更新代码,需要重启应用或等待缓存过期(10分钟)") +} diff --git a/tests/integration/tenant/project_test.go b/tests/integration/tenant/project_test.go index f0bc02f..e1ab0df 100644 --- a/tests/integration/tenant/project_test.go +++ b/tests/integration/tenant/project_test.go @@ -6,6 +6,7 @@ import ( "fmt" "testing" + "github.com/qianfree/team-api/internal/consts" admintest "github.com/qianfree/team-api/tests/integration/admin/testinfra" "github.com/qianfree/team-api/tests/integration/tenant/testinfra" ) @@ -126,6 +127,41 @@ func TestProjectApiKeys(t *testing.T) { deleteResp.AssertSuccess(t) } +func TestProjectApiKeyCreate_ArchivedProject(t *testing.T) { + client, _ := testinfra.GetAuthedClient(t) + + projectID, cleanup := testinfra.CreateTestProject(t, client) + defer cleanup() + + archiveResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil) + archiveResp.AssertSuccess(t) + + createKeyResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/api-keys", projectID), map[string]any{ + "name": "archived-project-key", + }) + createKeyResp.AssertError(t, consts.CodeProjectNotActive) +} + +func TestProjectApiKeyCreate_BudgetExhaustedProject(t *testing.T) { + client, _ := testinfra.GetAuthedClient(t) + + suffix := testinfra.RandomSuffix() + createResp := client.Post("/api/tenant/projects", map[string]any{ + "name": fmt.Sprintf("exhausted-project-%s", suffix), + "budget": 1.0, + }) + createResp.AssertSuccess(t) + projectID := createResp.GetID(t) + defer client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil) + + testinfra.MarkProjectBudgetExhausted(t, projectID) + + createKeyResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/api-keys", projectID), map[string]any{ + "name": "budget-exhausted-project-key", + }) + createKeyResp.AssertError(t, consts.CodeProjectNotActive) +} + func TestProjectUsageStats(t *testing.T) { client, _ := testinfra.GetAuthedClient(t) diff --git a/tests/integration/tenant/testinfra/db_cleanup.go b/tests/integration/tenant/testinfra/db_cleanup.go index 510a324..58e7abd 100644 --- a/tests/integration/tenant/testinfra/db_cleanup.go +++ b/tests/integration/tenant/testinfra/db_cleanup.go @@ -95,3 +95,12 @@ func HardDeleteFeedback(t *testing.T, id int64) { t.Helper() g.DB().Exec(context.Background(), "DELETE FROM spt_feedbacks WHERE id = $1", id) } + +func MarkProjectBudgetExhausted(t *testing.T, projectID int64) { + t.Helper() + if _, err := g.DB().Exec(context.Background(), + "UPDATE tnt_projects SET status = 'budget_exhausted' WHERE id = $1", + projectID); err != nil { + t.Fatalf("mark project budget exhausted: %v", err) + } +} diff --git a/web/admin/src/views/SetupPage.vue b/web/admin/src/views/SetupPage.vue index a19057f..cdebab3 100644 --- a/web/admin/src/views/SetupPage.vue +++ b/web/admin/src/views/SetupPage.vue @@ -159,7 +159,7 @@ async function handleSetup() { > diff --git a/web/admin/src/views/system/settings/AuditTab.vue b/web/admin/src/views/system/settings/AuditTab.vue index ace96a1..39ab27a 100644 --- a/web/admin/src/views/system/settings/AuditTab.vue +++ b/web/admin/src/views/system/settings/AuditTab.vue @@ -27,14 +27,14 @@ const values = useFormValues()
diff --git a/web/admin/src/views/system/settings/ChannelTab.vue b/web/admin/src/views/system/settings/ChannelTab.vue index 0b1fc18..70b1573 100644 --- a/web/admin/src/views/system/settings/ChannelTab.vue +++ b/web/admin/src/views/system/settings/ChannelTab.vue @@ -40,15 +40,15 @@ const values = useFormValues()
diff --git a/web/admin/src/views/system/settings/DataGovernanceTab.vue b/web/admin/src/views/system/settings/DataGovernanceTab.vue index 021defb..ed7c52b 100644 --- a/web/admin/src/views/system/settings/DataGovernanceTab.vue +++ b/web/admin/src/views/system/settings/DataGovernanceTab.vue @@ -6,7 +6,7 @@ function num(key: string) { return Number(values[key]) || 0 } function set(key: string, v: number | undefined): void { - values[key] = String(v ?? 0) + values[key] = v ?? 0 } diff --git a/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index 02a1c0e..18461e7 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -14,7 +14,7 @@ const values = useFormValues()
+
+ + + + + + + + + +
diff --git a/web/admin/src/views/system/settings/PaymentTab.vue b/web/admin/src/views/system/settings/PaymentTab.vue index 0f258e8..58c34d4 100644 --- a/web/admin/src/views/system/settings/PaymentTab.vue +++ b/web/admin/src/views/system/settings/PaymentTab.vue @@ -4,14 +4,14 @@ const values = useFormValues() function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') { if (direction === 'cny_to_usd') { - const usdToCny = parseFloat(values['payment_exchange_rate_usd_to_cny']) + const usdToCny = Number(values['payment_exchange_rate_usd_to_cny']) if (usdToCny > 0) { - values['payment_exchange_rate_cny_to_usd'] = String(Math.round((1 / usdToCny) * 10000) / 10000) + values['payment_exchange_rate_cny_to_usd'] = Math.round((1 / usdToCny) * 10000) / 10000 } } else { - const cnyToUsd = parseFloat(values['payment_exchange_rate_cny_to_usd']) + const cnyToUsd = Number(values['payment_exchange_rate_cny_to_usd']) if (cnyToUsd > 0) { - values['payment_exchange_rate_usd_to_cny'] = String(Math.round((1 / cnyToUsd) * 10000) / 10000) + values['payment_exchange_rate_usd_to_cny'] = Math.round((1 / cnyToUsd) * 10000) / 10000 } } } @@ -31,7 +31,7 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') { @@ -49,8 +49,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
@@ -60,8 +60,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
diff --git a/web/admin/src/views/system/settings/PerformanceTab.vue b/web/admin/src/views/system/settings/PerformanceTab.vue index f8d99c6..0b8083a 100644 --- a/web/admin/src/views/system/settings/PerformanceTab.vue +++ b/web/admin/src/views/system/settings/PerformanceTab.vue @@ -6,7 +6,7 @@ function num(key: string) { return Number(values[key]) || 0 } function set(key: string, v: number | undefined): void { - values[key] = String(v ?? 0) + values[key] = v ?? 0 } diff --git a/web/admin/src/views/tenant/MembersPage.vue b/web/admin/src/views/tenant/MembersPage.vue index 158ad3b..247e2d7 100644 --- a/web/admin/src/views/tenant/MembersPage.vue +++ b/web/admin/src/views/tenant/MembersPage.vue @@ -394,7 +394,7 @@ const { exporting, exportFile } = useExport({ - + diff --git a/web/admin/src/views/tenant/TenantsPage.vue b/web/admin/src/views/tenant/TenantsPage.vue index 822e733..84631b8 100644 --- a/web/admin/src/views/tenant/TenantsPage.vue +++ b/web/admin/src/views/tenant/TenantsPage.vue @@ -404,7 +404,7 @@ const { exporting, exportFile } = useExport({ - + diff --git a/web/tenant/src/components/common/ApiKeyEditModal.vue b/web/tenant/src/components/common/ApiKeyEditModal.vue index db42dd8..d4746b0 100644 --- a/web/tenant/src/components/common/ApiKeyEditModal.vue +++ b/web/tenant/src/components/common/ApiKeyEditModal.vue @@ -10,6 +10,8 @@ export interface ApiKeyData { name: string expires_at: string | null rate_limit_qps: number | null + rate_limit_concurrency: number | null + ip_whitelist: string[] | null total_quota: number | null used_quota: number | null } @@ -33,6 +35,8 @@ const form = reactive({ name: '', expires_at: '', rate_limit_qps: null as number | null, + rate_limit_concurrency: null as number | null, + ip_whitelist_text: '', total_quota: null as number | null, }) const loading = ref(false) @@ -133,12 +137,38 @@ function resetForm() { form.name = '' form.expires_at = '' form.rate_limit_qps = null + form.rate_limit_concurrency = null + form.ip_whitelist_text = '' form.total_quota = null selectedModelNames.value = [] modelSearch.value = '' createdKey.value = '' } +function parseIPWhitelist(): string[] { + return form.ip_whitelist_text + .split(/[\n,]/) + .map(item => item.trim()) + .filter(Boolean) +} + +function isNonNegativeNumber(value: number | null): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function applyLimitFields(body: Record) { + if (isNonNegativeNumber(form.rate_limit_qps)) { + body.rate_limit_qps = form.rate_limit_qps + } + if (isNonNegativeNumber(form.rate_limit_concurrency)) { + body.rate_limit_concurrency = form.rate_limit_concurrency + } + if (isNonNegativeNumber(form.total_quota)) { + body.total_quota = form.total_quota + } + body.ip_whitelist = parseIPWhitelist() +} + const modalTitle = computed(() => { if (props.mode === 'create') { return createdKey.value ? '密钥创建成功' : (props.projectId ? '创建项目密钥' : '创建 API 密钥') @@ -164,6 +194,8 @@ watch(() => props.show, (val) => { if (props.mode === 'edit' && props.apiKey) { form.name = props.apiKey.name form.rate_limit_qps = props.apiKey.rate_limit_qps + form.rate_limit_concurrency = props.apiKey.rate_limit_concurrency + form.ip_whitelist_text = (props.apiKey.ip_whitelist || []).join('\n') form.total_quota = props.apiKey.total_quota loadModelScopes(props.apiKey.id) } @@ -184,6 +216,7 @@ async function handleSubmit() { scope: 'full', model_names: selectedModelNames.value, } + applyLimitFields(body) if (form.expires_at) body.expires_at = form.expires_at const url = props.projectId ? `/tenant/projects/${props.projectId}/api-keys` @@ -195,16 +228,7 @@ async function handleSubmit() { name: form.name, model_names: selectedModelNames.value, } - if (form.rate_limit_qps !== null && form.rate_limit_qps > 0) { - body.rate_limit_qps = form.rate_limit_qps - } else if (form.rate_limit_qps === 0) { - body.rate_limit_qps = 0 - } - if (form.total_quota !== null && form.total_quota > 0) { - body.total_quota = form.total_quota - } else if (form.total_quota === 0) { - body.total_quota = 0 - } + applyLimitFields(body) if (form.expires_at) body.expires_at = form.expires_at await request.put(`/tenant/api-keys/${props.apiKey!.id}`, body) toast.success('更新成功') @@ -288,10 +312,14 @@ const isFormMode = computed(() => {

-
+
- + +
+
+ +
@@ -300,25 +328,36 @@ const isFormMode = computed(() => {
-
- -
- -
- - - -
+
+ + +

支持 192.168.1.10 或 10.0.0.0/24,也可用逗号分隔。

+
+ +
+ +
+ +
+ + +
-

当前过期时间:{{ formatDate(apiKey.expires_at) }}

-

当前:永不过期

-

留空表示永不过期

+

当前过期时间:{{ formatDate(apiKey.expires_at) }}

+

当前:永不过期

+

留空表示永不过期

+
diff --git a/web/tenant/src/components/common/PasswordStrengthMeter.vue b/web/tenant/src/components/common/PasswordStrengthMeter.vue index 36684f7..5488c9f 100644 --- a/web/tenant/src/components/common/PasswordStrengthMeter.vue +++ b/web/tenant/src/components/common/PasswordStrengthMeter.vue @@ -22,7 +22,7 @@ const score = computed(() => { if (pw.length >= 8) s++ if (pw.length >= 12) s++ if (pw.length >= 16) s++ - if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++ + if (/[a-zA-Z]/.test(pw)) s++ if (/\d/.test(pw)) s++ if (/[^a-zA-Z0-9]/.test(pw)) s++ 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/JoinPage.vue b/web/tenant/src/views/auth/JoinPage.vue index 82a5530..0912ac4 100644 --- a/web/tenant/src/views/auth/JoinPage.vue +++ b/web/tenant/src/views/auth/JoinPage.vue @@ -75,7 +75,7 @@ v-model="form.password" type="password" required - placeholder="至少 8 位,含大小写字母和数字" + placeholder="至少 8 位,含字母和数字" class="input pl-11" />
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/service/ApiKeysPage.vue b/web/tenant/src/views/service/ApiKeysPage.vue index 6740c83..9796fbc 100644 --- a/web/tenant/src/views/service/ApiKeysPage.vue +++ b/web/tenant/src/views/service/ApiKeysPage.vue @@ -22,6 +22,7 @@ interface ApiKey { expires_at: string | null rate_limit_qps: number | null rate_limit_concurrency: number | null + ip_whitelist: string[] | null total_quota: number | null used_quota: number | null created_at: string @@ -104,12 +105,26 @@ function openEditModal(key: ApiKey) { name: key.name, expires_at: key.expires_at, rate_limit_qps: key.rate_limit_qps, + rate_limit_concurrency: key.rate_limit_concurrency, + ip_whitelist: key.ip_whitelist || [], total_quota: key.total_quota, used_quota: key.used_quota, } showEditModal.value = true } +function formatLimit(value: number | null | undefined, suffix = ''): string { + if (value === null || value === undefined) return '默认' + if (value <= 0) return '不限' + return `${value}${suffix}` +} + +function formatQuota(key: ApiKey): string { + const used = key.used_quota || 0 + if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限` + return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}` +} + async function disableKey(keyId: number) { if (!await confirm({ message: '确定禁用该 API Key?禁用后将无法使用。', confirmText: '确认禁用', danger: true })) return try { @@ -171,6 +186,7 @@ onMounted(() => { 名称 Key 前缀 权限 + 限制 状态 过期时间 创建时间 @@ -191,6 +207,14 @@ onMounted(() => { 不限模型 + +
+
QPS:{{ formatLimit(key.rate_limit_qps) }}
+
并发:{{ formatLimit(key.rate_limit_concurrency) }}
+
额度:{{ formatQuota(key) }}
+
IP:{{ key.ip_whitelist?.length ? key.ip_whitelist.length + ' 条' : '不限' }}
+
+ {{ statusLabel[key.status] || key.status }} 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/settings/ProfilePage.vue b/web/tenant/src/views/settings/ProfilePage.vue index e9053b7..df5bed8 100644 --- a/web/tenant/src/views/settings/ProfilePage.vue +++ b/web/tenant/src/views/settings/ProfilePage.vue @@ -561,7 +561,7 @@ async function handleOAuthUnlink(provider: string) { 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 @@