feat(channels): reap_orphan_sources —— 清理 v2.0.28 事故留下的存量孤儿源

## 发现路径:查"功能都正常了吗",逐源核对时发现的
生产 `config.yaml` 里有 21 个源,其中 `channels-buddy-585b335b64cf`
的账号 `585b335b64cf` **早已删除**(v2.0.23/24 的无凭据账号清理),
但它的源**仍在对外服务**:

- 独占提供 `hy4-preview-x` ⇒ 实测报 `404 Route Not Found`;
- 对 **6 个共享模型**则排在新源**前面**先被尝试(config 文件序即注册序)
  ⇒ 每次都撞 404 再回退,**每次白付一次上游往返**(实测 hy4-preview
  稳定 3.1~3.5s);
- 它的 base_url 还是 v2.0.30 之前的旧口径
  (`https://copilot.tencent.com/v2/chat/completions`,侵占了 endpoint 路径)。

**根因**:这正是 v2.0.28(`remove_source` 漏声明 ⇒ 撤源全被拒)日记里
那句"账号删了、它的源还在对外服务"的**存量残骸**。
v2.0.28 修了声明,**只防新增、不清理存量**。

**发现的一个助力**:v2.0.38 刚修好的错误文案让它暴露得更清楚 ——
客户端拿到的是 `404 Route Not Found`(APISIX 原文)而不是 `unknown error`。

## 修法
1. **立即清理**:走内核正式 API `DELETE /api/sources/<name>` 删掉该孤儿源
   (不手工改配置)。清理前备份配置,三重确认它确无对应账号。
2. **结构性修复**:新增 `reap_orphan_sources` 正式入口
   (`POST channels/reap_orphan_sources`,admin),识别
   `channels-<已知平台>-<账号id>` 且账号**不在**池中的源。

### 安全性(破坏性操作两条纪律,照 `reap_loopbacks` 先例)
- **只删该删的**:按**已知平台 id 前缀**逐个匹配。
  ⚠️ **不能用** `name:match("^channels%-(.+)-(.+)$")` —— Lua 的 `-` 是**贪婪**的,
  会把 `channels-buddy-buddy-main` 切成 plat="buddy-buddy"/acct="main"
  (账号 id 含 `-` 就错,而 `buddy-main` 就含)⇒ **孤儿漏杀**。
  运维手工配的源、旧版单账号源 `channels-<平台>` 一律不碰。
- **`dry_run` 默认开启**:不带 `dry_run=false` 只报告不删。

## 判据(`channels_orphan_source_test.go`,变异 2/2)
- 造出**真实形态**:`account_add` 建账号 → hidden 探针摘池(保留源),
  模拟"源留下"这个状态(不走 `account_delete`:那条路径会把源一并撤掉,
  而我们要的恰恰是它留下的形态)。
- 断言三件事:dry_run 只报告不删、真删后孤儿消失、**手工源全程存活**。
- **判别性用例**:第二个孤儿的账号 id 含 `-`(`dead-acct-1`)。
  这是唯一能区分"按已知平台前缀匹配"与"贪婪 match"的用例 ——
  实测不含 `-` 的 id 两种实现结果相同,**咬不住**(第一版就栽在这)。
- 变异:① `dry_run` 默认改 false ⇒ 报红;② 改回贪婪 match ⇒ 报红。

## ★ 过程中踩到的三个坑(都已记进代码注释)
1. **`Plugins.Call` 的参数是平铺的,不是 `req.body`** —— `bodyOf(req)` 返回空表,
   `dry_run=false` 被静默丢成默认 true ⇒ "真删"永远没发生。
   必须同时兜住两种调用形态(HTTP 路由走 body,插件 Call 平铺)。
2. **内核 `add_source` 取 `args["source"]`(嵌套一层)**,平铺字段被拒
   ("add_source requires a source table")。
3. **`config.Model` 要求 models 是对象数组 `{id=...}`**,传字符串数组被拒。
4. **判据只查 Go 侧 `err` 会静默通过**:内核把失败放在**返回体**里,
   Go `err` 仍是 nil ⇒ "源根本没注册"看起来像"没有孤儿"。
This commit is contained in:
JianFeeeee
2026-10-17 08:34:21 +08:00
parent 6eeb5832a3
commit 091f790734
2 changed files with 282 additions and 1 deletions

View File

