From 67e2ba070ff4348e90c6d86425d7d1a4adaa1206 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 15:35:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20account=5Ftest=20=E4=BB=8E?= =?UTF-8?q?=E4=B8=8D=E5=8F=91=E8=AF=B7=E6=B1=82=EF=BC=88=E5=81=87=E9=98=B3?= =?UTF-8?q?=E6=80=A7=EF=BC=89+=20=E4=B8=9A=E5=8A=A1=E7=BA=A7=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E8=A2=AB=E5=BD=93=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个真实缺陷,都属"看着能用、一用就错"的假阳性 —— 正是用户报「完全不可用」时 最容易误导人的那类信号。 ## ① account_test 一个网络请求都不发 原实现只做 syncAccountSource,然后查内核源列表里有没有那条记录,就返回 `{ok=true, ms=1}`(`ms` 甚至是硬编码的 1)。于是"测试账号"**永远绿**, 包括凭据已失效、被限流、上游宕机的情况。 照参考 account-probe 的语义改为**真打一次上游**:优先余额端点(最轻、所有有 余额的平台都有),退化到 checkin_status;两者都没有的平台如实报 「未声明可探测端点,仅校验本地凭据与源」,**不假装成功**。 失败还分两类(照参考:只说"失败"用户不知道该做什么): - `kind=auth`(401/403/invalid_grant/expired)→ "凭据已失效,请重新登录该账号" - `kind=transient` → "上游暂时不可用,可稍后重试" ## ② fetchAccountJSON 只看 HTTP 状态码,业务级错误被当成功 多家上游**HTTP 200 但 body 里带 error**(实测 buddy 的 invalid_grant 就是这个 形态)。只看状态码时"凭据已失效"会被当成成功。现补判 body 里的 `error`(字符串/对象)、`code`(非 0 即失败)。 这一条同时修好了所有走这个助手的路径:余额查询、签到状态、以及新的账号探测。 ## 判据 `channels_account_probe_test.go`(假上游 + 请求计数器)三条: 1. 正常凭据:**必须真的发了请求**(用原子计数器证明 hits>0)且 probed=true 2. 凭据失效:ok=false、kind=auth、必须带可操作提示 3. 无凭据账号:如实报错,不假装成功 回归:9 个包全绿。 --- internal/core/channels_account_probe_test.go | 105 +++++++++++++++++++ internal/lua/plugins/channels.lua | 76 ++++++++++++-- 2 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 internal/core/channels_account_probe_test.go diff --git a/internal/core/channels_account_probe_test.go b/internal/core/channels_account_probe_test.go new file mode 100644 index 0000000..8289121 --- /dev/null +++ b/internal/core/channels_account_probe_test.go @@ -0,0 +1,105 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +// `account_test` 必须**真打一次上游**。 +// +// 真实缺陷:此前它一个网络请求都不发 —— 只 syncAccountSource 后查内核源列表里 +// 有没有那条记录,就返回 {ok:true, ms:1}。于是"测试账号"永远绿,包括凭据已失效、 +// 被限流、上游宕机的情况。这正是「看着能用、一用就错」的假阳性,也正是用户报 +// 「完全不可用」时最容易误导人的那种信号。 +// +// 判据把三条都钉住:① 真发请求 ② 上游失败时必须 ok=false ③ 失败要区分 +// "凭据失效"(需重新登录)与"暂时性失败"(可重试)—— 只说"失败"用户不知道该做什么。 +func TestAccountTestReallyProbesUpstream(t *testing.T) { + var hits int32 + fail := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + if fail { + w.WriteHeader(200) + _, _ = w.Write([]byte(`{"error":"invalid_grant: token expired"}`)) + return + } + body, _ := json.Marshal(map[string]interface{}{ + "data": map[string]interface{}{"Response": map[string]interface{}{ + "Data": map[string]interface{}{"Accounts": []map[string]interface{}{ + {"Status": 1, "CycleCapacityRemain": 100}, + }}, + }}, + }) + _, _ = w.Write(body) + })) + defer ts.Close() + + _, ps := poolCore(t) + if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{ + "platform": "buddy", + "balance": map[string]interface{}{"url": ts.URL}, + }); err != nil { + t.Fatalf("set_endpoints: %v", err) + } + if _, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "buddy", "account_id": "probeacct", + "credential": `{"access_token":"TOK"}`, + }); err != nil { + t.Fatalf("account_add: %v", err) + } + + // ① 正常路径:必须真的打了上游 + atomic.StoreInt32(&hits, 0) + m, err := ps.Call("channels", "account_test", map[string]interface{}{ + "platform": "buddy", "account_id": "probeacct", + }) + if err != nil { + t.Fatalf("account_test: %v", err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j["ok"] != true { + t.Fatalf("正常凭据应 ok=true: %#v", j) + } + if j["probed"] != true { + t.Errorf("必须标记 probed=true 并真的发请求;实得 probed=%v", j["probed"]) + } + if n := atomic.LoadInt32(&hits); n == 0 { + t.Fatal("account_test 未向上游发任何请求 —— 这就是「看着能用」的假阳性") + } + + // ② 凭据失效:必须 ok=false,且归类为 auth(给出可操作提示) + fail = true + m2, _ := ps.Call("channels", "account_test", map[string]interface{}{ + "platform": "buddy", "account_id": "probeacct", + }) + j2, _ := m2.(map[string]interface{})["json"].(map[string]interface{}) + if j2["ok"] != false { + t.Errorf("凭据失效时必须 ok=false,实得 %v", j2["ok"]) + } + if j2["kind"] != "auth" { + t.Errorf("应归类为 auth(需重新登录),实得 %v", j2["kind"]) + } + if s, _ := j2["action"].(string); s == "" { + t.Error("失败必须给出可操作提示(否则用户不知道要重新登录还是等一会)") + } + + // ③ 无凭据的账号:如实报错,不假装成功 + if _, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "buddy", "account_id": "naked", + }); err != nil { + t.Fatalf("account_add(无凭据): %v", err) + } + m3, _ := ps.Call("channels", "account_test", map[string]interface{}{ + "platform": "buddy", "account_id": "naked", + }) + j3, _ := m3.(map[string]interface{})["json"].(map[string]interface{}) + if j3["ok"] != false { + t.Errorf("无凭据时必须 ok=false,实得 %v", j3["ok"]) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 52888d5..2368749 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -5675,6 +5675,18 @@ end -- account_test:测账号(对齐 account.test)—— 做一次上游可达探测。 -- 简化:内核 probe_all 是全局的;这里用 list_sources 里该账号源存在与否 + 凭据非空作为可达判据。 +-- account_test:**真打一次上游**探测该账号是否可用(对齐参考 account-probe 的 +-- 「重测」语义)。 +-- +-- ⚠️ 语义修正(真实缺陷):此前这个函数**一个网络请求都不发** —— 它只做 +-- syncAccountSource 然后查内核源列表里有没有那条记录,就返回 `{ok=true, ms=1}`。 +-- 于是"测试账号"永远绿,包括凭据已失效、被限流、上游宕机的情况 —— +-- 正是「看着能用、一用就错」的那类假阳性。 +-- +-- 照参考的两点关键设计: +-- 1. **只针对指定账号、不产生副作用**:不能顺手把限流标记写到别的账号上。 +-- 2. **停用账号也要能测**:用户明确要求(凭据解析不检查 enabled)。 +-- 我们额外可选地按声明打一次真实请求(余额端点最轻,退化用 checkin_status)。 function plugin.account_test(req) local args = bodyOf(req) if args.account_id == nil then args = req end @@ -5683,16 +5695,44 @@ function plugin.account_test(req) local acct = poolGet(plat.id, args and args.account_id) if not acct then return { status = 400, json = { error = "账号不存在" } } end local tok = credentialOf(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 + if not tok or tok == "" then + return { status = 200, json = { ok = false, error = "无凭据(需先登录)", + probed = false } } end - if not found then return { status = 200, json = { ok = false, error = "源未同步(上游可能不可达)" } } end - return { status = 200, json = { ok = true, ms = 1 } } + -- 同步一次源(确保最新凭据已入)—— 这是本地动作,不是"测试" + syncAccountSource(plat.id, acct.id) + + -- 真正的一次上游请求:优先余额端点(最轻、所有有余额的平台都有), + -- 退化到 checkin_status。两者都没有的平台如实报"无探测端点", + -- 不假装成功。 + local spec = plat.balance or plat.checkin_status + if not spec or not spec.url then + 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 + return { status = 200, json = { ok = found, probed = false, + error = found and nil or "源未同步", + note = "该平台未声明可探测端点,仅校验本地凭据与源" } } + end + local t0 = os.clock and os.clock() or 0 + local rec, err = fetchAccountJSON(spec, tok, credentialFull(acct.credentialRef), plat) + local ms = math.floor(((os.clock and os.clock() or 0) - t0) * 1000) + if err then + -- 失败要区分"凭据失效"(需重新登录)与"暂时性失败"(可重试)—— + -- 只说"失败"用户不知道该做什么。 + local kind = "transient" + local el = tostring(err):lower() + if el:find("401") or el:find("403") or el:find("unauthor") + or el:find("invalid_grant") or el:find("expired") then + kind = "auth" + end + return { status = 200, json = { ok = false, probed = true, ms = ms, + error = tostring(err), kind = kind, + action = kind == "auth" and "凭据已失效,请重新登录该账号" or "上游暂时不可用,可稍后重试" } } + end + return { status = 200, json = { ok = true, probed = true, ms = ms } } end -- account_refresh:刷新账号(对齐 account.refresh)—— 重新同步源 + 重新查余额 @@ -6010,6 +6050,24 @@ fetchAccountJSON = function(spec, tok, credTable, plat) if resp.status ~= 200 then return nil, "HTTP " .. tostring(resp.status) end local ok, rec = pcall(json.decode, resp.body or "") if not ok or type(rec) ~= "table" then return nil, "响应无法解析" end + -- ⚠️ 业务级错误必须在这里也判掉:多家上游**HTTP 200 但 body 里带 error** + -- (实测 buddy 的 invalid_grant 就是这个形态)。只看 HTTP 状态码的话, + -- "凭据已失效"会被当成成功 —— 账号测试于是永远报绿,这是最误导人的假阳性。 + local e = rec.error + if type(e) == "string" and e ~= "" then return nil, e end + if type(e) == "table" then + local msg = e.message or e.msg + if type(msg) == "string" and msg ~= "" then return nil, msg end + return nil, "上游返回 error 对象" + end + -- 有些上游把错误放在 code/msg 里(code 非 0 即失败) + local code = rec.code + if code ~= nil then + local n = tonumber(code) + if n and n ~= 0 and n ~= 200 then + return nil, tostring(rec.msg or rec.message or ("业务码 " .. tostring(code))) + end + end return rec, nil end