diff --git a/CHANGELOG.md b/CHANGELOG.md index f89a924ad..a26fa1529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- 管理后台新增统一资源与请求策略配置,支持默认重置、自用模式、即时生效和用户配额用量查看。 + ## v1.0.4 - 系统后台支持热更新 Worker 与全局渠道并发,各系统渠道可跟随全局值或单独覆盖;渠道满载时请求等待空闲槽位,不再直接失败。 diff --git a/README.md b/README.md index d3e1dcdab..4635c846c 100644 --- a/README.md +++ b/README.md @@ -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。 ## 公网部署安全 diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 06df6e3af..db176eb9b 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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 { diff --git a/backend/internal/handler/custom_proxy.go b/backend/internal/handler/custom_proxy.go index 831ab4d14..6055d6e47 100644 --- a/backend/internal/handler/custom_proxy.go +++ b/backend/internal/handler/custom_proxy.go @@ -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 diff --git a/backend/internal/handler/custom_proxy_test.go b/backend/internal/handler/custom_proxy_test.go index b6f6e620d..d856f7dc6 100644 --- a/backend/internal/handler/custom_proxy_test.go +++ b/backend/internal/handler/custom_proxy_test.go @@ -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, + } +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index c501604a3..6841910be 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -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) diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index e725d7b5b..080a9c83d 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -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 { diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index d3aed32c3..7d2936ca6 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -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) diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 631418907..62dd43e03 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -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 diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index f2830c48b..236579c14 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -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 { diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 3e444529b..a3f22158e 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -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 { diff --git a/backend/internal/service/admin_audit.go b/backend/internal/service/admin_audit.go index d9be598ea..454075d0b 100644 --- a/backend/internal/service/admin_audit.go +++ b/backend/internal/service/admin_audit.go @@ -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) { diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index 1d1bd5a03..cd08810ec 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -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) } diff --git a/backend/internal/service/concurrency_settings.go b/backend/internal/service/concurrency_settings.go deleted file mode 100644 index f805a7e57..000000000 --- a/backend/internal/service/concurrency_settings.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/service/concurrency_settings_test.go b/backend/internal/service/concurrency_settings_test.go index 0f70b61eb..038709df0 100644 --- a/backend/internal/service/concurrency_settings_test.go +++ b/backend/internal/service/concurrency_settings_test.go @@ -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) } diff --git a/backend/internal/service/coordination.go b/backend/internal/service/coordination.go index 9e625275b..429faabef 100644 --- a/backend/internal/service/coordination.go +++ b/backend/internal/service/coordination.go @@ -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 +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index a611e10fe..390998de2 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -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 { diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 7b4c70c5b..715cab6ff 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -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 { diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index 3b44c7140..528c46c79 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -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) } diff --git a/backend/internal/service/runtime_policy.go b/backend/internal/service/runtime_policy.go new file mode 100644 index 000000000..1230eecce --- /dev/null +++ b/backend/internal/service/runtime_policy.go @@ -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 } diff --git a/backend/internal/service/runtime_policy_test.go b/backend/internal/service/runtime_policy_test.go new file mode 100644 index 000000000..ae34d3f0b --- /dev/null +++ b/backend/internal/service/runtime_policy_test.go @@ -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) + } +} diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index f38d5f577..be76161d6 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -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 } } diff --git a/backend/internal/service/storage_quota.go b/backend/internal/service/storage_quota.go index d407cc129..19f6a33d7 100644 --- a/backend/internal/service/storage_quota.go +++ b/backend/internal/service/storage_quota.go @@ -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 } diff --git a/backend/internal/service/storage_quota_test.go b/backend/internal/service/storage_quota_test.go index c606a0c4e..e1c2e137d 100644 --- a/backend/internal/service/storage_quota_test.go +++ b/backend/internal/service/storage_quota_test.go @@ -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} diff --git a/backend/internal/service/task_security_test.go b/backend/internal/service/task_security_test.go index 9262b874f..517d4dfe9 100644 --- a/backend/internal/service/task_security_test.go +++ b/backend/internal/service/task_security_test.go @@ -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 { diff --git a/backend/internal/service/upload_quota.go b/backend/internal/service/upload_quota.go index 39f67cbd7..5995c56c8 100644 --- a/backend/internal/service/upload_quota.go +++ b/backend/internal/service/upload_quota.go @@ -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 diff --git a/backend/internal/service/upload_quota_test.go b/backend/internal/service/upload_quota_test.go index b27064673..53d5e3f23 100644 --- a/backend/internal/service/upload_quota_test.go +++ b/backend/internal/service/upload_quota_test.go @@ -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 上限") { diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 720b7b2af..2ea3555a9 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -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 { diff --git a/nginx.conf b/nginx.conf index 6b11a7d37..28350f239 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; diff --git a/web/src/lib/canvas/canvas-generation-batch.ts b/web/src/lib/canvas/canvas-generation-batch.ts index d82fe8b94..22d645fe6 100644 --- a/web/src/lib/canvas/canvas-generation-batch.ts +++ b/web/src/lib/canvas/canvas-generation-batch.ts @@ -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) { diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index f23c3f9cc..950fca272 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -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: [] }); diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index 18bedb7a8..8ca547401 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -164,7 +164,7 @@ export default function ChannelsPage() {