@gehennawu/dsh-service 1.9.2 → 1.9.3

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/index.js CHANGED
@@ -112,8 +112,12 @@ const MAX_BACKUP_COMPRESSED_BYTES = 512 * 1024 * 1024
112
112
  const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
113
113
  const backupIdSecret = randomBytes(32)
114
114
  const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
115
- const USAGE_INDEX_VERSION = 5
115
+ const USAGE_INDEX_VERSION = 6
116
116
  const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
117
+ // 报错滚动窗口:一个常量同时约束「折叠时是否收录」(recentErrorTime)、「出参前修剪」
118
+ // (publicUsage)与「落盘前修剪」(refreshUsageIndex),三者必须一致,否则会出现
119
+ // 收录了却被下一轮清掉的半衰状态。窗口只影响报错计数,与永久保留的用量无关。
120
+ const USAGE_ERROR_WINDOW_MS = 48 * 60 * 60 * 1000
117
121
  // 官方右栏文件编辑(v1.6 用户点名):浏览器只送资源地址,宿主解析会话后按 ctx.fs +
118
122
  // sandboxPolicy 读写;单文件上限双向生效(读取、保存、撤销通道都受它约束)。
119
123
  const FILE_RESOURCE_PREFIX = 'dsh-resource://file/session/'
@@ -1045,6 +1049,27 @@ function localDayForHour(hour, timezoneOffsetMinutes = 0) {
1045
1049
  return new Date(utcTime - offset * 60 * 1000).toISOString().slice(0, 10)
1046
1050
  }
1047
1051
 
1052
+ // 小时桶 → 本地「星期 × 小时」格键(打卡图)。与 localDayForHour 同一套时区折算:
1053
+ // 先按客户端偏移平移 UTC 时刻,再用 getUTC* 读取平移后的本地分量,不依赖宿主所在时区。
1054
+ // weekday 以周一为 0,与客户端热力日历的行序一致。
1055
+ function localWeekdayHourForHour(hour, timezoneOffsetMinutes = 0) {
1056
+ const offset = Number.isFinite(Number(timezoneOffsetMinutes)) ? Math.max(-840, Math.min(840, Number(timezoneOffsetMinutes))) : 0
1057
+ const utcTime = Date.parse(`${hour}:00:00.000Z`)
1058
+ if (!Number.isFinite(utcTime)) return undefined
1059
+ const shifted = new Date(utcTime - offset * 60 * 1000)
1060
+ return { weekday: (shifted.getUTCDay() + 6) % 7, hour: shifted.getUTCHours() }
1061
+ }
1062
+
1063
+ // 项目文件夹被删时只在「按项目」维度隐藏其入口;总量、日期桶与模型明细仍保留该段历史用量。
1064
+ function usageProjectMissing(project) {
1065
+ if (typeof project?.path !== 'string' || project.path.length === 0) return false
1066
+ try {
1067
+ return !existsSync(project.path)
1068
+ } catch (_) {
1069
+ return false
1070
+ }
1071
+ }
1072
+
1048
1073
  function emptyUsageTotals() {
1049
1074
  return { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
1050
1075
  }
@@ -1091,13 +1116,35 @@ function revisionKey(revision) {
1091
1116
  }
1092
1117
 
1093
1118
  function createUsageIndex() {
1094
- return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {} }
1119
+ return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions: {}, failedSessions: [] }
1120
+ }
1121
+
1122
+ // 版本迁移:折读口径变更时旧桶需要重折,但所有旧摘要必须保留,直到确认 live 且重折成功才替换。
1123
+ // 未标记 detached 的条目可能源文件已在升级前被删除(此前未标 detached),也可能源文件仍在但需要重折;
1124
+ // 绝不能在迁移时直接丢弃。我们给所有非 detached 条目打上显式的 rebuildPending 标记并跨重启保留:
1125
+ // 若后续刷新时发现源文件已删除,自动转为 detached 保留完整历史;
1126
+ // 若源文件仍在,全量重折成功后才替换旧摘要并清除标记,重折失败则继续保留旧摘要。
1127
+ function migrateUsageIndex(parsed) {
1128
+ const sessions = {}
1129
+ if (parsed?.sessions !== null && typeof parsed?.sessions === 'object') {
1130
+ for (const [id, session] of Object.entries(parsed.sessions)) {
1131
+ if (session !== null && typeof session === 'object') {
1132
+ const migrated = structuredClone(session)
1133
+ if (migrated.detached !== true) {
1134
+ migrated.rebuildPending = true
1135
+ }
1136
+ sessions[id] = migrated
1137
+ }
1138
+ }
1139
+ }
1140
+ return { version: USAGE_INDEX_VERSION, updatedAt: 0, sessions, failedSessions: [] }
1095
1141
  }
1096
1142
 
