dsh-api-dashboard 1.4.0 → 1.4.2

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.
package/src/index.js CHANGED
@@ -13,14 +13,53 @@
13
13
  * - sessionProjections 单元 queryBalanceCost 估算本会话消耗 (按模型单价)。
14
14
  */
15
15
 
16
- import Schema from '@deepseek-ai/schemastery'
17
- import { z } from 'zod'
18
- import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync, mkdirSync, rmSync, cpSync, statSync, readdirSync, realpathSync } from 'node:fs'
19
- import { fileURLToPath } from 'node:url'
16
+ import { createRequire } from 'node:module'
17
+ import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync, mkdirSync, rmSync, cpSync, statSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
18
+ import { fileURLToPath, pathToFileURL } from 'node:url'
20
19
  import { execFileSync } from 'node:child_process'
21
20
  import { tmpdir, homedir } from 'node:os'
22
21
  import { join, dirname, basename } from 'node:path'
23
22
 
23
+ /**
24
+ * peer 依赖加载器(v1.4.1)—— 这一条直接决定「别人下载后能不能用」。
25
+ *
26
+ * 病症(真机实测复现):用户按 README 执行 `dsh plugin --profile web add dsh-api-dashboard`,
27
+ * 安装成功,但启动时 **整个 dsh web 起不来**:
28
+ *
29
+ * Cannot find package '@deepseek-ai/schemastery' imported from
30
+ * /root/.local/share/pnpm/store/v10/files/42/e96689...
31
+ *
32
+ * 链路:profile 用 hoisted linker + autoInstallPeers:false(插件把它声明成 optional peer),
33
+ * DSHA 的 proot 带 `--link2symlink`,于是 pnpm 的硬链接被降级成**指向全局 store 的软链**;
34
+ * Node 的 ESM 会先把模块解析成 realpath,再从那开始向上找 node_modules ——
35
+ * 从 store 目录往上永远也走不到 `$DSH_HOME/profiles/node_modules`(DSH 放宿主依赖的地方)。
36
+ *
37
+ * 修法:先按常规 import;失败时改用宿主自己的模块回退目录做 CJS 解析
38
+ * (`createRequire` 用的是**给定路径**而不是 realpath,因此能穿透这层软链)。
39
+ * 这样 npm 安装、源码安装、软链安装三种布局都能加载。
40
+ */
41
+ const resolvePeer = async (spec) => {
42
+ let primary = null
43
+ try { return await import(spec) } catch (e) { primary = e }
44
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
45
+ const bases = [
46
+ join(home, 'profiles', 'node_modules', '__dshadb_resolver__.cjs'),
47
+ join(home, 'profiles', process.env.DSHA_STARTUP_PROFILE || 'web', 'node_modules', '__dshadb_resolver__.cjs'),
48
+ ]
49
+ for (const base of bases) {
50
+ try {
51
+ const entry = createRequire(base).resolve(spec)
52
+ return await import(pathToFileURL(entry).href)
53
+ } catch { /* 试下一个基准目录 */ }
54
+ }
55
+ throw primary
56
+ }
57
+
58
+ const schemaMod = await resolvePeer('@deepseek-ai/schemastery')
59
+ const Schema = schemaMod.default ?? schemaMod
60
+ const zodMod = await resolvePeer('zod')
61
+ const z = zodMod.z ?? zodMod.default?.z ?? zodMod
62
+
24
63
  export const name = 'dsh-api-dashboard'
25
64
 
26
65
  // ============================================================
@@ -36,7 +75,33 @@ const MANIFEST_API = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/co
36
75
  const TARBALL_URL = `https://codeload.github.com/${REPO_OWNER}/${REPO_NAME}/tar.gz/refs/heads/${REPO_BRANCH}`
37
76
 
38
77
  /** 插件运行实体的安装根目录 (src/index.js 上两级; ESM 默认按 realpath 加载) */
39
- const SELF_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
78
+ /**
79
+ * 插件自身的安装根目录(v1.4.1)。
80
+ *
81
+ * 不能只用 `dirname(dirname(import.meta.url))`:ESM 会把符号链接解析成 realpath,
82
+ * 而 npm 装进 profile 后文件常常是**指向 pnpm store 的软链** → 算出来的根目录是
83
+ * `.../store/v10/files`,于是 assets(图标 / 大肥鱼贴图 / 音效)全部 404、
84
+ * 版本号也读不到(一键更新会误判)。
85
+ * 这里按候选顺序找第一个「package.json 里 name 就是本插件」的目录。
86
+ */
87
+ const resolveSelfRoot = () => {
88
+ const fromUrl = dirname(dirname(fileURLToPath(import.meta.url)))
89
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
90
+ const profile = process.env.DSHA_STARTUP_PROFILE || 'web'
91
+ const candidates = [
92
+ fromUrl,
93
+ join(home, 'profiles', profile, 'node_modules', 'dsh-api-dashboard'),
94
+ join(home, 'profiles', 'node_modules', 'dsh-api-dashboard'),
95
+ ]
96
+ for (const dir of candidates) {
97
+ try {
98
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
99
+ if (pkg && pkg.name === 'dsh-api-dashboard') return dir
100
+ } catch { /* 试下一个 */ }
101
+ }
102
+ return fromUrl
103
+ }
104
+ const SELF_ROOT = resolveSelfRoot()
40
105
 
