# dsh-token-monitor 用量数据存储设计 > 状态:已落地 · 2026-09-11 更新 > 范围:按会话 / 按天 / 按模型的 token 用量与估算费用的本地存储与聚合,含 CC-switch 历史数据一键导入。 ## 实现状态总览(2026-09-11) | 章节 | 内容 | 状态 | |---|---|---| | §2.1 | DSH 会话日志折叠 | ✅ 已实现(fold.js + fold_watermarks;2026-09-11 起支持 DSH 日志版本升级,见 §7) | | §2.2 | CC-switch 数据源(读 db + 读 sql 文件) | ✅ 已实现(读 db `importCcSwitch` + 解析 sql 文件 `importCcSqlFile`) | | §3 | 存储选型(node:sqlite) | ✅ 已实现 | | §4.1 | `usage_requests` 事实表 | ✅ 已实现 | | §4.2 | `fold_watermarks` 水位表 | ✅ 已实现(2026-09-11 新增 `last_offset` / `pending` 两列) | | §4.3 | `usage_daily_rollups` 预聚合表 | ✅ 已实现 | | §4.4 | 模型归属规则 | ✅ 已实现(折叠游标) | | §4.5 | 提供方映射(vendor 归并) | ✅ 已实现(`provider-mappings.js` 单文件维护,不入库;distribution/rank 按 vendor 聚合) | | §5 | 定价与费用口径 | ✅ 已实现(2026-09-09 起 `model_prices` 单表优先、pi-ai 兜底,含 DeepSeek 峰谷倍率;详见 [模型定价表设计](模型定价表设计.md)) | | §6 | CC 导入(db 导入) | ✅ 已实现(`importCcSwitch` + `checkCcPending`) | | §6 | CC 导入(历史聚合迁移 `usage_daily_rollups`) | ✅ 已实现(`importCcRollups`:覆盖 upsert + `db-rollup` 独立水位) | | §6 | CC 导入(sql 文件导入) | ✅ 已实现(`importCcSqlFile` 解析 `proxy_request_logs`;用量页"导入"入口) | | §7 | DSH 同步节奏(5 分钟增量折叠) | ✅ 已实现(2026-09-11 起:日志版本识别 + 内容键身份 + 两道闸) | | §7 | 明细定期清理(60 天保留 + 24h 时间闸 + prune 审计) | ✅ 已实现(挂载折叠入口,只清明细不碰 rollup) | | §8 | 查询路由与用量页签 | ✅ 已实现(daily/by-model/sessions/hourly/distribution/calendar/rank) | | §8 | 用量页签 UI(conversation.view) | ✅ 已实现(含请求记录、使用排行、数据来源卡两列:最近更新 / 最近同步) | | §10 | 折叠失效可见 | ✅ 已实现(读不出 header / 命名认不出 / 有事件却 0 行 → 健康统计随 `/usage/sources` 下发,来源卡提示) | | §11.1 | 数据源注册表(SyncSource 接口) | ❌ 未实现(单源写死,多源预留) | | §11.2 | `sync_logs` 表 | ✅ 已实现(表 + store 方法 + CC 导入 + **DSH 折叠** + prune 审计四类写入;保留 14 天,每源至少留最新一行) | | §11.3 | 同步提示条(检测 + 按钮) | ✅ 已实现(弹层打开检测 + 同步按钮 + 结果摘要) | | §11.3 | CC 增量扫描(按 watermark) | ✅ 已实现(`checkCcPending`/`importCcSwitch` 增量,水位存 sync_logs) | | §12 | 后续扩展 | ⏸ 预留,不做 | | §13 | 路由来源校验与运维硬化 | ✅ 已实现(Host/Origin 校验、升级异步 + 单飞锁、请求体 32MB 上限) | ## 1. 目标与非目标 **目标** - 把本机产生的大模型调用明细(token 四项 + 估算费用)持久化到本地 SQLite,支持: - 按天统计 / 按模型统计(读 `usage_daily_rollups` 预聚合表,毫秒级) - 按会话统计(现有 `tokenUsage` 投影已覆盖,本库提供跨会话视图) - 估算费用(token × 刊例价,折叠时定格) - CC-switch 历史记录(`proxy_request_logs`)一键导入,幂等、可重复执行。 **非目标(本期不做)** - 不做工具耗时 / 审批行为 / 命令使用等行为统计(字段已在日志里,后续可加)。 - 不替代供应商侧额度查询(5h/本周窗口仍走 `/v1/usages` 实时 API)。 ## 2. 数据源 ### 2.1 DSH 会话日志(主源,增量折叠) - 位置:`$DSH_HOME/sessions//session-/` 下的会话日志;**文件名带"日志版本"**——v0 为 `session.jsonl.zstd`,vN(N≥1)为 `session.vN.jsonl.zstd`(当前 DSH 写 v3)。命名契约来自官方 `parseSessionFormatLogFilename`;读取规则与官方 `resolveGenerationInDirectory` 一致:**枚举目录内全部规范命名的日志,取版本号最大的那个**(低版本是历史快照,不会被删除)。每次扫描重新 glob 整棵树,新会话目录自动发现。 - 身份来自文件首行 header(实测):`{"type":"session","version":3,"id":"session-…","createdAt":…,"cwd":"…"}`——`version` 即该文件的日志版本;水位按 header `id` 键控,不依赖目录名。 - **文件是会话级而非天级**:一个会话跨多天 = 同一文件持续追加;`seq` 在**同一文件内**从 0 递增。注意:日志版本升级时 DSH 会把整段历史**重新编码**进新文件(事件内容保真、`seq` 重编号),因此 `seq` **不能跨版本比较**,水位与去重都不能依赖它(见 §7)。"按天"是折叠/查询时按事件 `time` 分组,与文件边界无关。 - 格式:**多个独立 Zstandard 帧串接**,每帧解压后是若干行 JSONL。信封字段:`{ type, seq, time, data, ... }`,`time` 为毫秒时间戳。 - 折叠所需事件: | 事件 | 取用字段 | 用途 | |---|---|---| | `session`(首行 header) | session id、cwd、createdAt | 会话登记 | | `session/title` | `title` | 更新水位行的 `title` 列(§4.2) | | `request/context` | `provider`、`model`、`contextWindow` | 路由归属(见 §4.3) | | `assistant/message` | `data.usage.{inputTokens, outputTokens, cacheReadTokens}` + `turn/step` + 信封 `seq/time` | 用量事实行 | | `step/start` + `assistant/chunk`(首 chunk) | 信封 `time` | TTFT(v0 日志):首 chunk 时间 − step 开始时间,写入 `ttft_ms` | | `assistant/message.data.stream[0].time` | 流内首块时间 | TTFT(v3 起):日志版本 ≥3 不再写 `assistant/chunk` 事件,chunk 流并入了消息体内嵌的 `stream` 数组,取首个块的时间 | | `turn/end` | `reason` | 轮次结局(completed / error / aborted),后续做可靠性指标 | - 分帧规则:按 zstd 帧头(魔数 `28 B5 2F FD` + 帧头内的 frame content size 字段)精确切帧,**不做魔数全文件扫描**(魔数可能恰好出现在压缩载荷里)。`node:zlib.zstdDecompressSync` 逐帧解压。 - **帧不可切分**(2026-09-11 修正):单帧实测可达 10MB 级,因此读取按"**至少凑满一帧**"自适应放宽窗口(首帧 4KB 起翻倍,读体内核 512KB 起翻倍、单帧硬上限 64MB),每轮每文件另有 16MB 软上限;没读完就标 `pending`,下一轮无视 mtime 继续——否则窗口切在帧中间时"切不出帧 → 位移不前进 → 永远卡住"。 - 活跃会话:最后未闭合的帧可能不完整,只折叠到最后一个完整帧,剩余部分等下次水位推进再读。只读打开,不干扰 DSH 写入。 **折叠粒度与口径**(与 CC-switch 请求级对齐:一次成功模型请求 = 一行): - 一行 = 一条 `assistant/message`(一个 step 一次请求)。usage 在流式 `assistant/chunk` 里也有一份,只认 `assistant/message`,不重复计。 - **计费闸(CC 源码实测教训)**:usage 行四项任一 > 0 即入库,不要求 `output > 0`——CC 曾因要求 stop_reason 非空 + output>0 系统性低估 4.1%(92% 集中在子代理/workflow 短命请求,input/cache 在请求受理时已计费)。 - 辅助调用(标题生成 / compaction 摘要 / 联网搜索等非 step 的直接模型调用)日志只记请求、不记 usage,**不计入**——量级可忽略(标题上限 64 token),口径为"step 级用量"。 - 失败/中断请求无 `assistant/message`,不计入——与 DSH 自带 `tokenUsage` 投影口径一致,界面数字不打架。 ### 2.2 CC-switch(外部平台用量来源,文件导入 + 可重入) **角色定位**:本插件只读 DSH 本平台会话日志(§2.1);DSH 之外平台众多(Claude Code / Codex / Gemini / OpenClaw / …),每个平台各有自己的会话日志格式,我们无法逐个适配解析——**直接消费 CC-switch 已汇总好的数据**,一份导入补齐全部外部平台用量。 **两个导入入口,两套读取方式(不共用逻辑)**: > 状态:**两个入口均已实现**——弹层"同步"读 db(`importCcSwitch`)、用量页"导入"读 sql 文件(`importCcSqlFile` + `parseCcSqlFile`)。 - **弹层底部提示条"同步"(§11.3)**:自动检测**本机** CC 库(`~/.cc-switch/cc-switch.db`)有无未同步记录,有则提示,点同步**读 db 文件**导入本机 CC 使用记录——默认行为,无需用户干预。 - **用量页底部数据来源卡片"导入"按钮**:**跨设备**使用记录,**由用户自己手动导入**——CC 导出功能生成的 SQL 文件(`cc-switch-export-*.sql`),用户选文件后**解析 SQL 文本**导入。 **读 db 方式**:`~/.cc-switch/cc-switch.db` **只读**打开(WAL 模式下只读连接不影响其运行),SQL 查询 `proxy_request_logs` 表。 **读 sql 文件方式**:CC 导出功能生成 `cc-switch-export-*.sql`(`sqlite3 .dump` 文本格式,含 `CREATE TABLE` + `INSERT INTO` 语句),解析 `INSERT INTO "proxy_request_logs"` 语句(仅此一张表),不依赖 SQLite 库文件存在。 - **`proxy_request_logs` 字段语义澄清**(2026-08-20 评审修正):`app_type` 是**客户端应用**(claude/codex/gemini/…),`provider_id='_session'` + `data_source='session_log'` 表示"该条记录来自该平台的会话日志"——即 CC 与我们的折叠是同一件事,只是 CC 汇聚了多个平台。**当前 CC 版本尚不支持 DSH 记录统计**(不产生 `app_type='dsh'` 的行),导入行都是外部平台的用量。 - 取 `proxy_request_logs` 单表(明细,30 天内);**2026-08-20 起同时迁移 `usage_daily_rollups`**(30 天前归档的按天聚合,以**覆盖语义** upsert 进我们的 rollup,走独立 `kind='db-rollup'` 水位增量——见 §6 补充说明)。`model_pricing` 不导入(定价唯源 pi-ai,见 §5)。 ## 3. 存储选型与位置 - **`node:sqlite`**(Node 22 内置 `DatabaseSync`):零依赖、零原生编译;DSH 自带的 session-query-sqlite 已验证同版本可用。 - 路径:`$DSH_HOME/storages/token-monitor/token-monitor.db`(与 DSH 其他存储同级)。按插件归属命名而非内容命名——库内除用量表外还有水位、同步日志等表,归属命名不随加表过时。 - 无外部写者:只有本插件服务端写库,单连接即可;开 WAL 只为读并发宽松。 ## 4. Schema 全库三张表:事实表(`usage_requests`)回答"发生了什么",预聚合表(`usage_daily_rollups`)回答"合计是多少",进度表(`fold_watermarks`)回答"我读到哪了"。另有计划中的 `sync_logs`(§11)。 ### 4.1 `usage_requests` —— 用量事实表 **粒度**:一行 = 一次成功的模型调用。DSH 侧对应一条 `assistant/message` 事件;CC 侧对应一条 `proxy_request_logs` 记录。 ```sql CREATE TABLE IF NOT EXISTS usage_requests ( -- 主键 record_id TEXT PRIMARY KEY, -- dsh: ':';cc: request_id -- 维度 source TEXT NOT NULL, -- 'dsh-logs' | 'cc-switch'(= SyncSource.id,§11.1);来源筛选与分组键 client TEXT NOT NULL, -- 产出应用(客户端)标识:DSH 行存 'dsh';CC 行存其 app_type provider TEXT NOT NULL, -- 'kimi-coding' / 'deepseek' / cc 的 provider_id model TEXT NOT NULL, session_id TEXT, -- 会话下钻维度;CC 数据可能为空 -- 用量 input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_write_tokens INTEGER NOT NULL DEFAULT 0, -- 沉睡字段:Anthropic 系才有非 0 -- 派生指标 cost_usd_nano INTEGER, -- 纳美元(1e-9 $)定点;折叠/导入时定格;定价缺失为 NULL ttft_ms INTEGER, -- 首 token 延迟,可空 -- 时间戳收尾 day TEXT NOT NULL, -- 'YYYY-MM-DD'(折叠时本地时区冻结):分桶事实,与 rollup 口径一致 created_at INTEGER NOT NULL, -- 事件时间,毫秒(CC 秒级 × 1000) ); CREATE INDEX IF NOT EXISTS idx_usage_day ON usage_requests (day); CREATE INDEX IF NOT EXISTS idx_usage_created_at ON usage_requests (created_at); CREATE INDEX IF NOT EXISTS idx_usage_model ON usage_requests (model, day); ``` 逐列说明: | 列 | 类型 | 来源与语义 | |---|---|---| | `record_id` | TEXT | **单字段主键**。DSH:`sessionId:seq`(seq 会话内单调递增,拼上会话 id 即全局唯一);CC:它的 `request_id`(UUID / `session:{app_type}:…`)。**幂等的根基**:重复折叠主键冲突,`INSERT OR IGNORE` 跳过。已评审接受的假设:跨源 id 格式不同(`session-uuid:N` vs UUID),不撞靠格式差异而非联合主键约束 | | `source` | TEXT | 数据来源标识,取值 = SyncSource.id(§11.1):`dsh-logs` = 本地日志折叠;`cc-switch` = CC 导入。来源筛选与分组键,为将来第三个来源留位 | | `client` | TEXT | 产出该请求的客户端应用,**统一非空**:DSH 自有行存 `'dsh'`;CC 行存其 `app_type`(claude/codex/…,CC 的 `type` 是误命名——值是应用标识不是类型,故不沿用)。**前瞻性过滤锚点**:若 CC 将来支持 DSH 会话日志,其行会与自有折叠双算——按 `source` + `client` 即可精确区分"DSH 自采"与"经 CC 转手的 DSH"(§6 另有导入白名单双保险) | | `provider` | TEXT | 供应商路由。DSH:`request/context` 的 provider(如 `kimi-coding`);CC:`provider_id`(`_session`/`_codex_session` 会话来源按模型反查真实供应商,反查失败标 `unknown` 待人工核对) | | `model` | TEXT | 模型 id(如 `k3`、`deepseek-v4-pro`)。按模型统计的分组键,也是定价查询的键 | | `session_id` | TEXT 可空 | 会话下钻维度。CC 的 session 概念与 DSH 不同(Claude Code 的会话 id),原样保留,只做展示不关联 | | `input_tokens` | INTEGER | 未缓存输入 token(供应商回报值) | | `output_tokens` | INTEGER | 输出 token(含推理 token,pi-ai 口径已折叠进去) | | `cache_read_tokens` | INTEGER | 缓存命中 token。DSH 事件里叫 `cacheReadTokens`,CC 同名 | | `cache_write_tokens` | INTEGER | 缓存写入 token。当前供应商恒 0(DeepSeek 无此计量、Kimi k3 写入价 0);CC 的 `cache_creation_tokens` 映射到此列。为 Anthropic 系预留,schema 不动即可启用 | | `cost_usd_nano` | INTEGER 可空 | 估算费用,**纳美元(1e-9 $)定点整数**——不用 REAL:f64 无法精确表示十进制小数,海量行 `SUM` 会积累尾差。纳刻度精确覆盖 CC 数据的 9 位小数(64 位上限 ~92 亿美元,无溢出之虞)。写入时定格(§5)。可空:目录无此模型定价时为 NULL,统计计入 token 但不计入费用。CC 行搬它的 `total_cost_usd`(9 位小数 → ×1e9 精确转整数) | | `ttft_ms` | INTEGER 可空 | 首 token 延迟。DSH:该 step 首个 `assistant/chunk` 时间 − `step/start` 时间;CC:直接搬 `first_token_ms`。衡量"模型今天快不快"的体感指标 | | `day` | TEXT | `created_at` 按**折叠时本地时区**折算的 `YYYY-MM-DD`,写入时冻结。**职责是分桶事实的物化而非分组优化**(按天分组已由 rollup 承担):下钻查询 `WHERE day = :day` 与 rollup 行的构成口径逐字一致——若只存 `created_at`,跨时区后按查询时时区切范围会和 rollup 的冻结分桶对不上账 | | `created_at` | INTEGER | 事件发生的毫秒时间戳。DSH 取事件信封 `time`;CC 的 `created_at` 是秒,×1000。时间窗查询("近 7 天")走它的索引 | 索引设计: | 索引 | 服务的查询 | |---|---| | `idx_usage_day (day)` | 按天分组、按天过滤(最高频) | | `idx_usage_created_at (created_at)` | 任意毫秒时间窗范围扫描 | | `idx_usage_model (model, day)` | 按模型排行、单模型的时间序列 | 主键 `record_id` 本身产生唯一 B-tree,会话维度的查询走前缀(`record_id LIKE ':%'`)即可命中,不单列 `session_id` 索引(低频,全表扫也小)。 示例行(Kimi 一次调用): ``` source='dsh-logs', record_id='session-0e7c…:146', session_id='session-0e7c…', client='dsh', provider='kimi-coding', model='k3', input_tokens=1912, output_tokens=220, cache_read_tokens=5632, cache_write_tokens=0, cost_usd_nano=1912×3000 + 220×15000 + 5632×300 = 10,725,600(= $0.0107256,全程整数运算), ttft_ms=3488, day='2026-08-17', created_at=1786952002247 ``` ### 4.2 `fold_watermarks` —— 折叠水位表 **粒度**:一行 = 一个 DSH 会话日志的读取进度。CC 导入不参与此表(它靠 `usage_requests` 主键幂等)。 ```sql CREATE TABLE IF NOT EXISTS fold_watermarks ( session_id TEXT PRIMARY KEY, log_path TEXT NOT NULL, last_seq INTEGER NOT NULL, -- 该文件内已折叠到的最大 seq(仅同文件续读时用作闸) file_mtime_ms INTEGER NOT NULL, -- 上次见到的文件 mtime(毫秒),用于快速跳过未变文件 title TEXT, -- 会话标题:折叠到 session/title 事件时更新,可空(未生成标题的会话) last_offset INTEGER NOT NULL DEFAULT 0, -- 已消费到的字节位置(帧边界) pending INTEGER NOT NULL DEFAULT 0, -- 1 = 本轮没读到文件尾(分批上限),下轮无视 mtime 继续 updated_at INTEGER NOT NULL ); ``` 逐列说明: | 列 | 语义 | |---|---| | `session_id` | 主键,取自日志首行 header 的权威 `id`(不是目录名),文件改名/移动不影响正确性 | | `log_path` | **当前选中的那个日志版本文件**的绝对路径。它同时承担"换文件检测":与本次选中的文件不一致(日志版本升级、水位丢失后重建)即进入"从头整读 + 全读闸"模式 | | `last_seq` | **该文件内**已折叠的最大事件 seq。新会话水位初始 -1(首事件 seq=0 会被收入)。仅当"同一文件续读"时用作跳过闸;跨文件(版本升级)不可比,改用全读闸 | | `file_mtime_ms` | 上轮见到的文件修改时间(**毫秒**,列名带单位——CC 曾因秒/纳秒混用被迫做兼容,此处预防)。扫描入口先读 header 再 stat,`file_mtime_ms` 没变且 `pending=0` 时跳过整个文件——不解压、不解析,活跃但无新事件的会话零成本 | | `title` | 会话标题,可空。日志里有 `session/title` 事件(`{title, messageSeqs, source}`,标题刷新会产生新事件),折叠到该事件时顺手更新此列。会话下拉/下钻列表显示名称就不必再查别的存储 | | `last_offset` | 已消费到的字节位置,**必然落在帧边界**(2026-09-11 新增)。配合 `pending` 支持大文件分批读,避免在宿主事件循环上做超长同步解压 | | `pending` | 1 = 本轮因分批上限没读到文件尾,下一轮必须无视 mtime 继续(2026-09-11 新增)。**注意与 §11.3 的"待同步 pending"同名不同义**:此处是"本轮没读完",那里是"该源有新数据待用户同步" | | `updated_at` | 水位行本身的最近推进时间,诊断用(能看出某个会话最后一次产生数据是什么时候) | **水位只是读取游标,不是身份**(2026-09-11 定稿):判断"这条事件是否已入库"靠 `usage_requests` 的**内容键主键**(`record_id = sessionId:事件时间:turn.step`,见 §7)与**全读闸**(该会话库内 `MAX(created_at)`),两者都来自业务数据本身。因此水位表整行丢失、`log_path` 变化、日志被重写,最坏结果只是"重读一遍文件",**不会重复计数、不会漏**。 生命周期: - **插入**:发现一个从未见过的 session id(新会话目录)时插入初始行(`last_seq = -1`) - **更新**:每轮成功折叠后,与 `usage_requests` 的数据行在**同一事务**内更新(§7 防重复三道防线) - **删除**:日志文件消失(会话被删)时清掉对应水位行;已折叠进 `usage_requests` 的历史数据保留 ### 4.3 `usage_daily_rollups` —— 按天预聚合表 **粒度**:一行 = (天 × 来源 × 客户端 × 会话 × 供应商 × 模型)的合计。仿 CC-switch 的同名表,但两点改良:①折叠**同事务增量 upsert**,非定期回灌,永远新鲜;②**未定价请求单列计数**,不会把"没定价"悄悄当 0 混进费用。**2026-08-20 起主键先后纳入 `client` 与 `session_id` 维度**:`client` 让按客户端排行等历史全量图直接读 rollup;`session_id` 让弹层"用量详情"聚焦查询按会话聚合——明细被 60 天清理删除后,会话历史仍由本表永久承载。CC 行无会话概念,`session_id` 恒 `''`。 ```sql CREATE TABLE IF NOT EXISTS usage_daily_rollups ( day TEXT NOT NULL, source TEXT NOT NULL, -- 'dsh-logs' | 'cc-switch',保留来源以支持筛选/下钻 client TEXT NOT NULL, -- 客户端应用(DSH 行 'dsh';CC 行其 app_type) session_id TEXT NOT NULL DEFAULT '', -- DSH 会话 id(聚焦查询维度);CC 行恒 ''(无会话概念) provider TEXT NOT NULL, model TEXT NOT NULL, requests INTEGER NOT NULL DEFAULT 0, -- 调用次数 input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_write_tokens INTEGER NOT NULL DEFAULT 0, cost_usd_nano INTEGER NOT NULL DEFAULT 0, -- 纳美元定点,仅累加已定价的行 unpriced_requests INTEGER NOT NULL DEFAULT 0, -- cost_usd_nano 为 NULL 的行数(改良点②) ttft_sum_ms INTEGER NOT NULL DEFAULT 0, -- 配合 ttft_count 算均值 ttft_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, source, client, session_id, provider, model) ); ``` 逐列说明: | 列 | 语义 | |---|---| | `day` / `source` / `client` / `session_id` / `provider` / `model` | 联合主键,即聚合维度。`source` 保留进主键(CC 的 provider 命名与 DSH 不同,直接合并会串行);`client` 2026-08-20 纳入(客户端筛选/排行不再依赖明细表);`session_id` 同日纳入——不带 session 的查询按 (day, model) 等 GROUP BY 时自然跨会话聚合,SQL 无需变化;仅聚焦查询(`daily`/`by-model` 的 session 参数)按会话过滤 | | `requests` | 该组调用次数(对应 CC 的 `request_count`;CC 历史行含失败请求,口径直搬) | | token 四列 | 该组 token 合计,与明细表同口径 | | `cost_usd_nano` | 该组费用合计(纳美元定点);只累加 `cost_usd_nano IS NOT NULL` 的明细行,整数加法精确无漂移 | | `unpriced_requests` | 定价缺失的行数:界面可提示"另有 N 次调用未定价",而不是让费用显得虚假精确 | | `ttft_sum_ms` / `ttft_count` | TTFT 累加器,均值 = sum/count;不存 avg 是避免增量更新的浮点漂移 | 维护方式(改良点①):折叠每条 `usage_requests` 时,在**同一事务**内对 rollup 做 upsert: ```sql INSERT INTO usage_daily_rollups (day, source, client, provider, model, requests, input_tokens, ..., ttft_count) VALUES (:day, :source, :client, :provider, :model, 1, :in, ..., :hasTtft) ON CONFLICT(day, source, client, provider, model) DO UPDATE SET requests = requests + 1, input_tokens = input_tokens + excluded.input_tokens, -- …其余列同理;cost_usd_nano 用 COALESCE(excluded.cost_usd_nano, 0) ``` CC 导入走同一个 upsert(`session_id` 传 `''`);CC 历史迁移走覆盖 upsert(同 `''`)。行数上界 = 天数 × 来源 × 客户端 × 会话 × 供应商 × 模型——DSH 会话维度由 `client='dsh'` 限定(每个活跃会话每天数行),整体仍在万级,查询毫秒。 **`session_id` 维度的职责**(2026-08-20 纳入,服务弹层"用量详情"聚焦查询):折叠 DSH 会话行(`source='dsh-logs'` 且 `session_id` 非空)时存真实会话 id;CC 行无会话概念统一 `''`(不参与会话维度、不拆散聚合)。**不参与明细清理**:明细被 60 天 prune 删除后,会话历史由此维度永久承载。不带 session 的查询按 `(day, model)` 等 GROUP BY 时自然跨会话聚合(SQL 无需变化);仅 `daily`/`by-model` 路由带 `session` 参数时按会话过滤(会话聚焦 + 聚焦选项收窄)。 修复路径:明细表是唯一事实源,rollup 损坏或口径调整时 `DELETE` + `INSERT ... SELECT ... GROUP BY` 全量重建。 ### 4.4 模型归属规则 `assistant/message` 本身不带模型。折叠时维护"最近一个 `request/context` 的 (provider, model)"游标,后续 usage 行归到该路由;会话中途换模型时归属自动切换。 ### 4.5 提供方映射(JS 单文件维护,不再入库) **粒度**:一行 = 一个提供方 ID。查询层把 `provider_id` 换成提供方名称 / 供应商,聚合按 vendor 归并(如 `kimi-coding` + `moonshotai-cn` + `moonshotai` → vendor `kimi`)。 - **唯一权威源**:`lib/util/provider-mappings.js`(`PROVIDER_MAPPING_SEED` + `VENDOR_LABELS`,2026-08 按 pi-ai 目录模型归属整理)。2026-08-24 起**不再同步进 `provider_mappings` 表**——表只是 JS 数据的缓存,徒增迁移面;旧库遗留表启动时 `DROP TABLE IF EXISTS` 清理。未映射的 provider 原样显示 id。 - **查询层**:`distribution`/`rank` 按 vendor 聚合(柱状图供应商轴与排行"供应商"维度),筛选参数是 vendor id 时 `providerCond` 展开为 `provider IN (…pids)`;overview 的 label 统一为 provider_name;`GET /token-monitor/provider-mappings` 把映射全表下发给客户端(客户端动态合并、硬编码兜底)。 ## 5. 定价与费用口径 > 2026-09-09 起:定价从"唯源 pi-ai 目录"升级为**单表 `model_prices` 优先 → pi-ai 兜底 → 未命中记 NULL**,支持按生效时间版本化、峰谷倍率与币种;完整设计见 [模型定价表设计](模型定价表设计.md)。下表口径(计算时点、CC 直搬、纳美元整数、汇率换算)**未变**。 - **唯一定价源(主)**:pi-ai 本地模型目录(`@earendil-works/pi-ai` 包内 `dist/providers/data/*.json`),随 DSH 安装,读取不联网。 - **计算时点**:折叠(或导入)时定格,写入 `cost_usd_nano`。全程整数运算:刊例价($/百万 token,可有 4 位小数如 0.0028)量化为 P4 整数(price × 1e4),`cost_nano = round(tokens × P4 / 10)`——单请求取整误差 ≤ 0.5 纳美元,聚合为精确整数加法。 - **定价缺失**:目录查不到该 model → `cost_usd_nano = NULL`,统计时该行不计入费用但计入 token;界面显示"未定价"。 - **CC 导入行**:搬它的 `total_cost_usd`(TEXT 十进制 → 定点解析 ×1e9 取整,9 位小数内精确),**不用任何定价目录重算**(其价格为当时口径,且重算会丢失它实付的 multiplier 等因素)。 - **展示换算**:API 返回前由 Host 统一 ÷1e9 转成美元数值/字符串;界面永远不见纳美元。 - **汇率(2026-08-21 新增)**:中文界面费用展示 × USD→CNY 实时汇率(`open.er-api.com`,24h 时间闸 + 本地 8 点后更新,失败默认 7.2 兜底;内存缓存不入库),随 overview/sources 接口下发;英文界面直接显示美元不换算。 ## 6. CC-switch 导入计划 > 状态:**db 文件导入已实现**(`importCcSwitch` + `checkCcPending`),**含 `usage_daily_rollups` 历史聚合迁移**(`importCcRollups`);**SQL 文件导入已实现**(`importCcSqlFile` + `parseCcSqlFile`,只解析 `proxy_request_logs` 一张表);**定价表不导入**(2026-08-20 定稿:定价唯源 pi-ai,见 §5)。 **历史聚合迁移补充说明**(2026-08-20):CC 的 `usage_daily_rollups` 存着 30 天前明细被归档删除的按天聚合(时间段与 `proxy_request_logs` 不重叠,机制保证同一请求只在一处)。`importCcRollups` 在每次 db 同步时连带处理: - **覆盖语义**:`INSERT ... ON CONFLICT(day, source, provider, model) DO UPDATE SET ... = excluded.xxx`——CC 聚合行是定格值,同 key 有则整体替换、无则新增;**与明细镜像的累加 upsert 严格区分**,重复同步/明细老化窗口均无双算。 - **独立水位**:`kind='db-rollup'`(watermark = 已迁移 MAX(date) 的本地午夜毫秒),与 `db-scan`(明细 created_at 毫秒)同源不同 kind 互不污染;删源时 `deleteBySource` 一并清空 → 重导即全新全量。 - **反查与口径**:`provider_id` 为 `_session`/`_codex_session`/`_opencode_session`(CC 会话日志来源标记)或 **UUID 形态的 provider 实例**(如 `0c1712c0-…`,本质是某供应商的配置实例)时,按**已知映射优先**(kimi 旧系列 k2.x 显式钉死 `kimi-coding`——其同名模型可能被 pi-ai 其他接入商先收录)、未知模型再走 pi-ai 反查、仍查不到标 `unknown`(待人工核对模型归属后补映射/重导刷新)——UUID 实例不能原样落库成 UUID(供应商维度会分裂脏值);`request_count`(CC 全量计数含失败)、`total_cost_usd`(搬值)、`input_tokens`(CC 已 fresh 归一)直搬;`avg_latency_ms`(总延迟均值 ≠ 我们的 TTFT)>0 时折算进 ttft 累加器,=0 不迁。 导入对象:**请求记录**(`proxy_request_logs`),来源可为 CC 库文件(`~/.cc-switch/cc-switch.db`)或 CC 导出的 SQL 文件(`cc-switch-export-*.sql`)。 **SQL 文件解析器**(`parseCcSqlFile`):逐行扫描,两步判断(先认 `INSERT INTO` 大小写不敏感 → 再提取表名只留 `proxy_request_logs`,可带引号/无引号/表名大小写),引号感知逗号切分 VALUES,跨行 INSERT 收集兜底。实测真实导出 3180 行全提取 + 5 种书写变体全通过。 **请求记录字段映射**(CC `proxy_request_logs` → `usage_requests`): | CC `proxy_request_logs` | `usage_requests` | 转换 | |---|---|---| | `request_id` | `record_id`(source='cc-switch') | 直接 | | `created_at` | `created_at` | 秒 → 毫秒 ×1000 | | `session_id` | `session_id` | 直接,可空 | | `provider_id` | `provider` | `_session` 显示名映射为 `cc-switch` | | `app_type` | `client` | 直接搬;**导入白名单**:只接受已知类型(claude/codex/gemini/opencode/grokbuild/pi),未知类型(如将来的 `dsh`)整行跳过计入 skipped——防止 CC 支持 DSH 后与自有折叠双算 | | `model` | `model` | 直接 | | `input_tokens` / `output_tokens` | 同名列 | 直接 | | `cache_read_tokens` | `cache_read_tokens` | 直接 | | `cache_creation_tokens` | `cache_write_tokens` | **改名映射**,同一概念 | | `total_cost_usd` | `cost_usd_nano` | TEXT 十进制 → 定点解析 ×1e9(9 位小数内精确) | | `first_token_ms` | `ttft_ms` | 直接搬,可空 | 执行规则: - **两套导入逻辑,不共用**: - **弹层"同步"(§11.3)**:读 **db 文件**——SQLite 只读打开 `~/.cc-switch/cc-switch.db`,直接查询 `proxy_request_logs` 表。本机记录,默认自动检测。 - **用量页"导入"**:解析 **SQL 文件**——CC 导出功能生成的 `cc-switch-export-*.sql`(`sqlite3 .dump` 文本格式,含 `CREATE TABLE` + `INSERT INTO` 语句)。逐条解析 `INSERT INTO "proxy_request_logs"`,不依赖 SQLite 库文件存在。跨设备记录,用户手动选文件。 - 两种来源的字段映射与口径归一规则完全一致(上文两张映射表),只是读取方式不同:db 走 SQL 查询、sql 文件走语句解析。 - `INSERT OR IGNORE`:主键去重,重复导入/点多次无副作用。 - 导入的明细行同样按 §4.3 的 upsert 规则进 `usage_daily_rollups`(以 INSERT 的 `changes() > 0` 为条件),导入完成汇总立即可查。 - 失败处理:db 文件不存在 → 提示未安装 CC-switch;sql 文件不存在/格式不符 → 提示文件无效;schema 版本不符(缺列)→ 报具体缺失列,不部分导入。 - **输入口径归一**:CC 的 `input_tokens` 是否含缓存由 `input_token_semantics` 列标记(CC 踩过的坑);导入时按该列换算为"未缓存输入",与 DSH 侧 `uncachedInputTokens` 同口径,保证"新增输入"指标两源可比。 ## 7. 同步节奏(DSH 日志折叠) > 2026-09-11 重写:DSH 引入**日志版本**(v0 → v3)后,旧版"写死 `session.jsonl.zstd` + 用 seq 当身份"的做法会漏统计(新版把整段历史重编码进 `session.v3.jsonl.zstd` 并重编号 seq)。完整设计与实测见 [会话日志折叠设计](会话日志折叠设计.md),此处只留结论。 - **选文件**:会话目录里按规范命名解析版本号,**取版本号最大者**(与官方 `resolveGenerationInDirectory` 同规则),代码里不写死版本号——将来的 v4/v5 自动跟随。 - **节奏**:插件启动时全量扫一次;之后每 **5 分钟**增量扫;详情弹层打开时触发一次增量扫。 - **身份(内容键)**:`record_id = ${sessionId}:${事件时间}:${turn}.${step}`,不含 seq / 文件 / 版本。日志版本升级时事件内容逐条保真(实测 4667/4667 一致),所以"新文件里迁移过来的历史"与"库里已有行"算出同一个键 → `INSERT OR IGNORE` 天然吸收。 - **两道闸**:① **全读闸**——仅当"从头整读"(换文件 / 水位丢失 / 位移失效)时启用,取该会话库内 `MAX(created_at)`,只收 `time > 闸` 的事件(把迁移过来的历史整体挡掉);② **seq 闸**——同一文件续读时用 `last_seq` 跳过已扫过的行。 - **自愈**:位移超出文件长度、或从位移处切不出帧(文件被原地重写/截断)→ 回退从头整读 + 全读闸。 - **失效可见**:读不出 header / 目录里有疑似日志但不是规范命名 / 有事件却产不出用量行 → 进健康统计,随 `/usage/sources` 下发并在数据来源卡提示(不再静默显示 0)。 - 单遍顺序读,内存占用 O(1);每轮每文件 16MB 软上限分批推进(§2.1)。 - **明细清理挂载在折叠入口**(`foldOnce` 尾部,与折叠共用全部触发时机):O(1) 内存时间闸(24h)在前,到点才查量闸(`MAX(day)` 早于 cutoff 才执行),执行 = 逐天 `DELETE FROM usage_requests WHERE day < cutoff`(60 天保留、本地午夜对齐完整天),**只清明细不碰 rollup**;时间闸持久化在 `sync_logs`(kind=`prune`),审计行复用 `imported` 列存删除行数。同一时间点还会清理 `sync_logs` 保留期(14 天,每源至少留最新一行,§11.2)。 - **折叠审计**:每轮折叠写一行 `sync_logs`(`source='dsh-logs'`、`kind='fold'`、`imported/skipped/files_scanned/errors`),供数据来源卡"最近同步"列与诊断使用(§11.2)。 **防重复三道防线**:① `file_mtime_ms` 不变且 `pending=0` 直接跳过文件;② 同文件内 `seq > last_seq`;③ 主键内容键 + `INSERT OR IGNORE` 幂等吸收。数据行写入、rollup upsert(§4.3)与水位推进在**同一事务**提交——崩溃不产生"数据已进、水位未进"的半截状态。实现细节:rollup upsert 以明细 INSERT 的 `changes() > 0` 为条件执行,保证病态场景(水位表丢失但明细仍在)下重折也不会双计。 ## 8. 查询与展示 服务端新增路由(与现有 `/token-monitor/overview` 并列)。**汇总查询一律读 `usage_daily_rollups`**(毫秒级,2026-08-20 起主键含 client 与 session_id 维度),明细表只服务于当天/会话级下钻: - `GET /token-monitor/usage/daily?days=N` → **session 聚焦读 rollup 的 `session_id` 维度**(会话历史永久,不受明细清理影响);当天(days=1)读明细(数据实时写入);多天读 rollup(client/provider/model 筛选直接在 rollup 上做;provider 参数为 vendor id 时展开 `IN`):`[{ day, model, requests, input_tokens, output_tokens, cache_read_tokens, cost_usd, unpriced_requests, ttft_avg_ms }]` - `GET /token-monitor/usage/by-model?days=N` → 读 rollup 按模型汇总(含 client 维度,筛选同上) - `GET /token-monitor/usage/sessions?day=...` → 读明细表按会话下钻(低频,量小;联查 `fold_watermarks` 取会话标题) - `GET /token-monitor/usage/hourly` → 当天趋势(读明细分钟级聚合,渲染就绪 buckets,含平均 TTFT;颗粒度 60/30/15/10/5/2 分钟自适应 ≥12 桶,**补桶不跨天**——当天图不出现昨天刻度;返回 `step`(桶间隔分钟),前端 tooltip 显示桶区间如 `15:00~15:30`) - `GET /token-monitor/usage/distribution` → 供应商×模型分布柱状图(**读 rollup 全量**,token 四桶口径;**按 vendor 聚合**、渲染全部模型不 Top8 截断,附加 `modelVendor`/`vendorModels` 供前端配色与 tooltip) - `GET /token-monitor/usage/calendar` → 年度消耗热力(**读 rollup 近 365 天**) - `GET /token-monitor/usage/rank` → 使用排行(**读 rollup 全量**,model/vendor/client 三维度——供应商维度按 vendor 聚合,`providers` 集合保留原始 provider id 供组合列展开) - `GET /token-monitor/usage/sources` → 数据来源路径(含 `usdCnyRate`/`rateFetchedAt` 汇率下发);`POST` 同路由 `{ source }` 打开本地目录 - `POST /token-monitor/import/cc-switch` → db 导入(`{ imported, skipped, skippedUnknownApp, rollupDays }`);`DELETE` → 清空 CC 来源数据(含 sync_logs) - `POST /token-monitor/import/cc-switch/sql` → SQL 文件导入(body 为文件内容) - `GET /token-monitor/sync/pending` → 本机 CC 库未同步探测(增量) **供应商抓取器**(`GET /token-monitor/overview`):注册表 `FETCHERS`(`lib/util/fetch-quotas.js`)共 9 个——`kimi-coding`(订阅额度 5h/7d + 权益等级)、`moonshotai-cn`(按量余额 CNY,现金/代金券明细)、`deepseek`(账户余额)、`opencode-go`(订阅额度 5h/7d/30d)、`openrouter`(积分余额,1 积分 = $1,本月/总消耗)、`minimax`/`minimax-cn`(Token 套餐 5h/7d 剩余%)、`zai`/`zai-coding-cn`(Coding 套餐 5h/7d,窗口自动识别)——FETCHERS 键与 DSH 路由名对齐,徽标可直接定位;overview 下发 label 统一为 `provider-mappings.js` 的提供方名(映射 JS 单文件维护,不入库)。 界面路线(已评审定稿): - **弹层(保持轻量,不再加料)**:供应商额度卡 + 本会话用量卡(会话下拉)。**跨会话的用量统计不进弹层**——弹层信息量已饱和。 - **终态(方案 4,用量统计的唯一归宿)**:注册 `conversation.view` 槽位,在主区与"对话 / 轨迹"并列加"**用量**"页签,整区做数据面板——时间窗切换、大数字卡组、按天趋势图、按模型排行、按会话明细表、费用专题。 - **入口**:详情弹层会话下拉的**名称右侧加"↗ 详情"入口**,点击打开主区"用量"页签(通过运行时的视图切换机制激活对应 view)。 - **语言跟随(2026-08-21)**:前端文案走 `t(key, vars)` 字典(zh/en,命名空间 token-monitor),随 DSH `locale` 切换;服务端返回的中文文案经 `SERVER_EN_RULES` 规则表在英文界面转译,未命中保留原文。 - **静态资源**:echarts 随插件分发(`lib/util/echarts.min.js`),经 `GET /token-monitor/echarts.min.js` 路由分发(2026-08-21 由 `vendor/` 移入 `lib/util/`,路由路径相应变更)。 弹层与主区页签读同一组 Host 路由,无数据口径分叉。 **界面细节(2026-09-11 定稿)**: - **请求记录表**:列宽 时间 11% / 会话 16% / 模型 11% / 其余数值列各 7.5% / 供应商 10% / 来源 7%(合计 100%)。**会话与模型列左对齐**,其余列右对齐——`UsageTable` 支持按列 `align` 覆盖,默认"首列左、其余右"。 - **数据来源卡**:列宽 来源 9% / 说明 35% / 目录 22% / 最近更新 12% / 最近同步 8% / 操作 14%(合计 100%)。 - 「最近更新」= 库里该来源**最新一条记录的时刻**(悬浮说明:「源头数据自身最新一条的时刻」)。源里没有新数据时它不会变,与同步是否执行为无关。 - 「最近同步」= **最近一次真正有新增的明细导入**导入了多少条(口径见 §11.2):跳过空转轮(否则每 5 分钟一轮的空转会把数字冲成 0),也排除 `db-rollup`(聚合行与请求行量纲不同)。无记录显示 `—`。 - **日志格式提示**:折叠健康统计命中异常时,卡片表格**下方**渲染两行——第一行红色「检测到日志格式变化:」+ 命中项拼接,第二行灰色固定文案「通常由 DSH 版本更新引起,插件会在后续版本适配;已记录的数据不受影响,可以继续使用。」。间距对称(到表格 12px、到卡片底边 12px),**无异常时该子节点不渲染,卡片布局分毫不动**。 - **会话头部徽标**:注册进 `conversation.session.header.utilities`,`order: -99`。官方槽是"按 order 升序**稳定排序**,同值按注册先后",而官方 `open-in-app`(文件夹按钮)也是 `-10`——同值会因插件激活/热替换顺序不同而左右乱跳,故取一个明显更小的值钉在最左。 - **弹层"↗ 详情"交棒**:聚焦该会话 + 客户端锁定 DSH + 时间窗置"全部"。聚焦同时把"用户已主动选过时间窗"置位——否则挂载时那次 `/overview` 拉取会在几十毫秒后按 `defaultDays` 把它覆盖回"当天"(2026-09-11 修复的竞态);点 ✕ 关闭时连同该标记一起还原。 ## 9. 容量与演进 增长模型:一次模型调用 = 明细一行。参考 CC-switch 实测:4.5 个月 ≈ 3416 行(~25 行/天,轻度);重度使用按一天数千行估算,一年数百万行。 **汇总查询与明细增长解耦**:按天/按模型/费用等所有高频汇总一律读 `usage_daily_rollups`——其行数上界 = 天数 × 来源 × 供应商 × 模型(一年千级),无论明细涨到多少行,汇总查询恒为毫秒级。明细表只服务会话级下钻(带窗口 + 索引,实测百万行 <150ms)。 实测 benchmark(node:sqlite,100 万行合成明细,2026-08-17):近 30 天按天聚合 81ms、近 30 天 × 模型 132ms、全量全表汇总 1127ms——rollup 让交互路径完全绕开第三种。 演进余量:明细表涨到千万级时,下钻查询可加 `(source, session_id)` 索引或按年分表;rollup 口径调整时从明细 `DELETE` + `INSERT SELECT GROUP BY` 重建。 ## 10. 风险与注意 | 风险 | 缓解 | |---|---| | DSH 日志版本演进(v0→v3→…) | **已适配**(2026-09-11):按规范命名取版本号最大者、内容键身份、全读闸;未知行结构由健康统计告警(读不出 header / 有事件却 0 用量行 → 来源卡提示),不再静默显示 0 | | 单帧超大(实测 10MB 级) | 读取按"至少凑满一帧"自适应放宽,单帧硬上限 64MB;超限则跳过并上报,不推进水位 | | 活跃会话写入中读取 | 只读 + 只折叠完整帧;水位推进天然处理 | | 继承 / fork 会话(`parentSession`、`delegationDepth>0`) | 同一事件可能在父子两个会话各计一次。本机 12 个日志均为 `delegationDepth:0`、无 parent,属**未触及的边界**,已记录待观察 | | 非 zstd 编码(明文 `.jsonl`) | 不支持;走 `noHeader` 告警通道(宁可报警也不静默) | | CC-switch 运行中占用 db | 只读连接;WAL 下读不阻塞 | | 删除会话日志 | 已折叠数据保留在库里(历史不因删日志消失);被删会话的水位行顺手清掉 | | 刊例价更新 | 历史成本定格不重算;token 在库可随时全量重定价 | | 多 DSH 实例同写 token-monitor.db | 当前部署单实例;暂不处理,多实例时加文件锁 | | `node:sqlite` 属实验性 API | Node 22.5+ 起内置;宿主升级 Node 后构造参数/返回值若有变动需回归(见 §13) | ## 11. 同步日志与按需同步 > 状态说明:§11.3 检测 + 同步按钮**已实现**;§11.2 `sync_logs` 表已建(含 2026-08-20 新增两列),**写入逻辑已接入**(CC db-scan / sql-import / db-rollup + **DSH 折叠** + prune 审计,职责见 §11.2);§11.1 注册表**未实现**(当前单源写死)。 需求:新增同步日志表;每次启动检查各数据源是否有待同步数据,有则给出按钮按需同步;设计时考虑多数据源扩展。 ### 11.1 数据源注册表(扩展性的根) 参考 CC `sync_all_unlocked` 内核:所有数据源实现统一接口,注册进一张表驱动的清单: ```ts interface SyncSource { id: string; // 'dsh-logs' | 'cc-switch' | 将来的新工具 label: string; // 界面显示名 mode: 'auto' | 'manual'; // dsh-logs 常驻自动折叠;cc-switch 等导入源为 manual check(): Promise; // 轻量探测:有无新数据(不读全量) sync(): Promise; // 执行同步 } interface SyncResult { imported: number; skipped: number; filesScanned: number; errors: string[] } ``` - 新数据源 = 实现接口 + 注册一行,框架/日志/按钮逻辑零改动(CC 就是这么加 Codex/Gemini/OpenCode/Pi 源的)。 - 全局单飞互斥(参考 CC 的 `session_sync_mutex`),手动与自动不并发。 - 单源失败不拖垮全局:按源捕获错误进 `errors`,结果按源合并(CC 的 `merge_sync_step` 模式)。 ### 11.2 `sync_logs` 表 > 状态:**表已建 + store 方法已实现 + CC 导入与 DSH 折叠均已接入**;2026-09-11 起 **DSH 折叠每轮也写一行**(`kind='fold'`),用于数据来源卡"最近同步"列与诊断(折叠的增量水位仍走 `fold_watermarks`,§4.2/§7)。 ```sql CREATE TABLE IF NOT EXISTS sync_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, -- SyncSource.id kind TEXT NOT NULL, -- 'fold'(DSH 折叠轮)| 'db-scan'(弹层读本机 db)| 'db-rollup'(CC 聚合迁移)| 'file-import'(DSH JSON 导入)| 'sql-import'(用量页解析 SQL 文件)| 'prune'(清理审计) started_at INTEGER NOT NULL, finished_at INTEGER, -- NULL = 进行中(崩溃遗留据此识别) status TEXT NOT NULL, -- 'running' | 'ok' | 'partial' | 'failed' imported INTEGER NOT NULL DEFAULT 0, skipped INTEGER NOT NULL DEFAULT 0, skipped_unknown_app INTEGER NOT NULL DEFAULT 0, -- 2026-08-20 新增:定稿口径(只统计未知应用跳过) watermark INTEGER, -- 2026-08-20 新增:本次同步扫到的最大 created_at(毫秒),增量探测游标 files_scanned INTEGER NOT NULL DEFAULT 0, errors TEXT -- JSON 数组 ); ``` 界面可回答"上次什么时候同步的、同步进来多少、有没有失败"。 **职责划分(2026-09-11 更新)**: - **DSH 折叠(auto)**:增量水位走 `fold_watermarks`(字节位移 + 序号 + 内容键,§4.2/§7);**每轮同时写一行** sync_logs(`kind='fold'`,含 imported / skipped / files_scanned / errors)——它既是审计,也是数据来源卡"最近同步"列的数据源。 - **CC db-scan(弹层"同步")**:`importCcSwitch` 导入后写一行 sync_logs(kind='db-scan',含 imported / skipped_unknown_app / watermark);`checkCcPending` 以**该 kind** 最近成功同步的 watermark 为起点增量探测本机 db。 - **CC db-rollup(历史聚合迁移)**:`importCcRollups` 写一行(kind='db-rollup');它是**按天聚合行**,与"请求条数"量纲不同,因此**不参与**"最近同步"列的统计(下条)。 - **CC sql-import(用量页"导入")**:`importCcSqlFile` 解析 SQL 文件后写一行 sync_logs(kind='sql-import',**无水位语义**——SQL 文件是全量快照,无法确认是否同机,去重靠 `record_id` 幂等;watermark 恒 null,不参与任何增量探测)。**两种 kind 的水位互不混用**:`getLastSyncWatermark(source, kind)` 按 kind 过滤,sql-import 的水位不污染 db-scan 探测。 - **DSH file-import(用量页"导入"JSON)**:`importDshUsage` 写一行(kind='file-import')。 - **"最近同步"列的取数口径**(2026-09-11):只看**明细导入**(`kind IN ('fold','db-scan','file-import','sql-import')`,排除 `db-rollup` 与 `prune`),且**优先取最近一条 `imported > 0` 的行**(该源从未有过新增时退回最近一条)。目的:空转轮不再把上一轮的条数冲成 0,两列(最近更新 / 最近同步)随新数据一起动。 - **保留期**:`sync_logs` 保留 **14 天**,但每个 `source` 至少留最新一行(否则来源卡那两列会空)。清理挂载在每日 prune 时间闸内(§7 末条)。 - **删除 CC 数据**:`deleteBySource('cc-switch')` 同时清空该源 sync_logs(审计 + 水位)——否则删除后 `checkCcPending` 会认为记录全部"未同步"(imported 集合已空)→ 弹层又提示可同步 → 重新导入,删除形同虚设(2026-08-20 修复)。 - 三表各司其职:`fold_watermarks`=折叠游标、`sync_logs.watermark`(db-scan)=导入游标、`sync_logs` 行=审计与来源卡展示。 ### 11.3 启动检查 + 详情底部同步提示(交互定稿) > 定位:manual 源(CC-switch)的**自动检测 + 按需同步**(**本机**记录)。默认行为——弹层打开时自动探测本机 CC 库有无未同步记录,有则底部提示条给出"同步"入口;检测与同步是两件事,检测不写日志,点同步才执行导入。**跨设备记录不在弹层提示范围**——那是用量页数据来源卡片"导入"按钮的职责(用户手动导入,§2.2)。 交互流程(以 CC-switch 为例,其他 manual 源同构): 1. **启动检查**:插件启动时对每个注册源跑 `check()`——轻量探测,不读全量(cc-switch:库存在且存在未导入行;dsh-logs:`file_mtime_ms` 有变化的文件数)→ 得出每源 pending 摘要。 2. **底部提示条**:有 manual 源 pending 时,**详情弹层底部**("更新于 … / 刷新"一行之上)出现提示条: ``` ┌──────────────────────────────┐ │ ⓘ CC-switch 有 1,234 条使用记录未同步 [ 同步 ] │ └──────────────────────────────┘ ``` 多个源 pending 时逐源一行;无 pending 则整条不渲染(不占日常界面)。 3. **用户点击同步**:按钮进入"同步中…"(禁用,复用刷新按钮的 loading 态)→ Host 执行 `sync()` → 完成后提示条更新为结果摘要("已同步 1,234 条 · 跳过 12 条"),3 秒后淡出;失败则显示失败原因与重试按钮。 4. **落日志**:CC 导入(manual 源)每次同步写 `sync_logs` 一行;启动检查发现 pending 但用户未点,不写日志(只检测不算同步)。 5. **auto 源**(dsh-logs)维持 §7 常驻折叠,增量走 `fold_watermarks`,**不写** sync_logs(§11.2 职责划分);但**明细清理(prune)写** `kind='prune'` 审计行(时间闸持久化 + 删除行数)。 Host 路由(当前实现):`GET /token-monitor/sync/pending`(弹层打开时拉一次 + 同步后重拉)/ `POST /token-monitor/import/cc-switch`(读本机 db 导入;返回 `{ imported, skipped, skippedUnknownApp, filesScanned, errors }`)。通用 `POST /token-monitor/sync { source? }` 路由为将来多源预留,当前未实现(§12 或单源先跑通后加)。 **DSH 跨设备同步(2026-08 新增,§2.2 扩展)**:用量页数据来源卡片 DSH 行「导出 / 导入」: - `GET /token-monitor/export/dsh` → 本机 `source='dsh-logs'` 全量快照的 JSON(`detail` = usage_requests 全部行 + `rollups` = usage_daily_rollups 全部行 + 元信息 `kind/version/exportedAt/source`)。成本随行导出(不按接收端定价表重算);`day` 为导出机折叠时冻结的本地日期(跨时区归源机视角)。 - `POST /token-monitor/import/dsh`(body = 导出文件内容,落临时文件解析)→ 幂等合并:明细裸 `INSERT OR IGNORE`(record_id 主键去重,**不走 recordUsage**——避免明细插入联动 rollup 累加把接收端同键聚合翻倍);rollup 覆盖语义 upsert(`ON CONFLICT ... DO UPDATE SET = excluded`)。`record_id = "${sessionId}:${seq}"`(DSH 会话 UUID + 会话内序号),跨设备不相交 → 正常导入是纯新增;覆盖语义仅在"重复导入同文件 / 增量同步撞键 / 同会话日志被拷贝到两台设备"时触发,均为同值覆盖无副作用。审计写 `sync_logs`(`source='dsh-logs', kind='file-import'`,watermark 置 null——快照无增量游标,同 CC sql-import 约定)。 **统计口径(2026-08-20 定稿)**:导入结果**只向用户展示两数**——`imported`(新导入)与 `skippedUnknownApp`(未知应用跳过)。已导入的重复行(主键冲突)与白名单内正常导入都不算"跳过"、不统计;`skipped` 字段保留在返回体里供内部调试,前端提示条不显示它(避免"跳过 3180 条"这类对重复数据的误导性提示)。 ## 12. 后续扩展(预留,不做) - 可靠性面板:`llm/retry` 重试率、`turn/end` 失败率 - 工具维度:调用频率、平均耗时、错误率(`tool/call` ↔ `tool/result`) - 会话级明细页:点某天展开到会话列表 - 手动重定价命令(定价表更新后回刷历史) - ~~明细保留期 + 剪枝~~ → **已实现(2026-08-20,见 §7)**:60 天保留 + 24h 时间闸 + 逐天删除,只清明细不碰 rollup(沿用 CC 的本地午夜对齐细节,但无归档语义——rollup 实时 upsert 已完整) ## 13. 路由来源校验与运维硬化(2026-09-11) 背景:本插件的 18 个 HTTP 路由由 DSH 的 webServer 直接分发,而**宿主不做任何 Host / Origin 校验**(宿主的 `trustedHosts` 只被 `dsh-client-connection` 用于它自己的 `/api` 端点)。本插件有会执行命令(`/upgrade`)、读写本地文件(`/import`、`/export`、打开目录)的接口,不设防会有两类真实风险:① 用户浏览器里的**任意网页**都能用 `no-cors` 的简单 POST 盲触发这些写接口(简单请求不触发预检);② `dsh web --host 0.0.0.0`(LAN 模式)下,局域网内任何设备可直连全部接口,包括全量导出。 **统一来源校验**(`lib/util/http-guard.js`,18 个 handler 进门先过): - `Host` 必须是 loopback(`127.0.0.1` / `localhost` / `::1`)或宿主 `webRuntime.trustedHosts`(LAN 模式下的网卡地址 + `--trusted-host`);只比主机名不比端口(IP 字面量 Host 天然免疫 DNS rebinding)。 - 非只读方法(POST/DELETE…)再校验 `Origin`:存在时必须同源;缺失则放行(curl 等非浏览器客户端不带 Origin);`Origin: null`(sandbox 页)拒绝。 - **老版本兜底**:宿主没有 `webRuntime` 服务且绑定 `0.0.0.0` 时,退化为"只信 IP 字面量 Host"——避免把局域网访问整体挡死。 - 未通过一律 403,不区分原因(不给探测者信息)。 **其它硬化**: | 项 | 做法 | 原因 | |---|---|---| | 升级(`POST /token-monitor/upgrade`) | 子进程由 `spawnSync` 改 **异步 `spawn` + Promise**(保留超时/退出码/输出语义,超时先 SIGTERM 后 SIGKILL);路由加**服务端单飞锁**(进行中返回 `{ok:false, code:'busy'}`) | 升级一次十几秒、上限 5 分钟;同步等待会把宿主事件循环钉住,届时所有页面一起卡死;连点还会排队做重复工作 | | 请求体读取 | 统一走 `readBody(req, 32MB)`,超限回 **413** | 原来无上限地把 body 拼成字符串,一个超大请求即可吃满宿主内存(插件与 GUI 同进程) | | `sync_logs` 增长 | 保留 14 天、每源留最新一行(挂每日 prune) | 折叠 + CC 扫描各每 5 分钟一行 ≈ 576 行/天,无上界 | **未做(记录在案)**:`node:sqlite` 属实验性 API(宿主升级 Node 后需回归);折叠器对"继承/fork 会话"与非 zstd 编码的边界(见 §10)。 ## 附录 A:CC-switch 源码参考结论(2026-08-17 评审) | CC 的设计 | verdict | 说明 | |---|---|---| | `SessionSyncResult` 按源合并 + 单源错误不拖垮全局 | ✅ 采纳(§11.1/§11.2) | 同步结果形状与 sync_logs 列直接沿用 | | 游标推进与数据插入绑成原子事务 | ✅ 已在设计(§7) | 与我们三道防线方案互相印证 | | "任一计费维度 > 0 即导入" | ✅ 采纳(§2.1 计费闸) | 他们实测旧口径低估 4.1%,集中在子代理短命请求 | | 全局同步互斥锁(单飞) | ✅ 采纳(§11.1) | 手动/自动同步不并发 | | mtime 秒→纳秒免迁移技巧 | 🔶 备用 | 旧值自然触发一次幂等重扫;我们水位若改精度可用同款 | | 跨源指纹去重(DedupKey:时间窗 ±N 秒 + token 全等 + model 模糊匹配) | 🔶 备档 | 他们代理+日志双写同一请求才需要;我们双源不重叠。将来若引入代理源再启用 | | `rollup_and_prune` 保留期剪枝 | 🔶 可选扩展(§12) | 本地午夜对齐、剪枝前回填缺失成本两个细节值得照搬 | | 文件内同 message.id 多快照选代表行 | ❌ 不适用 | Claude 日志同一消息多条快照才需要;DSH 的 `assistant/message` 一请求一条 | | 写穿式托盘缓存 `usage_cache.rs` | ❌ 不参考 | 我们前端 60s 轮询已覆盖,无托盘场景 |