1097
1143
  async function loadUsageIndex(dshHome) {
1098
1144
  try {
1099
1145
  const parsed = JSON.parse(await readFile(join(dshHome, USAGE_INDEX_FILE), 'utf8'))
1100
- if (parsed?.version !== USAGE_INDEX_VERSION || typeof parsed.sessions !== 'object' || parsed.sessions === null) return createUsageIndex()
1146
+ if (typeof parsed?.sessions !== 'object' || parsed.sessions === null) return createUsageIndex()
1147
+ if (parsed.version !== USAGE_INDEX_VERSION) return migrateUsageIndex(parsed)
1101
1148
  return parsed
1102
1149
  } catch (error) {
1103
1150
  if (error?.code === 'ENOENT') return createUsageIndex()
@@ -1160,42 +1207,130 @@ function normalizeQuotaAllowedHosts(raw) {
1160
1207
  return out
1161
1208
  }
1162
1209
 
1210
+ // 重置卡到期串的两种「无时区」写法:纯日期与 datetime-local(面板录入格式)。
1211
+ // 两者都不带偏移,intl 语义是「用户墙上时间的这个点」,因此必须有客户端时区才能换算成绝对时刻。
1212
+ const RESET_CARD_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
1213
+ const RESET_CARD_NAIVE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/
1214
+
1215
+ /** 客户端时区偏移(分钟,JS getTimezoneOffset 语义)→ 合法范围整数;非法一律视为「未知」(undefined)。 */
1216
+ function quotaTimezoneOffset(value) {
1217
+ if (typeof value !== 'number' || !Number.isInteger(value) || Math.abs(value) > 840) return undefined
1218
+ return value
1219
+ }
1220
+
1221
+ function quotaTimeZone(value) {
1222
+ if (typeof value !== 'string' || value.length > 100) return undefined
1223
+ try { return new Intl.DateTimeFormat('en', { timeZone: value }).resolvedOptions().timeZone } catch (_) { return undefined }
1224
+ }
1225
+
1226
+ // 用目标日期的 IANA 时区规则解释存量墙上时间;不存在的 DST 跳跃时刻保留待人工修正。
1227
+ function resetCardWallTimeMs(raw, timeZone) {
1228
+ const wallText = raw.length === 10 ? `${raw}T23:59:59.999` : raw
1229
+ const wall = Date.parse(`${wallText}Z`)
1230
+ if (!Number.isFinite(wall)) return null
1231
+ const formatter = new Intl.DateTimeFormat('en-GB', {
1232
+ timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
1233
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
1234
+ })
1235
+ const wallAt = (at) => {
1236
+ const p = Object.fromEntries(formatter.formatToParts(new Date(at)).map(({ type, value }) => [type, value]))
1237
+ return Date.parse(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}Z`) + ((at % 1000) + 1000) % 1000
1238
+ }
1239
+ const candidates = []
1240
+ // 在目标墙上时刻两侧采样候选偏移,覆盖 DST 两档;反向映射相等才接受,避免跳跃日静默纠正。
1241
+ for (const delta of [-36, 0, 36]) {
1242
+ const sample = wall + delta * 3600000
1243
+ const candidate = wall - (wallAt(sample) - sample)
1244
+ if (wallAt(candidate) === wall) candidates.push(candidate)
1245
+ }
1246
+ return candidates.length === 0 ? null : Math.min(...candidates)
1247
+ }
1248
+
1163
1249
  /**
1164
1250
  * 重置卡到期时刻(毫秒):
1165
- * - 带时刻的 ISO 串(`2026-09-30T08:00` / 含秒、时区)→ Date.parse 的真实时刻;
1166
- * - 只到日的串(`2026-09-30`)→ **该自然日的 23:59:59.999**。裸日期被 Date.parse 当作当日
1167
- * 零点 UTC,会让「9-30 到期」的卡在 9-30 零点刚过就判过期,与用户「当天还有效」的直觉相反。
1251
+ * - 带偏移/时区的绝对串(`2026-09-30T08:00:00Z`、`+08:00`)→ Date.parse 的真实时刻,与时区无关;
1252
+ * - 只到日的串(`2026-09-30`)→ **该自然日在客户端时区里的 23:59:59.999**。裸日期被 Date.parse
1253
+ * 当作当日零点 UTC,会让「9-30 到期」的卡在 9-30 零点刚过就判过期,与「当天还有效」的直觉相反。
1254
+ * - datetime-local 串(`2026-09-30T08:00`)→ 该墙上时间在**客户端时区**里的绝对时刻。
1255
+ * 此前按宿主时区(容器 UTC)解释,与浏览器渲染各算一套:UTC+8 用户在界面上看到「已过期」,
1256
+ * 宿主却仍判未过期——既不自动移除,还继续当临期卡提示。
1257
+ * - 优先使用客户端 IANA 时区的目标日期规则;仅旧客户端缺时区名时兼容合法固定偏移。
1258
+ * - 无时区形态且没有合法时区信息 → null(宁可留着也不按宿主时区删卡/标过期)。
1168
1259
  * - 缺失/无法解析 → null(永不过期,只手动移除)。
1169
1260
  */
1170
- function resetCardExpiryMs(expiresAt) {
1261
+ function resetCardExpiryMs(expiresAt, timezoneOffsetMinutes, timeZone) {
1171
1262
  if (typeof expiresAt !== 'string') return null
1172
1263
  const raw = expiresAt.trim()
1173
1264
  if (raw === '') return null
1265
+ if (RESET_CARD_DATE_RE.test(raw) || RESET_CARD_NAIVE_RE.test(raw)) {
1266
+ // 显式提供的时区非法时不得降级成可能错误的偏移,更不能触发删除。
1267
+ if (timeZone !== undefined) {
1268
+ const zone = quotaTimeZone(timeZone)
1269
+ return zone === undefined ? null : resetCardWallTimeMs(raw, zone)
1270
+ }
1271
+ const offset = quotaTimezoneOffset(timezoneOffsetMinutes)
1272
+ if (offset === undefined) return null
1273
+ // 把「墙上时间」按 offset 平移成 UTC:getTimezoneOffset 是「本地→UTC 需要加的分钟数」,
1274
+ // 故 UTC = 墙上时间 + offset(UTC+8 → -480 → 减 8 小时)。
1275
+ const wall = Date.parse(raw.length === 10 ? `${raw}T00:00:00.000Z` : `${raw}Z`)
1276
+ if (!Number.isFinite(wall)) return null
1277
+ const endOfDay = raw.length === 10 ? 24 * 60 * 60 * 1000 - 1 : 0
1278
+ return wall + endOfDay + offset * 60 * 1000
1279
+ }
1280
+ // 非标准无偏移串不得落回宿主本地 Date.parse(例如空格代替 T)。
1281
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/i.test(raw)) return null
1174
1282
  const parsed = Date.parse(raw)
1175
- if (!Number.isFinite(parsed)) return null
1176
- // 纯日期形态(YYYY-MM-DD,无 T/时刻):顺延到当日末尾。
1177
- if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return parsed + 24 * 60 * 60 * 1000 - 1
1178
- return parsed
1283
+ return Number.isFinite(parsed) ? parsed : null
1179
1284
  }
1180
1285
 
1181
1286
  /** 已过期(到期时刻严格早于 now)的重置卡:宿主读配置时自动剔除。 */
1182
- function isResetCardExpired(card, now = Date.now()) {
1183
- const at = resetCardExpiryMs(card?.expiresAt)
1287
+ function isResetCardExpired(card, now = Date.now(), timezoneOffsetMinutes, timeZone) {
1288
+ const at = resetCardExpiryMs(card?.expiresAt, timezoneOffsetMinutes, timeZone)
1184
1289
  return at !== null && at < now
1185
1290
  }
1186
1291
 
1187
1292
  /**
1188
1293
  * 自动移除已过期的重置卡。读路径(refreshQuotaConfigCache)在加载后调用:
1189
1294
  * 有剔除才落盘,避免每次读都写文件。返回值同时告诉调用方「是否发生过变更」。
1295
+ * 无时区形态的卡必须带上客户端偏移才参与判定,否则会按宿主时区误删。
1190
1296
  */
1191
- function pruneExpiredResetCards(config, now = Date.now()) {
1297
+ function pruneExpiredResetCards(config, now = Date.now(), timezoneOffsetMinutes, timeZone) {
1192
1298
  if (!Array.isArray(config?.resetCards) || config.resetCards.length === 0) return false
1193
- const kept = config.resetCards.filter((card) => !isResetCardExpired(card, now))
1299
+ const kept = config.resetCards.filter((card) => !isResetCardExpired(card, now, timezoneOffsetMinutes, timeZone))
1194
1300
  if (kept.length === config.resetCards.length) return false
1195
1301
  config.resetCards = kept
1196
1302
  return true
1197
1303
  }
1198
1304
 
1305
+ /**
1306
+ * 把无时区的 datetime-local 到期串固化成绝对时刻(ISO,带 Z):一次迁移,之后任何时区都读得一致。
1307
+ * 纯日期串刻意保持原样——「9-30 到期」是本地日期承诺,改成绝对时刻会在界面上多出一个时间点,
1308
+ * 且它每次判定都用客户端时区的目标日期规则算当日末尾,无需固化。
1309
+ * 拿不到时区信息时原样返回,无法解析时同样原样返回(保留人工可核查的原文)。
1310
+ */
1311
+ function canonicalResetCardExpiresAt(expiresAt, timezoneOffsetMinutes, timeZone) {
1312
+ if (typeof expiresAt !== 'string') return expiresAt
1313
+ const raw = expiresAt.trim()
1314
+ if (!RESET_CARD_NAIVE_RE.test(raw)) return expiresAt
1315
+ const at = resetCardExpiryMs(raw, timezoneOffsetMinutes, timeZone)
1316
+ return at === null ? expiresAt : new Date(at).toISOString()
1317
+ }
1318
+
1319
+ /** 把配置里所有无时区 datetime 卡固化为绝对时刻。有改写才返回 true(调用方据此落盘)。 */
1320
+ function normalizeResetCardInstants(config, timezoneOffsetMinutes, timeZone) {
1321
+ if (!Array.isArray(config?.resetCards)) return false
1322
+ let changed = false
1323
+ for (const card of config.resetCards) {
1324
+ if (card === null || typeof card !== 'object') continue
1325
+ const next = canonicalResetCardExpiresAt(card.expiresAt, timezoneOffsetMinutes, timeZone)
1326
+ if (next !== card.expiresAt) {
1327
+ card.expiresAt = next
1328
+ changed = true
1329
+ }
1330
+ }
1331
+ return changed
1332
+ }
1333
+
1199
1334
  // 重置卡条目(v0.19 手填过渡方案:官方无 API Key 可查的端点):provider 必填,label/expiresAt 可选;
1200
1335
  // id 缺失时按原始位置合成稳定 id(老数据兼容),写入口生成的 id 原样保留。
1201
1336
  // v1.9.1 起另可带 account(CLIProxyAPI 多账号细分),缺省 = 整 provider 共享的卡,老数据零迁移。
@@ -1954,7 +2089,7 @@ function usageFailure(event) {
1954
2089
  }
1955
2090
 
1956
2091
  function recentErrorTime(time) {
1957
- return Number.isFinite(time) && time >= Date.now() - 24 * 60 * 60 * 1000
2092
+ return Number.isFinite(time) && time >= Date.now() - USAGE_ERROR_WINDOW_MS
1958
2093
  }
1959
2094
 
1960
2095
  function addUsageError(session, event, model) {
@@ -2145,14 +2280,21 @@ function usageFailedSessions(index) {
2145
2280
 
2146
2281
  function publicUsage(index, timezoneOffsetMinutes = 0) {
2147
2282
  const failedSessions = usageFailedSessions(index)
2148
- const indexedSessions = Object.keys(index.sessions).length
2149
- const result = { updatedAt: index.updatedAt, indexedSessions, successfulSessions: indexedSessions - failedSessions.filter((failure) => failure.stale).length, failedSessions, totals: emptyUsageTotals(), days: {}, errors: { models: [], tools: [] } }
2283
+ const allSessions = Object.values(index.sessions)
2284
+ // 保留条目仍计入总量与日期桶(它们就是历史用量),但在会话计数里单列,
2285
+ // 否则界面上的「已索引 N 个会话」会包含磁盘上已不存在的会话。
2286
+ const detachedSessions = allSessions.filter((session) => session.detached === true).length
2287
+ const indexedSessions = allSessions.length
2288
+ const result = { updatedAt: index.updatedAt, indexedSessions, detachedSessions, successfulSessions: indexedSessions - failedSessions.filter((failure) => failure.stale).length, failedSessions, totals: emptyUsageTotals(), days: {}, hours: {}, errors: { models: [], tools: [] } }
2150
2289
  const projects = new Map()
2290
+ const projectVisibility = new Map()
2151
2291
  const modelErrors = new Map()
2152
2292
  const toolErrors = new Map()
2153
- const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
2154
- for (const session of Object.values(index.sessions)) {
2293
+ const recentCutoff = Date.now() - USAGE_ERROR_WINDOW_MS
2294
+ for (const session of allSessions) {
2155
2295
  projects.set(session.project.id, session.project)
2296
+ // 项目文件夹被删只影响「按项目」筛选入口的可见性;用量本身照旧进总量与日期桶。
2297
+ if (!projectVisibility.has(session.project.id)) projectVisibility.set(session.project.id, !usageProjectMissing(session.project))
2156
2298
  pruneSessionErrors(session, recentCutoff)
2157
2299
  for (const error of Object.values(session.modelErrors || {})) {
2158
2300
  const count = error.recentTimes.length
@@ -2181,6 +2323,17 @@ function publicUsage(index, timezoneOffsetMinutes = 0) {
2181
2323
  projectBucket.models.set(model.id, modelBucket)
2182
2324
  }
2183
2325
  dayBucket.projects.set(session.project.id, projectBucket)
2326
+ // 打卡图(星期 × 小时):与 days 同为「已有原始数据的一次重排」,不新增持久化字段。
2327
+ // 只带 totals 与项目拆分(不带模型明细),实测体积约为带模型版本的 1/2。
2328
+ const slot = localWeekdayHourForHour(hour, timezoneOffsetMinutes)
2329
+ if (slot !== undefined) {
2330
+ const key = `${slot.weekday}-${slot.hour}`
2331
+ const hourBucket = result.hours[key] || (result.hours[key] = { weekday: slot.weekday, hour: slot.hour, totals: emptyUsageTotals(), projects: new Map() })
2332
+ addUsageTotals(hourBucket.totals, source.totals)
2333
+ const hourProject = hourBucket.projects.get(session.project.id) || { id: session.project.id, title: session.project.title, totals: emptyUsageTotals() }
2334
+ addUsageTotals(hourProject.totals, source.totals)
2335
+ hourBucket.projects.set(session.project.id, hourProject)
2336
+ }
2184
2337
  }
2185
2338
  }
2186
2339
  const errorSort = (a, b) => b.count - a.count || a.key.localeCompare(b.key) || a.projectId.localeCompare(b.projectId)
@@ -2197,6 +2350,13 @@ function publicUsage(index, timezoneOffsetMinutes = 0) {
2197
2350
  models: [...project.models.values()].map((model) => ({ ...model, totals: finishTotals(model.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
2198
2351
  })).sort((a, b) => a.title.localeCompare(b.title))
2199
2352
  }
2353
+ result.hours = Object.values(result.hours).map((bucket) => ({
2354
+ ...bucket,
2355
+ totals: finishTotals(bucket.totals),
2356
+ projects: [...bucket.projects.values()].map((project) => ({ ...project, totals: finishTotals(project.totals) })).sort((a, b) => a.id.localeCompare(b.id)),
2357
+ })).sort((a, b) => a.weekday - b.weekday || a.hour - b.hour)
2358
+ // missing 只对「项目文件夹已不存在」的条目为真,客户端据此隐藏项目分页按钮。
2359
+ result.projects = result.projects.map((project) => ({ ...project, missing: projectVisibility.get(project.id) === false }))
2200
2360
  return result
2201
2361
  }
2202
2362
 
@@ -2216,23 +2376,45 @@ async function refreshUsageIndex(ctx, dshHome, currentIndex) {
2216
2376
  const index = structuredClone(currentIndex)
2217
2377
  index.failedSessions = []
2218
2378
  const liveIds = new Set(snapshots.map((record) => String(record.header.id)))
2219
- for (const id of Object.keys(index.sessions)) if (!liveIds.has(id)) delete index.sessions[id]
2220
- const recentCutoff = Date.now() - 24 * 60 * 60 * 1000
2379
+ // 保留策略:会话被删(或项目文件夹被删)都不再扣减已统计的贡献——条目永久留在索引里,
2380
+ // 只打 detached 标记供界面区分,hours/lastSeq 等桶数据一字不动;会话回来时自动摘掉标记。
2381
+ // 这样总量与热力图始终是「索引建立以来的完整历史」,不再随删除缩水。
2382
+ const detachedAt = Date.now()
2383
+ for (const [id, session] of Object.entries(index.sessions)) {
2384
+ if (liveIds.has(id)) {
2385
+ if (session.detached === true) {
2386
+ delete session.detached
2387
+ delete session.detachedAt
2388
+ }
2389
+ continue
2390
+ }
2391
+ if (session.detached !== true) {
2392
+ session.detached = true
2393
+ session.detachedAt = detachedAt
2394
+ }
2395
+ if (session.rebuildPending) {
2396
+ delete session.rebuildPending
2397
+ }
2398
+ }
2399
+ const recentCutoff = Date.now() - USAGE_ERROR_WINDOW_MS
2221
2400
  for (const session of Object.values(index.sessions)) pruneSessionErrors(session, recentCutoff)
2222
2401
  for (const record of snapshots) {
2223
2402
  const id = String(record.header.id)
2224
2403
  const revision = revisionKey(record.revision)
2225
2404
  const previous = index.sessions[id]
2226
- if (previous?.revision === revision) continue
2227
- const fromSeq = usageReadStart(previous)
2405
+ const isRebuild = previous?.rebuildPending === true
2406
+ if (previous?.revision === revision && !isRebuild) continue
2407
+ const fromSeq = isRebuild ? 0 : usageReadStart(previous)
2228
2408
  let stage = 'read'
2229
2409
  try {
2230
2410
  const read = await persistenceReadSlice(persistence, record.header, fromSeq)
2231
2411
  const inheritedEventCount = inheritedEventCountFor(record, read)
2232
2412
  const events = usageReadEvents(read)
2233
2413
  stage = 'fold'
2234
- const next = foldUsageEvents(ctx, record, previous === undefined ? undefined : structuredClone(previous), events, inheritedEventCount)
2414
+ const foldBase = isRebuild ? undefined : (previous === undefined ? undefined : structuredClone(previous))
2415
+ const next = foldUsageEvents(ctx, record, foldBase, events, inheritedEventCount)
2235
2416
  next.revision = revision
2417
+ delete next.rebuildPending
2236
2418
  index.sessions[id] = next
2237
2419
  } catch (error) {
2238
2420
  index.failedSessions.push(usageSessionFailure(id, error, stage, previous !== undefined))
@@ -2607,7 +2789,7 @@ function runtimeEnvCheck(runtimeEnv) {
2607
2789
  return { id: 'runtime-env', status: 'info', detail: 'unknown' }
2608
2790
  }
2609
2791
 
2610
- async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
2792
+ async function collectDiagnostics(ctx, dshHome, runtimeEnv, options = {}) {
2611
2793
  const checks = []
2612
2794
  const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
2613
2795
 
@@ -2689,6 +2871,27 @@ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
2689
2871
  if (!compatReport.available) add('plugin-compat', 'info', 'unavailable')
2690
2872
  else checks.push(pluginCompatCheckItem(compatReport))
2691
2873
 
2874
+ // 使用统计索引(工作区实现,未发布):统计链路自身的健康度。单会话折读失败会让用量静默少算,
2875
+ // 此前只在模型统计页的全局警告里可见,诊断清单没有任何一项覆盖它。
2876
+ // 功能关闭时宿主根本不刷新索引(usage/usage-refresh 均受 modelUsage 门控),报「失败」
2877
+ // 只会是陈旧误导,故整项省略而不是降级成 info 行。
2878
+ // 追加在清单尾部:不动前五项 id 顺序契约(既有测试与客户端渲染顺序都依赖它)。
2879
+ if (options.usageEnabled === true) {
2880
+ let index = null
2881
+ try { index = await options.usageIndex } catch (_) { index = null }
2882
+ if (index === null || typeof index !== 'object') add('usage-index', 'info', 'unavailable')
2883
+ else {
2884
+ const failed = usageFailedSessions(index).length
2885
+ const indexed = index.sessions !== null && typeof index.sessions === 'object' ? Object.keys(index.sessions).length : 0
2886
+ const updatedAt = Number.isFinite(Number(index.updatedAt)) ? Number(index.updatedAt) : 0
2887
+ // updatedAt=0 = 从未建立索引(用户还没打开过模型统计页):不是故障,报 info。
2888
+ // 失败会话是「部分成功」语义(沿用 usage 页同款口径):warning 而非 error——统计仍可用,
2889
+ // 只是少算了这些会话,不点亮诊断标签 ⚠ 的 error 档。
2890
+ if (updatedAt === 0) add('usage-index', 'info', 'never')
2891
+ else add('usage-index', failed > 0 ? 'warning' : 'ok', `${failed}:${indexed}:${updatedAt}`)
2892
+ }
2893
+ }
2894
+
2692
2895
  let status = 'ok'
2693
2896
  // advisory 警告(手动启动环境的黄色提示)只做行内呈现:不把 overall 拉成 warning。
2694
2897
  if (checks.some((check) => check.status === 'error')) status = 'error'
@@ -2724,41 +2927,6 @@ async function collectHealth(ctx) {
2724
2927
  }
2725
2928
  }
2726
2929
 
2727
- /**
2728
- * 概览重置卡到期提示的窗口(设计定稿参数,只写这一处):到期时刻距今 **≤24 小时** 即提示。
2729
- * 用剩余时长而不是「当天」日历窗:纯日期卡本就顺延到当日 23:59:59.999、当天窗 ≈24h,
2730
- * 但精确时刻卡(datetime-local)在当天窗下只剩 0:00 到到期时刻的一截(可能仅几十分钟),
2731
- * 错过即被读路径自动清理、再无补看机会;剩余时长窗让两种录入方式都保证 ≥24h 曝光。
2732
- */
2733
- const QUOTA_RESET_CARD_EXPIRING_WINDOW_MS = 24 * 60 * 60 * 1000
2734
-
2735
- /**
2736
- * 剩余 ≤24h 到期的重置卡清单(概览 info 提示的数据源)。读路径的 prune 正常时不会剩下
2737
- * 过期卡,这里再防御性排除一次(at < now 与 isResetCardExpired 同口径)——宁可少提示,
2738
- * 绝不把已过期的卡当「即将到期」展示。返回 [{ provider, label }];无命中返回空数组。
2739
- */
2740
- async function collectResetCardsExpiringSoon(refreshQuotaConfigCache, now = Date.now()) {
2741
- let config
2742
- try {
2743
- config = await refreshQuotaConfigCache()
2744
- } catch (_) {
2745
- return []
2746
- }
2747
- const cards = Array.isArray(config?.resetCards) ? config.resetCards : []
2748
- if (cards.length === 0) return []
2749
- const horizon = now + QUOTA_RESET_CARD_EXPIRING_WINDOW_MS
2750
- const hits = []
2751
- for (const card of cards) {
2752
- const at = resetCardExpiryMs(card?.expiresAt)
2753
- if (at === null || at < now || at > horizon) continue
2754
- hits.push({
2755
- provider: typeof card.provider === 'string' ? card.provider : '',
2756
- label: typeof card.label === 'string' ? card.label : '',
2757
- })
2758
- }
2759
- return hits
2760
- }
2761
-
2762
2930
  function collectActiveWork(ctx) {
2763
2931
  const agentsService = ctx.get('agents')
2764
2932
  const jobsService = ctx.get('jobs')
@@ -4954,12 +5122,29 @@ function apply(ctx) {
4954
5122
  let quotaConfigLastCheckedAt = 0
4955
5123
  let quotaConfigMtimeMs = 0
4956
5124
  const quotaConfigPath = join(dshHome, QUOTA_CONFIG_FILE)
5125
+ // 最近一次请求上带的客户端时区偏移:无时区形态的重置卡到期串(纯日期 / datetime-local)
5126
+ // 必须按「用户墙上时间」解释,宿主容器时区(多为 UTC)不是用户的时区。额度/健康等读路径
5127
+ // 都带这个字段,记录最后一次即可;拿不到时宁可不判定(见 resetCardExpiryMs),也不按错时区删卡。
5128
+ let quotaClientOffset
5129
+ let quotaClientTimeZone
5130
+ const rememberQuotaClientOffset = (payload) => {
5131
+ if (payload?.timeZone !== undefined || payload?.timezoneOffsetMinutes !== undefined) {
5132
+ quotaClientOffset = quotaTimezoneOffset(payload?.timezoneOffsetMinutes)
5133
+ // 显式非法时区留给判定器拒绝;旧客户端只带偏移时不得沿用另一客户端的时区。
5134
+ quotaClientTimeZone = payload?.timeZone
5135
+ }
5136
+ return quotaClientOffset
5137
+ }
4957
5138
  // 过期重置卡自动移除:剔除内存快照里的过期条目,有变更才落盘(避免每次读都写文件)。
4958
5139
  // 读失败不影响本次结果——内存已剔除,下次读再试。TTL 快路径、TTL 到期后 mtime 未变、
4959
5140
  // 重新加载三条路都要过这关:mtime 未变就永远不会重新加载,缺了这一路,「停在概览页
4960
5141
  // 只靠 health 轮询」时运行期到点的卡在内存里清不掉。
4961
5142
  const pruneExpiredQuotaResetCards = async () => {
4962
- if (!pruneExpiredResetCards(quotaConfig)) return
5143
+ const offset = quotaClientOffset
5144
+ // 先固化无时区 datetime 卡(一次迁移,之后任何时区都读得一致),再按偏移剔除过期卡。
5145
+ const canonicalized = normalizeResetCardInstants(quotaConfig, offset, quotaClientTimeZone)
5146
+ const pruned = pruneExpiredResetCards(quotaConfig, Date.now(), offset, quotaClientTimeZone)
5147
+ if (!canonicalized && !pruned) return
4963
5148
  try {
4964
5149
  await saveQuotaConfig(dshHome, quotaConfig)
4965
5150
  quotaConfigMtimeMs = (await stat(quotaConfigPath)).mtimeMs
@@ -5255,16 +5440,16 @@ function apply(ctx) {
5255
5440
  } },
5256
5441
  'health': { handle: async (payload, rpcEndpoint) => {
5257
5442
  const value = await collectHealth(ctx)
5258
- // 剩余 24 小时内到期的重置卡(额度功能开启时才算):概览「可行动项」的 info 提示数据源。
5259
- // 走 health 而非 quota,是因为概览不打开额度页也该看到提醒,而 health 是面板的常驻轮询。
5260
- if (featureEnabled('quotaLookup')) {
5261
- value.resetCardsExpiringSoon = await collectResetCardsExpiringSoon(refreshQuotaConfigCache)
5262
- }
5443
+ // 概览不再提示重置卡到期(用户裁决整条去掉)。但 health 仍是面板最常打的读路径,
5444
+ // 借它推进「过期卡自动移除」:额度功能开启时读一次配置(内部按客户端时区固化+剔除)。
5445
+ if (featureEnabled('quotaLookup')) await refreshQuotaConfigCache()
5263
5446
  return { ok: true, value }
5264
5447
 
5265
5448
  } },
5266
5449
  'diagnostics': { feature: 'healthDiagnostics', handle: async (payload, rpcEndpoint) => {
5267
- return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
5450
+ // usageIndex 传的是闭包里的同一个 promise(不在检查里重读磁盘 JSON——索引可达数百 KB);
5451
+ // modelUsage 关闭时整个 usage-index 检查项缺席,避免用陈旧索引报假故障。
5452
+ return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv, { usageEnabled: featureEnabled('modelUsage'), usageIndex: usageIndexPromise }) }
5268
5453
 
5269
5454
  } },
5270
5455
  'plugin-restart': { feature: 'healthDiagnostics', audit: true, handle: async (payload, rpcEndpoint) => {
@@ -5410,12 +5595,17 @@ function apply(ctx) {
5410
5595
  coreRoutes,
5411
5596
  createSkillsRoutes({ ctx, describeJobs, dshHome, makeDescribeJobLogger, registerSkillCall, serializeSkillsIndexWrite, skillFileCache, skillsActiveControllers, skillsBatchRef, skillsIndexRef, SKILL_DESCRIPTION_MAX_CHARS, SKILL_USAGE_MAX_CHARS, bodyHashOf, describeSkillDraft, evaluateSkillFile, fixLegacySkillInvocationKeys, listSkillModels, locateSkillFrontmatter, mutateSkillEntryById, name, normalizeSkillDescribeLang, publicSkillEntry, rpcTechnicalFailure, sanitizeSkillDraftText, scanSkillEntries, selectSkillBatchCandidates, setSkillInvocationKey }),
5412
5597
  createSessionsRoutes({ ctx, dshHome, sessionBytesCache, sessionDeletePlans, sessionTitleCache, sessionTitlesReady, sessionViewCache, SESSIONS_BYTES_MAX_IDS, SESSIONS_DELETE_PLAN_TTL_MS, SESSIONS_VIEW_PAGE_SIZE, listSessionsForManage, loadDeletedSessions, name, resolveSessionBytesForIds, resolveSessionForDelete, rpcFailure, rpcTechnicalFailure, saveDeletedSessions, searchSessionsContent, sessionExists, sessionIsLive, viewSessionPage }),
5413
- createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
5598
+ createQuotaRoutes({ ctx, kickQuotaRefresh, quotaThrottle, refreshQuotaConfigCache, serializeQuotaConfigWrite, MAX_QUOTA_PROVIDER_NAME, MAX_QUOTA_RESET_CARDS, MAX_QUOTA_RESET_CARDS_PER_PROVIDER, MAX_QUOTA_RESET_CARD_ACCOUNT, canonicalResetCardExpiresAt, QUOTA_ADAPTER_BY_KIND, name, quotaCredentialConfigured, quotaCredentialEndpoint, quotaCredentialHintNames, readQuotaProfiles, resolveQuotaKind, rpcTechnicalFailure }),
5414
5599
  createSubagentRoutes({ ctx, dispatchRing, serializeSubagentRouteWrite, subagentRouteRef, subagentRouteLoadPromise, subagentSeamRef, MAX_SUBAGENT_ROUTE_FIELD, SUBAGENT_ROUTE_FALLBACK_MAX, SUBAGENT_ROUTE_MODES, listSubagentDispatches, listSubagentModels, rpcTechnicalFailure }),
5415
5600
  createBackupRoutes({ ctx, backupIntegrity, backupProgress, clearBackupProgress, downloadTokens, dshHome, setBackupProgress, withBackupLock, createBackup, deleteBackup, exportBackup, formatBackupTimestamp, importBackup, listBackups, name, rpcFailure }),
5416
5601
  ])
5417
5602
 
5418
- const dispatchRpc = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
5603
+ const dispatchEndpoint = createRpcDispatcher({ endpoints: rpcEndpoints, featureEnabled, logger: ctx.logger })
5604
+ // 只接受额度时区端点的上下文;usage 的固定偏移不能覆盖额度的 IANA 时区规则。
5605
+ const dispatchRpc = (endpoint, payload) => {
5606
+ if (endpoint === 'quota' || endpoint === 'quota-reset-card' || endpoint === 'health') rememberQuotaClientOffset(payload)
5607
+ return dispatchEndpoint(endpoint, payload)
5608
+ }
5419
5609
  try {
5420
5610
  ensureConnectionRpcWebServerSeam(ctx)
5421
5611
  ctx.connection.rpc.handle('/dsh-service', dispatchRpc, { authority: 'loopback' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
package/quota-routes.js CHANGED
@@ -17,6 +17,7 @@ export function createQuotaRoutes({
17
17
  MAX_QUOTA_RESET_CARDS,
18
18
  MAX_QUOTA_RESET_CARDS_PER_PROVIDER,
19
19
  MAX_QUOTA_RESET_CARD_ACCOUNT,
20
+ canonicalResetCardExpiresAt,
20
21
  QUOTA_ADAPTER_BY_KIND,
21
22
  name,
22
23
  quotaCredentialConfigured,
@@ -207,7 +208,15 @@ export function createQuotaRoutes({
207
208
  const card = { id: `rc-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`, provider: providerName }
208
209
  if (accountName !== '') card.account = accountName
209
210
  if (typeof payload?.label === 'string' && payload.label.trim() !== '') card.label = payload.label.trim().slice(0, 40)
210
- if (typeof payload?.expiresAt === 'string' && payload.expiresAt.trim() !== '') card.expiresAt = payload.expiresAt.trim().slice(0, 32)
211
+ // 新客户端发送按目标日期解析的 ISO;存量客户端裸串按请求时区固化,不能依赖宿主时区。
212
+ // 缺少时区信息或无法解析时保留原文,不做无依据的改写。
213
+ if (typeof payload?.expiresAt === 'string' && payload.expiresAt.trim() !== '') {
214
+ card.expiresAt = String(canonicalResetCardExpiresAt(
215
+ payload.expiresAt.trim(),
216
+ payload?.timezoneOffsetMinutes,
217
+ payload?.timeZone,
218
+ )).slice(0, 32)
219
+ }
211
220
  config.resetCards = [...allCards, card]
212
221
  }
213
222
  return { value: { ok: true } }