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:
commit
8ab1e1644c
113 files changed
+3160
-798
No files matched your search
@@ -5,7 +5,7 @@ import "github.com/gogf/gf/v2/frame/g"
|
||||
// TenantCreateReq 创建租户请求
|
||||
type TenantCreateReq struct {
|
||||
g.Meta `path:"/tenants" method:"post" mime:"json" tags:"管理后台-租户管理" summary:"创建租户"`
|
||||
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入租户名称|租户名称长度为2-100位" dc:"租户名称"`
|
||||
TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"`
|
||||
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"`
|
||||
Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"`
|
||||
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"`
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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:"邀请码"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,9 +26,9 @@ type MdlModelGroupsColumns struct {
|
||||
Code string // 分组唯一标识(如 full_access、basic_chat)
|
||||
Description string // 分组描述
|
||||
Status string // 状态:active(启用)/ disabled(禁用)
|
||||
IsDefault string // 是否为新租户默认模型组,注册时自动关联
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
IsDefault string // 是否为新租户默认模型组,注册时自动关联
|
||||
}
|
||||
|
||||
// mdlModelGroupsColumns holds the columns for the table mdl_model_groups.
|
||||
@@ -38,9 +38,9 @@ var mdlModelGroupsColumns = MdlModelGroupsColumns{
|
||||
Code: "code",
|
||||
Description: "description",
|
||||
Status: "status",
|
||||
IsDefault: "is_default",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
IsDefault: "is_default",
|
||||
}
|
||||
|
||||
// NewMdlModelGroupsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -21,40 +21,42 @@ type OpnAppsDao struct {
|
||||
|
||||
// OpnAppsColumns defines and stores column names for the table opn_apps.
|
||||
type OpnAppsColumns struct {
|
||||
Id string // 主键ID
|
||||
TenantId string // 所属租户ID
|
||||
Name string // 应用名称
|
||||
Description string // 应用描述
|
||||
AppId string // 应用标识(opn_xxx 格式)
|
||||
AppSecretHash string // App Secret 哈希(bcrypt)
|
||||
Permissions string // 权限范围(JSON 数组)
|
||||
IpWhitelist string // IP 白名单(JSON 数组,为空则不限制)
|
||||
CallbackUrl string // OAuth 回调 URL
|
||||
IsSandbox string // 是否沙箱应用
|
||||
Status string // 状态:active(启用)/ disabled(禁用)
|
||||
RateLimit string // 每分钟请求上限
|
||||
LastUsedAt string // 最后使用时间
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
Id string // 主键ID
|
||||
TenantId string // 所属租户ID
|
||||
Name string // 应用名称
|
||||
Description string // 应用描述
|
||||
AppId string // 应用标识(opn_xxx 格式)
|
||||
AppSecretHash string // App Secret 哈希(bcrypt)
|
||||
Permissions string // 权限范围(JSON 数组)
|
||||
IpWhitelist string // IP 白名单(JSON 数组,为空则不限制)
|
||||
CallbackUrl string // OAuth 回调 URL
|
||||
IsSandbox string // 是否沙箱应用
|
||||
Status string // 状态:active(启用)/ disabled(禁用)
|
||||
RateLimit string // 每分钟请求上限
|
||||
LastUsedAt string // 最后使用时间
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
EncryptedSecret string // AES-256 encrypted App Secret for HMAC verification
|
||||
}
|
||||
|
||||
// opnAppsColumns holds the columns for the table opn_apps.
|
||||
var opnAppsColumns = OpnAppsColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
Name: "name",
|
||||
Description: "description",
|
||||
AppId: "app_id",
|
||||
AppSecretHash: "app_secret_hash",
|
||||
Permissions: "permissions",
|
||||
IpWhitelist: "ip_whitelist",
|
||||
CallbackUrl: "callback_url",
|
||||
IsSandbox: "is_sandbox",
|
||||
Status: "status",
|
||||
RateLimit: "rate_limit",
|
||||
LastUsedAt: "last_used_at",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
Name: "name",
|
||||
Description: "description",
|
||||
AppId: "app_id",
|
||||
AppSecretHash: "app_secret_hash",
|
||||
Permissions: "permissions",
|
||||
IpWhitelist: "ip_whitelist",
|
||||
CallbackUrl: "callback_url",
|
||||
IsSandbox: "is_sandbox",
|
||||
Status: "status",
|
||||
RateLimit: "rate_limit",
|
||||
LastUsedAt: "last_used_at",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
EncryptedSecret: "encrypted_secret",
|
||||
}
|
||||
|
||||
// NewOpnAppsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// PlgExampleLogsDao is the data access object for the table plg_example_logs.
|
||||
type PlgExampleLogsDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns PlgExampleLogsColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// PlgExampleLogsColumns defines and stores column names for the table plg_example_logs.
|
||||
type PlgExampleLogsColumns struct {
|
||||
Id string //
|
||||
TenantId string //
|
||||
Action string //
|
||||
Detail string //
|
||||
CreatedAt string //
|
||||
}
|
||||
|
||||
// plgExampleLogsColumns holds the columns for the table plg_example_logs.
|
||||
var plgExampleLogsColumns = PlgExampleLogsColumns{
|
||||
Id: "id",
|
||||
TenantId: "tenant_id",
|
||||
Action: "action",
|
||||
Detail: "detail",
|
||||
CreatedAt: "created_at",
|
||||
}
|
||||
|
||||
// NewPlgExampleLogsDao creates and returns a new DAO object for table data access.
|
||||
func NewPlgExampleLogsDao(handlers ...gdb.ModelHandler) *PlgExampleLogsDao {
|
||||
return &PlgExampleLogsDao{
|
||||
group: "default",
|
||||
table: "plg_example_logs",
|
||||
columns: plgExampleLogsColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *PlgExampleLogsDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *PlgExampleLogsDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *PlgExampleLogsDao) Columns() PlgExampleLogsColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *PlgExampleLogsDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *PlgExampleLogsDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *PlgExampleLogsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -21,38 +21,42 @@ type SysAdminUsersDao struct {
|
||||
|
||||
// SysAdminUsersColumns defines and stores column names for the table sys_admin_users.
|
||||
type SysAdminUsersColumns struct {
|
||||
Id string // 主键ID
|
||||
Username string // 登录用户名
|
||||
PasswordHash string // 密码哈希(bcrypt)
|
||||
Email string // 邮箱地址(可选,可为NULL)
|
||||
DisplayName string // 显示名称
|
||||
Role string // 角色:super_admin(全权限)/ admin(可配置权限)
|
||||
Status string // 状态:active(启用)/ disabled(禁用)
|
||||
LastLoginAt string // 最后登录时间
|
||||
LastLoginIp string // 最后登录IP
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
TotpSecret string // TOTP 密钥(AES-256 加密存储)
|
||||
TotpEnabled string // 是否启用双因素认证
|
||||
BackupCodes string // 备用恢复码(bcrypt 哈希存储)
|
||||
Id string // 主键ID
|
||||
Username string // 登录用户名
|
||||
PasswordHash string // 密码哈希(bcrypt)
|
||||
Email string // 邮箱地址(可选,可为NULL)
|
||||
DisplayName string // 显示名称
|
||||
Role string // 角色:super_admin(全权限)/ admin(可配置权限)
|
||||
Status string // 状态:active(启用)/ disabled(禁用)
|
||||
LastLoginAt string // 最后登录时间
|
||||
LastLoginIp string // 最后登录IP
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
TotpSecret string // TOTP 密钥(AES-256 加密存储)
|
||||
TotpEnabled string // 是否启用双因素认证
|
||||
BackupCodes string // 备用恢复码(bcrypt 哈希存储)
|
||||
FailedAttempts string // 连续登录失败次数(成功登录后归零)
|
||||
LockedUntil string // 锁定截止时间(连续5次失败后锁定30分钟)
|
||||
}
|
||||
|
||||
// sysAdminUsersColumns holds the columns for the table sys_admin_users.
|
||||
var sysAdminUsersColumns = SysAdminUsersColumns{
|
||||
Id: "id",
|
||||
Username: "username",
|
||||
PasswordHash: "password_hash",
|
||||
Email: "email",
|
||||
DisplayName: "display_name",
|
||||
Role: "role",
|
||||
Status: "status",
|
||||
LastLoginAt: "last_login_at",
|
||||
LastLoginIp: "last_login_ip",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
TotpSecret: "totp_secret",
|
||||
TotpEnabled: "totp_enabled",
|
||||
BackupCodes: "backup_codes",
|
||||
Id: "id",
|
||||
Username: "username",
|
||||
PasswordHash: "password_hash",
|
||||
Email: "email",
|
||||
DisplayName: "display_name",
|
||||
Role: "role",
|
||||
Status: "status",
|
||||
LastLoginAt: "last_login_at",
|
||||
LastLoginIp: "last_login_ip",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
TotpSecret: "totp_secret",
|
||||
TotpEnabled: "totp_enabled",
|
||||
BackupCodes: "backup_codes",
|
||||
FailedAttempts: "failed_attempts",
|
||||
LockedUntil: "locked_until",
|
||||
}
|
||||
|
||||
// NewSysAdminUsersDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -21,13 +21,13 @@ type SysAgreementAcceptancesDao struct {
|
||||
|
||||
// SysAgreementAcceptancesColumns defines and stores column names for the table sys_agreement_acceptances.
|
||||
type SysAgreementAcceptancesColumns struct {
|
||||
Id string //
|
||||
Id string // 主键ID
|
||||
AgreementId string // 关联协议版本ID
|
||||
UserType string // 用户类型
|
||||
UserType string // 用户类型:admin(管理员) / tenant(租户用户)
|
||||
UserId string // 用户ID
|
||||
IpAddress string // IP地址
|
||||
UserAgent string // User-Agent
|
||||
CreatedAt string //
|
||||
IpAddress string // 接受时的IP地址
|
||||
UserAgent string // 接受时的浏览器User-Agent
|
||||
CreatedAt string // 接受时间
|
||||
}
|
||||
|
||||
// sysAgreementAcceptancesColumns holds the columns for the table sys_agreement_acceptances.
|
||||
@@ -71,7 +71,7 @@ func (dao *SysAgreementAcceptancesDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO.
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
@@ -81,6 +81,11 @@ func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *SysAgreementAcceptancesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -21,19 +21,19 @@ type SysAgreementsDao struct {
|
||||
|
||||
// SysAgreementsColumns defines and stores column names for the table sys_agreements.
|
||||
type SysAgreementsColumns struct {
|
||||
Id string //
|
||||
Code string // 协议标识码
|
||||
Version string // 版本号
|
||||
Id string // 主键ID
|
||||
Code string // 协议标识码:terms(用户协议) / privacy(隐私政策)
|
||||
Version string // 版本号,如 1.0、2.0
|
||||
Title string // 协议标题
|
||||
Content string // 协议正文(Markdown)
|
||||
Summary string // 版本变更摘要
|
||||
Status string // 状态
|
||||
IsCurrent string // 是否为当前生效版本
|
||||
ForceAccept string // 是否强制用户接受
|
||||
Status string // 状态:draft(草稿) / published(已发布) / archived(已归档)
|
||||
IsCurrent string // 是否为该标识码的当前生效版本(每个code仅一条)
|
||||
ForceAccept string // 是否强制用户接受(true=登录后必须接受才能继续)
|
||||
PublishedAt string // 发布时间
|
||||
CreatedBy string // 创建的管理员ID
|
||||
CreatedAt string //
|
||||
UpdatedAt string //
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// sysAgreementsColumns holds the columns for the table sys_agreements.
|
||||
@@ -83,7 +83,7 @@ func (dao *SysAgreementsDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO.
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
@@ -93,6 +93,11 @@ func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *SysAgreementsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type TntTenantsColumns struct {
|
||||
MaxConcurrency string // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
|
||||
DefaultChannelScope string // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
|
||||
Level string // 当前等级(对应 tnt_tenant_level_configs.level)
|
||||
TeamEnabled string // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
|
||||
}
|
||||
|
||||
// tntTenantsColumns holds the columns for the table tnt_tenants.
|
||||
@@ -61,6 +62,7 @@ var tntTenantsColumns = TntTenantsColumns{
|
||||
MaxConcurrency: "max_concurrency",
|
||||
DefaultChannelScope: "default_channel_scope",
|
||||
Level: "level",
|
||||
TeamEnabled: "team_enabled",
|
||||
}
|
||||
|
||||
// NewTntTenantsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// plgExampleLogsDao is the data access object for the table plg_example_logs.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type plgExampleLogsDao struct {
|
||||
*internal.PlgExampleLogsDao
|
||||
}
|
||||
|
||||
var (
|
||||
// PlgExampleLogs is a globally accessible object for table plg_example_logs operations.
|
||||
PlgExampleLogs = plgExampleLogsDao{internal.NewPlgExampleLogsDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 表示不限。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ",")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,16 +36,40 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
username := strings.TrimSpace(req.Username)
|
||||
tenantName := strings.TrimSpace(req.TenantName)
|
||||
|
||||
// 先校验禁用词:命中禁用词时优先返回更准确的提示,
|
||||
// 简化注册:组织信息可选,留空时后端自动生成(个人模式,team_enabled=false)
|
||||
userProvidedCode := tenantCode != ""
|
||||
userProvidedName := tenantName != ""
|
||||
|
||||
// 先校验用户名禁用词:命中禁用词时优先返回更准确的提示,
|
||||
// 避免被用户名格式校验的「不能包含特殊字符或中文」遮盖真实原因
|
||||
if err := common.ValidateForbiddenWords(ctx, username, "用户名"); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
|
||||
}
|
||||
if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
|
||||
|
||||
// 组织代码:留空则自动生成(org-<base36>);用户显式传入才校验禁用词
|
||||
if !userProvidedCode {
|
||||
generated, err := generateUniqueTenantCode(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantCode = generated
|
||||
} else {
|
||||
if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
|
||||
}
|
||||
}
|
||||
if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
|
||||
|
||||
// 组织名称:留空则用用户名派生默认值;用户显式传入才校验禁用词
|
||||
if !userProvidedName {
|
||||
// 用户名较长时“xxx 的组织”可能超出组织名称显示宽度上限,按宽度截断兜底
|
||||
tenantName = common.TruncateToDisplayWidth(fmt.Sprintf("%s 的组织", username), common.TenantNameMaxDisplayWidth)
|
||||
} else {
|
||||
if err := common.ValidateTenantName(tenantName); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
|
||||
}
|
||||
if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil {
|
||||
return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
@@ -84,14 +108,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
}
|
||||
}
|
||||
|
||||
// Check tenant code uniqueness
|
||||
count, err := dao.TntTenants.Ctx(ctx).
|
||||
Where("code", tenantCode).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
|
||||
// Check tenant code uniqueness(仅用户显式传入时;自动生成的已在生成时查重)
|
||||
if userProvidedCode {
|
||||
count, err := dao.TntTenants.Ctx(ctx).
|
||||
Where("code", tenantCode).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate password policy
|
||||
@@ -109,12 +135,13 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// Create tenant
|
||||
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
|
||||
Name: strings.TrimSpace(req.TenantName),
|
||||
Name: tenantName,
|
||||
Code: tenantCode,
|
||||
// MaxMembers/MaxConcurrency: nil = 跟随等级配置
|
||||
// Level 1 defaults will apply
|
||||
Level: 1,
|
||||
Settings: "{}",
|
||||
Level: 1,
|
||||
Settings: "{}",
|
||||
TeamEnabled: false, // 新注册默认个人模式,设置自定义 code 后才激活团队功能
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "create tenant")
|
||||
@@ -214,8 +241,9 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339),
|
||||
}
|
||||
res.Tenant.ID = tenantID
|
||||
res.Tenant.Name = strings.TrimSpace(req.TenantName)
|
||||
res.Tenant.Name = tenantName
|
||||
res.Tenant.Code = tenantCode
|
||||
res.Tenant.TeamEnabled = false
|
||||
res.User.ID = ownerUserID
|
||||
res.User.Username = username
|
||||
res.User.Role = "owner"
|
||||
@@ -408,6 +436,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
res.Tenant.ID = tenant.Id
|
||||
res.Tenant.Name = tenant.Name
|
||||
res.Tenant.Code = tenant.Code
|
||||
res.Tenant.TeamEnabled = tenant.TeamEnabled
|
||||
res.User.ID = user.Id
|
||||
res.User.Username = user.Username
|
||||
res.User.Role = user.Role
|
||||
@@ -455,6 +484,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
res.Tenant.ID = tenant.Id
|
||||
res.Tenant.Name = tenant.Name
|
||||
res.Tenant.Code = tenant.Code
|
||||
res.Tenant.TeamEnabled = tenant.TeamEnabled
|
||||
res.User.ID = user.Id
|
||||
res.User.Username = user.Username
|
||||
res.User.Role = user.Role
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
)
|
||||
|
||||
// generateUniqueTenantCode 生成一个不冲突的默认租户 code。
|
||||
// 格式:org-<base36>,满足正则 ^[a-z0-9][a-z0-9-]*[a-z0-9]$,长度 3-30。
|
||||
// 用 crypto/rand 保证不可预测,循环查重最多 5 次避免唯一索引冲突。
|
||||
func generateUniqueTenantCode(ctx context.Context) (string, error) {
|
||||
const maxAttempts = 5
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
code, err := randomTenantCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
count, err := dao.TntTenants.Ctx(ctx).Where("code", code).Count()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count == 0 {
|
||||
return code, nil
|
||||
}
|
||||
g.Log().Warningf(ctx, "生成的租户 code 冲突,重试: %s", code)
|
||||
}
|
||||
return "", fmt.Errorf("生成唯一租户 code 失败(重试 %d 次)", maxAttempts)
|
||||
}
|
||||
|
||||
// randomTenantCode 生成单个 "org-<base36>" 形式的随机 code。
|
||||
func randomTenantCode() (string, error) {
|
||||
b := make([]byte, 9)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
var chars []byte
|
||||
tmp := new(big.Int).SetBytes(b)
|
||||
base := big.NewInt(36)
|
||||
mod := new(big.Int)
|
||||
for tmp.Sign() > 0 && len(chars) < 16 {
|
||||
tmp.DivMod(tmp, base, mod)
|
||||
chars = append([]byte{alphabet[mod.Int64()]}, chars...)
|
||||
}
|
||||
if len(chars) == 0 {
|
||||
chars = []byte{'0'}
|
||||
}
|
||||
return "org-" + string(chars), nil
|
||||
}
|
||||
@@ -109,6 +109,9 @@ func (s *sTenant) RevokeInvitation(ctx context.Context, req *v1.TenantInvitation
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
|
||||
@@ -90,6 +90,9 @@ func (s *sTenant) InviteMember(ctx context.Context, req *v1.TenantMemberInviteRe
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
creatorID := middleware.GetUserID(ctx)
|
||||
|
||||
@@ -224,6 +227,11 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
|
||||
return nil, common.NewBusinessError(consts.CodeInvitationExpired, consts.MsgInvitationExpired)
|
||||
}
|
||||
|
||||
// 团队功能门控:目标租户必须已启用团队功能(公开接口,ctx 无 tenant_id,用目标租户 ID 校验)
|
||||
if err := requireTeamEnabledForTenant(ctx, invitation.TenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||
|
||||
// Validate password
|
||||
@@ -384,6 +392,9 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
// Validate role
|
||||
@@ -501,6 +512,9 @@ func (s *sTenant) RemoveMember(ctx context.Context, req *v1.TenantMemberRemoveRe
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
currentUserID := middleware.GetUserID(ctx)
|
||||
memberID := req.Id
|
||||
@@ -670,6 +684,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
currentUserID := middleware.GetUserID(ctx)
|
||||
memberID := req.Id
|
||||
@@ -719,6 +736,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda
|
||||
|
||||
// ResetMemberPassword resets a member's password. Only admins can reset other members' passwords.
|
||||
func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) {
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
currentUserID := middleware.GetUserID(ctx)
|
||||
memberID := req.Id
|
||||
@@ -932,6 +952,9 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
columns := []export.Column{
|
||||
|
||||
@@ -35,6 +35,9 @@ type ImportResult struct {
|
||||
|
||||
// MemberImport parses CSV content, validates, creates an import record.
|
||||
func (s *sTenant) MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) {
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
creatorID := middleware.GetUserID(ctx)
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second)
|
||||
|
||||
// MemberModelScopesSet sets the available models for a member (full replace).
|
||||
func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) {
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
// 验证用户属于当前租户
|
||||
|
||||
@@ -59,6 +59,9 @@ func (s *sTenant) MemberQuotaSet(ctx context.Context, req *v1.TenantMemberQuotaS
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
if req.QuotaType == "periodic" && req.Period == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -43,5 +43,4 @@ type AudRequestLogs struct {
|
||||
TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt *gtime.Time // 异步任务达到终态的时间
|
||||
AttachmentIds any // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
|
||||
Model any // 请求使用的模型名称(从请求体或 URL 中提取)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type MdlModelGroups struct {
|
||||
Code any // 分组唯一标识(如 full_access、basic_chat)
|
||||
Description any // 分组描述
|
||||
Status any // 状态:active(启用)/ disabled(禁用)
|
||||
IsDefault any // 是否为新租户默认模型组,注册时自动关联
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
IsDefault any // 是否为新租户默认模型组,注册时自动关联
|
||||
}
|
||||
@@ -11,20 +11,21 @@ import (
|
||||
|
||||
// OpnApps is the golang structure of table opn_apps for DAO operations like Where/Data.
|
||||
type OpnApps struct {
|
||||
g.Meta `orm:"table:opn_apps, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 所属租户ID
|
||||
Name any // 应用名称
|
||||
Description any // 应用描述
|
||||
AppId any // 应用标识(opn_xxx 格式)
|
||||
AppSecretHash any // App Secret 哈希(bcrypt)
|
||||
Permissions any // 权限范围(JSON 数组)
|
||||
IpWhitelist any // IP 白名单(JSON 数组,为空则不限制)
|
||||
CallbackUrl any // OAuth 回调 URL
|
||||
IsSandbox any // 是否沙箱应用
|
||||
Status any // 状态:active(启用)/ disabled(禁用)
|
||||
RateLimit any // 每分钟请求上限
|
||||
LastUsedAt *gtime.Time // 最后使用时间
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
g.Meta `orm:"table:opn_apps, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 所属租户ID
|
||||
Name any // 应用名称
|
||||
Description any // 应用描述
|
||||
AppId any // 应用标识(opn_xxx 格式)
|
||||
AppSecretHash any // App Secret 哈希(bcrypt)
|
||||
Permissions any // 权限范围(JSON 数组)
|
||||
IpWhitelist any // IP 白名单(JSON 数组,为空则不限制)
|
||||
CallbackUrl any // OAuth 回调 URL
|
||||
IsSandbox any // 是否沙箱应用
|
||||
Status any // 状态:active(启用)/ disabled(禁用)
|
||||
RateLimit any // 每分钟请求上限
|
||||
LastUsedAt *gtime.Time // 最后使用时间
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
EncryptedSecret any // AES-256 encrypted App Secret for HMAC verification
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package do
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// PlgExampleLogs is the golang structure of table plg_example_logs for DAO operations like Where/Data.
|
||||
type PlgExampleLogs struct {
|
||||
g.Meta `orm:"table:plg_example_logs, do:true"`
|
||||
Id any //
|
||||
TenantId any //
|
||||
Action any //
|
||||
Detail any //
|
||||
CreatedAt *gtime.Time //
|
||||
}
|
||||
@@ -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 // 接受时间
|
||||
}
|
||||
@@ -12,17 +12,17 @@ import (
|
||||
// SysAgreements is the golang structure of table sys_agreements for DAO operations like Where/Data.
|
||||
type SysAgreements struct {
|
||||
g.Meta `orm:"table:sys_agreements, do:true"`
|
||||
Id any //
|
||||
Code any // 协议标识码
|
||||
Version any // 版本号
|
||||
Id any // 主键ID
|
||||
Code any // 协议标识码:terms(用户协议) / privacy(隐私政策)
|
||||
Version any // 版本号,如 1.0、2.0
|
||||
Title any // 协议标题
|
||||
Content any // 协议正文(Markdown)
|
||||
Summary any // 版本变更摘要
|
||||
Status any // 状态:draft / published / archived
|
||||
IsCurrent any // 是否为当前生效版本
|
||||
ForceAccept any // 是否强制用户接受
|
||||
Status any // 状态:draft(草稿) / published(已发布) / archived(已归档)
|
||||
IsCurrent any // 是否为该标识码的当前生效版本(每个code仅一条)
|
||||
ForceAccept any // 是否强制用户接受(true=登录后必须接受才能继续)
|
||||
PublishedAt *gtime.Time // 发布时间
|
||||
CreatedBy any // 创建的管理员ID
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
}
|
||||
@@ -30,4 +30,5 @@ type TntTenants struct {
|
||||
MaxConcurrency any // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
|
||||
DefaultChannelScope any // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
|
||||
Level any // 当前等级(对应 tnt_tenant_level_configs.level)
|
||||
TeamEnabled any // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
|
||||
}
|
||||
@@ -41,5 +41,4 @@ type AudRequestLogs struct {
|
||||
TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间
|
||||
AttachmentIds string `json:"attachment_ids" orm:"attachment_ids" description:"提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])"` // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
|
||||
Model string `json:"model" orm:"model" description:"请求使用的模型名称(从请求体或 URL 中提取)"` // 请求使用的模型名称(从请求体或 URL 中提取)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ type MdlModelGroups struct {
|
||||
Code string `json:"code" orm:"code" description:"分组唯一标识(如 full_access、basic_chat)"` // 分组唯一标识(如 full_access、basic_chat)
|
||||
Description string `json:"description" orm:"description" description:"分组描述"` // 分组描述
|
||||
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
|
||||
IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联
|
||||
}
|
||||
@@ -10,19 +10,20 @@ import (
|
||||
|
||||
// OpnApps is the golang structure for table opn_apps.
|
||||
type OpnApps struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称
|
||||
Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述
|
||||
AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式)
|
||||
AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt)
|
||||
Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组)
|
||||
IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制)
|
||||
CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL
|
||||
IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用
|
||||
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
|
||||
RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限
|
||||
LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称
|
||||
Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述
|
||||
AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式)
|
||||
AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt)
|
||||
Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组)
|
||||
IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制)
|
||||
CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL
|
||||
IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用
|
||||
Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用)
|
||||
RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限
|
||||
LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
EncryptedSecret string `json:"encrypted_secret" orm:"encrypted_secret" description:"AES-256 encrypted App Secret for HMAC verification"` // AES-256 encrypted App Secret for HMAC verification
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// PlgExampleLogs is the golang structure for table plg_example_logs.
|
||||
type PlgExampleLogs struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
|
||||
Action string `json:"action" orm:"action" description:""` //
|
||||
Detail string `json:"detail" orm:"detail" description:""` //
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
}
|
||||
@@ -10,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:"接受时间"` // 接受时间
|
||||
}
|
||||
@@ -10,17 +10,17 @@ import (
|
||||
|
||||
// SysAgreements is the golang structure for table sys_agreements.
|
||||
type SysAgreements struct {
|
||||
Id int64 `json:"id" orm:"id"`
|
||||
Code string `json:"code" orm:"code"`
|
||||
Version string `json:"version" orm:"version"`
|
||||
Title string `json:"title" orm:"title"`
|
||||
Content string `json:"content" orm:"content"`
|
||||
Summary string `json:"summary" orm:"summary"`
|
||||
Status string `json:"status" orm:"status"`
|
||||
IsCurrent bool `json:"is_current" orm:"is_current"`
|
||||
ForceAccept bool `json:"force_accept" orm:"force_accept"`
|
||||
PublishedAt *gtime.Time `json:"published_at" orm:"published_at"`
|
||||
CreatedBy int64 `json:"created_by" orm:"created_by"`
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
Code string `json:"code" orm:"code" description:"协议标识码:terms(用户协议) / privacy(隐私政策)"` // 协议标识码:terms(用户协议) / privacy(隐私政策)
|
||||
Version string `json:"version" orm:"version" description:"版本号,如 1.0、2.0"` // 版本号,如 1.0、2.0
|
||||
Title string `json:"title" orm:"title" description:"协议标题"` // 协议标题
|
||||
Content string `json:"content" orm:"content" description:"协议正文(Markdown)"` // 协议正文(Markdown)
|
||||
Summary string `json:"summary" orm:"summary" description:"版本变更摘要"` // 版本变更摘要
|
||||
Status string `json:"status" orm:"status" description:"状态:draft(草稿) / published(已发布) / archived(已归档)"` // 状态:draft(草稿) / published(已发布) / archived(已归档)
|
||||
IsCurrent bool `json:"is_current" orm:"is_current" description:"是否为该标识码的当前生效版本(每个code仅一条)"` // 是否为该标识码的当前生效版本(每个code仅一条)
|
||||
ForceAccept bool `json:"force_accept" orm:"force_accept" description:"是否强制用户接受(true=登录后必须接受才能继续)"` // 是否强制用户接受(true=登录后必须接受才能继续)
|
||||
PublishedAt *gtime.Time `json:"published_at" orm:"published_at" description:"发布时间"` // 发布时间
|
||||
CreatedBy int64 `json:"created_by" orm:"created_by" description:"创建的管理员ID"` // 创建的管理员ID
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -28,4 +28,5 @@ type TntTenants struct {
|
||||
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"租户总并发上限(NULL表示跟随等级配置,0表示不限制)"` // 租户总并发上限(NULL表示跟随等级配置,0表示不限制)
|
||||
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope" description:"默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)"` // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
|
||||
Level int `json:"level" orm:"level" description:"当前等级(对应 tnt_tenant_level_configs.level)"` // 当前等级(对应 tnt_tenant_level_configs.level)
|
||||
TeamEnabled bool `json:"team_enabled" orm:"team_enabled" description:"团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)"` // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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 限流信息(用于设置响应头)
|
||||
|
||||
@@ -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 渠道选择结果
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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分钟)")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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="可选,默认同用户名" />
|
||||
|
||||
@@ -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
Reference in new issue
Block a user