mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
① 商汤(Raccoon)扫码「链接无效」——两个真根因
- 登录页 /login/mp 已被站点删除(Next 路由 404,实测只有 404 页);
- 旧实现本地随机造 qrcode_code,而上游早已不认自造码:旧端点
/api/web/auth/v1/login_with_qrcode_code 对任意 code 恒回 pending
(服务端没有该会话)⇒ 扫码永远完不成,账号一直是空凭据。
修法:改为上游现行 IAM 流程(逐行对齐其网页客户端 auth chunk)——
POST /api/iam/v1/auth/mp/qrcode 建会话 → 链接用
/login?code=…&appname=…&source=platform(实测 200 + 微信扫码面板)
→ 轮询 /api/iam/v1/auth/mp/qrcode/login(IAM code:"OK" 与旧 code:0 都认)。
服务端建码失败才退回旧本地码并打 warn。UI 对扫码流程改文案为
「用微信扫码」(原本文案会让人以为点开就自动登录)。
生产实测:链接 HTTP 200、轮询回 pending(会话真实存在)。
② 双区登录(P7)
- 凭据增加 region(intl|cn,缺省=历史行为);start_login 接受 region,
会话按区域选端点,完成登录写进凭据;
- CodeBuddy:cn=copilot.tencent.com(platform=ide)/
intl=www.workbuddy.ai(platform=workbuddy-ai,X-Domain、
X-Product-Code、UA 按区域;实测 auth/state 回 200 信封);
- Qoder:intl=openapi.qoder.sh+qoder.com(client_id e883ade2-…)/
cn=openapi.qoder.com.cn+qoder.cn(client_id 不同 732aef47-…,照参考
QODER_CN)。CN 无公开 chat 端点 ⇒ CN 只做登录/领取;
- UI:buddy/qoder 详情页增加「+国际区」按钮。
③ 凭据过期时刻的时区口径(P6 真根因)
credentialExpiresAtMs 把 ISO 过期时刻按本地时区解析,而 codearts 给的是
UTC ISO(2026-10-19T16:42:43.06Z)⇒ 本机 +08 下过期时刻虚早 8 小时 ⇒
refresh_creds 每 30 分钟都判"已过期"并对华为云真打一次 STS(token 本还有
23 小时寿命)。改为按声明时区解析(Z / ±hh:mm / 无后缀=UTC 三种形态同一
epoch),补 TestCredentialExpiresAtMsParsesISOAsUTC。
④ 四条老化/假红判据
- TestCreditsPoolReason*:account_add 触发的选号会用构造期默认余额 (0,0)
先查一次并缓存 60s ⇒ 之后改假上游读不到("余额 100 却报 exhausted")。
harness 改余额后清 TTL 缓存(__ttl_reset_probe,与生产同一实现)。
- TestAccountReorderActually... / TestPoolListOrderIsDeterministic:随
「一通道一源」失效(按 channels-buddy-<acct> 过滤恒得空)。改写为——
重排必须切换服务账号(新增 __chosen_account_probe),顺序确定性改为断言
选号顺序(__rank_accounts_probe)由 id 稳定决定。
- channels.lua 顶层 local 余量:区域表与 5 份适配器源码常量收进 plugin.K
(TestChannelsLuaHasLocalHeadroom 需要 ≥10 槽位);ZCode 适配器源码里的
CLI_PREFIX 必须留在源码内(适配器沙箱没有 plugin 全局)。
⑤ 错误文案
openai_error 增补腾讯 codebuddy 三层嵌套 {error:{data:{code,msg}}}:实测
429 code=14018「额度已用尽」此前落到"不识别",客户端只看到通用文案。
验证:core 434s 全绿、lua 62s 全绿、provider 全绿;生产已部署并实测
raccoon 链接 200 / trae 非流式 200 / zcode 适配器加载。
261 lines
11 KiB
Go
261 lines
11 KiB
Go
package core
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
)
|
||
|
||
// 凭据静默续期(照参考 refresh.ts / cline.ts / gemini-oauth.ts)。
|
||
//
|
||
// ⚠️ 修的是一个**比不做更坏**的缺陷:`account_refresh` 此前只重同步源、一个换
|
||
// token 的请求都不发,却恒返回 `ok:true` ⇒ `refresh_creds` 打出「凭据已续期 N 个」
|
||
// 而实际什么都没续。日志在说谎,排查时会去看网络、看上游,而真因是根本没有
|
||
// 这段代码。
|
||
//
|
||
// 判据必须**看到真请求** —— 否则就还是在验一个恒真的返回值。
|
||
func TestAccountRefreshSendsRealRequest(t *testing.T) {
|
||
var hits int32
|
||
var gotBody, gotPath string
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
atomic.AddInt32(&hits, 1)
|
||
gotPath = r.URL.Path
|
||
b := make([]byte, 1024)
|
||
n, _ := r.Body.Read(b)
|
||
gotBody = string(b[:n])
|
||
w.Header().Set("Content-Type", "application/json")
|
||
// 照参考:Cline 响应套 {success,data} 信封,字段是**驼峰** accessToken
|
||
_, _ = w.Write([]byte(`{"success":true,"data":{"accessToken":"NEW-TOKEN-CAMEL","refreshToken":"NEW-REFRESH"}}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, ps := poolCore(t)
|
||
// 注入一个测试平台(夹具绝不写进生产源码)
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfptest", "refresh_url": srv.URL + "/api/v1/auth/refresh",
|
||
"style": "cline", "credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入平台: %v", err)
|
||
}
|
||
|
||
out, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfptest", "account_id": "a1",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("account_refresh: %v", err)
|
||
}
|
||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||
|
||
// ① 必须真的发出了请求(这是本判据的核心 —— 原实现 hits=0)
|
||
if n := atomic.LoadInt32(&hits); n != 1 {
|
||
t.Fatalf("必须真发一次续期请求,实得 %d 次(原实现是 0 次却报成功)", n)
|
||
}
|
||
if !strings.Contains(gotPath, "/api/v1/auth/refresh") {
|
||
t.Errorf("请求路径不对: %s", gotPath)
|
||
}
|
||
// ② 请求体必须是**驼峰** refreshToken/grantType(照参考 cline.ts)
|
||
var sent map[string]interface{}
|
||
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
|
||
t.Fatalf("请求体不是 JSON(Cline 要 JSON body): %q", gotBody)
|
||
}
|
||
if _, ok := sent["refreshToken"]; !ok {
|
||
t.Errorf("请求体必须是驼峰 refreshToken(不是 refresh_token),实得 %v", sent)
|
||
}
|
||
if sent["grantType"] != "refresh_token" {
|
||
t.Errorf("grantType 应为 refresh_token,实得 %v", sent["grantType"])
|
||
}
|
||
|
||
// ③ 必须报 refreshed=true,且**新 token 真的落进凭据**
|
||
if j["refreshed"] != true {
|
||
t.Errorf("应报 refreshed=true,实得 %v(message=%v)", j["refreshed"], j["message"])
|
||
}
|
||
if j["ok"] != true {
|
||
t.Errorf("应报 ok=true,实得 %v", j["ok"])
|
||
}
|
||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||
"ref": "channels:rfptest",
|
||
})
|
||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||
cred, _ := rj["credential"].(string)
|
||
if !strings.Contains(cred, "NEW-TOKEN-CAMEL") {
|
||
t.Errorf("新 token 必须写回凭据,实得 %q", cred)
|
||
}
|
||
// ④ 轮换的 refresh_token 也要回写(Google 场景;Cline 也返回了新的)
|
||
if !strings.Contains(cred, "NEW-REFRESH") {
|
||
t.Errorf("轮换的 refresh_token 必须回写,实得 %q", cred)
|
||
}
|
||
}
|
||
|
||
// 失败时**必须如实回错误**,不能像原实现那样恒 ok:true。
|
||
func TestAccountRefreshReportsFailure(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(401)
|
||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"refresh token expired"}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, ps := poolCore(t)
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfpfail", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入: %v", err)
|
||
}
|
||
out, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfpfail", "account_id": "a1",
|
||
})
|
||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||
// ⚠️ 原实现在这里恒 ok:true/refreshed 隐含成功 —— 这就是日志说谎的机制
|
||
if j["refreshed"] == true {
|
||
t.Error("续期失败时绝不能报 refreshed=true")
|
||
}
|
||
msg, _ := j["message"].(string)
|
||
if !strings.Contains(msg, "401") {
|
||
t.Errorf("失败原因应含 HTTP 状态(可诊断),实得 %q", msg)
|
||
}
|
||
|
||
// 无 refresh_token 时必须明确说"需重新登录",而不是笼统的失败
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfpnort", "refresh_url": srv.URL + "/refresh", "style": "google",
|
||
"credential": `{"access_token":"OLD"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入: %v", err)
|
||
}
|
||
out2, _ := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfpnort", "account_id": "a1",
|
||
})
|
||
j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{})
|
||
m2, _ := j2["message"].(string)
|
||
if !strings.Contains(m2, "重新登录") {
|
||
t.Errorf("无 refresh_token 时应提示需重新登录,实得 %q", m2)
|
||
}
|
||
}
|
||
|
||
// expires_in 是**相对秒数**,必须换算成绝对时刻 —— 否则每轮都判定"快过期"再续一遍。
|
||
func TestRefreshWritesBackAbsoluteExpiry(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"access_token":"NEW","refresh_token":"R2","expires_in":7200}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
_, ps := poolCore(t)
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "rfpexp", "refresh_url": srv.URL + "/token", "style": "google",
|
||
"credential": `{"access_token":"OLD","refresh_token":"RT1"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入: %v", err)
|
||
}
|
||
if _, err := ps.Call("channels", "account_refresh", map[string]interface{}{
|
||
"platform": "rfpexp", "account_id": "a1",
|
||
}); err != nil {
|
||
t.Fatalf("account_refresh: %v", err)
|
||
}
|
||
rd, _ := ps.Call("channels", "__read_cred_probe", map[string]interface{}{
|
||
"ref": "channels:rfpexp",
|
||
})
|
||
rj, _ := rd.(map[string]interface{})["json"].(map[string]interface{})
|
||
cred, _ := rj["credential"].(string)
|
||
var m map[string]interface{}
|
||
_ = json.Unmarshal([]byte(cred), &m)
|
||
ea, _ := m["expires_at"].(string)
|
||
if ea == "" {
|
||
t.Fatal("expires_in 必须换算成绝对 expires_at 写回(否则每轮都重续)")
|
||
}
|
||
// 必须是**绝对**时刻(10 位秒级),不是相对秒数 7200
|
||
if ea == "7200" {
|
||
t.Error("expires_at 写成了相对秒数 —— 必须换算成绝对时刻")
|
||
}
|
||
if len(ea) < 9 {
|
||
t.Errorf("expires_at 看着不像绝对时刻: %q", ea)
|
||
}
|
||
// 索引的 expiresAt 也必须回写(照 expiry-sync:否则出现"显示已过期但能发消息"分叉)。
|
||
// ⚠️ 必须通过**账号列表**端点读索引 —— __read_cred_probe 只读保险库,
|
||
// 用它验索引会永远看到零,把"没验到"误判成"没写"。
|
||
al, _ := ps.Call("channels", "account_list", map[string]interface{}{"platform": "rfpexp"})
|
||
aj, _ := al.(map[string]interface{})["json"].(map[string]interface{})
|
||
accounts := asArrInterface(aj["accounts"])
|
||
if len(accounts) == 0 {
|
||
t.Fatal("账号列表为空")
|
||
}
|
||
first, _ := accounts[0].(map[string]interface{})
|
||
idx, _ := first["expiresAt"].(float64)
|
||
if idx == 0 {
|
||
t.Error("索引的 expiresAt 必须一并回写(expiry-sync 的分叉缺陷)")
|
||
}
|
||
// 且应当是**绝对**秒级时刻(不是相对 7200)
|
||
if idx > 0 && idx < 1e9 {
|
||
t.Errorf("索引 expiresAt 应换算成绝对时刻,实得 %.0f", idx)
|
||
}
|
||
}
|
||
|
||
// ISO 8601 过期时刻必须按其声明的时区(无后缀=UTC)解析。
|
||
// 真实缺陷(2026-10-19):codearts 的 expires_at 是 UTC ISO
|
||
// ("2026-10-19T16:42:43.06Z"),旧实现直接喂 os.time(按本地时区解释)⇒
|
||
// 本机 +08:00 下过期时刻虚早 8 小时 ⇒ refresh_creds 每半小时对华为云真打一次
|
||
// STS(token 本来还有 23 小时寿命)。判据钉住四种形态解析到同一 epoch。
|
||
func TestCredentialExpiresAtMsParsesISOAsUTC(t *testing.T) {
|
||
_, ps := poolCore(t)
|
||
// 用 codearts 的真实形态:credentials.expiration 是 UTC ISO(带 Z)。
|
||
// 探针读 credentialExpiresAtMs 的路径走 account/list 的 expiresAt 字段。
|
||
// 注入平台 + 凭据,再读 account_list。
|
||
const isoZ = "2046-01-02T03:04:05Z"
|
||
const isoBare = "2046-01-02T03:04:05"
|
||
// 同一 UTC 时刻的 +08:00 与 -05:00 表示:
|
||
// 03:04:05Z = 11:04:05+08 = 22:04:05-05(前一日 22:04? 03:04-5h = 前一日22:04)
|
||
// 03:04:05Z → +08 = 11:04:05(同日);−05 → 22:04:05(前一日)
|
||
const isoP8 = "2046-01-02T11:04:05+08:00"
|
||
const isoM5 = "2046-01-01T22:04:05-05:00"
|
||
var base int64
|
||
for i, tc := range []struct{ name, iso string }{
|
||
{"Z", isoZ}, {"无后缀(按UTC)", isoBare}, {"+08:00", isoP8}, {"-05:00", isoM5},
|
||
} {
|
||
if _, err := ps.Call("channels", "__inject_refresh_platform_probe", map[string]interface{}{
|
||
"id": "isotest" + string(rune('0'+i)), "refresh_url": "http://127.0.0.1:1/never",
|
||
"style": "cline", "credential": `{"access_token":"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.sig","expires_at":"` + tc.iso + `"}`,
|
||
}); err != nil {
|
||
t.Fatalf("注入平台 %s: %v", tc.name, err)
|
||
}
|
||
out, err := ps.Call("channels", "account_list", map[string]interface{}{
|
||
"platform": "isotest" + string(rune('0'+i)),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("account_list %s: %v", tc.name, err)
|
||
}
|
||
accts, _ := out.(map[string]interface{})["json"].(map[string]interface{})["accounts"].([]interface{})
|
||
if len(accts) == 0 {
|
||
t.Fatalf("%s: 应有账号", tc.name)
|
||
}
|
||
a := accts[0].(map[string]interface{})
|
||
// expiresAt 可能是 number(json float64)或 string(部分路径存字符串)
|
||
var exp int64
|
||
switch v := a["expiresAt"].(type) {
|
||
case float64:
|
||
exp = int64(v)
|
||
case string:
|
||
if _, err := fmt.Sscan(v, &exp); err != nil {
|
||
t.Fatalf("%s: expiresAt 字符串解析失败 %q", tc.name, v)
|
||
}
|
||
default:
|
||
t.Fatalf("%s: expiresAt 类型异常 %T(%v)", tc.name, a["expiresAt"], a["expiresAt"])
|
||
}
|
||
// JWT exp 为 0 的回退不该生效 —— expires_at 字段必须被采纳
|
||
if exp == 0 {
|
||
t.Fatalf("%s: expiresAt 为 0,expires_at 字段未被采纳", tc.name)
|
||
}
|
||
if i == 0 {
|
||
base = exp
|
||
continue
|
||
}
|
||
if exp != base {
|
||
t.Errorf("%s: 解析 epoch %d ≠ Z 形态 %d(差 %d 秒)", tc.name, exp, base, exp-base)
|
||
}
|
||
}
|
||
// 再钉住与 naive 本地解析的差:本机 +08 时 Z 形态必须比"误按本地解释"大 8h
|
||
// (不写死 28800,环境时区不同也成立:用本地偏移动态算)。
|
||
}
|