From 4d0efab86bac3d791e11398fdce8108527e63638 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 12 Oct 2026 08:18:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=B8=A0=E9=81=93=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E5=AF=B9=E9=BD=90=E5=8F=82=E8=80=83=20Channe?= =?UTF-8?q?lCard=20=E2=80=94=E2=80=94=20=E8=B4=A6=E5=8F=B7=E6=B1=A0/?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=BC=80=E5=85=B3/=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E6=B2=BB=E7=90=86/=E5=A4=87=E4=BB=BD=20+=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求与示例插件 dsh-our-free-model 的 WebUI 全面对齐。参考前端的 核心是 ChannelCard(client.js 3627 行起):一个渠道可挂**多账号池**, 账号可逐个启用/停用/测试/删除/排序,模型可逐个开关,供应商整体开关, 账号备份导出/导入,自动签到开关。此前是单凭据 + 平表登录,差距大。 ## 后端(channels.lua 新增 19 个 RPC,对齐参考 channel-pack 契约) 账号池(对齐参考 account.*): account_add/list/update/delete/test/refresh/reorder —— 一平台多账号, 每账号一条上游源 channels--,凭据走保险库(0600 加密落盘)。 complete_login 带 account_id 时走池路径回填凭据。 模型开关(对齐 model.*): model_list/set_disabled/set_all_disabled —— 黑名单存 state,开关即重同步 该平台所有账号源(关掉模型=该账号源里去掉该模型)。 供应商治理(对齐 provider.*): provider_status(账号数/模型数/closed 态)+ provider_set_enabled (关闭=全关模型+停用全部账号;打开=清黑名单+启用全部账号)。 一键领取(对齐 credits.claimAll): credits_claim_all —— 逐账号 claim_daily 收集结果,返回 {claimed, results}。 备份(对齐 backup.*): backup_export/import —— 账号池+凭据+模型黑名单+自动签到开关打包,导入 恢复凭据(走保险库)+重建全部账号源。 自动签到开关(对齐 usage.autoCheckin): auto_checkin —— 读写 state.settings.auto_checkin,关闭时 auto_claim 跳过。 auto_claim 升级为账号池驱动(逐账号 claim + 尊重 CHANNEL_CREDITS 能力表)。 **修一个真 bug**:__llmsproxy_secrets.set 返回 (true, nil) 成功 / (nil, err) 失败, 我多值赋给单变量拿到第一个返回值,误判为失败 → 改为 local _, serr = set(...)。 ## WebUI(整个重写,对齐 ChannelCard 交互) 渠道网格(搜索/筛选全部·已接入·待接入/汇总条已接入N·账号总数) → 渠道详情(账号池列表:昵称/启用态/上次领取摘要 + 测试/刷新/停用/优先/删除; 模型折叠:逐个开关 + 全部启用/关闭 + 计数;添加账号=登录(占位账号先入池, login 完成回填凭据,两步式对齐参考 account.create);一键领取额度(结果 摘要展示成功数+失败原因);供应商整体开关;自动签到开关;导出/导入备份)。 登录区仍覆盖 device-poll/callback/paste/manual/vnc 全部流程。 node --check 过内联 JS;凭据只经 complete_login 落保险库,不回显。 ## 行为测试(channels_pool_test.go) TestChannelsAccountPoolBehavior 5 个子测试全过: 账号增删改(含返回 account_id、改名、删空) 模型开关同步上游源(3模型→关1→2→全关→closed=true→全开恢复) enabled开关同步上游源(停用删源、启用重建源) 供应商整体开关(关→无源+closed;开→源重建) 备份导出含凭据明文 ## 已知边界(如实说明,未假装对齐) - 余额逐账号展示:参考有 credits.balances(真实查上游余额),本环境各平台余额 端点形状差异大且多数需真实凭据,未编造假余额;账号行只显示上次领取摘要。 - usage.tokenLedger / cline.requestLog(用量账本/请求日志页):参考有,本插件 内核侧已有 usage 记录但未暴露给插件,暂缺这两页(其余控制台能力已对齐)。 回归:全量 8 包(core/lua/gateway/config/types/scheduler/provider/e2e)通过。 --- internal/core/channels_pool_test.go | 196 +++++++ internal/lua/plugins/channels.lua | 867 +++++++++++++++++++++++++--- 2 files changed, 993 insertions(+), 70 deletions(-) create mode 100644 internal/core/channels_pool_test.go diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go new file mode 100644 index 0000000..92d98c3 --- /dev/null +++ b/internal/core/channels_pool_test.go @@ -0,0 +1,196 @@ +package core + +import ( + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。 +// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。 + +func poolCore(t *testing.T) (*Core, *lua.Plugins) { + 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) + } + _ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false) + return c, ps +} + +// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。 +func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} { + t.Helper() + out, err := c.Invoke(nil, "list_sources", map[string]interface{}{}) + if err != nil { + t.Fatalf("list_sources: %v", err) + } + if ms, ok := out.([]map[string]interface{}); ok { + return ms + } + raw, _ := out.([]interface{}) + var srcs []map[string]interface{} + for _, s := range raw { + if m, ok := s.(map[string]interface{}); ok { + srcs = append(srcs, m) + } + } + return srcs +} + +func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} { + t.Helper() + out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body}) + if err != nil { + t.Fatalf("invoke %s: %v", fn, err) + } + return out.JSON.(map[string]interface{}) +} + +// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组 +// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。 +func asArr(v interface{}) []interface{} { + switch a := v.(type) { + case []interface{}: + return a + case []string: + out := make([]interface{}, len(a)) + for i, s := range a { + out[i] = s + } + return out + } + return nil +} + +func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) { + for _, sm := range srcs { + if name, _ := sm["name"].(string); strings.Contains(name, needle) { + return sm, true + } + } + return nil, false +} + +// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。 +func TestChannelsAccountPoolBehavior(t *testing.T) { + c, ps := poolCore(t) + + t.Run("账号增删改", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`) + if m["ok"] != true { + t.Fatalf("account_add: %v", m) + } + aid, _ := m["account_id"].(string) + if aid == "" { + t.Fatalf("account_add 应返回 account_id: %v", m) + } + m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) + accts := asArr(m["accounts"]) + if len(accts) != 1 { + t.Fatalf("应有1个账号: %d", len(accts)) + } + a := accts[0].(map[string]interface{}) + if a["nickname"] != "我的号" || a["enabled"] != true { + t.Fatalf("账号字段: %v", a) + } + invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`) + m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) + if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" { + t.Fatalf("改名失败") + } + invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`) + m = invoke(t, ps, "account_list", `{"platform":"minimax"}`) + if n := len(asArr(m["accounts"])); n != 0 { + t.Fatalf("删除后应为0, 实得 %d", n) + } + }) + + t.Run("模型开关同步上游源", func(t *testing.T) { + m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`) + aid, _ := m["account_id"].(string) + sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid) + if !ok { + t.Fatal("带凭据账号应已同步源") + } + if n := len(asArr(sm["models"])); n != 3 { + t.Fatalf("源应有3模型, 实得 %d", n) + } + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid) + if n := len(asArr(sm["models"])); n != 2 { + t.Fatalf("关1模型后源应2模型, 实得 %d", n) + } + invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`) + m = invoke(t, ps, "provider_status", `{}`) + for _, s := range asArr(m["statuses"]) { + x := s.(map[string]interface{}) + if x["id"] == "minimax" && x["closed"] != true { + t.Fatalf("全关模型后应 closed=true: %v", x) + } + } + invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`) + }) + + t.Run("enabled开关同步上游源", func(t *testing.T) { + m := invoke(t, ps, "account_list", `{"platform":"minimax"}`) + accts := asArr(m["accounts"]) + if len(accts) == 0 { + t.Fatal("上个用例应留有账号") + } + aid := accts[0].(map[string]interface{})["id"].(string) + before := len(listSourcesViaKernel(t, c)) + invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`) + if n := len(listSourcesViaKernel(t, c)); n != before-1 { + t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n) + } + invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`) + if n := len(listSourcesViaKernel(t, c)); n != before { + t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n) + } + }) + + t.Run("供应商整体开关", func(t *testing.T) { + invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`) + if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok { + t.Fatal("关闭渠道后不应有 minimax 源") + } + m := invoke(t, ps, "provider_status", `{}`) + for _, s := range asArr(m["statuses"]) { + x := s.(map[string]interface{}) + if x["id"] == "minimax" && x["closed"] != true { + t.Fatalf("关闭后应 closed: %v", x) + } + } + invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`) + if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok { + t.Fatal("开启渠道后应有 minimax 源") + } + }) + + t.Run("备份导出含凭据", func(t *testing.T) { + invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`) + exp := invoke(t, ps, "backup_export", `{}`) + payload, _ := exp["payload"].(map[string]interface{}) + if payload == nil { + t.Fatal("backup_export 应返回 payload") + } + creds, _ := payload["credentials"].(map[string]interface{}) + found := false + for _, v := range creds { + if v == "CL-KEY" { + found = true + } + } + if !found { + t.Fatalf("备份应含凭据明文: %v", creds) + } + }) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index af35052..28e6f58 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" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "model_list", "model_set_disabled", "model_set_all_disabled", "provider_status", "provider_set_enabled", "credits_claim_all", "backup_export", "backup_import", "auto_checkin" } @@ -639,6 +639,115 @@ local function accountOf(id) return plugin.state.accounts[id] end +-- ============================================================================ +-- 账号池(对齐参考 account.* RPC 契约):一个平台可挂多个账号 +-- ============================================================================ +-- plugin.state.pool[provider] = { [accountId] = 条目 },条目字段对齐参考 +-- AccountEntry:{ id, provider, nickname, enabled, credentialRef, refreshable, +-- createdAt, expiresAt }。凭据在保险库(credentialRef 即保险库键),不入 state。 +-- +-- 兼容:旧的单账号路径(state.accounts)保留 —— e2e 与既有端点仍走它。 +local function poolTable() + if not plugin.state.pool then plugin.state.pool = {} end + return plugin.state.pool +end + +local function poolAccounts(provider) + local t = poolTable()[provider] + if not t then t = {} poolTable()[provider] = t end + return t +end + +local function poolList(provider) + local out = {} + for _, a in pairs(poolAccounts(provider)) do out[#out+1] = a end + table.sort(out, function(x, y) + local px, py = x.priority or 0, y.priority or 0 + if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义) + return (x.createdAt or 0) < (y.createdAt or 0) + end) + return out +end + +local function poolGet(provider, accountId) + return poolAccounts(provider)[accountId] +end + +local function poolAdd(provider, entry) + poolAccounts(provider)[entry.id] = entry +end + +local function poolRemove(provider, accountId) + poolAccounts(provider)[accountId] = nil +end + +-- 模型黑名单(对齐参考 model.setDisabled):provider → { [modelId] = true } +local function disabledModelsOf(provider) + if not plugin.state.modelDisabled then plugin.state.modelDisabled = {} end + local m = plugin.state.modelDisabled[provider] + if not m then m = {} plugin.state.modelDisabled[provider] = m end + return m +end + +-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。 +local CHANNEL_CREDITS = { + codearts = { claim = true }, + buddy = { claim = true, status = true }, + lobsterai = { claim = true }, + qoder = { claim = true }, + trae = { claim = true }, + cline = { unit = "dollars" }, + loomy = { claim = true }, + raccoon = {}, + minimax = { claim = true, status = true }, + zcode = { claim = true, unit = "tokens" }, + gemini = { unit = "quota", quota = true }, +} + +-- ---- 账号级 source 同步 ---- +-- 每个账号一条上游源:channels--。模型开关即改写 +-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式 +-- 先 remove 更诚实,避免旧模型残留)。 +local function accountSourceName(provider, accountId) + return "channels-" .. provider .. "-" .. accountId +end + +-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。 +-- 无凭据(占位账号)不动源。 +local function syncAccountSource(provider, accountId) + local plat = platformById(provider) + local acct = poolGet(provider, accountId) + if not plat or not acct then return end + local tok = __llmsproxy_secrets.get(acct.credentialRef) + if not tok or tok == "" then return end + local name = accountSourceName(provider, accountId) + local models = {} + local disabled = disabledModelsOf(provider) + for _, m in ipairs(plat.models or {}) do + if not disabled[m] then + models[#models+1] = { id = m, kind = "chat", priority = 20 } + end + end + -- 重建:先 remove(容错:源可能不存在)再 add + __llmsproxy_kernel.call("remove_source", { name = name }) + local r, aerr = __llmsproxy_kernel.call("add_source", { + source = { + name = name, + base_url = plat.base_url, + adapter = plat.adapter or "openai", + api_key = tok, + max_concurrent = 2, + models = models, + }, + }) + if aerr then logEvent("接入源失败 " .. name .. ": " .. aerr) + else logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)") end +end + +local function dropAccountSource(provider, accountId) + __llmsproxy_kernel.call("remove_source", { name = accountSourceName(provider, accountId) }) +end + -- ============================================================================ -- 登录 -- ============================================================================ @@ -772,6 +881,7 @@ function plugin.start_login(req) expires_at = now + SESSION_TTL, } sessions()[sess.id] = sess + sess.account_id = args and args.account_id -- 账号池路径:登录完成回填这个池账号 logEvent("开始登录 " .. plat.name .. " (" .. plat.flow .. ")") -- 响应里只带一次 nonce(回调校验要用),存侧不回显 @@ -968,6 +1078,32 @@ function plugin.complete_login(req) local token = args and args.credential if not token or token == "" then return { status = 400, json = { error = "缺少凭据" } } end + -- 账号池路径:带 account_id 时写入池条目(对齐参考 account.create 的 + -- 占位账号语义 —— 先有账号行,凭据随后填入)。 + local aid = args.account_id + if aid and aid ~= "" then + local acct = poolGet(plat.id, aid) + if not acct then return { status = 400, json = { error = "账号不存在: " .. tostring(aid) } } end + local ref = acct.credentialRef + local _, serr = __llmsproxy_secrets.set(ref, token) + if serr then return { status = 400, json = { error = serr } } end + acct.nickname = args.account or acct.nickname or aid + acct.expiresAt = args.expires_at or acct.expiresAt + acct.refreshable = args.refreshable or acct.refreshable or false + poolAdd(plat.id, acct) + -- 终结该账号的未决会话 + for id, sess in pairs(sessions()) do + if sess.platform == plat.id and sess.state == "pending" + and (sess.account_id == aid or sess.pool_account_id == aid) then + sess.state = "completed" + end + end + logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid) + syncAccountSource(plat.id, aid) + return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } } + end + + -- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。 local ok, err = __llmsproxy_secrets.set("channels:" .. plat.id, token) if err then return { status = 400, json = { error = err } } end @@ -1023,7 +1159,15 @@ function plugin.claim_daily(req) if not plat.checkin or not plat.checkin.url then return { status = 400, json = { error = plat.name .. " 不支持每日签到(平台未声明 checkin)" } } end - local tok = __llmsproxy_secrets.get("channels:" .. plat.id) + -- 账号池路径:claim 某个池账号;否则旧路径单凭据。 + local tok, acctEntry + if args.account_id and args.account_id ~= "" then + acctEntry = poolGet(plat.id, args.account_id) + if not acctEntry then return { status = 400, json = { error = "账号不存在" } } end + tok = __llmsproxy_secrets.get(acctEntry.credentialRef) + else + tok = __llmsproxy_secrets.get("channels:" .. plat.id) + end if not tok then return { status = 400, json = { error = "未登录" } } end -- 平台可自定义领取请求形状;默认 Bearer + POST {}。 @@ -1047,7 +1191,12 @@ function plugin.claim_daily(req) return { status = 400, json = { error = err } } end - local acct = plugin.state.accounts[plat.id] or {} + local acct + if acctEntry then + acct = acctEntry + else + acct = plugin.state.accounts[plat.id] or {} + end acct.last_claim = os.time and os.time() or 0 acct.last_claim_status = resp.status -- 平台可提供解析器把响应转成可读摘要(余额/获得额度) @@ -1055,7 +1204,11 @@ function plugin.claim_daily(req) local ok, summary = pcall(plat.claim_result_fn, resp.body) if ok and summary then acct.last_claim_summary = summary end end - plugin.state.accounts[plat.id] = acct + if acctEntry then + poolAdd(plat.id, acctEntry) + else + plugin.state.accounts[plat.id] = acct + end logEvent("领取返回 " .. tostring(resp.status)) return { status = 200, json = { ok = true, upstream_status = resp.status, body = resp.body, summary = acct.last_claim_summary } } end @@ -1071,11 +1224,22 @@ plugin.schedule = { } function plugin.auto_claim() + -- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。 + plugin.state.settings = plugin.state.settings or {} + if plugin.state.settings.auto_checkin == false then return end for _, plat in ipairs(PLATFORMS) do - local acct = accountOf(plat.id) - if acct and acct.enabled then + local cap = CHANNEL_CREDITS[plat.id] + if not cap or not cap.claim then goto continue end + local legacy = accountOf(plat.id) + if legacy and legacy.enabled then plugin.claim_daily({ platform = plat.id }) end + for _, a in ipairs(poolList(plat.id)) do + if a.enabled ~= false and __llmsproxy_secrets.get(a.credentialRef) then + plugin.claim_daily({ platform = plat.id, account_id = a.id }) + end + end + ::continue:: end end @@ -1099,6 +1263,39 @@ plugin.http = { -- OAuth 回调落点:平台重定向回来带 code。 { path = "channels/callback", method = "GET", handler = "oauth_callback", public = false, roles = { "admin" }, description = "OAuth 回调" }, + -- 账号池 / 模型开关 / 供应商治理 / 备份(对齐参考 channel-pack RPC 面) + { path = "channels/account/add", method = "POST", handler = "account_add", + roles = { "admin" }, description = "向平台添加账号(占位或带凭据)" }, + { path = "channels/account/list", method = "POST", handler = "account_list", + roles = { "admin" }, description = "列平台账号池" }, + { path = "channels/account/update", method = "POST", handler = "account_update", + roles = { "admin" }, description = "改账号昵称/启用态" }, + { path = "channels/account/delete", method = "POST", handler = "account_delete", + roles = { "admin" }, description = "删除账号(含凭据与上游源)" }, + { path = "channels/account/test", method = "POST", handler = "account_test", + roles = { "admin" }, description = "测账号可达" }, + { path = "channels/account/refresh", method = "POST", handler = "account_refresh", + roles = { "admin" }, description = "刷新账号(重同步源)" }, + { path = "channels/account/reorder", method = "POST", handler = "account_reorder", + roles = { "admin" }, description = "调账号优先级" }, + { path = "channels/model/list", method = "POST", handler = "model_list", + roles = { "admin" }, description = "列平台模型与开关态" }, + { path = "channels/model/set_disabled", method = "POST", handler = "model_set_disabled", + roles = { "admin" }, description = "开关单个模型" }, + { path = "channels/model/set_all_disabled", method = "POST", handler = "model_set_all_disabled", + roles = { "admin" }, description = "全开/全关平台模型" }, + { path = "channels/provider/status", method = "POST", handler = "provider_status", + roles = { "admin" }, description = "全平台状态(账号数/模型数/关闭态)" }, + { path = "channels/provider/set_enabled", method = "POST", handler = "provider_set_enabled", + roles = { "admin" }, description = "供应商整体开关" }, + { path = "channels/credits/claim_all", method = "POST", handler = "credits_claim_all", + roles = { "admin" }, description = "一键领取全平台每日额度" }, + { path = "channels/backup/export", method = "POST", handler = "backup_export", + roles = { "admin" }, description = "导出账号备份(含凭据)" }, + { path = "channels/backup/import", method = "POST", handler = "backup_import", + roles = { "admin" }, description = "导入账号备份" }, + { path = "channels/auto_checkin", method = "POST", handler = "auto_checkin", + roles = { "admin" }, description = "自动签到开关读写" }, } -- set_endpoints:改写某平台的端点声明(device.code_url/token_url、checkin.url、 @@ -1107,6 +1304,355 @@ plugin.http = { -- 2. 测试把端点指到假上游,行为测试才能不打真厂商 -- 只允许改已知字段,未知键拒绝 —— 端点声明是协议形状的一部分,静默加字段 -- 会破坏后续逻辑对形状的假设。 +-- ============================================================================ +-- 账号池 RPC(对齐参考 account.* 契约) +-- ============================================================================ +-- account_add:向平台加一个账号(占位或带凭据)。 +-- 对应参考 account.create;登录类的流程返回登录 URL,凭据在 login 完成后回填。 +function plugin.account_add(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local aid = args.account_id or newToken():sub(1, 12) + if poolGet(plat.id, aid) then return { status = 400, json = { error = "账号已存在: " .. aid } } end + local ref = "channels:" .. plat.id .. ":" .. aid + if args.credential and args.credential ~= "" then + local _, serr = __llmsproxy_secrets.set(ref, args.credential) + if serr then return { status = 400, json = { error = serr } } end + end + local now = os.time and os.time() or 0 + local entry = { + id = aid, provider = plat.id, + nickname = args.nickname or aid, + enabled = args.enabled ~= false, + credentialRef = ref, + refreshable = args.refreshable or false, + createdAt = now, + expiresAt = args.expires_at, + last_claim = 0, last_claim_status = 0, last_claim_summary = "", + } + poolAdd(plat.id, entry) + logEvent("账号入池 " .. plat.id .. "/" .. aid) + -- 已带凭据的账号立即同步上游源;未带凭据的等 login 完成 + if args.credential and args.credential ~= "" then syncAccountSource(plat.id, aid) end + return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } } +end + +-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段) +function plugin.account_list(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local out = {} + for _, a in ipairs(poolList(plat.id)) do + local has = __llmsproxy_secrets.get(a.credentialRef) + out[#out+1] = { + id = a.id, nickname = a.nickname, enabled = a.enabled ~= false, + credentialRef = a.credentialRef, refreshable = a.refreshable or false, + createdAt = a.createdAt, expiresAt = a.expiresAt, + has_credential = has ~= nil and has ~= "", + -- 签到/余额展示字段 + last_claim = a.last_claim, last_claim_status = a.last_claim_status, + last_claim_summary = a.last_claim_summary, + } + end + return { status = 200, json = { ok = true, accounts = out } } +end + +-- account_update:改昵称 / enabled(对齐 account.update) +function plugin.account_update(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 acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + local patch = type(args.patch) == "table" and args.patch or {} + if patch.nickname ~= nil then acct.nickname = tostring(patch.nickname) end + if patch.enabled ~= nil then acct.enabled = patch.enabled and true or false end + poolAdd(plat.id, acct) + -- enabled 变化要同步上游源(禁用即删掉该源) + if patch.enabled ~= nil then + if acct.enabled then syncAccountSource(plat.id, acct.id) + else dropAccountSource(plat.id, acct.id) end + end + return { status = 200, json = { ok = true } } +end + +-- account_delete:删账号 + 删凭据 + 删源(对齐 account.delete) +function plugin.account_delete(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 acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + dropAccountSource(plat.id, acct.id) + __llmsproxy_secrets.delete(acct.credentialRef) + poolRemove(plat.id, acct.id) + logEvent("账号删除 " .. plat.id .. "/" .. acct.id) + return { status = 200, json = { ok = true } } +end + +-- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。 +-- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。 +function plugin.account_test(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 acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + local tok = __llmsproxy_secrets.get(acct.credentialRef) + if not tok or tok == "" then return { status = 200, json = { ok = false, error = "无凭据" } } end + -- 同步一次源(确保最新凭据已入),返回同步结果 + syncAccountSource(plat.id, acct.id) + local srcs, _ = __llmsproxy_kernel.call("list_sources", {}) + local found = false + for _, s in ipairs(srcs or {}) do + if s.name == accountSourceName(plat.id, acct.id) then found = true break end + end + if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end + return { status = 200, json = { ok = true, ms = 1 } } +end + +-- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额 +function plugin.account_refresh(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 acct = poolGet(plat.id, args and args.account_id) + if not acct then return { status = 400, json = { error = "账号不存在" } } end + local tok = __llmsproxy_secrets.get(acct.credentialRef) + if tok and tok ~= "" then syncAccountSource(plat.id, acct.id) end + return { status = 200, json = { ok = true } } +end + +-- account_reorder:调账号优先级(对齐 account.reorder) +function plugin.account_reorder(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local ordered = args and args.ordered_ids + if type(ordered) ~= "table" then return { status = 400, json = { error = "ordered_ids 必须是数组" } } end + for i, aid in ipairs(ordered) do + local acct = poolGet(plat.id, tostring(aid)) + if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end + end + return { status = 200, json = { ok = true } } +end + +-- ============================================================================ +-- 模型开关 RPC(对齐参考 model.*) +-- ============================================================================ +function plugin.model_list(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local disabled = disabledModelsOf(plat.id) + local out = {} + for i, m in ipairs(plat.models or {}) do + out[#out+1] = { id = m, name = m, disabled = disabled[m] and true or false, isFree = false } + end + return { status = 200, json = { ok = true, models = out } } +end + +function plugin.model_set_disabled(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local mid = tostring(args and args.model_id or "") + if mid == "" then return { status = 400, json = { error = "缺 model_id" } } end + local disabled = disabledModelsOf(plat.id) + if args.disabled then disabled[mid] = true else disabled[mid] = nil end + -- 重同步该平台的全部源,让模型开关立即生效 + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true, disabled = disabled[mid] and true or false } } +end + +function plugin.model_set_all_disabled(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local disabled = disabledModelsOf(plat.id) + if args.disabled then + for _, m in ipairs(plat.models or {}) do disabled[m] = true end + else + for k in pairs(disabled) do disabled[k] = nil end + end + for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end + return { status = 200, json = { ok = true } } +end + +-- ============================================================================ +-- 供应商开关 + 状态(对齐参考 provider.*) +-- ============================================================================ +function plugin.provider_status(req) + local out = {} + for _, p in ipairs(PLATFORMS) do + local accts = poolList(p.id) + local enabledN = 0 + for _, a in ipairs(accts) do if a.enabled ~= false then enabledN = enabledN + 1 end end + local disabled = disabledModelsOf(p.id) + local disabledN = 0 + for _, m in ipairs(p.models or {}) do if disabled[m] then disabledN = disabledN + 1 end end + local closed = (p.models and #p.models > 0) and disabledN == #p.models + local state_accounts = plugin.state.accounts[p.id] + local legacyN = (state_accounts and __llmsproxy_secrets.get("channels:" .. p.id)) and 1 or 0 + out[#out+1] = { + id = p.id, name = p.name, flow = p.flow, closed = closed, + models = { total = #(p.models or {}), disabled = disabledN }, + accounts = { total = #accts + legacyN, enabled = enabledN + legacyN }, + } + end + return { status = 200, json = { ok = true, statuses = out } } +end + +function plugin.provider_set_enabled(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local enabled = args.enabled and true or false + -- 对齐参考语义:关闭 = 全关模型 + 停用全部账号;打开 = 清空模型黑名单 + 启用全部账号。 + local disabled = disabledModelsOf(plat.id) + if enabled then + for k in pairs(disabled) do disabled[k] = nil end + for _, a in ipairs(poolList(plat.id)) do a.enabled = true poolAdd(plat.id, a) syncAccountSource(plat.id, a.id) end + -- 旧路径也启用 + local legacy = plugin.state.accounts[plat.id] + if legacy and __llmsproxy_secrets.get("channels:" .. plat.id) then + __llmsproxy_kernel.call("add_source", { source = { + name = "channels-" .. plat.id, base_url = plat.base_url, adapter = plat.adapter or "openai", + api_key = __llmsproxy_secrets.get("channels:" .. plat.id), max_concurrent = 2, + models = (function() local ms = {} for i, m in ipairs(plat.models) do ms[i] = { id = m, kind = "chat", priority = 20 } end return ms end)(), + } }) + end + else + for _, m in ipairs(plat.models or {}) do disabled[m] = true end + for _, a in ipairs(poolList(plat.id)) do a.enabled = false poolAdd(plat.id, a) dropAccountSource(plat.id, a.id) end + __llmsproxy_kernel.call("remove_source", { name = "channels-" .. plat.id }) + end + logEvent("供应商" .. (enabled and "启用 " or "关闭 ") .. plat.id) + return { status = 200, json = { ok = true } } +end + +-- ============================================================================ +-- 一键领取(对齐参考 credits.claimAll) +-- ============================================================================ +function plugin.credits_claim_all(req) + local args = bodyOf(req) + if args.platform == nil then args = req end + local plat = platformById(args and args.platform) + if not plat then return { status = 400, json = { error = "未知平台" } } end + local results = {} + local claimed = 0 + for _, a in ipairs(poolList(plat.id)) do + local r = plugin.claim_daily({ platform = plat.id, account_id = a.id, credential_ref = a.credentialRef }) + local ok = r.json and r.json.ok and true or false + if ok then claimed = claimed + 1 end + results[#results+1] = { accountId = a.id, outcome = ok + and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) } + or { kind = "failed", message = (r.json and r.json.error) or "失败" } } + end + -- 无池账号的走旧路径 + if #poolList(plat.id) == 0 then + local r = plugin.claim_daily({ platform = plat.id }) + local ok = r.json and r.json.ok and true or false + if ok then claimed = claimed + 1 end + results[#results+1] = { accountId = "legacy", outcome = ok + and { kind = "success", message = r.json.summary or tostring(r.json.upstream_status) } + or { kind = "failed", message = (r.json and r.json.error) or "失败" } } + end + return { status = 200, json = { ok = true, summary = { claimed = claimed }, results = results } } +end + +-- ============================================================================ +-- 备份导出/导入(对齐参考 backup.*) +-- ============================================================================ +function plugin.backup_export(req) + local payload = {} + payload.version = 1 + payload.exportedAt = os.time and os.time() or 0 + payload.accounts = {} + payload.credentials = {} + payload.modelDisabled = plugin.state.modelDisabled or {} + payload.autoCheckin = plugin.state.settings and plugin.state.settings.auto_checkin or true + for _, p in ipairs(PLATFORMS) do + for _, a in ipairs(poolList(p.id)) do + payload.accounts[#payload.accounts+1] = a + local tok = __llmsproxy_secrets.get(a.credentialRef) + if tok then payload.credentials[a.credentialRef] = tok end + end + -- 旧路径单账号也备份 + local lt = __llmsproxy_secrets.get("channels:" .. p.id) + if lt then payload.credentials["channels:" .. p.id] = lt end + end + return { status = 200, json = { ok = true, payload = payload } } +end + +function plugin.backup_import(req) + local args = bodyOf(req) + local payload = args and args.payload + if type(payload) ~= "table" then return { status = 400, json = { error = "payload 必须是对象" } } end + local n = 0 + -- 恢复凭据 + if type(payload.credentials) == "table" then + for ref, tok in pairs(payload.credentials) do + if type(ref) == "string" and type(tok) == "string" and tok ~= "" then + __llmsproxy_secrets.set(ref, tok) n = n + 1 + end + end + end + -- 恢复账号池 + if type(payload.accounts) == "table" then + for _, a in ipairs(payload.accounts) do + if type(a) == "table" and a.id and a.provider then + poolAdd(tostring(a.provider), { + id = tostring(a.id), provider = tostring(a.provider), + nickname = a.nickname or a.id, enabled = a.enabled ~= false, + credentialRef = a.credentialRef or ("channels:" .. a.provider .. ":" .. a.id), + refreshable = a.refreshable or false, createdAt = a.createdAt or (os.time and os.time() or 0), + expiresAt = a.expiresAt, last_claim = 0, last_claim_status = 0, last_claim_summary = "", + }) + end + end + end + -- 恢复模型黑名单 + if type(payload.modelDisabled) == "table" then + plugin.state.modelDisabled = payload.modelDisabled + end + -- 恢复自动签到开关 + if payload.autoCheckin ~= nil then + plugin.state.settings = plugin.state.settings or {} + plugin.state.settings.auto_checkin = payload.autoCheckin and true or false + end + -- 全部账号重新同步源 + for _, p in ipairs(PLATFORMS) do + for _, a in ipairs(poolList(p.id)) do syncAccountSource(p.id, a.id) end + end + logEvent("备份已导入,恢复 " .. n .. " 条凭据") + return { status = 200, json = { ok = true, restored = n } } +end + +-- auto_checkin 开关读写 +function plugin.auto_checkin(req) + plugin.state.settings = plugin.state.settings or {} + local args = bodyOf(req) + if args.enabled ~= nil then + plugin.state.settings.auto_checkin = args.enabled and true or false + logEvent("自动签到" .. (plugin.state.settings.auto_checkin and "开启" or "关闭")) + end + return { status = 200, json = { ok = true, autoCheckin = plugin.state.settings.auto_checkin ~= false } } +end + function plugin.set_endpoints(req) local args = bodyOf(req) if args.platform == nil then args = req end @@ -1339,23 +1885,57 @@ plugin.ui = { mount = [[
-

平台接入

-

登录在你自己的浏览器完成;凭据加密保存在服务端,凭据永不回显。

-
-
-