fix(platform): 渠道并发 - 支持后台热配置并等待空闲槽位 (#34)

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-23 18:35:51 +08:00
committed by GitHub
parent f26f9983bc
commit 93316dd3d7
25 changed files with 703 additions and 112 deletions

View File

@@ -4,6 +4,8 @@ CANVAS_ALLOW_PRIVATE_UPSTREAMS=false
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS=
CANVAS_CORS_ORIGINS=http://localhost:3000
CANVAS_DATA_PATH=./data
CANVAS_WORKER_CONCURRENCY=3
CANVAS_CHANNEL_CONCURRENCY=3
# 新服务器请直接运行 README 的一键部署命令;脚本会在 .env 中生成以下 PostgreSQL 配置。
# POSTGRES_DB=open_ai_canvas

View File

@@ -2,6 +2,8 @@
## Unreleased
- 系统后台支持热更新 Worker 与全局渠道并发,各系统渠道可跟随全局值或单独覆盖;渠道满载时请求等待空闲槽位,不再直接失败。
## v1.0.3
- 系统公告正文支持受控 HTML,用户可以看到蓝色链接并在新窗口打开,同时支持粗体、斜体、下划线、列表和换行等常见格式。

View File

@@ -120,6 +120,8 @@ bun run dev
打开 `http://localhost:3000`,注册首个管理员账号,再在系统设置中配置渠道和模型。
任务 Worker 和全局渠道并发可在“系统配置 → 任务并发”中热更新;系统渠道可选择跟随全局值,或单独设置 `1-100` 的最大并发数。未保存后台配置时分别回退到 `CANVAS_WORKER_CONCURRENCY` 和 `CANVAS_CHANNEL_CONCURRENCY`,两者默认均为 `3`。渠道槽位暂满时任务会等待,不会直接标记失败。
Docker 一体化运行:
```bash

View File

@@ -439,6 +439,37 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"setting": setting})
})
r.GET("/admin/settings/concurrency", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
setting, err := svc.AdminRuntimeConcurrencySetting(user)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"setting": setting})
})
r.PATCH("/admin/settings/concurrency", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
var req service.RuntimeConcurrencySettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
setting, err := svc.UpdateRuntimeConcurrencySetting(user, req)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"setting": setting})
})
r.GET("/admin/api-logs", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
@@ -524,12 +555,35 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
}
modelName := proxyRequestModel(c.GetHeader("Content-Type"), body)
billingOrderID := ""
query := c.Request.URL.Query()
for _, key := range []string{"key", "api_key", "access_token", "token"} {
query.Del(key)
}
target := strings.TrimRight(channel.BaseURL, "/") + path
if encodedQuery := query.Encode(); encodedQuery != "" {
target += "?" + encodedQuery
}
if _, err := service.ValidateOutboundURL(target); err != nil {
_ = svc.RefundBilling(billingOrderID, "系统渠道地址校验失败")
failService(c, err)
return
}
// 同步代理与后台任务必须共享渠道槽位,否则两条入口会共同超过供应商并发上限。
releaseChannel, concurrencyLimit, err := svc.AcquireChannelSlot(c.Request.Context(), channel.ID, "", 36*time.Minute)
if err != nil {
log := apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, model.ApiCallStatusFailed, 0, time.Since(startedAt), err.Error(), concurrencyLimit)
log.ErrorCode, log.Error = service.ChannelSlotFailureDetails(err)
logSystemProxyCall(svc, log, nil)
fail(c, http.StatusServiceUnavailable, err)
return
}
defer releaseChannel()
if c.Request.Method == http.MethodPost {
capability := "text"
switch channel.InterfaceType {
case model.ChannelInterfaceOpenAIImage:
capability = "image"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2:
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo:
capability = "video"
}
order, err := svc.ReserveProxyBilling(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"))
@@ -544,19 +598,6 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
return
}
}
query := c.Request.URL.Query()
for _, key := range []string{"key", "api_key", "access_token", "token"} {
query.Del(key)
}
target := strings.TrimRight(channel.BaseURL, "/") + path
if encodedQuery := query.Encode(); encodedQuery != "" {
target += "?" + encodedQuery
}
if _, err := service.ValidateOutboundURL(target); err != nil {
_ = svc.RefundBilling(billingOrderID, "系统渠道地址校验失败")
failService(c, err)
return
}
upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target, bytes.NewReader(body))
if err != nil {
_ = svc.RefundBilling(billingOrderID, "系统渠道请求构造失败")
@@ -583,7 +624,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
status = model.ApiCallStatusFailed
errorText = err.Error()
_ = svc.MarkBillingUncertain(billingOrderID, "系统渠道连接中断,费用状态待核对")
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, status, statusCode, time.Since(startedAt), errorText), nil)
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), nil)
fail(c, http.StatusBadGateway, errors.New("系统渠道连接失败"))
return
}
@@ -597,7 +638,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
status = model.ApiCallStatusFailed
errorText = readErr.Error()
_ = svc.MarkBillingUncertain(billingOrderID, "系统渠道响应读取失败,费用状态待核对")
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, status, statusCode, time.Since(startedAt), errorText), nil)
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), nil)
fail(c, http.StatusBadGateway, errors.New("系统渠道响应读取失败"))
return
}
@@ -615,7 +656,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
} else {
_ = svc.RefundBilling(billingOrderID, "上游明确返回失败")
}
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, status, statusCode, time.Since(startedAt), errorText), responseBody)
logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, c.Request.Method, path, target, body, status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody)
for _, key := range []string{"Content-Type", "Cache-Control", "Content-Disposition"} {
if value := resp.Header.Get(key); value != "" {
c.Header(key, value)
@@ -625,12 +666,12 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), responseBody)
}
func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID string, method string, path string, target string, body []byte, status model.ApiCallStatus, statusCode int, duration time.Duration, errorText string) model.ApiCallLog {
func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID string, method string, path string, target string, body []byte, status model.ApiCallStatus, statusCode int, duration time.Duration, errorText string, concurrencyLimit int) model.ApiCallLog {
capability := "text"
switch channel.InterfaceType {
case model.ChannelInterfaceOpenAIImage:
capability = "image"
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2:
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2, model.ChannelInterfaceXAIVideo:
capability = "video"
}
requestKind := "create"
@@ -656,6 +697,7 @@ func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID st
StatusCode: statusCode,
DurationMs: duration.Milliseconds(),
Error: errorText,
ConcurrencyLimit: concurrencyLimit,
UpstreamURL: target,
}
}

View File

@@ -156,6 +156,7 @@ type ModelChannel struct {
APIKey string `json:"-"`
APIFormat string `json:"apiFormat" gorm:"size:24"`
InterfaceType ChannelInterfaceType `json:"interfaceType" gorm:"size:32"`
ConcurrencyLimit int `json:"concurrencyLimit"`
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -207,6 +208,7 @@ type ApiCallLog struct {
Currency string `json:"currency" gorm:"size:12"`
ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"`
Error string `json:"error"`
ConcurrencyLimit int `json:"concurrencyLimit"`
UpstreamURL string `json:"upstreamUrl"`
CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"`
}

