fix(sources): 源编辑不再清零 proxy_url / api_key_env / timeout

bf0657b 修好了 api_key,但 upsert 仍会重写整个源,于是请求无法表达的字段
一律被重置为零值。这四个字段的后果都不是"少个配置项":

  - api_key_env 丢失 ⇒ 盘上无明文密钥的源变成无凭据源,写操作返回 200,
    下一次调用上游才 401。而 README 恰恰把这个特性当作卖点在宣传。
  - proxy_url 丢失 ⇒ 一个走代理的上游变成直连(或反之),且完全无声。
  - timeout / queue_timeout 丢失 ⇒ 退回默认 120s / 60s。

触发路径不是只有脚本:WebUI 的 saveSource() 发的 payload 只含表单上的
11 个字段,而 editSource() 表单里根本没有这 4 项 ⇒ 运维在界面上改个并发数
就会静默清掉它们。

修法用「存在性」语义而不是「空即继承」:

  - 不传   → 保留已存值(部分更新的客户端要的就是这个)
  - 传了   → 覆盖,包括传空串表示清空

api_key 刻意保留它原有的「空即继承」规则,不跟着改成指针:该规则已随
v1.7.6 发布,脚本依赖它;而凭据丢失比代理丢失严重得多。两个字段的失败
模式相反,所以规则相反——这一条写进了两处注释。

另一处是差一点的:config.Source 把 Timeout/QueueTimeout 标成 json:"-",
所以 reveal 接口的结构体序列化**根本不返回它们**。表单读不到 → 输入框
恒空 → 而输入框每次都回传 → 每存一次就把 timeout 清零。等于把刚修好的
丢字段换个方向又造了一个。因此 reveal 分支现在显式返回 duration 字符串。

判据:
  - TestWebUIEditPayloadPreservesRoutingFields 用的是 WebUI 真实 payload
    的逐字节副本,并同时断言"确实改动的字段生效",否则"什么都不写"也能过
  - TestSourceEditPreservesAPIKeyEnv 单独盯 api_key_env(唯一造成凭据丢失的)
  - CanBeSet / CanBeCleared 分别盯两个方向:只有"空即继承"的实现过不了
    CanBeCleared(清空代理框会永远保留旧代理)
  - 持久化判据**重新加载 config.yaml 并按语义比对**:300s 会被重新序列化成
    5m0s,按字符串匹配是假红(我自己先踩了一次)
  - TestSourcePayloadCoversEveryEditableField 用反射卡住"这一类":新增
    Source 字段而没接到 API 上时立刻变红。反射查结构体而非 marshal 结果,
    因为指针 + omitempty 会合法地从序列化输出里消失,那正是"未提及"信号
  - 三个 UI 契约判据把 JS 侧也钉住(表单必须回传、必须从 reveal 读)

变异验证(每次都先确认 build 通过,再数红格):
  1. 去掉覆盖逻辑        → 7 个判据红
  2. 改成"空即继承"      → CanBeCleared + ClearIsScoped 红
  3. reveal 不返回 duration → TestSourceRevealExposesDurations 红
  4. 表单不回传 api_key_env → TestUIEditFormRoundTrips... 红

顺带修正 /api/v1 索引:DELETE /api/keys 的路径段写的是 {name},实际是 key
本身;PUT /api/keys/{key} 实现了却没列。

全量 + vet + race 全绿;WebUI 内联脚本过 node --check。
This commit is contained in:
JianFeeeee
2026-10-01 20:36:48 +08:00
parent 980f4a0e40
commit 18e422a943
5 changed files with 785 additions and 5 deletions

View File

@ -904,6 +904,12 @@
mConc: "并发上限",
mRPM: "RPM 限速 (0=不限)",
mTemp: "温度",
mKeyEnv: "Key 环境变量",
mKeyEnvPh: "优先于 API Key,不落盘明文",
mProxy: "代理 URL",
mProxyPh: "如 http://127.0.0.1:7890,留空直连",
mTimeout: "请求超时",
mTimeoutPh: "如 300s,留空用默认 120s",
mModels: "模型列表",
mAddModel: "+ 模型",
mMeta: "Meta",
@ -1135,6 +1141,12 @@
mConc: "Max concurrency",
mRPM: "RPM limit (0 = unlimited)",
mTemp: "Temperature",
mKeyEnv: "Key env var",
mKeyEnvPh: "Takes precedence over API Key; nothing written to disk",
mProxy: "Proxy URL",
mProxyPh: "e.g. http://127.0.0.1:7890 — empty means direct",
mTimeout: "Request timeout",
mTimeoutPh: "e.g. 300s — empty uses the 120s default",
mModels: "Models",
mAddModel: "+ model",
mMeta: "Meta",
@ -2866,7 +2878,11 @@
</div>
<div class="row">
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
<div></div>
<div><label>${t("mProxy")}</label><input id="s-proxy" value="${escAttr(s.proxy_url || "")}" placeholder="${t("mProxyPh")}"></div>
</div>
<div class="row">
<div><label>${t("mKeyEnv")}</label><input id="s-keyenv" value="${escAttr(s.api_key_env || "")}" placeholder="${t("mKeyEnvPh")}"></div>
<div><label>${t("mTimeout")}</label><input id="s-timeout" value="${escAttr(timeoutText(s.timeout))}" placeholder="${t("mTimeoutPh")}"></div>
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
@ -2896,6 +2912,21 @@
<button class="ghost del small" onclick="this.closest('.model-row').remove()">&times;</button>
</div>`;
}
// timeoutText renders a stored Go duration for the timeout input.
//
// The API returns timeout as a json:"-"
// field (time.Duration), so the JSON number it arrives as is NANOSECONDS,
// not seconds. Showing the raw number would offer "300000000000" in a box
// that expects "300s"; an unset timeout (0) must show as empty so the
// box reads as "default" rather than "0s".
function timeoutText(v) {
const n = typeof v === "string" ? parseFloat(v) : v;
if (!n || !isFinite(n) || n <= 0) return "";
const secs = Math.round(n / 1e9);
if (secs % 3600 === 0) return secs / 3600 + "h";
if (secs % 60 === 0) return secs / 60 + "m";
return secs + "s";
}
function addModelRow() {
const div = $("#s-models");
div.insertAdjacentHTML(
@ -2930,6 +2961,14 @@
temperature: parseFloat($("#s-temp").value) || 0,
models,
meta,
// These four are always SENT, so the server can tell "the user left
// this empty on purpose" from "this form does not know about it".
// Omitting them made the server keep the stored value, which is right
// for an API client with a partial payload but wrong here: emptying
// the proxy box would have kept the old proxy forever.
proxy_url: $("#s-proxy").value.trim(),
api_key_env: $("#s-keyenv").value.trim(),
timeout: $("#s-timeout").value.trim(),
};
btn.disabled = true;
try {