dsh-whale-widget 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +56 -6
  2. package/lib/index.js +219 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,6 +11,7 @@ DeepSeek Harness(DSH)Web 界面右下角的常驻挂件:小鲸鱼气泡图
11
11
  | 你要挂在哪 | 用哪条分支 | 安装方式 |
12
12
  |---|---|---|
13
13
  | **DSH Web 界面右下角**(就是这个 README 描述的插件) | `main`(默认分支) | `dsh plugin --profile web add dsh-whale-widget`(推荐,装 npm 已发布版);也可以从本仓库装 `dsh plugin --profile web add github:MeteorNOX/DeepSeek-Balance-Whale-Widget`,但那样装的是 **main 当前状态、不跟随已发布版本** |
14
+ | **官方桌面客户端(Electron)右下角** | `main`(同一个包) | ⚠️ **不能用** `--profile web`(桌面端读的是 `desktop` profile,CLI 也拒绝 `--profile desktop`)—— 在桌面端会话里**让 DSH 自己装**,见下方「官方桌面端(Electron 客户端)请先看这一节」 |
14
15
  | **Codex 桌面应用**(跟随 Codex 窗口、无独立网页) | [`For-Codex`](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/tree/For-Codex) | 该分支的 `api-balance-whale`:解压到 `%USERPROFILE%\plugins\api-balance-whale`,再按分支内的 [安装说明](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/blob/For-Codex/docs/INSTALL-AND-ROLLBACK-0.2.0.md) 注册计划任务(也可以直接交给 Codex 自己装喵~) |
15
16
 
16
17
  ⚠️ **两条分支互不兼容**:`For-Codex` 的插件不能用 `dsh plugin … add` 装进 DSH 网页;本主分支的插件也不能在 Codex 桌面里运行。上表第一行是本仓库默认分支(`main`)的能力,第二行是另一个分支的能力。
@@ -24,7 +25,7 @@ DeepSeek Harness(DSH)Web 界面右下角的常驻挂件:小鲸鱼气泡图
24
25
  ### 记账与显示
25
26
 
26
27
  - 🐋 **常驻自启**:随 DSH Web 界面每次打开自动出现(标准 DSH bundle 插件)
27
- - 💰 **余额**:60 秒自动刷新 + 点击鲸鱼手动刷新;余额变化时数字**滚动动画**;瞬时网络抖动自动沿用最近余额不报错
28
+ - 💰 **余额**:60 秒自动刷新 + 点击鲸鱼手动刷新;余额变化时数字**滚动动画**;瞬时网络抖动自动沿用最近余额不报错。**两种取数方式,API key 优先**:① DSH 凭据里的 `DEEPSEEK_API_KEY`;② **DSH 账号登录态** —— 通过「设置 → 账号与余额」登录了 DeepSeek 账号、又没配 API key 时自动使用(**不需要去 platform 建 key**)
28
29
  - 📊 **今日已用(小鲸鱼记账)**:不需要任何令牌。余额**下降**按观测累计为消费,余额**上升**(充值 / 赠金)单独记录、不会冲掉已有消费;检测到余额增加会提示「待核对余额调整」,可在「小鲸鱼记账 → **DeepSeek(内置)→ 设置 → 余额校正**」按实际累计到账金额与非调用扣减校正 —— **「已观测消费」与「余额校正」都是 DeepSeek 账户口径**(由该 API key 的余额观测得出,不含其它厂商;「本机模型费用」才是所有模型的本地估算)(逐轮明细保留 90 天或最多 2 万条、逐日归档保留 365 天,超期数据归档到 `.dshw-usage-archive.json`);金额按 8 位小数记账、显示保留两位
29
30
  - 💬 **每轮对话消耗**:监听 DSH 本机会话事件,按模型**真实 usage**(input / cache / output / reasoning tokens)换算金额,每轮结束弹出消耗泡泡;可开关、自动关闭秒数可设(0 = 不自动关闭),**泡泡内容可自定义**(模块化,金额用 `{cost}` 引用),入口:菜单 → 每轮消耗提示 → 「自定义提示」
30
31
  - ⛰️ **峰谷定价**:工作日高峰 9:00–12:00、14:00–18:00(北京时间),其余空闲;2026-08-23 起**周末全天谷价**。峰谷模块支持状态文字、倒计时、"梁文峰谷 / !?强强?! / 简洁"等多种样式
@@ -143,6 +144,33 @@ dsh-whale-widget/
143
144
 
144
145
  ## 安装
145
146
 
