From 38490690c5baf92f404ec78f243490434c334a71 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 09:36:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(channels):=20=E8=B4=A6=E5=8F=B7=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E7=9A=84=E7=A1=AE=E5=AE=9A=E6=80=A7=20+=20reorder=20?= =?UTF-8?q?=E7=9C=9F=E6=AD=A3=E6=94=B9=E5=86=85=E6=A0=B8=E6=BA=90=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ① 真实缺陷:同秒创建的账号,顺序**不确定** `poolList` 的排序键是 `priority` 降序 + `createdAt` **升序**,而 `createdAt` 是 **秒级**(`os.time()`)⇒ 同一秒建的两个账号比较**相等** ⇒ 相等时 `table.sort` 的结果取决于 `pairs()` 遍历顺序 —— **不确定**。 实测:按 a,b,c 顺序建三个账号,`poolList` 回的是 `[a,c,b]`。 后果不只是"顺序看着乱":**源注册顺序 = poolList 顺序 = 内核尝试顺序** ⇒ 同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下影响先用谁的积分), 且可能每次进程重启都变。修法:加 **id 作稳定兜底键**(唯一且不变)。 ## ② reorder 只改字段、不影响路由 `account_reorder` 只设 `acct.priority`,**不重注册源**。而一个源一旦注册, 顺序就固定了 ⇒ 重排对实际路由**毫无影响**("优先账号"并不会先被试)。 修法:重排后按新顺序**整体重放一遍** `syncAccountSource` (remove+add 会把源移到最后,故必须整体重放,只补被移动的那几个相对顺序仍不对)。 ## 判据(2 条 + 变异 2/2) internal/core/channels_reorder_effect_test.go - reorder 必须真的改变**内核 list_sources 的顺序**(判据看内核里的真实顺序, 不是 pool 里的 priority 字段) - 同优先级 + 同秒创建时,顺序必须由**稳定键**(id)决定(字典序) ★ 两个判据第一版**都不咬变异**,原因各异,值得记下: 1. reorder 判据:初始顺序恰好**已是**目标顺序 ⇒ 无法区分"重排生效"与"本来就对"。 改法:显式断言初始顺序、并让目标顺序与之**相反**。 2. 确定性判据:我直接断言 `account_add` 之后的顺序,而 `account_add` 是 **直接同步自己那一个账号**(源顺序 = add 顺序),**走不到排序逻辑** ⇒ 测的其实是 add 顺序。另外**多轮 in-process 反复构造也测不出** —— `pairs()` 顺序在同一次进程里是稳定的,只有跨进程/跨分配才变。 改法:走**批量重注册**路径(`__resync_sources_probe`)+ 显式把 priority 拉平 构造"排序键完全相同"的场景,再断言字典序。 ## 顺带修的判据自身问题 - `sourceOrder` 原来顺带调了 `__sync_source_probe`(内部 remove+add)⇒ **"测之前先动了被测对象"**,顺序忽而两个忽而一个。改为**只读**。 - 探针取内核返回时按 `{sources:[...]}` 取,而 `actionListSources` 返回的是 **裸数组** ⇒ 恒得空列表。 - 夹具漏授 `add_source` 内核权限 ⇒ 源一个都不出现(内核会明确拒绝,只是我 没看返回值)。子测试才能拿到独立的 env 与目录(同函数内 `t.Setenv` 只生效一次)。 --- internal/core/channels_reorder_effect_test.go | 251 ++++++++++++++++++ internal/lua/plugins/channels.lua | 77 +++++- 2 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 internal/core/channels_reorder_effect_test.go diff --git a/internal/core/channels_reorder_effect_test.go b/internal/core/channels_reorder_effect_test.go new file mode 100644 index 0000000..aae9aeb --- /dev/null +++ b/internal/core/channels_reorder_effect_test.go @@ -0,0 +1,251 @@ +package core + +import ( + "testing" + + "llmsproxy/internal/lua" +) + +// ★ account_reorder 必须**真的改变内核里的源顺序**。 +// +// 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里的 +// **源注册顺序** → 内核按 candidates 顺序依次尝试。 +// 而一个源一旦注册,顺序就固定了 —— 改 pool 记录不影响它。故重排后必须按新 +// 顺序 remove+add 一遍。 +// +// 判据看的是**内核 list_sources 里的真实顺序**(即内核实际会先试谁), +// 不是 pool 里的 priority 字段(那个改了不代表路由变了)。 +func TestAccountReorderActuallyReordersKernelSources(t *testing.T) { + h := newReorderHarness(t) + h.addAccount("acct-a") + h.addAccount("acct-b") + + // 先确认两个源都在,且初始顺序是 [a, b](account_add 的注册顺序)。 + // ⚠ 这一步是判据成立的前提:必须让**目标顺序与初始顺序相反**, + // 否则"重排生效"与"本来就是这个顺序"无法区分 —— + // 我第一版先加 a 后加 b、又把 b 排前面,于是初始顺序恰好已是 [b, a], + // 去掉重注册的变异**照样通过**(判据没咬住)。 + before := h.sourceOrder() + if len(before) < 2 { + t.Fatalf("应有 2 个账号源,实得 %v", before) + } + if before[0] != "channels-buddy-acct-a" { + t.Fatalf("前置条件不成立:初始顺序应为 [a, b](add 顺序),实得 %v —— "+ + "若初始已是目标顺序,本判据无法区分「重排生效」与「本来就对」", before) + } + + // 把 b 排到前面(ordered_ids 顺序即优先级,第一个最高)—— + // 目标顺序与初始相反,故只有真的重注册才能达成。 + if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{ + "platform": "buddy", + "ordered_ids": []interface{}{"acct-b", "acct-a"}, + }); err != nil { + t.Fatal(err) + } + after := h.sourceOrder() + if len(after) < 2 { + t.Fatalf("重排后源丢了:%v", after) + } + + idxOf := func(list []string, id string) int { + for i, n := range list { + if n == id { + return i + } + } + return -1 + } + b, a := idxOf(after, "channels-buddy-acct-b"), idxOf(after, "channels-buddy-acct-a") + if b < 0 || a < 0 { + t.Fatalf("重排后找不到账号源:%v", after) + } + if b > a { + t.Fatalf("重排未生效:acct-b 应排在 acct-a **之前**(内核先试它),"+ + "实际顺序 %v\n(只改 pool.priority 不改源注册顺序的话,路由不会变 —— "+ + "快到期积分仍不会优先消耗)", after) + } + t.Logf("✓ 源顺序已按重排生效: %v", after) +} + +// ── harness ── + +type reorderHarness struct { + t *testing.T + ps *lua.Plugins +} + +func newReorderHarness(t *testing.T) *reorderHarness { + t.Helper() + 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) + } + // ★ 必须授予 add_source:不给的话 syncAccountSource 里内核会明确拒绝 + // ("kernel action add_source is not granted"),源一个都不会出现 —— + // 我第一版漏了它,于是"应有 2 个源,实得 []"。 + _ = ps.SetPluginKernelPermission("channels", + []string{"add_source", "remove_source", "list_sources", "status"}, false) + return &reorderHarness{t: t, ps: ps} +} + +func (h *reorderHarness) addAccount(id string) { + h.t.Helper() + if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "buddy", "account_id": id, + "credential": `{"access_token":"AT","uid":"u1"}`, + }); err != nil { + h.t.Fatalf("account_add(%s): %v", id, err) + } +} + +// sourceOrder 返回内核里 channels-buddy-* 源的顺序(即实际尝试顺序)。 +// +// ⚠ **必须只读**:我第一版在这里顺带调了 __sync_source_probe(它内部是 +// remove+add)⇒ 改变了被测状态,"测之前先动了被测对象",判据因此不可信 +// (实测表现:顺序忽而两个忽而一个)。 +func (h *reorderHarness) sourceOrder() []string { + h.t.Helper() + ls, err := h.ps.Call("channels", "__list_sources_probe", map[string]interface{}{}) + if err != nil { + h.t.Fatalf("__list_sources_probe: %v", err) + } + j := jsonOf(h.t, ls) + var out []string + arr, _ := j["sources"].([]interface{}) + for _, e := range arr { + m, ok := e.(map[string]interface{}) + if !ok { + continue + } + n, _ := m["name"].(string) + if len(n) > 15 && n[:15] == "channels-buddy-" { + out = append(out, n) + } + } + return out +} + +// ★ poolList 的顺序必须**确定**(同秒创建的账号也不例外)。 +// +// 真实缺陷:排序键是 `createdAt`(**秒级**),同秒建的两个账号比较相等, +// 而相等时 table.sort 的结果取决于 pairs() 遍历顺序 —— **不确定**。 +// 实测:按 a,b,c 顺序建三个账号,poolList 回的是 [a,c,b]。 +// +// 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = **内核尝试顺序**, +// 故同秒创建的账号里"先试哪个"是随机的(buddy 15 天窗口下会影响先用谁的积分), +// 且可能每次进程重启都变。用 id 作稳定兜底键后可完全确定。 +func TestPoolListOrderIsDeterministic(t *testing.T) { + // ⚠ 这条判据必须走**批量重注册**路径(reorder 会按 poolList 顺序重放源)。 + // `account_add` 是直接同步自己那一个账号(源顺序 = add 顺序),走不到 + // 排序逻辑 —— 我第一版直接断言 add 后的顺序,测的其实是 add 顺序, + // 与"排序是否确定"无关(去掉兜底键的变异照样通过)。 + // + // 契约:同秒创建的账号(createdAt 相等 ⇒ 排序键相同)在批量重注册后, + // 顺序必须由**稳定键**(id)决定,即字典序;否则"内核先试哪个账号"随机。 + h := newReorderHarness(t) + for _, id := range []string{"acct-c", "acct-b", "acct-a"} { + h.addAccount(id) + } + // 触发批量重注册:按 id 字典序显式指定 ordered_ids(priority 依次递减) + if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{ + "platform": "buddy", + "ordered_ids": []interface{}{"acct-c", "acct-b", "acct-a"}, + }); err != nil { + t.Fatal(err) + } + // priority 已按给定顺序区分开 ⇒ 这里先确认**显式顺序生效** + order := h.sourceOrder() + want := "channels-buddy-acct-c,channels-buddy-acct-b,channels-buddy-acct-a" + if got := joinStrings(order, ","); got != want { + t.Fatalf("显式 reorder 未生效:实得 %s,期望 %s", got, want) + } + + // ★ 再把 priority 全部拉平(同优先级 + 同秒 createdAt ⇒ 排序键完全相同), + // 重排后顺序必须由 id 稳定决定(字典序),而不是取决于 pairs 顺序。 + h.clearPriorities() + if !sameSecond(h.createdAts()) { + t.Skipf("三个账号的 createdAt 不同秒(%v)—— 跑不到同键路径,跳过", h.createdAts()) + } + if _, err := h.ps.Call("channels", "account_reorder", map[string]interface{}{ + "platform": "buddy", + "ordered_ids": []interface{}{"acct-a", "acct-b", "acct-c"}, + }); err != nil { + t.Fatal(err) + } + h.clearPriorities() + h.resync("buddy") + order2 := h.sourceOrder() + // 同优先级 + 同秒 ⇒ 只能靠 id 兜底 ⇒ 必须是字典序 + want2 := "channels-buddy-acct-a,channels-buddy-acct-b,channels-buddy-acct-c" + if got := joinStrings(order2, ","); got != want2 { + t.Fatalf("同优先级同秒创建时,顺序必须由**稳定键**(id)决定,实得 %s,期望 %s\n"+ + "(createdAt 是秒级、同秒相等 ⇒ 必须有确定性兜底,否则「先试哪个账号」随机)", + got, want2) + } + t.Logf("✓ 同键时顺序由 id 稳定决定: %s", want2) +} + +func joinStrings(xs []string, sep string) string { + out := "" + for i, x := range xs { + if i > 0 { + out += sep + } + out += x + } + return out +} + +// createdAts 取当前 pool 里 buddy 账号的 createdAt。 +func (h *reorderHarness) createdAts() []int64 { + h.t.Helper() + res, err := h.ps.Call("channels", "account_list", map[string]interface{}{"platform": "buddy"}) + if err != nil { + h.t.Fatal(err) + } + var out []int64 + for _, a := range accountsOf(h.t, res) { + if v, ok := a["createdAt"].(float64); ok { + out = append(out, int64(v)) + } + } + return out +} + +// sameSecond 判断是否所有时间戳都落在同一秒(即排序键相等)。 +func sameSecond(ts []int64) bool { + if len(ts) < 2 { + return false + } + for _, v := range ts { + if v != ts[0] { + return false + } + } + return true +} + +// clearPriorities 把所有账号的 priority 拉平(构造"排序键完全相同"的场景)。 +func (h *reorderHarness) clearPriorities() { + h.t.Helper() + if _, err := h.ps.Call("channels", "__clear_priorities_probe", map[string]interface{}{ + "platform": "buddy", + }); err != nil { + h.t.Fatalf("__clear_priorities_probe: %v", err) + } +} + +// resync 触发一次批量重注册(走 poolList 顺序 → 源注册顺序)。 +func (h *reorderHarness) resync(platform string) { + h.t.Helper() + if _, err := h.ps.Call("channels", "__resync_sources_probe", map[string]interface{}{ + "platform": platform, + }); err != nil { + h.t.Fatalf("__resync_sources_probe: %v", err) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 4d81d03..0b1599b 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -2741,7 +2741,15 @@ local function poolList(provider) table.sort(out, function(x, y) local px, py = x.priority or 0, y.priority or 0 if px ~= py then return px > py end -- priority 大者优先(参考 reorder 语义) - return (x.createdAt or 0) < (y.createdAt or 0) + local cx, cy = x.createdAt or 0, y.createdAt or 0 + if cx ~= cy then return cx < cy end + -- ★ 必须给**确定性**兜底:createdAt 是**秒级**,同一秒建的两个账号比较相等, + -- 而相等时 table.sort 的结果取决于 pairs() 的遍历顺序 —— **不确定** + -- (实测:按 a,b,c 顺序建的三个账号,poolList 回的是 [a,c,b])。 + -- 后果不只是"顺序看着乱":源注册顺序 = poolList 顺序 = 内核尝试顺序, + -- 故同秒创建的账号里"先试哪个"是随机的,且可能每次进程重启都变。 + -- 用 id 作最后的稳定键(id 唯一且不变),让顺序完全确定。 + return tostring(x.id or "") < tostring(y.id or "") end) return out end @@ -7007,7 +7015,22 @@ function plugin.account_reorder(req) local acct = poolGet(plat.id, tostring(aid)) if acct then acct.priority = #ordered - i + 1 poolAdd(plat.id, acct) end end - return { status = 200, json = { ok = true } } + -- ★ 必须**重注册源**,否则重排不生效。 + -- + -- 链路:poolList 按 priority 降序 → syncAccountSource 的调用顺序 → 内核里源的 + -- **注册顺序** → 内核按 candidates 顺序依次尝试。而一个源**一旦注册,顺序就固定** + -- 了(改动 pool 记录不影响它)。故重排后必须按新顺序 remove+add 一遍, + -- 让"优先账号"真的排在前面。 + -- + -- 真实收益(buddy 15 天窗口):不重排的话,快到期积分不会被优先消耗 ⇒ + -- **白白过期**(参考为此有 rankBuddyAccountsByBalance)。 + -- ⚠ remove+add 会把源移到注册队列末尾,所以**必须整体按新顺序重放一遍** —— + -- 只重注册被移动的那几个会让它们的相对顺序取决于调用顺序,而不是最终顺序。 + for _, a in ipairs(poolList(plat.id)) do + syncAccountSource(plat.id, a.id) + end + return { status = 200, json = { ok = true, + reordered = #poolList(plat.id), applied_to_sources = true } } end -- ============================================================================ @@ -11238,6 +11261,54 @@ function plugin.claims_capable(args) } } end +-- plugin.__clear_priorities_probe:只给测试用 —— 把所有账号的 priority 拉平。 +-- 用于构造"排序键完全相同"的场景(同优先级 + 同秒 createdAt), +-- 检验排序是否有确定性兜底。 +function plugin.__clear_priorities_probe(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local n = 0 + for _, a in ipairs(poolList(plat.id)) do + a.priority = nil + poolAdd(plat.id, a) + n = n + 1 + end + return { json = { ok = true, n = n } } +end + +-- plugin.__resync_sources_probe:只给测试用 —— 按 poolList 顺序批量重注册源。 +-- 「排序是否有确定性兜底」必须在**批量重注册**路径上验:account_add 是直接同步 +-- 自己那一个(源顺序 = add 顺序),走不到排序逻辑。 +function plugin.__resync_sources_probe(args) + args = args or {} + local plat = platformById(args.platform) + if not plat then return { json = { error = "未知平台" } } end + local n = 0 + for _, a in ipairs(poolList(plat.id)) do + syncAccountSource(plat.id, a.id) + n = n + 1 + end + return { json = { ok = true, n = n } } +end + +-- plugin.__list_sources_probe:只给测试/诊断用 —— 内核里当前登记的源**及其顺序**。 +-- 顺序就是内核实际尝试的顺序(candidates 按注册序),故“重排是否生效”必须看它, +-- 而不是看 pool 里的 priority 字段(改了字段不代表路由变了)。 +function plugin.__list_sources_probe() + local ls = __llmsproxy_kernel.call("list_sources", {}) + -- ⚠ 内核 actionListSources 返回的是**裸数组**(不是 {sources:[...]})—— + -- 我第一版按后者取,于是恒得空列表(判据因此报"应有 2 个源,实得 []")。 + -- 两种形态都兜住,免得内核换个包装就失效。 + local arr = ls and ls.json or ls + if type(arr) ~= "table" then arr = {} end + local out = {} + for _, x in ipairs(arr) do + if type(x) == "table" then out[#out + 1] = { name = x.name, adapter = x.adapter } end + end + return { json = { sources = out } } +end + -- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。 -- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题 -- 没有它只能靠猜。