本文是 DeepSeekPulse 的Design Docs第二篇,讲系统架构与数据流。 产品定位见《产品定位与边界》,关键实现见《三个关键技术决定》。
一、分层
┌─────────────────────────────────────────────┐
│ UI 层 TrayApp 托盘宿主、定时器、菜单 │
│ PopupForm 浮窗(WebView2 容器) │
│ SettingsForm 设置 │
│ CleanupForm 进程清理 │
│ LoginForm 内嵌登录抓 token │
├─────────────────────────────────────────────┤
│ Web 层 index.html / app.js / style.css │
│ (纯静态,通过 postMessage 通信) │
├─────────────────────────────────────────────┤
│ Data 层 BalanceClient DeepSeek 余额(官方) │
│ UsageClient DeepSeek 用量(内部) │
│ GlmClient GLM 套餐额度 │
├─────────────────────────────────────────────┤
│ Core 层 AppConfig / ConfigStore 配置持久化 │
│ PulseSnapshot / GlmSnapshot 数据模型 │
│ PeakHours 峰谷规则 │
│ ProcessScanner 进程判定 │
│ Log 分级日志 │
│ StateStore 本地记账 │
└─────────────────────────────────────────────┘
依赖方向严格单向:UI → Data → Core,Core 不引用任何上层。ProcessScanner 和 PeakHours 都是纯函数式的,不碰 UI 也不碰网络,好测。
二、为什么用 WebView2 画界面
WinForms 自绘这些内容(进度条、时间轴、渐变、圆角卡片)要写大量 OnPaint,而且改一次样式就要重新编译。
选 WebView2 的收益:
- 样式与逻辑分离——
Web目录和 exe 放在一起,改 CSS 立刻生效,不用重新 build - 布局能力——flex / grid 几行搞定的事,GDI+ 要几十行坐标计算
- 现成的动画——进度条过渡、时间轴游标移动,CSS transition 一行解决
代价是要处理 WebView2 的生命周期(初始化异步、导航完成前执行脚本会打到旧文档上),以及 C# ↔ JS 的序列化边界。
三、数据流
3.1 刷新循环
Timer.Tick (默认 60s)
└─ RefreshAsync()
├─ BalanceClient.GetBalanceAsync() ─┐
│ └─ 失败 → error 记入快照 │ 两段独立
├─ UsageClient.GetTodayAsync() │ try/catch
│ └─ 失败 → 降级为差值估算 │
├─ GlmClient.GetAsync() │
│ └─ 失败 → GlmSnapshot.Empty(原因) ─┘
├─ PeakHours.NowBeijing() → 峰谷状态
└─ 组装 PulseSnapshot
├─ UpdateTrayVisuals() 托盘图标 + tooltip
└─ PopupForm.Push(snapshot) 序列化推给前端
关键设计:三个网络请求互不阻塞失败。任何一个抛异常只影响它自己那部分数据,其余照常展示。
3.2 数据模型
public sealed record PulseSnapshot(
bool Ok, string? Error,
BalanceInfo? Balance, // 余额三件套
TodayUsage Today, // 今日消耗 + 指标
PeakInfo Peak, // 峰谷状态
string UpdatedAt,
int RefreshSeconds,
GlmSnapshot? Glm); // 可选:没配 Key 就是 null
public sealed record GlmSnapshot(
bool Ok, string? Error,
string Level, string LevelLabel,
List<GlmLimit> Limits,
string Host, string UpdatedAt);
public sealed record GlmLimit(
string Kind, // tokens5h / tokensWeek / tools
string Label,
double UsedPercent,
DateTime? ResetAt,
long? Used, long? Total,
int Order);
GlmSnapshot 用可选字段而不是"空对象",因为"没配置"和"配置了但查询失败"是两种不同状态,界面要分别处理(一个是引导配置,一个是显示错误)。
3.3 渲染管线
C# 把快照序列化成 JSON,通过 ExecuteScriptAsync 注入:
_web.CoreWebView2.ExecuteScriptAsync(
$"window.pulse && window.pulse.render({json});");
前端只有一个入口:
function render(data) {
if (!data) return;
// 连接状态、余额、消耗、峰谷、GLM 全部在这里展开
renderGlm(data.glm); // 内部自己处理 null / !ok / 正常 三种情况
}
导航时序是个坑:WebView2 的 NavigationCompleted 之前执行脚本会打到旧文档上,数据静默丢失。所以加了两级 ready 标志:
if (!_coreReady || !_navReady || _web.CoreWebView2 is null) {
_pendingJson = json; // 先缓存
return;
}
导航完成后检查缓存再补推。
3.4 高度自适应
浮窗高度必须贴合内容(两个页签内容高度不同)。前端测量后回报:
function reportHeight() {
requestAnimationFrame(() => {
const h = Math.ceil(document.body.getBoundingClientRect().height);
if (h > 200) post('HEIGHT:' + h); // 太小说明渲染失败,别缩成一条
});
}
宿主收到后保持底边不动调整高度,再重算圆角区域:
int bottom = Bottom;
Height = cssHeight;
Top = bottom - Height;
ApplyRoundRegion();
那个 h > 200 的判断是有来历的——开发时一次 JS 报错导致内容没渲染,body 高度只剩 180px,窗口被缩成一条横线。加这个下限至少能让故障可见,而不是伪装成"正常但很窄"。
四、降级策略
4.1 DeepSeek 今日消耗:双轨
拿到网页登录态 token?
├─ 是 → 调 /api/v0/usage/amount + /cost
│ ├─ 成功 → 标签「接口精确」
│ └─ 失败 → 落到估算
└─ 否 → 估算:余额下降累计
├─ 余额上涨 → 视为充值,不累加
└─ 余额下降 → 累加差值
估算模式的关键是处理充值。直接算 上次余额 - 这次余额 会在充值时得到负数。正确逻辑是只在余额下降时累加,上涨时重置基准。
4.2 GLM:主机与鉴权双重探测
GLM 有两套主机(国内 open.bigmodel.cn、国际 api.z.ai),鉴权方式社区文档还有分歧(裸 Key vs Bearer)。所以做成探测:
for host in [缓存主机, 用户指定, 国内, 国际]:
for auth in [缓存方式, 裸Key, Bearer]:
请求 → 成功且能解析 → 缓存 (host, auth) 并返回
失败 → 换下一个组合
探测结果缓存在静态字段里,后续不再重复试探。日志会记下最终用了哪套,接口变更时一眼能看出。
五、状态与持久化
| 文件 | 内容 | 位置 |
|---|---|---|
config.json | API Key、刷新间隔、显示模式、GLM 配置 | %APPDATA%\DeepSeekPulse\ |
state.json | 今日已花金额、余额基准、日期 | 同上 |
log.txt | 分级日志,超 1MB 轮转 | 同上 |
sample-*.json | 最近一次接口原始响应 | 同上 |
全部在 %APPDATA%,不写注册表(除了开机自启那一项),不污染程序目录。这样换版本、绿色复制都安全。
六、错误处理的三层
第一层:请求级。 每个 Client 内部 catch 所有异常,转成 Ok=false + Error=原因,绝不向上抛。
第二层:渲染级。 前端 window.onerror 把 JS 异常回传给宿主写日志,避免白屏无迹可查。
第三层:进程级。
Application.ThreadException // UI 线程
AppDomain.CurrentDomain.UnhandledException // 其他线程
TaskScheduler.UnobservedTaskException // 未观察的 Task
三个都挂上,崩溃前落 [crash] 日志,弹窗问用户要不要打开日志文件。
七、日志设计
格式固定:时间 [级别] [模块] 内容
模块枚举:app / balance / usage / glm / login / popup / tray / settings / cleanup / auto / kill / crash
启动时记一次环境快照(版本、OS、时区、屏幕、WebView2 版本、数据目录),异地排障先看这几行。生产环境默认 INFO 级别,--demo 或调试时开 DEBUG。
八、单实例与命令行
用命名 Mutex 保证单实例。但调试参数(--demo / --settings / --cleanup)走独立的 Mutex 名,这样可以在已有实例运行时再起一个做界面核对:
string mutexName = demoMode || openLogin || openCleanup
? @"Local\DeepSeekPulse.SingleInstance.Debug"
: @"Local\DeepSeekPulse.SingleInstance";
另外提供无界面模式(--cleanup-auto [--dry-run]),扫完写日志不弹窗,可以挂计划任务。