fix(setting):渠道代理改为系统配置而不是配置文件

This commit is contained in:
qian committed 2026-09-01 23:34:14 +08:00
1 parent 3c749c6c0a
commit 4d0e2901d9
6 files changed
+325 -37

No files matched your search

+19
View File
@@ -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` 中。
+13 -8
View File
@@ -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() != "")
})
}
+2 -2
View File
@@ -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",
-3
View File
@@ -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
+17 -21
View File
@@ -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 决定。
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { IconExclamationCircle } from '@arco-design/web-vue/es/icon'
import { useFormValues } from './useSettings'
const values = useFormValues()
const showPolicyGuide = ref(false)
</script>
<template>
@@ -55,7 +58,10 @@ const values = useFormValues()
<!-- 路由策略 -->
<div class="section">
<div class="section-title">路由策略</div>
<div class="section-title section-title-with-help">
路由策略
<IconExclamationCircle class="help-icon" :size="14" @click="showPolicyGuide = true" />
</div>
<AFormItem label="路由策略覆盖(JSON)">
<ATextarea
:model-value="values['channel_routing_policy'] as string"
@@ -67,7 +73,8 @@ const values = useFormValues()
</AFormItem>
<div class="section-desc">
渠道调度引擎策略,部分字段覆盖内置默认值,保存后 30 秒内热生效;留空使用全部默认。
保存时会校验 JSON 合法性与取值范围。字段说明见 docs/reference/渠道调度运维手册.md。
保存时会校验 JSON 合法性与取值范围。<b>此为高级功能,非必要不要修改</b>;
点击标题旁的感叹号图标可查看字段说明与配置示例。
</div>
</div>
@@ -83,7 +90,7 @@ const values = useFormValues()
/>
</AFormItem>
<div class="section-desc">
全局代理 URL,支持 http:// 和 socks5:// 协议。在渠道编辑中开启"使用代理"即可生效。
全局代理 URL,支持 http:// 和 socks5:// 协议。在渠道编辑中开启"使用代理"即可生效,保存后即时生效,无需重启。
</div>
</div>
@@ -107,6 +114,138 @@ const values = useFormValues()
/>
</div>
</div>
<!-- 路由策略功能说明弹窗 -->
<AModal v-model:visible="showPolicyGuide" title="路由策略覆盖说明" :width="720" :footer="false">
<div class="policy-guide">
<div class="guide-warning">
<IconExclamationCircle :size="16" class="warning-icon" />
<div>
<b>高级功能,非必要不要修改。</b>
此处参数直接影响线上渠道调度行为,内置默认值已适用于绝大多数场景,调整前请先理解各参数含义。
</div>
</div>
<p class="guide-intro">
这是渠道调度引擎的总参数配置:调度器如何挑选渠道、失败如何重试、何时熔断,均由这份 JSON 控制。
留空 = 全部使用内置默认值;填写的字段会浅覆盖到默认值之上(只写要改的字段)。
保存时按 Schema 校验,非法配置直接拒绝;保存后最长 30 秒内热生效,无需重启。
</p>
<div class="guide-section">
<div class="guide-title"><span class="guide-index">1</span>调度打分公式</div>
<p>调度器为每个请求给候选渠道打分,按权重加权随机选择:</p>
<p><code>W(c) = baseWeight × tierFactor × healthFactor × headroom^γ × costFactor × rampFactor × protoFactor</code></p>
</div>
<div class="guide-section">
<div class="guide-title"><span class="guide-index">2</span>可覆盖字段与默认值</div>
<div class="policy-table-wrap">
<table class="policy-table">
<thead>
<tr>
<th>字段组</th>
<th>关键字段(默认值)</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>tierFactors</td>
<td>primary 1.0 / secondary 0.15 / reserve 0.02</td>
<td>渠道层级流量偏置:备用层级平时几乎不接流量,主级全挂时才扩组兜底</td>
</tr>
<tr>
<td>health</td>
<td>alpha 2 / latRefMs 3000</td>
<td>成功率指数(越大越惩罚低成功率渠道);延迟基准(超过开始降权)</td>
</tr>
<tr>
<td>load</td>
<td>gamma 2 / leaseSeconds 90</td>
<td>余量指数(越满的渠道越少接新请求);并发租约时长</td>
</tr>
<tr>
<td>cost</td>
<td>beta 0.5 / min 0.5 / max 2.0</td>
<td>成本偏好(越大越偏向便宜渠道)及影响幅度上下限</td>
</tr>
<tr>
<td>ramp</td>
<td>windowSeconds 120 / floor 0.05</td>
<td>新渠道、熔断恢复渠道的小流量爬坡</td>
</tr>
<tr>
<td>binding</td>
<td>ttlSeconds 1800 等</td>
<td>会话→渠道亲和绑定的有效期与守卫阈值</td>
</tr>
<tr>
<td>retry</td>
<td>原地 2 / 换渠道 2 / 凭证轮换 1 次;总时限 30s</td>
<td>失败重试预算、退避曲线(100~1000ms)、429 等待上限(2000ms)</td>
</tr>
<tr>
<td>breaker</td>
<td>渠道级 8 / 模型级 4 次;冷却 30~300s;autoDisableAfterSeconds 600</td>
<td>熔断阈值与冷却;autoDisableAfterSeconds 即本页「渠道自动禁用」的持续时长阈值</td>
</tr>
<tr>
<td>session</td>
<td>headerName X-Session-Id 等</td>
<td>会话亲和的解析来源(Claude metadata.user_id、Responses previous_response_id)</td>
</tr>
<tr>
<td>replay</td>
<td>unsafeModes / safeModes</td>
<td>禁止盲目重放的端点(默认图片/视频生成,防止重复扣费)</td>
</tr>
<tr>
<td>proto</td>
<td>responsesMismatch 0.25 / chatBridgeMismatch 0.75</td>
<td>入站协议与渠道能力不匹配时的降权因子</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="guide-section">
<div class="guide-title"><span class="guide-index">3</span>书写规则</div>
<ol class="guide-rules">
<li><b>浅覆盖</b>:嵌套对象只覆盖写出的字段,如 <code>{ "breaker": { "failThreshold": 5 } }</code> 只改这一项,同组其余保持默认</li>
<li><code>tierFactors</code> 按键合并:只写 <code>secondary</code> 不影响 <code>primary</code> / <code>reserve</code></li>
<li>⚠️ 数组字段(<code>replay.unsafeModes</code> / <code>safeModes</code>)是<b>整体替换</b>:要改必须写全完整列表,漏一项等于把它移出保护</li>
<li>字段名使用 camelCase(如 <code>latRefMs</code>,不是 <code>lat_ref_ms</code>)</li>
<li>清空文本框 = 恢复全部内置默认(空串合法)</li>
</ol>
</div>
<div class="guide-section">
<div class="guide-title"><span class="guide-index">4</span>校验规则(保存时拦截)</div>
<p>
常见拒绝原因:<code>tierFactors.primary</code> ≤ 0 或层级名不合法(仅 primary / secondary / reserve);
<code>health.alpha</code> ≤ 0;<code>cost.min</code> &gt; <code>cost.max</code>;重试预算为负;
<code>backoffMaxMs</code> &lt; <code>backoffBaseMs</code>;<code>cooldownMaxSeconds</code> &lt; <code>cooldownSeconds</code>;proto 因子不在 (0,1]。
</p>
</div>
<div class="guide-example">
<div class="guide-example-title">配置示例</div>
<pre><code>{
"version": 1,
"tierFactors": { "secondary": 0.3 },
"breaker": { "failThreshold": 5, "autoDisableAfterSeconds": 300 },
"retry": { "totalDeadlineSeconds": 45, "failoverBudget": 3 },
"binding": { "ttlSeconds": 900 }
}</code></pre>
<p>
含义:备用渠道流量偏置翻倍;60 秒窗口 5 次失败即熔断;熔断 5 分钟不恢复自动禁用;
重试总时限放宽到 45 秒、最多换 3 个渠道;会话亲和绑定缩短到 15 分钟。
</p>
<p>再如 <code>{ "health": { "latRefMs": 1500 }, "cost": { "beta": 1.0 } }</code>:延迟超过 1.5 秒即开始降权、更偏向便宜渠道。</p>
</div>
</div>
</AModal>
</div>
</template>
@@ -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;
}
</style>