Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop-tauri/src-tauri/server/src/server/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub mod plugin_theme;
pub mod prompt;
pub mod protocol;
pub mod proxies;
pub mod qoder_cookie;
pub mod qoder_umid;
pub mod queue_api;
pub mod retry_api;
Expand Down
105 changes: 105 additions & 0 deletions desktop-tauri/src-tauri/server/src/server/api/qoder_cookie.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! Qoder 网页 Cookie(积分到期明细)的账号级管理入口。
//!
//! - `GET /api/qoder-cookie/info?account=<id>` → 插件 `webcookie.info`
//! - `POST /api/qoder-cookie/set` `{accountId, cookie}` → `webcookie.set`
//! - `POST /api/qoder-cookie/clear` `{accountId}` → `webcookie.clear`
//!
//! 为什么是账号级而不是 provider 级:网页 Cookie 属于某个 Qoder 登录账号;
//! 同一家下多个账号各有一枚会话,不能共用。插件负责校验 Cookie 的 `user_id`
//! 与账号一致并加密落库;网关这里只做「账号 → provider → 插件动作」的转发,
//! 不解析 Cookie 内容,也不把 Cookie 写日志。

use axum::extract::{Query, State};
use axum::response::Response;
use axum::Json;
use serde::Deserialize;
use serde_json::Value;

use crate::server::core::providers::adapter;
use crate::server::errors::management_error;
use crate::server::http::ok_json;
use crate::server::ServerState;

#[derive(Debug, Deserialize)]
pub struct AccountQuery {
pub account: String,
}

#[derive(Debug, Deserialize)]
pub struct SetPayload {
#[serde(rename = "accountId")]
pub account_id: String,
pub cookie: String,
}

#[derive(Debug, Deserialize)]
pub struct ClearPayload {
#[serde(rename = "accountId")]
pub account_id: String,
}

async fn call_action(
state: &ServerState,
account: &str,
action: &str,
payload: &Value,
) -> Result<Value, (i32, String)> {
let account = account.trim();
if account.is_empty() {
return Err((400, "缺少 Qoder 账号 id".to_string()));
}
let Some(provider) = state.store().account_provider_of(account) else {
return Err((404, "账号不存在,请刷新后重试".to_string()));
};
if provider != "qoder" && provider != "qoder-intl" {
return Err((400, "这条账号不是 Qoder,不能设置 Qoder 网页 Cookie".to_string()));
}
let Some(adapter) = adapter::adapter_by_id(&provider) else {
return Err((400, "Qoder 提供商插件没有安装或尚未加载".to_string()));
};
adapter
.admin_action(state.store(), account, action, payload)
.await
.map_err(|error| (error.status_code, error.message))
}

/// GET /api/qoder-cookie/info?account=<id>
pub async fn info(State(state): State<ServerState>, Query(query): Query<AccountQuery>) -> Response {
match call_action(&state, &query.account, "webcookie.info", &Value::Null).await {
Ok(value) => ok_json(value),
Err((status_code, message)) => management_error(status_code, &message),
}
}

/// POST /api/qoder-cookie/set
pub async fn set(State(state): State<ServerState>, Json(payload): Json<SetPayload>) -> Response {
if payload.cookie.trim().is_empty() {
return management_error(400, "缺少 Qoder 网页 Cookie");
}
match call_action(
&state,
&payload.account_id,
"webcookie.set",
&serde_json::json!({ "cookie": payload.cookie }),
)
.await
{
Ok(value) => {
let packages = value.get("packages").and_then(Value::as_i64).unwrap_or(0);
crate::server::logging::log(
"[Qoder]",
&format!("网页 Cookie 已保存(账号 {},读到 {} 份积分包)", payload.account_id, packages),
);
ok_json(value)
}
Err((status_code, message)) => management_error(status_code, &message),
}
}

/// POST /api/qoder-cookie/clear
pub async fn clear(State(state): State<ServerState>, Json(payload): Json<ClearPayload>) -> Response {
match call_action(&state, &payload.account_id, "webcookie.clear", &Value::Null).await {
Ok(value) => ok_json(value),
Err((status_code, message)) => management_error(status_code, &message),
}
}
38 changes: 33 additions & 5 deletions desktop-tauri/src-tauri/server/src/server/core/account_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,29 @@ pub fn supports_credit_expiry(provider: &str) -> bool {
.unwrap_or_else(|| matches!(provider, "workbuddy" | "workbuddy-intl" | "trae" | "officeace"))
}

