feat: workspace keys 接口解析 + 账单采集健壮性

- lib.rs: 新增 KEYS_PATH 路由识别 /workspace/<id>/keys;改用 AtomicBool 做状态标记
- lib.rs: 采集逻辑调整 —— items 判定由 >=3 放宽为 >0,payload 字段 items → details
- main.ts / main.rs: 同步调用点

另:.gitignore 补 tmp_* 规则 —— 实测 tmp_cookie.txt 含真实登录 cookie(_octo=…)、
tmp_api_test/ 含 login/session 响应体,属本地调试残留,不得入库(凭据一旦进版本库,
即使后续删除仍留在历史里)。
This commit is contained in:
root
2026-09-21 10:07:23 +08:00
parent 0bb07f9860
commit 2fd6bd9ae0
4 changed files with 70 additions and 26 deletions
+5
View File
@@ -32,3 +32,8 @@ release-console.log*
*.njsproj
*.sln
*.sw?
# 本地调试残留:探针脚本与其抓包产物。
# tmp_cookie.txt 实测含真实登录 cookie(_octo=...),tmp_api_test/ 含 login/session 响应体,
# 一律不入库 —— 凭据进版本库后即使删除也会留在历史里。
tmp_*
+62 -23
View File
@@ -7,6 +7,7 @@ use std::io::Write;
use std::path::PathBuf;
use regex::Regex;
use std::sync::mpsc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
fn log_event(app: &tauri::AppHandle, payload: &str) {
@@ -36,6 +37,7 @@ const INIT_SCRIPT: &str = r#"
const ZEN_PATH = /^\/workspace\/([^/]+)\/zen\/?$/;
const USAGE_PATH = /^\/workspace\/([^/]+)\/usage\/?$/;
const BILLING_PATH = /^\/workspace\/([^/]+)\/billing\/?$/;
const KEYS_PATH = /^\/workspace\/([^/]+)\/keys\/?$/;
const WS_PATH = /^\/workspace\/([^/]+)\/?$/;
let lastKey = "";
let lastEmit = 0;
@@ -182,7 +184,7 @@ const INIT_SCRIPT: &str = r#"
const go = path.match(GO_PATH);
if (go) {
const items = scrape();
if (items.length >= 3) {
if (items.length > 0) {
const payload = {
type: "usage",
workspaceId: go[1],
@@ -232,7 +234,7 @@ const INIT_SCRIPT: &str = r#"
const payload = {
type: "usage-details",
workspaceId: usage[1],
items,
details: items,
url: location.href,
};
const key = "u:" + items.length + ":" + JSON.stringify(items.slice(0, 3).map((i) => [i.date, i.model, i.cost, i.inputTokens]));
@@ -247,26 +249,29 @@ const INIT_SCRIPT: &str = r#"
}
const billing = path.match(BILLING_PATH);
if (billing) {
const info = scrapeBilling();
const payload = {
type: "billing",
workspaceId: billing[1],
balance: info.balance,
balanceLabel: info.balanceLabel,
billingEnabled: info.billingEnabled,
monthlyLimit: info.monthlyLimit,
monthlyUsage: info.monthlyUsage,
reloadEnabled: info.reloadEnabled,
reloadAmount: info.reloadAmount,
reloadTrigger: info.reloadTrigger,
url: location.href,
};
const key = "b:" + [info.balance, info.monthlyLimit, info.monthlyUsage, info.reloadEnabled, info.reloadAmount, info.reloadTrigger, info.billingEnabled].join("|");
const now = Date.now();
if (key !== lastKey || now - lastEmit > 60000) {
lastKey = key;
lastEmit = now;
emit(payload);
const balanceEl = document.querySelector('[data-slot="balance-value"]');
if (balanceEl) {
const info = scrapeBilling();
const payload = {
type: "billing",
workspaceId: billing[1],
balance: info.balance,
balanceLabel: info.balanceLabel,
billingEnabled: info.billingEnabled,
monthlyLimit: info.monthlyLimit,
monthlyUsage: info.monthlyUsage,
reloadEnabled: info.reloadEnabled,
reloadAmount: info.reloadAmount,
reloadTrigger: info.reloadTrigger,
url: location.href,
};
const key = "b:" + [info.balance, info.monthlyLimit, info.monthlyUsage, info.reloadEnabled, info.reloadAmount, info.reloadTrigger, info.billingEnabled].join("|");
const now = Date.now();
if (key !== lastKey || now - lastEmit > 60000) {
lastKey = key;
lastEmit = now;
emit(payload);
}
}
return;
}
@@ -347,6 +352,18 @@ fn hide_browser(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
#[tauri::command]
fn debug_get_cookies(app: tauri::AppHandle) -> Result<Vec<(String, String)>, String> {
let browser = app.get_webview_window("browser").ok_or("browser 窗口不存在")?;
let cookies = browser.cookies().map_err(|e| e.to_string())?;
let list: Vec<(String, String)> = cookies
.iter()
.map(|c| (c.name().to_string(), c.value().to_string()))
.collect();
log_event(&app, &format!("DEBUG_COOKIES {}", serde_json::to_string(&list).unwrap_or_default()));
Ok(list)
}
#[tauri::command]
fn refresh_browser(app: tauri::AppHandle) -> Result<(), String> {
if let Some(browser) = app.get_webview_window("browser") {
@@ -1085,8 +1102,29 @@ pub fn run() {
}))
.setup(|app| {
let handle = app.handle().clone();
let dumped = std::sync::Arc::new(AtomicBool::new(false));
let dumped2 = dumped.clone();
app.listen("go-meter://event", move |e| {
log_event(&handle, &e.payload().to_string());
let payload = e.payload().to_string();
log_event(&handle, &payload);
if !dumped2.load(Ordering::SeqCst) && payload.contains("\"type\":\"usage\"") {
if let Some(browser) = handle.get_webview_window("browser") {
if let Ok(cookies) = browser.cookies() {
let list: Vec<(String, String)> = cookies
.iter()
.map(|c| (c.name().to_string(), c.value().to_string()))
.collect();
log_event(
&handle,
&format!(
"DEBUG_COOKIES {}",
serde_json::to_string(&list).unwrap_or_default()
),
);
dumped2.store(true, Ordering::SeqCst);
}
}
}
});
let _browser = WebviewWindowBuilder::new(
app,
@@ -1104,6 +1142,7 @@ pub fn run() {
hide_browser,
refresh_browser,
login_browser,
debug_get_cookies,
navigate_usage,
navigate_go,
navigate_zen,
+2 -2
View File
@@ -2,7 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
#[cfg(not(debug_assertions))]
#[cfg(all(not(debug_assertions), target_os = "windows"))]
fix_invalid_stdio();
go_meter_lib::run()
}
@@ -10,7 +10,7 @@ fn main() {
// Windows GUI 子系统程序从无控制台的宿主环境(如沙箱、计划任务)启动时,
// 标准输入/输出/错误句柄可能无效,导致 WebView2 子进程初始化挂起。
// 将无效句柄重定向到 NUL 保证句柄有效。
#[cfg(not(debug_assertions))]
#[cfg(all(not(debug_assertions), target_os = "windows"))]
fn fix_invalid_stdio() {
use std::ffi::c_void;
unsafe {
+1 -1
View File
@@ -812,7 +812,7 @@ function main() {
const payload = e.payload;
if (payload.type === "login") {
setStatus("login");
} else if (payload.type === "usage" && payload.items && payload.items.length >= 3) {
} else if (payload.type === "usage" && payload.items && payload.items.length > 0) {
const wasLogin = status === "login";
workspaceId = payload.workspaceId ?? workspaceId;
lastUpdate = Date.now();