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