From ade9c122ebdd82adffc7b22a4d74a2c90e767108 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 07:45:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=97=A0=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=9A=84=E6=89=B9=E9=87=8F=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=88dry=5Frun=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=BC=80=E5=90=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么需要 `account_add` 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"), 于是"先建占位、再走登录补齐"是正常用法。但登录没走完时(用户放弃、或历史批量 导入)会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数 (面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"难以判断。 实测生产:13 个账号里只有 1 个真正可用 —— codearts 10 个 + trae 2 个全是占位 (同秒批量创建、nickname=id、凭据库里无对应条目)。 ## 安全性(破坏性操作,两条性质都被变异验证钉住) ① **只删没有凭据的**:有凭据的一律不动(哪怕它测试失败 —— 那是"可疑"而非 "空壳",处置完全不同); ② **dry_run 默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删; ③ 复用 `plugin.account_delete`(同一份删除实现)—— 删除时要顺带做的事 (drop source、清旧单账号源、删凭据)不另写一套,否则必然漂移。 ## ★ 踩到 Lua 顶层 local 上限(第 13 次同类) helper 写成顶层 `local function accountHasCredential` 后 `luac -p` 直接报 `too many local variables (limit is 200)`。改为挂在 plugin 表上(`plugin.__hasCredential`) —— 同样只有一份实现,且不占顶层名额。**每次给本文件加顶层 local 前必须 luac -p。** ## 判据(3 条 + 变异 2/2) internal/core/channels_clear_credentialless_test.go - 只删空壳、保留有凭据的,且**有凭据者的凭据必须仍在**(防误删) - 指定 platform 时范围隔离(不动别的平台) - 未知平台必须报错(否则会误删全部平台) 变异:无条件全删 → 报红;dry_run 默认改 false → 报红。 --- .../channels_clear_credentialless_test.go | 195 ++++++++++++++++++ internal/lua/plugins/channels.lua | 81 +++++++- 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 internal/core/channels_clear_credentialless_test.go diff --git a/internal/core/channels_clear_credentialless_test.go b/internal/core/channels_clear_credentialless_test.go new file mode 100644 index 0000000..e85043f --- /dev/null +++ b/internal/core/channels_clear_credentialless_test.go @@ -0,0 +1,195 @@ +package core + +import ( + "testing" + + "llmsproxy/internal/lua" +) + +// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。 +// +// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、 +// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数, +// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用 +// (codearts 10 + trae 2 全是占位)。 +// +// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。 +func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) { + h := newAccountCleanupHarness(t) + + // ① 默认 dry_run:必须**不删**任何东西 + pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + preJ := jsonOf(t, pre) + if preJ["dry_run"] != true { + t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"]) + } + // 夹具放了 4 个 codearts(3 空壳 + 1 有凭据) + if got := len(accountIDs(t, h, "codearts")); got != 4 { + t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got) + } + // 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5 + if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 { + t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"]) + } + if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 { + t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"]) + } + + // ② 真执行:删无凭据的,**保留有凭据的** + if _, err := h.ps.Call("channels", "account_clear_credentialless", + map[string]interface{}{"dry_run": false}); err != nil { + t.Fatal(err) + } + if got := len(accountIDs(t, h, "codearts")); got != 1 { + t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got) + } + // ★ 关键:留下的必须是**有凭据的那个** + left := accountIDs(t, h, "codearts") + if len(left) != 1 || left[0] != "withcred" { + t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left) + } + // 有凭据的账号其凭据也必须仍在(不能被误删) + if !h.hasCredential("withcred") { + t.Fatal("有凭据账号的凭据被误删了") + } + t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好") +} + +// 指定平台时只动那个平台,别的不碰。 +func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) { + h := newAccountCleanupHarness(t) + + if _, err := h.ps.Call("channels", "account_clear_credentialless", + map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil { + t.Fatal(err) + } + // codearts 被清(剩 1),trae 的 2 个占位**不该被动** + if got := len(accountIDs(t, h, "codearts")); got != 1 { + t.Fatalf("codearts 应剩 1,实得 %d", got) + } + if got := len(accountIDs(t, h, "trae")); got != 2 { + t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got) + } + t.Log("✓ 平台范围隔离正确") +} + +// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。 +func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) { + h := newAccountCleanupHarness(t) + res, err := h.ps.Call("channels", "account_clear_credentialless", + map[string]interface{}{"platform": "no-such-platform", "dry_run": false}) + if err != nil { + t.Fatal(err) + } + j := jsonOf(t, res) + if j["error"] == nil { + t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j) + } + // 且什么都没删 + if got := len(accountIDs(t, h, "codearts")); got != 4 { + t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got) + } +} + +// ── harness ── + +type accountCleanupHarness struct { + t *testing.T + ps *lua.Plugins +} + +func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness { + t.Helper() + 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.Fatal(err) + } + h := &accountCleanupHarness{t: t, ps: ps} + // 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位 + for _, id := range []string{"empty1", "empty2", "empty3"} { + h.add("codearts", id, "") + } + h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`) + h.add("trae", "tempty1", "") + h.add("trae", "tempty2", "") + return h +} + +func (h *accountCleanupHarness) add(platform, id, cred string) { + h.t.Helper() + args := map[string]interface{}{"platform": platform, "account_id": id} + if cred != "" { + args["credential"] = cred + } + if _, err := h.ps.Call("channels", "account_add", args); err != nil { + h.t.Fatalf("account_add(%s/%s): %v", platform, id, err) + } +} + +func (h *accountCleanupHarness) hasCredential(accountID string) bool { + h.t.Helper() + res, err := h.ps.Call("channels", "account_list", + map[string]interface{}{"platform": "codearts"}) + if err != nil { + h.t.Fatal(err) + } + for _, a := range accountsOf(h.t, res) { + if a["id"] == accountID { + return a["has_credential"] == true + } + } + return false +} + +// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。 +func jsonOf(t *testing.T, got interface{}) map[string]interface{} { + t.Helper() + m, ok := got.(map[string]interface{}) + if !ok { + t.Fatalf("返回值不是对象: %#v", got) + } + j, ok := m["json"].(map[string]interface{}) + if !ok { + t.Fatalf("返回值缺 json 层: %#v", m) + } + return j +} + +// accountsOf 取 account_list 返回的账号数组。 +func accountsOf(t *testing.T, got interface{}) []map[string]interface{} { + t.Helper() + j := jsonOf(t, got) + arr, _ := j["accounts"].([]interface{}) + out := make([]map[string]interface{}, 0, len(arr)) + for _, e := range arr { + if m, ok := e.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out +} + +// accountIDs 列某平台的账号 id。 +func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string { + t.Helper() + res, err := h.ps.Call("channels", "account_list", + map[string]interface{}{"platform": platform}) + if err != nil { + t.Fatalf("account_list(%s): %v", platform, err) + } + var out []string + for _, a := range accountsOf(t, res) { + if id, ok := a["id"].(string); ok { + out = append(out, id) + } + } + return out +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 5adb8e9..6a538de 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "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_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", "__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_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", "__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" } @@ -6289,6 +6289,8 @@ plugin.http = { roles = { "admin" }, description = "列平台账号池" }, { path = "channels/account/update", method = "POST", handler = "account_update", roles = { "admin" }, description = "改账号昵称/启用态" }, + { path = "channels/account/clear_credentialless", method = "POST", handler = "account_clear_credentialless", + roles = { "admin" }, description = "清理无凭据账号(占位;dry_run 默认开启)" }, { path = "channels/account/delete", method = "POST", handler = "account_delete", roles = { "admin" }, description = "删除账号(含凭据与上游源)" }, { path = "channels/account/test", method = "POST", handler = "account_test", @@ -6412,6 +6414,83 @@ function plugin.account_add(req) return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } } end +-- account_clear_credentialless:批量清掉**没有任何凭据**的账号。 +-- +-- ## 为什么需要它(真实场景) +-- account_add 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"), +-- 于是"先建占位、再走登录补齐"是正常用法。但登录若没走完(用户放弃、或历史批量 +-- 导入),就会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数 +-- (面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"变得难以判断。 +-- 实测生产有 12 个这种占位(codearts 10 + trae 2),13 个账号里只有 1 个真可用。 +-- +-- ## 安全性(**破坏性**操作,必须能证明它只删该删的) +-- ① 只删没有凭据的:有凭据的账号**一律不动**(哪怕它测试失败 —— 那是"可疑" +-- 而不是"空壳",两者处置完全不同); +-- ② dry_run **默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删; +-- ③ 复用 plugin.account_delete(同一份删除实现)—— 删除时要顺带做的事 +-- (drop source、清旧单账号源、删凭据)不能另写一套,否则必然漂移。 +-- ⚠️ 不写成顶层 `local`:本文件顶层 local 数已临近 Lua 的单函数 200 上限 +-- (加它会直接 luac -p 报 "too many local variables")。 +-- 挂在 plugin 表上同样只有一份实现,且不占顶层名额。 +plugin.__hasCredential = function(acct) + if not acct or not acct.credentialRef then return false end + local v = __llmsproxy_secrets.get(acct.credentialRef) + return type(v) == "string" and v ~= "" +end + +function plugin.account_clear_credentialless(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local dry = args.dry_run + if dry == nil then dry = true end + + local platforms + if args.platform and args.platform ~= "" then + local plat = platformById(args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + platforms = { plat } + else + platforms = allPlatforms() + end + + local victims, kept = {}, 0 + for _, plat in ipairs(platforms) do + for _, a in ipairs(poolList(plat.id)) do + if plugin.__hasCredential(a) then + kept = kept + 1 + else + victims[#victims + 1] = { platform = plat.id, id = a.id, nickname = a.nickname } + end + end + end + + if dry then + return { status = 200, json = { + ok = true, dry_run = true, would_delete = #victims, + kept_with_credential = kept, accounts = victims, + message = "预演:将删除 " .. #victims .. " 个无凭据账号(有凭据的 " .. kept + .. " 个不动)。确认后传 dry_run=false 执行。", + } } + end + + local done, failed = {}, {} + for _, v in ipairs(victims) do + local r = plugin.account_delete({ platform = v.platform, account_id = v.id }) + if r and r.json and r.json.ok then + done[#done + 1] = v.platform .. "/" .. v.id + else + failed[#failed + 1] = v.platform .. "/" .. v.id + end + end + logEvent("清理无凭据账号 " .. #done .. " 个(失败 " .. #failed .. ")") + return { status = 200, json = { + ok = true, dry_run = false, deleted = #done, failed = #failed, + kept_with_credential = kept, deleted_accounts = done, + failed_accounts = #failed > 0 and failed or nil, + } } +end + + -- account_list:列平台的账号(对齐参考 account.list,含余额状态字段) function plugin.account_list(req) local args = bodyOf(req)