View File

@@ -79,6 +79,8 @@ type ChannelRequest struct {
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
InterfaceType string `json:"interfaceType"`
ConcurrencyLimit *int `json:"concurrencyLimit"`
UseGlobalConcurrency *bool `json:"useGlobalConcurrency"`
Models []string `json:"models"`
Enabled *bool `json:"enabled"`
}
@@ -93,6 +95,7 @@ type PublicModelChannel struct {
APIKey string `json:"apiKey"`
APIFormat string `json:"apiFormat"`
InterfaceType model.ChannelInterfaceType `json:"interfaceType"`
ConcurrencyLimit int `json:"concurrencyLimit"`
Models []string `json:"models"`
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
HasAPIKey bool `json:"hasApiKey"`
@@ -530,6 +533,16 @@ func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.M
// 系统渠道均由后端按已声明的接口类型分发,调用格式固定为 Bearer/OpenAI 兼容鉴权。
channel.APIFormat = "openai"
channel.InterfaceType = interfaceType
if req.UseGlobalConcurrency != nil && *req.UseGlobalConcurrency {
channel.ConcurrencyLimit = 0
} else if req.ConcurrencyLimit != nil {
if *req.ConcurrencyLimit < minChannelConcurrencyLimit || *req.ConcurrencyLimit > maxChannelConcurrencyLimit {
return channel, BadAuthRequest("最大并发数必须是 1-100 的整数")
}
channel.ConcurrencyLimit = *req.ConcurrencyLimit
} else if req.UseGlobalConcurrency != nil {
return channel, BadAuthRequest("请填写渠道最大并发数")
}
channel.ModelsJSON = string(modelsJSON)
if req.Enabled != nil {
channel.Enabled = *req.Enabled
@@ -620,6 +633,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
APIKey: apiKey,
APIFormat: channel.APIFormat,
InterfaceType: interfaceType,
ConcurrencyLimit: channel.ConcurrencyLimit,
Models: models,
ModelCosts: modelCosts,
HasAPIKey: strings.TrimSpace(channel.APIKey) != "",

View File

@@ -18,6 +18,7 @@ func TestChannelFromRequestStoresInterfaceType(t *testing.T) {
BaseURL: server.URL + "/v1",
APIKey: "secret",
InterfaceType: "newapi-channel-1",
ConcurrencyLimit: intPtr(6),
Models: []string{"seedance-2.0"},
}, model.ModelChannel{})
if err != nil {
@@ -29,6 +30,9 @@ func TestChannelFromRequestStoresInterfaceType(t *testing.T) {
if channel.APIFormat != "openai" {
t.Fatalf("APIFormat = %q, want openai", channel.APIFormat)
}
if channel.ConcurrencyLimit != 6 {
t.Fatalf("ConcurrencyLimit = %d, want 6", channel.ConcurrencyLimit)
}
}
func TestChannelFromRequestStoresXAIVideoInterfaceType(t *testing.T) {
@@ -70,3 +74,29 @@ func TestChannelFromRequestRejectsUnknownInterfaceType(t *testing.T) {
t.Fatal("channelFromRequest() error = nil")
}
}
func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) {
for _, limit := range []int{0, 101} {
_, 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)
}
}
}
func TestRuntimeConcurrencyUsesEnvironmentFallback(t *testing.T) {
t.Setenv("CANVAS_CHANNEL_CONCURRENCY", "7")
t.Setenv("CANVAS_WORKER_CONCURRENCY", "9")
setting := runtimeConcurrencyFromEnvironment()
if setting.ChannelConcurrency != 7 || setting.WorkerConcurrency != 9 {
t.Fatalf("runtimeConcurrencyFromEnvironment() = %#v", setting)
}
useGlobal := true
channel, err := channelFromRequest(ChannelRequest{Name: "Global", BaseURL: "https://example.com/v1", InterfaceType: "newapi", UseGlobalConcurrency: &useGlobal}, model.ModelChannel{ConcurrencyLimit: 4})
if err != nil || channel.ConcurrencyLimit != 0 {
t.Fatalf("global concurrency channel = %#v, error = %v", channel, err)
}
}
func intPtr(value int) *int { return &value }

View File