147
+ ### 官方桌面端(Electron 客户端)请先看这一节 ⚠️
148
+
149
+ **桌面端读的不是 `web` profile。** 官方桌面客户端用的是 **`desktop` profile**(`%USERPROFILE%\.dsh\profiles\desktop`),而 `dsh plugin` 命令行**按设计拒绝**碰它:
150
+
151
+ ```
152
+ error: profile "desktop" is managed exclusively by the Electron application
153
+ ```
154
+
155
+ 所以下面「方式 A~D」里的 `--profile web` 命令**对桌面端不适用** —— 按它装完,插件落在 `web` profile 里,而桌面窗口读的是 `desktop` profile,一个字也读不到。表现就是「右下角什么都没有(连 ☰ 也没有)、控制台也不报错」。
156
+
157
+ **桌面端的正确装法:让桌面端里的 DSH 自己装。**
158
+
159
+ 在桌面客户端的会话里直接说一句就够了(例如「把 dsh-whale-widget 装上」)—— 它会调用内置的 `plugin_manager` 工具,而那个工具的**作用域就是当前 profile**(桌面端 = `desktop`),由客户端自带的插件管理器在 profile 目录内用**自带的 pnpm** 完成安装,并把 `dsh-whale-widget` 写进该 profile 的 `dsh.profile.bundles`。需要指定版本时也可以写 `dsh-whale-widget@<版本号>`(例如 `dsh-whale-widget@0.3.13`,**填当下的最新版**)这样的安装规格。
160
+
161
+ > 等价的手工路径是在该 profile 目录里用客户端自带的 pnpm 装一次、再手工写进 `dsh.profile.bundles`,但**不推荐** —— 那是客户端自己管理的目录。
162
+
163
+ **生效方式(实测)**:新装一个包通常**热生效**(工具返回 `"application":"applied"`);**换版本(升级)需要重启客户端**(返回 `"restart-required"`)。
164
+
165
+ **装完怎么自检**(两条分别对应宿主半区与客户端半区):
166
+
167
+ - **宿主半区**:`%USERPROFILE%\.dsh\.dshw-turn.json` 存在,且 `seq` 随对话递增;
168
+ - **客户端半区**:桌面端 `%APPDATA%\@deepseek-ai\dsh-desktop\Local Storage\leveldb` 里出现 `dshw-pos` / `dshw-last-seq` —— 这两个键**只有挂件前端**会写。
169
+
170
+ 桌面端界面里看不到插件版本号时,可以看 `%USERPROFILE%\.dsh\profiles\desktop\package.json` 的 `dsh.profile.bundles` 和同目录的 `pnpm-lock.yaml`。
171
+
172
+ > 下面方式 A~D 都是 **Web(`dsh web`)** 的安装方式。
173
+
146
174
  ### 方式 A:已有本插件的完整资源包(本地目录 / 压缩包)(推荐)
147
175
 
148
176
  适用于别人直接发给你一个 zip,或你手上已有一份解压好的插件目录(目录里应有 `package.json`、`cordis.patch.yml`、`lib/`、`assets/`、`README.md`)。
@@ -264,17 +292,33 @@ MeteorNOX/DeepSeek-Balance-Whale-Widget,或者我本地已经有这个插件
264
292
  - curl "http://127.0.0.1:3080/dsh-whale/audio-fragment.wav?id=end_a" 也应返回 200 audio/wav
265
293
  (用来确认资源包里的内置音效已随包就位;若是 404 说明 assets/ 不完整)
266
294
 
267
- 另外请检查 DSH 凭据里是否配置了 DEEPSEEK_API_KEY(没有就提示用户配置)。
295
+ 另外请检查 DSH 凭据里是否配置了 DEEPSEEK_API_KEY(没有就提示用户配置;如果用户是用 DSH 账号登录的,较新 DSH 上不配 key 也能取余额,**别把"没 key"当成故障**)。
268
296
  ```
269
297
 
270
298
  ## 凭据(安装后必读)
271
299
 
272
- 只需一个凭据:
300
+ **余额有两种取数方式(API key 优先,两者互不干扰):**
273
301
 
274
- - **`DEEPSEEK_API_KEY`(必需)**:DeepSeek API 密钥,用于拉取余额(`GET https://api.deepseek.com/user/balance`)。在 DSH 凭据服务里配置(凭据管理界面 / `.dsh/.credentials.yaml`)。
302
+ - **`DEEPSEEK_API_KEY`(推荐)**:DeepSeek API 密钥,用于拉取余额(`GET https://api.deepseek.com/user/balance`)。在 DSH 凭据服务里配置(凭据管理界面 / `.dsh/.credentials.yaml`)。
303
+ - **DSH 账号登录态(可选,**不需要** API key)**:如果你是通过 DSH 的「设置 → 账号与余额」**登录 DeepSeek 账号**的,插件会在**没有 API key 时自动**改用 DSH 自己的账号服务取余额(充值钱包 + 赠金钱包,两者相加作为记账基准)。token、平台请求头与失效清理都由 DSH 负责,**插件不接触你的账号令牌**。
304
+ - 需要**较新的 DSH**(如桌面端 `0.1.7-rc.2`)才有这个服务;老版本、未登录、或账号没有余额钱包时,会回到下面的「未配置」提示。
305
+ - 账号态与 API key 态的**账本是分开的**(按账号标识区分):从 API key 换成账号登录后,"今日已用"会从新的观测重新起算。
275
306
 
276
307
  > **不需要** `DEEPSEEK_PLATFORM_TOKEN`。早期版本的"实时·令牌"模式已下线,今日已用统一由**小鲸鱼记账**(余额差 + 会话事件)计算,零令牌开箱即用。
277
308
 
