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