Merge branch 'main' into dev

# Conflicts:
#	docs/reference/goframe-conventions.md
#	internal/consts/consts.go
#	web/admin/src/views/system/settings/AuditTab.vue
#	web/admin/src/views/system/settings/EmailTab.vue
#	web/admin/src/views/system/settings/PaymentTab.vue
#	web/tenant/src/components/common/PasswordStrengthMeter.vue
This commit is contained in:
qian committed 2026-06-23 23:21:55 +08:00
commit 8ab1e1644c
113 files changed
+3160 -798

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ import "github.com/gogf/gf/v2/frame/g"
// TenantCreateReq 创建租户请求
type TenantCreateReq struct {
g.Meta `path:"/tenants" method:"post" mime:"json" tags:"管理后台-租户管理" summary:"创建租户"`
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入租户名称|租户名称长度为2-100位" dc:"租户名称"`
TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"`
Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"`
+12 -7
View File
@@ -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"`
}
+10 -8
View File
@@ -8,8 +8,8 @@ type TenantRegisterReq struct {
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"`
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"`
TenantName string `json:"tenant_name" dc:"组织名称(可选,留空自动生成;汉字最多8个,字母最多16个)"`
TenantCode string `json:"tenant_code" v:"length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#组织代码长度为3-30位|组织代码格式不正确" dc:"组织代码(可选,留空自动生成)"`
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(Turnstile关闭时必填)"`
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"`
@@ -21,9 +21,10 @@ type TenantRegisterRes struct {
RefreshToken string `json:"refresh_token"`
ExpiresAt string `json:"expires_at"`
Tenant struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
} `json:"tenant"`
User struct {
ID int64 `json:"id"`
@@ -51,9 +52,10 @@ type TenantLoginRes struct {
TotpRequired bool `json:"totp_required"` // 是否需要 2FA 验证
ProvisionalToken string `json:"provisional_token,omitempty"` // 2FA 临时令牌
Tenant struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
} `json:"tenant"`
User struct {
ID int64 `json:"id"`
+1 -1
View File
@@ -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:"邀请码"`
}
+3 -1
View File
@@ -11,6 +11,7 @@ type TenantOrgInfoRes struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
LogoURL string `json:"logo_url"`
Status string `json:"status"`
Level int `json:"level"`
@@ -23,7 +24,8 @@ type TenantOrgInfoRes struct {
// TenantOrgUpdateReq 更新组织信息请求
type TenantOrgUpdateReq struct {
g.Meta `path:"/organization" method:"put" mime:"json" tags:"租户控制台-组织" summary:"更新组织信息"`
Name *string `json:"name" v:"length:2,100#组织名称长度为2-100位" dc:"组织名称"`
Name *string `json:"name" dc:"组织名称(汉字最多8个,字母最多16个)"`
Code *string `json:"code" v:"length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#组织代码长度为3-30位|组织代码格式不正确" dc:"组织代码(首次设置即激活团队功能)"`
LogoURL *string `json:"logo_url" dc:"Logo URL"`
}
+10 -6
View File
@@ -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 {
+26
View File
@@ -505,3 +505,29 @@ return gerror.NewCodef(gcode.New(...), "%s包含禁用词「%s」,请修改后
**正确做法**:构造错误时区分 `NewXxx`(字面文本)与 `NewXxxf`(格式化)两个系列。凡消息含 `%s`/`%v`/`%d` 等占位符且需填入变量,必须用 `f` 变体(`gerror.Newf`、`gerror.NewCodef`、`gerror.NewWrapf`、`gerror.NewSkipf`);把格式串当字面文本传入 `NewXxx` 系列,占位符不会替换,多余实参还会被拼接到消息末尾。
### 2026-06-23:Scan 查询单标量值(`*string`/`*int`)报参数类型错误
**问题描述**:`organization.go` 的 `GetOrgInfo` 查询租户等级名称时,`var levelName *string; dao.Xxx.Scan(&levelName)` 触发 WARN:`element of parameter "pointer" for function Scan should type of struct/*struct/[]struct/[]*struct`,等级名查不到(降级为空)。
**原因**:GoFrame 的 `Scan` **仅支持 struct / *struct / []struct / []*struct**,不支持标量类型(string/int/bool 等)的指针。`levelName` 声明为 `*string`,`&levelName` 是 `**string`,不在支持范围内,直接报参数类型错误。注意这和 2026-05-14 记录的「Scan 值类型 vs 指针类型」是两回事——那个讲的是 struct 查询无行时的行为差异,本次是 **Scan 根本不接受标量**。
**修复方式**:查询单个标量值改用 `Value()`,返回 `*gvar.Var`,再用 `.String()`/`.Int64()`/`.Bool()` 取值:
```go
// 错误 — Scan 不支持标量类型指针
var levelName *string
dao.Xxx.Ctx(ctx).Where(...).Fields("name").Scan(&levelName) // 报参数类型错误
// 正确 — 单标量值用 Value()
v, err := dao.Xxx.Ctx(ctx).Where(...).Fields("name").Value()
if err != nil {
g.Log().Warningf(ctx, "查询失败: %v", err)
} else {
levelName = v.String() // 无记录时返回 "",不会报错
}
```
**正确做法**:按查询意图选择 API:
- 查询单行记录 → `Scan(&structPtr)`(指针类型,无行返回 nil,见 2026-05-14 记录)
- 查询多行记录 → `Scan(&[]struct{})`
- 查询单个标量值(`COUNT`、`SUM`、单个字段)→ `Value()` 返回 `*gvar.Var`,用 `.String()/.Int64()/.Bool()` 取值,**不要用 `Scan(&scalar)` 或 `Scan(&scalarPtr)`**
+4 -1
View File
@@ -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())
})
+1 -1
View File
@@ -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
}
+9
View File
@@ -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 = "仅草稿状态的协议可修改或删除"
@@ -52,7 +52,6 @@ type AudRequestLogsColumns struct {
TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt string // 异步任务达到终态的时间
AttachmentIds string // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
Model string // 请求使用的模型名称(从请求体或 URL 中提取)
}
// audRequestLogsColumns holds the columns for the table aud_request_logs.
@@ -88,7 +87,6 @@ var audRequestLogsColumns = AudRequestLogsColumns{
TaskUpstreamHeaders: "task_upstream_headers",
TaskCompletedAt: "task_completed_at",
AttachmentIds: "attachment_ids",
Model: "model",
}
// NewAudRequestLogsDao creates and returns a new DAO object for table data access.
+2 -2
View File
@@ -26,9 +26,9 @@ type MdlModelGroupsColumns struct {
Code string // 分组唯一标识(如 full_access、basic_chat)
Description string // 分组描述
Status string // 状态:active(启用)/ disabled(禁用)
IsDefault string // 是否为新租户默认模型组,注册时自动关联
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
IsDefault string // 是否为新租户默认模型组,注册时自动关联
}
// mdlModelGroupsColumns holds the columns for the table mdl_model_groups.
@@ -38,9 +38,9 @@ var mdlModelGroupsColumns = MdlModelGroupsColumns{
Code: "code",
Description: "description",
Status: "status",
IsDefault: "is_default",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
IsDefault: "is_default",
}
// NewMdlModelGroupsDao creates and returns a new DAO object for table data access.
+32 -30
View File
@@ -21,40 +21,42 @@ type OpnAppsDao struct {
// OpnAppsColumns defines and stores column names for the table opn_apps.
type OpnAppsColumns struct {
Id string // 主键ID
TenantId string // 所属租户ID
Name string // 应用名称
Description string // 应用描述
AppId string // 应用标识(opn_xxx 格式)
AppSecretHash string // App Secret 哈希(bcrypt)
Permissions string // 权限范围(JSON 数组)
IpWhitelist string // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string // OAuth 回调 URL
IsSandbox string // 是否沙箱应用
Status string // 状态:active(启用)/ disabled(禁用)
RateLimit string // 每分钟请求上限
LastUsedAt string // 最后使用时间
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
Id string // 主键ID
TenantId string // 所属租户ID
Name string // 应用名称
Description string // 应用描述
AppId string // 应用标识(opn_xxx 格式)
AppSecretHash string // App Secret 哈希(bcrypt)
Permissions string // 权限范围(JSON 数组)
IpWhitelist string // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string // OAuth 回调 URL
IsSandbox string // 是否沙箱应用
Status string // 状态:active(启用)/ disabled(禁用)
RateLimit string // 每分钟请求上限
LastUsedAt string // 最后使用时间
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
EncryptedSecret string // AES-256 encrypted App Secret for HMAC verification
}
// opnAppsColumns holds the columns for the table opn_apps.
var opnAppsColumns = OpnAppsColumns{
Id: "id",
TenantId: "tenant_id",
Name: "name",
Description: "description",
AppId: "app_id",
AppSecretHash: "app_secret_hash",
Permissions: "permissions",
IpWhitelist: "ip_whitelist",
CallbackUrl: "callback_url",
IsSandbox: "is_sandbox",
Status: "status",
RateLimit: "rate_limit",
LastUsedAt: "last_used_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
Id: "id",
TenantId: "tenant_id",
Name: "name",
Description: "description",
AppId: "app_id",
AppSecretHash: "app_secret_hash",
Permissions: "permissions",
IpWhitelist: "ip_whitelist",
CallbackUrl: "callback_url",
IsSandbox: "is_sandbox",
Status: "status",
RateLimit: "rate_limit",
LastUsedAt: "last_used_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
EncryptedSecret: "encrypted_secret",
}
// NewOpnAppsDao creates and returns a new DAO object for table data access.
+87
View File
@@ -0,0 +1,87 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// PlgExampleLogsDao is the data access object for the table plg_example_logs.
type PlgExampleLogsDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns PlgExampleLogsColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// PlgExampleLogsColumns defines and stores column names for the table plg_example_logs.
type PlgExampleLogsColumns struct {
Id string //
TenantId string //
Action string //
Detail string //
CreatedAt string //
}
// plgExampleLogsColumns holds the columns for the table plg_example_logs.
var plgExampleLogsColumns = PlgExampleLogsColumns{
Id: "id",
TenantId: "tenant_id",
Action: "action",
Detail: "detail",
CreatedAt: "created_at",
}
// NewPlgExampleLogsDao creates and returns a new DAO object for table data access.
func NewPlgExampleLogsDao(handlers ...gdb.ModelHandler) *PlgExampleLogsDao {
return &PlgExampleLogsDao{
group: "default",
table: "plg_example_logs",
columns: plgExampleLogsColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *PlgExampleLogsDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *PlgExampleLogsDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *PlgExampleLogsDao) Columns() PlgExampleLogsColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *PlgExampleLogsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *PlgExampleLogsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *PlgExampleLogsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+32 -28
View File
@@ -21,38 +21,42 @@ type SysAdminUsersDao struct {
// SysAdminUsersColumns defines and stores column names for the table sys_admin_users.
type SysAdminUsersColumns struct {
Id string // 主键ID
Username string // 登录用户名
PasswordHash string // 密码哈希(bcrypt)
Email string // 邮箱地址(可选,可为NULL)
DisplayName string // 显示名称
Role string // 角色:super_admin(全权限)/ admin(可配置权限)
Status string // 状态:active(启用)/ disabled(禁用)
LastLoginAt string // 最后登录时间
LastLoginIp string // 最后登录IP
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
TotpSecret string // TOTP 密钥(AES-256 加密存储)
TotpEnabled string // 是否启用双因素认证
BackupCodes string // 备用恢复码(bcrypt 哈希存储)
Id string // 主键ID
Username string // 登录用户名
PasswordHash string // 密码哈希(bcrypt)
Email string // 邮箱地址(可选,可为NULL)
DisplayName string // 显示名称
Role string // 角色:super_admin(全权限)/ admin(可配置权限)
Status string // 状态:active(启用)/ disabled(禁用)
LastLoginAt string // 最后登录时间
LastLoginIp string // 最后登录IP
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
TotpSecret string // TOTP 密钥(AES-256 加密存储)
TotpEnabled string // 是否启用双因素认证
BackupCodes string // 备用恢复码(bcrypt 哈希存储)
FailedAttempts string // 连续登录失败次数(成功登录后归零)
LockedUntil string // 锁定截止时间(连续5次失败后锁定30分钟)
}
// sysAdminUsersColumns holds the columns for the table sys_admin_users.
var sysAdminUsersColumns = SysAdminUsersColumns{
Id: "id",
Username: "username",
PasswordHash: "password_hash",
Email: "email",
DisplayName: "display_name",
Role: "role",
Status: "status",
LastLoginAt: "last_login_at",
LastLoginIp: "last_login_ip",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
TotpSecret: "totp_secret",
TotpEnabled: "totp_enabled",
BackupCodes: "backup_codes",
Id: "id",
Username: "username",
PasswordHash: "password_hash",
Email: "email",
DisplayName: "display_name",
Role: "role",
Status: "status",
LastLoginAt: "last_login_at",
LastLoginIp: "last_login_ip",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
TotpSecret: "totp_secret",
TotpEnabled: "totp_enabled",
BackupCodes: "backup_codes",
FailedAttempts: "failed_attempts",
LockedUntil: "locked_until",
}
// NewSysAdminUsersDao creates and returns a new DAO object for table data access.
@@ -21,13 +21,13 @@ type SysAgreementAcceptancesDao struct {
// SysAgreementAcceptancesColumns defines and stores column names for the table sys_agreement_acceptances.
type SysAgreementAcceptancesColumns struct {
Id string //
Id string // 主键ID
AgreementId string // 关联协议版本ID
UserType string // 用户类型
UserType string // 用户类型:admin(管理员) / tenant(租户用户)
UserId string // 用户ID
IpAddress string // IP地址
UserAgent string // User-Agent
CreatedAt string //
IpAddress string // 接受时的IP地址
UserAgent string // 接受时的浏览器User-Agent
CreatedAt string // 接受时间
}
// sysAgreementAcceptancesColumns holds the columns for the table sys_agreement_acceptances.
@@ -71,7 +71,7 @@ func (dao *SysAgreementAcceptancesDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO.
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
@@ -81,6 +81,11 @@ func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model {
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysAgreementAcceptancesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+14 -9
View File
@@ -21,19 +21,19 @@ type SysAgreementsDao struct {
// SysAgreementsColumns defines and stores column names for the table sys_agreements.
type SysAgreementsColumns struct {
Id string //
Code string // 协议标识码
Version string // 版本号
Id string // 主键ID
Code string // 协议标识码:terms(用户协议) / privacy(隐私政策)
Version string // 版本号,如 1.0、2.0
Title string // 协议标题
Content string // 协议正文(Markdown)
Summary string // 版本变更摘要
Status string // 状态
IsCurrent string // 是否为当前生效版本
ForceAccept string // 是否强制用户接受
Status string // 状态:draft(草稿) / published(已发布) / archived(已归档)
IsCurrent string // 是否为该标识码的当前生效版本(每个code仅一条)
ForceAccept string // 是否强制用户接受(true=登录后必须接受才能继续)
PublishedAt string // 发布时间
CreatedBy string // 创建的管理员ID
CreatedAt string //
UpdatedAt string //
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
}
// sysAgreementsColumns holds the columns for the table sys_agreements.
@@ -83,7 +83,7 @@ func (dao *SysAgreementsDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO.
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
@@ -93,6 +93,11 @@ func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model {
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysAgreementsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+2
View File
@@ -39,6 +39,7 @@ type TntTenantsColumns struct {
MaxConcurrency string // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
DefaultChannelScope string // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level string // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled string // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
}
// tntTenantsColumns holds the columns for the table tnt_tenants.
@@ -61,6 +62,7 @@ var tntTenantsColumns = TntTenantsColumns{
MaxConcurrency: "max_concurrency",
DefaultChannelScope: "default_channel_scope",
Level: "level",
TeamEnabled: "team_enabled",
}
// NewTntTenantsDao creates and returns a new DAO object for table data access.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"github.com/qianfree/team-api/internal/dao/internal"
)
// plgExampleLogsDao is the data access object for the table plg_example_logs.
// You can define custom methods on it to extend its functionality as needed.
type plgExampleLogsDao struct {
*internal.PlgExampleLogsDao
}
var (
// PlgExampleLogs is a globally accessible object for table plg_example_logs operations.
PlgExampleLogs = plgExampleLogsDao{internal.NewPlgExampleLogsDao()}
)
// Add your custom methods and functionality below.
+62 -14
View File
@@ -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)
+39 -23
View File
@@ -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)
+48
View File
@@ -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)
+37 -2
View File
@@ -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",
},
},
}
+11 -2
View File
@@ -81,6 +81,11 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
}
tenantName := strings.TrimSpace(req.TenantName)
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
@@ -122,7 +127,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
Name: strings.TrimSpace(req.TenantName),
Name: tenantName,
Code: tenantCode,
MaxMembers: maxMembersVal,
MaxConcurrency: maxConcurrencyVal,
@@ -470,7 +475,11 @@ func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1
data := do.TntTenants{}
if req.Name != "" {
data.Name = req.Name
tenantName := strings.TrimSpace(req.Name)
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
data.Name = tenantName
}
if req.MaxMembers != nil {
if *req.MaxMembers < 1 {
+57
View File
@@ -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)
}
}()
}
+69 -46
View File
@@ -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)
}
+64
View File
@@ -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 表示不限。
+17
View File
@@ -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 {
+13 -3
View File
@@ -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, ",")
+21
View File
@@ -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
+7 -10
View File
@@ -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
@@ -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")
+6 -6
View File
@@ -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",
+58 -10
View File
@@ -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)
}
+65 -4
View File
@@ -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
}
+95 -36
View File
@@ -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)
+11 -7
View File
@@ -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,
+2
View File
@@ -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")
+68 -9
View File
@@ -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,
+47 -17
View File
@@ -36,16 +36,40 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
username := strings.TrimSpace(req.Username)
tenantName := strings.TrimSpace(req.TenantName)
// 先校验禁用词:命中禁用词时优先返回更准确的提示,
// 简化注册:组织信息可选,留空时后端自动生成(个人模式,team_enabled=false)
userProvidedCode := tenantCode != ""
userProvidedName := tenantName != ""
// 先校验用户名禁用词:命中禁用词时优先返回更准确的提示,
// 避免被用户名格式校验的「不能包含特殊字符或中文」遮盖真实原因
if err := common.ValidateForbiddenWords(ctx, username, "用户名"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
// 组织代码:留空则自动生成(org-<base36>);用户显式传入才校验禁用词
if !userProvidedCode {
generated, err := generateUniqueTenantCode(ctx)
if err != nil {
return nil, err
}
tenantCode = generated
} else {
if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
}
if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
// 组织名称:留空则用用户名派生默认值;用户显式传入才校验禁用词
if !userProvidedName {
// 用户名较长时“xxx 的组织”可能超出组织名称显示宽度上限,按宽度截断兜底
tenantName = common.TruncateToDisplayWidth(fmt.Sprintf("%s 的组织", username), common.TenantNameMaxDisplayWidth)
} else {
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
}
// Validate username format
@@ -84,14 +108,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
}
}
// Check tenant code uniqueness
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
// Check tenant code uniqueness(仅用户显式传入时;自动生成的已在生成时查重)
if userProvidedCode {
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
}
}
// Validate password policy
@@ -109,12 +135,13 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Create tenant
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
Name: strings.TrimSpace(req.TenantName),
Name: tenantName,
Code: tenantCode,
// MaxMembers/MaxConcurrency: nil = 跟随等级配置
// Level 1 defaults will apply
Level: 1,
Settings: "{}",
Level: 1,
Settings: "{}",
TeamEnabled: false, // 新注册默认个人模式,设置自定义 code 后才激活团队功能
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create tenant")
@@ -214,8 +241,9 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339),
}
res.Tenant.ID = tenantID
res.Tenant.Name = strings.TrimSpace(req.TenantName)
res.Tenant.Name = tenantName
res.Tenant.Code = tenantCode
res.Tenant.TeamEnabled = false
res.User.ID = ownerUserID
res.User.Username = username
res.User.Role = "owner"
@@ -408,6 +436,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
res.Tenant.ID = tenant.Id
res.Tenant.Name = tenant.Name
res.Tenant.Code = tenant.Code
res.Tenant.TeamEnabled = tenant.TeamEnabled
res.User.ID = user.Id
res.User.Username = user.Username
res.User.Role = user.Role
@@ -455,6 +484,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
res.Tenant.ID = tenant.Id
res.Tenant.Name = tenant.Name
res.Tenant.Code = tenant.Code
res.Tenant.TeamEnabled = tenant.TeamEnabled
res.User.ID = user.Id
res.User.Username = user.Username
res.User.Role = user.Role
+55
View File
@@ -0,0 +1,55 @@
package tenant
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/dao"
)
// generateUniqueTenantCode 生成一个不冲突的默认租户 code。
// 格式:org-<base36>,满足正则 ^[a-z0-9][a-z0-9-]*[a-z0-9]$,长度 3-30。
// 用 crypto/rand 保证不可预测,循环查重最多 5 次避免唯一索引冲突。
func generateUniqueTenantCode(ctx context.Context) (string, error) {
const maxAttempts = 5
for i := 0; i < maxAttempts; i++ {
code, err := randomTenantCode()
if err != nil {
return "", err
}
count, err := dao.TntTenants.Ctx(ctx).Where("code", code).Count()
if err != nil {
return "", err
}
if count == 0 {
return code, nil
}
g.Log().Warningf(ctx, "生成的租户 code 冲突,重试: %s", code)
}
return "", fmt.Errorf("生成唯一租户 code 失败(重试 %d 次)", maxAttempts)
}
// randomTenantCode 生成单个 "org-<base36>" 形式的随机 code。
func randomTenantCode() (string, error) {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "", err
}
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
var chars []byte
tmp := new(big.Int).SetBytes(b)
base := big.NewInt(36)
mod := new(big.Int)
for tmp.Sign() > 0 && len(chars) < 16 {
tmp.DivMod(tmp, base, mod)
chars = append([]byte{alphabet[mod.Int64()]}, chars...)
}
if len(chars) == 0 {
chars = []byte{'0'}
}
return "org-" + string(chars), nil
}
+3
View File
@@ -109,6 +109,9 @@ func (s *sTenant) RevokeInvitation(ctx context.Context, req *v1.TenantInvitation
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
+23
View File
@@ -90,6 +90,9 @@ func (s *sTenant) InviteMember(ctx context.Context, req *v1.TenantMemberInviteRe
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
creatorID := middleware.GetUserID(ctx)
@@ -224,6 +227,11 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
return nil, common.NewBusinessError(consts.CodeInvitationExpired, consts.MsgInvitationExpired)
}
// 团队功能门控:目标租户必须已启用团队功能(公开接口,ctx 无 tenant_id,用目标租户 ID 校验)
if err := requireTeamEnabledForTenant(ctx, invitation.TenantID); err != nil {
return nil, err
}
email := strings.TrimSpace(strings.ToLower(req.Email))
// Validate password
@@ -384,6 +392,9 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
// Validate role
@@ -501,6 +512,9 @@ func (s *sTenant) RemoveMember(ctx context.Context, req *v1.TenantMemberRemoveRe
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
currentUserID := middleware.GetUserID(ctx)
memberID := req.Id
@@ -670,6 +684,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
currentUserID := middleware.GetUserID(ctx)
memberID := req.Id
@@ -719,6 +736,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda
// ResetMemberPassword resets a member's password. Only admins can reset other members' passwords.
func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) {
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
currentUserID := middleware.GetUserID(ctx)
memberID := req.Id
@@ -932,6 +952,9 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
columns := []export.Column{
+3
View File
@@ -35,6 +35,9 @@ type ImportResult struct {
// MemberImport parses CSV content, validates, creates an import record.
func (s *sTenant) MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) {
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
creatorID := middleware.GetUserID(ctx)
@@ -59,6 +59,9 @@ var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second)
// MemberModelScopesSet sets the available models for a member (full replace).
func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) {
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
// 验证用户属于当前租户
+3
View File
@@ -59,6 +59,9 @@ func (s *sTenant) MemberQuotaSet(ctx context.Context, req *v1.TenantMemberQuotaS
if role != "owner" && role != "admin" {
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
}
if err := requireTeamEnabled(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
if req.QuotaType == "periodic" && req.Period == "" {
+95 -36
View File
@@ -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
}
+70 -25
View File
@@ -2,9 +2,11 @@ package tenant
import (
"context"
"strings"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
@@ -31,14 +33,15 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
tenantID := middleware.GetTenantID(ctx)
var tenant *struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoUrl string `json:"logo_url"`
Status string `json:"status"`
Level int `json:"level"`
MaxMembers *int `json:"max_members"`
CreatedAt string `json:"created_at"`
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
TeamEnabled bool `json:"team_enabled"`
LogoUrl string `json:"logo_url"`
Status string `json:"status"`
Level int `json:"level"`
MaxMembers *int `json:"max_members"`
CreatedAt string `json:"created_at"`
}
err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Scan(&tenant)
@@ -57,15 +60,17 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
return nil, err
}
// Look up level name
var levelName *string
// Look up level name(单值查询用 Value(),Scan 仅支持 struct/*struct)
levelName := ""
if tenant.Level > 0 {
err = dao.TntTenantLevelConfigs.Ctx(ctx).
v, err := dao.TntTenantLevelConfigs.Ctx(ctx).
Where("level", tenant.Level).
Fields("name").
Scan(&levelName)
Value()
if err != nil {
g.Log().Warningf(ctx, "查询租户等级名称失败: %v", err)
} else {
levelName = v.String()
}
}
@@ -73,18 +78,14 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
effectiveMaxMembers, _, _ := billing.GetTenantEffectiveLimits(ctx, tenantID)
return &v1.TenantOrgInfoRes{
ID: tenant.Id,
Name: tenant.Name,
Code: tenant.Code,
LogoURL: tenant.LogoUrl,
Status: tenant.Status,
Level: tenant.Level,
LevelName: func() string {
if levelName != nil {
return *levelName
}
return ""
}(),
ID: tenant.Id,
Name: tenant.Name,
Code: tenant.Code,
TeamEnabled: tenant.TeamEnabled,
LogoURL: tenant.LogoUrl,
Status: tenant.Status,
Level: tenant.Level,
LevelName: levelName,
MaxMembers: effectiveMaxMembers,
MemberCount: int(memberCount),
CreatedAt: tenant.CreatedAt,
@@ -92,20 +93,64 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1
}
// UpdateOrgInfo updates tenant organization info.
// 组织代码仅可在未启用团队功能时设置一次(设置即激活),启用后不可修改。
func (s *sTenant) UpdateOrgInfo(ctx context.Context, req *v1.TenantOrgUpdateReq) (*v1.TenantOrgUpdateRes, error) {
if err := ownerOnly(ctx); err != nil {
return nil, err
}
tenantID := middleware.GetTenantID(ctx)
// 规整 code(小写化);留空表示不修改 code
var newCode *string
if req.Code != nil {
c := strings.TrimSpace(strings.ToLower(*req.Code))
if c == "" {
return nil, common.NewBadRequestError("组织代码不能为空")
}
newCode = &c
}
data := do.TntTenants{}
if req.Name != nil {
data.Name = *req.Name
name := strings.TrimSpace(*req.Name)
if err := common.ValidateTenantName(name); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
data.Name = name
}
if req.LogoURL != nil {
data.LogoUrl = *req.LogoURL
}
// 处理组织代码设置:启用团队功能后不可修改(只能设置一次)
if newCode != nil {
// 已启用团队功能 → 拒绝修改 code
current, err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Fields("team_enabled").Value()
if err != nil {
return nil, err
}
if current.Bool() {
return nil, common.NewBadRequestError("组织代码启用团队功能后不可修改")
}
// 首次设置:校验禁用词 + 唯一性(排除自己)
if err := common.ValidateForbiddenWords(ctx, *newCode, "组织代码"); err != nil {
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
}
count, err := dao.TntTenants.Ctx(ctx).
Where("code", *newCode).
WhereNot("id", tenantID).
Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
}
data.Code = *newCode
data.TeamEnabled = true
}
_, err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Data(data).Update()
if err != nil {
return nil, err
+174 -54
View File
@@ -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()
+33
View File
@@ -0,0 +1,33 @@
package tenant
import (
"context"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
"github.com/qianfree/team-api/internal/middleware"
)
// requireTeamEnabled 校验当前认证用户所属租户已启用团队功能。
// 用于 ctx 中携带 tenant_id 的团队写操作接口。
func requireTeamEnabled(ctx context.Context) error {
return requireTeamEnabledForTenant(ctx, middleware.GetTenantID(ctx))
}
// requireTeamEnabledForTenant 校验指定租户已启用团队功能。
// 用于 JoinByInvite 等公开接口:ctx 无 tenant_id,需在查到目标 tenant_id 后调用。
func requireTeamEnabledForTenant(ctx context.Context, tenantID int64) error {
if tenantID <= 0 {
return common.NewForbiddenError("租户不存在")
}
enabled, err := dao.TntTenants.Ctx(ctx).
Where("id", tenantID).Fields("team_enabled").Value()
if err != nil {
return err
}
if !enabled.Bool() {
return common.NewBusinessError(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled)
}
return nil
}
+21 -11
View File
@@ -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
}
+9
View File
@@ -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()
}
+9 -7
View File
@@ -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.
+31 -4
View File
@@ -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 {
+127
View File
@@ -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)
}
}
}
+351 -1
View File
@@ -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 ""
}
+20
View File
@@ -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)
}
}
+11
View File
@@ -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 == "" {
-1
View File
@@ -43,5 +43,4 @@ type AudRequestLogs struct {
TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt *gtime.Time // 异步任务达到终态的时间
AttachmentIds any // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
Model any // 请求使用的模型名称(从请求体或 URL 中提取)
}
+1 -1
View File
@@ -17,7 +17,7 @@ type MdlModelGroups struct {
Code any // 分组唯一标识(如 full_access、basic_chat)
Description any // 分组描述
Status any // 状态:active(启用)/ disabled(禁用)
IsDefault any // 是否为新租户默认模型组,注册时自动关联
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
IsDefault any // 是否为新租户默认模型组,注册时自动关联
}
+17 -16
View File
@@ -11,20 +11,21 @@ import (
// OpnApps is the golang structure of table opn_apps for DAO operations like Where/Data.
type OpnApps struct {
g.Meta `orm:"table:opn_apps, do:true"`
Id any // 主键ID
TenantId any // 所属租户ID
Name any // 应用名称
Description any // 应用描述
AppId any // 应用标识(opn_xxx 格式)
AppSecretHash any // App Secret 哈希(bcrypt)
Permissions any // 权限范围(JSON 数组)
IpWhitelist any // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl any // OAuth 回调 URL
IsSandbox any // 是否沙箱应用
Status any // 状态:active(启用)/ disabled(禁用)
RateLimit any // 每分钟请求上限
LastUsedAt *gtime.Time // 最后使用时间
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
g.Meta `orm:"table:opn_apps, do:true"`
Id any // 主键ID
TenantId any // 所属租户ID
Name any // 应用名称
Description any // 应用描述
AppId any // 应用标识(opn_xxx 格式)
AppSecretHash any // App Secret 哈希(bcrypt)
Permissions any // 权限范围(JSON 数组)
IpWhitelist any // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl any // OAuth 回调 URL
IsSandbox any // 是否沙箱应用
Status any // 状态:active(启用)/ disabled(禁用)
RateLimit any // 每分钟请求上限
LastUsedAt *gtime.Time // 最后使用时间
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
EncryptedSecret any // AES-256 encrypted App Secret for HMAC verification
}
+20
View File
@@ -0,0 +1,20 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
// PlgExampleLogs is the golang structure of table plg_example_logs for DAO operations like Where/Data.
type PlgExampleLogs struct {
g.Meta `orm:"table:plg_example_logs, do:true"`
Id any //
TenantId any //
Action any //
Detail any //
CreatedAt *gtime.Time //
}
+17 -15
View File
@@ -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分钟)
}
@@ -12,11 +12,11 @@ import (
// SysAgreementAcceptances is the golang structure of table sys_agreement_acceptances for DAO operations like Where/Data.
type SysAgreementAcceptances struct {
g.Meta `orm:"table:sys_agreement_acceptances, do:true"`
Id any //
Id any // 主键ID
AgreementId any // 关联协议版本ID
UserType any // 用户类型:admin / tenant
UserType any // 用户类型:admin(管理员) / tenant(租户用户)
UserId any // 用户ID
IpAddress any // IP地址
UserAgent any // User-Agent
CreatedAt *gtime.Time //
IpAddress any // 接受时的IP地址
UserAgent any // 接受时的浏览器User-Agent
CreatedAt *gtime.Time // 接受时间
}
+8 -8
View File
@@ -12,17 +12,17 @@ import (
// SysAgreements is the golang structure of table sys_agreements for DAO operations like Where/Data.
type SysAgreements struct {
g.Meta `orm:"table:sys_agreements, do:true"`
Id any //
Code any // 协议标识码
Version any // 版本号
Id any // 主键ID
Code any // 协议标识码:terms(用户协议) / privacy(隐私政策)
Version any // 版本号,如 1.0、2.0
Title any // 协议标题
Content any // 协议正文(Markdown)
Summary any // 版本变更摘要
Status any // 状态:draft / published / archived
IsCurrent any // 是否为当前生效版本
ForceAccept any // 是否强制用户接受
Status any // 状态:draft(草稿) / published(已发布) / archived(已归档)
IsCurrent any // 是否为该标识码的当前生效版本(每个code仅一条)
ForceAccept any // 是否强制用户接受(true=登录后必须接受才能继续)
PublishedAt *gtime.Time // 发布时间
CreatedBy any // 创建的管理员ID
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
}
+1
View File
@@ -30,4 +30,5 @@ type TntTenants struct {
MaxConcurrency any // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
DefaultChannelScope any // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level any // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled any // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
}
@@ -41,5 +41,4 @@ type AudRequestLogs struct {
TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间
AttachmentIds string `json:"attachment_ids" orm:"attachment_ids" description:"提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])"` // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
Model string `json:"model" orm:"model" description:"请求使用的模型名称(从请求体或 URL 中提取)"` // 请求使用的模型名称(从请求体或 URL 中提取)
}
+1 -1
View File
@@ -15,7 +15,7 @@ type MdlModelGroups struct {
Code string `json:"code" orm:"code" description:"分组唯一标识(如 full_access、basic_chat)"` // 分组唯一标识(如 full_access、basic_chat)
Description string `json:"description" orm:"description" description:"分组描述"` // 分组描述
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联
}
+16 -15
View File
@@ -10,19 +10,20 @@ import (
// OpnApps is the golang structure for table opn_apps.
type OpnApps struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称
Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述
AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式)
AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt)
Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组)
IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL
IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限
LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称
Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述
AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式)
AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt)
Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组)
IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制)
CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL
IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限
LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
EncryptedSecret string `json:"encrypted_secret" orm:"encrypted_secret" description:"AES-256 encrypted App Secret for HMAC verification"` // AES-256 encrypted App Secret for HMAC verification
}
+18
View File
@@ -0,0 +1,18 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
import (
"github.com/gogf/gf/v2/os/gtime"
)
// PlgExampleLogs is the golang structure for table plg_example_logs.
type PlgExampleLogs struct {
Id int64 `json:"id" orm:"id" description:""` //
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
Action string `json:"action" orm:"action" description:""` //
Detail string `json:"detail" orm:"detail" description:""` //
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
}
+16 -14
View File
@@ -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分钟)
}
@@ -10,11 +10,11 @@ import (
// SysAgreementAcceptances is the golang structure for table sys_agreement_acceptances.
type SysAgreementAcceptances struct {
Id int64 `json:"id" orm:"id"`
AgreementId int64 `json:"agreement_id" orm:"agreement_id"`
UserType string `json:"user_type" orm:"user_type"`
UserId int64 `json:"user_id" orm:"user_id"`
IpAddress string `json:"ip_address" orm:"ip_address"`
UserAgent string `json:"user_agent" orm:"user_agent"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
AgreementId int64 `json:"agreement_id" orm:"agreement_id" description:"关联协议版本ID"` // 关联协议版本ID
UserType string `json:"user_type" orm:"user_type" description:"用户类型:admin(管理员) / tenant(租户用户)"` // 用户类型:admin(管理员) / tenant(租户用户)
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
IpAddress string `json:"ip_address" orm:"ip_address" description:"接受时的IP地址"` // 接受时的IP地址
UserAgent string `json:"user_agent" orm:"user_agent" description:"接受时的浏览器User-Agent"` // 接受时的浏览器User-Agent
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"接受时间"` // 接受时间
}
+13 -13
View File
@@ -10,17 +10,17 @@ import (
// SysAgreements is the golang structure for table sys_agreements.
type SysAgreements struct {
Id int64 `json:"id" orm:"id"`
Code string `json:"code" orm:"code"`
Version string `json:"version" orm:"version"`
Title string `json:"title" orm:"title"`
Content string `json:"content" orm:"content"`
Summary string `json:"summary" orm:"summary"`
Status string `json:"status" orm:"status"`
IsCurrent bool `json:"is_current" orm:"is_current"`
ForceAccept bool `json:"force_accept" orm:"force_accept"`
PublishedAt *gtime.Time `json:"published_at" orm:"published_at"`
CreatedBy int64 `json:"created_by" orm:"created_by"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
Code string `json:"code" orm:"code" description:"协议标识码:terms(用户协议) / privacy(隐私政策)"` // 协议标识码:terms(用户协议) / privacy(隐私政策)
Version string `json:"version" orm:"version" description:"版本号,如 1.0、2.0"` // 版本号,如 1.0、2.0
Title string `json:"title" orm:"title" description:"协议标题"` // 协议标题
Content string `json:"content" orm:"content" description:"协议正文(Markdown)"` // 协议正文(Markdown)
Summary string `json:"summary" orm:"summary" description:"版本变更摘要"` // 版本变更摘要
Status string `json:"status" orm:"status" description:"状态:draft(草稿) / published(已发布) / archived(已归档)"` // 状态:draft(草稿) / published(已发布) / archived(已归档)
IsCurrent bool `json:"is_current" orm:"is_current" description:"是否为该标识码的当前生效版本(每个code仅一条)"` // 是否为该标识码的当前生效版本(每个code仅一条)
ForceAccept bool `json:"force_accept" orm:"force_accept" description:"是否强制用户接受(true=登录后必须接受才能继续)"` // 是否强制用户接受(true=登录后必须接受才能继续)
PublishedAt *gtime.Time `json:"published_at" orm:"published_at" description:"发布时间"` // 发布时间
CreatedBy int64 `json:"created_by" orm:"created_by" description:"创建的管理员ID"` // 创建的管理员ID
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
}
+1
View File
@@ -28,4 +28,5 @@ type TntTenants struct {
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"租户总并发上限(NULL表示跟随等级配置,0表示不限制)"` // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope" description:"默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)"` // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level int `json:"level" orm:"level" description:"当前等级(对应 tnt_tenant_level_configs.level)"` // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled bool `json:"team_enabled" orm:"team_enabled" description:"团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)"` // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
}
+1 -1
View File
@@ -144,7 +144,7 @@
<input
type="password" id="password" name="password" required
class="input-field w-full px-3.5 py-2.5 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-900 placeholder-gray-400 outline-none font-mono"
placeholder="至少8位,含大小写字母和数字" autocomplete="new-password">
placeholder="至少8位,含字母和数字" autocomplete="new-password">
</div>
<!-- Confirm Password -->
@@ -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);
+48
View File
@@ -0,0 +1,48 @@
-- +goose Up
-- P1-1: 管理员暴力破解防护 — 新增失败计数和锁定字段
ALTER TABLE sys_admin_users
ADD COLUMN IF NOT EXISTS failed_attempts INT DEFAULT 0 NOT NULL,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ;
COMMENT ON COLUMN sys_admin_users.failed_attempts IS '连续登录失败次数(成功登录后归零)';
COMMENT ON COLUMN sys_admin_users.locked_until IS '锁定截止时间(连续5次失败后锁定30分钟)';
-- P1-3: TOTP 密钥列宽度不足 — AES-GCM 加密 + base64 编码后约 80 字符,原 VARCHAR(64) 溢出
ALTER TABLE sys_admin_users ALTER COLUMN totp_secret TYPE VARCHAR(255);
ALTER TABLE tnt_users ALTER COLUMN totp_secret TYPE VARCHAR(255);
-- 开放平台应用密钥加密存储(HMAC 校验用)
ALTER TABLE opn_apps ADD COLUMN IF NOT EXISTS encrypted_secret TEXT;
COMMENT ON COLUMN opn_apps.encrypted_secret IS 'AES-256 encrypted App Secret for HMAC verification';
-- 修正项目 Key 类型:绑定项目的 Key 应为 project 类型而非 personal
UPDATE api_keys
SET key_type = 'project'
WHERE project_id IS NOT NULL
AND key_type = 'personal';
-- 团队功能开关:false=个人模式(默认),true=已激活团队
ALTER TABLE tnt_tenants
ADD COLUMN IF NOT EXISTS team_enabled BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN tnt_tenants.team_enabled IS '团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)';
-- 存量租户均通过旧注册流程设置过自定义 code,视为团队已启用
UPDATE tnt_tenants SET team_enabled = TRUE WHERE team_enabled = FALSE;
-- +goose Down
-- 团队功能开关(team_enabled 的存量数据回填不可逆,仅回滚结构)
ALTER TABLE tnt_tenants DROP COLUMN IF EXISTS team_enabled;
-- 开放平台应用密钥(api_keys.key_type 的数据修正是不可逆的数据变更,仅回滚结构)
ALTER TABLE opn_apps DROP COLUMN IF EXISTS encrypted_secret;
-- TOTP 密钥列宽度还原
ALTER TABLE sys_admin_users ALTER COLUMN totp_secret TYPE VARCHAR(64);
ALTER TABLE tnt_users ALTER COLUMN totp_secret TYPE VARCHAR(64);
-- 管理员暴力破解防护字段
ALTER TABLE sys_admin_users
DROP COLUMN IF EXISTS failed_attempts,
DROP COLUMN IF EXISTS locked_until;
@@ -0,0 +1,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;
+15 -3
View File
@@ -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 限流信息(用于设置响应头)
+11 -6
View File
@@ -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 渠道选择结果
+15
View File
@@ -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)
+74 -9
View File
@@ -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)
}
}
}
+49 -3
View File
@@ -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())
+66
View File
@@ -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)
+22 -24
View File
@@ -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,
+36 -13
View File
@@ -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)
}
})
}
}
+65
View File
@@ -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分钟)")
}
+36
View File
@@ -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)
@@ -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)
}
}
+1 -1
View File
@@ -159,7 +159,7 @@ async function handleSetup() {
>
<AInputPassword
v-model="form.password"
placeholder="至少8位,含大小写字母和数字"
placeholder="至少8位,含字母和数字"
@keydown.enter="handleSetup"
/>
</AFormItem>
@@ -27,14 +27,14 @@ const values = useFormValues()
<div class="section-grid">
<AFormItem label="审计日志保留天数" class="field-full">
<AInputNumber
:model-value="Number(values['audit_retention_days']) || undefined"
:model-value="values['audit_retention_days'] as number"
@change="(v: number | undefined) => values['audit_retention_days'] = v"
:min="7" :max="3650" style="width: 100%"
/>
</AFormItem>
<AFormItem label="操作日志保留天数" class="field-full">
<AInputNumber
:model-value="Number(values['operation_log_retention_days']) || undefined"
:model-value="values['operation_log_retention_days'] as number"
@change="(v: number | undefined) => values['operation_log_retention_days'] = v"
:min="30" :max="3650" style="width: 100%"
/>
@@ -40,15 +40,15 @@ const values = useFormValues()
<div class="section-grid" style="margin-top: 12px">
<AFormItem label="自动禁用失败阈值">
<AInputNumber
:model-value="Number(values['channel_auto_disable_threshold']) || undefined"
@change="(v: number | undefined) => values['channel_auto_disable_threshold'] = String(v ?? 5)"
:model-value="values['channel_auto_disable_threshold'] as number"
@change="(v: number | undefined) => values['channel_auto_disable_threshold'] = v ?? 5"
:min="2" :max="50" style="width: 100%"
/>
</AFormItem>
<AFormItem label="健康快照保留天数">
<AInputNumber
:model-value="Number(values['health_snapshot_retention_days']) || undefined"
@change="(v: number | undefined) => values['health_snapshot_retention_days'] = String(v ?? 7)"
:model-value="values['health_snapshot_retention_days'] as number"
@change="(v: number | undefined) => values['health_snapshot_retention_days'] = v ?? 7"
:min="1" :max="90" style="width: 100%"
/>
</AFormItem>
@@ -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
}
</script>
@@ -14,7 +14,7 @@ const values = useFormValues()
</AFormItem>
<AFormItem label="SMTP 端口">
<AInputNumber
:model-value="Number(values['email_smtp_port']) || undefined"
:model-value="values['email_smtp_port'] as number"
@change="(v: number | undefined) => values['email_smtp_port'] = v"
:min="1" :max="65535"
placeholder="587"
@@ -40,6 +40,29 @@ const values = useFormValues()
@change="(v: string | number | boolean) => values['register_email_verification'] = v"
/>
</div>
<div class="section-grid" style="margin-top: 12px">
<AFormItem label="IP每小时注册限制" help="同一IP每小时最多注册次数(0表示不限制)">
<AInputNumber
:model-value="values['register_ip_limit_per_hour'] as number"
@change="(v: number | undefined) => values['register_ip_limit_per_hour'] = v ?? 50"
:min="0" :max="200" style="width: 100%"
/>
</AFormItem>
<AFormItem label="IP每天注册限制" help="同一IP每天最多注册次数(0表示不限制)">
<AInputNumber
:model-value="values['register_ip_limit_per_day'] as number"
@change="(v: number | undefined) => values['register_ip_limit_per_day'] = v ?? 500"
:min="0" :max="1000" style="width: 100%"
/>
</AFormItem>
<AFormItem label="全局每分钟注册限制" help="全系统每分钟最多注册次数(0表示不限制)" class="field-full">
<AInputNumber
:model-value="values['register_global_limit_per_minute'] as number"
@change="(v: number | undefined) => values['register_global_limit_per_minute'] = v ?? 50"
:min="0" :max="200" style="width: 100%"
/>
</AFormItem>
</div>
</div>
<!-- 维护模式 -->
@@ -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') {
</AFormItem>
<AFormItem label="最低充值金额">
<AInputNumber
:model-value="Number(values['payment_min_topup']) || undefined"
:model-value="values['payment_min_topup'] as number"
@change="(v: number | undefined) => values['payment_min_topup'] = v"
:min="1" style="width: 100%"
/>
@@ -49,8 +49,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
<AFormItem label="CNY → USD" help="1 人民币兑换多少美元">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<AInputNumber
:model-value="parseFloat(values['payment_exchange_rate_cny_to_usd'] || '0')"
@change="(v: number | undefined) => values['payment_exchange_rate_cny_to_usd'] = String(v ?? 0)"
:model-value="values['payment_exchange_rate_cny_to_usd'] as number"
@change="(v: number | undefined) => values['payment_exchange_rate_cny_to_usd'] = v ?? 0"
:min="0.001" :max="100" :step="0.01" :precision="4"
style="flex: 1"
/>
@@ -60,8 +60,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
<AFormItem label="USD → CNY" help="1 美元兑换多少人民币">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<AInputNumber
:model-value="parseFloat(values['payment_exchange_rate_usd_to_cny'] || '0')"
@change="(v: number | undefined) => values['payment_exchange_rate_usd_to_cny'] = String(v ?? 0)"
:model-value="values['payment_exchange_rate_usd_to_cny'] as number"
@change="(v: number | undefined) => values['payment_exchange_rate_usd_to_cny'] = v ?? 0"
:min="0.001" :max="1000" :step="0.01" :precision="4"
style="flex: 1"
/>
@@ -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
}
</script>
+1 -1
View File
@@ -394,7 +394,7 @@ const { exporting, exportFile } = useExport({
<AInput v-model="createForm.email" placeholder="成员邮箱" />
</AFormItem>
<AFormItem field="password" label="密码" :rules="[{ required: true, message: '请输入密码' }, { minLength: 8, maxLength: 64, message: '密码长度为8-64位' }]">
<AInput v-model="createForm.password" type="password" placeholder="需包含大小写字母和数字,至少8位" />
<AInput v-model="createForm.password" type="password" placeholder="需包含字母和数字,至少8位" />
</AFormItem>
<AFormItem field="display_name" label="显示名称">
<AInput v-model="createForm.display_name" placeholder="可选,默认同用户名" />
+1 -1
View File
@@ -404,7 +404,7 @@ const { exporting, exportFile } = useExport({
<AInput v-model="createForm.email" placeholder="管理员邮箱" />
</AFormItem>
<AFormItem field="password" label="管理员密码" :rules="[{ required: true, message: '请输入密码' }, { minLength: 8, maxLength: 64, message: '密码长度为8-64位' }]">
<AInput v-model="createForm.password" type="password" placeholder="需包含大小写字母和数字,至少8位" />
<AInput v-model="createForm.password" type="password" placeholder="需包含字母和数字,至少8位" />
</AFormItem>
<ASpace :size="16" class="w-full">
<AFormItem field="max_members" label="最大成员数" extra="留空跟随等级" class="flex-1">
@@ -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<string, any>) {
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(() => {
</p>
</div>
<div v-if="mode === 'edit'" class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="input-label">QPS 限速</label>
<input v-model.number="form.rate_limit_qps" type="number" min="0" class="input" placeholder="0 = 不限制" />
<input v-model.number="form.rate_limit_qps" type="number" min="0" class="input" placeholder="留空使用默认" />
</div>
<div>
<label class="input-label">并发限制</label>
<input v-model.number="form.rate_limit_concurrency" type="number" min="0" class="input" placeholder="0 = 不限制" />
</div>
<div>
<label class="input-label">总额度 (USD)</label>
@@ -300,25 +328,36 @@ const isFormMode = computed(() => {
</div>
</div>
<div>
<label class="input-label">过期时间</label>
<div class="flex items-center gap-2">
<input
v-model="form.expires_at"
type="datetime-local"
class="input flex-1"
:placeholder="mode === 'create' ? '留空表示永不过期' : '留空表示不修改'"
/>
<div class="inline-flex rounded-lg overflow-hidden border border-gray-200 shrink-0">
<button type="button" @click="quickFillExpires(1)" class="px-3.5 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 transition-colors border-r border-gray-200">1天</button>
<button type="button" @click="quickFillExpires(7)" class="px-3.5 py-2 text-sm font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors border-r border-gray-200">1周</button>
<button type="button" @click="quickFillExpires(30)" class="px-3.5 py-2 text-sm font-medium text-violet-700 bg-violet-50 hover:bg-violet-100 transition-colors">1月</button>
</div>
<div>
<label class="input-label">IP 白名单</label>
<textarea
v-model="form.ip_whitelist_text"
class="input"
rows="3"
placeholder="每行一个 IP 或 CIDR,留空表示不限制"
></textarea>
<p class="input-hint">支持 192.168.1.10 或 10.0.0.0/24,也可用逗号分隔。</p>
</div>
<div>
<label class="input-label">过期时间</label>
<div class="flex items-center gap-2">
<input
v-model="form.expires_at"
type="datetime-local"
class="input flex-1"
:placeholder="mode === 'create' ? '留空表示永不过期' : '留空表示不修改'"
/>
<div class="inline-flex rounded-lg overflow-hidden border border-gray-200 shrink-0">
<button type="button" @click="quickFillExpires(1)" class="px-3.5 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 transition-colors border-r border-gray-200">1天</button>
<button type="button" @click="quickFillExpires(7)" class="px-3.5 py-2 text-sm font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 transition-colors border-r border-gray-200">1周</button>
<button type="button" @click="quickFillExpires(30)" class="px-3.5 py-2 text-sm font-medium text-violet-700 bg-violet-50 hover:bg-violet-100 transition-colors">1月</button>
</div>
<p v-if="mode === 'edit' && apiKey && apiKey.expires_at" class="input-hint">当前过期时间:{{ formatDate(apiKey.expires_at) }}</p>
<p v-else-if="mode === 'edit'" class="input-hint">当前:永不过期</p>
<p v-else class="input-hint">留空表示永不过期</p>
</div>
<p v-if="mode === 'edit' && apiKey && apiKey.expires_at" class="input-hint">当前过期时间:{{ formatDate(apiKey.expires_at) }}</p>
<p v-else-if="mode === 'edit'" class="input-hint">当前:永不过期</p>
<p v-else class="input-hint">留空表示永不过期</p>
</div>
</div>
<div v-else class="space-y-4">
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more