@ -116,7 +116,7 @@ plugin.kernel = {
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
@ -6955,6 +6955,8 @@ plugin.http = {
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
{ path = "channels/reap_loopbacks", method = "POST", handler = "reap_loopbacks",
roles = { "admin" }, description = "回收所有回环回调接收器(防测试/异常退出后泄漏)" },
{ path = "channels/reap_orphan_sources", method = "POST", handler = "reap_orphan_sources",
roles = { "admin" }, description = "清理孤儿源(账号已删但源仍在对外服务;默认 dry_run)" },
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
roles = { "admin" }, description = "回收服务端浏览器栈" },
}
@ -8413,6 +8415,116 @@ end
-- 测试残留 —— 生产路径的会被正常回收(因为生产会话真会过期)。
--
-- 对生产也有用:浏览器/授权页关掉后想立刻清理,不必等会话过期。
-- plugin.__add_source_for_test / __forget_account_probe:只给测试用。
--
-- 为什么要它们:孤儿源(账号已删、源留下)是 v2.0.28 事故的**存量形态**,
-- 而正常删除路径(account_delete)会把源一并撤掉 ⇒ 无法用它复现该状态。
-- 两个探针各自只做一件事,且都只在 __ 前缀(不暴露为 HTTP 路由)。
function plugin.__add_source_for_test(args)
args = args or {}
-- ⚠️ 内核 actionAddSource 取的是 args["source"](**嵌套一层**)——
-- 直接平铺字段会被拒("add_source requires a source table")。
-- 实测踩到:我的第一版写平铺,于是源根本没注册,而判据只看到"没有孤儿"。
-- ⚠️ 内核 config.Model 要求 models 是**对象数组** `{id=...}`,
-- 传字符串数组会被拒("source shape is invalid: cannot unmarshal string
-- into Source.models.0")。归一化,免得调用方要懂内核结构。
local ms = {}
for _, m in ipairs(args.models or {}) do
if type(m) == "table" then ms[#ms + 1] = m else ms[#ms + 1] = { id = m } end
end
local r, err = __llmsproxy_kernel.call("add_source", {
source = {
name = args.name, base_url = args.base_url, adapter = args.adapter,
models = ms,
},
})
if r == nil or r.ok ~= true then
return { json = { error = tostring(err or "add_source 失败") } }
end
return { json = { ok = true } }
end
function plugin.__forget_account_probe(args)
args = args or {}
local plat = platformById(args.platform)
if not plat then return { json = { error = "未知平台" } } end
poolRemove(plat.id, args.account_id)
return { json = { ok = true } }
end
-- reap_orphan_sources:清理**孤儿源** —— 名为 `channels-<平台>-<账号id>`
-- 但账号池里**已无**该账号的源。
--
-- ★ 为什么需要它(真实事故,2026-10-17 于生产实测):
-- v2.0.28 修好了 `remove_source` 漏声明,但**修复之前**凡是走过
-- `account_delete` 的账号,它的源都因为撤源被拒而**留了下来**。
-- 孤儿源没有对应账号 ⇒ 没凭据 ⇒ 每次调用必然失败,但它们**仍在对外服务**:
-- 实测 `channels-buddy-585b335b64cf`(账号早已删除)仍在 config.yaml 里,
-- 独占提供 `hy4-preview-x`,对 6 个共享模型则排在新源**前面**先被尝试 ——
-- 每次都撞 404 再回退(实测每次白付 ~3.1-3.5s 上游往返)。
-- ⇒ 这正是 v2.0.28 那句"账号删了、它的源还在对外服务"的**存量残骸**:
-- 修声明只防新增,**不清理存量**。故需要这个显式一次性入口。
--
-- ★ 安全性(破坏性操作的两条纪律,照 reap_loopbacks 的先例):
-- ① **只删该删的**:名字必须**严格**匹配某个**已知平台**的
-- `channels-<平台>-<账号id>`,且该账号确实不在池中。
-- ⚠️ 不能用 `name:match("^channels%-(.+)-(.+)$")` —— Lua 的 `-` 是**贪婪**的,
-- 会把 `channels-buddy-buddy-main` 切成 plat="buddy-buddy"/acct="main"
-- (账号 id 里含 `-` 就错,而 buddy-main 就含)。按已知平台前缀逐个匹配。
-- 运维手工配的源(任何其它名字)一律不碰;旧版单账号源 `channels-<平台>`
-- 也不碰(那是 account_delete 的收尾职责,且可能有意保留作兼容)。
-- ② **dry_run 默认开启**:不带 `dry_run=false` 只报告不删。
function plugin.reap_orphan_sources(req)
-- ⚠️ 两种调用形态都要兜住(本仓库的既有约定,见 account_add 等):
-- * HTTP 路由:参数在 req.body(JSON 字符串)
-- * 插件 Call(测试/内部):参数**直接就是** req,bodyOf 会返回空表
-- ⇒ 只读 bodyOf 会把 `dry_run=false` 静默丢成默认的 true(**实测评据
-- 就是这么失败的**:dry_run 永远是 true,所以"真删"根本没发生)。
local args = bodyOf(req) or {}
if args.dry_run == nil then args = req or {} end
local dry = args.dry_run
if dry == nil then dry = true end
if dry ~= false then dry = true end
local ls = __llmsproxy_kernel.call("list_sources", {})
local arr = ls and ls.json or ls
if type(arr) ~= "table" then arr = {} end
local found, removed, failed = {}, 0, {}
for _, x in ipairs(arr) do
local nm = type(x) == "table" and x.name or nil
if type(nm) == "string" then
local plat, acct = nil, nil
for _, p in ipairs(PLATFORMS) do
local pref = "channels-" .. p.id .. "-"
if nm:sub(1, #pref) == pref then
plat, acct = p.id, nm:sub(#pref + 1)
break
end
end
if plat and acct ~= "" and poolGet(plat, acct) == nil then
found[#found + 1] = nm
if not dry then
local r, err = __llmsproxy_kernel.call("remove_source", { name = nm })
if r == nil or r.ok ~= true then
failed[#failed + 1] = nm .. ": " .. tostring(err or "(无错误信息)")
logEvent("孤儿源清理失败 " .. nm .. ": " .. tostring(err or "(无错误信息)"),
"error", plat)
else
removed = removed + 1
logEvent("孤儿源已清理 " .. nm .. "(账号 " .. acct .. " 已不在池中)", "warn", plat)
end
end
end
end
end
return { status = 200, json = {
ok = #failed == 0, dry_run = dry,
orphan_count = #found, orphans = found,
removed = removed, failed = failed,
} }
end
function plugin.reap_loopbacks(req)
local n = 0
for sid in pairs(plugin.state.loopbacks or {}) do