modelstage 0.0.0-stage → 0.5.24

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 (286) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +101 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +58 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +647 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +334 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/web/README.md +193 -0
  126. package/src/web/app/anim-list.js +222 -0
  127. package/src/web/app/background-solids.js +150 -0
  128. package/src/web/app/backgrounds.js +152 -0
  129. package/src/web/app/camera-log.js +28 -0
  130. package/src/web/app/context.js +72 -0
  131. package/src/web/app/dir-picker.js +242 -0
  132. package/src/web/app/entry-report.js +71 -0
  133. package/src/web/app/memory-bars.js +91 -0
  134. package/src/web/app/memory-budget.js +228 -0
  135. package/src/web/app/memory-guard.js +204 -0
  136. package/src/web/app/model-actions.js +682 -0
  137. package/src/web/app/renderer-setting.js +70 -0
  138. package/src/web/app/root-manager.js +254 -0
  139. package/src/web/app/status.js +138 -0
  140. package/src/web/app/topbar-actions.js +357 -0
  141. package/src/web/app/transport-actions.js +297 -0
  142. package/src/web/app/ui-state.js +559 -0
  143. package/src/web/app/url-params.js +171 -0
  144. package/src/web/app/view-settings.js +186 -0
  145. package/src/web/components.css +465 -0
  146. package/src/web/debug/diag-hud.js +95 -0
  147. package/src/web/debug/drag.js +369 -0
  148. package/src/web/debug/log.js +320 -0
  149. package/src/web/debug/model-cmp.js +292 -0
  150. package/src/web/debug/panel.css +474 -0
  151. package/src/web/debug/panel.js +712 -0
  152. package/src/web/debug/race-probe.js +82 -0
  153. package/src/web/debug/upload.js +130 -0
  154. package/src/web/engine/adapters/atlas.js +47 -0
  155. package/src/web/engine/adapters/index.js +22 -0
  156. package/src/web/engine/adapters/renderer.js +223 -0
  157. package/src/web/engine/adapters/skeleton.js +105 -0
  158. package/src/web/engine/adapters/texture.js +64 -0
  159. package/src/web/engine/animation.js +181 -0
  160. package/src/web/engine/appearance.js +136 -0
  161. package/src/web/engine/camera-adapter.js +212 -0
  162. package/src/web/engine/effect-chain.js +138 -0
  163. package/src/web/engine/engine-state.js +120 -0
  164. package/src/web/engine/filters.js +224 -0
  165. package/src/web/engine/fit.js +197 -0
  166. package/src/web/engine/frame-gate.js +33 -0
  167. package/src/web/engine/gl-driver.js +56 -0
  168. package/src/web/engine/gl.js +77 -0
  169. package/src/web/engine/index.js +20 -0
  170. package/src/web/engine/loader.js +55 -0
  171. package/src/web/engine/model-info.js +26 -0
  172. package/src/web/engine/pipeline.js +200 -0
  173. package/src/web/engine/player.js +427 -0
  174. package/src/web/engine/pma/blend.js +76 -0
  175. package/src/web/engine/pma/detect.js +118 -0
  176. package/src/web/engine/pma/index.js +8 -0
  177. package/src/web/engine/pma/resolve.js +112 -0
  178. package/src/web/engine/pma/upload.js +127 -0
  179. package/src/web/engine/pma.js +5 -0
  180. package/src/web/engine/render-frame.js +93 -0
  181. package/src/web/engine/runtime-capabilities.js +209 -0
  182. package/src/web/engine/runtime.js +37 -0
  183. package/src/web/engine/skins.js +165 -0
  184. package/src/web/engine/textures.js +174 -0
  185. package/src/web/index.html +115 -0
  186. package/src/web/infra/api.js +531 -0
  187. package/src/web/infra/diag.js +509 -0
  188. package/src/web/infra/document.js +269 -0
  189. package/src/web/infra/dom.js +31 -0
  190. package/src/web/infra/events.mjs +27 -0
  191. package/src/web/infra/log.js +153 -0
  192. package/src/web/infra/mem-stats.js +112 -0
  193. package/src/web/infra/plugin-host-registration.mjs +285 -0
  194. package/src/web/infra/plugin-host-report.mjs +32 -0
  195. package/src/web/infra/plugin-host-unload.mjs +79 -0
  196. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  197. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  198. package/src/web/infra/plugin-host.js +628 -0
  199. package/src/web/infra/ready.js +461 -0
  200. package/src/web/infra/schedule.js +51 -0
  201. package/src/web/infra/shortcuts.js +129 -0
  202. package/src/web/infra/slot-registry.js +402 -0
  203. package/src/web/infra/vfs.js +247 -0
  204. package/src/web/live2d/bridge.js +400 -0
  205. package/src/web/live2d/model.js +321 -0
  206. package/src/web/live2d/overlay.js +142 -0
  207. package/src/web/live2d/pixi-app.js +111 -0
  208. package/src/web/live2d/playback-probe.js +92 -0
  209. package/src/web/live2d/probe-geometry.js +329 -0
  210. package/src/web/live2d/probe.js +119 -0
  211. package/src/web/live2d/release.js +66 -0
  212. package/src/web/live2d/runtime-adapter.js +289 -0
  213. package/src/web/live2d/stage-ctx.js +152 -0
  214. package/src/web/live2d/stage.js +204 -0
  215. package/src/web/live2d/vfs-settings.js +174 -0
  216. package/src/web/live2d/view.js +89 -0
  217. package/src/web/main.js +629 -0
  218. package/src/web/panel/background-list.js +373 -0
  219. package/src/web/panel/bg-source.js +291 -0
  220. package/src/web/panel/color-picker.css +199 -0
  221. package/src/web/panel/color-picker.js +278 -0
  222. package/src/web/panel/dialog.css +116 -0
  223. package/src/web/panel/dialog.js +173 -0
  224. package/src/web/panel/dom.js +35 -0
  225. package/src/web/panel/icons.js +58 -0
  226. package/src/web/panel/memory-bar.js +195 -0
  227. package/src/web/panel/panel-settings.js +1263 -0
  228. package/src/web/panel/panel.css +1304 -0
  229. package/src/web/panel/render-choice.js +104 -0
  230. package/src/web/panel/schema.js +784 -0
  231. package/src/web/panel/settings-io.js +291 -0
  232. package/src/web/panel/shell-controls.js +274 -0
  233. package/src/web/panel/slider.js +210 -0
  234. package/src/web/panel/toolbar-schema.js +111 -0
  235. package/src/web/panel/toolbar.js +195 -0
  236. package/src/web/panel/transport-picker.js +375 -0
  237. package/src/web/panel/transport.js +321 -0
  238. package/src/web/panel/view.js +553 -0
  239. package/src/web/plugin/unpack-worker.js +185 -0
  240. package/src/web/render/camera/constants.js +60 -0
  241. package/src/web/render/camera/index.js +123 -0
  242. package/src/web/render/camera/spine.js +124 -0
  243. package/src/web/render/capture.js +30 -0
  244. package/src/web/render/interaction.js +527 -0
  245. package/src/web/render/live2d.js +627 -0
  246. package/src/web/render/playback.js +47 -0
  247. package/src/web/render/reset-view.js +29 -0
  248. package/src/web/render/spine.js +386 -0
  249. package/src/web/render/surface-switch.js +176 -0
  250. package/src/web/render/surface.js +571 -0
  251. package/src/web/render/surfaces.js +70 -0
  252. package/src/web/roster/filter.js +31 -0
  253. package/src/web/roster/list.js +276 -0
  254. package/src/web/roster/persist.js +52 -0
  255. package/src/web/roster/reveal.js +77 -0
  256. package/src/web/roster/rows.js +248 -0
  257. package/src/web/roster/snapshot.js +37 -0
  258. package/src/web/roster/view.js +670 -0
  259. package/src/web/spine/canvas-api-groups.js +267 -0
  260. package/src/web/spine/canvas-api.js +97 -0
  261. package/src/web/spine/canvas-document.js +80 -0
  262. package/src/web/spine/canvas-engine.js +193 -0
  263. package/src/web/spine/canvas-entry.js +28 -0
  264. package/src/web/spine/canvas-pixels.js +36 -0
  265. package/src/web/spine/canvas-runtime.js +127 -0
  266. package/src/web/spine/canvas-view.js +108 -0
  267. package/src/web/spine/canvas.js +7 -0
  268. package/src/web/spine/runtime-hint.js +73 -0
  269. package/src/web/spine/runtimes.js +212 -0
  270. package/src/web/style.css +493 -0
  271. package/tool/lib/bundle-probe-node.mjs +121 -0
  272. package/tool/lib/check-perms.mjs +114 -0
  273. package/tool/lib/contract-checks.mjs +582 -0
  274. package/tool/lib/cubism-core.mjs +122 -0
  275. package/tool/lib/export-bundle.mjs +328 -0
  276. package/tool/lib/paths.mjs +20 -0
  277. package/tool/lib/runtime-guides.mjs +65 -0
  278. package/tool/lib/runtime-package.mjs +403 -0
  279. package/tool/lib/spine-meta.mjs +227 -0
  280. package/tool/lib/spine-motions.mjs +104 -0
  281. package/tool/lib/spine-runtime.mjs +131 -0
  282. package/tool/lib/test-lock.mjs +178 -0
  283. package/tool/lib/test-report.mjs +133 -0
  284. package/tool/parse-jsonc.mjs +86 -0
  285. package/tool/resolve-live2d-runtimes.mjs +276 -0
  286. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,270 @@
