问题背景

用 DeepSeek API 的人都会遇到一个场景:想知道这个月花了多少钱、缓存命中率怎么样、哪个模型最烧钱,就得打开浏览器、登录 platform.deepseek.com、翻到用量页。手机上更麻烦——网页版在移动端体验一般,而且每次都要重新登录。

我给自己写了个监控应用(deepseek-monitor,MIT),用一套 Flutter 代码覆盖 Android、iOS、macOS、Windows、Linux 和 Web 六个平台,还带桌面小组件。

这篇文章不复述功能列表,而是记录做这个东西时真正卡住的地方——这些坑都不是 Flutter 的问题,而是「平台接口本身的行为和文档不一样」。如果你也想抓某个只有网页版的数据,这些经验可以直接复用。

1. 先摸清数据从哪来

应用只读三个端点:

用途端点认证方式
余额GET https://api.deepseek.com/user/balanceAPI Key
余额(另一条路)平台 /api/v0/users/get_user_summary平台令牌
用量GET https://platform.deepseek.com/api/v0/usage/by_api_key/amount?start=&end=&tz=平台令牌
费用GET https://platform.deepseek.com/api/v0/usage/by_api_key/cost?start=&end=&tz=平台令牌

1.1 时间参数的脾气

start / end 是 Unix 秒,区间左闭右开。看起来没什么,但 tz 参数有个硬约束:

tz 必须是 900 的倍数(即 15 分钟),默认 28800(东八区)。

也就是说你不能传任意时区偏移,得先归一化。费用接口返回的值已经包含峰谷计价,不需要自己在客户端按时段算。

1.2 最大的坑:认证失败不改变 HTTP 状态码

这是整个项目里最值得写下来的一条。

一般我们验证令牌有效性的写法是「请求一下,看状态码是不是 200」。但平台对无效令牌的响应是:

HTTP/2 200 OK
{"code":40003,"msg":"Authorization Failed (invalid token)","data":null}

三个端点都是这个行为。 后果很严重:如果你只看状态码,那么任何字符串都会被判定为有效令牌——用户随手粘贴一段乱码,你会告诉他”保存成功”,然后在真正拉数据时才莫名其妙地失败。

正确做法是一律解信封,把下面三种情况都算作失败:

  • code != 0
  • data 不是对象
  • 响应体不是 JSON

状态码只用来区分「401/403 与 5xx」这类真正的传输层错误。代码里体现为一个统一的 _platformEnvelope 处理函数,所有平台端点都走它。

2. 凭据从哪来:localStorage 里没有真令牌

既然要展示用量,就得拿到登录态。这里有个反直觉的发现。

2.1 userToken 不是令牌

登录后的页面里,localStorage 确实有一个叫 userToken 的键。按名字看它就是令牌,对吧?

不是。 它的值只有三十几个字符——那是一段短 id。

更麻烦的是,同一份 localStorage 里还躺着另外两个”看起来像令牌”的值。实测三个候选值的长度分别是 36、88、64。

真正的凭据是那个 64 字符的值,而它只出现在发往 platform.deepseek.com/api/... 的 Authorization 请求头上,不在 storage 里。

所以自动获取令牌的逻辑必须是:先信任请求头,再逐个向平台验证,第一个通过验证的才是答案。如果偷懒只取 storage 里排序最靠前的那一个,那个 36 字符的 id 会永远挡在真令牌前面——而且因为上面那个”状态码骗局”,你甚至不会立刻发现。

2.2 两个平台,两条取头路线

拿请求头这件事,Android 和 Apple 平台要写两套代码,因为底层 webview 的能力不一样:

平台机制为什么
AndroidshouldInterceptRequestWebViewClient 会把每个请求连头一起交出来
iOS / macOS注入 UserScript 钩住 fetch 与 XMLHttpRequest.prototype.setRequestHeader,把 authorization 存进 window.__dsAuthWKWebView 的请求拦截不为普通 http/https 流量回调

第二条是 Apple 平台的老问题:WKNavigationDelegate 的拦截只覆盖导航请求,页面里的 XHR/fetch 抓不到,只能用 JS 注入去钩。

两条路拿到的是同一种东西(真实请求上的头),所以最终逻辑里它们并列在 storage 之前:

extractBearerToken(_headerToken) ?? extractTappedAuth(dump)

2.3 SPA 换路由不会再触发一次加载

第三个坑来自单页应用的本质。

登录成功后,地址从 /sign_in 变成 /usage,但这是 history.pushState 干的——WebView 的 onLoadStop 不会再触发。

如果按”最后一次加载时看到的地址”来判断当前还在不在登录页,就会把已经登录成功的用户挡在门外(页面其实已经跳过去了,但你的变量里还是旧地址)。

解法:自动读取前先用 getUrl() 主动问一次当前地址。另外,手动点击永远不做这层拦截——用户点按钮就是明确意图,不要在中间加判断。

顺带一个安全设计:令牌不会一拿到就落盘,而是先调用用量接口探活,验证通过才写入本地;凭据默认遮蔽保存,已存的只回显后四位。

