Files
CC-Switch/src-tauri/src/usage_events.rs
T
in30mn1a bc1467db8d feat(usage): real-time stats refresh + fix codex sync panic on non-ASCII model names (#3027)
The usage dashboard previously only refreshed on app restart for users
who don't route through the cc-switch proxy. Two issues were involved:

1. The session-sync background task panicked when a Codex model name
   contained non-ASCII characters (e.g. `【官】glm-5.1`), because
   `normalize_codex_model` sliced `&name[name.len() - 11..]` without
   verifying char boundaries. Once the task panicked, no session logs
   were imported until the app was restarted (where startup-time
   `rollup_and_prune` happened to flush pending data).

2. Even with sync working, the dashboard only polled every 30s and
   skipped polling when the window was unfocused, so freshly-imported
   data was invisible until the next poll or window refocus.

Fixes
-----

* `normalize_codex_model`: guard the 11-byte ISO-date suffix slice with
  `is_char_boundary` + `is_ascii` checks. ASCII-only suffix means the
  date-stripping logic is correct, and non-ASCII names (which can never
  be valid date suffixes anyway) now bypass the slice safely.

* New `usage_events` module that emits `usage-log-recorded` to the
  frontend whenever `proxy_request_logs` actually gains a new row.
  Sources covered: proxy `log_request`, Claude/Codex/Gemini session
  sync, and startup `rollup_and_prune`. Notifications use a global
  `OnceLock<AppHandle>` so call sites that don't already hold an
  `AppHandle` (e.g. `UsageLogger`) can notify without signature churn.

* 200ms debounce in `notify_log_recorded` collapses bursts (a single
  Codex sync importing 3000+ entries triggers ~2 emits, not 3000) so
  the frontend's `invalidateQueries` is never spammed.

* Frontend `useUsageEventBridge` listens for the event and invalidates
  `usageKeys.all`. Hook is mounted only on `UsageDashboard`, so the
  listener is unsubscribed automatically when the user navigates away.

Verification
------------

* `cargo check` passes (existing 25 dead-code warnings in
  `commands/misc.rs` are pre-existing and unrelated).
* `tsc --noEmit` passes.
* Manually verified end-to-end: a Codex sync run that imported 3145
  entries produced 2 debounced emits, both logged as `emit
  usage-log-recorded 成功`, and the dashboard updated within ~200ms.

Behaviour notes
---------------

* `INSERT OR IGNORE` paths (Claude/Codex session sync) only notify when
  the row is actually inserted, so dedup-skipped writes don't trigger
  empty refreshes.
* Gemini's `INSERT … ON CONFLICT … DO UPDATE` path reuses the existing
  `conn.changes() > 0` check and only notifies when token counts truly
  changed.
* `rollup_and_prune` notifies once per pruning cycle (at most once per
  app start) so the dashboard reflects the new aggregate state.

Co-authored-by: in30mn1a <in30mn1a@users.noreply.github.com>
2026-05-29 22:47:51 +08:00

69 lines
2.6 KiB
Rust

//! 使用统计实时刷新事件模块
//!
//! 当 `proxy_request_logs` 表写入新数据时(代理日志、会话同步、归档等),
//! 通过本模块向前端 emit `usage-log-recorded` 事件,让 UsageDashboard
//! 立刻 invalidate 查询缓存而无需等待轮询周期。
//!
//! 设计要点:
//! - 全局单例 AppHandle:写日志路径上不持有 AppHandle,用 OnceCell 共享。
//! - 200ms 防抖合并:流式响应等场景在短时间内可能写入多条日志,
//! 合并成一次事件可避免前端连续 invalidate。
//! - 不阻塞写入:通知失败仅记录 warn 日志,不向上传播错误。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
/// 前端监听的事件名
pub const EVENT_USAGE_LOG_RECORDED: &str = "usage-log-recorded";
/// 防抖窗口:合并 200ms 内的多次通知。
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(200);
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
/// 防抖标记:true 表示已有调度任务在等待 emit,后续通知合并到该任务。
static EMIT_SCHEDULED: AtomicBool = AtomicBool::new(false);
/// 在应用 setup 阶段调用一次,注入 AppHandle。
///
/// 重复调用是无害的(OnceLock 仅首次写入生效),但应用启动期只该被
/// `lib.rs::run` 调一次。
pub fn init(handle: AppHandle) {
if APP_HANDLE.set(handle).is_err() {
log::debug!("usage_events::init 重复调用,已忽略");
} else {
log::info!("[usage-event] AppHandle 已注入,事件推送启用");
}
}
/// 通知前端有新的使用日志写入。
///
/// 调用方**不**需要持有 AppHandle,可以从任意线程/任意写入路径调用。
/// 内部 200ms 防抖合并,绝不阻塞调用线程。
pub fn notify_log_recorded() {
// AppHandle 未注入(典型出现在单元测试或 setup 之前):直接放弃。
let Some(handle) = APP_HANDLE.get() else {
return;
};
// 已有调度任务:本次通知被合并到既有任务里,无需再起线程。
if EMIT_SCHEDULED.swap(true, Ordering::AcqRel) {
return;
}
let handle = handle.clone();
std::thread::spawn(move || {
std::thread::sleep(DEBOUNCE_WINDOW);
// 必须先清标志再 emit:万一 emit 期间又有新通知进来,
// 下一轮防抖窗口会重新调度,不会丢失。
EMIT_SCHEDULED.store(false, Ordering::Release);
if let Err(e) = handle.emit(EVENT_USAGE_LOG_RECORDED, ()) {
log::warn!("emit {EVENT_USAGE_LOG_RECORDED} 失败: {e}");
}
});
}