修复安全问题 (#9)

* fix:修复权限问题

* fix:修复项目key类型错误问题

* fix:修复rbac权限问题

* fix:修复api密钥未及时失效的问题

* improve:优化渠道调度稳定性

* improve:优化路由

* fix:修复项目预算控制缺陷

* fix:加强限流等安全措施

* feat:添加限流相关字段的前端展示及接口字段

* improve:优化注册时密码要求,只需要字母和数字即可

* feat(setting):添加注册限流设置

* improve:优化前端数字类型转换

* chore:rbac权限放行

---------

Co-authored-by: chengqian <wuchengqian@kkidc.com>
Co-authored-by: qian <wuchengqian5@163.com>
This commit is contained in:
authored and GitHub committed 2026-06-23 22:47:54 +08:00
1 parent 501726b831
commit 22ee0aa112
69 files changed
+2228 -455

No files matched your search

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