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 适配器加载。
238 lines
8.3 KiB
Go
238 lines
8.3 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
|
||
//
|
||
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
|
||
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
|
||
// 而号里其实有钱。两者的**正确动作完全相反**:
|
||
//
|
||
// exhausted → 等额度重置
|
||
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
|
||
//
|
||
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
|
||
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
|
||
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
h.serveBalance(0, 0)
|
||
got := h.reason()
|
||
if got["reason"] != "exhausted" {
|
||
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
|
||
got["reason"], got["detail"])
|
||
}
|
||
})
|
||
|
||
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
h.serveBalanceError(500)
|
||
got := h.reason()
|
||
if got["reason"] != "unknown" {
|
||
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
|
||
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
|
||
}
|
||
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
|
||
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
|
||
}
|
||
})
|
||
|
||
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
got := h.reason()
|
||
if got["reason"] != "no_accounts" {
|
||
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
|
||
}
|
||
})
|
||
|
||
t.Run("有可用账号 → usable", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
h.serveBalance(100, 0)
|
||
got := h.reason()
|
||
if got["reason"] != "usable" {
|
||
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
|
||
}
|
||
if u, _ := got["usable"].(float64); int(u) != 1 {
|
||
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
|
||
}
|
||
})
|
||
|
||
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
// ⚠ 必须有**启用的账号**:判定的优先级是
|
||
// no_accounts → 无余额端点 → usable → unknown/exhausted
|
||
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
|
||
// 那是**正确**行为),把正确的实现判成了错。
|
||
h.addAccountOn("minimax", "m1")
|
||
// minimax 未声明 balance 端点
|
||
got := h.reasonOn("minimax")
|
||
if got["reason"] != "unknown" {
|
||
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
|
||
got["reason"], got["detail"])
|
||
}
|
||
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
|
||
t.Fatalf("说明必须点明无法判定,实得 %q", d)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
|
||
// (用户动作不同:解锁 vs 等重置)。
|
||
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
|
||
h := newPoolReasonHarness(t)
|
||
h.addAccount("a1")
|
||
// total_fn 取包列表:只有永久积分(到期未知)
|
||
h.serveBalance(0, 50)
|
||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||
map[string]interface{}{"lock": true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := h.reason()
|
||
if got["reason"] != "locked" {
|
||
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
|
||
got["reason"], got["detail"])
|
||
}
|
||
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
|
||
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
|
||
map[string]interface{}{"lock": false}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
h.serveBalance(0, 50)
|
||
if got2 := h.reason(); got2["reason"] != "usable" {
|
||
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
|
||
}
|
||
}
|
||
|
||
// ── harness ──
|
||
|
||
type poolReasonHarness struct {
|
||
t *testing.T
|
||
ps *lua.Plugins
|
||
ts *httptest.Server
|
||
body atomic.Value
|
||
fail atomic.Bool
|
||
hasAcct atomic.Bool
|
||
}
|
||
|
||
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
|
||
t.Helper()
|
||
h := &poolReasonHarness{t: t}
|
||
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
|
||
h.body.Store(buddyPkgBody(0, 0))
|
||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if h.fail.Load() {
|
||
w.WriteHeader(500)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
fmt.Fprint(w, h.body.Load().(string))
|
||
}))
|
||
t.Cleanup(h.ts.Close)
|
||
|
||
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)
|
||
}
|
||
h.ps = ps
|
||
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
|
||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||
"platform": "buddy",
|
||
"balance": map[string]interface{}{"url": h.ts.URL},
|
||
}); err != nil {
|
||
t.Logf("set_endpoints(balance) 未支持: %v", err)
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (h *poolReasonHarness) addAccount(id string) {
|
||
h.t.Helper()
|
||
h.addAccountOn("buddy", id)
|
||
}
|
||
|
||
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
|
||
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
|
||
// no_accounts —— 判据写错而非实现错)。
|
||
func (h *poolReasonHarness) addAccountOn(platform, id string) {
|
||
h.t.Helper()
|
||
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
|
||
"platform": platform, "account_id": id,
|
||
"credential": `{"access_token":"AT","uid":"u1"}`,
|
||
}); err != nil {
|
||
h.t.Fatalf("account_add(%s): %v", platform, err)
|
||
}
|
||
h.hasAcct.Store(true)
|
||
}
|
||
|
||
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
|
||
//
|
||
// ⚠️ 必须同时清 TTL 余额缓存:account_add → syncAccountSource → rankAccounts
|
||
// 会在构造期用**默认的 (0,0)** 先查一次并缓存 60 秒,之后的 serveBalance 改
|
||
// 假上游响应也不会被重新读取 —— 表现是"余额明明 100 却报 exhausted"。
|
||
// __ttl_reset_probe 是插件自己的清缓存出口(与生产同一实现)。
|
||
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
|
||
h.fail.Store(false)
|
||
h.body.Store(buddyPkgBody(expiring, permanent))
|
||
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
|
||
h.t.Fatalf("清 TTL 缓存: %v", err)
|
||
}
|
||
}
|
||
|
||
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
|
||
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
|
||
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
|
||
func buddyPkgBody(expiring, permanent int) string {
|
||
pkgs := []string{}
|
||
if expiring > 0 {
|
||
// 3 天到期(< 15 天窗口)
|
||
endMs := (time.Now().Unix() + 3*86400) * 1000
|
||
pkgs = append(pkgs, fmt.Sprintf(
|
||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
|
||
}
|
||
if permanent > 0 {
|
||
// DeductionEndTime=0(未知)⇒ 永久桶
|
||
pkgs = append(pkgs, fmt.Sprintf(
|
||
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
|
||
}
|
||
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
|
||
}
|
||
|
||
func (h *poolReasonHarness) serveBalanceError(code int) {
|
||
h.fail.Store(true)
|
||
// 同上:清掉构造期缓存,否则"查询失败"用例会命中旧的成功缓存。
|
||
if _, err := h.ps.Call("channels", "__ttl_reset_probe", map[string]interface{}{}); err != nil {
|
||
h.t.Fatalf("清 TTL 缓存: %v", err)
|
||
}
|
||
}
|
||
|
||
func (h *poolReasonHarness) reason() map[string]interface{} {
|
||
h.t.Helper()
|
||
return h.reasonOn("buddy")
|
||
}
|
||
|
||
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
|
||
h.t.Helper()
|
||
res, err := h.ps.Call("channels", "credits_pool_reason",
|
||
map[string]interface{}{"platform": platform})
|
||
if err != nil {
|
||
h.t.Fatalf("credits_pool_reason: %v", err)
|
||
}
|
||
return jsonOf(h.t, res)
|
||
}
|