309
+ ### 安全边界(0.3.15 起,**请务必一读**)
310
+
311
+ 插件会把凭据交给谁、以及谁能改这件事的规则:
312
+
313
+ 1. **凭据只会发往"该厂商内置模板里写死的端点"**。
314
+ 自定义模型里如果填了一个**不在该厂商模板内**的接口地址(或者模板地址里用 `{base}` 指向你自己填的地址),插件**默认不会把凭据发过去**,而是返回一条明确提示。
315
+ - 自建网关(New API / 自托管 / Ollama 等)确实需要自定义地址:请**在这台机器本机**打开插件面板,对该模型勾选「**允许把凭据发送到自定义地址**」。这是按模型保存的一个显式标志。
316
+ 2. **配置与凭据的改动只能来自本机(回环地址)**。
317
+ 所有写请求(保存模型、写/删凭据、改外观设置等)如果来源不是 `127.0.0.1` / `localhost` / `[::1]`,一律 `403`。
318
+ - 为什么:任意持有 DSH Web 会话的人如果能写模型配置,就等于能决定"凭据被发往哪里" —— 那会把你的 API key 直接送到对方服务器上。**只读接口不受影响**(局域网里照常能看挂件)。
319
+ - 需要远端管理?用环境变量显式声明:`DSHW_ADMIN_HOSTS=192.0.2.10:3080,myhost.lan`(逗号分隔,可带端口;默认空)。
320
+ 3. 接口地址不允许携带用户名/密码(`https://user:pass@host/`),凭据名只允许字母、数字与下划线。
321
+
278
322
  添加自定义模型时,还会按需用到各自厂商的凭据名(都可不配,用到哪个配哪个):
279
323
 
280
324
  | 凭据名 | 用途 |
@@ -352,13 +396,18 @@ curl http://127.0.0.1:3080/dsh-whale/audio.json
352
396
 
353
397
  ## 常见问题
354
398
 
