From 3c749c6c0a6209e767d0d577db1ee0b86eb8ecf7 Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 1 Sep 2026 23:11:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(setting):=E7=A7=BB=E9=99=A4=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E5=A4=9A=E4=B8=AA=E6=9C=AA=E7=94=9F=E6=95=88?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tenant/tenant.go | 2 - api/tenant/v1/playground.go | 40 ------- internal/cmd/cmd.go | 43 +++++++- internal/consts/consts.go | 23 ++-- .../controller/tenant/tenant_v1_playground.go | 15 --- internal/logic/billing/ratelimit.go | 50 +++++---- internal/logic/common/audit_log_writer.go | 17 ++- internal/logic/common/settings_registry.go | 44 +++----- internal/logic/tenant/oauth.go | 31 ++++-- internal/logic/tenant/playground.go | 104 ------------------ internal/service/tenant.go | 2 - relay/common/relay_info.go | 52 ++++++++- relay/helper/stream_scanner.go | 25 +++-- .../src/views/system/settings/AuditTab.vue | 21 +--- .../system/settings/DataGovernanceTab.vue | 3 - .../src/views/system/settings/GeneralTab.vue | 3 + .../views/system/settings/PerformanceTab.vue | 47 +------- 17 files changed, 202 insertions(+), 320 deletions(-) delete mode 100644 api/tenant/v1/playground.go delete mode 100644 internal/controller/tenant/tenant_v1_playground.go delete mode 100644 internal/logic/tenant/playground.go diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index 6319729..c56fc98 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -133,8 +133,6 @@ type ITenantV1 interface { TenantPlanList(ctx context.Context, req *v1.TenantPlanListReq) (res *v1.TenantPlanListRes, err error) TenantPlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (res *v1.TenantPlanCurrentRes, err error) TenantPlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (res *v1.TenantPlanCancelAutoRenewRes, err error) - SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (res *v1.SandboxChatRes, err error) - SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (res *v1.SandboxQuotaRes, err error) TenantPluginList(ctx context.Context, req *v1.TenantPluginListReq) (res *v1.TenantPluginListRes, err error) TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDetailReq) (res *v1.TenantPluginDetailRes, err error) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (res *v1.TenantPluginConfigUpdateRes, err error) diff --git a/api/tenant/v1/playground.go b/api/tenant/v1/playground.go deleted file mode 100644 index c133a74..0000000 --- a/api/tenant/v1/playground.go +++ /dev/null @@ -1,40 +0,0 @@ -package v1 - -import "github.com/gogf/gf/v2/frame/g" - -// PlaygroundMessage 对话消息(Playground 和 Sandbox 共用) -type PlaygroundMessage struct { - Role string `json:"role" v:"required|in:system,user,assistant#请指定角色|角色无效"` - Content string `json:"content" v:"required#消息内容不能为空"` -} - -// ============================================================ -// Sandbox(模拟调用,不计费) -// ============================================================ - -// SandboxChatReq Sandbox 对话请求 -type SandboxChatReq struct { - g.Meta `path:"/sandbox/chat" method:"post" mime:"json" tags:"租户控制台-Playground" summary:"Sandbox模拟对话"` - Model string `json:"model" v:"required#请选择模型" dc:"模型名称"` - Messages []PlaygroundMessage `json:"messages" v:"required|length:1,50#请输入消息|消息数量超出限制" dc:"对话消息列表"` - Temperature *float64 `json:"temperature" dc:"温度参数 (0-2)"` - MaxTokens *int `json:"max_tokens" dc:"最大输出 Token 数"` - Stream bool `json:"stream" d:"true" dc:"是否流式响应"` -} - -type SandboxChatRes struct { - Content string `json:"content"` - IsSandbox bool `json:"is_sandbox"` - RemainingQuota int `json:"remaining_quota" dc:"本月剩余沙箱额度"` -} - -// SandboxQuotaReq 沙箱额度查询 -type SandboxQuotaReq struct { - g.Meta `path:"/sandbox/quota" method:"get" tags:"租户控制台-Playground" summary:"查询沙箱额度"` -} - -type SandboxQuotaRes struct { - TotalQuota int `json:"total_quota"` - RemainingQuota int `json:"remaining_quota"` - UsedQuota int `json:"used_quota"` -} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 88a6a1a..13bf4a9 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -7,6 +7,7 @@ import ( "fmt" "io/fs" "net/http" + "net/http/pprof" "strings" "time" @@ -34,6 +35,7 @@ import ( "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/response" "github.com/qianfree/team-api/internal/utility/crypto" + relaycommon "github.com/qianfree/team-api/relay/common" adminHandler "github.com/qianfree/team-api/internal/handler/admin" "github.com/qianfree/team-api/internal/handler/landing" @@ -120,7 +122,7 @@ var ( common.InitChannelErrorWriter() // Initialize async audit log writer - common.InitAuditLogWriter() + common.InitAuditLogWriter(ctx) // Initialize async channel debug log writer + 注入 relay 层提交钩子 common.InitChannelDebugLogWriter() @@ -129,12 +131,32 @@ var ( // Initialize async error log writer response.InitErrorLogWriter() + // 按配置拉起 pprof 调试端口(「性能配置-调试」):仅监听本机回环,禁止公网暴露; + // 端口在进程启动时读取,变更后需重启生效 + if port := common.Config().GetInt(ctx, "pprof_port"); port > 0 && port <= 65535 { + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + if listenErr := http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", port), mux); listenErr != nil { + g.Log().Warningf(ctx, "pprof server exited: %v", listenErr) + } + }() + g.Log().Infof(ctx, "pprof listening on 127.0.0.1:%d", port) + } + // Register cron jobs common.InitCronScheduler() cs := common.GetCronScheduler() registerCronJobs(cs) cs.StartBackground(ctx) + // 全局超时配置注入 relay 层(渠道级 settings.timeout_seconds 仍优先,此处为未配置渠道的兜底) + syncGlobalRelayTimeouts(ctx) + // 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron) billing.StartWalletMaterializer(ctx) @@ -363,6 +385,25 @@ func watchChannelAutoTestInterval(cs *common.CronScheduler) { }) } +// syncGlobalRelayTimeouts 将系统设置中的全局超时注入 relay 层,并在配置变更时动态刷新(免重启)。 +// 优先级:渠道级 settings.timeout_seconds > 此处注入的全局值 > relay 层内置默认(180s/300s)。 +// relay 包不反向 import internal(会循环依赖),故由本函数桥接注入。 +func syncGlobalRelayTimeouts(ctx context.Context) { + apply := func(ctx context.Context) { + relaycommon.SetGlobalRequestTimeoutSeconds(common.Config().GetInt(ctx, "request_timeout_seconds")) + relaycommon.SetGlobalStreamIdleTimeoutSeconds(common.Config().GetInt(ctx, "streaming_timeout_seconds")) + } + apply(ctx) + common.OnSettingsChanged(func(ctx context.Context, key string) { + if key != "request_timeout_seconds" && key != "streaming_timeout_seconds" { + return + } + apply(ctx) + g.Log().Infof(ctx, "全局 relay 超时已刷新: request=%ds stream_idle=%ds", + relaycommon.GlobalRequestTimeoutSeconds(), relaycommon.GlobalStreamIdleTimeoutSeconds()) + }) +} + // registerCronJobs 集中注册所有定时任务,避免散落在主启动流程中。 // 新增 cron 任务时在此追加一项即可;任务名(用于分布式锁 key)需保持唯一且稳定。 func registerCronJobs(cs *common.CronScheduler) { diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 98c4549..08d5e2d 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -150,18 +150,17 @@ const ( CodeInvalidConfirmToken = 10046 // Security errors (10047+) - CodeTotpRequired = 10047 - CodeTotpInvalid = 10048 - CodeTotpAlreadyEnabled = 10049 - CodeTotpNotEnabled = 10050 - CodeBackupCodeInvalid = 10051 - CodeTurnstileFailed = 10052 - CodeIpRestricted = 10053 - CodeHighRisk2FARequired = 10054 - CodeNoAvailableApiKey = 10055 - CodeSandboxQuotaExceeded = 10056 - CodeCaptchaRequired = 10057 - CodeCaptchaFailed = 10058 + CodeTotpRequired = 10047 + CodeTotpInvalid = 10048 + CodeTotpAlreadyEnabled = 10049 + CodeTotpNotEnabled = 10050 + CodeBackupCodeInvalid = 10051 + CodeTurnstileFailed = 10052 + CodeIpRestricted = 10053 + CodeHighRisk2FARequired = 10054 + CodeNoAvailableApiKey = 10055 + CodeCaptchaRequired = 10057 + CodeCaptchaFailed = 10058 // OAuth errors (10059+) CodeOAuthDisabled = 10059 diff --git a/internal/controller/tenant/tenant_v1_playground.go b/internal/controller/tenant/tenant_v1_playground.go deleted file mode 100644 index 8da7cec..0000000 --- a/internal/controller/tenant/tenant_v1_playground.go +++ /dev/null @@ -1,15 +0,0 @@ -package tenant - -import ( - "context" - - v1 "github.com/qianfree/team-api/api/tenant/v1" - "github.com/qianfree/team-api/internal/service" -) - -func (c *ControllerV1) SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (res *v1.SandboxChatRes, err error) { - return service.Tenant().SandboxChat(ctx, req) -} -func (c *ControllerV1) SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (res *v1.SandboxQuotaRes, err error) { - return service.Tenant().SandboxQuota(ctx, req) -} diff --git a/internal/logic/billing/ratelimit.go b/internal/logic/billing/ratelimit.go index da78aaf..64ca3e1 100644 --- a/internal/logic/billing/ratelimit.go +++ b/internal/logic/billing/ratelimit.go @@ -26,6 +26,7 @@ type RateLimitConfig struct { TenantQPS int // 租户级 QPS 限制(默认 1000) UserQPS int // 用户级 QPS 限制(默认 100) KeyQPS int // Key 级 QPS 限制(默认 60) + SystemConc int // 系统级并发限制(默认 0,不限制)——全平台在途请求总闸门 TenantConc int // 租户级并发限制(默认 0,不限制) } @@ -35,6 +36,7 @@ var DefaultRateLimitConfig = RateLimitConfig{ TenantQPS: 1000, UserQPS: 100, KeyQPS: 60, + SystemConc: 0, TenantConc: 0, } @@ -64,6 +66,9 @@ func LoadRateLimitConfig(ctx context.Context) RateLimitConfig { if v := cfg.GetInt(ctx, "key_qps_limit"); v > 0 { c.KeyQPS = v } + if v := cfg.GetInt(ctx, "global_concurrency_limit"); v > 0 { + c.SystemConc = v + } if v := cfg.GetInt(ctx, "tenant_concurrency_limit"); v > 0 { c.TenantConc = v } @@ -207,20 +212,20 @@ func withKeyQPS(config RateLimitConfig, keyQPS int) RateLimitConfig { } // --------------------------------------------------------------------------- -// 并发限流:单个 Lua 脚本完成 2 级获取 + 失败回滚(租户 + 模型) +// 并发限流:单个 Lua 脚本完成 3 级获取 + 失败回滚(系统 + 租户 + 模型) // --------------------------------------------------------------------------- -// acquireConcurrentLua 一次 EVAL 完成 2 级并发许可获取(租户 + 模型)。 -// KEYS[1..2]: tenant/model 的 Redis key -// ARGV[1..2]: 各级并发限制(0 表示不检查该级) -// ARGV[3]: EXPIRE 秒数 +// acquireConcurrentLua 一次 EVAL 完成 3 级并发许可获取(系统 + 租户 + 模型)。 +// KEYS[1..3]: system/tenant/model 的 Redis key +// ARGV[1..3]: 各级并发限制(0 表示不检查该级) +// ARGV[4]: EXPIRE 秒数 // // 逐级尝试获取,任一级失败则回滚已获取的许可。 // 返回 1=成功, 0=失败。 var acquireConcurrentLua = ` -local keys = {KEYS[1], KEYS[2]} -local limits = {tonumber(ARGV[1]), tonumber(ARGV[2])} -local expire = tonumber(ARGV[3]) +local keys = {KEYS[1], KEYS[2], KEYS[3]} +local limits = {tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])} +local expire = tonumber(ARGV[4]) local acquired = {} local function tryAcquire(i) @@ -243,16 +248,17 @@ local function tryAcquire(i) return true end -if not tryAcquire(1) then return 0 end -if not tryAcquire(2) then return 0 end +for i = 1, 3 do + if not tryAcquire(i) then return 0 end +end return 1 ` -// releaseConcurrentLua 一次 EVAL 释放 2 级并发许可(租户 + 模型)。 +// releaseConcurrentLua 一次 EVAL 释放 3 级并发许可(系统 + 租户 + 模型)。 // ARGV[1] = expire 秒数(用于 SET 后恢复 TTL) var releaseConcurrentLua = ` local expire = tonumber(ARGV[1]) -for i = 1, 2 do +for i = 1, 3 do local v = redis.call("DECR", KEYS[i]) if v < 0 then redis.call("SET", KEYS[i], 0) @@ -262,11 +268,14 @@ end return 1 ` -// AcquireConcurrent 获取并发许可(两级:租户→模型) +// AcquireConcurrent 获取并发许可(三级:系统→租户→模型) +// 系统级并发限制来自全局配置 global_concurrency_limit(0 表示不限), +// 是全平台在途请求总闸门(Redis 计数跨实例共享,多副本天然生效); // 租户级并发限制优先使用 tnt_tenants.max_concurrency,0 表示不限; // 模型级并发限制使用 mdl_tenant_models.max_concurrency,NULL 表示不限; // 若未设置则回退到全局配置 tenant_concurrency_limit。 func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, userID, apiKeyID int64, modelName string) bool { + systemKey := "conc:system" tenantKey := fmt.Sprintf("conc:tenant:%d", tenantID) modelKey := fmt.Sprintf("conc:tenant:%d:model:%s", tenantID, modelName) @@ -274,9 +283,9 @@ func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, us tenantLimit := getTenantConcurrencyLimit(ctx, tenantID) modelLimit := getModelConcurrencyLimit(ctx, tenantID, modelName) - result, err := g.Redis().Do(ctx, "EVAL", acquireConcurrentLua, 2, - tenantKey, modelKey, - tenantLimit, modelLimit, + result, err := g.Redis().Do(ctx, "EVAL", acquireConcurrentLua, 3, + systemKey, tenantKey, modelKey, + config.SystemConc, tenantLimit, modelLimit, 300) // EXPIRE 秒数 if err != nil { return true // Redis 不可用,允许通过 @@ -285,14 +294,15 @@ func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, us return result.Int() == 1 } -// ReleaseConcurrent 释放并发许可(租户 + 模型两级) -// 单次 EVAL 释放所有 2 个 key。 +// ReleaseConcurrent 释放并发许可(系统 + 租户 + 模型三级) +// 单次 EVAL 释放所有 3 个 key。 func ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) { + systemKey := "conc:system" tenantKey := fmt.Sprintf("conc:tenant:%d", tenantID) modelKey := fmt.Sprintf("conc:tenant:%d:model:%s", tenantID, modelName) - g.Redis().Do(ctx, "EVAL", releaseConcurrentLua, 2, - tenantKey, modelKey, + g.Redis().Do(ctx, "EVAL", releaseConcurrentLua, 3, + systemKey, tenantKey, modelKey, 300) // EXPIRE 秒数 } diff --git a/internal/logic/common/audit_log_writer.go b/internal/logic/common/audit_log_writer.go index 6e549d4..9f426f9 100644 --- a/internal/logic/common/audit_log_writer.go +++ b/internal/logic/common/audit_log_writer.go @@ -1,6 +1,7 @@ package common import ( + "context" "time" ) @@ -10,12 +11,22 @@ var DefaultAuditLogWriter *UsageLogWriter // InitAuditLogWriter 初始化审计日志批量写入器 // 审计日志与大模型请求 1:1,量级与 bil_usage_logs 相当,必须走批量通道避免单条 INSERT 打爆审计库。 // 配置了 database.audit 独立审计库时经 DB getter 路由过去,否则回退主库(与 AuditModelCtx 一致)。 -func InitAuditLogWriter() { +// 批量参数来自「性能配置-批量写入」(sys_options,重启后生效);未配置或非法时回落内置默认。 +func InitAuditLogWriter(ctx context.Context) { + batchSize := Config().GetInt(ctx, "batch_write_size") + if batchSize <= 0 { + batchSize = 64 + } + flushInterval := time.Duration(Config().GetInt(ctx, "batch_write_interval_ms")) * time.Millisecond + if flushInterval <= 0 { + flushInterval = 1 * time.Second + } + DefaultAuditLogWriter = NewUsageLogWriter(UsageLogWriterConfig{ Table: "aud_request_logs", QueueSize: 8192, - BatchSize: 64, - FlushInterval: 1 * time.Second, + BatchSize: batchSize, + FlushInterval: flushInterval, Workers: 2, Overflow: OverflowSyncFallback, DB: GetAuditDB, diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 22efeb2..12310b3 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -75,6 +75,8 @@ var Registry = []SettingDef{ Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true}, {Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general", Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"}, + {Key: "site_url", Type: SettingTypeString, Default: "", Category: "general", + Label: "站点后端地址", Description: "平台后端 API 的对外访问地址,如 https://api.example.com,用于构造 OAuth 登录回调地址(须与 GitHub/Google 应用后台登记的回调域名一致);为空时按当前请求 Host 推断"}, {Key: "pricing_time_timezone", Type: SettingTypeString, Default: "Asia/Shanghai", Category: "general", Label: "计费时区", Description: "模型时段定价(峰谷/促销)按此时区解释,IANA 时区名,如 Asia/Shanghai"}, @@ -128,19 +130,11 @@ var Registry = []SettingDef{ Label: "验证码有效期(秒)", Validation: "min:60,max:600"}, {Key: "register_forbidden_words", Type: SettingTypeString, Default: "admin,system,root,api,test,administrator,管理员,系统", Category: "security", Label: "注册禁用词", Description: "组织名称、组织代码、用户名中包含这些词时禁止注册(不区分大小写),多个禁用词用英文逗号分隔"}, - {Key: "sandbox_enabled", Type: SettingTypeBool, Default: "true", Category: "performance", - Label: "启用沙箱模式"}, - {Key: "sandbox_default_quota", Type: SettingTypeInt, Default: "100", Category: "performance", - Label: "沙箱每月默认额度"}, // ── Audit ── {Key: "audit_level", Type: SettingTypeString, Default: "full", Category: "audit", Label: "全局审计级别", Validation: "enum:full,full_text,masked,question_only,none", Description: "full=完整记录, full_text=全量文本, masked=脱敏记录, question_only=仅提问, none=不记录"}, - {Key: "audit_retention_days", Type: SettingTypeInt, Default: "90", Category: "audit", - Label: "审计日志保留天数", Validation: "min:7,max:3650"}, - {Key: "operation_log_retention_days", Type: SettingTypeInt, Default: "365", Category: "audit", - Label: "操作日志保留天数", Validation: "min:30,max:3650"}, // ── Payment ── {Key: "payment_amount_options", Type: SettingTypeJSON, Default: "[10,20,50,100,200,500]", Category: "payment", @@ -167,32 +161,26 @@ var Registry = []SettingDef{ Label: "用户级 QPS 上限", Validation: "min:0"}, {Key: "key_qps_limit", Type: SettingTypeInt, Default: "60", Category: "performance", Label: "Key 级 QPS 上限", Validation: "min:0"}, + {Key: "global_concurrency_limit", Type: SettingTypeInt, Default: "0", Category: "performance", + Label: "系统级并发上限", Validation: "min:0", + Description: "全平台同时在途请求的总闸门(多副本共享计数);流式长请求占用连接数分钟,QPS 限流拦不住堆积,须靠并发上限兜底;0=不限制,保存后约 5 秒生效"}, {Key: "tenant_concurrency_limit", Type: SettingTypeInt, Default: "0", Category: "performance", Label: "租户级并发上限", Validation: "min:0"}, - {Key: "request_timeout_seconds", Type: SettingTypeInt, Default: "120", Category: "performance", - Label: "请求超时(秒)", Validation: "min:5,max:600"}, - {Key: "batch_write_size", Type: SettingTypeInt, Default: "64", Category: "performance", - Label: "批量写入大小", Validation: "min:1,max:1000"}, - {Key: "channel_affinity_ttl", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "渠道亲和性TTL(秒)", Validation: "min:0,max:3600"}, - {Key: "auto_test_interval", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "渠道自动测试间隔(秒)", Validation: "min:60,max:86400"}, + {Key: "request_timeout_seconds", Type: SettingTypeInt, Default: "180", Category: "performance", + Label: "请求超时(秒)", Validation: "min:5,max:600", + Description: "非流式请求的全局默认总超时;渠道级超时设置优先,未配置的渠道使用此值,保存后即时生效"}, {Key: "streaming_timeout_seconds", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "流式超时(秒)", Validation: "min:30,max:3600", - Description: "流式响应最大持续时间"}, - {Key: "cache_enabled", Type: SettingTypeBool, Default: "true", Category: "performance", - Label: "缓存开关", Description: "是否启用 API Key 缓存"}, - {Key: "cache_ttl_seconds", Type: SettingTypeInt, Default: "300", Category: "performance", - Label: "缓存过期时间(秒)", Validation: "min:30,max:86400", - Description: "API Key 缓存有效期"}, + Label: "流式空闲超时(秒)", Validation: "min:30,max:3600", + Description: "流式响应两个数据块之间的最大空闲间隔(非总时长)的全局默认;渠道级设置优先,保存后即时生效"}, + {Key: "batch_write_size", Type: SettingTypeInt, Default: "64", Category: "performance", + Label: "批量写入大小", Validation: "min:1,max:1000", + Description: "审计日志批量落库条数,重启后生效"}, {Key: "batch_write_interval_ms", Type: SettingTypeInt, Default: "1000", Category: "performance", Label: "批量写入间隔(ms)", Validation: "min:100,max:30000", - Description: "日志批量写入的间隔时间"}, - {Key: "global_concurrency_limit", Type: SettingTypeInt, Default: "1000", Category: "performance", - Label: "系统级并发上限", Validation: "min:0"}, + Description: "审计日志批量落库周期,重启后生效"}, {Key: "pprof_port", Type: SettingTypeInt, Default: "0", Category: "performance", Label: "pprof 端口", Validation: "min:0,max:65535", - Description: "性能分析端口,0=关闭"}, + Description: "性能分析端口,仅监听本机回环(127.0.0.1),0=关闭,重启后生效"}, // ── Content Filter ── {Key: "content_filter_mode", Type: SettingTypeString, Default: "off", Category: "content_filter", @@ -257,8 +245,6 @@ var Registry = []SettingDef{ Label: "临时数据保留天数", Validation: "min:1,max:30"}, {Key: "data_export_expiry_days", Type: SettingTypeInt, Default: "7", Category: "data_governance", Label: "数据导出文件保留天数", Validation: "min:1,max:30"}, - {Key: "data_deletion_completion_days", Type: SettingTypeInt, Default: "30", Category: "data_governance", - Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"}, {Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance", Label: "启用文件保留期检查"}, {Key: "file_image_retention_days", Type: SettingTypeInt, Default: "0", Category: "data_governance", diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index f3a204c..0016651 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -68,6 +68,27 @@ func getOAuthSetting(ctx context.Context, key string) string { return common.Config().GetOption(ctx, key) } +// getOAuthRedirectBase 返回构造 OAuth redirect_uri 的基址。 +// 优先级:site_url(管理后台「基础配置-站点后端地址」,应与 GitHub/Google 应用后台 +// 登记的回调域名一致)> 按当前 API 请求自身 Host 推断。 +// 授权跳转与 token 交换两个阶段的 redirect_uri 必须完全一致(OAuth 规范强制校验), +// 因此兜底取后端 API 请求的 Host 而非前端 Origin——授权与回调都是打向后端的请求, +// 同一部署下 Host 推断结果天然一致。 +func getOAuthRedirectBase(ctx context.Context) string { + base := strings.TrimRight(getOAuthSetting(ctx, "site_url"), "/") + if base != "" { + return base + } + if r := g.RequestFromCtx(ctx); r != nil { + scheme := "https" + if r.GetHeader("X-Forwarded-Proto") == "http" || strings.HasPrefix(r.GetHost(), "localhost") || strings.HasPrefix(r.GetHost(), "127.0.0.1") { + scheme = "http" + } + return fmt.Sprintf("%s://%s", scheme, r.GetHost()) + } + return "" +} + // encryptOAuthToken encrypts a token string for secure storage. func encryptOAuthToken(ctx context.Context, token string) string { if token == "" { @@ -113,10 +134,7 @@ func (s *sTenant) GetOAuthAuthorizeURL(ctx context.Context, req *v1.OAuthAuthori return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用") } - siteURL := getOAuthSetting(ctx, "site_url") - if siteURL == "" { - siteURL = "http://localhost:3000" - } + siteURL := getOAuthRedirectBase(ctx) redirectURI := siteURL + fmt.Sprintf("/api/tenant/oauth/%s/callback", req.Provider) state := generateState() @@ -599,10 +617,7 @@ func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuth v.Set("code", code) v.Set("client_id", getOAuthSetting(ctx, "oauth_google_client_id")) v.Set("client_secret", getOAuthSetting(ctx, "oauth_google_client_secret")) - siteURL := getOAuthSetting(ctx, "site_url") - if siteURL == "" { - siteURL = "http://localhost:3000" - } + siteURL := getOAuthRedirectBase(ctx) v.Set("redirect_uri", siteURL+"/api/tenant/oauth/google/callback") v.Set("grant_type", "authorization_code") diff --git a/internal/logic/tenant/playground.go b/internal/logic/tenant/playground.go deleted file mode 100644 index 1507fc2..0000000 --- a/internal/logic/tenant/playground.go +++ /dev/null @@ -1,104 +0,0 @@ -package tenant - -import ( - "context" - "fmt" - "time" - - "github.com/gogf/gf/v2/frame/g" - - v1 "github.com/qianfree/team-api/api/tenant/v1" - "github.com/qianfree/team-api/internal/logic/common" - "github.com/qianfree/team-api/internal/middleware" -) - -// ============================================================ -// Sandbox(模拟调用,不计费) -// ============================================================ - -func (s *sTenant) SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (*v1.SandboxChatRes, error) { - tenantID := middleware.GetTenantID(ctx) - - now := time.Now() - quotaKey := fmt.Sprintf("sandbox:quota:%d:%s", tenantID, now.Format("200601")) - remaining, err := g.Redis().Do(ctx, "GET", quotaKey) - if err != nil { - return nil, err - } - - defaultQuota := g.Cfg().MustGet(ctx, "sandbox.sandbox_default_quota").Int() - if defaultQuota <= 0 { - defaultQuota = 100 - } - - remainInt := defaultQuota - if !remaining.IsNil() && !remaining.IsEmpty() { - remainInt = remaining.Int() - } - - if remainInt <= 0 { - return nil, common.NewBusinessError(10056, "本月沙箱额度已用完") - } - - _, err = g.Redis().Do(ctx, "DECR", quotaKey) - if err != nil { - return nil, err - } - if remainInt == defaultQuota { - _, _ = g.Redis().Do(ctx, "EXPIRE", quotaKey, 86400*30) - } - - content := generateSimulatedResponse(req.Model, req.Messages) - - return &v1.SandboxChatRes{ - Content: content, - IsSandbox: true, - RemainingQuota: remainInt - 1, - }, nil -} - -func (s *sTenant) SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (*v1.SandboxQuotaRes, error) { - tenantID := middleware.GetTenantID(ctx) - - defaultQuota := g.Cfg().MustGet(ctx, "sandbox.sandbox_default_quota").Int() - if defaultQuota <= 0 { - defaultQuota = 100 - } - - now := time.Now() - quotaKey := fmt.Sprintf("sandbox:quota:%d:%s", tenantID, now.Format("200601")) - remaining, err := g.Redis().Do(ctx, "GET", quotaKey) - if err != nil { - return nil, err - } - - remainInt := defaultQuota - if !remaining.IsNil() && !remaining.IsEmpty() { - remainInt = remaining.Int() - } - - used := defaultQuota - remainInt - if used < 0 { - used = 0 - } - - return &v1.SandboxQuotaRes{ - TotalQuota: defaultQuota, - RemainingQuota: remainInt, - UsedQuota: used, - }, nil -} - -func generateSimulatedResponse(model string, messages []v1.PlaygroundMessage) string { - userMsg := "" - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "user" { - userMsg = messages[i].Content - break - } - } - if userMsg == "" { - userMsg = "你的请求" - } - return fmt.Sprintf("这是来自模型 %s 的模拟响应。您发送的消息是:%q\n\n(沙箱模式:此响应为模拟数据,不会产生实际费用)", model, userMsg) -} diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 399f968..164b128 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -260,8 +260,6 @@ type ( PlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (*v1.TenantPlanCurrentRes, error) // PlanCancelAutoRenew 取消自动续费 PlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (*v1.TenantPlanCancelAutoRenewRes, error) - SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (*v1.SandboxChatRes, error) - SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (*v1.SandboxQuotaRes, error) TenantPluginList(ctx context.Context, req *v1.TenantPluginListReq) (*v1.TenantPluginListRes, error) TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDetailReq) (*v1.TenantPluginDetailRes, error) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (*v1.TenantPluginConfigUpdateRes, error) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 36ba205..e41f707 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -3,6 +3,7 @@ package common import ( "context" "net/http" + "sync/atomic" "time" "github.com/qianfree/team-api/relay/constant" @@ -38,15 +39,60 @@ func (m *ChannelMeta) UpstreamSpeaksResponses() bool { } const ( - // DefaultTimeoutSeconds 非流式请求的默认总超时(http.Client.Timeout)。 + // DefaultTimeoutSeconds 非流式请求的内置默认总超时(http.Client.Timeout)。 // 推理模型(GLM-4.5/4.6 thinking、Claude thinking 等)非流式调用需跑完整段生成才回响应头, // 60s 会误杀;放宽到 180s。个别仍超时的模型建议在渠道级 settings.timeout_seconds 配 >180(自动切 longRun 传输层)。 + // 该常量仅作系统设置 request_timeout_seconds 未配置时的最终兜底。 DefaultTimeoutSeconds = 180 ImagesGenerationTimeoutSecs = 600 + + // DefaultStreamIdleTimeoutSeconds 流式响应的内置默认空闲超时(两个 chunk 之间的最大间隔,非总时长)。 + // 仅作系统设置 streaming_timeout_seconds 未配置时的最终兜底。 + DefaultStreamIdleTimeoutSeconds = 300 ) +// 全局超时配置(sys_options request_timeout_seconds / streaming_timeout_seconds), +// 0 表示未配置、回落内置默认。relay 包不 import internal(会循环依赖), +// 由 internal 侧在启动时与配置变更时(OnSettingsChanged,免重启)注入。 +var ( + globalRequestTimeoutSeconds atomic.Int64 + globalStreamIdleTimeoutSeconds atomic.Int64 +) + +// SetGlobalRequestTimeoutSeconds 注入全局非流式请求超时(<=0 视为未配置)。 +func SetGlobalRequestTimeoutSeconds(seconds int) { + if seconds < 0 { + seconds = 0 + } + globalRequestTimeoutSeconds.Store(int64(seconds)) +} + +// GlobalRequestTimeoutSeconds 返回全局非流式请求超时(未配置返回内置默认 180s)。 +func GlobalRequestTimeoutSeconds() int { + if v := int(globalRequestTimeoutSeconds.Load()); v > 0 { + return v + } + return DefaultTimeoutSeconds +} + +// SetGlobalStreamIdleTimeoutSeconds 注入全局流式空闲超时(<=0 视为未配置)。 +func SetGlobalStreamIdleTimeoutSeconds(seconds int) { + if seconds < 0 { + seconds = 0 + } + globalStreamIdleTimeoutSeconds.Store(int64(seconds)) +} + +// GlobalStreamIdleTimeoutSeconds 返回全局流式空闲超时(未配置返回内置默认 300s)。 +func GlobalStreamIdleTimeoutSeconds() int { + if v := int(globalStreamIdleTimeoutSeconds.Load()); v > 0 { + return v + } + return DefaultStreamIdleTimeoutSeconds +} + // GetTimeoutSeconds 返回请求超时秒数。 -// 图片生成模式强制最低 600s(即使渠道配置了更短的自定义超时),其余模式渠道自定义优先。 +// 优先级:渠道自定义 > 全局配置(系统设置)> 内置默认。图片生成模式强制最低 600s(即使渠道配置了更短的自定义超时)。 func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int { if constant.RelayMode(relayMode) == constant.RelayModeImagesGenerations { if s.TimeoutSeconds > ImagesGenerationTimeoutSecs { @@ -57,7 +103,7 @@ func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int { if s.TimeoutSeconds > 0 { return s.TimeoutSeconds } - return DefaultTimeoutSeconds + return GlobalRequestTimeoutSeconds() } // ChannelSettings 渠道配置(来自 chn_channels.settings JSONB) diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 8a66be7..fdc092e 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -16,15 +16,14 @@ import ( ) const ( - defaultStreamTimeout = 300 * time.Second // 两个 chunk 之间的最大空闲间隔(非总超时) - defaultPingInterval = 15 * time.Second - dataChanSize = 10 - stopChanSize = 3 - shutdownWaitTimeout = 5 * time.Second - minLineLength = 6 - scannerInitialBuf = 64 * 1024 - scannerMaxBuf = 10 * 1024 * 1024 - resetChanSize = 8 + defaultPingInterval = 15 * time.Second + dataChanSize = 10 + stopChanSize = 3 + shutdownWaitTimeout = 5 * time.Second + minLineLength = 6 + scannerInitialBuf = 64 * 1024 + scannerMaxBuf = 10 * 1024 * 1024 + resetChanSize = 8 ) // DataHandlerFunc data 处理回调 @@ -246,15 +245,17 @@ shutdown: } } -// getStreamTimeout 获取流式空闲超时时间(两个 chunk 之间的最大间隔),不低于 defaultStreamTimeout。 +// getStreamTimeout 获取流式空闲超时时间(两个 chunk 之间的最大间隔)。 +// 优先级:渠道自定义(须大于全局值才生效)> 全局配置(系统设置 streaming_timeout_seconds)> 内置默认。 func getStreamTimeout(info *common.RelayInfo) time.Duration { + global := time.Duration(common.GlobalStreamIdleTimeoutSeconds()) * time.Second if info.ChannelMeta != nil && info.ChannelMeta.Settings.TimeoutSeconds > 0 { t := time.Duration(info.ChannelMeta.Settings.TimeoutSeconds) * time.Second - if t > defaultStreamTimeout { + if t > global { return t } } - return defaultStreamTimeout + return global } // safeSendBool 安全发送到 stopChan(recover 避免向已关闭 channel 写入) diff --git a/web/admin/src/views/system/settings/AuditTab.vue b/web/admin/src/views/system/settings/AuditTab.vue index 39ab27a..b613687 100644 --- a/web/admin/src/views/system/settings/AuditTab.vue +++ b/web/admin/src/views/system/settings/AuditTab.vue @@ -21,26 +21,7 @@ const values = useFormValues() - -
-
日志保留
-
- - - - - - -
-
+ diff --git a/web/admin/src/views/system/settings/DataGovernanceTab.vue b/web/admin/src/views/system/settings/DataGovernanceTab.vue index ed7c52b..bfe2598 100644 --- a/web/admin/src/views/system/settings/DataGovernanceTab.vue +++ b/web/admin/src/views/system/settings/DataGovernanceTab.vue @@ -35,9 +35,6 @@ function set(key: string, v: number | undefined): void { - - - diff --git a/web/admin/src/views/system/settings/GeneralTab.vue b/web/admin/src/views/system/settings/GeneralTab.vue index 55a07c6..254afa2 100644 --- a/web/admin/src/views/system/settings/GeneralTab.vue +++ b/web/admin/src/views/system/settings/GeneralTab.vue @@ -39,6 +39,9 @@ function handleMaintenanceModeChange(enabled: boolean) { + + + diff --git a/web/admin/src/views/system/settings/PerformanceTab.vue b/web/admin/src/views/system/settings/PerformanceTab.vue index 8dc2cd0..77bf211 100644 --- a/web/admin/src/views/system/settings/PerformanceTab.vue +++ b/web/admin/src/views/system/settings/PerformanceTab.vue @@ -54,27 +54,11 @@ function set(key: string, v: number | undefined): void {
- +
- -
-
缓存设置
-
- - -
-
- - -
-
-
批量写入
@@ -88,35 +72,6 @@ function set(key: string, v: number | undefined): void {
- -
-
渠道调度
-
- - -
-
- - -
-
- - -
-
沙箱模式
-
- - -
-
- - -
-
-
调试