diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index d42344a..96a31f8 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -873,3 +873,22 @@ s.Start() - **不要丢弃 `s.Start()` 的 error**:Start 失败后测试仍会继续跑,症状表现为断言数值不对或 EOF,与真正原因(服务器没起来)完全无关,极易误导排查方向。 排查信号:测试断言"期望 N 实际 0"且伴随 `EOF`;`GetListenedPort()` 返回 `-1`;同一测试在 Linux CI 通过但本地 Windows 失败。 + +### 2026-09-01(二):channel_proxy_url 双数据源分裂——设置页保存的代理对 relay 渠道转发不生效 + +**问题**:管理后台「系统设置 → 渠道配置 → 代理地址」保存后,渠道编辑中开启「使用代理」的请求并不走该代理。设置页把 `channel_proxy_url` 写入 `sys_options`,但 relay 层(`relay/common/http_client.go` 的 `GetSystemProxyURL`)读的是 `g.Cfg().MustGet(ctx, "channel_proxy_url")`——即 `manifest/config/config.yaml` 配置文件里的**同名 key**。两套数据源互不相通:设置页的值只被 `oauth/client.go`(OAuth 出站)读到,它宣称的主要用途(渠道转发代理、Realtime WebSocket 拨号)从未读到过。 + +**原因**:同一配置 key 被两个模块各自实现了读取逻辑,一个走设置注册表(`sys_options`),一个走 `g.Cfg()`(配置文件),没有收敛到同一加载入口。`g.Cfg().MustGet` 对键不存在不报错、静默返回空串(见 2026-08-31 记录),掩盖了分裂——渠道一直"代理未配置",无人察觉。 + +**修复**: + +1. `relay/common/http_client.go`:`GetSystemProxyURL` 不再读 `g.Cfg()`(含 10s TTL 轮询缓存一并删除),改为读包内 atomic 值,新增 `SetSystemProxyURL` setter; +2. `internal/cmd/cmd.go`:沿用 `SetGlobalRequestTimeoutSeconds` 的桥接注入模式(relay 不反向 import internal,会循环依赖)——启动时 + `OnSettingsChanged("channel_proxy_url")` 时从 `sys_options` 读取并注入,免重启、多实例经 Redis pub/sub 各自刷新; +3. `config.example.yaml` 删除已失效的 `channel_proxy_url` key,配置文件不再承载该配置。 + +**正确做法(通用规则)**: + +- **一个配置 key 只能有一个数据源、一个加载入口**。凡注册进设置注册表(`sys_options`,管理员可改)的 key,所有消费方——包括 relay 这类不 import internal 的独立模块——都必须经同一加载函数取值;独立模块用「setter 注入 + OnSettingsChanged 刷新」桥接(本项目既有模式:全局超时 `SetGlobalRequestTimeoutSeconds`)。 +- **禁止用 `g.Cfg()` 读取任何注册表里的 key**:同名 key 在配置文件里恰好存在时会造成"双真相源",配置文件的值会静默覆盖管理员在后台设置的值(或反之),且 `MustGet` 对键缺失不报错,分裂长期不可见。 + +排查信号:设置页某配置"保存了但不生效";`grep -rn "g.Cfg()"` 命中的 key 同时出现在 `settings_registry.go` 中。 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 13bf4a9..b43be74 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -154,8 +154,8 @@ var ( registerCronJobs(cs) cs.StartBackground(ctx) - // 全局超时配置注入 relay 层(渠道级 settings.timeout_seconds 仍优先,此处为未配置渠道的兜底) - syncGlobalRelayTimeouts(ctx) + // relay 层运行时配置注入(全局超时兜底/系统代理;渠道级 settings 仍优先) + syncGlobalRelaySettings(ctx) // 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron) billing.StartWalletMaterializer(ctx) @@ -385,22 +385,27 @@ func watchChannelAutoTestInterval(cs *common.CronScheduler) { }) } -// syncGlobalRelayTimeouts 将系统设置中的全局超时注入 relay 层,并在配置变更时动态刷新(免重启)。 -// 优先级:渠道级 settings.timeout_seconds > 此处注入的全局值 > relay 层内置默认(180s/300s)。 +// syncGlobalRelaySettings 将系统设置中 relay 层的运行时配置注入 relay 层,并在配置变更时动态刷新(免重启): +// - 全局超时:优先级 渠道级 settings.timeout_seconds > 此处注入的全局值 > relay 层内置默认(180s/300s) +// - 系统代理:channel_proxy_url,渠道编辑中开启「使用代理」的请求经此代理转发 +// // relay 包不反向 import internal(会循环依赖),故由本函数桥接注入。 -func syncGlobalRelayTimeouts(ctx context.Context) { +func syncGlobalRelaySettings(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")) + relaycommon.SetSystemProxyURL(common.Config().GetString(ctx, "channel_proxy_url")) } apply(ctx) common.OnSettingsChanged(func(ctx context.Context, key string) { - if key != "request_timeout_seconds" && key != "streaming_timeout_seconds" { + if key != "request_timeout_seconds" && key != "streaming_timeout_seconds" && key != "channel_proxy_url" { return } apply(ctx) - g.Log().Infof(ctx, "全局 relay 超时已刷新: request=%ds stream_idle=%ds", - relaycommon.GlobalRequestTimeoutSeconds(), relaycommon.GlobalStreamIdleTimeoutSeconds()) + // 代理 URL 可能含认证凭据,只记录是否已配置,不落日志 + g.Log().Infof(ctx, "全局 relay 配置已刷新: request=%ds stream_idle=%ds proxy_configured=%v", + relaycommon.GlobalRequestTimeoutSeconds(), relaycommon.GlobalStreamIdleTimeoutSeconds(), + relaycommon.GetSystemProxyURL() != "") }) } diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 12310b3..46e18e4 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -202,11 +202,11 @@ var Registry = []SettingDef{ {Key: "channel_auto_disable_enabled", Type: SettingTypeBool, Default: "false", Category: "channel", Label: "渠道自动禁用", Description: "渠道熔断持续超过路由策略 breaker.autoDisableAfterSeconds(默认 10 分钟)未恢复时自动禁用"}, {Key: "channel_routing_policy", Type: SettingTypeJSON, Default: "", Category: "channel", - Label: "路由策略覆盖(JSON)", Description: "渠道调度引擎策略,部分字段覆盖内置默认值,保存后 30 秒内热生效;留空使用全部默认。字段说明见 docs/reference/渠道调度运维手册.md"}, + Label: "路由策略覆盖(JSON)", Description: "渠道调度引擎策略,部分字段覆盖内置默认值,保存后 30 秒内热生效;留空使用全部默认。高级功能,非必要不要修改;字段说明见管理后台渠道配置页路由策略的说明弹窗"}, {Key: "health_snapshot_retention_days", Type: SettingTypeInt, Default: "7", Category: "channel", Label: "健康快照保留天数", Validation: "min:1,max:90"}, {Key: "channel_proxy_url", Type: SettingTypeString, Default: "", Category: "channel", - Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求"}, + Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求;OAuth 出站请求同样走此代理。保存后即时生效,无需重启"}, {Key: "sync_image_async_enabled", Type: SettingTypeBool, Default: "false", Category: "channel", Label: "同步图片厂商异步化", Description: "开启后,同步阻塞返回的图片厂商(如 OpenAI/DALL·E)走 /v1/images/generations/async 时由后台 worker 池异步处理,客户端提交即拿 task_id 后轮询取图;关闭则该端点对同步厂商返回不支持。依赖对象存储保存生成结果,开启前需先在「存储配置」中配置对象存储(OSS/S3/COS)"}, {Key: "sync_image_rehost_url", Type: SettingTypeBool, Default: "false", Category: "channel", diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index 12c1a6d..031998c 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -83,9 +83,6 @@ demo: enabled: false message: "演示环境,数据不可修改" -# Channel proxy (for accessing Google/Anthropic etc. from China) -channel_proxy_url: "" - # System update source override # Default: GitHub official API (https://api.github.com/repos/qianfree/team-api). # Override to point at a self-hosted release server / local mock release (see diff --git a/relay/common/http_client.go b/relay/common/http_client.go index da5091f..15680a3 100644 --- a/relay/common/http_client.go +++ b/relay/common/http_client.go @@ -62,33 +62,29 @@ var ( // proxiedState 管理带代理的 HTTP 客户端,按需初始化并在代理 URL 变更时重建。 var proxiedState struct { - mu sync.RWMutex - proxyURL string - transport *http.Transport // ResponseHeaderTimeout=defaultResponseHeaderTimeout(普通) - longRun *http.Transport // ResponseHeaderTimeout=0(长耗时同步) - nonStream *http.Client - stream *http.Client - proxyURLCached atomic.Value // string - cacheTime atomic.Int64 // 上次读取配置的 unix 时间戳(秒) + mu sync.RWMutex + proxyURL string + transport *http.Transport // ResponseHeaderTimeout=defaultResponseHeaderTimeout(普通) + longRun *http.Transport // ResponseHeaderTimeout=0(长耗时同步) + nonStream *http.Client + stream *http.Client + proxyURLValue atomic.Value // string,系统代理 URL(sys_options channel_proxy_url,由 internal 注入) } -const proxyCacheTTL = 10 // seconds +// SetSystemProxyURL 注入系统代理 URL(sys_options 的 channel_proxy_url)。 +// relay 包不 import internal(会循环依赖),由 internal 侧在启动时与配置变更时 +// (OnSettingsChanged,免重启)注入,模式同 SetGlobalRequestTimeoutSeconds。 +func SetSystemProxyURL(proxyURL string) { + proxiedState.proxyURLValue.Store(proxyURL) +} -// GetSystemProxyURL 从系统配置读取 channel_proxy_url(带本地缓存)。 +// GetSystemProxyURL 返回系统代理 URL(sys_options 的 channel_proxy_url,空串=未配置)。 // 导出供 WebSocket 拨号器等非 HTTP 客户端调用方使用。 func GetSystemProxyURL() string { - now := time.Now().Unix() - last := proxiedState.cacheTime.Load() - if now-last < proxyCacheTTL { - if v, ok := proxiedState.proxyURLCached.Load().(string); ok { - return v - } + if v, ok := proxiedState.proxyURLValue.Load().(string); ok { + return v } - - proxyURL := g.Cfg().MustGet(context.Background(), "channel_proxy_url").String() - proxiedState.proxyURLCached.Store(proxyURL) - proxiedState.cacheTime.Store(now) - return proxyURL + return "" } // buildProxiedTransport 构建一个带(可选)代理的传输层,ResponseHeaderTimeout 由 rht 决定。 diff --git a/web/admin/src/views/system/settings/ChannelTab.vue b/web/admin/src/views/system/settings/ChannelTab.vue index 40e1628..caa6787 100644 --- a/web/admin/src/views/system/settings/ChannelTab.vue +++ b/web/admin/src/views/system/settings/ChannelTab.vue @@ -1,6 +1,9 @@ @@ -118,4 +257,136 @@ const values = useFormValues() line-height: 1.6; margin-top: -8px; } +.section-title-with-help { + display: flex; + align-items: center; + gap: 6px; +} +.help-icon { + color: var(--color-text-3); + cursor: pointer; + transition: color 0.2s; + flex: 0 0 auto; +} +.help-icon:hover { + color: rgb(var(--arcoblue-6)); +} + +/* 路由策略说明弹窗(样式惯例对齐 ChannelsPage 调度说明弹窗) */ +.policy-guide { + color: var(--color-text-1); + font-size: 13px; + line-height: 1.7; + max-height: 65vh; + overflow-y: auto; + padding-right: 6px; +} +.policy-guide .guide-intro { + margin: 0 0 14px; + color: var(--color-text-2); +} +.policy-guide .guide-section { + margin-bottom: 16px; +} +.policy-guide .guide-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font-weight: 600; +} +.policy-guide .guide-index { + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + flex: 0 0 22px; + border-radius: 50%; + color: rgb(var(--arcoblue-6)); + background: var(--color-primary-light-1); + font-size: 12px; +} +.policy-guide p { + margin: 0; +} +.policy-guide p + p { + margin-top: 6px; +} +.policy-guide code { + padding: 1px 4px; + border-radius: 3px; + color: rgb(var(--arcoblue-6)); + background: var(--color-primary-light-1); +} +.guide-warning { + display: flex; + gap: 8px; + padding: 10px 12px; + margin-bottom: 14px; + border-radius: 6px; + background: var(--color-warning-light-1); +} +.guide-warning .warning-icon { + color: rgb(var(--warning-6)); + flex: 0 0 auto; + margin-top: 3px; +} +.guide-rules { + margin: 0; + padding-left: 20px; +} +.guide-rules li { + margin-bottom: 4px; +} +.policy-table-wrap { + overflow-x: auto; +} +.policy-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} +.policy-table th, +.policy-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--color-fill-2); + text-align: left; + vertical-align: top; +} +.policy-table th { + font-weight: 600; + white-space: nowrap; + background: var(--color-fill-1); +} +.policy-table td:first-child { + white-space: nowrap; + font-weight: 500; + color: var(--color-text-1); +} +.policy-guide .guide-example { + padding: 14px 16px; + border-radius: 6px; + background: var(--color-fill-2); +} +.policy-guide .guide-example-title { + margin-bottom: 6px; + font-weight: 600; +} +.policy-guide .guide-example p { + margin: 8px 0 0; +} +.policy-guide pre { + margin: 8px 0 0; + padding: 12px 14px; + border-radius: 6px; + background: var(--color-fill-1); + overflow-x: auto; + font-size: 12.5px; +} +.policy-guide pre code { + padding: 0; + background: none; + color: inherit; +}