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

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

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

+
+ +
+ +
+ +
+ + +
-

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

-

当前:永不过期

-

留空表示永不过期

+

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

+

当前:永不过期

+

留空表示永不过期

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