feat(admin): 资源与策略 - 后台统一维护账号配额与运行时请求限制 (#37)
Fixes #35 Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 管理后台新增统一资源与请求策略配置,支持默认重置、自用模式、即时生效和用户配额用量查看。
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- 系统后台支持热更新 Worker 与全局渠道并发,各系统渠道可跟随全局值或单独覆盖;渠道满载时请求等待空闲槽位,不再直接失败。
|
||||
|
||||
@@ -120,7 +120,7 @@ bun run dev
|
||||
|
||||
打开 `http://localhost:3000`,注册首个管理员账号,再在系统设置中配置渠道和模型。
|
||||
|
||||
任务 Worker 和全局渠道并发可在“系统配置 → 任务并发”中热更新;系统渠道可选择跟随全局值,或单独设置 `1-100` 的最大并发数。未保存后台配置时分别回退到 `CANVAS_WORKER_CONCURRENCY` 和 `CANVAS_CHANNEL_CONCURRENCY`,两者默认均为 `3`。渠道槽位暂满时任务会等待,不会直接标记失败。
|
||||
资源配额、Worker/渠道/账号任务并发、业务频控、任务超时和渠道中转策略可在“系统配置 → 资源与策略”中统一热更新,支持重置和自用模式;系统渠道可选择跟随全局值,或单独设置 `1-999` 的最大并发数。未保存后台配置时 Worker 和全局渠道并发分别回退到 `CANVAS_WORKER_CONCURRENCY` 和 `CANVAS_CHANNEL_CONCURRENCY`,两者默认均为 `3`。渠道槽位暂满时任务会等待,不会直接标记失败。
|
||||
|
||||
Docker 一体化运行:
|
||||
|
||||
@@ -133,7 +133,7 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
- 用户自定义 AI API Key 保存在浏览器本地;创建异步任务时会提交给自部署后端并加密入队,生产环境必须使用 HTTPS。
|
||||
- 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。
|
||||
- 媒体资源在启用 OSS 时保存到私有 OSS,否则保存到后端数据目录;删除业务记录不会自动清理 OSS 对象。
|
||||
- 用户主动上传的单个文件必须小于 50MB,每个账号按 UTC 自然日累计必须小于 200MB;AI 生成结果不计入主动上传额度。
|
||||
- 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。
|
||||
|
||||
## 公网部署安全
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -33,7 +34,8 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "register:"+c.ClientIP(), 5, time.Hour) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "register:"+c.ClientIP(), policy.Request.RegisterPerHour, time.Hour) {
|
||||
return
|
||||
}
|
||||
result, err := svc.Register(req)
|
||||
@@ -53,7 +55,8 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "email-code:"+c.ClientIP(), 10, time.Hour) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "email-code:"+c.ClientIP(), policy.Request.EmailCodePerHour, time.Hour) {
|
||||
return
|
||||
}
|
||||
if err := svc.SendRegistrationEmailCode(req.Email); err != nil {
|
||||
@@ -69,10 +72,11 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "login-ip:"+c.ClientIP(), 50, 10*time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "login-ip:"+c.ClientIP(), policy.Request.LoginIPPerTenMinutes, 10*time.Minute) {
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "login:"+c.ClientIP()+":"+strings.ToLower(strings.TrimSpace(req.Username)), 10, 10*time.Minute) {
|
||||
if !enforceRateLimit(c, "login:"+c.ClientIP()+":"+strings.ToLower(strings.TrimSpace(req.Username)), policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) {
|
||||
return
|
||||
}
|
||||
result, err := svc.Login(req)
|
||||
@@ -112,7 +116,12 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
channels, _ := svc.PublicSystemChannels()
|
||||
ok(c, gin.H{"user": publicUser, "systemChannels": channels})
|
||||
limits, err := svc.PublicRuntimeLimits()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": publicUser, "systemChannels": channels, "runtimeLimits": limits})
|
||||
})
|
||||
r.GET("/channels/system", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
@@ -439,31 +448,58 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.GET("/admin/settings/concurrency", func(c *gin.Context) {
|
||||
r.GET("/admin/settings/runtime-policy", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.AdminRuntimeConcurrencySetting(user)
|
||||
setting, err := svc.AdminRuntimePolicySetting(user)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.PATCH("/admin/settings/concurrency", func(c *gin.Context) {
|
||||
r.GET("/admin/settings/runtime-policy/self-use", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.RuntimeConcurrencySettingRequest
|
||||
setting, err := svc.AdminSelfUseRuntimePolicy(user)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.PUT("/admin/settings/runtime-policy", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req service.RuntimePolicySetting
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.UpdateRuntimeConcurrencySetting(user, req)
|
||||
setting, err := svc.UpdateRuntimePolicySetting(user, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.DELETE("/admin/settings/runtime-policy", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.ResetRuntimePolicySetting(user)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -536,14 +572,15 @@ func RegisterSystemProxyRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
|
||||
func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, channel *model.ModelChannel) {
|
||||
startedAt := time.Now()
|
||||
if !enforceRateLimit(c, "system-proxy:"+user.ID, 120, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "system-proxy:"+user.ID, policy.Request.SystemRelayPerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
path := c.Param("path")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSystemProxyBodyBytes)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, policy.Request.SystemRelayRequestMB<<20)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
@@ -633,7 +670,8 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
status = model.ApiCallStatusFailed
|
||||
}
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, (128<<20)+1))
|
||||
responseLimit := policy.Request.SystemRelayResponseMB << 20
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, responseLimit+1))
|
||||
if readErr != nil {
|
||||
status = model.ApiCallStatusFailed
|
||||
errorText = readErr.Error()
|
||||
@@ -642,9 +680,9 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
|
||||
fail(c, http.StatusBadGateway, errors.New("系统渠道响应读取失败"))
|
||||
return
|
||||
}
|
||||
if len(responseBody) > 128<<20 {
|
||||
if int64(len(responseBody)) > responseLimit {
|
||||
_ = svc.MarkBillingUncertain(billingOrderID, "上游已响应但响应体超过限制,费用状态待核对")
|
||||
fail(c, http.StatusBadGateway, errors.New("系统渠道响应超过 128MB 限制"))
|
||||
fail(c, http.StatusBadGateway, fmt.Errorf("系统渠道响应超过 %dMB 限制", policy.Request.SystemRelayResponseMB))
|
||||
return
|
||||
}
|
||||
if status == model.ApiCallStatusSucceeded {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
@@ -16,23 +15,9 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCustomRelayBodyBytes int64 = 32 << 20
|
||||
maxCustomRelayModelResponseBytes int64 = 4 << 20
|
||||
maxCustomRelayJSONResponseBytes int64 = 16 << 20
|
||||
maxCustomRelayErrorResponseBytes int64 = 64 << 10
|
||||
maxCustomRelayStreamBytes int64 = 32 << 20
|
||||
maxCustomRelayConcurrency = 4
|
||||
customRelayHTTPTimeout = 10 * time.Minute
|
||||
)
|
||||
const maxCustomRelayErrorResponseBytes int64 = 64 << 10
|
||||
|
||||
var (
|
||||
customRelayClient = service.CustomRelayHTTPClient
|
||||
customRelayActive = struct {
|
||||
sync.Mutex
|
||||
users map[string]int
|
||||
}{users: map[string]int{}}
|
||||
)
|
||||
var customRelayClient = service.CustomRelayHTTPClient
|
||||
|
||||
func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.Any("/ai/custom", func(c *gin.Context) {
|
||||
@@ -41,19 +26,26 @@ func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "custom-relay:"+user.ID, 120, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "custom-relay:"+user.ID, policy.Request.CustomRelayPerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
if !acquireCustomRelaySlot(user.ID) {
|
||||
ttl := time.Duration(policy.Request.CustomRelayTimeoutMinutes+1) * time.Minute
|
||||
release, acquired, err := svc.AcquireCustomRelaySlot(c.Request.Context(), user.ID, policy.Request.CustomRelayConcurrency, ttl)
|
||||
if err != nil {
|
||||
fail(c, http.StatusServiceUnavailable, errors.New("自定义渠道并发协调服务不可用"))
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
fail(c, http.StatusTooManyRequests, errors.New("自定义渠道并发请求过多,请等待已有请求完成"))
|
||||
return
|
||||
}
|
||||
defer releaseCustomRelaySlot(user.ID)
|
||||
proxyCustomRelayRequest(c)
|
||||
defer release()
|
||||
proxyCustomRelayRequest(c, policy.Request)
|
||||
})
|
||||
}
|
||||
|
||||
func proxyCustomRelayRequest(c *gin.Context) {
|
||||
func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy) {
|
||||
target, err := service.ValidateCustomRelayURL(c.GetHeader("X-Canvas-Upstream-URL"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -72,16 +64,17 @@ func proxyCustomRelayRequest(c *gin.Context) {
|
||||
fail(c, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
if c.Request.ContentLength > maxCustomRelayBodyBytes {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求不能超过 32MB"))
|
||||
requestLimit := policy.CustomRelayRequestMB << 20
|
||||
if c.Request.ContentLength > requestLimit {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求超过配置上限"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxCustomRelayBodyBytes)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, requestLimit)
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
var maxBytesError *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesError) {
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求不能超过 32MB"))
|
||||
fail(c, http.StatusRequestEntityTooLarge, errors.New("自定义渠道请求超过配置上限"))
|
||||
return
|
||||
}
|
||||
fail(c, http.StatusBadRequest, errors.New("读取自定义渠道请求失败"))
|
||||
@@ -111,16 +104,16 @@ func proxyCustomRelayRequest(c *gin.Context) {
|
||||
upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
resp, err := customRelayClient(customRelayHTTPTimeout).Do(upstreamReq)
|
||||
resp, err := customRelayClient(time.Duration(policy.CustomRelayTimeoutMinutes) * time.Minute).Do(upstreamReq)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游连接失败"))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
writeCustomRelayResponse(c, resp, apiKey, c.Request.Method == http.MethodGet)
|
||||
writeCustomRelayResponse(c, resp, apiKey, policy.CustomRelayResponseMB<<20)
|
||||
}
|
||||
|
||||
func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string, modelList bool) {
|
||||
func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string, responseLimit int64) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
mediaType, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
@@ -133,17 +126,14 @@ func writeCustomRelayResponse(c *gin.Context, resp *http.Response, apiKey string
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Status(resp.StatusCode)
|
||||
c.Writer.WriteHeaderNow()
|
||||
copyCustomRelayStream(c, resp.Body, apiKey)
|
||||
copyCustomRelayStream(c, resp.Body, apiKey, responseLimit)
|
||||
return
|
||||
}
|
||||
if mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json") {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回了不支持的内容类型"))
|
||||
return
|
||||
}
|
||||
limit := maxCustomRelayJSONResponseBytes
|
||||
if modelList {
|
||||
limit = maxCustomRelayModelResponseBytes
|
||||
}
|
||||
limit := responseLimit
|
||||
body, err := readLimitedRelayBody(resp.Body, limit)
|
||||
if err != nil || !json.Valid(body) {
|
||||
fail(c, http.StatusBadGateway, errors.New("自定义渠道上游返回无效或过大的 JSON"))
|
||||
@@ -167,14 +157,14 @@ func writeCustomRelayError(c *gin.Context, resp *http.Response, apiKey string, m
|
||||
fail(c, resp.StatusCode, errors.New("自定义渠道上游请求失败"))
|
||||
}
|
||||
|
||||
func copyCustomRelayStream(c *gin.Context, source io.Reader, apiKey string) {
|
||||
func copyCustomRelayStream(c *gin.Context, source io.Reader, apiKey string, maxBytes int64) {
|
||||
redactor := newRelayStreamRedactor(apiKey)
|
||||
buffer := make([]byte, 32<<10)
|
||||
var written int64
|
||||
for written < maxCustomRelayStreamBytes {
|
||||
for written < maxBytes {
|
||||
read, err := source.Read(buffer)
|
||||
if read > 0 {
|
||||
remaining := maxCustomRelayStreamBytes - written
|
||||
remaining := maxBytes - written
|
||||
if int64(read) > remaining {
|
||||
read = int(remaining)
|
||||
}
|
||||
@@ -217,26 +207,6 @@ func customRelayAPIKey(value string) (string, error) {
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
func acquireCustomRelaySlot(userID string) bool {
|
||||
customRelayActive.Lock()
|
||||
defer customRelayActive.Unlock()
|
||||
if customRelayActive.users[userID] >= maxCustomRelayConcurrency {
|
||||
return false
|
||||
}
|
||||
customRelayActive.users[userID]++
|
||||
return true
|
||||
}
|
||||
|
||||
func releaseCustomRelaySlot(userID string) {
|
||||
customRelayActive.Lock()
|
||||
defer customRelayActive.Unlock()
|
||||
if customRelayActive.users[userID] <= 1 {
|
||||
delete(customRelayActive.users, userID)
|
||||
return
|
||||
}
|
||||
customRelayActive.users[userID]--
|
||||
}
|
||||
|
||||
func redactRelaySecret(body []byte, apiKey string) []byte {
|
||||
if apiKey == "" {
|
||||
return body
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -44,7 +46,7 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -82,7 +84,7 @@ func TestCustomRelayConvertsGeminiAuthentication(t *testing.T) {
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -106,7 +108,7 @@ func TestCustomRelayStreamsBeforeUpstreamCompletes(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
context, _ := gin.CreateTestContext(w)
|
||||
context.Request = r
|
||||
proxyCustomRelayRequest(context)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
}))
|
||||
defer proxy.Close()
|
||||
request, _ := http.NewRequest(http.MethodPost, proxy.URL, strings.NewReader(`{"model":"test"}`))
|
||||
@@ -157,7 +159,7 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
t.Cleanup(func() { customRelayClient = previous })
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/ai/custom", strings.NewReader(`{"model":"test"}`))
|
||||
request.ContentLength = maxCustomRelayBodyBytes + 1
|
||||
request.ContentLength = (defaultCustomRelayTestPolicy().CustomRelayRequestMB << 20) + 1
|
||||
request.Header.Set("Authorization", "Bearer test-key")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Canvas-Upstream-URL", "https://127.0.0.1/v1/responses")
|
||||
@@ -166,7 +168,7 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
|
||||
proxyCustomRelayRequest(context)
|
||||
proxyCustomRelayRequest(context, defaultCustomRelayTestPolicy())
|
||||
if response.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
@@ -175,28 +177,15 @@ func TestCustomRelayRejectsOversizedDeclaredBodyBeforeConnecting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRelayConcurrencyLimitReleasesSlots(t *testing.T) {
|
||||
const userID = "relay-limit-test-user"
|
||||
for index := 0; index < maxCustomRelayConcurrency; index++ {
|
||||
if !acquireCustomRelaySlot(userID) {
|
||||
t.Fatalf("slot %d should be available", index)
|
||||
}
|
||||
}
|
||||
if acquireCustomRelaySlot(userID) {
|
||||
t.Fatal("request above the concurrency limit should be rejected")
|
||||
}
|
||||
for index := 0; index < maxCustomRelayConcurrency; index++ {
|
||||
releaseCustomRelaySlot(userID)
|
||||
}
|
||||
if !acquireCustomRelaySlot(userID) {
|
||||
t.Fatal("released slot should be reusable")
|
||||
}
|
||||
releaseCustomRelaySlot(userID)
|
||||
}
|
||||
|
||||
func useCustomRelayTestClient(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
previous := customRelayClient
|
||||
customRelayClient = func(time.Duration) *http.Client { return client }
|
||||
t.Cleanup(func() { customRelayClient = previous })
|
||||
}
|
||||
|
||||
func defaultCustomRelayTestPolicy() service.RuntimeRequestPolicy {
|
||||
return service.RuntimeRequestPolicy{
|
||||
CustomRelayRequestMB: 32, CustomRelayResponseMB: 32, CustomRelayTimeoutMinutes: 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ func RegisterTaskRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "tasks:"+user.ID, 30, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "tasks:"+user.ID, policy.Request.TaskCreatePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
@@ -112,7 +113,8 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "sessions:"+user.ID, 20, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "sessions:"+user.ID, policy.Request.SessionCreatePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
@@ -147,10 +149,11 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "session-files:"+user.ID, 30, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "session-files:"+user.ID, policy.Request.SessionFilePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.MaxSessionUploadBytes+(1<<20))
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, (policy.Resource.SessionUploadMB<<20)+(1<<20))
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
|
||||
@@ -20,8 +20,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxSystemProxyBodyBytes int64 = 64 << 20
|
||||
|
||||
var (
|
||||
runtimeService *service.Service
|
||||
geminiGeneratePath = regexp.MustCompile(`^/models/([^/:]+):(generateContent|streamGenerateContent)$`)
|
||||
@@ -120,6 +118,15 @@ func enforceRateLimit(c *gin.Context, key string, limit int, window time.Duratio
|
||||
return false
|
||||
}
|
||||
|
||||
func loadRuntimePolicy(c *gin.Context, svc *service.Service) (service.RuntimePolicySetting, bool) {
|
||||
policy, err := svc.RuntimePolicy()
|
||||
if err != nil {
|
||||
fail(c, http.StatusServiceUnavailable, errors.New("读取运行时策略失败:"+err.Error()))
|
||||
return service.RuntimePolicySetting{}, false
|
||||
}
|
||||
return policy, true
|
||||
}
|
||||
|
||||
func authorizeSystemProxy(channel *model.ModelChannel, method string, requestPath string, contentType string, body []byte) error {
|
||||
requestPath, err := normalizedProxyPath(requestPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,10 +68,11 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "resources-upload:"+user.ID, 30, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "resources-upload:"+user.ID, policy.Request.ResourceUploadPerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.MaxResourceUploadBytes+(1<<20))
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, (policy.Resource.ResourceUploadMB<<20)+(1<<20))
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
@@ -93,7 +94,8 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "resources-import:"+user.ID, 30, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "resources-import:"+user.ID, policy.Request.ResourceImportPerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
@@ -247,7 +249,8 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "assets-write:"+user.ID, 120, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "assets-write:"+user.ID, policy.Request.AssetWritePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20)
|
||||
@@ -316,7 +319,8 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "canvas-write:"+user.ID, 120, time.Minute) {
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "canvas-write:"+user.ID, policy.Request.CanvasWritePerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20)
|
||||
|
||||
@@ -144,9 +144,9 @@ func (r *Repository) CreditLedgerReferenceExists(referenceKey string) (bool, err
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder) error {
|
||||
func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := enforceActiveTaskLimit(tx, task.UserID); err != nil {
|
||||
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reserveBillingOrder(tx, order); err != nil {
|
||||
@@ -156,19 +156,19 @@ func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *mo
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) CreateTaskWithActiveLimit(task *model.Task) error {
|
||||
func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := enforceActiveTaskLimit(tx, task.UserID); err != nil {
|
||||
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(task).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder) (*model.Task, error) {
|
||||
func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder, activeTaskLimit int) (*model.Task, error) {
|
||||
var task model.Task
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := enforceActiveTaskLimit(tx, userID); err != nil {
|
||||
if err := enforceActiveTaskLimit(tx, userID, activeTaskLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
if order != nil {
|
||||
@@ -197,12 +197,12 @@ func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *m
|
||||
return &task, err
|
||||
}
|
||||
|
||||
func enforceActiveTaskLimit(tx *gorm.DB, userID string) error {
|
||||
func enforceActiveTaskLimit(tx *gorm.DB, userID string, activeTaskLimit int) error {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Task{}).Where("user_id = ? AND status IN ?", userID, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= 5 {
|
||||
if count >= int64(activeTaskLimit) {
|
||||
return ErrActiveTaskLimit
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -18,15 +18,15 @@ type Repository struct {
|
||||
}
|
||||
|
||||
type UserStorageUsage struct {
|
||||
AssetCount int64
|
||||
AssetBytes int64
|
||||
CanvasCount int64
|
||||
CanvasBytes int64
|
||||
SessionCount int64
|
||||
SessionBytes int64
|
||||
TaskCount int64
|
||||
TaskBytes int64
|
||||
APICallCount int64
|
||||
AssetCount int64 `json:"assetCount"`
|
||||
AssetBytes int64 `json:"assetBytes"`
|
||||
CanvasCount int64 `json:"canvasCount"`
|
||||
CanvasBytes int64 `json:"canvasBytes"`
|
||||
SessionCount int64 `json:"sessionCount"`
|
||||
SessionBytes int64 `json:"sessionBytes"`
|
||||
TaskCount int64 `json:"taskCount"`
|
||||
TaskBytes int64 `json:"taskBytes"`
|
||||
APICallCount int64 `json:"apiCallCount"`
|
||||
}
|
||||
|
||||
func New(db *gorm.DB) *Repository {
|
||||
@@ -532,6 +532,10 @@ func (r *Repository) SaveSystemSetting(setting *model.SystemSetting) error {
|
||||
return r.db.Save(setting).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteSystemSetting(key string) error {
|
||||
return r.db.Delete(&model.SystemSetting{}, "key = ?", key).Error
|
||||
}
|
||||
|
||||
func (r *Repository) LatestUserOSSSetting(userID string) (*model.UserOSSSetting, error) {
|
||||
var setting model.UserOSSSetting
|
||||
if err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").First(&setting).Error; err != nil {
|
||||
|
||||
@@ -487,6 +487,10 @@ func (s *Service) LogAPICall(log model.ApiCallLog) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
usage, err := s.repo.UserStorageUsage(log.UserID)
|
||||
@@ -494,7 +498,7 @@ func (s *Service) LogAPICall(log model.ApiCallLog) error {
|
||||
return err
|
||||
}
|
||||
incomingBytes := int64(len(log.Path) + len(log.Model) + len(log.ProviderRequestID) + len(log.ErrorCode) + len(log.Error) + len(log.UpstreamURL))
|
||||
if err := validateAPICallLogQuota(usage, incomingBytes); err != nil {
|
||||
if err := validateAPICallLogQuotaWithPolicy(usage, incomingBytes, policy.Resource); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Create(&log)
|
||||
@@ -537,7 +541,7 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
|
||||
channel.ConcurrencyLimit = 0
|
||||
} else if req.ConcurrencyLimit != nil {
|
||||
if *req.ConcurrencyLimit < minChannelConcurrencyLimit || *req.ConcurrencyLimit > maxChannelConcurrencyLimit {
|
||||
return channel, BadAuthRequest("最大并发数必须是 1-100 的整数")
|
||||
return channel, BadAuthRequest("最大并发数必须是 1-999 的整数")
|
||||
}
|
||||
channel.ConcurrencyLimit = *req.ConcurrencyLimit
|
||||
} else if req.UseGlobalConcurrency != nil {
|
||||
|
||||
@@ -10,9 +10,13 @@ import (
|
||||
)
|
||||
|
||||
type AdminUserDetail struct {
|
||||
User model.User `json:"user"`
|
||||
Account model.CreditAccount `json:"account"`
|
||||
Counts repository.AdminUserCounts `json:"counts"`
|
||||
User model.User `json:"user"`
|
||||
Account model.CreditAccount `json:"account"`
|
||||
Counts repository.AdminUserCounts `json:"counts"`
|
||||
StorageUsage repository.UserStorageUsage `json:"storageUsage"`
|
||||
StoredFileBytes int64 `json:"storedFileBytes"`
|
||||
DailyUploadBytes int64 `json:"dailyUploadBytes"`
|
||||
Quota RuntimeResourcePolicy `json:"quota"`
|
||||
}
|
||||
|
||||
type AdminTaskPage struct {
|
||||
@@ -63,7 +67,26 @@ func (s *Service) AdminUserDetail(actor *model.User, userID string) (*AdminUserD
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminUserDetail{User: *user, Account: *account, Counts: counts}, nil
|
||||
usage, err := s.repo.UserStorageUsage(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storedFileBytes, err := s.repo.UserStoredFileBytes(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dailyUploadBytes, err := s.repo.DailyUploadBytes(user.ID, time.Now().UTC().Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminUserDetail{
|
||||
User: *user, Account: *account, Counts: counts, StorageUsage: usage,
|
||||
StoredFileBytes: storedFileBytes, DailyUploadBytes: dailyUploadBytes, Quota: policy.Resource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminUserLedger(actor *model.User, userID string, entryType string, page int, limit int) (*WalletSummary, error) {
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestChannelFromRequestRejectsUnknownInterfaceType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) {
|
||||
for _, limit := range []int{0, 101} {
|
||||
for _, limit := range []int{0, 1000} {
|
||||
_, err := channelFromRequest(ChannelRequest{Name: "Bad", BaseURL: "https://example.com/v1", InterfaceType: "newapi", ConcurrencyLimit: &limit}, model.ModelChannel{})
|
||||
if err == nil {
|
||||
t.Fatalf("channelFromRequest() concurrencyLimit = %d, error = nil", limit)
|
||||
@@ -87,7 +87,7 @@ func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) {
|
||||
func TestRuntimeConcurrencyUsesEnvironmentFallback(t *testing.T) {
|
||||
t.Setenv("CANVAS_CHANNEL_CONCURRENCY", "7")
|
||||
t.Setenv("CANVAS_WORKER_CONCURRENCY", "9")
|
||||
setting := runtimeConcurrencyFromEnvironment()
|
||||
setting := defaultRuntimePolicy().Task
|
||||
if setting.ChannelConcurrency != 7 || setting.WorkerConcurrency != 9 {
|
||||
t.Fatalf("runtimeConcurrencyFromEnvironment() = %#v", setting)
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const runtimeConcurrencySettingKey = "runtime_concurrency"
|
||||
|
||||
type RuntimeConcurrencySettingRequest struct {
|
||||
WorkerConcurrency int `json:"workerConcurrency"`
|
||||
ChannelConcurrency int `json:"channelConcurrency"`
|
||||
}
|
||||
|
||||
type PublicRuntimeConcurrencySetting struct {
|
||||
WorkerConcurrency int `json:"workerConcurrency"`
|
||||
ChannelConcurrency int `json:"channelConcurrency"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type runtimeConcurrencySettingValue struct {
|
||||
WorkerConcurrency int `json:"workerConcurrency"`
|
||||
ChannelConcurrency int `json:"channelConcurrency"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminRuntimeConcurrencySetting(actor *model.User) (*PublicRuntimeConcurrencySetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readRuntimeConcurrencySetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRuntimeConcurrencySetting(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateRuntimeConcurrencySetting(actor *model.User, req RuntimeConcurrencySettingRequest) (*PublicRuntimeConcurrencySetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := runtimeConcurrencySettingValue{WorkerConcurrency: req.WorkerConcurrency, ChannelConcurrency: req.ChannelConcurrency}
|
||||
if err := validateRuntimeConcurrencySetting(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, _, err := s.readRuntimeConcurrencySetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: runtimeConcurrencySettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if current != nil {
|
||||
setting.CreatedAt = current.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "runtime_concurrency.update", "system_setting", runtimeConcurrencySettingKey, "更新任务并发配置", value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRuntimeConcurrencySetting(&setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) runtimeConcurrencySetting() (runtimeConcurrencySettingValue, error) {
|
||||
_, value, err := s.readRuntimeConcurrencySetting()
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *Service) readRuntimeConcurrencySetting() (*model.SystemSetting, runtimeConcurrencySettingValue, error) {
|
||||
setting, err := s.repo.SystemSetting(runtimeConcurrencySettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, runtimeConcurrencyFromEnvironment(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, runtimeConcurrencySettingValue{}, err
|
||||
}
|
||||
value := runtimeConcurrencySettingValue{}
|
||||
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
|
||||
return nil, runtimeConcurrencySettingValue{}, errors.New("任务并发配置格式无效")
|
||||
}
|
||||
if err := validateRuntimeConcurrencySetting(value); err != nil {
|
||||
return nil, runtimeConcurrencySettingValue{}, err
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func runtimeConcurrencyFromEnvironment() runtimeConcurrencySettingValue {
|
||||
return runtimeConcurrencySettingValue{
|
||||
WorkerConcurrency: effectiveChannelConcurrencyLimit(envInt("CANVAS_WORKER_CONCURRENCY", taskWorkerConcurrency)),
|
||||
ChannelConcurrency: defaultChannelConcurrencyLimit(),
|
||||
}
|
||||
}
|
||||
|
||||
func validateRuntimeConcurrencySetting(value runtimeConcurrencySettingValue) error {
|
||||
if value.WorkerConcurrency < minChannelConcurrencyLimit || value.WorkerConcurrency > maxChannelConcurrencyLimit {
|
||||
return BadAuthRequest("Worker 并发数必须是 1-100 的整数")
|
||||
}
|
||||
if value.ChannelConcurrency < minChannelConcurrencyLimit || value.ChannelConcurrency > maxChannelConcurrencyLimit {
|
||||
return BadAuthRequest("全局渠道并发数必须是 1-100 的整数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func publicRuntimeConcurrencySetting(setting *model.SystemSetting, value runtimeConcurrencySettingValue) *PublicRuntimeConcurrencySetting {
|
||||
result := &PublicRuntimeConcurrencySetting{WorkerConcurrency: value.WorkerConcurrency, ChannelConcurrency: value.ChannelConcurrency}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -28,11 +28,14 @@ func TestRuntimeConcurrencySettingPersistsAndChannelOverrideWins(t *testing.T) {
|
||||
}
|
||||
svc := New(repository.New(db), t.TempDir())
|
||||
actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
|
||||
setting, err := svc.UpdateRuntimeConcurrencySetting(actor, RuntimeConcurrencySettingRequest{WorkerConcurrency: 8, ChannelConcurrency: 6})
|
||||
policy := defaultRuntimePolicy()
|
||||
policy.Task.WorkerConcurrency = 8
|
||||
policy.Task.ChannelConcurrency = 6
|
||||
setting, err := svc.UpdateRuntimePolicySetting(actor, policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if setting.WorkerConcurrency != 8 || setting.ChannelConcurrency != 6 {
|
||||
if setting.Task.WorkerConcurrency != 8 || setting.Task.ChannelConcurrency != 6 {
|
||||
t.Fatalf("setting = %#v", setting)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type localRateEntry struct {
|
||||
|
||||
const (
|
||||
minChannelConcurrencyLimit = 1
|
||||
maxChannelConcurrencyLimit = 100
|
||||
maxChannelConcurrencyLimit = maxRuntimeConcurrency
|
||||
defaultChannelConcurrencyValue = 3
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ func (c *runtimeCoordinator) circuitOpen(ctx context.Context, channelID string)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (c *runtimeCoordinator) recordChannelResult(ctx context.Context, channelID string, failed bool) {
|
||||
func (c *runtimeCoordinator) recordChannelResult(ctx context.Context, channelID string, failed bool, failureLimit int, openDuration time.Duration) {
|
||||
if c.redis == nil || strings.TrimSpace(channelID) == "" {
|
||||
return
|
||||
}
|
||||
@@ -204,8 +204,8 @@ func (c *runtimeCoordinator) recordChannelResult(ctx context.Context, channelID
|
||||
return
|
||||
}
|
||||
_ = c.redis.Expire(ctx, failureKey, time.Minute).Err()
|
||||
if count >= int64(envInt("CANVAS_CHANNEL_CIRCUIT_FAILURES", 5)) {
|
||||
_ = c.redis.Set(ctx, openKey, "1", time.Duration(envInt("CANVAS_CHANNEL_CIRCUIT_SECONDS", 60))*time.Second).Err()
|
||||
if count >= int64(failureLimit) {
|
||||
_ = c.redis.Set(ctx, openKey, "1", openDuration).Err()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *Service) AcquireChannelSlot(ctx context.Context, channelID string, fall
|
||||
}
|
||||
if channel.ConcurrencyLimit > 0 {
|
||||
if channel.ConcurrencyLimit < minChannelConcurrencyLimit || channel.ConcurrencyLimit > maxChannelConcurrencyLimit {
|
||||
return nil, limit, channelSlotError{scope: scope, limit: limit, err: errors.New("渠道并发配置超出 1-100 范围")}
|
||||
return nil, limit, channelSlotError{scope: scope, limit: limit, err: errors.New("渠道并发配置超出 1-999 范围")}
|
||||
}
|
||||
limit = channel.ConcurrencyLimit
|
||||
}
|
||||
@@ -273,3 +273,22 @@ func (s *Service) AllowRequest(ctx context.Context, key string, limit int, windo
|
||||
}
|
||||
return s.coordinator.allow(ctx, key, limit, window)
|
||||
}
|
||||
|
||||
func (s *Service) AcquireCustomRelaySlot(ctx context.Context, userID string, limit int, ttl time.Duration) (func(), bool, error) {
|
||||
if s.coordinator == nil {
|
||||
return nil, false, errors.New("运行时协调器未初始化")
|
||||
}
|
||||
return s.coordinator.acquire(ctx, "custom-relay:"+userID, limit, ttl)
|
||||
}
|
||||
|
||||
func (s *Service) RecordChannelResult(ctx context.Context, channelID string, failed bool) error {
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.coordinator == nil {
|
||||
return errors.New("运行时协调器未初始化")
|
||||
}
|
||||
s.coordinator.recordChannelResult(ctx, channelID, failed, policy.Request.ChannelCircuitFailureCount, time.Duration(policy.Request.ChannelCircuitOpenSeconds)*time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,12 +243,17 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ
|
||||
return fmt.Errorf("读取任务参考资源失败:%w", err)
|
||||
}
|
||||
defer body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(body, maxTaskResourceBytes+1))
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(data)) > maxTaskResourceBytes {
|
||||
return errors.New("任务参考资源超过 512MB")
|
||||
resourceLimit := megabytes(policy.Resource.ResourceUploadMB)
|
||||
data, err := io.ReadAll(io.LimitReader(body, resourceLimit+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(data)) > resourceLimit {
|
||||
return fmt.Errorf("任务参考资源超过 %dMB", policy.Resource.ResourceUploadMB)
|
||||
}
|
||||
mimeType := normalizedMediaMimeType(firstNonEmpty(media.MimeType, resource.MimeType), data)
|
||||
media.DataURL = dataURL(mimeType, data)
|
||||
@@ -630,7 +635,7 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
if id == "" {
|
||||
return nil, errors.New("视频接口没有返回任务 ID")
|
||||
}
|
||||
for deadline := time.Now().Add(videoPollTimeout); time.Now().Before(deadline); {
|
||||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||||
var state map[string]interface{}
|
||||
if err := getJSON(ctx, input.Config, "/videos/"+id, &state); err != nil {
|
||||
return nil, err
|
||||
@@ -871,7 +876,7 @@ func runNewAPIChannel1VideoTask(ctx context.Context, input canvasGenerationInput
|
||||
if id == "" {
|
||||
return nil, errors.New("NewAPI 渠道 1 没有返回任务 ID")
|
||||
}
|
||||
for deadline := time.Now().Add(videoPollTimeout); time.Now().Before(deadline); {
|
||||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||||
var state map[string]interface{}
|
||||
if err := getJSON(ctx, input.Config, "/videos/"+id, &state); err != nil {
|
||||
return nil, err
|
||||
@@ -1076,7 +1081,7 @@ func runSeedanceVideosTask(ctx context.Context, input canvasGenerationInput) (ma
|
||||
if id == "" {
|
||||
return nil, errors.New("Seedance 接口没有返回任务 ID")
|
||||
}
|
||||
for deadline := time.Now().Add(videoPollTimeout); time.Now().Before(deadline); {
|
||||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||||
var state map[string]interface{}
|
||||
if err := getJSON(ctx, input.Config, "/videos/"+id, &state); err != nil {
|
||||
return nil, err
|
||||
@@ -1138,7 +1143,7 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn
|
||||
if id == "" {
|
||||
return nil, errors.New("Seedance 接口没有返回任务 ID")
|
||||
}
|
||||
for deadline := time.Now().Add(videoPollTimeout); time.Now().Before(deadline); {
|
||||
for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); {
|
||||
var state map[string]interface{}
|
||||
if err := getJSON(ctx, input.Config, "/contents/generations/tasks/"+id, &state); err != nil {
|
||||
return nil, err
|
||||
@@ -1259,12 +1264,26 @@ func doJSON(req *http.Request, target interface{}) error {
|
||||
|
||||
func doBinary(req *http.Request) ([]byte, string, error) {
|
||||
startedAt := time.Now()
|
||||
requestTimeout := providerHTTPTimeout
|
||||
if deadline, ok := req.Context().Deadline(); ok {
|
||||
if remaining := time.Until(deadline); remaining > 0 {
|
||||
requestTimeout = remaining
|
||||
}
|
||||
}
|
||||
var release func()
|
||||
var coordinator *runtimeCoordinator
|
||||
var runtimeService *Service
|
||||
responseLimit := maxProviderResponseBytes
|
||||
channelID := ""
|
||||
if metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok && metadata.Service != nil {
|
||||
runtimeService = metadata.Service
|
||||
coordinator = metadata.Service.coordinator
|
||||
channelID = metadata.ChannelID
|
||||
policy, err := metadata.Service.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("读取生成资源限制失败:%w", err)
|
||||
}
|
||||
responseLimit = megabytes(policy.Resource.GeneratedFileMB)
|
||||
open, err := coordinator.circuitOpen(req.Context(), channelID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("读取渠道熔断状态失败:%w", err)
|
||||
@@ -1277,7 +1296,7 @@ func doBinary(req *http.Request) ([]byte, string, error) {
|
||||
slotID = "custom:" + strings.ToLower(req.URL.Host)
|
||||
}
|
||||
var concurrencyLimit int
|
||||
release, concurrencyLimit, err = metadata.Service.AcquireChannelSlot(req.Context(), channelID, slotID, providerHTTPTimeout+time.Minute)
|
||||
release, concurrencyLimit, err = metadata.Service.AcquireChannelSlot(req.Context(), channelID, slotID, requestTimeout+time.Minute)
|
||||
metadata.ConcurrencyLimit = concurrencyLimit
|
||||
req = req.WithContext(context.WithValue(req.Context(), providerAnalyticsKey{}, metadata))
|
||||
if err != nil {
|
||||
@@ -1290,47 +1309,54 @@ func doBinary(req *http.Request) ([]byte, string, error) {
|
||||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||||
return nil, "", err
|
||||
}
|
||||
client := OutboundHTTPClient(providerHTTPTimeout)
|
||||
client := OutboundHTTPClient(requestTimeout)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if coordinator != nil {
|
||||
coordinator.recordChannelResult(req.Context(), channelID, !errors.Is(err, context.Canceled))
|
||||
if runtimeService != nil {
|
||||
_ = runtimeService.RecordChannelResult(req.Context(), channelID, !errors.Is(err, context.Canceled))
|
||||
}
|
||||
recordProviderRequest(req, startedAt, 0, nil, err)
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.ContentLength > maxProviderResponseBytes {
|
||||
err = errors.New("上游响应超过 64MB 限制")
|
||||
if resp.ContentLength > responseLimit {
|
||||
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
|
||||
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
|
||||
return nil, "", err
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderResponseBytes+1))
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, responseLimit+1))
|
||||
if err != nil {
|
||||
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
|
||||
return nil, "", err
|
||||
}
|
||||
if int64(len(data)) > maxProviderResponseBytes {
|
||||
err = errors.New("上游响应超过 64MB 限制")
|
||||
if int64(len(data)) > responseLimit {
|
||||
err = fmt.Errorf("上游响应超过 %s 限制", formatStorageLimit(responseLimit))
|
||||
recordProviderRequest(req, startedAt, resp.StatusCode, nil, err)
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := resp.Header.Get("Content-Type")
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if coordinator != nil {
|
||||
coordinator.recordChannelResult(req.Context(), channelID, resp.StatusCode >= 500)
|
||||
if runtimeService != nil {
|
||||
_ = runtimeService.RecordChannelResult(req.Context(), channelID, resp.StatusCode >= 500)
|
||||
}
|
||||
httpErr := providerHTTPError{StatusCode: resp.StatusCode, Status: resp.Status, Body: string(data)}
|
||||
recordProviderRequest(req, startedAt, resp.StatusCode, data, httpErr)
|
||||
return nil, "", httpErr
|
||||
}
|
||||
recordProviderRequest(req, startedAt, resp.StatusCode, data, nil)
|
||||
if coordinator != nil {
|
||||
coordinator.recordChannelResult(req.Context(), channelID, false)
|
||||
if runtimeService != nil {
|
||||
_ = runtimeService.RecordChannelResult(req.Context(), channelID, false)
|
||||
}
|
||||
return data, mimeType, nil
|
||||
}
|
||||
|
||||
func providerPollingDeadline(ctx context.Context) time.Time {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
return deadline
|
||||
}
|
||||
return time.Now().Add(videoPollTimeout)
|
||||
}
|
||||
|
||||
func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode int, responseBody []byte, requestErr error) {
|
||||
metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||||
if !ok || metadata.Service == nil {
|
||||
|
||||
@@ -27,8 +27,6 @@ import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
const maxImportedResourceBytes int64 = MaxResourceUploadBytes
|
||||
const maxTaskResourceBytes int64 = 512 << 20
|
||||
const providerResourceURLTTL = 4 * time.Hour
|
||||
const directResourceURLTTL = 5 * time.Minute
|
||||
|
||||
@@ -125,7 +123,11 @@ func detectUploadedMimeType(file multipart.File, fileName string, declared strin
|
||||
}
|
||||
|
||||
func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, width int, height int, durationMs int64) (*model.Resource, error) {
|
||||
payload, err := downloadRemoteResource(rawURL)
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := downloadRemoteResource(rawURL, megabytes(policy.Resource.ResourceUploadMB))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -294,7 +296,7 @@ func (s *Service) persistGeneratedMediaValueMode(userID string, value interface{
|
||||
return item, nil
|
||||
case map[string]interface{}:
|
||||
if raw := inlineMediaValue(item); raw != "" {
|
||||
mimeType, data, err := decodeDataURL(raw)
|
||||
mimeType, data, err := s.decodeDataURL(raw)
|
||||
if err != nil && !skipInvalidDataURL {
|
||||
return nil, err
|
||||
}
|
||||
@@ -361,7 +363,7 @@ func inlineMediaValue(item map[string]interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeDataURL(value string) (string, []byte, error) {
|
||||
func (s *Service) decodeDataURL(value string) (string, []byte, error) {
|
||||
header, encoded, ok := strings.Cut(value, ",")
|
||||
if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(strings.ToLower(header), ";base64") {
|
||||
return "", nil, fmt.Errorf("%w:格式无效", errInvalidGeneratedDataURL)
|
||||
@@ -371,8 +373,12 @@ func decodeDataURL(value string) (string, []byte, error) {
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w:base64 解码失败:%v", errInvalidGeneratedDataURL, err)
|
||||
}
|
||||
if int64(len(data)) > maxTaskResourceBytes {
|
||||
return "", nil, errors.New("单个生成资源超过 512MB")
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if int64(len(data)) > megabytes(policy.Resource.GeneratedFileMB) {
|
||||
return "", nil, fmt.Errorf("单个生成资源超过 %dMB", policy.Resource.GeneratedFileMB)
|
||||
}
|
||||
return mimeType, data, nil
|
||||
}
|
||||
@@ -398,7 +404,7 @@ type remoteResourcePayload struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func downloadRemoteResource(rawURL string) (remoteResourcePayload, error) {
|
||||
func downloadRemoteResource(rawURL string, maxBytes int64) (remoteResourcePayload, error) {
|
||||
parsed, err := validateRemoteResourceURL(rawURL)
|
||||
if err != nil {
|
||||
return remoteResourcePayload{}, err
|
||||
@@ -416,12 +422,12 @@ func downloadRemoteResource(rawURL string) (remoteResourcePayload, error) {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return remoteResourcePayload{}, fmt.Errorf("远程资源下载失败:%s", resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxImportedResourceBytes))
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes))
|
||||
if err != nil {
|
||||
return remoteResourcePayload{}, err
|
||||
}
|
||||
if int64(len(data)) >= maxImportedResourceBytes {
|
||||
return remoteResourcePayload{}, BadAuthRequest("远程资源必须小于 50MB")
|
||||
if int64(len(data)) >= maxBytes {
|
||||
return remoteResourcePayload{}, BadAuthRequest(fmt.Sprintf("远程资源必须小于 %s", formatStorageLimit(maxBytes)))
|
||||
}
|
||||
mimeType := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||
if idx := strings.Index(mimeType, ";"); idx >= 0 {
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestPersistGeneratedMediaAppliesStoredFileQuota(t *testing.T) {
|
||||
ID: "existing",
|
||||
UserID: "user-1",
|
||||
Status: model.ResourceStatusReady,
|
||||
Size: MaxUserStoredFileBytes - 1,
|
||||
Size: gigabytes(defaultRuntimePolicy().Resource.StoredFileGB) - 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
380
backend/internal/service/runtime_policy.go
Normal file
380
backend/internal/service/runtime_policy.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const runtimePolicySettingKey = "runtime_policy"
|
||||
|
||||
const (
|
||||
maxRuntimeUploadMB int64 = 999
|
||||
maxRuntimeStorageGB int64 = 999
|
||||
maxRuntimeDataMB int64 = 999_999
|
||||
maxRuntimeCount int64 = 999_999_999
|
||||
maxRuntimeRate = 999_999
|
||||
maxRuntimeConcurrency = 999
|
||||
maxRuntimeTimeoutMinutes = 9_999
|
||||
)
|
||||
|
||||
type RuntimeResourcePolicy struct {
|
||||
ResourceUploadMB int64 `json:"resourceUploadMB"`
|
||||
SessionUploadMB int64 `json:"sessionUploadMB"`
|
||||
GeneratedFileMB int64 `json:"generatedFileMB"`
|
||||
DailyUploadMB int64 `json:"dailyUploadMB"`
|
||||
StoredFileGB int64 `json:"storedFileGB"`
|
||||
StructuredDataMB int64 `json:"structuredDataMB"`
|
||||
TaskDataGB int64 `json:"taskDataGB"`
|
||||
AssetCount int64 `json:"assetCount"`
|
||||
CanvasCount int64 `json:"canvasCount"`
|
||||
SessionCount int64 `json:"sessionCount"`
|
||||
TaskCount int64 `json:"taskCount"`
|
||||
APICallLogCount int64 `json:"apiCallLogCount"`
|
||||
}
|
||||
|
||||
type RuntimeTaskPolicy struct {
|
||||
WorkerConcurrency int `json:"workerConcurrency"`
|
||||
ChannelConcurrency int `json:"channelConcurrency"`
|
||||
ActiveTaskLimit int `json:"activeTaskLimit"`
|
||||
ImageTimeoutMinutes int `json:"imageTimeoutMinutes"`
|
||||
TextTimeoutMinutes int `json:"textTimeoutMinutes"`
|
||||
AudioTimeoutMinutes int `json:"audioTimeoutMinutes"`
|
||||
VideoTimeoutMinutes int `json:"videoTimeoutMinutes"`
|
||||
StoryboardTimeoutMinutes int `json:"storyboardTimeoutMinutes"`
|
||||
DefaultTimeoutMinutes int `json:"defaultTimeoutMinutes"`
|
||||
}
|
||||
|
||||
type RuntimeRequestPolicy struct {
|
||||
TaskCreatePerMinute int `json:"taskCreatePerMinute"`
|
||||
SessionCreatePerMinute int `json:"sessionCreatePerMinute"`
|
||||
ResourceUploadPerMinute int `json:"resourceUploadPerMinute"`
|
||||
ResourceImportPerMinute int `json:"resourceImportPerMinute"`
|
||||
SessionFilePerMinute int `json:"sessionFilePerMinute"`
|
||||
AssetWritePerMinute int `json:"assetWritePerMinute"`
|
||||
CanvasWritePerMinute int `json:"canvasWritePerMinute"`
|
||||
RegisterPerHour int `json:"registerPerHour"`
|
||||
EmailCodePerHour int `json:"emailCodePerHour"`
|
||||
LoginIPPerTenMinutes int `json:"loginIPPerTenMinutes"`
|
||||
LoginAccountPerTenMinutes int `json:"loginAccountPerTenMinutes"`
|
||||
SystemRelayPerMinute int `json:"systemRelayPerMinute"`
|
||||
CustomRelayPerMinute int `json:"customRelayPerMinute"`
|
||||
CustomRelayConcurrency int `json:"customRelayConcurrency"`
|
||||
CustomRelayRequestMB int64 `json:"customRelayRequestMB"`
|
||||
CustomRelayResponseMB int64 `json:"customRelayResponseMB"`
|
||||
CustomRelayTimeoutMinutes int `json:"customRelayTimeoutMinutes"`
|
||||
SystemRelayRequestMB int64 `json:"systemRelayRequestMB"`
|
||||
SystemRelayResponseMB int64 `json:"systemRelayResponseMB"`
|
||||
ChannelCircuitFailureCount int `json:"channelCircuitFailureCount"`
|
||||
ChannelCircuitOpenSeconds int `json:"channelCircuitOpenSeconds"`
|
||||
}
|
||||
|
||||
type RuntimePolicySetting struct {
|
||||
Resource RuntimeResourcePolicy `json:"resource"`
|
||||
Task RuntimeTaskPolicy `json:"task"`
|
||||
Request RuntimeRequestPolicy `json:"request"`
|
||||
}
|
||||
|
||||
type PublicRuntimePolicySetting struct {
|
||||
RuntimePolicySetting
|
||||
Configured bool `json:"configured"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PublicRuntimeLimits struct {
|
||||
ActiveTaskLimit int `json:"activeTaskLimit"`
|
||||
ResourceUploadMB int64 `json:"resourceUploadMB"`
|
||||
SessionUploadMB int64 `json:"sessionUploadMB"`
|
||||
}
|
||||
|
||||
func defaultRuntimePolicy() RuntimePolicySetting {
|
||||
return RuntimePolicySetting{
|
||||
Resource: RuntimeResourcePolicy{
|
||||
ResourceUploadMB: 50,
|
||||
SessionUploadMB: 32,
|
||||
GeneratedFileMB: 64,
|
||||
DailyUploadMB: 200,
|
||||
StoredFileGB: 2,
|
||||
StructuredDataMB: 256,
|
||||
TaskDataGB: 1,
|
||||
AssetCount: 2_000,
|
||||
CanvasCount: 1_000,
|
||||
SessionCount: 1_000,
|
||||
TaskCount: 20_000,
|
||||
APICallLogCount: 100_000,
|
||||
},
|
||||
Task: RuntimeTaskPolicy{
|
||||
WorkerConcurrency: effectiveChannelConcurrencyLimit(envInt("CANVAS_WORKER_CONCURRENCY", taskWorkerConcurrency)),
|
||||
ChannelConcurrency: defaultChannelConcurrencyLimit(),
|
||||
ActiveTaskLimit: 5,
|
||||
ImageTimeoutMinutes: 8,
|
||||
TextTimeoutMinutes: 8,
|
||||
AudioTimeoutMinutes: 8,
|
||||
VideoTimeoutMinutes: 30,
|
||||
StoryboardTimeoutMinutes: 12,
|
||||
DefaultTimeoutMinutes: 10,
|
||||
},
|
||||
Request: RuntimeRequestPolicy{
|
||||
TaskCreatePerMinute: 30,
|
||||
SessionCreatePerMinute: 20,
|
||||
ResourceUploadPerMinute: 30,
|
||||
ResourceImportPerMinute: 30,
|
||||
SessionFilePerMinute: 30,
|
||||
AssetWritePerMinute: 120,
|
||||
CanvasWritePerMinute: 120,
|
||||
RegisterPerHour: 5,
|
||||
EmailCodePerHour: 10,
|
||||
LoginIPPerTenMinutes: 50,
|
||||
LoginAccountPerTenMinutes: 10,
|
||||
SystemRelayPerMinute: 120,
|
||||
CustomRelayPerMinute: 120,
|
||||
CustomRelayConcurrency: 4,
|
||||
CustomRelayRequestMB: 32,
|
||||
CustomRelayResponseMB: 32,
|
||||
CustomRelayTimeoutMinutes: 10,
|
||||
SystemRelayRequestMB: 64,
|
||||
SystemRelayResponseMB: 128,
|
||||
ChannelCircuitFailureCount: min(envInt("CANVAS_CHANNEL_CIRCUIT_FAILURES", 5), maxRuntimeConcurrency),
|
||||
ChannelCircuitOpenSeconds: min(envInt("CANVAS_CHANNEL_CIRCUIT_SECONDS", 60), 86_400),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func selfUseRuntimePolicy() RuntimePolicySetting {
|
||||
value := defaultRuntimePolicy()
|
||||
value.Resource = RuntimeResourcePolicy{
|
||||
ResourceUploadMB: maxRuntimeUploadMB, SessionUploadMB: maxRuntimeUploadMB, GeneratedFileMB: maxRuntimeUploadMB,
|
||||
DailyUploadMB: maxRuntimeDataMB, StoredFileGB: maxRuntimeStorageGB, StructuredDataMB: maxRuntimeDataMB,
|
||||
TaskDataGB: maxRuntimeStorageGB, AssetCount: maxRuntimeCount, CanvasCount: maxRuntimeCount,
|
||||
SessionCount: maxRuntimeCount, TaskCount: maxRuntimeCount, APICallLogCount: maxRuntimeCount,
|
||||
}
|
||||
value.Task = RuntimeTaskPolicy{
|
||||
WorkerConcurrency: maxRuntimeConcurrency, ChannelConcurrency: maxRuntimeConcurrency, ActiveTaskLimit: maxRuntimeConcurrency,
|
||||
ImageTimeoutMinutes: maxRuntimeTimeoutMinutes, TextTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||
AudioTimeoutMinutes: maxRuntimeTimeoutMinutes, VideoTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||
StoryboardTimeoutMinutes: maxRuntimeTimeoutMinutes, DefaultTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||
}
|
||||
value.Request = RuntimeRequestPolicy{
|
||||
TaskCreatePerMinute: maxRuntimeRate, SessionCreatePerMinute: maxRuntimeRate,
|
||||
ResourceUploadPerMinute: maxRuntimeRate, ResourceImportPerMinute: maxRuntimeRate,
|
||||
SessionFilePerMinute: maxRuntimeRate, AssetWritePerMinute: maxRuntimeRate, CanvasWritePerMinute: maxRuntimeRate,
|
||||
RegisterPerHour: maxRuntimeRate, EmailCodePerHour: maxRuntimeRate,
|
||||
LoginIPPerTenMinutes: maxRuntimeRate, LoginAccountPerTenMinutes: maxRuntimeRate,
|
||||
SystemRelayPerMinute: maxRuntimeRate, CustomRelayPerMinute: maxRuntimeRate,
|
||||
CustomRelayConcurrency: maxRuntimeConcurrency, CustomRelayRequestMB: maxRuntimeUploadMB,
|
||||
CustomRelayResponseMB: maxRuntimeUploadMB, CustomRelayTimeoutMinutes: maxRuntimeTimeoutMinutes,
|
||||
SystemRelayRequestMB: maxRuntimeUploadMB, SystemRelayResponseMB: maxRuntimeUploadMB,
|
||||
ChannelCircuitFailureCount: maxRuntimeConcurrency, ChannelCircuitOpenSeconds: 1,
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Service) RuntimePolicy() (RuntimePolicySetting, error) {
|
||||
_, value, err := s.readRuntimePolicy()
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *Service) runtimeConcurrencySetting() (RuntimeTaskPolicy, error) {
|
||||
policy, err := s.RuntimePolicy()
|
||||
return policy.Task, err
|
||||
}
|
||||
|
||||
func (s *Service) PublicRuntimeLimits() (*PublicRuntimeLimits, error) {
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PublicRuntimeLimits{
|
||||
ActiveTaskLimit: policy.Task.ActiveTaskLimit, ResourceUploadMB: policy.Resource.ResourceUploadMB,
|
||||
SessionUploadMB: policy.Resource.SessionUploadMB,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminRuntimePolicySetting(actor *model.User) (*PublicRuntimePolicySetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readRuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRuntimePolicy(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminSelfUseRuntimePolicy(actor *model.User) (*PublicRuntimePolicySetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRuntimePolicy(nil, selfUseRuntimePolicy()), nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateRuntimePolicySetting(actor *model.User, value RuntimePolicySetting) (*PublicRuntimePolicySetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRuntimePolicy(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, before, err := s.readRuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: runtimePolicySettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if current != nil {
|
||||
setting.CreatedAt = current.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "runtime_policy.update", "system_setting", runtimePolicySettingKey, "更新资源与请求策略", map[string]any{"before": before, "after": value}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRuntimePolicy(&setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetRuntimePolicySetting(actor *model.User) (*PublicRuntimePolicySetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, before, err := s.readRuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.DeleteSystemSetting(runtimePolicySettingKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after := defaultRuntimePolicy()
|
||||
if err := s.appendAdminAudit(actor, "runtime_policy.reset", "system_setting", runtimePolicySettingKey, "重置资源与请求策略", map[string]any{"before": before, "after": after}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicRuntimePolicy(nil, after), nil
|
||||
}
|
||||
|
||||
func (s *Service) readRuntimePolicy() (*model.SystemSetting, RuntimePolicySetting, error) {
|
||||
setting, err := s.repo.SystemSetting(runtimePolicySettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
value := defaultRuntimePolicy()
|
||||
return nil, value, validateRuntimePolicy(value)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, RuntimePolicySetting{}, err
|
||||
}
|
||||
value := RuntimePolicySetting{}
|
||||
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
|
||||
return nil, RuntimePolicySetting{}, errors.New("资源与请求策略配置格式无效")
|
||||
}
|
||||
if err := validateRuntimePolicy(value); err != nil {
|
||||
return nil, RuntimePolicySetting{}, err
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func validateRuntimePolicy(value RuntimePolicySetting) error {
|
||||
resource := value.Resource
|
||||
for label, item := range map[string]int64{
|
||||
"普通资源单文件": resource.ResourceUploadMB, "Agent 会话附件": resource.SessionUploadMB,
|
||||
"单个生成资源": resource.GeneratedFileMB,
|
||||
} {
|
||||
if item < 1 || item > maxRuntimeUploadMB {
|
||||
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d MB 的整数", label, maxRuntimeUploadMB))
|
||||
}
|
||||
}
|
||||
if resource.DailyUploadMB < 1 || resource.DailyUploadMB > maxRuntimeDataMB {
|
||||
return BadAuthRequest(fmt.Sprintf("每日上传量必须是 1-%d MB 的整数", maxRuntimeDataMB))
|
||||
}
|
||||
if resource.StoredFileGB < 1 || resource.StoredFileGB > maxRuntimeStorageGB || resource.TaskDataGB < 1 || resource.TaskDataGB > maxRuntimeStorageGB {
|
||||
return BadAuthRequest(fmt.Sprintf("账号文件与任务数据容量必须是 1-%d GB 的整数", maxRuntimeStorageGB))
|
||||
}
|
||||
if resource.StructuredDataMB < 1 || resource.StructuredDataMB > maxRuntimeDataMB {
|
||||
return BadAuthRequest(fmt.Sprintf("结构化数据容量必须是 1-%d MB 的整数", maxRuntimeDataMB))
|
||||
}
|
||||
storedMB := resource.StoredFileGB * 1024
|
||||
if resource.ResourceUploadMB > storedMB || resource.SessionUploadMB > storedMB || resource.GeneratedFileMB > storedMB {
|
||||
return BadAuthRequest("单文件上限不能大于账号文件总容量")
|
||||
}
|
||||
for label, item := range map[string]int64{
|
||||
"素材数量": resource.AssetCount, "画布数量": resource.CanvasCount, "Agent 会话数量": resource.SessionCount,
|
||||
"任务历史数量": resource.TaskCount, "请求日志数量": resource.APICallLogCount,
|
||||
} {
|
||||
if item < 1 || item > maxRuntimeCount {
|
||||
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 的整数", label, maxRuntimeCount))
|
||||
}
|
||||
}
|
||||
task := value.Task
|
||||
for label, item := range map[string]int{
|
||||
"Worker 并发数": task.WorkerConcurrency, "全局渠道并发数": task.ChannelConcurrency, "活动任务上限": task.ActiveTaskLimit,
|
||||
} {
|
||||
if item < 1 || item > maxRuntimeConcurrency {
|
||||
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 的整数", label, maxRuntimeConcurrency))
|
||||
}
|
||||
}
|
||||
for label, item := range map[string]int{
|
||||
"图片任务超时": task.ImageTimeoutMinutes, "文本任务超时": task.TextTimeoutMinutes,
|
||||
"音频任务超时": task.AudioTimeoutMinutes, "视频任务超时": task.VideoTimeoutMinutes,
|
||||
"分镜任务超时": task.StoryboardTimeoutMinutes, "默认任务超时": task.DefaultTimeoutMinutes,
|
||||
} {
|
||||
if item < 1 || item > maxRuntimeTimeoutMinutes {
|
||||
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 分钟的整数", label, maxRuntimeTimeoutMinutes))
|
||||
}
|
||||
}
|
||||
request := value.Request
|
||||
for label, item := range map[string]int{
|
||||
"任务创建频控": request.TaskCreatePerMinute, "会话创建频控": request.SessionCreatePerMinute,
|
||||
"资源上传频控": request.ResourceUploadPerMinute, "资源导入频控": request.ResourceImportPerMinute,
|
||||
"会话附件频控": request.SessionFilePerMinute, "素材写入频控": request.AssetWritePerMinute,
|
||||
"画布写入频控": request.CanvasWritePerMinute, "注册频控": request.RegisterPerHour,
|
||||
"验证码频控": request.EmailCodePerHour, "登录 IP 频控": request.LoginIPPerTenMinutes,
|
||||
"登录账号频控": request.LoginAccountPerTenMinutes, "系统渠道频控": request.SystemRelayPerMinute,
|
||||
"自定义渠道频控": request.CustomRelayPerMinute,
|
||||
} {
|
||||
if item < 1 || item > maxRuntimeRate {
|
||||
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 的整数", label, maxRuntimeRate))
|
||||
}
|
||||
}
|
||||
if request.CustomRelayConcurrency < 1 || request.CustomRelayConcurrency > maxRuntimeConcurrency {
|
||||
return BadAuthRequest(fmt.Sprintf("自定义渠道并发必须是 1-%d 的整数", maxRuntimeConcurrency))
|
||||
}
|
||||
for label, item := range map[string]int64{
|
||||
"自定义渠道请求体": request.CustomRelayRequestMB, "自定义渠道响应体": request.CustomRelayResponseMB,
|
||||
"系统渠道请求体": request.SystemRelayRequestMB, "系统渠道响应体": request.SystemRelayResponseMB,
|
||||
} {
|
||||
if item < 1 || item > maxRuntimeUploadMB {
|
||||
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d MB 的整数", label, maxRuntimeUploadMB))
|
||||
}
|
||||
}
|
||||
if request.CustomRelayTimeoutMinutes < 1 || request.CustomRelayTimeoutMinutes > maxRuntimeTimeoutMinutes {
|
||||
return BadAuthRequest(fmt.Sprintf("自定义渠道超时必须是 1-%d 分钟的整数", maxRuntimeTimeoutMinutes))
|
||||
}
|
||||
if request.ChannelCircuitFailureCount < 1 || request.ChannelCircuitFailureCount > maxRuntimeConcurrency {
|
||||
return BadAuthRequest(fmt.Sprintf("渠道熔断失败次数必须是 1-%d 的整数", maxRuntimeConcurrency))
|
||||
}
|
||||
if request.ChannelCircuitOpenSeconds < 1 || request.ChannelCircuitOpenSeconds > 86_400 {
|
||||
return BadAuthRequest("渠道熔断时长必须是 1-86400 秒的整数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func publicRuntimePolicy(setting *model.SystemSetting, value RuntimePolicySetting) *PublicRuntimePolicySetting {
|
||||
result := &PublicRuntimePolicySetting{RuntimePolicySetting: value, Configured: setting != nil}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func megabytes(value int64) int64 { return value << 20 }
|
||||
func gigabytes(value int64) int64 { return value << 30 }
|
||||
65
backend/internal/service/runtime_policy_test.go
Normal file
65
backend/internal/service/runtime_policy_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRuntimePolicyDefaultsAndSelfUseModeValidate(t *testing.T) {
|
||||
if err := validateRuntimePolicy(defaultRuntimePolicy()); err != nil {
|
||||
t.Fatalf("default runtime policy error = %v", err)
|
||||
}
|
||||
selfUse := selfUseRuntimePolicy()
|
||||
if err := validateRuntimePolicy(selfUse); err != nil {
|
||||
t.Fatalf("self-use runtime policy error = %v", err)
|
||||
}
|
||||
if selfUse.Task.WorkerConcurrency != 999 || selfUse.Resource.ResourceUploadMB != 999 {
|
||||
t.Fatalf("self-use maxima = worker %d, upload %d", selfUse.Task.WorkerConcurrency, selfUse.Resource.ResourceUploadMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePolicyRejectsSingleFileAboveAccountCapacity(t *testing.T) {
|
||||
policy := defaultRuntimePolicy()
|
||||
policy.Resource.StoredFileGB = 1
|
||||
policy.Resource.ResourceUploadMB = 999
|
||||
if err := validateRuntimePolicy(policy); err != nil {
|
||||
t.Fatalf("999MB should fit in 1GB: %v", err)
|
||||
}
|
||||
policy.Resource.StoredFileGB = 0
|
||||
if err := validateRuntimePolicy(policy); err == nil {
|
||||
t.Fatal("zero account capacity should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimePolicySaveAndResetTakeEffectImmediately(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.AdminAuditEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(repository.New(db), t.TempDir())
|
||||
actor := &model.User{ID: "admin", Role: model.UserRoleAdmin}
|
||||
policy := defaultRuntimePolicy()
|
||||
policy.Task.ActiveTaskLimit = 17
|
||||
if _, err := svc.UpdateRuntimePolicySetting(actor, policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err := svc.RuntimePolicy()
|
||||
if err != nil || effective.Task.ActiveTaskLimit != 17 {
|
||||
t.Fatalf("effective active task limit = %d, error = %v", effective.Task.ActiveTaskLimit, err)
|
||||
}
|
||||
if _, err := svc.ResetRuntimePolicySetting(actor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err = svc.RuntimePolicy()
|
||||
if err != nil || effective.Task.ActiveTaskLimit != 5 {
|
||||
t.Fatalf("reset active task limit = %d, error = %v", effective.Task.ActiveTaskLimit, err)
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,6 @@ type Service struct {
|
||||
|
||||
const taskWorkerConcurrency = 3
|
||||
const taskLogPayloadLimit = 4000
|
||||
const MaxSessionUploadBytes int64 = 32 << 20
|
||||
const (
|
||||
imageTaskTimeout = 8 * time.Minute
|
||||
textTaskTimeout = 8 * time.Minute
|
||||
audioTaskTimeout = 8 * time.Minute
|
||||
videoTaskTimeout = 30 * time.Minute
|
||||
storyboardTaskTimeout = 12 * time.Minute
|
||||
defaultTaskTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
type CreateSessionRequest struct {
|
||||
ProjectID string `json:"projectId"`
|
||||
@@ -202,6 +193,10 @@ func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*Sessi
|
||||
compactedSnapshot := compactPersistedValue(req.CanvasSnapshot)
|
||||
snapshotJSON, _ := json.Marshal(compactedSnapshot)
|
||||
session := model.Session{ID: newID(), UserID: userID, ProjectID: req.ProjectID, Status: model.SessionStatusActive, Prompt: prompt, CanvasSnapshotJSON: string(snapshotJSON)}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
usage, err := s.repo.UserStorageUsage(userID)
|
||||
if err != nil {
|
||||
@@ -209,7 +204,7 @@ func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*Sessi
|
||||
return nil, err
|
||||
}
|
||||
incomingBytes := int64(len([]byte(prompt))*2 + len(snapshotJSON))
|
||||
if err := validateStructuredStorageQuota(usage, "session", true, incomingBytes); err != nil {
|
||||
if err := validateStructuredStorageQuotaWithPolicy(usage, "session", true, incomingBytes, policy.Resource); err != nil {
|
||||
s.storageMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
@@ -279,12 +274,16 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
|
||||
if containsInlineMediaDataURL(normalizedInput) {
|
||||
return nil, BadAuthRequest("任务输入不能包含内嵌媒体,请先上传到资源存储")
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activeTasks, err := s.repo.ActiveTaskCountForUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activeTasks >= 5 {
|
||||
return nil, BadAuthRequest("同时排队或运行的任务最多 5 个,请等待已有任务完成")
|
||||
if activeTasks >= int64(policy.Task.ActiveTaskLimit) {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
|
||||
}
|
||||
taskType := req.Type
|
||||
if taskType == "" {
|
||||
@@ -303,9 +302,9 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
|
||||
if billingOrder != nil {
|
||||
task.BillingOrderID = billingOrder.ID
|
||||
}
|
||||
err = s.createTaskWithinStorageQuota(&task, billingOrder)
|
||||
err = s.createTaskWithinStorageQuota(&task, billingOrder, policy)
|
||||
if errors.Is(err, repository.ErrActiveTaskLimit) {
|
||||
return nil, BadAuthRequest("同时排队或运行的任务最多 5 个,请等待已有任务完成")
|
||||
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
|
||||
}
|
||||
if errors.Is(err, repository.ErrInsufficientCredits) {
|
||||
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
|
||||
@@ -407,12 +406,16 @@ func (s *Service) RetryTask(userID string, id string) (*model.Task, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, err = s.repo.RetryTaskWithBilling(userID, task.ID, billingOrder)
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, err = s.repo.RetryTaskWithBilling(userID, task.ID, billingOrder, policy.Task.ActiveTaskLimit)
|
||||
if errors.Is(err, repository.ErrInsufficientCredits) {
|
||||
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
|
||||
}
|
||||
if errors.Is(err, repository.ErrActiveTaskLimit) {
|
||||
return nil, BadAuthRequest("同时排队或运行的任务最多 5 个,请等待已有任务完成")
|
||||
return nil, BadAuthRequest(fmt.Sprintf("同时排队或运行的任务最多 %d 个,请等待已有任务完成", policy.Task.ActiveTaskLimit))
|
||||
}
|
||||
if errors.Is(err, repository.ErrTaskNotRetryable) {
|
||||
return nil, BadAuthRequest("任务已被其他请求重新入队,请勿重复重试")
|
||||
@@ -592,10 +595,15 @@ func publicTaskInputJSON(raw string) string {
|
||||
}
|
||||
|
||||
func (s *Service) StoreUpload(userID string, sessionID string, header *multipart.FileHeader) (*model.SessionFile, error) {
|
||||
if header == nil || header.Size > MaxSessionUploadBytes {
|
||||
return nil, BadAuthRequest("会话文件不能超过 32MB")
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
day, err := s.reserveUserUploadQuota(userID, header.Size)
|
||||
maxBytes := megabytes(policy.Resource.SessionUploadMB)
|
||||
if header == nil || header.Size > maxBytes {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB))
|
||||
}
|
||||
day, err := s.reserveSessionUploadQuota(userID, header.Size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -625,7 +633,7 @@ func (s *Service) StoreUpload(userID string, sessionID string, header *multipart
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := io.Copy(dst, io.LimitReader(file, MaxSessionUploadBytes+1))
|
||||
size, err := io.Copy(dst, io.LimitReader(file, maxBytes+1))
|
||||
closeErr := dst.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
@@ -635,9 +643,9 @@ func (s *Service) StoreUpload(userID string, sessionID string, header *multipart
|
||||
_ = os.Remove(path)
|
||||
return nil, closeErr
|
||||
}
|
||||
if size > MaxSessionUploadBytes {
|
||||
if size > maxBytes {
|
||||
_ = os.Remove(path)
|
||||
return nil, BadAuthRequest("会话文件不能超过 32MB")
|
||||
return nil, BadAuthRequest(fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB))
|
||||
}
|
||||
item := model.SessionFile{ID: newID(), UserID: userID, SessionID: sessionID, FileName: header.Filename, MimeType: header.Header.Get("Content-Type"), Path: path, Size: size}
|
||||
if err := s.repo.Create(&item); err != nil {
|
||||
@@ -659,7 +667,11 @@ func (s *Service) ProcessNextTask() error {
|
||||
|
||||
func (s *Service) processClaimedTask(task *model.Task) error {
|
||||
_ = s.log(task.UserID, task.ID, "info", "后端任务开始处理", "")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeout(task.Type))
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), taskExecutionTimeoutWithPolicy(task.Type, policy.Task))
|
||||
defer cancel()
|
||||
leaseDone := make(chan struct{})
|
||||
leaseLost := make(chan error, 1)
|
||||
@@ -784,20 +796,20 @@ func taskFailureMessage(err error) string {
|
||||
return truncateRunes(err.Error(), 2_000)
|
||||
}
|
||||
|
||||
func taskExecutionTimeout(taskType string) time.Duration {
|
||||
func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) time.Duration {
|
||||
switch {
|
||||
case taskType == "agent_storyboard" || taskType == "agent_storyboard_rows":
|
||||
return storyboardTaskTimeout
|
||||
return time.Duration(policy.StoryboardTimeoutMinutes) * time.Minute
|
||||
case strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_"):
|
||||
return videoTaskTimeout
|
||||
return time.Duration(policy.VideoTimeoutMinutes) * time.Minute
|
||||
case strings.HasPrefix(taskType, "canvas_image"):
|
||||
return imageTaskTimeout
|
||||
return time.Duration(policy.ImageTimeoutMinutes) * time.Minute
|
||||
case strings.HasPrefix(taskType, "canvas_audio"):
|
||||
return audioTaskTimeout
|
||||
return time.Duration(policy.AudioTimeoutMinutes) * time.Minute
|
||||
case strings.HasPrefix(taskType, "canvas_text"):
|
||||
return textTaskTimeout
|
||||
return time.Duration(policy.TextTimeoutMinutes) * time.Minute
|
||||
default:
|
||||
return defaultTaskTimeout
|
||||
return time.Duration(policy.DefaultTimeoutMinutes) * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxUserStructuredDataBytes int64 = 256 << 20
|
||||
MaxUserTaskDataBytes int64 = 1 << 30
|
||||
MaxUserAssetCount = 2_000
|
||||
MaxUserCanvasCount = 1_000
|
||||
MaxUserSessionCount = 1_000
|
||||
MaxUserTaskCount = 20_000
|
||||
MaxUserAPICallLogCount = 100_000
|
||||
)
|
||||
|
||||
func structuredBytes(usage repository.UserStorageUsage) int64 {
|
||||
return usage.AssetBytes + usage.CanvasBytes + usage.SessionBytes
|
||||
}
|
||||
|
||||
func validateStructuredStorageQuota(usage repository.UserStorageUsage, kind string, creating bool, deltaBytes int64) error {
|
||||
if structuredBytes(usage)+deltaBytes > MaxUserStructuredDataBytes {
|
||||
return BadAuthRequest("账号画布、素材和会话数据已达到 256MB 上限,请先删除不需要的内容")
|
||||
func validateStructuredStorageQuotaWithPolicy(usage repository.UserStorageUsage, kind string, creating bool, deltaBytes int64, policy RuntimeResourcePolicy) error {
|
||||
if structuredBytes(usage)+deltaBytes > megabytes(policy.StructuredDataMB) {
|
||||
return BadAuthRequest(fmt.Sprintf("账号画布、素材和会话数据已达到 %dMB 上限,请先删除不需要的内容", policy.StructuredDataMB))
|
||||
}
|
||||
if !creating {
|
||||
return nil
|
||||
}
|
||||
switch kind {
|
||||
case "asset":
|
||||
if usage.AssetCount >= MaxUserAssetCount {
|
||||
return BadAuthRequest("账号素材数量已达到 2000 个上限")
|
||||
if usage.AssetCount >= policy.AssetCount {
|
||||
return BadAuthRequest(fmt.Sprintf("账号素材数量已达到 %d 个上限", policy.AssetCount))
|
||||
}
|
||||
case "canvas":
|
||||
if usage.CanvasCount >= MaxUserCanvasCount {
|
||||
return BadAuthRequest("账号画布数量已达到 1000 个上限")
|
||||
if usage.CanvasCount >= policy.CanvasCount {
|
||||
return BadAuthRequest(fmt.Sprintf("账号画布数量已达到 %d 个上限", policy.CanvasCount))
|
||||
}
|
||||
case "session":
|
||||
if usage.SessionCount >= MaxUserSessionCount {
|
||||
return BadAuthRequest("账号 Agent 会话数量已达到 1000 个上限")
|
||||
if usage.SessionCount >= policy.SessionCount {
|
||||
return BadAuthRequest(fmt.Sprintf("账号 Agent 会话数量已达到 %d 个上限", policy.SessionCount))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTaskStorageQuota(usage repository.UserStorageUsage, incomingBytes int64) error {
|
||||
if usage.TaskCount >= MaxUserTaskCount {
|
||||
return BadAuthRequest("账号任务历史已达到 20000 条上限,请联系管理员归档")
|
||||
func validateTaskStorageQuotaWithPolicy(usage repository.UserStorageUsage, incomingBytes int64, policy RuntimeResourcePolicy) error {
|
||||
if usage.TaskCount >= policy.TaskCount {
|
||||
return BadAuthRequest(fmt.Sprintf("账号任务历史已达到 %d 条上限,请联系管理员归档", policy.TaskCount))
|
||||
}
|
||||
return validateTaskDataGrowthQuota(usage, incomingBytes)
|
||||
return validateTaskDataGrowthQuotaWithPolicy(usage, incomingBytes, policy)
|
||||
}
|
||||
|
||||
func validateTaskDataGrowthQuota(usage repository.UserStorageUsage, incomingBytes int64) error {
|
||||
if usage.TaskBytes+incomingBytes > MaxUserTaskDataBytes {
|
||||
return BadAuthRequest("账号任务历史数据已达到 1GB 上限,请联系管理员归档")
|
||||
func validateTaskDataGrowthQuotaWithPolicy(usage repository.UserStorageUsage, incomingBytes int64, policy RuntimeResourcePolicy) error {
|
||||
if usage.TaskBytes+incomingBytes > gigabytes(policy.TaskDataGB) {
|
||||
return BadAuthRequest(fmt.Sprintf("账号任务历史数据已达到 %dGB 上限,请联系管理员归档", policy.TaskDataGB))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAPICallLogQuota(usage repository.UserStorageUsage, incomingBytes int64) error {
|
||||
if usage.APICallCount >= MaxUserAPICallLogCount {
|
||||
return BadAuthRequest("账号上游请求日志已达到 100000 条上限,请联系管理员归档")
|
||||
func validateAPICallLogQuotaWithPolicy(usage repository.UserStorageUsage, incomingBytes int64, policy RuntimeResourcePolicy) error {
|
||||
if usage.APICallCount >= policy.APICallLogCount {
|
||||
return BadAuthRequest(fmt.Sprintf("账号上游请求日志已达到 %d 条上限,请联系管理员归档", policy.APICallLogCount))
|
||||
}
|
||||
return validateTaskDataGrowthQuota(usage, incomingBytes)
|
||||
return validateTaskDataGrowthQuotaWithPolicy(usage, incomingBytes, policy)
|
||||
}
|
||||
|
||||
func validateStructuredReplacementQuota(usage repository.UserStorageUsage, kind string, count int, bytes int64) error {
|
||||
func validateStructuredReplacementQuotaWithPolicy(usage repository.UserStorageUsage, kind string, count int, bytes int64, policy RuntimeResourcePolicy) error {
|
||||
deltaBytes := bytes
|
||||
switch kind {
|
||||
case "asset":
|
||||
if count > MaxUserAssetCount {
|
||||
return BadAuthRequest("账号素材数量不能超过 2000 个")
|
||||
if int64(count) > policy.AssetCount {
|
||||
return BadAuthRequest(fmt.Sprintf("账号素材数量不能超过 %d 个", policy.AssetCount))
|
||||
}
|
||||
deltaBytes -= usage.AssetBytes
|
||||
case "canvas":
|
||||
if count > MaxUserCanvasCount {
|
||||
return BadAuthRequest("账号画布数量不能超过 1000 个")
|
||||
if int64(count) > policy.CanvasCount {
|
||||
return BadAuthRequest(fmt.Sprintf("账号画布数量不能超过 %d 个", policy.CanvasCount))
|
||||
}
|
||||
deltaBytes -= usage.CanvasBytes
|
||||
}
|
||||
return validateStructuredStorageQuota(usage, kind, false, deltaBytes)
|
||||
return validateStructuredStorageQuotaWithPolicy(usage, kind, false, deltaBytes, policy)
|
||||
}
|
||||
|
||||
func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder) error {
|
||||
func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *model.BillingOrder, policy RuntimePolicySetting) error {
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
usage, err := s.repo.UserStorageUsage(task.UserID)
|
||||
@@ -91,17 +82,21 @@ func (s *Service) createTaskWithinStorageQuota(task *model.Task, billingOrder *m
|
||||
return err
|
||||
}
|
||||
incomingBytes := int64(len([]byte(task.Prompt)) + len([]byte(task.InputJSON)) + len([]byte(task.Error)))
|
||||
if err := validateTaskStorageQuota(usage, incomingBytes); err != nil {
|
||||
if err := validateTaskStorageQuotaWithPolicy(usage, incomingBytes, policy.Resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if billingOrder != nil {
|
||||
return s.repo.CreateTaskWithCreditReservation(task, billingOrder)
|
||||
return s.repo.CreateTaskWithCreditReservation(task, billingOrder, policy.Task.ActiveTaskLimit)
|
||||
}
|
||||
return s.repo.CreateTaskWithActiveLimit(task)
|
||||
return s.repo.CreateTaskWithActiveLimit(task, policy.Task.ActiveTaskLimit)
|
||||
}
|
||||
|
||||
// 任务完成会同时扩张任务历史和 Agent 会话数据,必须在同一临界区核算并原子写入。
|
||||
func (s *Service) saveTaskCompletionWithinStorageQuota(task *model.Task, resultJSON []byte, opsJSON []byte, hasCanvasOps bool) error {
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
|
||||
@@ -139,10 +134,10 @@ func (s *Service) saveTaskCompletionWithinStorageQuota(task *model.Task, resultJ
|
||||
for index := range results {
|
||||
taskDelta += int64(len(results[index].URL) + len(results[index].Payload))
|
||||
}
|
||||
if err := validateTaskDataGrowthQuota(usage, taskDelta); err != nil {
|
||||
if err := validateTaskDataGrowthQuotaWithPolicy(usage, taskDelta, policy.Resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateStructuredStorageQuota(usage, "session", false, structuredDelta); err != nil {
|
||||
if err := validateStructuredStorageQuotaWithPolicy(usage, "session", false, structuredDelta, policy.Resource); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -11,30 +11,33 @@ import (
|
||||
)
|
||||
|
||||
func TestValidateStructuredStorageQuotaRejectsBytesAndCounts(t *testing.T) {
|
||||
usage := repository.UserStorageUsage{AssetBytes: MaxUserStructuredDataBytes - 8, AssetCount: MaxUserAssetCount}
|
||||
if err := validateStructuredStorageQuota(usage, "asset", false, 9); err == nil {
|
||||
policy := defaultRuntimePolicy().Resource
|
||||
usage := repository.UserStorageUsage{AssetBytes: megabytes(policy.StructuredDataMB) - 8, AssetCount: policy.AssetCount}
|
||||
if err := validateStructuredStorageQuotaWithPolicy(usage, "asset", false, 9, policy); err == nil {
|
||||
t.Fatal("validateStructuredStorageQuota() byte error = nil")
|
||||
}
|
||||
if err := validateStructuredStorageQuota(usage, "asset", true, 0); err == nil {
|
||||
if err := validateStructuredStorageQuotaWithPolicy(usage, "asset", true, 0, policy); err == nil {
|
||||
t.Fatal("validateStructuredStorageQuota() count error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStructuredStorageQuotaAllowsReplacementThatShrinksData(t *testing.T) {
|
||||
usage := repository.UserStorageUsage{AssetBytes: MaxUserStructuredDataBytes, AssetCount: MaxUserAssetCount}
|
||||
if err := validateStructuredStorageQuota(usage, "asset", false, -1); err != nil {
|
||||
policy := defaultRuntimePolicy().Resource
|
||||
usage := repository.UserStorageUsage{AssetBytes: megabytes(policy.StructuredDataMB), AssetCount: policy.AssetCount}
|
||||
if err := validateStructuredStorageQuotaWithPolicy(usage, "asset", false, -1, policy); err != nil {
|
||||
t.Fatalf("validateStructuredStorageQuota() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskStorageQuotaRejectsHistoryGrowth(t *testing.T) {
|
||||
if err := validateTaskStorageQuota(repository.UserStorageUsage{TaskCount: MaxUserTaskCount}, 0); err == nil {
|
||||
policy := defaultRuntimePolicy().Resource
|
||||
if err := validateTaskStorageQuotaWithPolicy(repository.UserStorageUsage{TaskCount: policy.TaskCount}, 0, policy); err == nil {
|
||||
t.Fatal("validateTaskStorageQuota() count error = nil")
|
||||
}
|
||||
if err := validateTaskStorageQuota(repository.UserStorageUsage{TaskBytes: MaxUserTaskDataBytes}, 1); err == nil {
|
||||
if err := validateTaskStorageQuotaWithPolicy(repository.UserStorageUsage{TaskBytes: gigabytes(policy.TaskDataGB)}, 1, policy); err == nil {
|
||||
t.Fatal("validateTaskStorageQuota() byte error = nil")
|
||||
}
|
||||
if err := validateAPICallLogQuota(repository.UserStorageUsage{APICallCount: MaxUserAPICallLogCount}, 0); err == nil {
|
||||
if err := validateAPICallLogQuotaWithPolicy(repository.UserStorageUsage{APICallCount: policy.APICallLogCount}, 0, policy); err == nil {
|
||||
t.Fatal("validateAPICallLogQuota() count error = nil")
|
||||
}
|
||||
}
|
||||
@@ -44,7 +47,7 @@ func TestUserStorageUsageCountsPersistedPayloads(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := []any{
|
||||
@@ -76,7 +79,7 @@ func TestSaveTaskCompletionPersistsRelatedRowsTogether(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := model.Session{ID: "session-1", UserID: "user-1", Status: model.SessionStatusActive}
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestCreateSessionRemovesDraftWhenTaskCreationFails(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.Asset{}, &model.CanvasProject{}, &model.Session{}, &model.Message{}, &model.Task{}, &model.TaskLog{}, &model.Result{}, &model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 5 {
|
||||
|
||||
@@ -2,26 +2,39 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
const MaxResourceUploadBytes int64 = 50 << 20
|
||||
const MaxDailyUserUploadBytes int64 = 200 << 20
|
||||
const MaxUserStoredFileBytes int64 = 2 << 30
|
||||
|
||||
// 上传额度在写文件或 OSS 前原子预留,避免并发请求同时通过日限额检查。
|
||||
func (s *Service) reserveUserUploadQuota(userID string, size int64) (string, error) {
|
||||
return s.reserveUserStoredFileQuota(userID, size, MaxResourceUploadBytes, "单个上传文件必须小于 50MB")
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.reserveUserStoredFileQuota(userID, size, megabytes(policy.Resource.ResourceUploadMB), megabytes(policy.Resource.DailyUploadMB), gigabytes(policy.Resource.StoredFileGB), fmt.Sprintf("单个上传文件必须小于 %dMB", policy.Resource.ResourceUploadMB))
|
||||
}
|
||||
|
||||
func (s *Service) reserveSessionUploadQuota(userID string, size int64) (string, error) {
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.reserveUserStoredFileQuota(userID, size, megabytes(policy.Resource.SessionUploadMB)+1, megabytes(policy.Resource.DailyUploadMB), gigabytes(policy.Resource.StoredFileGB), fmt.Sprintf("会话文件不能超过 %dMB", policy.Resource.SessionUploadMB))
|
||||
}
|
||||
|
||||
func (s *Service) reserveGeneratedResourceQuota(userID string, size int64) (string, error) {
|
||||
return s.reserveUserStoredFileQuota(userID, size, maxProviderResponseBytes+1, "单个生成文件不能超过 64MB")
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.reserveUserStoredFileQuota(userID, size, megabytes(policy.Resource.GeneratedFileMB)+1, megabytes(policy.Resource.DailyUploadMB), gigabytes(policy.Resource.StoredFileGB), fmt.Sprintf("单个生成文件不能超过 %dMB", policy.Resource.GeneratedFileMB))
|
||||
}
|
||||
|
||||
func (s *Service) reserveUserStoredFileQuota(userID string, size int64, exclusiveSingleFileLimit int64, singleFileMessage string) (string, error) {
|
||||
func (s *Service) reserveUserStoredFileQuota(userID string, size int64, exclusiveSingleFileLimit int64, dailyLimit int64, storedLimit int64, singleFileMessage string) (string, error) {
|
||||
if size <= 0 {
|
||||
return "", BadAuthRequest("上传文件不能为空")
|
||||
}
|
||||
@@ -38,20 +51,27 @@ func (s *Service) reserveUserStoredFileQuota(userID string, size int64, exclusiv
|
||||
if s.pendingStorage == nil {
|
||||
s.pendingStorage = map[string]int64{}
|
||||
}
|
||||
if storedBytes+s.pendingStorage[userID]+size >= MaxUserStoredFileBytes {
|
||||
return "", BadAuthRequest("账号资源和会话附件已达到 2GB 上限,请联系管理员清理历史文件")
|
||||
if storedBytes+s.pendingStorage[userID]+size >= storedLimit {
|
||||
return "", BadAuthRequest(fmt.Sprintf("账号资源和会话附件已达到 %s 上限,请联系管理员清理历史文件", formatStorageLimit(storedLimit)))
|
||||
}
|
||||
s.pendingStorage[userID] += size
|
||||
if err := s.repo.ReserveDailyUpload(userID, day, size, MaxDailyUserUploadBytes); err != nil {
|
||||
if err := s.repo.ReserveDailyUpload(userID, day, size, dailyLimit); err != nil {
|
||||
s.decreasePendingStorage(userID, size)
|
||||
if errors.Is(err, repository.ErrDailyUploadLimitExceeded) {
|
||||
return "", BadAuthRequest("每个账号 UTC 自然日上传总量必须小于 200MB")
|
||||
return "", BadAuthRequest(fmt.Sprintf("每个账号 UTC 自然日上传总量必须小于 %s", formatStorageLimit(dailyLimit)))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return day, nil
|
||||
}
|
||||
|
||||
func formatStorageLimit(value int64) string {
|
||||
if value%(1<<30) == 0 {
|
||||
return fmt.Sprintf("%dGB", value>>30)
|
||||
}
|
||||
return fmt.Sprintf("%dMB", value>>20)
|
||||
}
|
||||
|
||||
func (s *Service) releaseUserUploadQuota(userID string, day string, size int64) {
|
||||
if day == "" || size <= 0 {
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestReserveUserUploadQuotaRejectsSingleFileAtLimit(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
_, err := svc.reserveUserUploadQuota("user-1", MaxResourceUploadBytes)
|
||||
_, err := svc.reserveUserUploadQuota("user-1", megabytes(defaultRuntimePolicy().Resource.ResourceUploadMB))
|
||||
if err == nil || !strings.Contains(err.Error(), "小于 50MB") {
|
||||
t.Fatalf("reserveUserUploadQuota() error = %v", err)
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestCommitUserUploadQuotaKeepsDailyUsageWithoutPendingStorage(t *testing.T)
|
||||
|
||||
func TestReserveUserUploadQuotaRejectsTotalStoredFilesAtLimit(t *testing.T) {
|
||||
svc := newResourceTestService(t)
|
||||
if err := svc.repo.Create(&model.Resource{ID: "resource-1", UserID: "user-1", Status: model.ResourceStatusReady, Size: MaxUserStoredFileBytes - 1}); err != nil {
|
||||
if err := svc.repo.Create(&model.Resource{ID: "resource-1", UserID: "user-1", Status: model.ResourceStatusReady, Size: gigabytes(defaultRuntimePolicy().Resource.StoredFileGB) - 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.reserveUserUploadQuota("user-1", 1); err == nil || !strings.Contains(err.Error(), "2GB 上限") {
|
||||
|
||||
@@ -52,6 +52,10 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS
|
||||
if err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
existing, existingErr := s.repo.AssetForUser(userID, asset.ID)
|
||||
@@ -66,7 +70,7 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS
|
||||
if err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
if err := validateStructuredStorageQuota(usage, "asset", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes); err != nil {
|
||||
if err := validateStructuredStorageQuotaWithPolicy(usage, "asset", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes, policy.Resource); err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
if err := s.repo.UpsertAsset(&asset); err != nil {
|
||||
@@ -107,13 +111,17 @@ func (s *Service) ReplaceUserAssets(userID string, req AssetsSyncRequest) ([]jso
|
||||
assets = append(assets, item)
|
||||
totalBytes += int64(len(raw))
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
usage, err := s.repo.UserStorageUsage(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStructuredReplacementQuota(usage, "asset", len(assets), totalBytes); err != nil {
|
||||
if err := validateStructuredReplacementQuotaWithPolicy(usage, "asset", len(assets), totalBytes, policy.Resource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.ReplaceAssets(userID, assets); err != nil {
|
||||
@@ -164,6 +172,10 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U
|
||||
if err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
existing, existingErr := s.repo.CanvasProjectForUser(userID, project.ID)
|
||||
@@ -178,7 +190,7 @@ func (s *Service) UpsertUserCanvasProject(userID string, raw json.RawMessage) (U
|
||||
if err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
if err := validateStructuredStorageQuota(usage, "canvas", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes); err != nil {
|
||||
if err := validateStructuredStorageQuotaWithPolicy(usage, "canvas", errors.Is(existingErr, gorm.ErrRecordNotFound), int64(len(raw))-existingBytes, policy.Resource); err != nil {
|
||||
return UserDataSummary{}, err
|
||||
}
|
||||
if err := s.repo.UpsertCanvasProject(&project); err != nil {
|
||||
@@ -208,13 +220,17 @@ func (s *Service) ReplaceUserCanvasProjects(userID string, req CanvasProjectsSyn
|
||||
projects = append(projects, item)
|
||||
totalBytes += int64(len(raw))
|
||||
}
|
||||
policy, err := s.RuntimePolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.storageMu.Lock()
|
||||
defer s.storageMu.Unlock()
|
||||
usage, err := s.repo.UserStorageUsage(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStructuredReplacementQuota(usage, "canvas", len(projects), totalBytes); err != nil {
|
||||
if err := validateStructuredReplacementQuotaWithPolicy(usage, "canvas", len(projects), totalBytes, policy.Resource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.ReplaceCanvasProjects(userID, projects); err != nil {
|
||||
|
||||
@@ -12,7 +12,8 @@ server {
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 64m;
|
||||
# 业务配置最高允许 999MB;网关保留少量 multipart 开销,避免保存出无法生效的上限。
|
||||
client_max_body_size 1024m;
|
||||
access_log /var/log/nginx/access.log canvas_no_query;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
gzip on;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { CanvasGenerationBatch, CanvasGenerationBatchStatus } from "@/types/canvas";
|
||||
|
||||
const TASK_CAPACITY_MESSAGE = "同时排队或运行的任务最多 5 个";
|
||||
const TASK_CAPACITY_MESSAGE = /同时排队或运行的任务最多 \d+ 个/;
|
||||
|
||||
export function isGenerationTaskCapacityError(error: unknown) {
|
||||
return error instanceof Error && error.message.includes(TASK_CAPACITY_MESSAGE);
|
||||
return error instanceof Error && TASK_CAPACITY_MESSAGE.test(error.message);
|
||||
}
|
||||
|
||||
export function isGenerationCostUncertainError(error: unknown) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
]);
|
||||
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
|
||||
useUserStore.getState().setUser(payload.user);
|
||||
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
|
||||
await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate()]);
|
||||
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
|
||||
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function ChannelsPage() {
|
||||
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
|
||||
<Form.Item name="apiKey" label={editingChannel ? `API Key(${configuredSecretText})` : "API Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key" }]}><Input.Password placeholder={editingChannel ? "留空保留原密钥" : "系统渠道密钥"} /></Form.Item>
|
||||
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 100, message: "请输入 1-100 的整数" }]}><InputNumber className="w-full" min={1} max={100} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-100"} /></Form.Item>
|
||||
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" }]}><InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} /></Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tooltip } from "antd";
|
||||
import { BarChart3, BellRing, Coins, FileClock, Gauge, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
|
||||
import { BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
|
||||
@@ -40,7 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{
|
||||
label: "系统配置",
|
||||
items: [
|
||||
{ path: "/admin/settings/concurrency", label: "任务并发", description: "Worker 与渠道请求调度", icon: <Gauge className="size-4" />, secondary: true },
|
||||
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" />, secondary: true },
|
||||
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" />, secondary: true },
|
||||
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" />, secondary: true },
|
||||
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" />, secondary: true },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Descriptions, Drawer, Empty, Skeleton, Table, Tabs, Tag } from "antd";
|
||||
import { App, Descriptions, Drawer, Empty, Progress, Skeleton, Table, Tabs, Tag } from "antd";
|
||||
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { getAdminUserDetail, listAdminUserAuditEvents, listAdminUserLedger, listAdminUserTasks, type AdminAuditEvent, type AdminUserDetail, type AdminUserTask } from "@/services/api/auth";
|
||||
@@ -119,6 +119,20 @@ export function AdminUserDetailDrawer({ userId, onClose }: { userId: string | nu
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 text-sm font-medium">资源与配额占用</div>
|
||||
<div className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||
{quotaUsageItems(detail).map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-foreground/60">{item.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-foreground/75">{item.display}</span>
|
||||
</div>
|
||||
<Progress percent={Math.min(100, item.limit > 0 ? Math.round(item.value / item.limit * 100) : 0)} size="small" showInfo={false} status={item.value >= item.limit ? "exception" : "normal"} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -195,3 +209,20 @@ export function AdminUserDetailDrawer({ userId, onClose }: { userId: string | nu
|
||||
function formatTime(value?: string) {
|
||||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
}
|
||||
|
||||
function quotaUsageItems(detail: AdminUserDetail) {
|
||||
const structuredBytes = detail.storageUsage.assetBytes + detail.storageUsage.canvasBytes + detail.storageUsage.sessionBytes;
|
||||
const bytes = (value: number) => value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(2)} GB` : `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
const number = (value: number) => new Intl.NumberFormat("zh-CN").format(value);
|
||||
return [
|
||||
{ label: "资源与附件", value: detail.storedFileBytes, limit: detail.quota.storedFileGB * 1024 ** 3, display: `${bytes(detail.storedFileBytes)} / ${detail.quota.storedFileGB} GB` },
|
||||
{ label: "今日上传(UTC)", value: detail.dailyUploadBytes, limit: detail.quota.dailyUploadMB * 1024 ** 2, display: `${bytes(detail.dailyUploadBytes)} / ${detail.quota.dailyUploadMB} MB` },
|
||||
{ label: "画布、素材与会话数据", value: structuredBytes, limit: detail.quota.structuredDataMB * 1024 ** 2, display: `${bytes(structuredBytes)} / ${detail.quota.structuredDataMB} MB` },
|
||||
{ label: "任务与请求日志数据", value: detail.storageUsage.taskBytes, limit: detail.quota.taskDataGB * 1024 ** 3, display: `${bytes(detail.storageUsage.taskBytes)} / ${detail.quota.taskDataGB} GB` },
|
||||
{ label: "素材数量", value: detail.storageUsage.assetCount, limit: detail.quota.assetCount, display: `${number(detail.storageUsage.assetCount)} / ${number(detail.quota.assetCount)}` },
|
||||
{ label: "画布数量", value: detail.storageUsage.canvasCount, limit: detail.quota.canvasCount, display: `${number(detail.storageUsage.canvasCount)} / ${number(detail.quota.canvasCount)}` },
|
||||
{ label: "Agent 会话数量", value: detail.storageUsage.sessionCount, limit: detail.quota.sessionCount, display: `${number(detail.storageUsage.sessionCount)} / ${number(detail.quota.sessionCount)}` },
|
||||
{ label: "任务历史数量", value: detail.storageUsage.taskCount, limit: detail.quota.taskCount, display: `${number(detail.storageUsage.taskCount)} / ${number(detail.quota.taskCount)}` },
|
||||
{ label: "上游请求日志数量", value: detail.storageUsage.apiCallCount, limit: detail.quota.apiCallLogCount, display: `${number(detail.storageUsage.apiCallCount)} / ${number(detail.quota.apiCallLogCount)}` },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { App, Button, Form, InputNumber, Tag } from "antd";
|
||||
import { Gauge, RadioTower, Workflow } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAdminRuntimeConcurrencySetting, updateAdminRuntimeConcurrencySetting, type RuntimeConcurrencySetting } from "@/services/api/auth";
|
||||
import { useAdminContext } from "../admin-context";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
type ConcurrencyFormValues = Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">;
|
||||
|
||||
export default function ConcurrencySettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
const { references } = useAdminContext();
|
||||
const [setting, setSetting] = useState<RuntimeConcurrencySetting | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm<ConcurrencyFormValues>();
|
||||
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
|
||||
|
||||
useEffect(() => {
|
||||
void getAdminRuntimeConcurrencySetting()
|
||||
.then(({ setting: value }) => {
|
||||
setSetting(value);
|
||||
form.setFieldsValue(value);
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取任务并发配置失败"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [form, message]);
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateAdminRuntimeConcurrencySetting(values);
|
||||
setSetting(result.setting);
|
||||
form.setFieldsValue(result.setting);
|
||||
message.success("任务并发配置已生效");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存任务并发配置失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="任务并发" description="Worker 与渠道请求调度">
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<SettingsSectionCard
|
||||
icon={<Gauge className="size-4" />}
|
||||
title="运行时并发"
|
||||
description="控制任务消费速度和未单独配置渠道的默认请求并发。"
|
||||
status={<Tag bordered={false} color="blue">热更新</Tag>}
|
||||
footer={<><div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "当前使用环境变量默认值"}</div><Button type="primary" loading={saving} onClick={() => void save()}>保存并发配置</Button></>}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
|
||||
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2">
|
||||
<Form.Item name="workerConcurrency" label="Worker 并发数" extra="控制集群同时执行的后台任务数。" rules={concurrencyRules("Worker 并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<Workflow className="size-3.5 text-foreground/40" />} /></Form.Item>
|
||||
<Form.Item name="channelConcurrency" label="全局渠道并发数" extra="渠道选择“跟随系统”时使用;自定义渠道也使用该值。" rules={concurrencyRules("全局渠道并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<RadioTower className="size-3.5 text-foreground/40" />} /></Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
<div className="rounded-lg border border-border bg-muted/25 px-4 py-3 text-xs leading-6 text-foreground/55">任务可以继续进入队列;达到渠道上限时会等待空闲槽位,不会因暂时满载直接失败。单独配置的渠道上限优先于这里的全局值。</div>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function concurrencyRules(label: string) {
|
||||
return [{ required: true, message: `请填写${label}` }, { type: "number" as const, min: 1, max: 100, message: `${label}必须是 1-100 的整数` }];
|
||||
}
|
||||
|
||||
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
|
||||
200
web/src/pages/admin/settings/runtime-policy-settings-page.tsx
Normal file
200
web/src/pages/admin/settings/runtime-policy-settings-page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { App, Button, Form, InputNumber, Tag } from "antd";
|
||||
import { Database, Gauge, Infinity as InfinityIcon, Network, RotateCcw, Save, ShieldCheck, TimerReset } from "lucide-react";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
getAdminRuntimePolicySetting,
|
||||
getAdminSelfUseRuntimePolicy,
|
||||
resetAdminRuntimePolicySetting,
|
||||
updateAdminRuntimePolicySetting,
|
||||
type RuntimePolicySetting,
|
||||
} from "@/services/api/auth";
|
||||
import { useAdminContext } from "../admin-context";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
type PolicyGroup = "resource" | "task" | "request";
|
||||
type PolicyField = {
|
||||
group: PolicyGroup;
|
||||
name: string;
|
||||
label: string;
|
||||
extra: string;
|
||||
unit: string;
|
||||
max: number;
|
||||
};
|
||||
|
||||
const resourceFields: PolicyField[] = [
|
||||
{ group: "resource", name: "resourceUploadMB", label: "普通资源单文件", extra: "素材上传和远程导入的单文件业务上限。", unit: "MB", max: 999 },
|
||||
{ group: "resource", name: "sessionUploadMB", label: "Agent 会话附件", extra: "单个会话附件的大小上限。", unit: "MB", max: 999 },
|
||||
{ group: "resource", name: "generatedFileMB", label: "单个生成资源", extra: "上游生成响应和落库资源的单文件上限。", unit: "MB", max: 999 },
|
||||
{ group: "resource", name: "dailyUploadMB", label: "每日上传总量", extra: "按 UTC 自然日累计资源与附件上传。", unit: "MB", max: 999_999 },
|
||||
{ group: "resource", name: "storedFileGB", label: "账号文件总量", extra: "资源文件与 Agent 会话附件合计。", unit: "GB", max: 999 },
|
||||
{ group: "resource", name: "structuredDataMB", label: "结构化数据总量", extra: "画布、素材和 Agent 会话结构化数据合计。", unit: "MB", max: 999_999 },
|
||||
{ group: "resource", name: "taskDataGB", label: "任务数据总量", extra: "任务历史、结果和上游请求日志合计。", unit: "GB", max: 999 },
|
||||
{ group: "resource", name: "assetCount", label: "素材数量", extra: "单账号可保存的素材记录数。", unit: "条", max: 999_999_999 },
|
||||
{ group: "resource", name: "canvasCount", label: "画布数量", extra: "单账号可保存的画布数量。", unit: "个", max: 999_999_999 },
|
||||
{ group: "resource", name: "sessionCount", label: "Agent 会话数量", extra: "单账号可保存的 Agent 会话数量。", unit: "个", max: 999_999_999 },
|
||||
{ group: "resource", name: "taskCount", label: "任务历史数量", extra: "单账号保留的任务历史记录数。", unit: "条", max: 999_999_999 },
|
||||
{ group: "resource", name: "apiCallLogCount", label: "请求日志数量", extra: "单账号保留的上游请求日志数。", unit: "条", max: 999_999_999 },
|
||||
];
|
||||
|
||||
const concurrencyFields: PolicyField[] = [
|
||||
{ group: "task", name: "workerConcurrency", label: "Worker 并发", extra: "集群同时执行的后台任务数。", unit: "个", max: 999 },
|
||||
{ group: "task", name: "channelConcurrency", label: "全局渠道并发", extra: "渠道选择跟随系统时采用的并发上限。", unit: "个", max: 999 },
|
||||
{ group: "task", name: "activeTaskLimit", label: "账号活动任务", extra: "单账号同时排队或运行的任务数。", unit: "个", max: 999 },
|
||||
];
|
||||
|
||||
const timeoutFields: PolicyField[] = [
|
||||
{ group: "task", name: "imageTimeoutMinutes", label: "图片任务超时", extra: "图片任务进入失败状态前的最长执行时间。", unit: "分钟", max: 9_999 },
|
||||
{ group: "task", name: "textTimeoutMinutes", label: "文本任务超时", extra: "文本任务的最长执行时间。", unit: "分钟", max: 9_999 },
|
||||
{ group: "task", name: "audioTimeoutMinutes", label: "音频任务超时", extra: "音频任务的最长执行时间。", unit: "分钟", max: 9_999 },
|
||||
{ group: "task", name: "videoTimeoutMinutes", label: "视频任务超时", extra: "视频任务的最长执行时间。", unit: "分钟", max: 9_999 },
|
||||
{ group: "task", name: "storyboardTimeoutMinutes", label: "分镜任务超时", extra: "Agent 分镜任务的最长执行时间。", unit: "分钟", max: 9_999 },
|
||||
{ group: "task", name: "defaultTimeoutMinutes", label: "默认任务超时", extra: "未匹配专用类型时使用的最长执行时间。", unit: "分钟", max: 9_999 },
|
||||
];
|
||||
|
||||
const rateFields: PolicyField[] = [
|
||||
{ group: "request", name: "taskCreatePerMinute", label: "任务创建", extra: "每账号每分钟允许创建的任务数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "sessionCreatePerMinute", label: "会话创建", extra: "每账号每分钟允许创建的会话数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "resourceUploadPerMinute", label: "资源上传", extra: "每账号每分钟上传资源的次数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "resourceImportPerMinute", label: "资源导入", extra: "每账号每分钟导入远程资源的次数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "sessionFilePerMinute", label: "会话附件", extra: "每账号每分钟上传会话附件的次数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "assetWritePerMinute", label: "素材写入", extra: "每账号每分钟写入素材的次数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "canvasWritePerMinute", label: "画布写入", extra: "每账号每分钟写入画布的次数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "registerPerHour", label: "账号注册", extra: "每 IP 每小时允许注册的次数。", unit: "次/小时", max: 999_999 },
|
||||
{ group: "request", name: "emailCodePerHour", label: "邮箱验证码", extra: "每 IP 每小时允许请求验证码的次数。", unit: "次/小时", max: 999_999 },
|
||||
{ group: "request", name: "loginIPPerTenMinutes", label: "登录 IP", extra: "每 IP 每 10 分钟允许登录的次数。", unit: "次/10分钟", max: 999_999 },
|
||||
{ group: "request", name: "loginAccountPerTenMinutes", label: "登录账号组合", extra: "同一 IP 与账号组合每 10 分钟的登录次数。", unit: "次/10分钟", max: 999_999 },
|
||||
{ group: "request", name: "systemRelayPerMinute", label: "系统渠道中转", extra: "每账号每分钟使用系统渠道的请求数。", unit: "次/分钟", max: 999_999 },
|
||||
{ group: "request", name: "customRelayPerMinute", label: "自定义渠道中转", extra: "每账号每分钟使用自定义渠道的请求数。", unit: "次/分钟", max: 999_999 },
|
||||
];
|
||||
|
||||
const relayFields: PolicyField[] = [
|
||||
{ group: "request", name: "customRelayConcurrency", label: "自定义渠道并发", extra: "单账号同时进行的自定义渠道请求数。", unit: "个", max: 999 },
|
||||
{ group: "request", name: "customRelayRequestMB", label: "自定义渠道请求体", extra: "中转到自定义上游的请求体上限。", unit: "MB", max: 999 },
|
||||
{ group: "request", name: "customRelayResponseMB", label: "自定义渠道响应体", extra: "自定义上游 JSON 与流式响应的读取上限。", unit: "MB", max: 999 },
|
||||
{ group: "request", name: "customRelayTimeoutMinutes", label: "自定义渠道超时", extra: "自定义渠道连接与响应的最长等待时间。", unit: "分钟", max: 9_999 },
|
||||
{ group: "request", name: "systemRelayRequestMB", label: "系统渠道请求体", extra: "中转到系统渠道的请求体上限。", unit: "MB", max: 999 },
|
||||
{ group: "request", name: "systemRelayResponseMB", label: "系统渠道响应体", extra: "系统渠道上游响应的读取上限。", unit: "MB", max: 999 },
|
||||
{ group: "request", name: "channelCircuitFailureCount", label: "熔断失败次数", extra: "一分钟内连续失败达到该值后打开熔断。", unit: "次", max: 999 },
|
||||
{ group: "request", name: "channelCircuitOpenSeconds", label: "熔断持续时间", extra: "渠道熔断打开后拒绝请求的时间。", unit: "秒", max: 86_400 },
|
||||
];
|
||||
|
||||
export default function RuntimePolicySettingsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const { references } = useAdminContext();
|
||||
const [setting, setSetting] = useState<RuntimePolicySetting | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [form] = Form.useForm<RuntimePolicySetting>();
|
||||
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
|
||||
|
||||
useEffect(() => {
|
||||
void getAdminRuntimePolicySetting()
|
||||
.then(({ setting: value }) => {
|
||||
setSetting(value);
|
||||
form.setFieldsValue(value);
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取资源与请求策略失败"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [form, message]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", beforeUnload);
|
||||
}, [dirty]);
|
||||
|
||||
const useSelfMode = async () => {
|
||||
try {
|
||||
const result = await getAdminSelfUseRuntimePolicy();
|
||||
form.setFieldsValue(result.setting);
|
||||
setDirty(true);
|
||||
message.info("已填入自用模式上限,保存后生效");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取自用模式失败");
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
modal.confirm({
|
||||
title: "重置全部资源与请求策略?",
|
||||
content: "将删除已保存的自定义策略并立即恢复系统默认值。",
|
||||
okText: "重置默认值",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
const result = await resetAdminRuntimePolicySetting();
|
||||
setSetting(result.setting);
|
||||
form.setFieldsValue(result.setting);
|
||||
setDirty(false);
|
||||
message.success("已恢复系统默认策略");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "重置资源与请求策略失败");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateAdminRuntimePolicySetting({ resource: values.resource, task: values.task, request: values.request });
|
||||
setSetting(result.setting);
|
||||
form.setFieldsValue(result.setting);
|
||||
setDirty(false);
|
||||
message.success("资源与请求策略已即时生效");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存资源与请求策略失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title="资源与策略"
|
||||
description="账号配额、任务调度与请求安全策略"
|
||||
actions={<div className="flex items-center gap-2"><Button icon={<RotateCcw className="size-4" />} disabled={loading || saving} onClick={reset}>重置</Button><Button icon={<InfinityIcon className="size-4" />} disabled={loading || saving} onClick={() => void useSelfMode()}>自用模式</Button></div>}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={() => setDirty(true)}>
|
||||
<div className="mx-auto max-w-6xl space-y-5">
|
||||
<PolicySection icon={<Database className="size-4" />} title="资源与账号配额" description="上传、文件容量、结构化数据和历史记录上限。" fields={resourceFields} />
|
||||
<PolicySection icon={<Gauge className="size-4" />} title="任务与并发" description="后台任务消费、渠道调度和单账号活动任务上限。" fields={concurrencyFields} status={<Tag bordered={false} color="blue">热更新</Tag>} />
|
||||
<PolicySection icon={<TimerReset className="size-4" />} title="任务超时" description="不同生成类型的最长执行时间。" fields={timeoutFields} />
|
||||
<PolicySection icon={<ShieldCheck className="size-4" />} title="业务频控" description="账号与 IP 维度的固定窗口请求限制。" fields={rateFields} />
|
||||
<PolicySection icon={<Network className="size-4" />} title="渠道中转与熔断" description="请求体、响应体、并发、超时和上游故障保护。" fields={relayFields} />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-4">
|
||||
<div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "当前使用系统默认策略"}</div>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || !dirty} onClick={() => void save()}>保存配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicySection({ icon, title, description, fields, status }: { icon: ReactNode; title: string; description: string; fields: PolicyField[]; status?: ReactNode }) {
|
||||
return (
|
||||
<SettingsSectionCard icon={icon} title={title} description={description} status={status}>
|
||||
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{fields.map((field) => (
|
||||
<Form.Item key={`${field.group}.${field.name}`} name={[field.group, field.name]} label={field.label} extra={field.extra} rules={[{ required: true, message: `请填写${field.label}` }, { type: "number", min: 1, max: field.max, message: `${field.label}必须是 1-${field.max} 的整数` }]}>
|
||||
<InputNumber className="w-full" min={1} max={field.max} precision={0} addonAfter={field.unit} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import { buildGenerationConfig, generationTaskMetadata, resetGenerationTaskMetad
|
||||
import { unchangedModeratedPrompt } from "@/lib/generation-error";
|
||||
import { cancelGenerationTask, listGenerationTasks } from "@/services/api/task-center";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchMode, CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
|
||||
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
|
||||
|
||||
const MAX_ACTIVE_TASKS_PER_USER = 5;
|
||||
const SCHEDULER_INTERVAL_MS = 2_000;
|
||||
const MAX_BATCH_HISTORY = 20;
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
const { message, modal } = App.useApp();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const activeTaskLimit = useUserStore((state) => state.runtimeLimits.activeTaskLimit);
|
||||
const schedulingRef = useRef(false);
|
||||
const controllersRef = useRef(new Map<string, AbortController>());
|
||||
|
||||
@@ -139,7 +140,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
});
|
||||
}, [projectId, setNodes]);
|
||||
|
||||
// 调度只补齐用户级 5 个活跃任务的空位,后端 3 个 worker 继续负责实际执行并发。
|
||||
// 调度只补齐后台返回的用户级活跃任务空位,最终并发仍由后端原子校验。
|
||||
const scheduleWaitingItems = useCallback(async () => {
|
||||
if (!projectLoaded || schedulingRef.current) return;
|
||||
schedulingRef.current = true;
|
||||
@@ -154,7 +155,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
const item = currentNodes.flatMap((node) => node.metadata?.generationBatches || []).flatMap((batch) => batch.items).find((candidate) => candidate.id === itemId);
|
||||
return item ? !nodeById.get(item.nodeId)?.metadata?.taskId : false;
|
||||
}).length;
|
||||
let availableSlots = Math.max(0, MAX_ACTIVE_TASKS_PER_USER - activeTaskCount - pendingReservations);
|
||||
let availableSlots = Math.max(0, activeTaskLimit - activeTaskCount - pendingReservations);
|
||||
if (!availableSlots) return;
|
||||
|
||||
const candidates: Array<{ batch: CanvasGenerationBatch; item: CanvasGenerationBatchItem; node: CanvasNodeData }> = [];
|
||||
@@ -198,7 +199,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
|
||||
} finally {
|
||||
schedulingRef.current = false;
|
||||
}
|
||||
}, [effectiveConfig, handleGenerateNode, isAiConfigReady, nodesRef, projectId, projectLoaded, reconcileBatches, updateBatch]);
|
||||
}, [activeTaskLimit, effectiveConfig, handleGenerateNode, isAiConfigReady, nodesRef, projectId, projectLoaded, reconcileBatches, updateBatch]);
|
||||
|
||||
const retryFailedBatchItems = useCallback((sourceNodeId: string, batchId: string, itemId?: string) => {
|
||||
const batch = findBatch(nodesRef.current, sourceNodeId, batchId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsP
|
||||
import ChannelsPage from "@/pages/admin/channels/channels-page";
|
||||
import LogsPage from "@/pages/admin/logs/logs-page";
|
||||
import RedemptionCodesPage from "@/pages/admin/redemption-codes/redemption-codes-page";
|
||||
import ConcurrencySettingsPage from "@/pages/admin/settings/concurrency-settings-page";
|
||||
import RuntimePolicySettingsPage from "@/pages/admin/settings/runtime-policy-settings-page";
|
||||
import StorageSettingsPage from "@/pages/admin/settings/storage-settings-page";
|
||||
import StoryboardPromptsPage from "@/pages/admin/storyboard-prompts/storyboard-prompts-page";
|
||||
import UsersPage from "@/pages/admin/users/users-page";
|
||||
@@ -62,8 +62,9 @@ export const router = createBrowserRouter([
|
||||
{ path: "credit-operations", element: <CreditOperationsPage /> },
|
||||
{ path: "redemption-codes", element: <RedemptionCodesPage /> },
|
||||
{ path: "logs", element: <LogsPage /> },
|
||||
{ path: "settings", element: <Navigate to="concurrency" replace /> },
|
||||
{ path: "settings/concurrency", element: <ConcurrencySettingsPage /> },
|
||||
{ path: "settings", element: <Navigate to="runtime-policy" replace /> },
|
||||
{ path: "settings/concurrency", element: <Navigate to="/admin/settings/runtime-policy" replace /> },
|
||||
{ path: "settings/runtime-policy", element: <RuntimePolicySettingsPage /> },
|
||||
{ path: "settings/access", element: <AccessSettingsPage /> },
|
||||
{ path: "settings/email", element: <EmailSettingsPage /> },
|
||||
{ path: "settings/storage", element: <StorageSettingsPage /> },
|
||||
|
||||
@@ -29,6 +29,13 @@ export type AdminUser = LocalUser & {
|
||||
export type AuthSessionPayload = {
|
||||
user: LocalUser | null;
|
||||
systemChannels?: ModelChannel[];
|
||||
runtimeLimits?: RuntimeLimits;
|
||||
};
|
||||
|
||||
export type RuntimeLimits = {
|
||||
activeTaskLimit: number;
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
};
|
||||
|
||||
export type ApiCallLog = {
|
||||
@@ -81,6 +88,13 @@ export type AdminUserDetail = {
|
||||
user: LocalUser;
|
||||
account: { userId: string; availableMicrocredits: number; reservedMicrocredits: number; version: number };
|
||||
counts: { ledgerEntries: number; tasks: number; apiCalls: number; auditEvents: number };
|
||||
storageUsage: {
|
||||
assetCount: number; assetBytes: number; canvasCount: number; canvasBytes: number;
|
||||
sessionCount: number; sessionBytes: number; taskCount: number; taskBytes: number; apiCallCount: number;
|
||||
};
|
||||
storedFileBytes: number;
|
||||
dailyUploadBytes: number;
|
||||
quota: RuntimeResourcePolicy;
|
||||
};
|
||||
|
||||
export type AdminUserTask = {
|
||||
@@ -197,9 +211,62 @@ export type AdminOSSSetting = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type RuntimeConcurrencySetting = {
|
||||
export type RuntimeResourcePolicy = {
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
generatedFileMB: number;
|
||||
dailyUploadMB: number;
|
||||
storedFileGB: number;
|
||||
structuredDataMB: number;
|
||||
taskDataGB: number;
|
||||
assetCount: number;
|
||||
canvasCount: number;
|
||||
sessionCount: number;
|
||||
taskCount: number;
|
||||
apiCallLogCount: number;
|
||||
};
|
||||
|
||||
export type RuntimeTaskPolicy = {
|
||||
workerConcurrency: number;
|
||||
channelConcurrency: number;
|
||||
activeTaskLimit: number;
|
||||
imageTimeoutMinutes: number;
|
||||
textTimeoutMinutes: number;
|
||||
audioTimeoutMinutes: number;
|
||||
videoTimeoutMinutes: number;
|
||||
storyboardTimeoutMinutes: number;
|
||||
defaultTimeoutMinutes: number;
|
||||
};
|
||||
|
||||
export type RuntimeRequestPolicy = {
|
||||
taskCreatePerMinute: number;
|
||||
sessionCreatePerMinute: number;
|
||||
resourceUploadPerMinute: number;
|
||||
resourceImportPerMinute: number;
|
||||
sessionFilePerMinute: number;
|
||||
assetWritePerMinute: number;
|
||||
canvasWritePerMinute: number;
|
||||
registerPerHour: number;
|
||||
emailCodePerHour: number;
|
||||
loginIPPerTenMinutes: number;
|
||||
loginAccountPerTenMinutes: number;
|
||||
systemRelayPerMinute: number;
|
||||
customRelayPerMinute: number;
|
||||
customRelayConcurrency: number;
|
||||
customRelayRequestMB: number;
|
||||
customRelayResponseMB: number;
|
||||
customRelayTimeoutMinutes: number;
|
||||
systemRelayRequestMB: number;
|
||||
systemRelayResponseMB: number;
|
||||
channelCircuitFailureCount: number;
|
||||
channelCircuitOpenSeconds: number;
|
||||
};
|
||||
|
||||
export type RuntimePolicySetting = {
|
||||
resource: RuntimeResourcePolicy;
|
||||
task: RuntimeTaskPolicy;
|
||||
request: RuntimeRequestPolicy;
|
||||
configured?: boolean;
|
||||
updatedBy?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -329,12 +396,20 @@ export function updateAdminOSSSetting(input: Partial<AdminOSSSetting>) {
|
||||
return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input));
|
||||
}
|
||||
|
||||
export function getAdminRuntimeConcurrencySetting() {
|
||||
return request<{ setting: RuntimeConcurrencySetting }>(api.get("/admin/settings/concurrency"));
|
||||
export function getAdminRuntimePolicySetting() {
|
||||
return request<{ setting: RuntimePolicySetting }>(api.get("/admin/settings/runtime-policy"));
|
||||
}
|
||||
|
||||
export function updateAdminRuntimeConcurrencySetting(input: Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">) {
|
||||
return request<{ setting: RuntimeConcurrencySetting }>(api.patch("/admin/settings/concurrency", input));
|
||||
export function getAdminSelfUseRuntimePolicy() {
|
||||
return request<{ setting: RuntimePolicySetting }>(api.get("/admin/settings/runtime-policy/self-use"));
|
||||
}
|
||||
|
||||
export function updateAdminRuntimePolicySetting(input: Pick<RuntimePolicySetting, "resource" | "task" | "request">) {
|
||||
return request<{ setting: RuntimePolicySetting }>(api.put("/admin/settings/runtime-policy", input));
|
||||
}
|
||||
|
||||
export function resetAdminRuntimePolicySetting() {
|
||||
return request<{ setting: RuntimePolicySetting }>(api.delete("/admin/settings/runtime-policy"));
|
||||
}
|
||||
|
||||
export function listAdminApiLogs(params: AdminListParams = {}) {
|
||||
|
||||
@@ -16,10 +16,18 @@ export type LocalUser = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type RuntimeLimits = {
|
||||
activeTaskLimit: number;
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
};
|
||||
|
||||
type UserStore = {
|
||||
hydrated: boolean;
|
||||
user: LocalUser | null;
|
||||
runtimeLimits: RuntimeLimits;
|
||||
setUser: (user: LocalUser | null) => void;
|
||||
setRuntimeLimits: (limits?: RuntimeLimits) => void;
|
||||
setHydrated: (hydrated: boolean) => void;
|
||||
clearSession: () => void;
|
||||
};
|
||||
@@ -27,7 +35,9 @@ type UserStore = {
|
||||
export const useUserStore = create<UserStore>()((set) => ({
|
||||
hydrated: false,
|
||||
user: null,
|
||||
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 },
|
||||
setUser: (user) => set({ user }),
|
||||
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
|
||||
setHydrated: (hydrated) => set({ hydrated }),
|
||||
clearSession: () => set({ user: null }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user