feat(usage): add manual historical cache data repair

This commit is contained in:
lzt404 committed 2026-08-28 00:51:28 +08:00
1 parent f695193e24
commit 44dcdf13a8
7 files changed
+263 -40

No files matched your search

+1
View File
@@ -2452,6 +2452,7 @@ fn main() {
usage::get_usage_analysis,
usage::get_usage_events,
usage::get_usage_pricing,
usage::repair_usage_cache_records,
usage::save_usage_model_price,
usage::delete_usage_model_price,
usage::sync_usage_model_prices,
+35 -37
View File
@@ -35,7 +35,6 @@ const LEGACY_USAGE_EVENTS_DIR: &str = "events";
const LEGACY_USAGE_INBOX_DIR: &str = "inbox";
const LEGACY_JSON_MIGRATION_KEY: &str = "legacy_json_v1";
const USAGE_DATABASE_MIGRATION_KEY: &str = "keeper_v3";
const CLAUDE_INPUT_MIGRATION_KEY: &str = "claude_input_inclusive_v1";
const USAGE_FAILURE_MIGRATION_KEY: &str = "failure_details_v4";
const USAGE_UPDATED_EVENT: &str = "usage-records-updated";
const USAGE_SCHEMA_VERSION: u8 = 1;
@@ -266,6 +265,14 @@ pub(crate) struct UsageOverview {
timeline: Vec<UsageTimelinePoint>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageRepairResult {
scanned: u64,
repaired: u64,
backup_path: Option<String>,
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModelPrice {
@@ -529,24 +536,18 @@ fn initialize_usage_storage_at(root: &Path) -> Result<(), String> {
.pragma_update(None, "journal_mode", "WAL")
.map_err(|error| format!("启用 SQLite WAL 失败: {error}"))?;
migrate_usage_database(&mut connection, root)?;
migrate_claude_input_tokens(&mut connection, root)?;
migrate_legacy_json_storage(&mut connection, root)?;
cleanup_usage_inbox(&connection, Local::now())
}
fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Result<(), String> {
let already_done: Option<String> = connection
.query_row(
"SELECT value FROM usage_metadata WHERE key = ?1",
params![CLAUDE_INPUT_MIGRATION_KEY],
|row| row.get(0),
)
.optional()
.map_err(|error| format!("检查 Claude 使用记录迁移状态失败: {error}"))?;
if already_done.is_some() {
return Ok(());
}
#[tauri::command]
pub(crate) fn repair_usage_cache_records() -> Result<UsageRepairResult, String> {
let root = usage_root_dir()?;
repair_usage_cache_records_at(&root)
}
fn repair_usage_cache_records_at(root: &Path) -> Result<UsageRepairResult, String> {
let mut connection = open_usage_database_at(root)?;
let candidate_count: i64 = connection
.query_row(
r#"SELECT COUNT(*) FROM usage_events
@@ -560,7 +561,11 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu
)
.map_err(|error| format!("检查 Claude 使用记录异常行失败: {error}"))?;
if candidate_count > 0 {
if candidate_count <= 0 {
return Ok(UsageRepairResult::default());
}
let backup_path = {
let backup_dir = root.join(USAGE_BACKUP_DIR_NAME);
fs::create_dir_all(&backup_dir)
.map_err(|error| format!("创建 Claude 使用记录迁移备份目录失败: {error}"))?;
@@ -574,7 +579,8 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu
params![backup_path.to_string_lossy().to_string()],
)
.map_err(|error| format!("备份 Claude 使用记录失败: {error}"))?;
}
backup_path.to_string_lossy().to_string()
};
let transaction = connection
.transaction()
@@ -598,21 +604,14 @@ fn migrate_claude_input_tokens(connection: &mut Connection, root: &Path) -> Resu
[],
)
.map_err(|error| format!("迁移 Claude 使用记录失败: {error}"))?;
transaction
.execute(
"INSERT INTO usage_metadata (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![CLAUDE_INPUT_MIGRATION_KEY, Local::now().to_rfc3339()],
)
.map_err(|error| format!("写入 Claude 使用记录迁移标记失败: {error}"))?;
transaction
.commit()
.map_err(|error| format!("提交 Claude 使用记录迁移失败: {error}"))?;
if migrated > 0 {
eprintln!("migrated {migrated} Claude usage rows to inclusive input token semantics");
}
Ok(())
Ok(UsageRepairResult {
scanned: candidate_count.max(0) as u64,
repaired: migrated as u64,
backup_path: Some(backup_path),
})
}
fn migrate_usage_database(connection: &mut Connection, root: &Path) -> Result<(), String> {
@@ -3301,7 +3300,7 @@ mod tests {
}
#[test]
fn migrates_legacy_claude_input_tokens_once() {
fn repairs_legacy_claude_input_tokens_on_demand() {
let root = test_root("claude-input-migration");
let connection = open_test_database(&root);
connection
@@ -3316,15 +3315,12 @@ mod tests {
[],
)
.unwrap();
connection
.execute(
"DELETE FROM usage_metadata WHERE key = ?1",
params![CLAUDE_INPUT_MIGRATION_KEY],
)
.unwrap();
drop(connection);
initialize_usage_storage_at(&root).unwrap();
let result = repair_usage_cache_records_at(&root).unwrap();
assert_eq!(result.scanned, 1);
assert_eq!(result.repaired, 1);
assert!(result.backup_path.is_some());
let connection = open_usage_database_at(&root).unwrap();
let row: (i64, i64, i64) = connection
.query_row(
@@ -3336,7 +3332,9 @@ mod tests {
assert_eq!(row, (720, 740, 620));
drop(connection);
initialize_usage_storage_at(&root).unwrap();
let second = repair_usage_cache_records_at(&root).unwrap();
assert_eq!(second.scanned, 0);
assert_eq!(second.repaired, 0);
let rerun_connection = open_usage_database_at(&root).unwrap();
let rerun_input: i64 = rerun_connection
.query_row(
+13
View File
@@ -113,6 +113,7 @@ export const jaOverrides = {
'usage.tab.analysis': '分析',
'usage.tab.events': 'リクエスト詳細',
'usage.tab.pricing': '料金統計',
'usage.tab.dataManagement': 'データ管理',
'usage.refresh': '更新',
'usage.filter.reset': 'フィルター解除',
'usage.filter.timeRange': '期間',
@@ -179,6 +180,18 @@ export const jaOverrides = {
'usage.pricing.saved': 'モデル料金を保存し、概要のコストを更新しました',
'usage.pricing.deleted': 'モデル料金を削除し、概要のコストを更新しました',
'usage.pricing.deleteConfirm': 'モデル {model} の料金を削除しますか?',
'usage.dataManagement.title': '履歴データ管理',
'usage.dataManagement.description': 'キャッシュ Token の不整合がある使用履歴をスキャンして修復します。',
'usage.dataManagement.manualBadge': '手動操作',
'usage.dataManagement.notice': '修復前にデータベースをバックアップします。判定可能な Claude / Anthropic の記録のみ処理します。',
'usage.dataManagement.actionTitle': 'キャッシュ記録を修復',
'usage.dataManagement.actionDescription': 'キャッシュ読み取りと作成を含む入力 Token の集計方式に統一します。',
'usage.dataManagement.run': 'スキャンして修復',
'usage.dataManagement.running': '処理中…',
'usage.dataManagement.confirm': '履歴キャッシュ記録をスキャンして修復しますか?先にデータベースをバックアップします。',
'usage.dataManagement.scanned': 'スキャン件数',
'usage.dataManagement.repaired': '修復件数',
'usage.dataManagement.backup': 'バックアップ',
'usage.trend.title': 'リクエストと Token の推移',
'usage.trend.description': 'システムのローカル時刻ごとに集計',
'usage.token.title': 'Token の内訳',
+13
View File
@@ -113,6 +113,7 @@ export const en: Record<MessageKey, string> = {
'usage.tab.analysis': 'Analysis',
'usage.tab.events': 'Request Details',
'usage.tab.pricing': 'Price Statistics',
'usage.tab.dataManagement': 'Data Management',
'usage.refresh': 'Refresh',
'usage.filter.reset': 'Reset Filters',
'usage.filter.timeRange': 'Time range',
@@ -179,6 +180,18 @@ export const en: Record<MessageKey, string> = {
'usage.pricing.saved': 'Model price saved; overview cost updated',
'usage.pricing.deleted': 'Model price deleted; overview cost updated',
'usage.pricing.deleteConfirm': 'Delete the price for {model}?',
'usage.dataManagement.title': 'Historical Data Management',
'usage.dataManagement.description': 'Scan and repair historical usage records with inconsistent cache token accounting.',
'usage.dataManagement.manualBadge': 'Manual action',
'usage.dataManagement.notice': 'A database backup is created first. Only identifiable Claude / Anthropic records are processed.',
'usage.dataManagement.actionTitle': 'Repair cache records',
'usage.dataManagement.actionDescription': 'Use input token accounting that includes cache reads and cache creation.',
'usage.dataManagement.run': 'Scan and repair',
'usage.dataManagement.running': 'Processing…',
'usage.dataManagement.confirm': 'Scan and repair historical cache records? A database backup will be created first.',
'usage.dataManagement.scanned': 'Scanned',
'usage.dataManagement.repaired': 'Repaired',
'usage.dataManagement.backup': 'Backup file',
'usage.trend.title': 'Request and Token Trend',
'usage.trend.description': 'Grouped by local system hour',
'usage.token.title': 'Token Breakdown',
+13
View File
@@ -111,6 +111,7 @@ export const zhCN = {
'usage.tab.analysis': '分析',
'usage.tab.events': '请求明细',
'usage.tab.pricing': '价格统计',
'usage.tab.dataManagement': '数据管理',
'usage.refresh': '刷新数据',
'usage.filter.reset': '重置筛选',
'usage.filter.timeRange': '时间范围',
@@ -177,6 +178,18 @@ export const zhCN = {
'usage.pricing.saved': '模型价格已保存,总览成本已更新',
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
'usage.dataManagement.title': '历史数据管理',
'usage.dataManagement.description': '扫描并修复缓存 Token 口径异常的历史使用记录。',
'usage.dataManagement.manualBadge': '手动操作',
'usage.dataManagement.notice': '修复前会自动备份数据库,仅处理可确认的 Claude / Anthropic 异常记录。',
'usage.dataManagement.actionTitle': '修复异常缓存记录',
'usage.dataManagement.actionDescription': '将输入 Token 统一为包含缓存读取和缓存创建的统计口径。',
'usage.dataManagement.run': '扫描并修复',
'usage.dataManagement.running': '处理中…',
'usage.dataManagement.confirm': '确定扫描并修复历史缓存记录吗?修复前会自动创建数据库备份。',
'usage.dataManagement.scanned': '扫描记录',
'usage.dataManagement.repaired': '修复记录',
'usage.dataManagement.backup': '备份文件',
'usage.trend.title': '请求与 Token 趋势',
'usage.trend.description': '按系统本地小时聚合',
'usage.token.title': 'Token 构成',
+88 -3
View File
@@ -22,6 +22,7 @@ import {
Terminal,
Trash2,
TriangleAlert,
Wrench,
X,
} from 'lucide-react';
import { getCurrentLocale, useI18n } from '../i18n';
@@ -29,7 +30,7 @@ import type { MessageKey } from '../i18n/resources';
import { formatCacheReadRate, formatGenerationSpeed } from '../services/usageMetrics';
import { formatUsageNumber } from '../services/usageNumber';
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing';
type UsageTab = 'overview' | 'analysis' | 'events' | 'pricing' | 'data-management';
type UsageRange = '4h' | '24h' | 'today' | '7d' | '30d' | 'all' | 'custom';
type CollectorStatus = {
@@ -158,6 +159,12 @@ type UsagePricing = {
savedPrices: number;
};
type UsageRepairResult = {
scanned: number;
repaired: number;
backupPath: string | null;
};
type ModelPriceSyncResult = {
imported: number;
skipped: number;
@@ -185,7 +192,9 @@ const emptyAnalysis: UsageAnalysis = { models: [], providers: [], sources: [], a
const loadTab = (): UsageTab => {
try {
const saved = localStorage.getItem(TAB_KEY);
return saved === 'analysis' || saved === 'events' || saved === 'pricing' ? saved : 'overview';
return saved === 'analysis' || saved === 'events' || saved === 'pricing' || saved === 'data-management'
? saved
: 'overview';
} catch {
return 'overview';
}
@@ -356,7 +365,7 @@ export function UsageRecordsPage() {
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setEvents(nextEvents);
} else {
} else if (activeTab === 'pricing') {
const [nextStatus, nextOptions, nextPricing] = await Promise.all([
statusRequest,
optionsRequest,
@@ -366,6 +375,11 @@ export function UsageRecordsPage() {
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
setPricing(nextPricing);
} else {
const [nextStatus, nextOptions] = await Promise.all([statusRequest, optionsRequest]);
if (requestId !== requestIdRef.current) return;
setStatus(nextStatus);
setOptionsAnalysis(nextOptions);
}
setError('');
} catch (requestError) {
@@ -475,6 +489,14 @@ export function UsageRecordsPage() {
<CircleDollarSign size={15} />
<span>{t('usage.tab.pricing')}</span>
</button>
<button
type="button"
className={activeTab === 'data-management' ? 'active' : ''}
onClick={() => setActiveTab('data-management')}
>
<Wrench size={15} />
<span>{t('usage.tab.dataManagement')}</span>
</button>
</div>
<div className="usage-topbar-actions">
@@ -677,6 +699,69 @@ export function UsageRecordsPage() {
{activeTab === 'pricing' && pricing ? (
<PricingView pricing={pricing} query={buildQueries().query} onChanged={() => loadData(true)} />
) : null}
{activeTab === 'data-management' ? <UsageDataManagementView /> : null}
</section>
);
}
function UsageDataManagementView() {
const { t } = useI18n();
const [running, setRunning] = useState(false);
const [result, setResult] = useState<UsageRepairResult | null>(null);
const [error, setError] = useState('');
const repair = async () => {
if (!window.confirm(t('usage.dataManagement.confirm'))) return;
setRunning(true);
setError('');
setResult(null);
try {
const next = await invoke<UsageRepairResult>('repair_usage_cache_records');
setResult(next);
} catch (requestError) {
setError(String(requestError));
} finally {
setRunning(false);
}
};
return (
<section className="panel usage-data-management-panel">
<div className="usage-data-management-heading">
<div>
<Wrench size={20} aria-hidden="true" />
<div>
<h2>{t('usage.dataManagement.title')}</h2>
<p>{t('usage.dataManagement.description')}</p>
</div>
</div>
<span className="usage-data-management-badge">{t('usage.dataManagement.manualBadge')}</span>
</div>
<div className="usage-data-management-notice">
<TriangleAlert size={17} aria-hidden="true" />
<span>{t('usage.dataManagement.notice')}</span>
</div>
<div className="usage-data-management-action">
<div>
<strong>{t('usage.dataManagement.actionTitle')}</strong>
<span>{t('usage.dataManagement.actionDescription')}</span>
</div>
<button type="button" className="primary-button" onClick={() => void repair()} disabled={running}>
<Wrench size={15} />
{running ? t('usage.dataManagement.running') : t('usage.dataManagement.run')}
</button>
</div>
{error ? <div className="management-alert error">{error}</div> : null}
{result ? (
<div className="usage-data-management-result">
<div><span>{t('usage.dataManagement.scanned')}</span><strong>{result.scanned.toLocaleString()}</strong></div>
<div><span>{t('usage.dataManagement.repaired')}</span><strong>{result.repaired.toLocaleString()}</strong></div>
<div><span>{t('usage.dataManagement.backup')}</span><strong title={result.backupPath ?? undefined}>{result.backupPath ?? '—'}</strong></div>
</div>
) : null}
</section>
);
}
+100
View File
@@ -7718,6 +7718,95 @@ body.table-col-resizing * {
background: var(--theme-fffdf8);
}
.usage-data-management-panel {
padding: 24px;
}
.usage-data-management-heading,
.usage-data-management-action,
.usage-data-management-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.usage-data-management-heading > div {
display: flex;
align-items: flex-start;
gap: 12px;
}
.usage-data-management-heading h2 {
margin: 0;
font-size: 18px;
}
.usage-data-management-heading p,
.usage-data-management-action span {
display: block;
margin: 6px 0 0;
color: var(--theme-716b61);
font-size: 13px;
}
.usage-data-management-badge {
padding: 5px 9px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 999px;
color: var(--theme-716b61);
font-size: 12px;
}
.usage-data-management-notice {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 22px 0;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--theme-c77b30) 28%, var(--theme-d8d4cc));
border-radius: 10px;
background: color-mix(in srgb, var(--theme-c77b30) 8%, transparent);
color: var(--theme-716b61);
font-size: 13px;
}
.usage-data-management-action {
padding: 16px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 12px;
background: var(--theme-f8f5ee);
}
.usage-data-management-result {
align-items: stretch;
margin-top: 18px;
}
.usage-data-management-result > div {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 5px;
padding: 12px;
border: 1px solid var(--theme-d8d4cc);
border-radius: 10px;
}
.usage-data-management-result span {
color: var(--theme-716b61);
font-size: 12px;
}
.usage-data-management-result strong {
overflow: hidden;
color: var(--theme-2d2a26);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-pricing-toolbar {
display: flex;
align-items: center;
@@ -9010,6 +9099,17 @@ body.table-col-resizing * {
}
@media (max-width: 520px) {
.usage-data-management-heading,
.usage-data-management-action,
.usage-data-management-result {
align-items: stretch;
flex-direction: column;
}
.usage-data-management-action .primary-button {
width: 100%;
}
.panel-heading {
flex-direction: column;
}