1
+ // http/background-solids.mjs —— 纯色背景预设的数据层(读/写/校验/自愈)
2
+ //
3
+ // 为什么校验放这里而不是路由层:
4
+ // ① 前端会**原样显示**这些错误文案 ⇒ 文案与规则必须在同一处定义,不能两套;
5
+ // ② 校验放数据层后,11 条测试可以全部直接打数据层,**不用起 HTTP、不抢端口**;
6
+ // ③ 路由层保持"读 body → 转发 → 回包"的薄形态(与 http/backgrounds.mjs 的既有做法一致)。
7
+ //
8
+ // 落盘:`data/backgrounds/solids.json`(运行时数据;.gitignore 已忽略 /data/backgrounds/)。
9
+ // 原子写 + fsync + 串行队列全部来自 http/atomic-json.mjs。
10
+ //
11
+ // 并发约定:**单实例内串行一致;多实例共享同一 dataDir 不保证**(无文件锁)。
12
+ // 见 docs/契约/数据与接口.md §四。
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { createLogger } from '../core/log.mjs';
17
+ import { backupFile, createSerialQueue, ensureDir, writeJsonAtomic } from './atomic-json.mjs';
18
+
19
+ const log = createLogger('solid-bg');
20
+
21
+ /** 数据文件名(在 data/backgrounds/ 下) */
22
+ const FILE_NAME = 'solids.json';
23
+
24
+ /** reset 前的存档前缀 */
25
+ const BAK_PREFIX = `solids.bak-`;
26
+
27
+ /** 损坏文件留证前缀 */
28
+ const CORRUPT_PREFIX = `solids.corrupt-`;
29
+
30
+ /** 备份保留份数(bak 与 corrupt 各自轮转,互不挤占) */
31
+ const KEEP_BACKUPS = 5;
32
+
33
+ /** 数量上限:防止被脚本刷爆数据文件 */
34
+ const MAX_ITEMS = 50;
35
+
36
+ /** 名称长度上限(按字符数算,中文一个字算一个) */
37
+ const MAX_NAME_LEN = 30;
38
+
39
+ /** 颜色格式:只接受 6 位十六进制;3 位缩写不接受(避免前端要处理两种形态) */
40
+ const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
41
+
42
+ /**
43
+ * 默认 5 个(原色板;绿幕/蓝幕是常用项)。
44
+ * id 用**确定性 slug**:reset 是幂等操作,产出变化的 id 反直觉;确定性 id 也让测试能直接断言。
45
+ */
46
+ export const DEFAULT_SOLIDS = [
47
+ { id: 'solid-default-carbon', name: '炭黑', color: '#1a1a1c' },
48
+ { id: 'solid-default-warm-dark', name: '暖暗', color: '#2a2520' },
49
+ { id: 'solid-default-bone-white', name: '骨白', color: '#d6d0c4' },
50
+ { id: 'solid-default-green-screen', name: '绿幕', color: '#3d8f5c' },
51
+ { id: 'solid-default-blue-screen', name: '蓝幕', color: '#2f6aa3' },
52
+ ];
53
+
54
+ /** 错误文案(前端原样展示,不要写成 "invalid color" 这种给程序看的话) */
55
+ export const MESSAGES = {
56
+ badColor: '颜色格式不对,应为 #RRGGBB 形式(如 #3d8f5c)',
57
+ emptyName: '名称不能为空',
58
+ longName: (n) => `名称太长(最多 ${MAX_NAME_LEN} 个字,当前 ${n} 个)`,
59
+ dupName: (n) => `已经有同名颜色了("${n}"),换个名字`,
60
+ tooMany: `颜色数量已达上限(最多 ${MAX_ITEMS} 个),先删几个再加`,
61
+ notFound: (id) => `找不到这个颜色(id=${id}),可能已被删除`,
62
+ ioFail: '读取颜色数据失败,本次操作已取消(不会覆盖你的数据)',
63
+ };
64
+
65
+ /** 生成一个新 id(时间 + 随机后缀,够用且无依赖) */
66
+ function newId() {
67
+ const t = Date.now().toString(36);
68
+ const r = Math.random().toString(36).slice(2, 8);
69
+ return `solid-${t}-${r}`;
70
+ }
71
+
72
+ /** 归一化颜色:统一小写输出 */
73
+ function normColor(c) {
74
+ return String(c || '').trim().toLowerCase();
75
+ }
76
+
77
+ /** 归一化名称:去首尾空格(中文里 "炭黑" 与 "炭黑 " 才算重复) */
78
+ function normName(n) {
79
+ return String(n == null ? '' : n).trim();
80
+ }
81
+
82
+ /** 校验颜色(返回 null 表示通过,否则返回错误文案) */
83
+ export function checkColor(color) {
84
+ return COLOR_RE.test(String(color || '')) ? null : MESSAGES.badColor;
85
+ }
86
+
87
+ /** 校验名称(返回 null 表示通过,否则返回错误文案) */
88
+ export function checkName(name) {
89
+ const n = normName(name);
90
+ if (!n) return MESSAGES.emptyName;
91
+ if ([...n].length > MAX_NAME_LEN) return MESSAGES.longName([...n].length);
92
+ return null;
93
+ }
94
+
95
+ /** 名称是否已被占用(大小写敏感;excludeId 用于改名时排除自身) */
96
+ function nameTaken(items, name, excludeId) {
97
+ return items.some((it) => it.id !== excludeId && it.name === name);
98
+ }
99
+
100
+ /** 单条记录的合法形态(缺字段/形态不对 ⇒ 丢弃该条) */
101
+ function sanitizeItem(it) {
102
+ if (!it || typeof it !== 'object') return null;
103
+ if (typeof it.id !== 'string' || !it.id) return null;
104
+ if (typeof it.name !== 'string' || !normName(it.name)) return null;
105
+ if (typeof it.color !== 'string' || !COLOR_RE.test(it.color)) return null;
106
+ return {
107
+ id: it.id,
108
+ name: normName(it.name),
109
+ color: normColor(it.color),
110
+ createdAt: Number.isFinite(Number(it.createdAt)) ? Number(it.createdAt) : Date.now(),
111
+ };
112
+ }
113
+
114
+ /** 造一份默认列表(每次初始化/reset 都重新盖 createdAt) */
115
+ function makeDefaults(at = Date.now()) {
116
+ return DEFAULT_SOLIDS.map((d) => ({ id: d.id, name: d.name, color: d.color, createdAt: at }));
117
+ }
118
+
119
+ /** 组装内部上下文(目录、文件、串行队列) */
120
+ function makeCtx(paths) {
121
+ const dir = path.join(paths.dataDir, 'backgrounds');
122
+ return { dir, file: path.join(dir, FILE_NAME), enqueue: createSerialQueue() };
123
+ }
124
+
125
+ /** reset 存档:把当前文件复制成 solids.bak-<ts>.json(文件不存在返回 null) */
126
+ function backupCurrent(ctx, at) {
127
+ if (!fs.existsSync(ctx.file)) return null;
128
+ return backupFile(ctx.file, ctx.dir, BAK_PREFIX, at, KEEP_BACKUPS);
129
+ }
130
+
131
+ /** 原子写盘(tmp → fsync → rename) */
132
+ function saveItems(ctx, items) {
133
+ ensureDir(ctx.dir);
134
+ writeJsonAtomic(ctx.file, { version: 1, items });
135
+ }
136
+
137
+ /**
138
+ * 读磁盘并返回条目。**"读失败"与"没有数据"必须分开**(契约 §7.1.1 写入凭证):
139
+ * ENOENT ⇒ 首次初始化写默认;其它 IO 错误 ⇒ 返回 error,调用方绝不能拿默认值覆盖;
140
+ * 内容坏了 ⇒ 先留证再重建。
141
+ * @returns {{items:object[]}|{error:{status:number, body:object}}}
142
+ */
143
+ function loadItems(ctx) {
144
+ let raw = null;
145
+ try {
146
+ raw = fs.readFileSync(ctx.file, 'utf8');
147
+ } catch (e) {
148
+ if (e && e.code === 'ENOENT') {
149
+ const items = makeDefaults();
150
+ saveItems(ctx, items);
151
+ log.info('初始化默认纯色预设', { file: ctx.file, count: items.length });
152
+ return { items };
153
+ }
154
+ log.error('读取纯色预设失败(不覆盖原数据)', { file: ctx.file, code: e && e.code, error: String((e && e.message) || e) });
155
+ return { error: { status: 500, body: { error: MESSAGES.ioFail } } };
156
+ }
157
+
158
+ let parsed = null;
159
+ try { parsed = JSON.parse(raw); } catch { parsed = null; }
160
+ const arr = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.items) ? parsed.items : null);
161
+ if (!arr) {
162
+ const bak = backupFile(ctx.file, ctx.dir, CORRUPT_PREFIX, Date.now(), KEEP_BACKUPS);
163
+ const items = makeDefaults();
164
+ saveItems(ctx, items);
165
+ log.warn('纯色预设内容损坏,已备份并重建默认', { file: ctx.file, backup: bak, bytes: raw.length });
166
+ return { items };
167
+ }
168
+ return { items: pickValid(arr, ctx.file) };
169
+ }
170
+
171
+ /** 过滤非法条目(只丢该条,其余保留) */
172
+ function pickValid(arr, file) {
173
+ const items = [];
174
+ let dropped = 0;
175
+ for (const it of arr) {
176
+ const ok = sanitizeItem(it);
177
+ if (ok) items.push(ok);
178
+ else dropped += 1;
179
+ }
180
+ if (dropped) log.warn('纯色预设里有非法条目,已丢弃', { file, dropped, kept: items.length });
181
+ return items;
182
+ }
183
+
184
+ /** 串行队列内的"读 → 改 → 写";fn 返回 {items, extra} 或 {error} */
185
+ function mutate(ctx, fn) {
186
+ return ctx.enqueue(() => {
187
+ const loaded = loadItems(ctx);
188
+ if (loaded.error) return loaded.error;
189
+ const out = fn(loaded.items);
190
+ if (out.error) return out.error;
191
+ saveItems(ctx, out.items);
192
+ return { items: out.items, extra: out.extra };
193
+ });
194
+ }
195
+
196
+ /** GET 列表 */
197
+ function apiList(ctx) {
198
+ const loaded = loadItems(ctx);
199
+ if (loaded.error) return loaded.error;
200
+ return { status: 200, body: { items: loaded.items } };
201
+ }
202
+
203
+ /** 新增:校验 → 查重 → 追加 */
204
+ function apiAdd(ctx, input) {
205
+ return mutate(ctx, (items) => {
206
+ const name = normName(input.name);
207
+ const nameErr = checkName(name);
208
+ if (nameErr) return { error: { status: 400, body: { error: nameErr } } };
209
+ const colorErr = checkColor(input.color);
210
+ if (colorErr) return { error: { status: 400, body: { error: colorErr } } };
211
+ if (items.length >= MAX_ITEMS) return { error: { status: 400, body: { error: MESSAGES.tooMany } } };
212
+ if (nameTaken(items, name, null)) return { error: { status: 400, body: { error: MESSAGES.dupName(name) } } };
213
+ const item = { id: newId(), name, color: normColor(input.color), createdAt: Date.now() };
214
+ return { items: [...items, item], extra: { item } };
215
+ }).then((r) => (r.items ? { status: 200, body: { item: r.extra.item } } : r));
216
+ }
217
+
218
+ /** 改名:id/color/createdAt 不变;同名查重(排除自身) */
219
+ function apiRename(ctx, input) {
220
+ return mutate(ctx, (items) => {
221
+ const id = String(input.id || '');
222
+ const idx = items.findIndex((it) => it.id === id);
223
+ if (idx < 0) return { error: { status: 404, body: { error: MESSAGES.notFound(id) } } };
224
+ const name = normName(input.name);
225
+ const nameErr = checkName(name);
226
+ if (nameErr) return { error: { status: 400, body: { error: nameErr } } };
227
+ if (nameTaken(items, name, id)) return { error: { status: 400, body: { error: MESSAGES.dupName(name) } } };
228
+ const next = items.slice();
229
+ next[idx] = { ...next[idx], name };
230
+ return { items: next, extra: { item: next[idx] } };
231
+ }).then((r) => (r.items ? { status: 200, body: { item: r.extra.item } } : r));
232
+ }
233
+
234
+ /** 删除 */
235
+ function apiRemove(ctx, input) {
236
+ return mutate(ctx, (items) => {
237
+ const id = String(input.id || '');
238
+ if (!items.some((it) => it.id === id)) return { error: { status: 404, body: { error: MESSAGES.notFound(id) } } };
239
+ return { items: items.filter((it) => it.id !== id), extra: { id } };
240
+ }).then((r) => (r.items ? { status: 200, body: { removed: true, id: r.extra.id } } : r));
241
+ }
242
+
243
+ /** 重置:先备份当前,再重建默认 5 个 */
244
+ function apiReset(ctx) {
245
+ return ctx.enqueue(() => {
246
+ const at = Date.now();
247
+ const bak = backupCurrent(ctx, at); // 备份失败不阻断重置(最多丢一次历史,不丢当前数据)
248
+ const items = makeDefaults(at);
249
+ saveItems(ctx, items);
250
+ log.info('纯色预设已重置为默认', { file: ctx.file, backup: bak, count: items.length });
251
+ return { status: 200, body: { items } };
252
+ });
253
+ }
254
+
255
+ /**
256
+ * 建立纯色预设存储句柄(路由层只跟这个对象打交道)。
257
+ * @param {{dataDir:string}} paths core/paths.mjs 产出的路径集合
258
+ */
259
+ export function createBackgroundSolids(paths) {
260
+ const ctx = makeCtx(paths);
261
+ return {
262
+ dir: ctx.dir,
263
+ file: ctx.file,
264
+ list: () => apiList(ctx),
265
+ add: (input = {}) => apiAdd(ctx, input),
266
+ rename: (input = {}) => apiRename(ctx, input),
267
+ remove: (input = {}) => apiRemove(ctx, input),
268
+ reset: () => apiReset(ctx),
269
+ };
270
+ }
@@ -0,0 +1,388 @@
1
+ // http/backgrounds.mjs —— 舞台「自定义背景图」的存储与五个接口
2
+ //
3
+ // 为什么要服务端存:背景图要跟"换机/备份"一起走、要在刷新后仍在、要能被重命名与删除。
4
+ // 只写 localStorage 的 dataURL 是假上传(换浏览器就没了、无法列表管理、单张 4MB 写爆存储),
5
+ // 所以这里落盘到 data/backgrounds/(运行时数据目录,与 data/export/ 同类,不入库)。
6
+ //
7
+ // 五个接口(路由在 serve.mjs 里挂,这里只做数据与校验):
8
+ // GET /api/backgrounds 列表
9
+ // POST /api/backgrounds 上传(原始二进制 + x-filename 头)
10
+ // POST /api/backgrounds/rename 重命名(只改显示名,id/file 不变)
11
+ // POST /api/backgrounds/delete 删除(文件进 .trash/,索引同步移除)
12
+ // GET /api/backgrounds/file/<id> 出图(id 只查索引,绝不拼路径)
13
+ //
14
+ // 三条硬约束(本机无 GPU、内存小,且 URL 里的 id 是用户可控输入):
15
+ // 1. 上传边读边计数,超限立刻中断——不允许"先全读进内存再判断"
16
+ // 2. 出图只认索引里的 id,索引没有就 404;文件路径永远由索引里的 file 字段拼出并校验前缀
17
+ // 3. index.json 走 .tmp + rename 原子写,且用串行队列避免并发写互相覆盖
18
+
19
+ import fs from 'node:fs';
20
+ import { createSerialQueue, writeFileAtomic, writeJsonAtomic } from './atomic-json.mjs';
21
+ import path from 'node:path';
22
+ import crypto from 'node:crypto';
23
+
24
+ /** 单张背景图上限:5MB(够一张 4K 静图;再大让用户自己压,避免软渲染下贴图上传/解码过慢) */
25
+ export const MAX_BG_BYTES = 5 * 1024 * 1024;
26
+
27
+ /** 允许的图片类型 → 落盘扩展名。刻意不收 gif:动画背景会持续重绘,与"暂停不空转"目标冲突 */
28
+ const ALLOWED_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
29
+
30
+ /** 显示名上限(超出截断,不报错——用户改个名不该被卡住) */
31
+ const MAX_NAME_LEN = 60;
32
+
33
+ /** 索引文件名与回收目录名 */
34
+ const INDEX_FILE = 'index.json';
35
+ const TRASH_DIR = '.trash';
36
+
37
+ // ------------------------------------------------------------------ 纯工具
38
+
39
+ /**
40
+ * 净化显示名:去掉控制字符与首尾空白,压缩连续空白,截断到上限。
41
+ * 只影响"展示",永远不参与文件路径(落盘名由服务端生成)。
42
+ * @param {unknown} raw 原始输入
43
+ * @returns {string} 净化后的名字(可能为空串,由调用方兜底)
44
+ */
45
+ function sanitizeName(raw) {
46
+ const s = String(raw == null ? '' : raw)
47
+ // eslint-disable-next-line no-control-regex
48
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
49
+ .replace(/\s+/g, ' ')
50
+ .trim();
51
+ return s.length > MAX_NAME_LEN ? s.slice(0, MAX_NAME_LEN) : s;
52
+ }
53
+
54
+ /**
55
+ * 还原请求头里的非 ASCII 文本(中文文件名)。
56
+ *
57
+ * 为什么需要两步:HTTP 头按规范是 latin-1,浏览器若不百分号编码就会把 UTF-8 字节
58
+ * 直接塞进去,Node 读出来是乱码("我的背景" → "æçèæ¯");
59
+ * 前端约定用 encodeURIComponent 传,所以先试百分号解码,失败再按 latin-1→utf-8 还原。
60
+ * @param {unknown} raw 请求头原值
61
+ * @returns {string} 可读文本
62
+ */
63
+ function decodeHeaderText(raw) {
64
+ const s = String(raw == null ? '' : raw);
65
+ if (!s) return '';
66
+ if (/%[0-9A-Fa-f]{2}/.test(s)) {
67
+ try { return decodeURIComponent(s); } catch { /* 落到下面的字节还原 */ }
68
+ }
69
+ try {
70
+ const restored = Buffer.from(s, 'latin1').toString('utf8');
71
+ // 还原后若出现替换符说明本来就不是 UTF-8 字节(真 latin-1 名字),保持原样
72
+ return restored.includes('\ufffd') ? s : restored;
73
+ } catch {
74
+ return s;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 从 x-filename 里取一个"基名"当显示名(仅展示用;绝不进落盘路径)。
80
+ * @param {string|undefined} raw 请求头原值
81
+ * @returns {string} 去掉扩展名与目录部分的基名
82
+ */
83
+ function displayNameFromHeader(raw) {
84
+ const text = decodeHeaderText(raw);
85
+ const base = text.replace(/\\/g, '/').split('/').pop() || '';
86
+ return sanitizeName(base.replace(/\.[A-Za-z0-9]{1,8}$/, ''));
87
+ }
88
+
89
+ /**
90
+ * 按文件头判定真实图片类型(不信任 Content-Type 与扩展名)。
91
+ * @param {Buffer} buf 文件开头若干字节
92
+ * @returns {string|null} 命中的 MIME,未识别返回 null
93
+ */
94
+ function sniffImageType(buf) {
95
+ if (!buf || buf.length < 12) return null;
96
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
97
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png';
98
+ // JPEG: FF D8 FF
99
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
100
+ // WebP: "RIFF" .... "WEBP"
101
+ if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * 统一失败响应(前端按 reason 给不同提示,不用笼统"上传失败")。
107
+ * @param {string} reason 机器可读原因
108
+ * @param {string} message 人类可读说明
109
+ * @param {number} [status] HTTP 状态码
110
+ */
111
+ function fail(reason, message, status = 400) {
112
+ return { ok: false, status, body: { ok: false, reason, error: message } };
113
+ }
114
+
115
+ // ------------------------------------------------------------------ 索引读写
116
+
117
+ /**
118
+ * 索引存储:原子写 + 串行队列。
119
+ * @param {{dir: string, indexFile: string, trashDir: string}} ctx 存储上下文
120
+ */
121
+ function createIndexStore(ctx) {
122
+ /** 串行队列:索引读写不并发,避免两次上传把 index.json 互相覆盖(与全仓同一实现) */
123
+ const enqueue = createSerialQueue();
124
+
125
+ /** 确保目录存在(首次使用时创建) */
126
+ function ensureDirs() {
127
+ fs.mkdirSync(ctx.dir, { recursive: true });
128
+ fs.mkdirSync(ctx.trashDir, { recursive: true });
129
+ }
130
+
131
+ /** 读索引(文件不存在/损坏都当空索引,不让坏文件把整个列表打挂) */
132
+ function read() {
133
+ try {
134
+ const obj = JSON.parse(fs.readFileSync(ctx.indexFile, 'utf8'));
135
+ const items = Array.isArray(obj && obj.items) ? obj.items : [];
136
+ return items.filter((it) => it && typeof it.id === 'string' && typeof it.file === 'string');
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 写索引:复用全仓统一的 writeJsonAtomic(写 tmp → **fsync** → rename)。
144
+ *
145
+ * 旧实现手写了 tmp+rename 但**漏了 fsync**:rename 只保证"看到旧或新文件",
146
+ * 不保证新文件内容已落盘 —— 崩溃窗口里会出现 0 字节的 index.json,
147
+ * 图片还在但界面里全部消失(纯色预设那条路早就用 writeJsonAtomic 修过,这里是漏网的另一处)。
148
+ */
149
+ function write(items) {
150
+ ensureDirs();
151
+ writeJsonAtomic(ctx.indexFile, { version: 1, items });
152
+ }
153
+
154
+ return { ensureDirs, read, write, enqueue };
155
+ }
156
+
157
+ /** 索引项 → 对外视图(带上可直接用于 <img>/background 的 url) */
158
+ function toItemView(it) {
159
+ return {
160
+ id: it.id,
161
+ name: it.name,
162
+ url: `/api/backgrounds/file/${it.id}`,
163
+ bytes: Number(it.bytes) || 0,
164
+ mime: it.mime || '',
165
+ addedAt: it.addedAt || '',
166
+ };
167
+ }
168
+
169
+ /**
170
+ * 把索引项解析成绝对路径,并校验它确实落在 backgrounds 目录内。
171
+ * 双保险:file 字段由服务端生成(不含用户输入),这里再做一次前缀校验,
172
+ * 防止索引文件被外部改坏后变成路径穿越。
173
+ * @param {{dir: string}} ctx 存储上下文
174
+ * @param {{file: string}} it 索引项
175
+ * @returns {string|null} 绝对路径(越界或不存在返回 null)
176
+ */
177
+ function resolveFile(ctx, it) {
178
+ const base = path.resolve(ctx.dir);
179
+ const abs = path.resolve(base, String(it.file || ''));
180
+ if (abs !== base && !abs.startsWith(`${base}${path.sep}`)) return null;
181
+ try {
182
+ if (!fs.statSync(abs).isFile()) return null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ return abs;
187
+ }
188
+
189
+ // ------------------------------------------------------------------ 五个动作
190
+
191
+ /**
192
+ * 列表:新加的排前面(用户刚上传的最想先看到)。
193
+ * @param {object} store 索引存储
194
+ */
195
+ function listItems(store) {
196
+ const items = store.read().slice().reverse();
197
+ return { ok: true, limit: MAX_BG_BYTES, types: Object.keys(ALLOWED_TYPES), items: items.map(toItemView) };
198
+ }
199
+
200
+ /**
201
+ * 校验并落盘一次上传(此时内容已在内存里,且必定 ≤ 上限)。
202
+ * @param {{store: object, ctx: object}} env 存储与上下文
203
+ * @param {Buffer} buf 文件内容
204
+ * @param {object} headers 请求头
205
+ */
206
+ function saveUpload(env, buf, headers) {
207
+ const mime = sniffImageType(buf);
208
+ if (!mime || !ALLOWED_TYPES[mime]) {
209
+ const declared = String(headers['content-type'] || '(未声明)');
210
+ return fail('bad-type', `只接受 png / jpeg / webp 且文件内容要与类型相符(收到:${declared})`, 415);
211
+ }
212
+ const ext = ALLOWED_TYPES[mime];
213
+ const id = crypto.randomBytes(8).toString('hex');
214
+ const file = `bg-${Date.now()}-${id}.${ext}`;
215
+ const abs = path.join(env.ctx.dir, file);
216
+ try {
217
+ env.store.ensureDirs();
218
+ // 与索引同一套原子写(tmp → fsync → rename):否则崩溃后可能"索引有这条、图片 0 字节",
219
+ // 而取文件路径只校验 isFile()(0 字节也算存在)⇒ 不会被剔除,用户长期破图。
220
+ writeFileAtomic(abs, buf);
221
+ } catch (e) {
222
+ return fail('disk-error', `写入磁盘失败:${(e && e.message) || e}`, 500);
223
+ }
224
+ return env.store.enqueue(() => {
225
+ const items = env.store.read();
226
+ const name = displayNameFromHeader(headers['x-filename']) || `未命名 ${items.length + 1}`;
227
+ const item = { id, name, file, mime, bytes: buf.length, addedAt: new Date().toISOString() };
228
+ items.push(item);
229
+ try {
230
+ env.store.write(items);
231
+ } catch (e) {
232
+ // 索引写失败:把刚落盘的文件清掉,避免产生"看不见的孤儿文件"
233
+ try { fs.unlinkSync(abs); } catch { /* 忽略 */ }
234
+ return fail('disk-error', `索引写入失败:${(e && e.message) || e}`, 500);
235
+ }
236
+ return { ok: true, status: 201, body: { ok: true, item: toItemView(item) } };
237
+ });
238
+ }
239
+
240
+ /**
241
+ * 边读边计数地上传一张图:超限立即中止。
242
+ * @param {{store: object, ctx: object}} env 存储与上下文
243
+ * @param {import('node:http').IncomingMessage} req 原始请求
244
+ * @returns {Promise<{ok:boolean, status:number, body:object, destroy?:boolean}>} 统一响应
245
+ */
246
+ function readUpload(env, req) {
247
+ // ① Content-Length 预检:声明就超限的,连读都不读(一个字节都不占内存)
248
+ const declared = Number(req.headers['content-length']);
249
+ if (Number.isFinite(declared) && declared > MAX_BG_BYTES) {
250
+ const r = fail('too-large',
251
+ `图片 ${(declared / 1048576).toFixed(2)}MB 超过上限 ${MAX_BG_BYTES / 1048576}MB,请先压缩`, 413);
252
+ r.destroy = true;
253
+ req.resume(); // 丢弃后续数据,避免占着连接不放
254
+ return Promise.resolve(r);
255
+ }
256
+ return new Promise((resolve) => {
257
+ const chunks = [];
258
+ let total = 0;
259
+ let aborted = false;
260
+ /**
261
+ * 中止这次上传并给出原因。
262
+ * 注意顺序:**先把响应交出去、再由路由层断开**——直接 destroy 会让客户端只看到
263
+ * "连接被重置",拿不到 413 与具体原因(实测 curl 只收到 100 Continue)。
264
+ */
265
+ const abortWith = (reason, message, status) => {
266
+ if (aborted) return;
267
+ aborted = true;
268
+ req.resume(); // 继续把剩余字节读出来丢弃:不占内存,也能让连接安静下来
269
+ const r = fail(reason, message, status);
270
+ r.destroy = true;
271
+ resolve(r);
272
+ };
273
+ req.on('data', (chunk) => {
274
+ if (aborted) return;
275
+ total += chunk.length;
276
+ // ② 流式累加:没有 Content-Length(chunked)时靠这里兜住上限
277
+ if (total > MAX_BG_BYTES) {
278
+ abortWith('too-large', `图片超过上限 ${MAX_BG_BYTES / 1048576}MB,请先压缩`, 413);
279
+ return;
280
+ }
281
+ chunks.push(chunk);
282
+ });
283
+ req.on('error', () => abortWith('aborted', '上传中断(连接已断开)', 400));
284
+ req.on('end', () => {
285
+ if (aborted) return;
286
+ if (!total) { resolve(fail('empty', '没有收到文件内容', 400)); return; }
287
+ resolve(saveUpload(env, Buffer.concat(chunks), req.headers));
288
+ });
289
+ });
290
+ }
291
+
292
+ /**
293
+ * 重命名:只改显示名,id 与 file 保持不变(URL 因此永远稳定)。
294
+ * 重名不拒绝而是自动加 `(2)`/`(3)` 后缀——用户想保留两份同名图是合理需求,
295
+ * 但列表里出现两个一模一样的名字会分不清谁是谁。
296
+ * @param {object} store 索引存储
297
+ * @param {string} id 目标 id
298
+ * @param {unknown} rawName 新显示名
299
+ */
300
+ function renameItem(store, id, rawName) {
301
+ const name = sanitizeName(rawName);
302
+ if (!name) return Promise.resolve(fail('bad-name', '名称不能为空'));
303
+ return store.enqueue(() => {
304
+ const items = store.read();
305
+ const hit = items.find((it) => it.id === id);
306
+ if (!hit) return fail('not-found', `没有这条背景图:${id}`, 404);
307
+ const taken = new Set(items.filter((it) => it.id !== id).map((it) => it.name));
308
+ let final = name;
309
+ let n = 2;
310
+ while (taken.has(final)) { final = `${name} (${n})`; n += 1; }
311
+ hit.name = final;
312
+ try {
313
+ store.write(items);
314
+ } catch (e) {
315
+ return fail('disk-error', `索引写入失败:${(e && e.message) || e}`, 500);
316
+ }
317
+ return { ok: true, status: 200, body: { ok: true, item: toItemView(hit), adjusted: final !== name } };
318
+ });
319
+ }
320
+
321
+ /**
322
+ * 删除:文件移进 .trash/(可恢复),索引同步移除(否则列表会出现点不开的幽灵项)。
323
+ * @param {{store: object, ctx: object}} env 存储与上下文
324
+ * @param {string} id 目标 id
325
+ */
326
+ function removeItem(env, id) {
327
+ return env.store.enqueue(() => {
328
+ const items = env.store.read();
329
+ const hit = items.find((it) => it.id === id);
330
+ if (!hit) return fail('not-found', `没有这条背景图:${id}`, 404);
331
+ const abs = resolveFile(env.ctx, hit);
332
+ let trashed = false;
333
+ if (abs) {
334
+ try {
335
+ env.store.ensureDirs();
336
+ fs.renameSync(abs, path.join(env.ctx.trashDir, `${Date.now()}-${path.basename(abs)}`));
337
+ trashed = true;
338
+ } catch {
339
+ // 移动失败不阻断索引移除:留在盘上的文件比"列表里删不掉"危害小
340
+ trashed = false;
341
+ }
342
+ }
343
+ const next = items.filter((it) => it.id !== id);
344
+ try {
345
+ env.store.write(next);
346
+ } catch (e) {
347
+ return fail('disk-error', `索引写入失败:${(e && e.message) || e}`, 500);
348
+ }
349
+ return { ok: true, status: 200, body: { ok: true, removed: id, trashed } };
350
+ });
351
+ }
352
+
353
+ /**
354
+ * 取一张图的落盘信息(出图接口用):**只按索引查 id**,URL 里的字符串永不参与拼路径。
355
+ * @param {{store: object, ctx: object}} env 存储与上下文
356
+ * @param {string} id 索引 id
357
+ * @returns {{abs: string, mime: string, name: string}|null} 命中返回路径与类型
358
+ */
359
+ function findFile(env, id) {
360
+ const hit = env.store.read().find((it) => it.id === id);
361
+ if (!hit) return null;
362
+ const abs = resolveFile(env.ctx, hit);
363
+ if (!abs) return null;
364
+ return { abs, mime: hit.mime || 'application/octet-stream', name: hit.name };
365
+ }
366
+
367
+ // ------------------------------------------------------------------ 对外句柄
368
+
369
+ /**
370
+ * 建立背景图存储句柄(路由层只跟这个对象打交道)。
371
+ * @param {{dataDir: string}} paths 项目路径集合(core/paths.mjs 产出)
372
+ */
373
+ export function createBackgrounds(paths) {
374
+ const ctx = {
375
+ dir: path.join(paths.dataDir, 'backgrounds'),
376
+ indexFile: path.join(paths.dataDir, 'backgrounds', INDEX_FILE),
377
+ trashDir: path.join(paths.dataDir, 'backgrounds', TRASH_DIR),
378
+ };
379
+ const env = { ctx, store: createIndexStore(ctx) };
380
+ return {
381
+ dir: ctx.dir,
382
+ list: () => listItems(env.store),
383
+ uploadStream: (req) => readUpload(env, req),
384
+ rename: (id, name) => renameItem(env.store, id, name),
385
+ remove: (id) => removeItem(env, id),
386
+ fileOf: (id) => findFile(env, id),
387
+ };
388
+ }