diff --git a/desktop-tauri/src-tauri/server/src/server/core/account_store/plugin_accounts.rs b/desktop-tauri/src-tauri/server/src/server/core/account_store/plugin_accounts.rs index a85522fc..d03c6e9e 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/account_store/plugin_accounts.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/account_store/plugin_accounts.rs @@ -302,7 +302,19 @@ impl AccountStore { public.insert(key.to_string(), record.get(key).cloned().unwrap_or(Value::Null)); } public.insert("enabled".to_string(), Value::Bool(record.enabled())); - public.insert("hasRefreshToken".to_string(), Value::Bool(false)); + // ── 手动「刷新 Token」入口的开关,按插件申报的续期能力给 ──────── + // 原先恒写 `false`,于是**插件家一律没有刷新按钮** —— 哪怕插件 + // 申报了 `refresh: true` 并且实现了 `token.refresh` + // (OfficeAce / CodeArts 两家都是),用户在凭证过期时只剩 + // 「重新登录」一条路。界面读的就是这个字段 + // (`hasRefreshToken ? <刷新项> : …`),点下去走 + // `POST /api/accounts/refresh` → 适配器的 `refresh_access_token`。 + public.insert( + "hasRefreshToken".to_string(), + Value::Bool(crate::server::core::plugin_host::refresh_capable( + &record.provider(), + )), + ); public.insert("tokenTail".to_string(), Value::Null); // 插件自己的展示字段(uid / 邮箱 / 余额……):原样并进来。撞键时 // 插件赢 —— 它比网关更清楚自己那条账号该显示什么。 @@ -311,6 +323,47 @@ impl AccountStore { public.insert(key.clone(), value.clone()); } } + // ── 但**用户设过的备注名**要赢回来,这是上面「插件赢」的唯一例外 ── + // 备注名走 `PATCH /api/accounts/{id}`,只写记录顶层的 `name` + + // `nameCustom`(见 `apply_patch`);而 12 家插件都会往 + // `pluginPublic.name` 里写它按自己口径算的展示名(邮箱、app key 前缀…)。 + // 不把备注名补回来,用户改完就在列表里纹丝不动 —— 前端 `displayNameOf` + // 在 `nameCustom` 为真时读的正是这个 `name`,读到的是插件那份旧的。 + // + // 判据用 `nameCustom` 而不是「顶层 name 非空」:插件在添加账号时写的 + // 种子名也落在顶层,拿它当用户意图会把默认名误判成备注名。 + if record + .get("nameCustom") + .and_then(Value::as_bool) + .unwrap_or(false) + { + if let Some(name) = record.get("name") { + public.insert("name".to_string(), name.clone()); + } + } + // ── 有效期:按插件**申报的键名**从 secret 取值 ──────────────── + // 协议里 `AccountFeatures::expiry` 就是为这一列准备的(各家键名真的 + // 不同:OrcaTerm 是 `tokenExpireAt`、比别家少一个 s),插件在握手时 + // 申报「我用哪个键表示到期」,网关在这里按名取值放上公开视图。 + // + // 之前没人读这个申报,于是插件家的有效期列**永远是空的** —— 而续期 + // 其实已经把新值写进 secret 了(见 `plugin_update_secret` 的 + // `expiresAt`),只是从没暴露到公开视图这一层。 + // + // 只在申报了键名时才读:没申报的家(旧插件 / 没有到期概念的家)保持 + // 原有形态,界面按自己的兜底表显示「—」,不凭空猜一个字段名出来。 + // 值放在**申报的那个键名**下(不是统一改名 `expiresAt`)—— 前端 + // `expiry` 表就是按各家自己的键名取的。 + if let Some(features) = + crate::server::core::plugin_host::account_features_of(&record.provider()) + { + let secret = secret_of(record); + for key in &features.expiry { + if let Some(value) = secret.get(key).filter(|value| !value.is_null()) { + public.insert(key.clone(), value.clone()); + } + } + } Value::Object(public) } } diff --git a/desktop-tauri/src-tauri/server/src/server/core/account_store/store_view.rs b/desktop-tauri/src-tauri/server/src/server/core/account_store/store_view.rs index 3310eed8..34a6aa0e 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/account_store/store_view.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/account_store/store_view.rs @@ -592,6 +592,25 @@ impl AccountStore { "limiters".to_string(), crate::server::core::limiter::effective_rules_json_in(record.fields()), ); + // ── 出网代理:跨家统一注入(插件家否则**永远显示「直连」**)── + // 与 `maxConcurrent` 完全同一个坑,而且更隐蔽:它是「值对了但 + // 读不到」而不是「值丢了」。`apply_patch` 把 `proxy` 正确写进 + // 记录(`record.set_proxy`),但公开形态原先只有各家的 + // `to_*_public_account` 里各自投影一份 —— 插件家走 + // `to_plugin_public_account`,那个骨架里没有 `proxy`,于是: + // · 库里明明存着 `{source:"pool",proxyId:"…"}`; + // · 界面读到 `proxy: undefined` → 整列显示「直连」; + // · 再选一次同一条时 `apply_patch` 判「值没变」→ `changes: []` + // → 前端仍弹「✅ 代理已更新」(假成功),用户点了无数次 + // 也不会变 —— 实测在 Cline Free 上复现。 + // + // 恒为「对象或 null」而不是缺键:`null` 就是直连(与写入侧 + // `normalize_account_proxy` 的 `None → Value::Null` 同一口径), + // 前端 `ProxyCell` 按 `proxy?.config?.source` 读,两种形态都能吃。 + fields.insert( + "proxy".to_string(), + describe_account_proxy(Some(&record.proxy())), + ); // 跨家统一注入的**账号展示名**:与前端 `displayNameOf` 同口径的 // 唯一后端实现(规则见本文件底部的 `display_name_of`)。 // 恒为非空字符串(`displayName` 全兜底失效时退 id),请求日志 / diff --git a/desktop-tauri/src-tauri/server/src/server/core/credential_maintenance.rs b/desktop-tauri/src-tauri/server/src/server/core/credential_maintenance.rs index 12cfdc39..368f9ee4 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/credential_maintenance.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/credential_maintenance.rs @@ -30,8 +30,9 @@ //! 展示。真正会抛错的只有「整个请求都发不出去」那一层(由 HTTP 客户端负责)。 //! //! ── 锁与 panic 的两条硬约束 ───────────────────────────────── -//! 1. **不持锁跨 await**:账号快照与逐账号的临期判定都是同步的(各自的取数 -//! 函数内部取锁即释放),网络刷新在锁外逐个 await; +//! 1. **不持锁跨 await**:账号快照取完即释放锁;临期判定会 await(插件家 +//! 要问它自己的存储,见下),但那些取数函数**内部取锁即释放**,await +//! 期间本模块不持有任何锁;网络刷新同样在锁外逐个 await; //! 2. **不 unwrap/expect**:本模块在后台任务与 HTTP handler 两条路径上跑, //! 而 release 是 `panic = "abort"`,任何一处 panic 都会带走整个应用。 //! @@ -139,7 +140,15 @@ pub async fn refresh_expiring_accounts(store: &AccountStore) -> Vec { } // 临期判定的详细理由见 trait 的 `credentials_expiring`: // 判不出来(没有过期信息 / 账号不存在 / 没有 refreshToken)一律 false - if !adapter.credentials_expiring(store, &id) { + // + // ── 必须走**异步**版(插件家的凭证要问它自己)──────────────── + // 同步版在插件适配器上是硬编码 `false`(`RpcAdapter::credentials_expiring`), + // 它的实现说明写着「真正的实现在 `credentials_expiring_async`」。 + // 调同步版会让**所有插件家**在这一步被判成「未临期」而永远跳过 —— + // 症状是凭证过期后每轮维护都报「无需刷新」,接口一路 401/过期错误, + // 而真正能续期的 `token.expiring` RPC 一次都没被问过。 + // 内置家的异步版默认转调同步版(见 trait),所以这一改对它们无影响。 + if !adapter.credentials_expiring_async(store, &id).await { slots[index] = Some(skipped(&id, provider, "凭证未过期或临期")); continue; } diff --git a/desktop-tauri/src-tauri/server/src/server/core/plugin_host/mod.rs b/desktop-tauri/src-tauri/server/src/server/core/plugin_host/mod.rs index e027d1f5..a845e5ec 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/plugin_host/mod.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/plugin_host/mod.rs @@ -238,6 +238,30 @@ pub fn account_features_of(provider: &str) -> Option { found } +/// 某一家申报的「有没有续期能力」(没装 / 没这一家 = `false`)。 +/// +/// ── 为什么要有这个 store 层的读数入口 ────────────────────────── +/// 「手动刷新 Token」这个入口由界面按公开视图的 `hasRefreshToken` 决定是否 +/// 渲染,而公开视图在 `account_store` 层拼装 —— 那里拿不到适配器(插件家 +/// 的 id 也不在 `ProviderKind` 枚举里)。`plugin_update_secret` 那边的 +/// `hasRefreshToken` 原先恒写 `false`,于是**插件家一律没有刷新按钮**, +/// 哪怕插件明明白白申报了 `refresh: true` 并且实现了 `token.refresh`。 +/// +/// 与 [`account_features_of`] 同一形态:读握手带回的 `ProviderInfo`。 +pub fn refresh_capable(provider: &str) -> bool { + let Some(host) = current() else { + return false; + }; + let Ok(guard) = host.providers.lock() else { + return false; + }; + guard + .iter() + .find(|info| info.id.as_str() == provider) + .map(|info| info.capabilities.refresh) + .unwrap_or(false) +} + /// 某一家申报的签到能力(没申报 = `None`)。 pub fn checkin_of(provider: &str) -> Option { let host = current()?;