feat(admin): 资源与策略 - 后台统一维护账号配额与运行时请求限制 (#37)

Fixes #35

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-23 21:19:07 +08:00
committed by GitHub
parent fca7c95b64
commit 61d12ec3ff
40 changed files with 1202 additions and 488 deletions

View File

@@ -2,6 +2,8 @@
## Unreleased
- 管理后台新增统一资源与请求策略配置,支持默认重置、自用模式、即时生效和用户配额用量查看。
## v1.0.4
- 系统后台支持热更新 Worker 与全局渠道并发,各系统渠道可跟随全局值或单独覆盖;渠道满载时请求等待空闲槽位,不再直接失败。

View File

@@ -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。
## 公网部署安全

View File

@@ -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 {

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
}

View 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 }

View 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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 上限") {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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: [] });

View File

@@ -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>

View File

@@ -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 },

View File

@@ -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)}` },
];
}

View File

@@ -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 }) : "--"; }

View 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 }) : "--";
}

View File

@@ -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);

View File

@@ -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 /> },

View File

@@ -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 = {}) {

View File

@@ -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 } }),
}));