mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
fix(channels): 登录后账号拿不到凭据 —— 完成路径不传 account_id + 只存裸 token
用户报障:「登录了,但插件这边无法获取账号信息」。实证: account_list → has_credential=false,credits/balances → 「无凭据」, 而 UI 上账号显示已登录。 ## 根因一:完成路径没把凭据写进池账号的键 UI 的真实流程是「account/add 建占位账号(credentialRef=channels:<plat>:<aid>) → login 带 account_id → 轮询 login/status」。但 poll_login 的两条完成分支 (device-poll / state-poll)调用 complete_login 时**没有传 account_id**, 于是走了兼容旧路径,凭据落到 `channels:<plat>` 这个与占位账号无关的键; 池账号的 credentialRef 指向 `channels:<plat>:<aid>`,从未写入 → account_list 的 has_credential 恒为 false。 修:两条分支都传 `account_id = s.account_id`(对齐参考 BuddyAuth.login 的 `flowOptions.accountId` 语义:多账号场景下凭据按账号 ref 写入)。 ## 根因二:只存裸 access_token,丢掉 refresh_token 与过期信息 state-poll 分支只取 `data.accessToken` 当凭据存,而参考 `runBuddyLoginFlow` 存的是 `buildCredential(token, account)` 的**完整 JSON** (access_token + refresh_token + expires_at + refresh_expires_at + token_type + scope + domain + user_id + nickname + enterprise_id + account_type)。 只存裸 token 的后果:refreshable 恒为 false → 无静默续期;expires_at 未知。 修:新增 buildCredential(逐字段对齐参考 buddy.ts)+ jwtClaim/absExpiryMs (参照实现:token 端点只回 expiresIn 不回 expiresAt,昵称只在 JWT 声明里) + credentialExpiresAtMs。state-poll 完成时存完整凭据 JSON 并回报 expires_at/refreshable。 ## 配套 - 新增 credentialOf(ref):凭据取值助手,**裸 token 与完整 JSON 两种形态都认** (历史数据/device-poll/paste 是裸 token,buddy 是 JSON)。所有消费点 (上游源 api_key、Bearer 头、HMAC 签名、备份)改走它,无须关心存储形态。 - complete_login 从凭据自动补齐 nickname/expiresAt/refreshable,调用方漏传也不丢。 - 内核新增 base64_decode(标准/base64url/无填充都认):插件读 JWT payload 必需,否则要在 Lua 里重写 base64。通用能力,无厂商知识。 ## 判据 TestBuddyStatePollBehavior 行2/行4: - 行2 断言凭据是完整 JSON(含 refresh_token + 昵称),非裸 token - 行4 回归用户报障:走 account/add → 带 account_id 登录,断言 ① 凭据落在 channels:buddy:<aid> ② account_list 报 has_credential=true ③ refreshable=true 变异验证:去掉 account_id → 行4 立即 FAIL(已实测)。 go test ./internal/core/ 与 ./internal/lua/ 全绿。
This commit is contained in:
@ -260,6 +260,38 @@ func (h *buddyHarness) begin() (map[string]interface{}, string) {
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string)
|
||||
}
|
||||
|
||||
// beginPool 走 UI 的真实流程:先 account/add 建占位账号,再带 account_id 登录。
|
||||
// 这正是用户报障("登录了但无法获取账号信息")的那条路径。
|
||||
func (h *buddyHarness) beginPool() (map[string]interface{}, string, string) {
|
||||
add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
aid := add.JSON.(map[string]interface{})["account_id"].(string)
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: fmt.Sprintf(`{"platform":"buddy","account_id":%q}`, aid),
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
return j, j["session"].(map[string]interface{})["id"].(string), aid
|
||||
}
|
||||
|
||||
func (h *buddyHarness) accountList() []interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"buddy"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
accts, _ := m["accounts"].([]interface{})
|
||||
return accts
|
||||
}
|
||||
func (h *buddyHarness) poll(id string) map[string]interface{} {
|
||||
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
@ -287,9 +319,17 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
if j := h.poll(sid); j["state"] != "completed" || j["account"] != "buddy-dev" {
|
||||
t.Fatalf("次拍应 completed+buddy-dev: %v", j)
|
||||
}
|
||||
// 凭据是**完整凭据 JSON**(对齐参考 buildCredential),不再是裸 token:
|
||||
// 必须带 refresh_token,否则 refreshable=false、无法静默续期。
|
||||
v, _, _ := h.ps.GetSecret("channels", "channels:buddy")
|
||||
if v != "BD-AT-1" {
|
||||
t.Fatalf("凭据应 BD-AT-1, 实得 %q", v)
|
||||
if !strings.Contains(v, `"access_token":"BD-AT-1"`) {
|
||||
t.Fatalf("凭据应含 access_token, 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("凭据必须含 refresh_token(否则无法续期), 实得 %q", v)
|
||||
}
|
||||
if !strings.Contains(v, "buddy-dev") {
|
||||
t.Fatalf("凭据应回填昵称, 实得 %q", v)
|
||||
}
|
||||
})
|
||||
t.Run("行3 account查询失败不阻塞_回退默认名", func(t *testing.T) {
|
||||
@ -303,6 +343,46 @@ func TestBuddyStatePollBehavior(t *testing.T) {
|
||||
t.Fatalf("account 失败不应阻塞: %v", j)
|
||||
}
|
||||
})
|
||||
|
||||
// 行4 回归:UI 真实流程(account/add 建占位账号 → 带 account_id 登录)。
|
||||
// 缺陷复现:poll_login 完成时不传 account_id → 凭据落到 `channels:buddy`,
|
||||
// 而池账号 credentialRef 是 `channels:buddy:<aid>` → account_list 报
|
||||
// has_credential=false、credits 报「无凭据」——即用户报障的
|
||||
// 「登录了但无法获取账号信息」。
|
||||
t.Run("行4 池路径:登录后账号必须真的拿到凭据", func(t *testing.T) {
|
||||
atomic.StoreInt32(&h.tokStep, 0)
|
||||
_, sid, aid := h.beginPool()
|
||||
h.poll(sid) // 首拍 11217
|
||||
j := h.poll(sid)
|
||||
if j["state"] != "completed" {
|
||||
t.Fatalf("池路径登录应完成: %v", j)
|
||||
}
|
||||
// ① 凭据必须落在池账号的 ref 上
|
||||
v, ok, _ := h.ps.GetSecret("channels", "channels:buddy:"+aid)
|
||||
if !ok || v == "" {
|
||||
t.Fatalf("凭据未落到池账号 ref(用户报障的根因); aid=%s ok=%v", aid, ok)
|
||||
}
|
||||
if !strings.Contains(v, `"refresh_token":"BD-RT-1"`) {
|
||||
t.Fatalf("池凭据应是完整 JSON: %q", v)
|
||||
}
|
||||
// ② account_list 必须报 has_credential=true(UI 读的就是这个字段)
|
||||
found := false
|
||||
for _, raw := range h.accountList() {
|
||||
a, _ := raw.(map[string]interface{})
|
||||
if a["id"] == aid {
|
||||
found = true
|
||||
if a["has_credential"] != true {
|
||||
t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
|
||||
}
|
||||
if a["refreshable"] != true {
|
||||
t.Fatalf("refreshable 应为 true(有 refresh_token), 实得 %v", a["refreshable"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("池账号 %s 未出现在 account_list", aid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
Reference in New Issue
Block a user