From 56ea269f989cb91d54f49c265e4bb61850344884 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 17 Oct 2026 08:34:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20reap=5Forphan=5Fsources=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E6=B8=85=E7=90=86=20v2.0.28=20=E4=BA=8B?= =?UTF-8?q?=E6=95=85=E7=95=99=E4=B8=8B=E7=9A=84=E5=AD=98=E9=87=8F=E5=AD=A4?= =?UTF-8?q?=E5=84=BF=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 发现路径:查"功能都正常了吗",逐源核对时发现的 生产 `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/` 删掉该孤儿源 (不手工改配置)。清理前备份配置,三重确认它确无对应账号。 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 ⇒ "源根本没注册"看起来像"没有孤儿"。 --- internal/core/channels_orphan_source_test.go | 169 +++++++++++++++++++ internal/lua/plugins/channels.lua | 114 ++++++++++++- 2 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 internal/core/channels_orphan_source_test.go diff --git a/internal/core/channels_orphan_source_test.go b/internal/core/channels_orphan_source_test.go new file mode 100644 index 0000000..9b5fb0b --- /dev/null +++ b/internal/core/channels_orphan_source_test.go @@ -0,0 +1,169 @@ +package core + +import ( + "testing" +) + +// ★ 孤儿源必须能被识别、且**只删该删的**。 +// +// 真实事故(2026-10-17 生产实测):`channels-buddy-585b335b64cf` 的账号早已删除, +// 源却仍在 config.yaml 里对外服务 —— 独占服务 hy4-preview-x(404), +// 对 6 个共享模型则排在正常源**前面**先被尝试(每次白付 ~3.4s 上游往返)。 +// +// 这是 v2.0.28(remove_source 漏声明)的**存量残骸**:修声明只防新增,不清理存量。 +func TestChannelsReapOrphanSources(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatalf("加载插件: %v", err) + } + // 生产里 channels 被授予这些(见 plugin.kernel 声明);夹具必须同样授权, + // 否则 add_source 会被拒 ⇒ 造不出孤儿源。 + if err := ps.SetPluginKernelPermission("channels", + []string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil { + t.Fatalf("授权: %v", err) + } + + // 装载前先清空账号池:这样插件初始化流程自己注册的源也一并算孤儿。 + cleanup := func() interface{} { + r, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{ + "dry_run": false, + }) + if err != nil { + t.Fatalf("reap_orphan_sources: %v", err) + } + return r + } + cleanup() + + // 造出**真实形态**:走 account_add 登记账号(它会注册源), + // 再用 hidden 探针把账号从池里摘掉 —— 模拟 v2.0.28 事故的残留形态 + // (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。 + // 不走 account_delete:那条路径的收尾已被 TestAccountDelete* 覆盖, + // 而我们要的恰恰是"源留下了"这个状态。 + if r, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "buddy", "account_id": "deadbeef1234", "credential": "test-cred", + }); err != nil { + t.Fatalf("account_add: %v", err) + } else if j := jsonOf(t, r); j["error"] != nil { + t.Fatalf("account_add 报错: %v", j["error"]) + } + // ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 —— + // 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-` + // 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。 + // (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开; + // 不含 `-` 的 id 两种实现结果相同,咬不住。) + if r, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "buddy", "account_id": "dead-acct-1", "credential": "test-cred", + }); err != nil { + t.Fatalf("account_add(2): %v", err) + } else if j := jsonOf(t, r); j["error"] != nil { + t.Fatalf("account_add(2) 报错: %v", j["error"]) + } + // 运维手工源:名字与账号源形态无关 ⇒ 绝不碰 + if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{ + "name": "my-handmade-source", "base_url": "http://127.0.0.1:1", + "adapter": "openai", "models": []interface{}{"m1"}, + }); err != nil { + t.Fatalf("注册手工源: %v", err) + } else if j := jsonOf(t, r); j["error"] != nil { + // ★ 必须看 json 里的 error:内核把失败放在返回体里, + // Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。 + t.Fatalf("注册手工源报错: %v", j["error"]) + } + // 摘账号(保留源) + for _, id := range []string{"deadbeef1234", "dead-acct-1"} { + if _, err := ps.Call("channels", "__forget_account_probe", map[string]interface{}{ + "platform": "buddy", "account_id": id, + }); err != nil { + t.Fatalf("摘账号 %s: %v", id, err) + } + } + + // ① dry_run(默认)只报告、不删 + res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + j := jsonOf(t, res) + orphans, _ := j["orphans"].([]interface{}) + seen := map[string]bool{} + for _, o := range orphans { + if s, _ := o.(string); s != "" { + seen[s] = true + } + } + for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} { + if !seen[want] { + t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+ + " 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+ + " 实得 %v", want, seen) + } + } + if seen["my-handmade-source"] { + t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)") + } + if j["dry_run"] != true { + t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"]) + } + if j["removed"].(float64) != 0 { + t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"]) + } + + // ② 确认 dry_run 真的没删(看源列表) + after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + names := sourceNames(jsonOf(t, after)) + if !names["channels-buddy-deadbeef1234"] { + t.Fatalf("dry_run 竟真删了!源列表: %v", names) + } + // 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿) + if !names["my-handmade-source"] { + t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names) + } + + // ③ 真删:孤儿消失、手工源存活 + res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false}) + if err != nil { + t.Fatal(err) + } + j = jsonOf(t, res) + if j["ok"] != true { + t.Fatalf("清理应成功,实得 %v", j) + } + after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + names = sourceNames(jsonOf(t, after)) + for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} { + if names[dead] { + t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names) + } + } + if !names["my-handmade-source"] { + t.Fatalf("★ 误删了运维手工源: %v", names) + } + t.Logf("✓ 孤儿源已清、手工源存活") +} + +// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。 +func sourceNames(j map[string]interface{}) map[string]bool { + out := map[string]bool{} + if arr, ok := j["sources"].([]interface{}); ok { + for _, x := range arr { + if m, ok := x.(map[string]interface{}); ok { + if s, ok := m["name"].(string); ok { + out[s] = true + } + } + } + } + return out +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index bcaccbc..77eb7cf 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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