mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): account_adopt —— 正确接管升级前的旧凭据(不手填密文)
真实场景:v2.0.x 之前登录只写一条 `channels:<plat>`(单账号模式),引入账号池后 凭据落在 `channels:<plat>:<aid>`。升级后老凭据仍在保险库、但池里没有账号引用它 —— **表现为"账号消失了"**(数据其实还在)。让用户重新登录是可见的额外代价 (要再走一次授权)。 新增 `account_adopt`:把非池凭据接管进指定池账号。**全程走插件的保险库 API** —— `__llmsproxy_secrets.get` 读出的是**明文**(保险库负责解密),写回时插件自己加密。 ★ 这个动作的存在本身就是为了**避免我上次踩的那个坑**:我当时手填保险库文件里的 密文,而保险库读取时会解密 ⇒ 凭据被**二次加密**(1971 → 2965 字符), 该账号推理开始返回空。判据里专门有一条钉住"接管后凭据必须与原文逐字节相同"。 判据(channels_account_adopt_test.go)四条: 1. 接管后池里出现该账号且 has_credential=true 2. **凭据与原文逐字节相同**(长度也一致)—— 防二次加密 3. 昵称等展示字段从凭据补齐 4. 没有旧凭据时如实报错,不假装成功 回归:9 个包全绿。
This commit is contained in:
@ -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:<plat>`)接管进某个池账号。
|
||||
--
|
||||
-- 为什么需要它(真实场景):v2.0.x 之前登录只写一条 `channels:<plat>`(单账号
|
||||
-- 模式),后来引入账号池后凭据落在 `channels:<plat>:<aid>`。升级后老凭据仍在
|
||||
-- 保险库里、但池里没有账号引用它 —— 表现是"账号消失了"(数据其实还在)。
|
||||
-- 重新登录当然可以,但那是**用户可见的额外代价**(要再走一次授权)。
|
||||
--
|
||||
-- ⚠️ 正确做法是让**插件自己读保险库**(`__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 {}
|
||||
|
||||
Reference in New Issue
Block a user