diff --git a/internal/core/channels_platforms_logged_in_test.go b/internal/core/channels_platforms_logged_in_test.go new file mode 100644 index 0000000..eafea60 --- /dev/null +++ b/internal/core/channels_platforms_logged_in_test.go @@ -0,0 +1,56 @@ +package core + +import "testing" + +// `platforms` 的 logged_in 必须看**账号池**,不能只看旧单账号保险库键。 +// +// 真实缺陷(2026-10-20 用户报「codearts 还是不可用」): +// `list_platforms` 当时写的是 `credentialOf("channels:" .. id)` —— 那是旧版 +// 单账号的键;而现网凭据键是 `channels::`(一平台多账号)。 +// 于是**所有**通过账号池接入的平台都被判成未登录,UI 把渠道卡片渲染成"未接入", +// 用户看到的正是"渠道不可用",而底层模型其实完全能用。 +// +// 判据钉两层:池里的账号让 logged_in=true,且账号标识能被 UI 拿到。 +func TestPlatformsLoggedInFromAccountPool(t *testing.T) { + _, ps := poolCore(t) + if _, err := ps.Call("channels", "account_add", map[string]interface{}{ + "platform": "codearts", "account_id": "acct-pool-1", + "credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`, + "nickname": "pool-nick", + }); err != nil { + t.Fatalf("account_add: %v", err) + } + // list_platforms 是 HTTP 路由处理器(不在 plugin.exports 里),走探针取同一份数据 + m, err := ps.Call("channels", "__platforms_probe", map[string]interface{}{}) + if err != nil { + t.Fatalf("__platforms_probe: %v", err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + arr, _ := j["platforms"].([]interface{}) + if len(arr) == 0 { + t.Fatalf("platforms 不应为空") + } + var got map[string]interface{} + for _, raw := range arr { + p, _ := raw.(map[string]interface{}) + if p["id"] == "codearts" { + got = p + } + } + if got == nil { + t.Fatalf("platforms 里找不到 codearts") + } + if got["logged_in"] != true { + t.Fatalf("账号池里有凭据的账号,logged_in 必须为 true;实得 %#v", got) + } + if got["account_id"] != "acct-pool-1" { + t.Errorf("account_id 应回池里的账号 id;实得 %#v", got["account_id"]) + } + if got["account"] != "pool-nick" { + t.Errorf("account 应回池里的昵称;实得 %#v", got["account"]) + } + if s, _ := got["accounts"].(float64); s < 1 { + t.Errorf("accounts 计数应 >=1;实得 %#v", got["accounts"]) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 2981a43..263aab5 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -140,7 +140,7 @@ plugin.kernel = { -- 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", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__trae_catalog_probe", "__trae_headers_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_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", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__trae_catalog_probe", "__trae_headers_probe", "__platforms_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_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" } @@ -9083,14 +9083,42 @@ end function plugin.list_platforms(req) local out = {} for _, p in ipairs(allPlatforms()) do + -- ★ logged_in 必须看**账号池**,不能只看旧的单账号键。 + -- + -- 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):这里原本 + -- `credentialOf("channels:" .. id)` —— 那是**旧版单账号**的保险库键; + -- 现网凭据键是 `channels::`(一平台多账号), + -- 于是**所有**通过账号池接入的平台都被判成未登录(buddy 之所以显示已登录, + -- 是因为它还留着一条旧格式凭据)。UI 据此把渠道卡片渲染成"未接入", + -- 用户看到的就是"渠道不可用",而底层模型其实完全能用。 + local logged, first, claimed = false, nil, nil + for _, a in ipairs(poolList(p.id)) do + if a.enabled ~= false and credentialOf(a.credentialRef) ~= nil then + logged = true + if first == nil then first = a end + if claimed == nil and a.last_claim ~= nil then claimed = a.last_claim end + end + end + -- 兼容旧单账号:池里没有时回落到 state.accounts + 旧凭据键 local acct = accountOf(p.id) or {} - local has = credentialOf("channels:" .. p.id) + local hasLegacy = credentialOf("channels:" .. p.id) ~= nil + if not logged and hasLegacy then logged = true end + if first == nil and (hasLegacy or acct.account ~= nil) then + first = { id = acct.id, nickname = acct.account, enabled = acct.enabled } + end out[#out + 1] = { id = p.id, name = p.name, flow = p.flow, - logged_in = has ~= nil, - account = acct.account, - enabled = acct.enabled, - last_claim = acct.last_claim, + logged_in = logged, + -- 账号展示:池里第一个可用账号优先(id 是 12 位 hex,UI 用来区分同平台多账号) + account_id = first and first.id or nil, + account = (first and first.nickname) or acct.account, + enabled = first and first.enabled ~= false or acct.enabled, + last_claim = claimed or acct.last_claim, + accounts = (function() + local n = 0 + for _, a in ipairs(poolList(p.id)) do if a.enabled ~= false then n = n + 1 end end + return n + end)(), } end return { status = 200, json = { platforms = out } } @@ -9429,8 +9457,32 @@ end function plugin.status_page(req) local r = __llmsproxy_kernel.call("status", {}) + -- accounts 必须**合并账号池**:只回 state.accounts(旧单账号表)时, + -- 通过账号池接入的平台在这里是空的,UI 的状态页于是显示"没有账号", + -- 与 platforms 的 logged_in 一样会让人误判成"渠道不可用" + -- (同一根因的两个出口,2026-10-20 一起修)。 + local accounts = {} + for k, v in pairs(plugin.state.accounts or {}) do accounts[k] = v end + for _, p in ipairs(allPlatforms()) do + local list = {} + for _, a in ipairs(poolList(p.id)) do + list[#list + 1] = { + id = a.id, nickname = a.nickname, enabled = a.enabled ~= false, + expiresAt = a.expiresAt, last_claim = a.last_claim, + has_credential = credentialOf(a.credentialRef) ~= nil, + } + end + if #list > 0 then + local base = accounts[p.id] or {} + local merged = {} + for k, v in pairs(base) do merged[k] = v end + merged.accounts = list + merged.count = #list + accounts[p.id] = merged + end + end return { status = 200, json = { - accounts = plugin.state.accounts, + accounts = accounts, log = plugin.state.log, kernel_ready = r and r.ready or false, } } @@ -13126,6 +13178,17 @@ function plugin.__list_sources_probe() return { json = { sources = out } } end +-- plugin.__platforms_probe:只给判据用 —— 原样返回 `platforms` 路由的数据。 +-- +-- 为什么需要它:`list_platforms` 是 plugin.http 的路由处理器,**不在** +-- plugin.exports 里(exports 是给 WebUI 直接调用的白名单),判据无法直接 +-- Call 到它。走探针是仓库既有做法(见 channels_remove_source_perm_test.go +-- 对 `status` 的同类处理),避免为了测试把只读状态处理器塞进 exports。 +function plugin.__platforms_probe(args) + local r = plugin.list_platforms(nil) or {} + return { json = r.json or {} } +end + -- plugin.__trae_headers_probe:只给判据用 —— 返回 trae 两套头各自的键集合。 -- -- 存在理由:2026-10-20 事故是「目录刷新用了签到头(traeCheckinHeaders)而不是