41
106
  /** 读取指定目录中 package.json 的 version, 异常返回 null */
42
107
  const readVersionAt = (dir) => {
@@ -96,7 +161,12 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
96
161
  try {
97
162
  const legacyReal = realPathSafe(join(homedir(), 'dsha-api-dashboard'))
98
163
  const selfReal = realPathSafe(SELF_ROOT)
99
- if (legacyReal && selfReal && legacyReal !== selfReal && existsSync(join(legacyReal, 'package.json'))) {
164
+ // H-2 (v1.4.1): 自动同步 ~/dsha-api-dashboard 只对「非 git 工作区」生效。
165
+ // 以前不判断, 于是维护者/开发者的 git clone 会在一次"一键更新"后被 tarball 覆写 ——
166
+ // codeload 的 tarball 里**没有 .git**(已实测), 删掉就再也回不来。
167
+ if (legacyReal && selfReal && legacyReal !== selfReal
168
+ && existsSync(join(legacyReal, 'package.json'))
169
+ && !existsSync(join(legacyReal, '.git'))) {
100
170
  dirs.push(legacyReal)
101
171
  }
102
172
  } catch { /* 探测失败不影响主流程 */ }
@@ -104,6 +174,16 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
104
174
  for (const dir of dirs) {
105
175
  if (!existsSync(join(dir, 'package.json'))) throw new Error(`target missing: ${dir}`)
106
176
  }
177
+ // H-2 (v1.4.1): target 是符号链接时, 下面的 rmSync 会**穿透软链删光真实目录的内容**,
178
+ // 而 cpSync 对软链会抛 ERR_FS_CP_DIR_TO_NON_DIR; 备份 tar 里存的又只是软链本身 →
179
+ // 真实目录永久损坏且回滚不回来。遇到软链直接拒绝, 让用户改用真实路径。
180
+ for (const dir of dirs) {
181
+ let st = null
182
+ try { st = lstatSync(dir) } catch { st = null }
183
+ if (st && st.isSymbolicLink()) {
184
+ throw new Error(`refusing to update a symbolic-link target: ${dir} (use its real path instead)`)
185
+ }
186
+ }
107
187
 
108
188
  // 1) 下载 tarball 到临时文件
109
189
  const tmpBase = join(tmpdir(), `dshadb-update-${Date.now()}`)
@@ -138,7 +218,9 @@ export async function applyUpdate({ targets = null, timeoutMs = 30000, remoteVer
138
218
  // 5) 交换: 删旧内容 → 拷新内容 (node_modules 保留, 避免重装依赖)
139
219
  for (const dir of dirs) {
140
220
  swapped = true
141
- const keep = new Set(['node_modules'])
221
+ // H-2 (v1.4.1): 必须保留 .git —— codeload 的 tarball 里没有它(实测),
222
+ // 删掉就等于把用户的 git 历史抹了, 且没有任何恢复途径。
223
+ const keep = new Set(['node_modules', '.git'])
142
224
  for (const entry of readdirSafe(dir)) {
143
225
  if (!keep.has(entry)) rmSync(join(dir, entry), { recursive: true, force: true })
144
226
  }
@@ -202,6 +284,15 @@ async function getUpdateStatus(force = false) {
202
284
  // ============================================================
203
285
  const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-api-dashboard.json')
204
286
 
287
+ /**
288
+ * A(v1.4.1): 每个中转站「上次探到的可用余额端点」的记忆表。
289
+ * `queryCustomRelay` 的 auto 探测是**串行**试 3 个候选端点, 每个都要跑满 timeoutMs ——
290
+ * 实测一次全量刷新因此要 8~13.6 秒。记住命中过的端点并优先试它, 稳态下每个中转站只打 1 个请求。
291
+ * 只做**排序提示**, 候选全表仍然会依次试, 所以某个中转站换了端点也能自动跟上。
292
+ * 持久化在状态文件 `relayEndpoints`(重启后依然生效)。
293
+ */
294
+ const relayEndpointHints = new Map()
295
+
205
296
  /**
206
297
  * 状态文件结构版本。**改动已持久化字段的默认值时必须 +1 并补一段迁移**,
207
298
  * 否则老用户的状态文件会把字段钉死在旧值上 —— 新默认值对老用户永远不生效。
@@ -211,8 +302,44 @@ const STATE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-ap
211
302
  */
212
303
  const CONFIG_VERSION = 2
213
304
 
305
+ /**
306
+ * 形状消毒 (v1.4.1): 状态文件是**我们自己的代码**写的, 但写盘可能被打断
307
+ * (磁盘满/进程被杀/UTF-8 截断), 用户也可能手改。字段形状一旦跑偏,
308
+ * 后面的 `(persisted.customRelays ?? []).map(...)` 会抛 TypeError —— 而 apply() 抛出
309
+ * 会让 **整个 dsh web 启动失败**(不是插件不显示, 是 GUI 打不开), 用户完全无从下手。
310
+ * 所以这里把所有"本该是数组/对象/数字"的字段统一消毒, 消毒不了就丢弃, 绝不让 apply() 抛。
311
+ * ⚠️ 新增持久化字段时, 记得同步登记到这里。
312
+ */
313
+ const ARRAY_FIELDS = ['customRelays', 'customModels', 'officialProviders', 'dshProviderOptOut', 'presets']
314
+ const OBJECT_FIELDS = ['whaleSettings', 'prices', 'relayEndpoints']
315
+ const NUMBER_FIELDS = [
316
+ ['refreshIntervalMs', 1000, 60000], // H-4b: 曾经能持久化成 -1 → 3 秒内 1794 次上游请求
317
+ ['clientPollIntervalMs', 1000, 60000],
318
+ ['timeoutMs', 1000, 60000],
319
+ ['safeThreshold', 0, Number.MAX_SAFE_INTEGER],
320
+ ['warnThreshold', 0, Number.MAX_SAFE_INTEGER],
321
+ ['configVersion', 0, Number.MAX_SAFE_INTEGER],
322
+ ]
323
+
324
+ const sanitizePersistedShape = (s) => {
325
+ const out = { ...s }
326
+ for (const k of ARRAY_FIELDS) {
327
+ if (k in out && !Array.isArray(out[k])) delete out[k]
328
+ }
329
+ for (const k of OBJECT_FIELDS) {
330
+ if (k in out && (out[k] === null || typeof out[k] !== 'object' || Array.isArray(out[k]))) delete out[k]
331
+ }
332
+ for (const [k, min, max] of NUMBER_FIELDS) {
333
+ if (!(k in out)) continue
334
+ const n = Number(out[k])
335
+ if (!Number.isFinite(n)) { delete out[k]; continue }
336
+ out[k] = Math.min(Math.max(Math.round(n), min), max)
337
+ }
338
+ return out
339
+ }
340
+
214
341
  const migratePersistedState = (parsed) => {
215
- const s = (parsed && typeof parsed === 'object') ? parsed : {}
342
+ const s = sanitizePersistedShape((parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {})
216
343
  const ver = Number.isFinite(s.configVersion) ? s.configVersion : 1
217
344
  let out = s
218
345
  if (ver < 2) {
@@ -234,6 +361,9 @@ const loadPersistedState = () => {
234
361
  const savePersistedState = (state) => {
235
362
  try {
236
363
  const merged = { ...loadPersistedState(), ...state }
364
+ // H-4c (v1.4.1): 以前这里从不建父目录, 且吞掉所有异常 → ~/.dsh 不存在时
365
+ // 接口照样回 ok:true, 用户配置"保存成功"却没落盘, 重启即丢。现在先建目录。
366
+ mkdirSync(dirname(STATE_FILE), { recursive: true })
237
367
  // mode 0o600: 状态文件含自定义中转站/模型的 API Key 明文, 必须限定本用户可读
238
368
  // (不能依赖 umask —— 默认 umask 0022 的桌面机会落成 0644); chmod 兜底修正旧文件
239
369
  writeFileSync(STATE_FILE + '.tmp', JSON.stringify(merged, null, 2), { encoding: 'utf8', mode: 0o600 })
@@ -471,7 +601,15 @@ export const selectDshProviders = (entries, kinds, optOut) => {
471
601
  */
472
602
  export const planBalancesFetch = ({ force = false, peek = false, hasData = false, age = 0, intervalMs = 5000 } = {}) => {
473
603
  const stale = age > (intervalMs || 300000) // 兼容旧行为: intervalMs 缺失时用 5 分钟
474
- if (!hasData) return 'wait' // 冷启动(服务端刚重启): 确实没东西可显示, 只能等
604
+ /**
605
+ * v1.4.1: 冷启动**不再阻塞首屏**。
606
+ * 旧行为是 `return 'wait'` —— 服务端刚重启时缓存为空, 第一个请求要 await 一次**全量**刷新,
607
+ * 而实测全量刷新要 8~13.6 秒(中转站 auto 探测是串行试 3 个端点), 用户看到的就是
608
+ * 「重启进来等半天」。现在改成: 立刻回「还在加载」+ 把刷新丢后台, 客户端保持骨架屏并**1.5 秒后重问**,
609
+ * 数据一到就上屏。注意这**不是假数据** —— 返回的是空列表 + loading 标记, 界面显示的是"加载中"而非 0。
610
+ * 只有显式强刷(force, 用户主动要新数据)才继续阻塞等。
611
+ */
612
+ if (!hasData) return force ? 'wait' : 'background'
475
613
  if (!force && !stale) return 'none'
476
614
  if (peek) return age > 1000 ? 'background' : 'none' // 1 秒内刚拉过就不重复打
477
615
  return age > 2000 ? 'wait' : 'none' // 显式强刷留 2 秒节流, 防连点打爆平台接口
@@ -945,12 +1083,18 @@ async function fetchWithTimeout(url, headers, timeoutMs, method = 'GET') {
945
1083
 
946
1084
  /** 读取请求体并限制大小 (默认 256KB) —— 防持有 token 者灌大包打爆内存。超限抛错。 */
947
1085
  async function readBody(req, limit = 256 * 1024) {
948
- let body = ''
1086
+ // H-4d (v1.4.1): 不能对每个 chunk 单独 toString —— 一个汉字的 3 个字节被 TCP 分到两个 chunk 时,
1087
+ // 两边都会解出替换字符 U+FFFD, 中文中转站名/自定义模型名会被写坏并持久化(实测)。
1088
+ // 改成先收集 Buffer 再整体解码。
1089
+ const chunks = []
1090
+ let size = 0
949
1091
  for await (const chunk of req) {
950
- body += chunk
951
- if (body.length > limit) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
1092
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1093
+ size += buf.length
1094
+ if (size > limit) throw Object.assign(new Error('request body too large'), { statusCode: 413 })
1095
+ chunks.push(buf)
952
1096
  }
953
- return body
1097
+ return Buffer.concat(chunks).toString('utf8')
954
1098
  }
955
1099
 
956
1100
  /** 字符串清洗: 截断到 max 长度 (设置面板传入的任意字段统一过这里) */
@@ -1118,16 +1262,30 @@ function checkAlerts(balances, config, ctx) {
1118
1262
  // ============================================================
1119
1263
 
1120
1264
  // 纯函数, 导出便于单测 (不影响对外行为)
1121
- export function parseResponse(queryType, json) {
1265
+ export function parseResponse(queryType, json, pref) {
1122
1266
  if (!json || typeof json !== 'object' || Array.isArray(json)) return null
1123
1267
  switch (queryType) {
1124
1268
  case 'deepseek': {
1125
1269
  const infos = Array.isArray(json?.balance_infos) ? json.balance_infos : []
1126
- const p = infos[0]
1270
+ // H-4d (v1.4.2): balance_infos 是「一个币种钱包一条」—— 官方文档 currency 取值 CNY / USD。
1271
+ // 旧代码盲取 infos[0],而**数组顺序不保证**:真机实测同一 key 连打 5 次,第 4 次顺序翻成
1272
+ // [USD=0.00, CNY=123.45] → 读到 USD 那条 → 有 123.45 元的账户显示成「$0.00 · 异常」。
1273
+ // 更糟的是下面那道 `total_balance == null` 红线**拦不住**它 —— "0.00" 是合法字符串,
1274
+ // 于是红线守卫被绕过、0 被当成真实余额渲染(与 v1.4.0 openai-credit-grants、
1275
+ // v1.4.1 openrouter 是同一族漏洞:选错一条就当真实数字)。
1276
+ // 现在按「主货币优先 → 余额 > 0 → 首条」确定性挑选,结果与接口返回顺序无关。
1277
+ const amountOf = (v) => {
1278
+ if (v === null || v === undefined || v === '') return null
1279
+ const n = Number(v)
1280
+ return Number.isFinite(n) ? n : null
1281
+ }
1282
+ const want = String(pref ?? '').trim().toUpperCase()
1283
+ const usable = infos.filter((x) => x && amountOf(x.total_balance) !== null)
1284
+ const p =
1285
+ (want ? usable.find((x) => String(x.currency || '').toUpperCase() === want) : undefined) ??
1286
+ usable.find((x) => amountOf(x.total_balance) > 0) ??
1287
+ usable[0]
1127
1288
  if (!p) return null
1128
- // v1.4.0 修复: total_balance 缺失时 toAmount(null)=0 会伪造「余额 0」。
1129
- // 与 AGENTS.md「字段存在性校验」一致 —— 缺关键字段即返回 null, 交给上层显示「未开放」。
1130
- if (p.total_balance == null) return null
1131
1289
  // total_balance 当前余额, granted_balance 赠送, topped_up_balance 充值
1132
1290
  const total = toAmount(p.total_balance)
1133
1291
  const grant = toAmount(p.granted_balance)
@@ -1140,11 +1298,22 @@ export function parseResponse(queryType, json) {
1140
1298
  if (!json || typeof json !== 'object') return null
1141
1299
  const hasAny = 'total_granted' in json || 'total_available' in json || 'total_used' in json
1142
1300
  if (!hasAny) return null
1143
- const total = toAmount(json?.total_granted)
1144
- const used = toAmount(json?.total_used)
1145
- const hasAvail = json?.total_available != null
1146
- const available = hasAvail ? toAmount(json.total_available) : null
1147
- return { total: hasAvail ? available : (total - used), currency: 'USD', available, used, note: 'OpenAI 兼容额度' }
1301
+ // H-4a (v1.4.1): 字段**存在但值无效**(null / 非数字)时, toAmount 会归 0,
1302
+ // 于是 total = 0 - used 得到一个**负数余额** —— 与 openrouter 那次是同一类漏洞
1303
+ // (红线: 不许把解析失败冒充成真实数字)。这里改成「值无效就不表态」。
1304
+ // ⚠️ Number(null) === 0、Number('') === 0 —— 必须先把「空值」挡掉, 否则等于没挡
1305
+ const numOrNull = (v) => {
1306
+ if (v === null || v === undefined || v === '') return null
1307
+ const n = Number(v)
1308
+ return Number.isFinite(n) ? n : null
1309
+ }
1310
+ const grantedN = numOrNull(json?.total_granted)
1311
+ const usedN = numOrNull(json?.total_used)
1312
+ const availN = numOrNull(json?.total_available)
1313
+ if (availN === null && (grantedN === null || usedN === null)) return null
1314
+ const used = usedN ?? 0
1315
+ const hasAvail = availN !== null
1316
+ return { total: hasAvail ? availN : (grantedN - used), currency: 'USD', available: hasAvail ? availN : null, used, note: 'OpenAI 兼容额度' }
1148
1317
  }
1149
1318
  case 'siliconflow': {
1150
1319
  const d = json?.data
@@ -1335,7 +1504,7 @@ async function queryPreset(platform, apiKey, config) {
1335
1504
  const text = await res.text()
1336
1505
  let json
1337
1506
  try { json = JSON.parse(text) } catch { json = null }
1338
- const parsed = parseResponse(queryType, json)
1507
+ const parsed = parseResponse(queryType, json, config?.currency)
1339
1508
 
1340
1509
  if (!parsed) {
1341
1510
  // v1.2.6: 业务错误分类抽到 classifyBizError (可单测)。
@@ -1398,6 +1567,13 @@ async function queryCustomRelay(relay, config) {
1398
1567
  )
1399
1568
  }
1400
1569
 
1570
+ // A: 命中过的端点排到最前(只影响顺序, 不影响"全都会试一遍"的语义)
1571
+ if (candidates.length > 1 && relayEndpointHints.has(id)) {
1572
+ const hint = relayEndpointHints.get(id)
1573
+ const idx = candidates.findIndex((c) => c.type === hint)
1574
+ if (idx > 0) candidates.unshift(candidates.splice(idx, 1)[0])
1575
+ }
1576
+
1401
1577
  for (const cand of candidates) {
1402
1578
  const headers = { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }
1403
1579
  const method = cand.type === 'quota' ? 'POST' : 'GET'
@@ -1407,8 +1583,13 @@ async function queryCustomRelay(relay, config) {
1407
1583
  const text = await res.text()
1408
1584
  let json
1409
1585
  try { json = JSON.parse(text) } catch { continue }
1410
- const parsed = parseResponse(cand.type, json)
1586
+ const parsed = parseResponse(cand.type, json, config?.currency)
1411
1587
  if (parsed) {
1588
+ // A: 记住这次命中的端点(变了才落盘, 避免每次刷新都写状态文件)
1589
+ if (relayEndpointHints.get(id) !== cand.type) {
1590
+ relayEndpointHints.set(id, cand.type)
1591
+ try { savePersistedState({ relayEndpoints: Object.fromEntries(relayEndpointHints) }) } catch { /* 落盘失败不影响本次结果 */ }
1592
+ }
1412
1593
  return {
1413
1594
  platform: id, name: name || '中转站', icon: 'relay', color: '#64748B', category: '中转站',
1414
1595
  status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available,
@@ -1468,14 +1649,14 @@ export async function queryCustomModel(model, config) {
1468
1649
 
1469
1650
  // 2) 指定解析类型
1470
1651
  if (queryType && queryType !== 'auto') {
1471
- const parsed = parseResponse(queryType, json)
1652
+ const parsed = parseResponse(queryType, json, config?.currency)
1472
1653
  if (parsed) {
1473
1654
  return { ...base, status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available, used: parsed.used, note: parsed.note, percent: parsed.percent, noBalance: false, queryType, fetchedAt: Date.now() }
1474
1655
  }
1475
1656
  } else {
1476
1657
  // 3) auto: 尝试常见格式
1477
1658
  for (const qt of ['openai', 'quota', 'deepseek', 'openai-billing']) {
1478
- const parsed = parseResponse(qt, json)
1659
+ const parsed = parseResponse(qt, json, config?.currency)
1479
1660
  if (parsed) {
1480
1661
  return { ...base, status: 'ok', total: parsed.total, currency: parsed.currency, available: parsed.available, used: parsed.used, note: parsed.note, percent: parsed.percent, noBalance: false, queryType: qt, fetchedAt: Date.now() }
1481
1662
  }
@@ -1914,13 +2095,25 @@ export function makeCostProjection(configOrGetter, services) {
1914
2095
  export function apply(ctx, config) {
1915
2096
  // 用户保存的配置优先于 cordis.patch.yml 的默认 config (持久化状态)
1916
2097
  const persisted = loadPersistedState()
2098
+ // A: 载入上次记住的中转站端点
2099
+ try {
2100
+ relayEndpointHints.clear()
2101
+ const saved = persisted.relayEndpoints
2102
+ if (saved && typeof saved === 'object' && !Array.isArray(saved)) {
2103
+ for (const [k, v] of Object.entries(saved)) {
2104
+ if (typeof k === 'string' && k.length <= 128 && typeof v === 'string' && v.length <= 32) relayEndpointHints.set(k, v)
2105
+ }
2106
+ }
2107
+ } catch { /* 忽略 */ }
1917
2108
  const runtimeConfig = {
1918
2109
  refreshIntervalMs: persisted.refreshIntervalMs ?? config.refreshIntervalMs ?? 5000,
1919
2110
  clientPollIntervalMs: persisted.clientPollIntervalMs ?? config.clientPollIntervalMs ?? 5000,
1920
2111
  timeoutMs: persisted.timeoutMs ?? config.timeoutMs ?? 8000,
1921
2112
  presets: config.presets ?? PLATFORM_PRESETS.map(p => p.id),
1922
- customRelays: (persisted.customRelays ?? config.customRelays ?? []).map(r => ({ ...r })),
1923
- customModels: (persisted.customModels ?? config.customModels ?? []).map(m => ({ ...m })),
2113
+ // H-1 (v1.4.1): 这里必须带 Array.isArray —— 状态文件形状跑偏时 apply() 抛出 =
2114
+ // 整个 dsh web 启动失败(migratePersistedState 已消毒, 这里是第二道防线)
2115
+ customRelays: (Array.isArray(persisted.customRelays) ? persisted.customRelays : (Array.isArray(config.customRelays) ? config.customRelays : [])).map(r => ({ ...r })),
2116
+ customModels: (Array.isArray(persisted.customModels) ? persisted.customModels : (Array.isArray(config.customModels) ? config.customModels : [])).map(m => ({ ...m })),
1924
2117
  prices: config.prices ?? { 'deepseek-chat': { cacheHit: 0.1, cacheMiss: 1, output: 2 } },
1925
2118
  defaultPrices: config.defaultPrices ?? { cacheHit: 0.1, cacheMiss: 1, output: 2 },
1926
2119
  currency: persisted.currency ?? config.currency ?? 'CNY',
@@ -2129,9 +2322,51 @@ export function apply(ctx, config) {
2129
2322
  res.end(body)
2130
2323
  }
2131
2324
 
2325
+ /**
2326
+ * H-3 (v1.4.1): 插件路由的鉴权闸门。
2327
+ *
2328
+ * 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
2329
+ * 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
2330
+ * 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
2331
+ * 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
2332
+ * 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
2333
+ * 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
2334
+ *
2335
+ * 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死。
2336
+ */
2337
+ /**
2338
+ * H-3 (v1.4.1): 插件路由的鉴权闸门。
2339
+ *
2340
+ * 为什么必须有这道闸: dsh 的 webserver 是「先查 exact 路由表, 再走 fallback」,
2341
+ * 而浏览器的登录 cookie 校验只写在 fallback 里(dsh-host-frontend-static) ——
2342
+ * 于是 `/api-dashboard/*` 全部绕过鉴权: 无需 token、无需 cookie 就能读配置、
2343
+ * 改配置、改挂件、甚至触发 `/update/install`(会重写插件目录)。
2344
+ * 更糟的是 `Content-Type: text/plain` 属于**浏览器不预检的 simple request**,
2345
+ * 用户手机上随便打开一个网页, 那个网页就能 POST 过来改配置(实测 HTTP 200 且真的改了)。
2346
+ *
2347
+ * 用 `connection.requestRejection(req)`: 与 dsh-web-mobile 同一个闸门,
2348
+ * 同时覆盖「浏览器 cookie 鉴权」与「Host/来源可信」两项检查。
2349
+ * ⚠️ 不能写 `ctx.get('connection')` —— 实测在插件 fiber 上取不到(返回 undefined),
2350
+ * 必须用嵌套 inject 拿服务实例。
2351
+ */
2352
+ let connectionSvc = null
2353
+ ctx.inject(['connection'], (c) => { connectionSvc = c.connection })
2354
+ const allowRequest = (req, res) => {
2355
+ const conn = connectionSvc
2356
+ // 老版本 dsh 没有 connection 服务时放行(那时本来也没有鉴权概念), 避免把插件打死
2357
+ if (!conn || typeof conn.requestRejection !== 'function') return true
2358
+ let rejection
2359
+ try { rejection = conn.requestRejection(req) } catch { rejection = undefined }
2360
+ if (rejection === undefined) return true
2361
+ res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
2362
+ res.end(rejection === 401 ? 'dsh web authentication required; reopen the URL printed by dsh web.\n' : 'forbidden\n')
2363
+ return false
2364
+ }
2365
+
2132
2366
  webCtx.effect(() => webCtx.webServer.register({
2133
2367
  kind: 'exact', path: '/api-dashboard/balances',
2134
2368
  async handler(req, res) {
2369
+ if (!allowRequest(req, res)) return
2135
2370
  if (!['GET', 'HEAD', 'POST'].includes(req.method)) { res.writeHead(405, { Allow: 'GET, HEAD, POST' }); res.end(); return }
2136
2371
  const params = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams
2137
2372
  const force = req.method === 'POST' || params.get('force') === '1'
@@ -2160,7 +2395,7 @@ export function apply(ctx, config) {
2160
2395
  res.end()
2161
2396
  return
2162
2397
  }
2163
- const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config })
2398
+ const body = JSON.stringify({ ok: true, balances: cache.balances, fetchedAt: cache.fetchedAt, config: cache.config, loading: cache.balances.length === 0 })
2164
2399
  res.writeHead(200, {
2165
2400
  'Content-Type': 'application/json; charset=utf-8',
2166
2401
  'Cache-Control': 'private, no-cache',
@@ -2175,6 +2410,7 @@ export function apply(ctx, config) {
2175
2410
  webCtx.effect(() => webCtx.webServer.register({
2176
2411
  kind: 'exact', path: '/api-dashboard/prices',
2177
2412
  async handler(req, res) {
2413
+ if (!allowRequest(req, res)) return
2178
2414
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2179
2415
  const cfg = runtimeConfig
2180
2416
  const cur = (cfg.currency ?? 'CNY').toUpperCase() === 'USD' ? 'USD' : 'CNY'
@@ -2195,6 +2431,7 @@ export function apply(ctx, config) {
2195
2431
  webCtx.effect(() => webCtx.webServer.register({
2196
2432
  kind: 'exact', path: '/api-dashboard/platforms',
2197
2433
  async handler(req, res) {
2434
+ if (!allowRequest(req, res)) return
2198
2435
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2199
2436
  const presets = PLATFORM_PRESETS.filter(p => runtimeConfig.presets.includes(p.id)).map(p => ({
2200
2437
  id: p.id, name: p.label, icon: p.icon, color: p.color, category: p.category, queryType: p.queryType,
@@ -2207,6 +2444,7 @@ export function apply(ctx, config) {
2207
2444
  webCtx.effect(() => webCtx.webServer.register({
2208
2445
  kind: 'exact', path: '/api-dashboard/update',
2209
2446
  async handler(req, res) {
2447
+ if (!allowRequest(req, res)) return
2210
2448
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2211
2449
  const force = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('force') === '1'
2212
2450
  try {
@@ -2221,6 +2459,7 @@ export function apply(ctx, config) {
2221
2459
  webCtx.effect(() => webCtx.webServer.register({
2222
2460
  kind: 'exact', path: '/api-dashboard/update/install',
2223
2461
  async handler(req, res) {
2462
+ if (!allowRequest(req, res)) return
2224
2463
  if (req.method !== 'POST') { res.writeHead(405, { Allow: 'POST' }); res.end(); return }
2225
2464
  try {
2226
2465
  const result = await applyUpdate({})
@@ -2242,6 +2481,7 @@ export function apply(ctx, config) {
2242
2481
  webCtx.effect(() => webCtx.webServer.register({
2243
2482
  kind: 'exact', path: '/api-dashboard/icon',
2244
2483
  async handler(req, res) {
2484
+ if (!allowRequest(req, res)) return
2245
2485
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2246
2486
  try {
2247
2487
  if (!iconCache) {
@@ -2267,6 +2507,7 @@ export function apply(ctx, config) {
2267
2507
  webCtx.effect(() => webCtx.webServer.register({
2268
2508
  kind: 'exact', path: '/api-dashboard/whale/image.png',
2269
2509
  async handler(req, res) {
2510
+ if (!allowRequest(req, res)) return
2270
2511
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2271
2512
  try {
2272
2513
  if (!whaleImgCache) whaleImgCache = readFileSync(whaleAsset('DSniang1.png'))
@@ -2284,6 +2525,7 @@ export function apply(ctx, config) {
2284
2525
  webCtx.effect(() => webCtx.webServer.register({
2285
2526
  kind: 'exact', path: '/api-dashboard/whale/rua.gif',
2286
2527
  async handler(req, res) {
2528
+ if (!allowRequest(req, res)) return
2287
2529
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2288
2530
  try {
2289
2531
  if (!whaleGifCache) whaleGifCache = readFileSync(whaleAsset('rua.gif'))
@@ -2301,6 +2543,7 @@ export function apply(ctx, config) {
2301
2543
  webCtx.effect(() => webCtx.webServer.register({
2302
2544
  kind: 'exact', path: `/api-dashboard/whale/sound/${kind}.mp3`,
2303
2545
  async handler(req, res) {
2546
+ if (!allowRequest(req, res)) return
2304
2547
  if (req.method !== 'GET') { res.writeHead(405, { Allow: 'GET' }); res.end(); return }
2305
2548
  try {
2306
2549
  const set = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('set') === 'fx1' ? 'fx1' : 'duck'
@@ -2322,6 +2565,7 @@ export function apply(ctx, config) {
2322
2565
  webCtx.effect(() => webCtx.webServer.register({
2323
2566
  kind: 'exact', path: '/api-dashboard/whale/settings',
2324
2567
  async handler(req, res) {
2568
+ if (!allowRequest(req, res)) return
2325
2569
  if (req.method === 'GET') { sendJson(res, 200, { ok: true, settings: runtimeConfig.whaleSettings }); return }
2326
2570
  if (req.method === 'PUT' || req.method === 'POST') {
2327
2571
  try {
@@ -2372,6 +2616,7 @@ export function apply(ctx, config) {
2372
2616
  webCtx.effect(() => webCtx.webServer.register({
2373
2617
  kind: 'exact', path: '/api-dashboard/config',
2374
2618
  async handler(req, res) {
2619
+ if (!allowRequest(req, res)) return
2375
2620
  if (req.method === 'GET') {
2376
2621
  sendJson(res, 200, {
2377
2622
  ok: true,
@@ -2380,6 +2625,11 @@ export function apply(ctx, config) {
2380
2625
  presets: runtimeConfig.presets,
2381
2626
  refreshIntervalSec: Math.round(runtimeConfig.refreshIntervalMs / 1000),
2382
2627
  currency: runtimeConfig.currency,
2628
+ // C-2 (v1.4.1): 以前这里不返回阈值, 设置面板只能等 /balances 带过来;
2629
+ // 冷启动那几秒(/balances 可能要等 8~10s)打开面板 → 显示默认 50/10 →
2630
+ // 用户一点"保存并生效"就把自己存的阈值覆盖掉了。补上。
2631
+ safeThreshold: runtimeConfig.safeThreshold,
2632
+ warnThreshold: runtimeConfig.warnThreshold,
2383
2633
  overseasCurrency: runtimeConfig.overseasCurrency,
2384
2634
  whaleEnabled: !!runtimeConfig.whaleEnabled,
2385
2635
  showNoBalanceBrands: !!runtimeConfig.showNoBalanceBrands,