@@ -245,9 +245,9 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery)
var buffer bytes.Buffer
buffer.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(&buffer)
_ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "计费单ID", "能力", "请求阶段", "模型", "状态", "HTTP状态", "耗时毫秒", "供应商任务ID", "错误码", "错误"})
_ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "计费单ID", "能力", "请求阶段", "模型", "状态", "HTTP状态", "耗时毫秒", "渠道并发上限", "供应商任务ID", "错误码", "错误"})
for _, log := range logs {
_ = writer.Write([]string{log.CreatedAt.UTC().Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.BillingOrderID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), log.ProviderRequestID, log.ErrorCode, log.Error})
_ = writer.Write([]string{log.CreatedAt.UTC().Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.BillingOrderID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), strconv.Itoa(log.ConcurrencyLimit), log.ProviderRequestID, log.ErrorCode, log.Error})
}
writer.Flush()
if err := writer.Error(); err != nil {

View File

@@ -0,0 +1,122 @@
package service
import (
"encoding/json"
"errors"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
const runtimeConcurrencySettingKey = "runtime_concurrency"
type RuntimeConcurrencySettingRequest struct {
WorkerConcurrency int `json:"workerConcurrency"`
ChannelConcurrency int `json:"channelConcurrency"`
}
type PublicRuntimeConcurrencySetting struct {
WorkerConcurrency int `json:"workerConcurrency"`
ChannelConcurrency int `json:"channelConcurrency"`
UpdatedBy string `json:"updatedBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type runtimeConcurrencySettingValue struct {
WorkerConcurrency int `json:"workerConcurrency"`
ChannelConcurrency int `json:"channelConcurrency"`
}
func (s *Service) AdminRuntimeConcurrencySetting(actor *model.User) (*PublicRuntimeConcurrencySetting, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
setting, value, err := s.readRuntimeConcurrencySetting()
if err != nil {
return nil, err
}
return publicRuntimeConcurrencySetting(setting, value), nil
}
func (s *Service) UpdateRuntimeConcurrencySetting(actor *model.User, req RuntimeConcurrencySettingRequest) (*PublicRuntimeConcurrencySetting, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
value := runtimeConcurrencySettingValue{WorkerConcurrency: req.WorkerConcurrency, ChannelConcurrency: req.ChannelConcurrency}
if err := validateRuntimeConcurrencySetting(value); err != nil {
return nil, err
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
current, _, err := s.readRuntimeConcurrencySetting()
if err != nil {
return nil, err
}
setting := model.SystemSetting{Key: runtimeConcurrencySettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
if current != nil {
setting.CreatedAt = current.CreatedAt
}
if err := s.repo.SaveSystemSetting(&setting); err != nil {
return nil, err
}
if err := s.appendAdminAudit(actor, "runtime_concurrency.update", "system_setting", runtimeConcurrencySettingKey, "更新任务并发配置", value); err != nil {
return nil, err
}
return publicRuntimeConcurrencySetting(&setting, value), nil
}
func (s *Service) runtimeConcurrencySetting() (runtimeConcurrencySettingValue, error) {
_, value, err := s.readRuntimeConcurrencySetting()
return value, err
}
func (s *Service) readRuntimeConcurrencySetting() (*model.SystemSetting, runtimeConcurrencySettingValue, error) {
setting, err := s.repo.SystemSetting(runtimeConcurrencySettingKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, runtimeConcurrencyFromEnvironment(), nil
}
if err != nil {
return nil, runtimeConcurrencySettingValue{}, err
}
value := runtimeConcurrencySettingValue{}
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
return nil, runtimeConcurrencySettingValue{}, errors.New("任务并发配置格式无效")
}
if err := validateRuntimeConcurrencySetting(value); err != nil {
return nil, runtimeConcurrencySettingValue{}, err
}
return setting, value, nil
}
func runtimeConcurrencyFromEnvironment() runtimeConcurrencySettingValue {
return runtimeConcurrencySettingValue{
WorkerConcurrency: effectiveChannelConcurrencyLimit(envInt("CANVAS_WORKER_CONCURRENCY", taskWorkerConcurrency)),
ChannelConcurrency: defaultChannelConcurrencyLimit(),
}
}
func validateRuntimeConcurrencySetting(value runtimeConcurrencySettingValue) error {
if value.WorkerConcurrency < minChannelConcurrencyLimit || value.WorkerConcurrency > maxChannelConcurrencyLimit {
return BadAuthRequest("Worker 并发数必须是 1-100 的整数")
}
if value.ChannelConcurrency < minChannelConcurrencyLimit || value.ChannelConcurrency > maxChannelConcurrencyLimit {
return BadAuthRequest("全局渠道并发数必须是 1-100 的整数")
}
return nil
}
func publicRuntimeConcurrencySetting(setting *model.SystemSetting, value runtimeConcurrencySettingValue) *PublicRuntimeConcurrencySetting {
result := &PublicRuntimeConcurrencySetting{WorkerConcurrency: value.WorkerConcurrency, ChannelConcurrency: value.ChannelConcurrency}
if setting != nil {
result.UpdatedBy = setting.UpdatedBy
result.CreatedAt = setting.CreatedAt
result.UpdatedAt = setting.UpdatedAt
}
return result
}

View File

@@ -0,0 +1,63 @@
package service
import (
"context"
"testing"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestRuntimeConcurrencySettingPersistsAndChannelOverrideWins(t *testing.T) {
t.Setenv("REDIS_URL", "")
t.Setenv("CANVAS_WORKER_CONCURRENCY", "3")
t.Setenv("CANVAS_CHANNEL_CONCURRENCY", "3")
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(&model.SystemSetting{}, &model.AdminAuditEvent{}, &model.ModelChannel{}); err != nil {
t.Fatal(err)
}
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})
if err != nil {
t.Fatal(err)
}
if setting.WorkerConcurrency != 8 || setting.ChannelConcurrency != 6 {
t.Fatalf("setting = %#v", setting)
}
channel := model.ModelChannel{ID: "channel-1", Scope: model.ChannelScopeSystem, Enabled: true, ConcurrencyLimit: 0}
if err := db.Create(&channel).Error; err != nil {
t.Fatal(err)
}
release, limit, err := svc.AcquireChannelSlot(context.Background(), channel.ID, "", time.Minute)
if err != nil {
t.Fatal(err)
}
release()
if limit != 6 {
t.Fatalf("global channel limit = %d, want 6", limit)
}
if err := db.Model(&channel).Update("concurrency_limit", 9).Error; err != nil {
t.Fatal(err)
}
release, limit, err = svc.AcquireChannelSlot(context.Background(), channel.ID, "", time.Minute)
if err != nil {
t.Fatal(err)
}
release()
if limit != 9 {
t.Fatalf("overridden channel limit = %d, want 9", limit)
}
}

View File

@@ -18,11 +18,50 @@ type localRateEntry struct {
count int
}
const (
minChannelConcurrencyLimit = 1
maxChannelConcurrencyLimit = 100
defaultChannelConcurrencyValue = 3
)
type channelSlotError struct {
scope string
limit int
err error
}
func (e channelSlotError) Error() string {
if errors.Is(e.err, context.DeadlineExceeded) {
return fmt.Sprintf("等待渠道并发槽位超时(渠道 %s,并发上限 %d)", e.scope, e.limit)
}
if errors.Is(e.err, context.Canceled) {
return fmt.Sprintf("等待渠道并发槽位已取消(渠道 %s,并发上限 %d)", e.scope, e.limit)
}
return fmt.Sprintf("获取渠道并发配额失败(渠道 %s,并发上限 %d):%v", e.scope, e.limit, e.err)
}
func (e channelSlotError) Unwrap() error { return e.err }
func ChannelSlotFailureDetails(err error) (string, string) {
var slotErr channelSlotError
if !errors.As(err, &slotErr) {
return "", ""
}
if errors.Is(slotErr, context.DeadlineExceeded) {
return "channel_concurrency_wait_timeout", slotErr.Error()
}
if errors.Is(slotErr, context.Canceled) {
return "channel_concurrency_wait_cancelled", slotErr.Error()
}
return "channel_concurrency_unavailable", slotErr.Error()
}
type runtimeCoordinator struct {
redis *redis.Client
instanceID string
localMu sync.Mutex
localRate map[string]localRateEntry
localSlots map[string]map[string]time.Time
}
var fixedWindowScript = redis.NewScript(`
@@ -40,7 +79,7 @@ return 1
`)
func newRuntimeCoordinator(dialect string) (*runtimeCoordinator, error) {
coordinator := &runtimeCoordinator{instanceID: newID(), localRate: map[string]localRateEntry{}}
coordinator := &runtimeCoordinator{instanceID: newID(), localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}
redisURL := strings.TrimSpace(os.Getenv("REDIS_URL"))
if redisURL == "" {
if dialect == "postgres" {
@@ -84,7 +123,30 @@ func (c *runtimeCoordinator) allow(ctx context.Context, key string, limit int, w
func (c *runtimeCoordinator) acquire(ctx context.Context, scope string, limit int, ttl time.Duration) (func(), bool, error) {
if c.redis == nil {
return func() {}, true, nil
c.localMu.Lock()
now := time.Now()
slots := c.localSlots[scope]
if slots == nil {
slots = map[string]time.Time{}
c.localSlots[scope] = slots
}
for token, expiresAt := range slots {
if !expiresAt.After(now) {
delete(slots, token)
}
}
if len(slots) >= limit {
c.localMu.Unlock()
return nil, false, nil
}
token := c.instanceID + ":" + newID()
slots[token] = now.Add(ttl)
c.localMu.Unlock()
return func() {
c.localMu.Lock()
delete(c.localSlots[scope], token)
c.localMu.Unlock()
}, true, nil
}
// 有过期分数的有序集合避免实例崩溃后永久占槽,业务数据库仍保存任务与账本真相。
key := "canvas:slots:" + scope
@@ -97,6 +159,28 @@ func (c *runtimeCoordinator) acquire(ctx context.Context, scope string, limit in
return func() { _ = c.redis.ZRem(context.Background(), key, token).Err() }, true, nil
}
func (c *runtimeCoordinator) acquireWithWait(ctx context.Context, scope string, limit int, ttl time.Duration) (func(), error) {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
if err := ctx.Err(); err != nil {
return nil, err
}
release, acquired, err := c.acquire(ctx, scope, limit, ttl)
if err != nil {
return nil, err
}
if acquired {
return release, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}
func (c *runtimeCoordinator) circuitOpen(ctx context.Context, channelID string) (bool, error) {
if c.redis == nil || strings.TrimSpace(channelID) == "" {
return false, nil
@@ -133,6 +217,52 @@ func envInt(key string, fallback int) int {
return value
}
func defaultChannelConcurrencyLimit() int {
return effectiveChannelConcurrencyLimit(envInt("CANVAS_CHANNEL_CONCURRENCY", defaultChannelConcurrencyValue))
}
func effectiveChannelConcurrencyLimit(configured int) int {
if configured < minChannelConcurrencyLimit || configured > maxChannelConcurrencyLimit {
return defaultChannelConcurrencyValue
}
return configured
}
func (s *Service) AcquireChannelSlot(ctx context.Context, channelID string, fallbackScope string, ttl time.Duration) (func(), int, error) {
setting, err := s.runtimeConcurrencySetting()
limit := defaultChannelConcurrencyLimit()
if err != nil {
return nil, limit, channelSlotError{scope: firstNonEmpty(strings.TrimSpace(channelID), strings.TrimSpace(fallbackScope), "unknown"), limit: limit, err: fmt.Errorf("读取全局并发配置失败:%w", err)}
}
limit = setting.ChannelConcurrency
scope := strings.TrimSpace(channelID)
if scope != "" {
channel, err := s.repo.SystemChannel(scope)
if err != nil {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: fmt.Errorf("读取渠道并发配置失败:%w", err)}
}
if channel.ConcurrencyLimit > 0 {
if channel.ConcurrencyLimit < minChannelConcurrencyLimit || channel.ConcurrencyLimit > maxChannelConcurrencyLimit {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: errors.New("渠道并发配置超出 1-100 范围")}
}
limit = channel.ConcurrencyLimit
}
} else {
scope = strings.TrimSpace(fallbackScope)
}
if scope == "" {
return nil, limit, channelSlotError{scope: "unknown", limit: limit, err: errors.New("渠道并发范围为空")}
}
if s.coordinator == nil {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: errors.New("运行时协调器未初始化")}
}
release, err := s.coordinator.acquireWithWait(ctx, "channel:"+scope, limit, ttl)
if err != nil {
return nil, limit, channelSlotError{scope: scope, limit: limit, err: err}
}
return release, limit, nil
}
func (s *Service) ValidateRuntime() error {
return s.runtimeErr
}

View File

@@ -0,0 +1,54 @@
package service
import (
"context"
"testing"
"time"
)
func TestRuntimeCoordinatorWaitsUntilChannelSlotIsReleased(t *testing.T) {
coordinator := &runtimeCoordinator{instanceID: "test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}
releaseFirst, acquired, err := coordinator.acquire(context.Background(), "channel:one", 1, time.Minute)
if err != nil || !acquired {
t.Fatalf("first acquire = (%v, %v), want acquired", acquired, err)
}
result := make(chan error, 1)
go func() {
releaseSecond, waitErr := coordinator.acquireWithWait(context.Background(), "channel:one", 1, time.Minute)
if waitErr == nil {
releaseSecond()
}
result <- waitErr
}()
select {
case err := <-result:
t.Fatalf("second acquire returned before release: %v", err)
case <-time.After(50 * time.Millisecond):
}
releaseFirst()
select {
case err := <-result:
if err != nil {
t.Fatalf("second acquire after release: %v", err)
}
case <-time.After(time.Second):
t.Fatal("second acquire did not resume after release")
}
}
func TestRuntimeCoordinatorStopsWaitingWhenContextIsCancelled(t *testing.T) {
coordinator := &runtimeCoordinator{instanceID: "test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}
release, acquired, err := coordinator.acquire(context.Background(), "channel:one", 1, time.Minute)
if err != nil || !acquired {
t.Fatalf("first acquire = (%v, %v), want acquired", acquired, err)
}
defer release()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := coordinator.acquireWithWait(ctx, "channel:one", 1, time.Minute); err == nil {
t.Fatal("acquireWithWait() error = nil after cancellation")
}
}

View File

@@ -97,6 +97,7 @@ type providerAnalyticsContext struct {
VideoSeconds int
RequestKind string
ProviderRequestID string
ConcurrencyLimit int
}
func withProviderAnalytics(ctx context.Context, service *Service, task model.Task) context.Context {
@@ -1274,17 +1275,17 @@ func doBinary(req *http.Request) ([]byte, string, error) {
if open {
return nil, "", errors.New("当前渠道连续失败,已暂时熔断,请稍后重试")
}
var acquired bool
slotID := channelID
if slotID == "" {
slotID = "custom:" + strings.ToLower(req.URL.Host)
}
release, acquired, err = coordinator.acquire(req.Context(), "channel:"+slotID, envInt("CANVAS_CHANNEL_CONCURRENCY", 2), providerHTTPTimeout+time.Minute)
var concurrencyLimit int
release, concurrencyLimit, err = metadata.Service.AcquireChannelSlot(req.Context(), channelID, slotID, providerHTTPTimeout+time.Minute)
metadata.ConcurrencyLimit = concurrencyLimit
req = req.WithContext(context.WithValue(req.Context(), providerAnalyticsKey{}, metadata))
if err != nil {
return nil, "", fmt.Errorf("获取渠道并发配额失败:%w", err)
}
if !acquired {
return nil, "", errors.New("当前渠道并发已满,请稍后重试")
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
defer release()
}
@@ -1356,7 +1357,13 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in
RequestKind: requestKind, Billable: req.Method == http.MethodPost,
APIFormat: "openai", Method: req.Method, Path: req.URL.Path, Model: metadata.Model,
Status: status, StatusCode: statusCode, DurationMs: time.Since(startedAt).Milliseconds(),
Error: errorText, UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path,
Error: errorText, ConcurrencyLimit: metadata.ConcurrencyLimit, UpstreamURL: req.URL.Scheme + "://" + req.URL.Host + req.URL.Path,
}
channelSlotFailure := false
if code, message := ChannelSlotFailureDetails(requestErr); code != "" {
channelSlotFailure = true
log.ErrorCode = code
log.Error = message
}
if requestKind == "create" && metadata.Capability == "video" {
log.VideoSeconds = metadata.VideoSeconds
@@ -1370,8 +1377,10 @@ func recordProviderRequest(req *http.Request, startedAt time.Time, statusCode in
}
metadata.Service.EnrichAPICallLog(&log, responseBody)
if err := metadata.Service.LogAPICall(log); err != nil {
if !channelSlotFailure {
_ = metadata.Service.MarkBillingUncertain(metadata.BillingOrderID, "上游调用日志写入失败,费用状态待核对")
}
}
}
func safeProviderLogError(err error) string {

View File

@@ -160,10 +160,14 @@ func New(repo *repository.Repository, dataDir string) *Service {
func (s *Service) StartWorker() {
go func() {
workerConcurrency := envInt("CANVAS_WORKER_CONCURRENCY", taskWorkerConcurrency)
slots := make(chan struct{}, workerConcurrency)
slots := make(chan struct{}, maxChannelConcurrencyLimit)
dispatch := func() {
for len(slots) < cap(slots) {
setting, err := s.runtimeConcurrencySetting()
if err != nil {
return
}
workerConcurrency := setting.WorkerConcurrency
for len(slots) < workerConcurrency {
releaseGlobal, acquired, err := s.coordinator.acquire(context.Background(), "workers", workerConcurrency, 45*time.Minute)
if err != nil || !acquired {
return
@@ -697,6 +701,10 @@ func (s *Service) processClaimedTask(task *model.Task) error {
result, err = s.persistGeneratedMediaResult(task.UserID, result)
}
if err != nil {
channelSlotFailedBeforeRequest := false
if code, _ := ChannelSlotFailureDetails(err); code != "" {
channelSlotFailedBeforeRequest = true
}
select {
case leaseErr := <-leaseLost:
_ = s.log(task.UserID, task.ID, "warn", "任务租约失效,等待其他 worker 恢复", leaseErr.Error())
@@ -709,7 +717,11 @@ func (s *Service) processClaimedTask(task *model.Task) error {
task.Error = "任务已取消"
task.CompletedAt = ptr(time.Now())
_ = s.repo.Save(task)
if channelSlotFailedBeforeRequest {
_ = s.RefundBilling(task.BillingOrderID, "等待渠道槽位期间取消,上游请求未发出")
} else {
_ = s.MarkBillingUncertain(task.BillingOrderID, "任务取消时上游费用状态不明确")
}
_ = s.markSessionFailed(*task, "会话任务已取消。")
_ = s.log(task.UserID, task.ID, "warn", "任务已取消", "")
return nil
@@ -722,7 +734,7 @@ func (s *Service) processClaimedTask(task *model.Task) error {
task.Error = taskFailureMessage(err)
task.CompletedAt = ptr(time.Now())
_ = s.repo.Save(task)
if providerSucceeded || s.BillingFailureRequiresReview(task.BillingOrderID, task.ID, err) {
if providerSucceeded || (!channelSlotFailedBeforeRequest && s.BillingFailureRequiresReview(task.BillingOrderID, task.ID, err)) {
_ = s.MarkBillingUncertain(task.BillingOrderID, task.Error)
} else {
_ = s.RefundBilling(task.BillingOrderID, task.Error)

View File

@@ -34,6 +34,8 @@ services:
CANVAS_DATABASE_DRIVER: postgres
DATABASE_URL: ${DATABASE_URL:?请先运行一键部署脚本生成数据库连接}
REDIS_URL: redis://redis:6379/0
CANVAS_WORKER_CONCURRENCY: ${CANVAS_WORKER_CONCURRENCY:-3}
CANVAS_CHANNEL_CONCURRENCY: ${CANVAS_CHANNEL_CONCURRENCY:-3}
CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false}
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}

View File

@@ -12,6 +12,8 @@ services:
CANVAS_DATABASE_DRIVER: ${CANVAS_DATABASE_DRIVER:-sqlite}
DATABASE_URL: ${DATABASE_URL:-}
REDIS_URL: ${REDIS_URL:-}
CANVAS_WORKER_CONCURRENCY: ${CANVAS_WORKER_CONCURRENCY:-3}
CANVAS_CHANNEL_CONCURRENCY: ${CANVAS_CHANNEL_CONCURRENCY:-3}
CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false}
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}

View File

@@ -42,6 +42,8 @@ services:
CANVAS_DATABASE_DRIVER: ${CANVAS_DATABASE_DRIVER:-postgres}
DATABASE_URL: ${DATABASE_URL:-}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
CANVAS_WORKER_CONCURRENCY: ${CANVAS_WORKER_CONCURRENCY:-3}
CANVAS_CHANNEL_CONCURRENCY: ${CANVAS_CHANNEL_CONCURRENCY:-3}
CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false}
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}

View File

@@ -12,6 +12,8 @@ services:
CANVAS_DATABASE_DRIVER: ${CANVAS_DATABASE_DRIVER:-sqlite}
DATABASE_URL: ${DATABASE_URL:-}
REDIS_URL: ${REDIS_URL:-}
CANVAS_WORKER_CONCURRENCY: ${CANVAS_WORKER_CONCURRENCY:-3}
CANVAS_CHANNEL_CONCURRENCY: ${CANVAS_CHANNEL_CONCURRENCY:-3}
CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false}
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}

View File

@@ -1,4 +1,4 @@
import { App, Button, Drawer, Form, Input, Select, Switch, Table, Tag } from "antd";
import { App, Button, Drawer, Form, Input, InputNumber, Select, Switch, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Pencil, Plus, Power, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
@@ -14,7 +14,7 @@ import { AdminPageFrame } from "../components/admin-shell";
import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton, configuredSecretText } from "../components/admin-ui";
import { ChannelModelManager } from "../components/channel-model-manager";
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; interfaceType: ChannelInterfaceType; enabled?: boolean };
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; interfaceType: ChannelInterfaceType; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
const interfaceTypeOptions = [
{ label: "文本", options: [{ label: "Chat Completions", value: "chat-completion" }, { label: "OpenAI Responses", value: "openai-response" }] },
@@ -41,6 +41,7 @@ export default function ChannelsPage() {
const [managingChannel, setManagingChannel] = useState<ModelChannel | null>(null);
const requestSequence = useRef(0);
const [form] = Form.useForm<ChannelFormValues>();
const useGlobalConcurrency = Form.useWatch("useGlobalConcurrency", form) !== false;
const hasFilters = Boolean(keyword || interfaceType !== "all" || status !== "all");
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
@@ -85,7 +86,7 @@ export default function ChannelsPage() {
const openDrawer = (channel?: ModelChannel) => {
setEditingChannel(channel || null);
form.resetFields();
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", interfaceType: channel.interfaceType || "newapi", enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", interfaceType: "newapi", enabled: true });
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", interfaceType: channel.interfaceType || "newapi", useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", interfaceType: "newapi", useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
setDrawerOpen(true);
};
@@ -106,7 +107,7 @@ export default function ChannelsPage() {
}
setSaving(true);
try {
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", interfaceType: values.interfaceType, enabled: values.enabled !== false };
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", interfaceType: values.interfaceType, useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
await syncChannels();
setDrawerOpen(false);
@@ -136,6 +137,7 @@ export default function ChannelsPage() {
{ title: "渠道", dataIndex: "name", render: (_, channel) => <div><div className="font-medium">{channel.name}</div><div className="max-w-lg truncate text-xs text-foreground/45">{channel.baseUrl}</div></div> },
{ title: "接口类型", dataIndex: "interfaceType", width: 160, render: (value: ChannelInterfaceType) => <Tag bordered={false} color={value === "newapi-channel-1" ? "green" : value === "newapi" ? "orange" : value === "newapi-channel-2" ? "purple" : value === "xai-video" ? "cyan" : "blue"}>{interfaceTypeLabel(value)}</Tag> },
{ title: "模型", dataIndex: "models", width: 100, render: (models: string[]) => `${models?.length || 0} 个` },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, render: (value: number) => value > 0 ? value : <span className="text-foreground/45">跟随系统</span> },
{ title: "密钥", dataIndex: "hasApiKey", width: 100, render: (configured) => <Tag bordered={false} color={configured ? "success" : "default"}>{configured ? "已配置" : "未配置"}</Tag> },
{ title: "状态", dataIndex: "enabled", width: 100, render: (enabled) => <Tag bordered={false} color={enabled !== false ? "success" : "default"}>{enabled !== false ? "已启用" : "已停用"}</Tag> },
{ title: "操作", width: 160, fixed: "right", align: "right", render: (_, channel) => <AdminRowActions primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }} actions={[{ key: "edit", label: "编辑渠道", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) }, { key: "toggle", label: channel.enabled !== false ? "停用渠道" : "启用渠道", icon: <Power className="size-3.5" />, danger: channel.enabled !== false, confirm: { title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,历史账单和调用记录继续保留。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", okText: channel.enabled !== false ? "确认停用" : "确认启用" }, onClick: () => toggleChannel(channel) }]} /> },
@@ -153,7 +155,7 @@ export default function ChannelsPage() {
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]} />
</ListToolbar>
<TableSurface>
{loading && channels.length === 0 ? <AdminTableSkeleton rows={8} columns={6} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={channels} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} description={hasFilters ? undefined : "创建渠道并配置模型后,普通用户即可使用系统模型。"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 860 }} />}
{loading && channels.length === 0 ? <AdminTableSkeleton rows={8} columns={7} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={channels} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} description={hasFilters ? undefined : "创建渠道并配置模型后,普通用户即可使用系统模型。"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 970 }} />}
</TableSurface>
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} width="min(560px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
<Form form={form} layout="vertical" requiredMark={false}>
@@ -161,6 +163,8 @@ export default function ChannelsPage() {
<Form.Item name="interfaceType" label="接口类型" rules={[{ required: true, message: "请选择接口类型" }]} extra="按生成能力选择实际上游协议;系统渠道统一使用 Bearer 鉴权。"><Select options={interfaceTypeOptions} onChange={(value: ChannelInterfaceType) => { const current = String(form.getFieldValue("baseUrl") || "").trim(); if (!current || current === defaultBaseUrlForChannelInterface()) form.setFieldValue("baseUrl", defaultBaseUrlForChannelInterface(value)); }} /></Form.Item>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
<Form.Item name="apiKey" label={editingChannel ? `API Key(${configuredSecretText})` : "API Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key" }]}><Input.Password placeholder={editingChannel ? "留空保留原密钥" : "系统渠道密钥"} /></Form.Item>
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 100, message: "请输入 1-100 的整数" }]}><InputNumber className="w-full" min={1} max={100} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-100"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Drawer>

View File

@@ -1,5 +1,5 @@
import { Tooltip } from "antd";
import { BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { BarChart3, BellRing, Coins, FileClock, Gauge, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { useState, type ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
@@ -40,6 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{
label: "系统配置",
items: [
{ path: "/admin/settings/concurrency", label: "任务并发", description: "Worker 与渠道请求调度", icon: <Gauge className="size-4" />, secondary: true },
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" />, secondary: true },
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" />, secondary: true },
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" />, secondary: true },

View File

@@ -33,6 +33,7 @@ export function ApiLogDetailDrawer({ logId, onClose }: { logId: string | null; o
["方法与路径", `${log.method} ${log.path}`],
["HTTP 状态", String(log.statusCode || "--")],
["耗时", `${log.durationMs} ms`],
["渠道并发上限", log.concurrencyLimit ? String(log.concurrencyLimit) : "--"],
["Token", log.usageAvailable ? `${log.inputTokens} 输入 / ${log.outputTokens} 输出 / ${log.cachedTokens} 缓存` : "未返回"],
["错误码", log.errorCode || "--"],
["错误详情", log.error || "--"],

View File

@@ -0,0 +1,73 @@
import { App, Button, Form, InputNumber, Tag } from "antd";
import { Gauge, RadioTower, Workflow } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAdminRuntimeConcurrencySetting, updateAdminRuntimeConcurrencySetting, type RuntimeConcurrencySetting } from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { SettingsSectionCard } from "../components/admin-ui";
type ConcurrencyFormValues = Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">;
export default function ConcurrencySettingsPage() {
const { message } = App.useApp();
const { references } = useAdminContext();
const [setting, setSetting] = useState<RuntimeConcurrencySetting | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<ConcurrencyFormValues>();
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
useEffect(() => {
void getAdminRuntimeConcurrencySetting()
.then(({ setting: value }) => {
setSetting(value);
form.setFieldsValue(value);
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取任务并发配置失败"))
.finally(() => setLoading(false));
}, [form, message]);
const save = async () => {
const values = await form.validateFields();
setSaving(true);
try {
const result = await updateAdminRuntimeConcurrencySetting(values);
setSetting(result.setting);
form.setFieldsValue(result.setting);
message.success("任务并发配置已生效");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存任务并发配置失败");
} finally {
setSaving(false);
}
};
return (
<AdminPageFrame title="任务并发" description="Worker 与渠道请求调度">
<div className="mx-auto max-w-5xl space-y-5">
<SettingsSectionCard
icon={<Gauge className="size-4" />}
title="运行时并发"
description="控制任务消费速度和未单独配置渠道的默认请求并发。"
status={<Tag bordered={false} color="blue">热更新</Tag>}
footer={<><div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "当前使用环境变量默认值"}</div><Button type="primary" loading={saving} onClick={() => void save()}>保存并发配置</Button></>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2">
<Form.Item name="workerConcurrency" label="Worker 并发数" extra="控制集群同时执行的后台任务数。" rules={concurrencyRules("Worker 并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<Workflow className="size-3.5 text-foreground/40" />} /></Form.Item>
<Form.Item name="channelConcurrency" label="全局渠道并发数" extra="渠道选择“跟随系统”时使用;自定义渠道也使用该值。" rules={concurrencyRules("全局渠道并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<RadioTower className="size-3.5 text-foreground/40" />} /></Form.Item>
</div>
</Form>
</SettingsSectionCard>
<div className="rounded-lg border border-border bg-muted/25 px-4 py-3 text-xs leading-6 text-foreground/55">任务可以继续进入队列;达到渠道上限时会等待空闲槽位,不会因暂时满载直接失败。单独配置的渠道上限优先于这里的全局值。</div>
</div>
</AdminPageFrame>
);
}
function concurrencyRules(label: string) {
return [{ required: true, message: `请填写${label}` }, { type: "number" as const, min: 1, max: 100, message: `${label}必须是 1-100 的整数` }];
}
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }

View File

@@ -7,6 +7,7 @@ import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsP
import ChannelsPage from "@/pages/admin/channels/channels-page";
import LogsPage from "@/pages/admin/logs/logs-page";
import RedemptionCodesPage from "@/pages/admin/redemption-codes/redemption-codes-page";
import ConcurrencySettingsPage from "@/pages/admin/settings/concurrency-settings-page";
import StorageSettingsPage from "@/pages/admin/settings/storage-settings-page";
import StoryboardPromptsPage from "@/pages/admin/storyboard-prompts/storyboard-prompts-page";
import UsersPage from "@/pages/admin/users/users-page";
@@ -61,7 +62,8 @@ export const router = createBrowserRouter([
{ path: "credit-operations", element: <CreditOperationsPage /> },
{ path: "redemption-codes", element: <RedemptionCodesPage /> },
{ path: "logs", element: <LogsPage /> },
{ path: "settings", element: <Navigate to="access" replace /> },
{ path: "settings", element: <Navigate to="concurrency" replace /> },
{ path: "settings/concurrency", element: <ConcurrencySettingsPage /> },
{ path: "settings/access", element: <AccessSettingsPage /> },
{ path: "settings/email", element: <EmailSettingsPage /> },
{ path: "settings/storage", element: <StorageSettingsPage /> },

View File

@@ -61,6 +61,7 @@ export type ApiCallLog = {
currency?: string;
errorCode?: string;
error?: string;
concurrencyLimit: number;
upstreamUrl: string;
createdAt: string;
};
@@ -196,6 +197,14 @@ export type AdminOSSSetting = {
updatedAt?: string;
};
export type RuntimeConcurrencySetting = {
workerConcurrency: number;
channelConcurrency: number;
updatedBy?: string;
createdAt?: string;
updatedAt?: string;
};
type BackendEnvelope<T> = { code: number; data: T; msg: string };
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
@@ -284,11 +293,11 @@ export function listAdminChannels(params: AdminListParams = {}) {
return request<{ channels: ModelChannel[]; total: number; page: number; limit: number }>(api.get("/admin/channels", { params }));
}
export function createAdminChannel(input: Partial<ModelChannel>) {
export function createAdminChannel(input: Partial<ModelChannel> & { useGlobalConcurrency?: boolean }) {
return request<{ channel: ModelChannel }>(api.post("/admin/channels", input));
}
export function updateAdminChannel(id: string, input: Partial<ModelChannel>) {
export function updateAdminChannel(id: string, input: Partial<ModelChannel> & { useGlobalConcurrency?: boolean }) {
return request<{ channel: ModelChannel }>(api.patch(`/admin/channels/${encodeURIComponent(id)}`, input));
}
@@ -320,6 +329,14 @@ export function updateAdminOSSSetting(input: Partial<AdminOSSSetting>) {
return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input));
}
export function getAdminRuntimeConcurrencySetting() {
return request<{ setting: RuntimeConcurrencySetting }>(api.get("/admin/settings/concurrency"));
}
export function updateAdminRuntimeConcurrencySetting(input: Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">) {
return request<{ setting: RuntimeConcurrencySetting }>(api.patch("/admin/settings/concurrency", input));
}
export function listAdminApiLogs(params: AdminListParams = {}) {
return request<{ logs: ApiCallLog[]; total: number; page: number; limit: number }>(api.get("/admin/api-logs", { params }));
}

View File

@@ -20,6 +20,7 @@ export type ModelChannel = {
scope?: "system" | "user";
enabled?: boolean;
hasApiKey?: boolean;
concurrencyLimit?: number;
modelCosts?: Array<{
model: string;
capability: ModelCapability;