pub fn rules_for(provider: &str, settings: &AccountSortSettings) -> Vec<AccountSortRule> {
settings.get(provider).cloned().unwrap_or_else(|| vec![
AccountSortRule::Credit { on: supports_credit_expiry(provider), dir: CreditSortDirection::Soon },
pub fn rules_for(
provider: &str,
settings: &AccountSortSettings,
credit_available: bool,
) -> Vec<AccountSortRule> {
let mut rules = settings.get(provider).cloned().unwrap_or_else(|| vec![
AccountSortRule::Credit {
on: supports_credit_expiry(provider) && credit_available,
dir: CreditSortDirection::Soon,
},
AccountSortRule::Added { on: false, dir: AddedSortDirection::New },
])
]);
// Qoder 没有网页 Cookie 时就算旧设置里勾了到期规则也不该继续生效:
// 所有账号都没有 expireAt,规则只会显示「到期 近→远」却实际回落到手动。
// 这里只压有效规则,不改落库配置 —— 之后配置 Cookie 时原设置会自动恢复。
if !credit_available {
for rule in &mut rules {
if let AccountSortRule::Credit { on, .. } = rule {
*on = false;
}
}
}
rules
}

pub fn manual_groups(accounts: &[Value]) -> Vec<(String, Vec<Value>)> {
Expand Down Expand Up @@ -108,7 +126,17 @@ pub fn sorting(accounts: &[Value], now: i64) -> AccountSorting {
let facts = credit_slot().lock().map(|facts| facts.clone()).unwrap_or_default();
for (provider, mut members) in manual_groups(accounts) {
let credit_supported = supports_credit_expiry(&provider);
let rules = rules_for(&provider, &configured);
// Qoder 的到期数据依赖账号级网页 Cookie。组内没有任何账号配置 Cookie 时,
// 不要把「最快到期优先」当成默认/有效规则;其他家没有这个账号级前提,
// 保持原行为(查一次余额后自然有数据)。
let credit_available = if matches!(provider.as_str(), "qoder" | "qoder-intl") {
members.iter().any(|account| {
account.get("hasWebCookie").and_then(Value::as_bool) == Some(true)
})
} else {
true
};
let rules = rules_for(&provider, &configured, credit_available);
for account in &members {
if let Some(id) = routing::account_id(account) {
let expiry = facts.get(id).and_then(|expiries| expiries.iter().find(|expiry| **expiry > now)).copied();
Expand Down
6 changes: 6 additions & 0 deletions desktop-tauri/src-tauri/server/src/server/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,12 @@ pub fn panel_router(state: ServerState) -> Router {
get(api::qoder_umid::get_status).post(api::qoder_umid::get_status),
)
.route("/api/qoder-umid/install", post(api::qoder_umid::install))
// ── Qoder 网页 Cookie(积分到期明细)──
// 账号级:info 读配置状态 / set 校验并保存 / clear 清除。
// 挂 protected:Cookie 是敏感会话,且写入会改变余额明细与到期排序。
.route("/api/qoder-cookie/info", get(api::qoder_cookie::info))
.route("/api/qoder-cookie/set", post(api::qoder_cookie::set))
.route("/api/qoder-cookie/clear", post(api::qoder_cookie::clear))
// ── 间隔型定时任务(凭证自动维护 / 模型目录刷新 / 两个前端自动刷新)──
// 挂 protected:它能改后端后台任务的执行节奏(间隔 1 分钟会让网关持续
// 打上游),并触发真打上游的刷新,敏感度与 /api/retention 同级。
Expand Down
7 changes: 7 additions & 0 deletions desktop-tauri/src-tauri/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ const BRIDGE_JS: &str = r#"
}),
getLoginState: () => invoke('login_state'),
cancelLogin: () => invoke('cancel_login'),
// Qoder 网页 Cookie(积分到期明细):壳打开内嵌登录窗口,读 HttpOnly
// Cookie 后交给网关保存。只支持桌面端;网页端/headless 用手动粘贴兜底。
captureQoderWebCookie: (accountId, provider) =>
invoke('capture_qoder_web_cookie', {
accountId: String(accountId || ''),
provider: String(provider || ''),
}),
// ── AutoClaw 国际版 OAuth(**授权地址由界面先拿好**)──────────
// 与 startLogin 的区别:那条是「壳去问后端要地址」,这条是「界面已经
// 拿到地址了,壳只负责开窗口并等待」。原因是这条链前面有一次**必须在
Expand Down
13 changes: 13 additions & 0 deletions desktop-tauri/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,19 @@ pub async fn cancel_login(app: AppHandle) -> Result<Value, String> {
Ok(json!({ "canceled": true }))
}

/// 打开 Qoder 网页登录窗口,读取 `qoder_session_cookie` 并交给网关保存。
///
/// 这是「积分到期明细」的自动获取入口:Cookie 是网页会话,openapi 的
/// accessToken 读不到逐份积分包(见插件 `qoder/balance.rs` 的模块头)。
#[tauri::command]
pub async fn capture_qoder_web_cookie(
app: AppHandle,
account_id: String,
provider: String,
) -> Result<Value, String> {
login::capture_qoder_web_cookie(&app, &account_id, &provider).await
}

/// 导出运行日志:拉取 JSONL 原文,弹系统保存框落盘。
#[tauri::command]
pub async fn export_logs(app: AppHandle, query: Option<String>) -> Result<Value, String> {
Expand Down
1 change: 1 addition & 0 deletions desktop-tauri/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ pub fn run() {
commands::login_kuku_sms_verify,
commands::login_state,
commands::cancel_login,
commands::capture_qoder_web_cookie,
commands::export_logs,
commands::pick_folder,
commands::get_app_settings,
Expand Down
114 changes: 114 additions & 0 deletions desktop-tauri/src-tauri/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2411,3 +2411,117 @@ pub fn open_in_browser(url: &str) -> Result<(), String> {
.map(|_| ())
.map_err(|error| format!("打开系统浏览器失败: {error}"))
}

// ─── Qoder 网页 Cookie(积分到期明细)──────────────────────────

/// 打开 Qoder 网页并读取 `qoder_session_cookie`,交给网关保存。
///
/// 与 KukuAI 的登录收尾同一条思路:登录窗口用的是**临时数据目录**,用户
/// 在窗口里完成登录后,壳侧用 WebView 的 Cookie 存储(含 HttpOnly)读取
/// `qoder_session_cookie`,再 POST 给后端。区别是这里不创建账号,只更新
/// 指定账号的 `webCookie` —— 后端/插件会校验 Cookie 的 `user_id` 与账号一致。
///
/// 只支持桌面端:外部浏览器/headless 没有 WebView Cookie 存储,只能走
/// 账号设置里的手动粘贴兜底。
pub async fn capture_qoder_web_cookie(
app: &AppHandle,
account_id: &str,
provider: &str,
) -> Result<Value, String> {
let account_id = account_id.trim();
if account_id.is_empty() {
return Err("缺少 Qoder 账号 id".to_string());
}
let url = match provider {
"qoder" => "https://qoder.com.cn/account/usage",
"qoder-intl" => "https://qoder.com/account/usage",
other => return Err(format!("不支持为提供商「{other}」获取 Qoder 网页 Cookie")),
};
let auth = url::Url::parse(url).map_err(|_| "Qoder 网页地址无效".to_string())?;
let profile = LoginProfile::new()?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| value.as_millis())
.unwrap_or(0);
let label = format!("qoder-cookie-{stamp}");
let window = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(auth))
.data_directory(profile.path().to_path_buf())
.general_autofill_enabled(false)
.title("登录 Qoder 以获取网页 Cookie")
.inner_size(1100.0, 820.0)
.min_inner_size(760.0, 560.0)
.center()
.build()
.map_err(|_| "打开 Qoder 登录窗口失败".to_string())?;

let deadline = tokio::time::Instant::now() + Duration::from_secs(5 * 60);
let mut attempts = 0u32;
let mut last_error: String;
loop {
if app.get_webview_window(&label).is_none() {
drop(profile);
return Err("登录窗口已关闭,未获取到 Qoder 网页 Cookie".to_string());
}
if tokio::time::Instant::now() >= deadline {
let _ = window.destroy();
drop(profile);
return Err("等待 Qoder 登录超时(5 分钟)".to_string());
}
if let Ok(cookies) = read_window_cookies(&window).await {
if let Some(cookie) = qoder_cookie_header(&cookies) {
let payload = json!({ "accountId": account_id, "cookie": cookie });
match gateway::call("POST", "/api/qoder-cookie/set", Some(&payload)).await {
Ok(value) => {
let _ = window.destroy();
drop(profile);
return Ok(value);
}
Err(error) => {
// Cookie 可能刚写入、会话还没完全生效;重试几次再报错。
attempts = attempts.saturating_add(1);
last_error = error;
if attempts >= 5 {
let _ = window.destroy();
drop(profile);
return Err(format!("保存 Qoder 网页 Cookie 失败:{last_error}"));
}
}
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}

/// 从 WebView Cookie 存储里拼 Qoder 域的 Cookie 头;没有会话 Cookie 时返回 None。
///
/// 与 KukuAI 的 `kuku_cookie_header` 同形:跳过统计 Cookie,保留同名的第一条;
/// Qoder 网页接口实测只带 `qoder_session_cookie` 就能 200,其余 WAF Cookie
/// 一并带上是为了抗上游风控变化。
fn qoder_cookie_header(cookies: &[tauri::webview::Cookie<'static>]) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
let mut seen: Vec<String> = Vec::new();
let mut has_session = false;
for cookie in cookies {
let name = cookie.name().to_string();
let domain = cookie.domain().unwrap_or("").to_string();
let is_qoder = domain.ends_with("qoder.com.cn")
|| domain.ends_with("qoder.cn")
|| domain.ends_with("qoder.com");
if !is_qoder || name.is_empty() {
continue;
}
if name.starts_with("Hm_") || name == "HMACCOUNT" || name == "gfprotpl" {
continue;
}
if seen.iter().any(|existing| existing == &name) {
continue;
}
if name == "qoder_session_cookie" {
has_session = true;
}
seen.push(name.clone());
parts.push(format!("{name}={}", cookie.value()));
}
has_session.then(|| parts.join("; "))
}
16 changes: 12 additions & 4 deletions desktop-tauri/ui-islands/src/islands/accounts-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,11 +752,13 @@ export function creditExpiryOf(entry: UsageEntry, now = Date.now()): number | nu
* 毫秒 `expireAt`,并按到期升序排好
* · officeace —— `officeace/subscription.rs` 的套餐配额 / 奖励活动行都带
* `expireAt`(毫秒,`expire_ms > 0` 才写)
* · qoder / qoder-intl —— 配置网页 Cookie(`webCookie`)后,
* `resource_package_quota.quota_detail` 的逐份 `expires_at` 写成 `expireAt`;
* 未配置 Cookie 时只出聚合行、不参与到期排序
*
* ❌ 有明细、但**明细里没有到期时间**(不进名单):
* · raccoon —— 明细只有 `name` + `remain`(上游只给余额一个数)
* · zcode —— `credits_of` 只写 total/used/remain,纯比例窗口整行跳过
* · qoder —— 代码注释明确写着「不写 `expireAt` / `grantAt`:桶级没有这些字段」
* · loomy —— 永久积分 / 每日赠送两行,都没有到期
* · modelscope —— 魔粒余额两行,没有到期
* · lobsterai —— 测试明确断言「未核实格式的字段一律不写」
Expand Down Expand Up @@ -1046,10 +1048,16 @@ export function positionMap(all: AccountRecord[] | null | undefined, sorting?: A
*/
export type { GroupSortRule } from './accounts-shared'

/** 默认规则不依赖余额缓存,支持积分到期的提供商在查到数据后自动排序。 */
export function defaultGroupSortRules(provider: string): GroupSortRule[] {
/**
* 默认规则。`creditAvailable` 由调用方按「这一家此刻有没有可用到期依据」给:
* Qoder 需要账号配置网页 Cookie,其余家沿用原有能力位。
*/
export function defaultGroupSortRules(
provider: string,
creditAvailable = supportsCreditExpiry(provider),
): GroupSortRule[] {
return [
{ key: 'credit', on: supportsCreditExpiry(provider), dir: 'soon' },
{ key: 'credit', on: creditAvailable, dir: 'soon' },
{ key: 'added', on: false, dir: 'new' },
]
}
Expand Down
Loading