3. 一次编写,六端运行:说清楚哪些做不到

“一套代码六端运行”听起来很美,实际情况要诚实列出来:

平台运行验证应用内登录自动取令牌桌面小组件
Android✅ 已实机验证✅✅ 三个尺寸,已实机验证
iOS✅ 模拟器(iPhone 16 / iOS 26.3)✅⚠️ 已实现,无真机未验证
macOS✅ 已实机验证✅⚠️ 已实现,未实机验证
Web✅ 已实机验证❌ 浏览器限制—
Windows⚠️ 未实机验证✅ 理论可用❌ 未实现
Linux⚠️ 未实机验证❌ 无 WebView 实现❌ 未实现

Linux 和 Web 做不到应用内登录,原因是 flutter_inappwebview 在这两个平台没有可用实现。处理方式不是硬撑一个坏体验,而是在这两个平台隐藏主按钮、改为展示手动步骤——让用户知道”你需要自己复制令牌”,而不是点了一个没反应的按钮。

这一点值得展开:跨平台项目的诚实度体现在你如何标注未验证的部分。上表里 iOS 的小组件标的是”已实现,无真机未验证”,Windows/Linux 标的是”未实机验证”。这不是谦虚,是让别人知道该不该信。

4. 桌面小组件:一份快照契约

小组件是这个项目里最难的部分,因为它的运行模型和应用进程完全不同。

4.1 三个尺寸各自回答一个问题

尺寸回答的问题
2×1还剩多少、今天花了多少
2×2还剩多少、今天花得凶不凶、本月请求与命中
4×2这个月的全貌:四列指标 + 最近七天柱图 + 日均与可用天数

尺寸不是简单地”放大缩小”,而是每个尺寸回答一个独立的问题——2×1 放不下趋势图,就不放。

4.2 后台刷新与”上次真正知道的数字”

  • Android:用 WorkManager,每 30 分钟后台自动刷新,不需要打开应用;三个尺寸共享同一个后台任务。
  • iOS / macOS:用 WidgetKit,三个尺寸共用同一份快照契约。

这里有个设计决策值得说:刷新失败时保留旧快照连同它的时间戳,界面显示”上次真正知道的数字”,而不是把数字清零。

理由是:一个显示”0 元”的小组件会让人以为钱花完了,而显示”上次更新是 3 小时前、当时余额 12.3 元”才是真实信息。监控工具的第一原则是不能比用户更不懂。

另外一个细节:未来日期不进柱图。

5. 「不假装自己知道」

这是整个项目我最想强调的一条设计原则,它体现在好几处:

  • 余额没取到就渲染 —,不填任何示例数字,也不填 0。
  • 只连了 API Key 时:余额是真实的,但用量数据明确标注「示例」——因为 API Key 只能看到余额,看不到用量。
  • 每块数据都标明自己是真实的还是示例的,不让人误判。
  • 跨域被拦时,不要丢一个 Failed to fetch 给用户,而是告诉他「浏览器拦下了跨域请求」以及一条反向代理的出路。

第二条对体验影响最大:不登录也能用示例数据把界面点一遍(知道这个应用长什么样),但必须让用户清楚哪些数字是真的。

6. 遇到的通用教训

把上面这些坑抽象出来,有四条对”抓只有网页版的数据”这件事普遍适用:

  1. 不要用 HTTP 状态码判断业务成败。 平台可能返回 200 OK 包一个错误码,任何”只看状态码”的验证都会被绕过。
  2. 变量名会骗人。 userToken 里放的不一定是令牌。判断凭据要看它在哪个位置被使用(请求头上),而不是它叫什么。
  3. WebView 在每个平台的拦截能力都不一样。 Android 能拿到带头的请求,Apple 平台不行;SPA 换路由不触发加载事件。凡是跟 webview 打交道,先假设”平台之间不一样”。
  4. 跨端项目要标注验证状态。 “能编译”和”能在真机上跑”是两件事,把两者混在一起说就是误导。

7. 小结

这个项目解决的问题很小——把只存在于网页里的用量数据,变成随时能看到的六端界面。但过程中踩到的坑挺有代表性:一个骗人的状态码、一个叫错名字的存储键、两条平台的取头路线、一个不触发的加载事件。

如果你也在做类似的事(抓一个只有网页版的数据源),建议按这个顺序走:

  1. 先用 devtools 把真实请求摸清——端点、参数约束(比如 tz 必须是 900 的倍数)、响应信封结构。
  2. 验证无效凭据时平台返回什么——这一步不做,后面全是幻觉。
  3. 搞清凭据到底在哪里——storage 里的名字不可信,看请求头。
  4. 再考虑跨端——先在一个平台跑通,把契约(快照格式、时间戳语义)定下来,再铺到其他平台。

项目地址:lucky845/deepseek-monitor(MIT)。不登录也能用示例数据把界面点一遍,接上令牌数字才变真——每块数据都标明自己是真实还是示例。