diff --git a/internal/core/channels_account_adopt_test.go b/internal/core/channels_account_adopt_test.go new file mode 100644 index 0000000..5ce780f --- /dev/null +++ b/internal/core/channels_account_adopt_test.go @@ -0,0 +1,102 @@ +package core + +import ( + "testing" +) + +// account_adopt:把升级前的**非池**凭据接管进账号池。 +// +// 为什么需要它:v2.0.x 之前登录只写一条 `channels:`,引入账号池后凭据落在 +// `channels::`。升级后老凭据仍在保险库、但池里没有账号引用它 —— +// 表现为"账号消失了"(数据其实还在)。让用户重新登录是**可见的额外代价**。 +// +// ⚠️ 本判据同时钉住一条操作纪律:**接管必须走插件的保险库 API**(读出的是明文, +// 写回时插件自己加密)。绝不要把保险库文件里的密文当明文喂给 API —— +// 保险库读取时会解密,那样会把凭据**二次加密**(实测踩过:1971 → 2965 字符, +// 该账号推理开始返回空)。 +func TestAccountAdoptLegacyCredential(t *testing.T) { + _, ps := poolCore(t) + const legacy = `{"access_token":"LEGACY-TOK","refresh_token":"RT","nickname":"老账号"}` + + // 造一条"旧版"非池凭据(模拟升级前留下的) + if _, err := ps.Call("channels", "__set_legacy_cred_probe", map[string]interface{}{ + "platform": "buddy", "credential": legacy, + }); err != nil { + t.Fatalf("造旧凭据: %v", err) + } + + // 接管进一个新池账号 + m, err := ps.Call("channels", "account_adopt", map[string]interface{}{ + "platform": "buddy", "account_id": "adopted1", + }) + if err != nil { + t.Fatalf("account_adopt: %v", err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil || j["ok"] != true { + t.Fatalf("接管失败: %#v", m) + } + + // ① 池里必须出现该账号,且**有凭据** + lm, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"}) + lj, _ := lm.(map[string]interface{})["json"].(map[string]interface{}) + found := false + for _, a := range asArrInterface(lj["accounts"]) { + if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" { + found = true + if am["has_credential"] != true { + t.Errorf("接管后应 has_credential=true,实得 %v", am["has_credential"]) + } + } + } + if !found { + t.Fatalf("接管后账号未出现在池里: %#v", lj["accounts"]) + } + + // ② 凭据必须是**原文**(未被二次加密)——这是本判据的核心 + out, err := ps.Call("channels", "__read_cred_probe", map[string]interface{}{ + "ref": "channels:buddy:adopted1", + }) + if err != nil { + t.Fatalf("读凭据: %v", err) + } + rj, _ := out.(map[string]interface{})["json"].(map[string]interface{}) + got, _ := rj["credential"].(string) + if got != legacy { + t.Errorf("接管后的凭据应与原文逐字节相同(不得二次加密)\n 期望: %s\n 实得: %s", + legacy, got) + } + if rj["len"] != nil { + if n, _ := rj["len"].(float64); int(n) != len(legacy) { + t.Errorf("凭据长度 %v,应为 %d(长度变化说明被二次加密)", n, len(legacy)) + } + } + + // ③ 补齐了展示字段(昵称从凭据里取) + for _, a := range asArrInterface(lj["accounts"]) { + if am, ok := a.(map[string]interface{}); ok && am["id"] == "adopted1" { + if am["nickname"] != "老账号" { + t.Errorf("昵称应从凭据补齐为「老账号」,实得 %v", am["nickname"]) + } + } + } + + // ④ 没有旧凭据时必须如实报错(不能假装成功) + _, _ = ps.Call("channels", "__clear_all_creds_probe", nil) + m2, _ := ps.Call("channels", "account_adopt", map[string]interface{}{ + "platform": "buddy", "account_id": "adopted2", + }) + j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{}) + if j2["ok"] == true { + t.Error("没有旧凭据时应如实失败,不能假装成功") + } +} + +// asArrInterface 宽松取数组(插件桥回传的可能是 []interface{})。 +func asArrInterface(v interface{}) []interface{} { + if a, ok := v.([]interface{}); ok { + return a + } + return nil +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 2368749..842862e 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", "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", "__is_model_gone_probe", "__rank_accounts_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_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", "__is_model_gone_probe", "__rank_accounts_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" } @@ -5536,6 +5536,8 @@ plugin.http = { roles = { "admin" }, description = "永久积分锁定" }, { path = "channels/account/reset", method = "POST", handler = "account_reset", roles = { "admin" }, description = "清账号限流标记" }, + { path = "channels/account/adopt", method = "POST", handler = "account_adopt", + help = "把旧版非池凭据接管进账号池(升级后账号消失时用)" }, { path = "channels/account/retest", method = "POST", handler = "account_retest", roles = { "admin" }, description = "重测账号" }, { path = "channels/model/set_disabled_many", method = "POST", handler = "model_set_disabled_many", @@ -5735,6 +5737,63 @@ function plugin.account_test(req) return { status = 200, json = { ok = true, probed = true, ms = ms } } end +-- account_adopt:把**非池**的旧凭据(`channels:`)接管进某个池账号。 +-- +-- 为什么需要它(真实场景):v2.0.x 之前登录只写一条 `channels:`(单账号 +-- 模式),后来引入账号池后凭据落在 `channels::`。升级后老凭据仍在 +-- 保险库里、但池里没有账号引用它 —— 表现是"账号消失了"(数据其实还在)。 +-- 重新登录当然可以,但那是**用户可见的额外代价**(要再走一次授权)。 +-- +-- ⚠️ 正确做法是让**插件自己读保险库**(`__llmsproxy_secrets.get`)再写进池引用。 +-- **绝不能**把保险库文件里的密文当明文喂给 API —— 保险库读取时会解密,那样会 +-- 把凭据**二次加密**(实测踩过:1971 → 2965 字符,该账号推理开始返回空)。 +function plugin.account_adopt(req) + local args = bodyOf(req) + if args.account_id == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local legacyRef = "channels:" .. plat.id + -- 全程只用插件的保险库 API:读出的是**明文**(保险库负责解密) + local raw = __llmsproxy_secrets.get(legacyRef) + if not raw or raw == "" then + return { status = 200, json = { ok = false, + error = "没有可接管的旧凭据(" .. legacyRef .. ")" } } + end + local aid = args.account_id + if not aid or aid == "" then + return { status = 400, json = { error = "缺 account_id(要接管进哪个池账号)" } } + end + local acct = poolGet(plat.id, aid) + if not acct then + -- 允许直接建:占位 + 写入 + local now = os.time and os.time() or 0 + acct = { id = aid, provider = plat.id, nickname = args.nickname or aid, + enabled = true, credentialRef = "channels:" .. plat.id .. ":" .. aid, + refreshable = false, createdAt = now, last_claim = 0, last_claim_status = 0, + last_claim_summary = "" } + poolAdd(plat.id, acct) + end + -- 写入的是**明文**(raw)—— 保险库写入时自己加密。这正是要避免"手填密文"的原因。 + local _, serr = __llmsproxy_secrets.set(acct.credentialRef, raw) + if serr then return { status = 200, json = { ok = false, error = serr } } end + -- 从凭据补齐展示字段 + if type(raw) == "string" and raw:sub(1, 1) == "{" then + local dok, cred = pcall(json.decode, raw) + if dok and type(cred) == "table" then + acct.nickname = args.nickname or cred.nickname or cred.user_name or cred.user_id or acct.nickname + acct.refreshable = credentialIsRefreshable(cred) + local exp = credentialExpiresAtMs(cred) + if exp then acct.expiresAt = tostring(exp) end + end + end + poolAdd(plat.id, acct) + markDirty() + syncAccountSource(plat.id, aid) + logEvent("已接管旧凭据到 " .. plat.id .. "/" .. aid) + return { status = 200, json = { ok = true, account_id = aid, + credentialRef = acct.credentialRef, message = "旧凭据已接入账号池" } } +end + -- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额 function plugin.account_refresh(req) local args = bodyOf(req) @@ -8510,6 +8569,29 @@ function plugin.__set_expiring_window_probe(args) return { json = { ok = true, days = expiringWindowDays() } } end +-- plugin.__set_legacy_cred_probe:只给测试用 —— 造一条"升级前"的非池凭据。 +function plugin.__set_legacy_cred_probe(args) + args = args or {} + local ref = "channels:" .. tostring(args.platform or "") + __llmsproxy_secrets.set(ref, tostring(args.credential or "")) + return { json = { ok = true, ref = ref } } +end + +-- plugin.__read_cred_probe:只给测试用 —— 读一条凭据(保险库解密后的**明文**)。 +-- 判据靠它证明"接管没有把凭据二次加密"。 +function plugin.__read_cred_probe(args) + args = args or {} + local v = __llmsproxy_secrets.get(tostring(args.ref or "")) + return { json = { credential = v, len = v and #v or 0 } } +end + +-- plugin.__clear_all_creds_probe:只给测试用 —— 清掉该插件的全部凭据。 +function plugin.__clear_all_creds_probe() + local ks = __llmsproxy_secrets.keys and __llmsproxy_secrets.keys() or {} + for _, k in ipairs(ks) do __llmsproxy_secrets.delete(k) end + return { json = { ok = true } } +end + -- plugin.__rank_accounts_probe:只给测试用 —— 暴露余额分档选号的结果。 function plugin.__rank_accounts_probe(args) args = args or {}