mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): account_test 从不发请求(假阳性)+ 业务级错误被当成功
两个真实缺陷,都属"看着能用、一用就错"的假阳性 —— 正是用户报「完全不可用」时
最容易误导人的那类信号。
## ① 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 个包全绿。
This commit is contained in:
105
internal/core/channels_account_probe_test.go
Normal file
105
internal/core/channels_account_probe_test.go
Normal file
@ -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"])
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user