355
- - **挂件不出现**:确认安装命令成功;`dsh --profile web --dump-config` 里能看到 `dsh-whale-widget`;重启 `dsh web` 后 F5。
399
+ - **挂件不出现**:
400
+ - **Web(`dsh web`)**:确认安装命令成功;`dsh --profile web --dump-config` 里能看到 `dsh-whale-widget`;重启 `dsh web` 后 F5。
401
+ - **官方桌面端**:先确认装对了 profile —— 桌面端读的是 **`desktop` profile**,**不能**用 `--profile web`(而 `--profile desktop` 会被 CLI 拒绝,这是设计如此)。正确入口见上面「**官方桌面端(Electron 客户端)请先看这一节**」,并用那里的**两条自检**判断是「没装到位」还是「装了但没渲染」。
356
402
  - **本插件所有接口(`/dsh-whale/*`)的访问校验**:默认只接受**回环地址**(`127.0.0.1` / `localhost` / `[::1]`)的请求,并拒绝跨站标记(`Sec-Fetch-Site: cross-site`)与 Origin 和 Host 不同源的请求 —— 这是防「恶意网页读写本机接口」的信任栅栏(issue #92 / #136)。如果你把 `dsh web` 放在**反向代理或局域网地址**后面,请用环境变量声明允许的 Host(逗号分隔,可带端口),否则会被 403:
357
403
  ```bash
358
404
  DSHW_TRUSTED_HOSTS=dsh.example.com,10.0.0.5:3080 dsh web
359
405
  ```
360
406
  - **图片/音效不显示、没声音**:确认插件包内 `assets/` 完整(`DSniang1.png`、`*.mp3`、`minecraft-exp-orb.wav` 等);缺失时相关功能静默降级。
361
- - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 凭据里配置。
407
+ - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 凭据里配置 API key;**或者**用「设置 → 账号与余额」登录 DeepSeek 账号(较新 DSH 支持)—— 没配 key 时插件会自动走账号态,不需要去 platform 建 key。
408
+ - **余额拿到了、但提示「账本不可用 / 已停止写入以保护原记录」**:账本文件(`.dshw-usage.json`)被外部改坏或结构异常,插件**拒绝覆盖**它以免丢数据。先把它备份改名让插件重建,再按需用「余额校正」补账。
409
+ - **保存设置报 `403` / 在别的机器上改了配置不生效**:0.3.15 起**写操作只能在运行 DSH 的那台机器上、用 `127.0.0.1`(本机地址)打开界面进行**。从局域网或反向代理访问时,读没问题、写会被拒。需要远端管理就用 `DSHW_ADMIN_HOSTS` 显式授权那台机器(见「凭据 → 安全边界」)。
410
+ - **提示「该地址不是 … 的内置端点,已拒绝把凭据发出去」**:这是 0.3.15 的安全策略 —— **自定义接口地址默认拿不到凭据**。确认那是你自己的网关(New API / 自托管等)后,在**本机**打开面板,对该模型勾选「允许把凭据发送到自定义地址」。
362
411
  - **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
363
412
  - **今日已用与官网有差异**:先核对同一账户、同一币种与同一统计区间;若有充值或其它余额调整,用「小鲸鱼记账 → DeepSeek(内置)→ 设置 → 余额校正」填写实际到账金额。余额接口只返回余额快照、不提供充值流水,充值与消费发生在同一次刷新间隔时需要实际到账金额才能校正。
364
413
  - **充值后消费数字没变**:这是预期行为——充值不会增加消费;数字上方会出现「待核对余额调整」,提示你补充本统计区间的累计到账金额。
@@ -389,6 +438,7 @@ curl http://127.0.0.1:3080/dsh-whale/audio.json
389
438
 
390
439
  ## 致谢
391
440
 
441
+ - **DSH 账号登录态读余额**(端点与鉴权头、凭据记录的位置、DSH 自身 `deepseekAccount` 服务的推荐调用方式,以及三个集成坑:账户标识字符集、赠金要计入基准、服务可能不存在)由 GitHub 用户 [@yybai25](https://github.com/yybai25) 在 [#157](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/issues/157) 中给出完整规格与**已在自己机器上验证过的参考实现**;**0.3.14 据此实现**(只调 DSH 服务、不接触账号令牌)。感谢他的贡献。
392
442
  - 充值记账修复方案(余额上升与下降分开记账、显式余额校正公式、按账户/币种隔离观测窗口、账本原子写入与迁移备份)由 GitHub 用户 [@Yang-huai406](https://github.com/Yang-huai406) 独立设计并实现为可运行的修复分支;**0.3.1 在该方案基础上移植合并**,并保留本项目既有的音效修复。感谢他的支持。
393
443
  - OpenCode Go 订阅额度(多窗口额度 `quota.json.windows`、按窗口展示与紧凑重置倒计时、「订阅额度」模块的窗口选择)由 GitHub 用户 [@ELFsay](https://github.com/ELFsay) 提交([#99](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/pull/99)),已合入 `main`。感谢他的贡献。
394
444
  - **历史合并 PR 的贡献者**(按合入顺序,均已进入本仓库代码 / 发布流程):
package/lib/index.js CHANGED
@@ -692,6 +692,38 @@ export default {
692
692
  + 'DSHW_TRUSTED_HOSTS 声明允许的 Host(逗号分隔,可带端口)。')
693
693
  } catch (err) {}
694
694
  }
695
+ // v761(安全修复 S1):**写请求(POST/PUT/PATCH/DELETE)必须来自本机(回环)**。
696
+ // 为什么:任意持有 DSH Web 会话的人都能 `POST /dsh-whale/api-models.json` 写一个自定义模型,
697
+ // 而余额 / 探活 / 额度会**带着解析出来的真实凭据**去请求模型里那个 URL ⇒ 凭据外带。
698
+ // 「改配置」等于「决定把凭据发往哪里」,所以它必须与"坐在这台机器前"绑定;
699
+ // **只读接口不受影响**(局域网里仍能正常看挂件)。
700
+ // 运维若确实需要远端管理,显式声明 DSHW_ADMIN_HOSTS(逗号分隔,可带端口),默认为空。
701
+ const ADMIN_AUTHORITIES = String(process.env.DSHW_ADMIN_HOSTS || '')
702
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
703
+ const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
704
+ function writeRejection(req) {
705
+ try {
706
+ const method = String((req && req.method) || 'GET').toUpperCase()
707
+ if (!WRITE_METHODS.has(method)) return null
708
+ const headers = (req && req.headers) || {}
709
+ let hostUrl = null
710
+ try { hostUrl = new URL('http://' + String(headers.host || '')) } catch (err) { return 403 }
711
+ if (isLoopbackHostname(hostUrl.hostname)) return null
712
+ const authority = hostUrl.host.toLowerCase()
713
+ const hostname = hostUrl.hostname.toLowerCase()
714
+ if (ADMIN_AUTHORITIES.some((e) => (e.indexOf(':') >= 0 ? e === authority : e === hostname))) return null
715
+ if (!writeRejection.warned) {
716
+ writeRejection.warned = true
717
+ try {
718
+ console.warn('[whale-balance] 已拒绝一个来自非本机的**写请求**(Host=' + authority + ')。'
719
+ + '自 0.3.15 起,配置与凭据的改动只能在**本机**(回环地址)打开界面进行 —— '
720
+ + '否则任何拿到 Web 会话的人都能把凭据改指向自己的服务器,从而把真实 API key 带走。'
721
+ + '确需远端管理,请用环境变量 DSHW_ADMIN_HOSTS 声明允许的管理主机。')
722
+ } catch (err) {}
723
+ }
724
+ return 403
725
+ } catch (err) { return 403 } // 自身出错 → 拒绝(fail-closed)
726
+ }
695
727
  function rejected(req, res) {
696
728
  const deny = (code) => {
697
729
  try { res.statusCode = code || 403; res.end() } catch (err) {}
@@ -701,6 +733,9 @@ export default {
701
733
  // 先算栅栏可用性,再自校验:非回环 Host 在"栅栏可用"时交给栅栏判(它知道 --trusted-host)
702
734
  const self = selfRejection(req, { fenceAvailable: !!conn })
703
735
  if (self !== null) return deny(self)
736
+ // v761(安全修复 S1):写完再放行 —— 读请求不受影响
737
+ const write = writeRejection(req)
738
+ if (write !== null) return deny(write)
704
739
  if (!conn) return false // 已通过自校验;此刻没有宿主栅栏可委托(加载时已 warn)
705
740
  let code
706
741
  try {
@@ -878,6 +913,60 @@ export default {
878
913
  )
879
914
  }
880
915
 
916
+ // v759(issue #157):**DSH 账号登录态**的余额路径。
917
+ // 只通过 DSH 账号登录的用户没有 API key,余额在 DeepSeek 平台的账号接口上。
918
+ // **不要自己拼 HTTP**:token 注入、5 个 `x-client-*` 头、401/`40003` 失效清理全部由 DSH 的
919
+ // `deepseekAccount` 服务负责(DSH 自己的「账号与余额」设置卡片就是 `ctx.deepseekAccount.getBalance(client)`)。
920
+ // ⚠️ 必须用 `ctx.get('deepseekAccount')` 读**可选服务**,**绝不能写进 inject** —— 老宿主根本没有这个服务,
921
+ // 一旦进 inject,整个插件会永远等不到服务而**完全不 apply**(比没有这个功能严重得多)。
922
+ // ⚠️ 与 API key 路径**互不干扰**(API key 永远优先;DSH 自己的注释也是 "Neither route falls back to the other")。
923
+ async function fetchAccountBalance() {
924
+ let account = null
925
+ try { account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : null } catch (err) { account = null }
926
+ if (!account || typeof account.getBalance !== 'function') return null
927
+ // 账户标识:优先用账号服务给的稳定 id(不同账号 → 不同账本 scope);取不到就退回固定标识。
928
+ // 一律 sha256[:24] 十六进制 —— accounting.mjs 校验 scope 必须匹配 /^[a-zA-Z0-9_-]{1,80}$/
929
+ // (带冒号这种可读写法会直接抛「账户标识无效」,见 issue #157 报告人踩的坑 1)。
930
+ let accountId = null
931
+ try {
932
+ const state = typeof account.getState === 'function' ? account.getState() : null
933
+ accountId = (state && (state.userId || (state.profile && state.profile.userId))) || null
934
+ } catch (err) { accountId = null }
935
+ let result
936
+ try {
937
+ result = await account.getBalance({
938
+ version: String(process.env.DSH_CLIENT_VERSION || process.env.DSH_VERSION || '') || 'unknown',
939
+ locale: String(process.env.DSH_LOCALE || '') || 'zh_CN',
940
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60, // 东为正的整秒
941
+ })
942
+ } catch (err) { return null }
943
+ // 未登录 / 没有 grant / 平台失败 → 一律返回 null,让调用方退回原来的文案(**不缓存错误**)
944
+ if (!result || result.status !== 'ready' || !Array.isArray(result.value) || result.value.length === 0) return null
945
+ const accNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
946
+ const wallets = result.value.filter((w) => w && accNum(w.balance) !== null)
947
+ if (wallets.length === 0) return null
948
+ const currency = wallets.some((w) => String(w.currency || '').toUpperCase() === 'CNY')
949
+ ? 'CNY'
950
+ : String((wallets[0] && wallets[0].currency) || 'CNY').toUpperCase()
951
+ const sumOf = (list) => (Array.isArray(list) ? list : [])
952
+ .filter((w) => w && String(w.currency || 'CNY').toUpperCase() === currency && accNum(w.balance) !== null)
953
+ .reduce((s, w) => s + Number(w.balance), 0)
954
+ const recharge = sumOf(result.value)
955
+ const bonus = sumOf(result.bonusWallets)
956
+ return {
957
+ ok: true,
958
+ // 充值 + 赠金:与 API key 路径的 `total_balance` 同口径(那个字段本身也是两者相加),
959
+ // 否则"按余额差"推算的今日已用会偏小(issue #157 报告人的坑 2)。
960
+ totalBalance: Number((recharge + bonus).toFixed(6)),
961
+ rechargeBalance: Number(recharge.toFixed(6)),
962
+ bonusBalance: Number(bonus.toFixed(6)),
963
+ currency,
964
+ balanceSource: 'account',
965
+ accountTag: createHash('sha256').update('dsh-account:' + (accountId === null ? 'default' : String(accountId))).digest('hex').slice(0, 24),
966
+ updatedAt: new Date().toISOString(),
967
+ }
968
+ }
969
+
881
970
  async function fetchBalance() {
882
971
  // v739(用户反馈「每次新实例的第一次余额请求都失败」):
883
972
  // ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
@@ -894,7 +983,13 @@ export default {
894
983
  }
895
984
  }
896
985
  if (!cred) {
897
- return { ok: false, code: 'NO_KEY', error: '未配置 DEEPSEEK_API_KEY' }
986
+ // v759(issue #157):没有 API key 时退回 DSH 账号登录态;两条路互不干扰,API key 始终第一优先。
987
+ const accountPayload = await fetchAccountBalance()
988
+ if (accountPayload) return accountPayload
989
+ return {
990
+ ok: false, code: 'NO_KEY',
991
+ error: '未配置 DEEPSEEK_API_KEY,且账号余额不可用(未登录 DeepSeek 账号,或该账号没有余额钱包)',
992
+ }
898
993
  }
899
994
  let lastErr = null
900
995
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -930,6 +1025,7 @@ export default {
930
1025
  totalBalance: Number(info.total_balance),
931
1026
  accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
932
1027
  currency: String(info.currency || 'CNY'),
1028
+ balanceSource: 'apikey',
933
1029
  updatedAt: new Date().toISOString(),
934
1030
  }
935
1031
  }
@@ -1092,7 +1188,7 @@ export default {
1092
1188
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
1093
1189
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
1094
1190
  return {
1095
- ok: true, version: '0.3.13', today: todayData, days7,
1191
+ ok: true, version: '0.3.15', today: todayData, days7,
1096
1192
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
1097
1193
  all: {
1098
1194
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1671,6 +1767,71 @@ export default {
1671
1767
  if (a === 0) throw new Error('禁止访问该地址')
1672
1768
  }
1673
1769
  if (/^fe[89ab][0-9a-f]:/i.test(host)) throw new Error('禁止访问 link-local 地址')
1770
+ // v761(安全修复 S1):URL 里不允许带 userinfo(`https://user:pass@host/`)——
1771
+ // 它是把凭据塞进 URL 的另一种写法,也会让日志/报错里出现凭据
1772
+ if (parsed.username || parsed.password) throw new Error('接口地址不允许携带用户名/密码')
1773
+ }
1774
+ // —— v761(安全修复 S1):**凭据目的地白名单** ——
1775
+ // 规则:只有当目标 origin **恰好等于该厂商内置模板里写死的端点**时,才允许携带凭据;
1776
+ // 其它地址(用户自定义 URL、以及由 `{base}` 展开出来的地址)都必须带 `model.allowCustomHost === true`
1777
+ // 才放行 —— 而该标志**只能由本机(回环)来源的写请求设置**(见 writeRejection),
1778
+ // 因此远端会话即使改了配置,也拿不到凭据。
1779
+ // 注意:**含 `{base}` 的模板 URL 不算可信**(base 是用户可填字段),一律按自定义地址处理。
1780
+ function originOfUrl(u) {
1781
+ try {
1782
+ const p = new URL(String(u))
1783
+ if (p.protocol !== 'http:' && p.protocol !== 'https:') return ''
1784
+ return p.origin.toLowerCase()
1785
+ } catch (err) { return '' }
1786
+ }
1787
+ function templateOrigins(tpl) {
1788
+ const out = new Set()
1789
+ const add = (u) => {
1790
+ const s = String(u == null ? '' : u)
1791
+ if (!s || s.indexOf('{base}') >= 0) return
1792
+ const o = originOfUrl(s.replace('{key}', 'placeholder'))
1793
+ if (o) out.add(o)
1794
+ }
1795
+ add(tpl && tpl.probeUrl)
1796
+ add(tpl && tpl.balance && tpl.balance.url)
1797
+ add(tpl && tpl.balance && tpl.balance.usage && tpl.balance.usage.url)
1798
+ add(tpl && tpl.quota && tpl.quota.url)
1799
+ return out
1800
+ }
1801
+ function credentialMayGoTo(model, tpl, url) {
1802
+ const o = originOfUrl(url)
1803
+ if (!o) return false
1804
+ if (templateOrigins(tpl).has(o)) return true
1805
+ return !!(model && model.allowCustomHost === true)
1806
+ }
1807
+ function warnCredDestinationOnce(url) {
1808
+ try {
1809
+ const o = originOfUrl(url) || '(无效地址)'
1810
+ if (!warnCredDestinationOnce.seen) warnCredDestinationOnce.seen = new Set()
1811
+ if (warnCredDestinationOnce.seen.has(o)) return
1812
+ warnCredDestinationOnce.seen.add(o)
1813
+ console.warn('[whale-balance] 已拒绝把凭据发送到非内置端点:' + o
1814
+ + '(若这是你自建/自托管的网关,请在**本机**打开插件面板,对该模型勾选「允许把凭据发送到自定义地址」)')
1815
+ } catch (err) {}
1816
+ }
1817
+ // 解析凭据 + 目的地校验:所有"带凭据发请求"的地方都必须走这里
1818
+ async function keyForDestination(model, tpl, url) {
1819
+ let key = ''
1820
+ try {
1821
+ const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1822
+ key = cred && cred.value ? String(cred.value) : ''
1823
+ } catch (err) { key = '' }
1824
+ if (!key) return { key: '', code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1825
+ if (!credentialMayGoTo(model, tpl, url)) {
1826
+ warnCredDestinationOnce(url)
1827
+ return {
1828
+ key: '', code: 'DEST_NOT_ALLOWED',
1829
+ error: '该地址不是 ' + String((tpl && tpl.name) || model.provider || '该厂商') + ' 的内置端点,'
1830
+ + '已拒绝把凭据发出去。若这是你自建/自托管的网关,请**在本机**打开插件面板,'
1831
+ + '对该模型勾选「允许把凭据发送到自定义地址」后重试。',
1832
+ }
1833
+ }
1834
+ return { key, code: '', error: '' }
1674
1835
  }
1675
1836
  async function apiFetchJson(url, auth, key) {
1676
1837
  const headers = {}
@@ -1713,16 +1874,14 @@ export default {
1713
1874
  }
1714
1875
  return { ok: false, error: (cs && cs.error) || '未找到 Codex 会话目录' }
1715
1876
  }
1716
- let key = ''
1717
- try {
1718
- const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1719
- key = cred && cred.value ? String(cred.value) : ''
1720
- } catch (err) { key = '' }
1721
- if (!key) return { ok: false, error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1722
1877
  const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
1723
1878
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
1724
1879
  const url = String(tpl.probeUrl || b.url || (base ? base + '/v1/models' : '')).replace('{base}', base)
1725
1880
  if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
1881
+ // v761(安全修复 S1):只在目的地可信时才带上凭据
1882
+ const k = await keyForDestination(model, tpl, url)
1883
+ if (!k.key) return { ok: false, code: k.code, error: k.error }
1884
+ const key = k.key
1726
1885
  try {
1727
1886
  const data = await apiFetchJson(url, b.auth, key)
1728
1887
  const bizErr = apiBusinessError(data)
@@ -1744,14 +1903,12 @@ export default {
1744
1903
  const tpl = apiTemplateOf(model.provider) || {}
1745
1904
  const q = tpl.quota
1746
1905
  if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
1747
- let key = ''
1748
- try {
1749
- const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1750
- key = cred && cred.value ? String(cred.value) : ''
1751
- } catch (err) { key = '' }
1752
- if (!key) return { ok: false, code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1753
1906
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
1754
1907
  const url = String(q.url).replace('{base}', base)
1908
+ // v761(安全修复 S1):额度 URL 虽来自模板,但 `{base}` 会被用户字段改写,同样要校验目的地
1909
+ const k = await keyForDestination(model, tpl, url)
1910
+ if (!k.key) return { ok: false, code: k.code, error: k.error }
1911
+ const key = k.key
1755
1912
  try {
1756
1913
  const data = await apiFetchJson(url, q.auth, key)
1757
1914
  const bizErr = apiBusinessError(data)
@@ -1838,14 +1995,12 @@ export default {
1838
1995
  const tpl = apiTemplateOf(model.provider) || {}
1839
1996
  const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
1840
1997
  if (!b.url) return { ok: false, code: 'NO_URL', error: '未配置余额接口地址' }
1841
- let key = ''
1842
- try {
1843
- const cred = await ctx.credentials.resolve(model.keyRef || tpl.keyRef || '')
1844
- key = cred && cred.value ? String(cred.value) : ''
1845
- } catch (err) { key = '' }
1846
- if (!key) return { ok: false, code: 'NO_KEY', error: '未配置 ' + (model.keyRef || tpl.keyRef || 'API key') }
1847
1998
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
1848
1999
  const url = String(b.url).replace('{base}', base)
2000
+ // v761(安全修复 S1):凭据只在目的地可信(厂商内置端点 / 本机确认过的自定义地址)时才带上
2001
+ const k = await keyForDestination(model, tpl, url)
2002
+ if (!k.key) return { ok: false, code: k.code, error: k.error }
2003
+ const key = k.key
1849
2004
  try {
1850
2005
  const data = await apiFetchJson(url, b.auth, key)
1851
2006
  const j = b.json || {}
@@ -1857,9 +2012,13 @@ export default {
1857
2012
  if (j.used) { const v = apiNum(pickJsonPath(data, j.used), j.scale); if (v !== null) used += v }
1858
2013
  if (b.usage && b.usage.url) {
1859
2014
  const u = b.usage
1860
- const d2 = await apiFetchJson(String(u.url).replace('{base}', base), u.auth || b.auth, key)
2015
+ // v761(安全修复 S1):usage 是可选增强 —— 目的地不可信就跳过,绝不把凭据发出去
2016
+ const usageUrl = String(u.url).replace('{base}', base)
2017
+ if (credentialMayGoTo(model, tpl, usageUrl)) {
2018
+ const d2 = await apiFetchJson(usageUrl, u.auth || b.auth, key)
1861
2019
  const j2 = u.json || {}
1862
2020
  if (j2.used) { const v = apiNum(pickJsonPath(d2, j2.used), j2.scale); if (v !== null) used += v }
2021
+ } else { warnCredDestinationOnce(usageUrl) }
1863
2022
  }
1864
2023
  if (remaining === null && total !== null) remaining = total - used
1865
2024
  if (remaining === null) return { ok: false, code: 'SHAPE', error: '接口返回里没找到配置的字段(检查 JSON 路径)' }
@@ -2063,6 +2222,8 @@ export default {
2063
2222
  if (!tpl) return { ok: false, error: '未知的厂商模板' }
2064
2223
  const name = String(p.name || '').trim().slice(0, 30) || tpl.name
2065
2224
  const keyRef = String(p.keyRef || tpl.keyRef || '').trim().slice(0, 64) || tpl.keyRef
2225
+ // v761(安全修复 S1):凭据名加白名单 —— 原实现接受任意字符串,理论上能写到别的键名上
2226
+ if (!/^[A-Za-z0-9_]{1,64}$/.test(keyRef)) return { ok: false, error: '凭据名只能包含字母、数字与下划线(≤64 字符)' }
2066
2227
  const id = p.id ? String(p.id) : ('api_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 7))
2067
2228
  const reg = readApiRegistry()
2068
2229
  let model = reg.models.find((m) => m && m.id === id)
@@ -2074,6 +2235,11 @@ export default {
2074
2235
  model.provider = p.provider
2075
2236
  model.currency = String(p.currency || tpl.currency || 'CNY').toUpperCase().slice(0, 8)
2076
2237
  model.keyRef = keyRef
2238
+ // v761(安全修复 S1):**允许把凭据发往自定义地址**(自建/自托管网关需要)。
2239
+ // 该标志是"凭据目的地白名单"的唯一例外,而所有写请求都被限制为**本机来源**(见 writeRejection),
2240
+ // 所以远端会话即使能改配置,也无法给自己开这个口子。缺省不动(前端局部更新不会误抹)。
2241
+ if (p.allowCustomHost === true) model.allowCustomHost = true
2242
+ else if (p.allowCustomHost === false) delete model.allowCustomHost
2077
2243
  if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
2078
2244
  if (p.balance && typeof p.balance === 'object') {
2079
2245
  // v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
@@ -2153,6 +2319,17 @@ export default {
2153
2319
  let entry = {
2154
2320
  id: m.id, name: m.name, provider: m.provider, currency: m.currency,
2155
2321
  keyRef: m.keyRef, builtin: !!m.builtin, baseUrl: m.baseUrl || '',
2322
+ // v761(安全修复 S1):该模型当前的目的地拿不到凭据(非厂商内置端点且未在本机确认过),
2323
+ // 前端据此提示用户"要去本机面板确认",而不是让余额莫名其妙地空着。
2324
+ needsHostConfirm: (() => {
2325
+ try {
2326
+ const tpl2 = apiTemplateOf(m.provider) || {}
2327
+ const b2 = mergeNonEmpty(tpl2.balance, m.balance) || {}
2328
+ const base2 = String(m.baseUrl || "").replace(/\/+$/, "")
2329
+ const u2 = String(tpl2.probeUrl || b2.url || "").replace("{base}", base2)
2330
+ return !!u2 && !credentialMayGoTo(m, tpl2, u2)
2331
+ } catch (err) { return false }
2332
+ })(),
2156
2333
  canAdjustBalance: canAdjustBuiltinBalance(m),
2157
2334
  matchIds: m.matchIds || [], settings: settings.models && settings.models[m.id] ? settings.models[m.id] : null,
2158
2335
  price: m.price || null,
@@ -2292,7 +2469,7 @@ export default {
2292
2469
  const summary = daySummary(led, todayKey())
2293
2470
  const nowSec = Math.floor(Date.now() / 1000)
2294
2471
  return {
2295
- ...visible, version: '0.3.13', isPeak: isPeakTime(nowSec),
2472
+ ...visible, version: '0.3.15', isPeak: isPeakTime(nowSec),
2296
2473
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2297
2474
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2298
2475
  peakHolidays: HOLIDAY_VALLEY_LIST,
@@ -2311,9 +2488,26 @@ export default {
2311
2488
  balanceInFlight = fetchBalance()
2312
2489
  .then((payload) => {
2313
2490
  if (payload.ok) {
2314
- recordLedgerUsage(payload.totalBalance, payload.currency, payload.accountTag, Date.parse(payload.updatedAt))
2315
- balanceCache = { at: Date.now(), payload }
2316
- return publicBalance(payload)
2491
+ // v759(issue #157 报告人踩到):**"记不了账"不该吞掉已经拿到的余额**。
2492
+ // 以前 recordLedgerUsage 抛错会被最外层 .catch 包成「余额服务异常: …」,
2493
+ // 界面看起来像"接口没通",其实余额已经拿到了(accountTag/币种不合法就会这样)。
2494
+ // 但要与"**账本整个读不出来**"区分开:那种情况必须保持 fail-closed(见下面的 rethrow)——
2495
+ // 一边是"余额拿到了、只是没记上账",一边是"账本已经坏了、连今日已用都算不出来",
2496
+ // 后者若也回 ok:true,用户会以为一切正常,而账本其实不可用。
2497
+ let ledgerError = ''
2498
+ try {
2499
+ recordLedgerUsage(payload.totalBalance, payload.currency, payload.accountTag, Date.parse(payload.updatedAt))
2500
+ } catch (err) {
2501
+ ledgerError = String((err && err.message) || err).slice(0, 160)
2502
+ }
2503
+ let visible
2504
+ try {
2505
+ visible = publicBalance(payload) // 读账本算今日已用/近 7 天
2506
+ } catch (err) {
2507
+ throw err // 账本读不出来(结构损坏等)→ 交给最外层 .catch,报 ok:false 并保留原记录
2508
+ }
2509
+ balanceCache = { at: Date.now(), payload } // 先缓存:之后即使记账失败,余额也继续显示
2510
+ return ledgerError ? { ...visible, ledgerError } : visible
2317
2511
  }
2318
2512
  if (payload.transient && balanceCache) {
2319
2513
  return { ...publicBalance(balanceCache.payload), stale: true, error: payload.error }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",