feat(db): rebuild Codex usage on upgrade and via maintenance action

Schema v16 wipes codex_session detail rows, _codex_session rollups and
Codex rollout cursors inside the migration savepoint (cursor deletion
uses pure shape matching so CODEX_HOME drift cannot orphan cursors);
the next session sync re-imports history from source JSONL under the
corrected importer. Fresh installs traverse the same branch as a no-op.

Add a manual "Rebuild Codex usage" maintenance action (single-flight,
hard-fail backup before reset, unconditional refresh notification even
when reimport is empty or fails after reset) with a destructive confirm
dialog, result toast and four-locale strings. Historical proxy-side
duplicate rows are intentionally left untouched; history whose source
JSONL was already deleted cannot be reconstructed.
This commit is contained in:
Jason
2026-07-20 12:18:47 +08:00
parent c9ac6efd69
commit eff1e0ccfc
11 changed files with 258 additions and 1 deletions
+59
View File
@@ -289,6 +289,35 @@ pub async fn sync_session_usage(
.map_err(|error| AppError::Message(format!("会话用量同步任务失败: {error}")))
}
/// Codex reset 成功后,无论重导是否导入新行或返回错误,都必须通知前端刷新。
/// 调用方应只在 reset 成功后调用,避免把未发生的数据变更误报为重建完成。
fn finish_codex_rebuild(
result: Result<crate::services::session_usage::SessionSyncResult, AppError>,
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
crate::usage_events::notify_log_recorded();
result
}
/// 备份数据库后,仅重建 Codex session 用量。锁覆盖 backup → reset → import
/// 整个序列,避免后台同步在清理和重导之间插入数据。
#[tauri::command]
pub async fn rebuild_codex_usage(
state: State<'_, AppState>,
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
let db = state.db.clone();
let _guard = crate::services::session_usage::session_sync_mutex()
.lock()
.await;
tauri::async_runtime::spawn_blocking(move || {
db.backup_database_file()?;
db.reset_codex_usage()?;
let result = crate::services::session_usage_codex::sync_codex_usage(&db);
finish_codex_rebuild(result)
})
.await
.map_err(|error| AppError::Message(format!("Codex 用量重建任务失败: {error}")))?
}
/// 获取数据来源分布
#[tauri::command]
pub fn get_usage_data_sources(
@@ -308,3 +337,33 @@ pub struct ModelPricingInfo {
pub cache_read_cost_per_million: String,
pub cache_creation_cost_per_million: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_rebuild_notifies_when_reimport_is_empty() {
crate::usage_events::take_test_notify_count();
let result = finish_codex_rebuild(Ok(
crate::services::session_usage::SessionSyncResult::default(),
))
.expect("空重导应成功");
assert_eq!(result.imported, 0);
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
}
#[test]
fn codex_rebuild_notifies_when_reimport_fails_after_reset() {
crate::usage_events::take_test_notify_count();
let result = finish_codex_rebuild(Err(AppError::Message(
"synthetic reimport failure".to_string(),
)));
assert!(result.is_err());
assert_eq!(crate::usage_events::take_test_notify_count(), 1);
}
}