@ninkch/dsh-infinite-canvas 0.17.18 → 0.18.1

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
@@ -12,6 +12,8 @@ import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync,
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { createHash } from 'node:crypto';
16
+ import { createRequire } from 'node:module';
15
17
 
16
18
  const NAME = 'infinite-canvas';
17
19
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -180,6 +182,404 @@ function sanitizeFolderName(name) {
180
182
  const s = String(name || '').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60);
181
183
  return s || '未命名项目';
182
184
  }
185
+
186
+ // ============================================================
187
+ // 存储引擎 v2(0.18.0):SQLite(node:sqlite)+ CAS 对象库
188
+ // <dataRoot>/canvas.db 元数据 + 项目文档 = 唯一事实源
189
+ // <dataRoot>/objects/<sha2>/<sha256> 媒体文件(内容寻址、去重、引用计数)
190
+ // node:sqlite 需要 Node ≥22.5(DSH 捆绑 Node 24 满足);不可用时整站回退 v1 JSON 存储。
191
+ // ============================================================
192
+ const require_ = createRequire(import.meta.url);
193
+ const SQLITE = (() => { try { return require_('node:sqlite'); } catch { return null; } })();
194
+ let _db = null; // DatabaseSync 实例(按 dataRoot 缓存)
195
+ let _dbRoot = null;
196
+ let _rpcMethods = null; // RPC 方法表(apply 时赋值;测试直接调用)
197
+
198
+ function sqlActive() { return !!SQLITE && !!dataRoot(); }
199
+ function dbFileFor(root) { return path.join(root, 'canvas.db'); }
200
+ function objectsDirFor(root) { return path.join(root, 'objects'); }
201
+ function objectAbsFor(root, sha) { return path.join(objectsDirFor(root), String(sha).slice(0, 2), String(sha)); }
202
+
203
+ function closeDb() {
204
+ if (!_db) return;
205
+ try { _db.close(); } catch { /* 已关闭 */ }
206
+ _db = null;
207
+ _dbRoot = null;
208
+ }
209
+ process.on('exit', () => { try { closeDb(); } catch { /* 忽略 */ } });
210
+
211
+ /** 打开(或复用)数据目录的 canvas.db;全新库自动执行一次性 v1 迁移。 */
212
+ function getDb() {
213
+ const root = dataRoot();
214
+ if (!root || !SQLITE) return null;
215
+ if (_db && _dbRoot === root) return _db;
216
+ closeDb();
217
+ mkdirSync(root, { recursive: true });
218
+ const db = new SQLITE.DatabaseSync(dbFileFor(root));
219
+ db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
220
+ ensureSchema(db, root);
221
+ _db = db;
222
+ _dbRoot = root;
223
+ return _db;
224
+ }
225
+
226
+ function metaGet(db, k) {
227
+ const r = db.prepare('SELECT value FROM meta WHERE key = ?').get(k);
228
+ return r ? r.value : null;
229
+ }
230
+ function metaSet(db, k, v) {
231
+ db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(k, String(v));
232
+ }
233
+
234
+ function ensureSchema(db, root) {
235
+ db.exec(`
236
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
237
+ CREATE TABLE IF NOT EXISTS projects (
238
+ id TEXT PRIMARY KEY, name TEXT, created_at INTEGER, updated_at INTEGER,
239
+ bible_updated_at INTEGER DEFAULT 0, summary TEXT, tombstones TEXT, doc TEXT);
240
+ CREATE TABLE IF NOT EXISTS assets (
241
+ id TEXT PRIMARY KEY, project_id TEXT, episode_id TEXT,
242
+ kind TEXT, mime TEXT, name TEXT,
243
+ rel TEXT, sha TEXT, bytes INTEGER,
244
+ width INTEGER, height INTEGER, duration REAL,
245
+ prompt TEXT, category TEXT, entity TEXT, entities TEXT, node_label TEXT,
246
+ text TEXT, source TEXT, created_at INTEGER, updated_at INTEGER);
247
+ CREATE INDEX IF NOT EXISTS idx_assets_proj ON assets(project_id, created_at DESC, id DESC);
248
+ CREATE INDEX IF NOT EXISTS idx_assets_sha ON assets(sha);
249
+ CREATE TABLE IF NOT EXISTS bindings (asset_id TEXT, entity_id TEXT, PRIMARY KEY (asset_id, entity_id));
250
+ CREATE TABLE IF NOT EXISTS entities (id TEXT PRIMARY KEY, project_id TEXT, kind TEXT, name TEXT);
251
+ CREATE TABLE IF NOT EXISTS episodes (id TEXT PRIMARY KEY, project_id TEXT, season INTEGER, number INTEGER, title TEXT);
252
+ `);
253
+ if (!metaGet(db, 'fts_tokenizer')) {
254
+ try {
255
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt, tokenize = 'trigram')`);
256
+ metaSet(db, 'fts_tokenizer', 'trigram'); // trigram:中文子串级全文检索(SQLite ≥3.34)
257
+ } catch {
258
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt)`);
259
+ metaSet(db, 'fts_tokenizer', 'unicode61');
260
+ }
261
+ } else if (metaGet(db, 'fts_tokenizer') === 'unicode61') {
262
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt)`);
263
+ } else {
264
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS assets_fts USING fts5(id UNINDEXED, name, prompt, tokenize = 'trigram')`);
265
+ }
266
+ if (!metaGet(db, 'schema_version')) {
267
+ // 全新库:从 v1 布局(~/.dsh 项目 JSON + 数据目录 assets.json 镜像)一次性导入
268
+ const counts = migrateV1IntoDb(db, root);
269
+ metaSet(db, 'schema_version', '2');
270
+ metaSet(db, 'migrated_from', 'v1@' + new Date().toISOString());
271
+ metaSet(db, 'migrated_counts', JSON.stringify(counts));
272
+ }
273
+ }
274
+
275
+ /** 项目文档摘要(列表页用,避免每次列表都解析全量 doc)。 */
276
+ function docSummary(d) {
277
+ const nodes = Object.values(d.nodes || {}).map((n) => ({
278
+ id: n.id, type: n.type, label: (n.data && n.data.label) || '', status: (n.data && n.data.status) || 'idle',
279
+ }));
280
+ return { nodeIds: Object.keys(d.nodes || {}), nodes, source: d.source || 'app' };
281
+ }
282
+
283
+ /** v1 → v2 一次性迁移:文档按 updatedAt 新者胜合并;资产索引照抄(媒体文件原地保留,rel 指针不动——零风险迁移)。 */
284
+ function migrateV1IntoDb(db, root) {
285
+ const counts = { projects: 0, assets: 0 };
286
+ const insProj = db.prepare('INSERT OR REPLACE INTO projects (id, name, created_at, updated_at, bible_updated_at, summary, tombstones, doc) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
287
+ const docs = new Map();
288
+ const consider = (d) => {
289
+ if (!d || !d.id) return;
290
+ const cur = docs.get(d.id);
291
+ if (!cur || ((cur.updatedAt || 0) <= (d.updatedAt || 0))) docs.set(d.id, d);
292
+ };
293
+ try {
294
+ for (const f of readdirSync(PROJECTS_DIR)) {
295
+ if (!f.endsWith('.json')) continue;
296
+ try { consider(JSON.parse(readFileSync(path.join(PROJECTS_DIR, f), 'utf8'))); } catch { /* 损坏跳过 */ }
297
+ }
298
+ } catch { /* 无旧目录 */ }
299
+ try {
300
+ const meta = readDataRootMeta();
301
+ for (const entry of meta.projects) {
302
+ try { consider(JSON.parse(readFileSync(path.join(root, 'projects', entry.folder, 'project.json'), 'utf8'))); } catch { /* 跳过 */ }
303
+ }
304
+ } catch { /* 无镜像 */ }
305
+ for (const d of docs.values()) {
306
+ insProj.run(
307
+ d.id, String(d.name || d.id).slice(0, 80), d.createdAt || 0, d.updatedAt || 0, d.bibleUpdatedAt || 0,
308
+ JSON.stringify(docSummary(d)), JSON.stringify(d.tombstones || []), JSON.stringify(d),
309
+ );
310
+ counts.projects++;
311
+ }
312
+ try {
313
+ const meta = readDataRootMeta();
314
+ for (const entry of meta.projects) {
315
+ let list = null;
316
+ try { list = JSON.parse(readFileSync(path.join(root, 'projects', entry.folder, 'assets.json'), 'utf8')); } catch { continue; }
317
+ if (!Array.isArray(list)) continue;
318
+ for (const a of list) {
319
+ if (!a || !a.id) continue;
320
+ upsertAssetRow(db, assetRowFromRecord({ ...a, projectId: a.projectId || entry.id }));
321
+ counts.assets++;
322
+ }
323
+ }
324
+ } catch { /* 无索引 */ }
325
+ return counts;
326
+ }
327
+
328
+ /** 客户端资产记录 → SQL 行参数(0.18 统一形状)。 */
329
+ function assetRowFromRecord(a) {
330
+ return {
331
+ id: String(a.id),
332
+ project_id: String(a.projectId || ''),
333
+ episode_id: a.episodeId ? String(a.episodeId) : null,
334
+ kind: String(a.kind || ''),
335
+ mime: String(a.mime || ''),
336
+ name: String(a.name || ''),
337
+ rel: a.rel ? String(a.rel) : null,
338
+ sha: a.sha ? String(a.sha).toLowerCase() : null,
339
+ bytes: Number(a.size || a.bytes || 0) || null,
340
+ width: Number(a.width || 0) || null,
341
+ height: Number(a.height || 0) || null,
342
+ duration: Number(a.duration || 0) || null,
343
+ prompt: String(a.prompt || ''),
344
+ category: String(a.category || 'general'),
345
+ entity: a.entity ? String(a.entity) : null,
346
+ entities: Array.isArray(a.entities) ? JSON.stringify(a.entities) : null,
347
+ node_label: a.nodeLabel ? String(a.nodeLabel) : null,
348
+ text: (a.kind === 'text' && typeof a.text === 'string') ? a.text : null,
349
+ source: String(a.source || ''),
350
+ created_at: Number(a.createdAt) || Date.now(),
351
+ updated_at: Date.now(),
352
+ };
353
+ }
354
+
355
+ /** SQL 行 → 客户端资产记录(v1 形状超集:新增 sha/episodeId)。 */
356
+ function rowToAsset(r) {
357
+ const out = {
358
+ id: r.id, projectId: r.project_id, kind: r.kind, mime: r.mime, name: r.name,
359
+ createdAt: r.created_at, category: r.category || 'general', prompt: r.prompt || '', source: r.source || '',
360
+ };
361
+ if (r.episode_id) out.episodeId = r.episode_id;
362
+ if (r.rel) { out.rel = r.rel; out.size = r.bytes || 0; }
363
+ if (r.sha) { out.sha = r.sha; out.size = r.bytes || 0; }
364
+ if (r.width) out.width = r.width;
365
+ if (r.height) out.height = r.height;
366
+ if (r.duration) out.duration = r.duration;
367
+ if (r.entity) out.entity = r.entity;
368
+ if (r.entities) { try { out.entities = JSON.parse(r.entities); } catch { /* 忽略 */ } }
369
+ if (r.node_label) out.nodeLabel = r.node_label;
370
+ if (r.text != null && r.text !== '') out.text = r.text;
371
+ return out;
372
+ }
373
+
374
+ function syncFts(db, id, name, prompt) {
375
+ db.prepare('DELETE FROM assets_fts WHERE id = ?').run(id);
376
+ db.prepare('INSERT INTO assets_fts (id, name, prompt) VALUES (?, ?, ?)').run(id, String(name || ''), String(prompt || ''));
377
+ }
378
+
379
+ function upsertAssetRow(db, row) {
380
+ db.prepare(`INSERT INTO assets (id, project_id, episode_id, kind, mime, name, rel, sha, bytes, width, height, duration, prompt, category, entity, entities, node_label, text, source, created_at, updated_at)
381
+ VALUES (@id, @project_id, @episode_id, @kind, @mime, @name, @rel, @sha, @bytes, @width, @height, @duration, @prompt, @category, @entity, @entities, @node_label, @text, @source, @created_at, @updated_at)
382
+ ON CONFLICT(id) DO UPDATE SET project_id=@project_id, episode_id=@episode_id, kind=@kind, mime=@mime, name=@name, rel=@rel, sha=@sha, bytes=@bytes,
383
+ width=@width, height=@height, duration=@duration, prompt=@prompt, category=@category, entity=@entity, entities=@entities,
384
+ node_label=@node_label, text=@text, source=@source, updated_at=@updated_at`).run(row);
385
+ syncFts(db, row.id, row.name, row.prompt);
386
+ }
387
+
388
+ /** 对象引用计数:>0 表示仍有资产引用,不可物理删除。 */
389
+ function objectRefCount(db, sha) {
390
+ return db.prepare('SELECT COUNT(*) AS n FROM assets WHERE sha = ?').get(sha).n;
391
+ }
392
+ /** 引用归零后删除 CAS 对象(其它项目还在用就保留——内容寻址天然跨项目共享)。 */
393
+ function gcObjectIfUnreferenced(db, root, sha) {
394
+ try {
395
+ if (objectRefCount(db, sha) > 0) return false;
396
+ const abs = objectAbsFor(root, sha);
397
+ if (existsSync(abs)) unlinkSync(abs);
398
+ return true;
399
+ } catch { return false; }
400
+ }
401
+
402
+ /** 资产删除(SQL 模式):行 + FTS + rel 旧文件 / sha 对象按引用计数清理。 */
403
+ function dbAssetDelete(id) {
404
+ const db = getDb();
405
+ const root = dataRoot();
406
+ const row = db.prepare('SELECT * FROM assets WHERE id = ?').get(id);
407
+ if (!row) return { ok: true, deleted: false };
408
+ db.prepare('DELETE FROM assets WHERE id = ?').run(id);
409
+ db.prepare('DELETE FROM assets_fts WHERE id = ?').run(id);
410
+ if (row.sha && root) gcObjectIfUnreferenced(db, root, row.sha);
411
+ if (row.rel && root) {
412
+ try {
413
+ const mapped = physicalRel(row.rel) || row.rel;
414
+ const { abs } = safeJoin(root, mapped);
415
+ unlinkSync(abs);
416
+ } catch { /* 已不存在视为成功 */ }
417
+ }
418
+ return { ok: true, deleted: true };
419
+ }
420
+
421
+ /** 资产分页查询:过滤 + keyset 游标(created_at, id)+ 中文全文检索。 */
422
+ function dbAssetsList(args) {
423
+ const db = getDb();
424
+ const limit = Math.min(200, Math.max(1, Number(args && args.limit) || 50));
425
+ const where = [];
426
+ const params = {};
427
+ const projectId = String((args && args.projectId) || '');
428
+ if (projectId) { where.push('project_id = @projectId'); params.projectId = projectId; }
429
+ const category = String((args && args.category) || '');
430
+ if (category) { where.push('category = @category'); params.category = category; }
431
+ const kind = String((args && args.kind) || '');
432
+ if (kind) { where.push('kind = @kind'); params.kind = kind; }
433
+ const q = String((args && args.q) || '').trim();
434
+ if (q) {
435
+ if (q.length >= 3 && metaGet(db, 'fts_tokenizer') === 'trigram') {
436
+ where.push("id IN (SELECT id FROM assets_fts WHERE assets_fts MATCH @q)");
437
+ params.q = '"' + q.replace(/"/g, '""') + '"';
438
+ } else {
439
+ where.push("(name LIKE @like ESCAPE '\\' OR prompt LIKE @like ESCAPE '\\')");
440
+ params.like = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
441
+ }
442
+ }
443
+ let cursor = null;
444
+ if (args && args.cursor) {
445
+ try { cursor = JSON.parse(Buffer.from(String(args.cursor), 'base64').toString('utf8')); } catch { cursor = null; }
446
+ }
447
+ if (Array.isArray(cursor) && cursor.length === 2) {
448
+ where.push('(created_at < @cAt OR (created_at = @cAt AND id < @cId))');
449
+ params.cAt = Number(cursor[0]) || 0;
450
+ params.cId = String(cursor[1] || '');
451
+ }
452
+ const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
453
+ const total = db.prepare('SELECT COUNT(*) AS n FROM assets ' + whereSql).get(params).n;
454
+ const rows = db.prepare('SELECT * FROM assets ' + whereSql + ' ORDER BY created_at DESC, id DESC LIMIT @limit').all({ ...params, limit: limit + 1 });
455
+ let nextCursor = null;
456
+ if (rows.length > limit) {
457
+ rows.length = limit;
458
+ const last = rows[rows.length - 1];
459
+ nextCursor = Buffer.from(JSON.stringify([last.created_at, last.id])).toString('base64');
460
+ }
461
+ return { ok: true, total, items: rows.map(rowToAsset), nextCursor };
462
+ }
463
+
464
+ function dbAssetsGet(ids) {
465
+ const db = getDb();
466
+ const out = [];
467
+ const get = db.prepare('SELECT * FROM assets WHERE id = ?');
468
+ for (const id of (Array.isArray(ids) ? ids : []).slice(0, 200)) {
469
+ const r = get.get(String(id));
470
+ if (r) out.push(rowToAsset(r));
471
+ }
472
+ return { ok: true, items: out };
473
+ }
474
+
475
+ function dbAssetNames(projectId) {
476
+ const db = getDb();
477
+ const rows = db.prepare('SELECT id, name FROM assets WHERE project_id = ? ORDER BY created_at DESC').all(String(projectId || ''));
478
+ return { ok: true, names: rows.map((r) => ({ id: r.id, name: r.name })) };
479
+ }
480
+
481
+ function dbAssetCreate(a) {
482
+ if (!a || !a.id || !a.projectId) throw new Error('需要 asset.id 与 asset.projectId');
483
+ const db = getDb();
484
+ const row = assetRowFromRecord(a);
485
+ upsertAssetRow(db, row);
486
+ return { ok: true, asset: rowToAsset(db.prepare('SELECT * FROM assets WHERE id = ?').get(row.id)) };
487
+ }
488
+
489
+ function dbAssetUpdate(id, patch) {
490
+ const db = getDb();
491
+ const row = db.prepare('SELECT * FROM assets WHERE id = ?').get(String(id || ''));
492
+ if (!row) throw new Error('资产不存在:' + id);
493
+ const cur = rowToAsset(row);
494
+ const merged = { ...cur };
495
+ if (patch && patch.name !== undefined) merged.name = String(patch.name).slice(0, 80);
496
+ if (patch && patch.category !== undefined) merged.category = String(patch.category || 'general');
497
+ if (patch && patch.projectId !== undefined) merged.projectId = String(patch.projectId || '');
498
+ if (patch && patch.prompt !== undefined) merged.prompt = String(patch.prompt || '');
499
+ if (patch && patch.entity !== undefined) merged.entity = patch.entity ? String(patch.entity) : undefined;
500
+ if (patch && patch.entities !== undefined) merged.entities = Array.isArray(patch.entities) ? patch.entities : undefined;
501
+ if (patch && patch.nodeLabel !== undefined) merged.nodeLabel = patch.nodeLabel ? String(patch.nodeLabel) : undefined;
502
+ if (patch && patch.text !== undefined && cur.kind === 'text') merged.text = String(patch.text || '');
503
+ upsertAssetRow(db, assetRowFromRecord({ ...merged, createdAt: cur.createdAt }));
504
+ return { ok: true, asset: rowToAsset(db.prepare('SELECT * FROM assets WHERE id = ?').get(row.id)) };
505
+ }
506
+
507
+ /** CAS 对象库 GC:扫描 objects/,删除无任何资产行引用的对象(mtime 超过 24h 才删,避免与并发上传竞态)。 */
508
+ function dbObjectsGc() {
509
+ const root = dataRoot();
510
+ const db = getDb();
511
+ if (!root || !db) throw new Error('SQL 存储不可用');
512
+ const odir = objectsDirFor(root);
513
+ let scanned = 0;
514
+ let removed = 0;
515
+ const grace = Date.now() - 24 * 3600 * 1000;
516
+ try {
517
+ for (const bucket of readdirSync(odir)) {
518
+ const bdir = path.join(odir, bucket);
519
+ let stats = null;
520
+ try { stats = statSync(bdir); } catch { continue; }
521
+ if (!stats.isDirectory()) continue;
522
+ for (const f of readdirSync(bdir)) {
523
+ if (!/^[0-9a-f]{64}$/i.test(f)) continue;
524
+ scanned++;
525
+ const abs = path.join(bdir, f);
526
+ let st = null;
527
+ try { st = statSync(abs); } catch { continue; }
528
+ if (st.mtimeMs > grace) continue; // 近期写入:可能尚未登记资产行
529
+ if (objectRefCount(db, f) === 0) {
530
+ try { unlinkSync(abs); removed++; } catch { /* 忽略 */ }
531
+ }
532
+ }
533
+ }
534
+ } catch { /* 目录不存在 = 无对象 */ }
535
+ return { ok: true, scanned, removed };
536
+ }
537
+
538
+ function readDocOrNull(id) {
539
+ if (sqlActive()) {
540
+ const db = getDb();
541
+ const row = db.prepare('SELECT doc FROM projects WHERE id = ?').get(id);
542
+ if (!row) return null;
543
+ try { return JSON.parse(row.doc); } catch { return null; }
544
+ }
545
+ try { return JSON.parse(readFileSync(projectFile(id), 'utf8')); } catch { return null; }
546
+ }
547
+ function docExists(id) {
548
+ if (sqlActive()) {
549
+ const db = getDb();
550
+ return !!db.prepare('SELECT id FROM projects WHERE id = ?').get(id);
551
+ }
552
+ return existsSync(projectFile(id));
553
+ }
554
+
555
+ /** JSON 降级模式的资产分页:扫全部 assets.json 平铺 → 过滤 → offset 游标模拟(无检索)。 */
556
+ function jsonAssetsList(args) {
557
+ const root = dataRoot();
558
+ const items = [];
559
+ if (root) {
560
+ try {
561
+ const meta = readDataRootMeta();
562
+ for (const entry of meta.projects) {
563
+ let list = null;
564
+ try { list = JSON.parse(readFileSync(path.join(root, 'projects', entry.folder, 'assets.json'), 'utf8')); } catch { continue; }
565
+ if (Array.isArray(list)) for (const a of list) { if (a && a.id) items.push({ ...a, projectId: a.projectId || entry.id }); }
566
+ }
567
+ } catch { /* 无目录 */ }
568
+ }
569
+ const projectId = String((args && args.projectId) || '');
570
+ const kind = String((args && args.kind) || '');
571
+ const category = String((args && args.category) || '');
572
+ const rows = items
573
+ .filter((a) => (!projectId || a.projectId === projectId) && (!kind || a.kind === kind) && (!category || (a.category || 'general') === category))
574
+ .sort((x, y) => (y.createdAt || 0) - (x.createdAt || 0));
575
+ const total = rows.length;
576
+ let start = 0;
577
+ if (args && args.cursor) { try { start = Number(Buffer.from(String(args.cursor), 'base64').toString('utf8')) || 0; } catch { start = 0; } }
578
+ const limit = Math.min(200, Math.max(1, Number(args && args.limit) || 50));
579
+ const page = rows.slice(start, start + limit);
580
+ const nextCursor = start + limit < total ? Buffer.from(String(start + limit)).toString('base64') : null;
581
+ return Promise.resolve({ ok: true, total, items: page, nextCursor });
582
+ }
183
583
  /** 确保项目的物理文件夹(按项目名)与清单条目存在;改名时尽力重命名目录(被占用/冲突则保留旧文件夹)。返回文件夹名。 */
184
584
  function ensureProjectFolder(pid, name) {
185
585
  const root = dataRoot();
@@ -516,6 +916,21 @@ function projectFile(id) {
516
916
  return path.join(PROJECTS_DIR, String(id) + '.json');
517
917
  }
518
918
  function projectsList() {
919
+ if (sqlActive()) {
920
+ const db = getDb();
921
+ const rows = db.prepare('SELECT id, name, created_at, updated_at, bible_updated_at, summary, tombstones FROM projects ORDER BY updated_at DESC').all();
922
+ return rows.map((r) => {
923
+ let sum = {};
924
+ try { sum = JSON.parse(r.summary || '{}'); } catch { /* 旧行/损坏摘要 */ }
925
+ let tombs = [];
926
+ try { tombs = JSON.parse(r.tombstones || '[]'); } catch { /* 旧行 */ }
927
+ return {
928
+ id: r.id, name: r.name, createdAt: r.created_at || 0, updatedAt: r.updated_at || 0,
929
+ source: sum.source || 'app', nodeIds: sum.nodeIds || [], nodes: sum.nodes || [],
930
+ tombstones: Array.isArray(tombs) ? tombs : [], bibleUpdatedAt: r.bible_updated_at || 0,
931
+ };
932
+ });
933
+ }
519
934
  let files = [];
520
935
  try { files = readdirSync(PROJECTS_DIR).filter((f) => f.endsWith('.json')); } catch { return []; }
521
936
  const list = [];
@@ -546,8 +961,7 @@ async function projectsPut(args) {
546
961
  if (deletedProjectIds().includes(id)) return { ok: true, id, deleted: true };
547
962
  const f = projectFile(id);
548
963
  const doc = (args && args.doc) || {};
549
- let prev = {};
550
- try { prev = JSON.parse(readFileSync(f, 'utf8')); } catch { /* 新项目 */ }
964
+ let prev = readDocOrNull(id) || {};
551
965
  // 墓碑:对话删除的节点 + 客户端本地删除的节点(客户端删除时也会写入 tombstones)。
552
966
  // tombstoneAt 记录每个墓碑的创建时刻:只有**推送时间戳(doc.ts)不早于墓碑时刻**的推送
553
967
  // 才被认定为"撤销恢复"允许清墓碑;陈旧推送(快照早于删除、无 ts 或 ts 更老)不得借
@@ -724,7 +1138,7 @@ async function fetchProxyForward(reqBody) {
724
1138
  const buf = Buffer.from(await upstream.arrayBuffer());
725
1139
  return { status: upstream.status, contentType: upstream.headers.get('content-type') || 'application/json; charset=utf-8', buf };
726
1140
  }
727
- /** 设置数据根目录(绝对路径)并立即迁移布局;供 RPC 与测试复用。 */
1141
+ /** 设置数据根目录(绝对路径)并挂载数据库;SQL 模式下首次打开自动执行 v1→v2 迁移。 */
728
1142
  function setDataRoot(raw) {
729
1143
  raw = String(raw ?? '').trim();
730
1144
  if (!raw) throw new Error('需要数据根目录路径');
@@ -736,16 +1150,36 @@ function setDataRoot(raw) {
736
1150
  const cfg = readIcConfig();
737
1151
  cfg.dataRoot = root;
738
1152
  writeIcConfig(cfg);
739
- // 首次设置/换目录:立即迁移布局(pid 文件夹 → 项目名文件夹)并镜像全部项目文档
740
- let migrated = null;
741
- try { migrated = dataRootMigrate(); } catch (e) { migrated = { error: String((e && e.message) || e) }; }
742
- return { ok: true, dataRoot: root, migrated };
1153
+ closeDb(); // 换目录:关闭旧库句柄,下一次访问按新路径重开
1154
+ let opened = null;
1155
+ if (sqlActive()) {
1156
+ try {
1157
+ const db = getDb();
1158
+ opened = { mode: 'sqlite', migrated: JSON.parse(metaGet(db, 'migrated_counts') || '{}') };
1159
+ } catch (e) { opened = { mode: 'sqlite', error: String((e && e.message) || e) }; }
1160
+ } else {
1161
+ // v1 布局迁移(仅 JSON 降级模式);就绪后自动搬存量资产到磁盘由客户端负责
1162
+ let migrated = null;
1163
+ try { migrated = dataRootMigrate(); } catch (e) { migrated = { error: String((e && e.message) || e) }; }
1164
+ opened = { mode: 'json', migrated };
1165
+ }
1166
+ return { ok: true, dataRoot: root, ...opened };
743
1167
  }
744
1168
  async function projectsDelete(args) {
745
1169
  const id = String((args && args.id) || '');
746
- try { unlinkSync(projectFile(id)); } catch { /* 已不存在视为成功 */ }
1170
+ if (sqlActive()) {
1171
+ const db = getDb();
1172
+ const root = dataRoot();
1173
+ // 先收集对象引用:项目资产行删除后,仅本项目引用的 CAS 对象一并回收
1174
+ const shas = db.prepare('SELECT DISTINCT sha FROM assets WHERE project_id = ? AND sha IS NOT NULL').all(id).map((r) => r.sha);
1175
+ db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
1176
+ db.prepare('DELETE FROM projects WHERE id = ?').run(id);
1177
+ if (root) for (const sha of shas) gcObjectIfUnreferenced(db, root, sha);
1178
+ } else {
1179
+ try { unlinkSync(projectFile(id)); } catch { /* 已不存在视为成功 */ }
1180
+ try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
1181
+ }
747
1182
  try { addDeletedProjectId(id); } catch { /* 墓碑写失败不影响删除 */ }
748
- try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
749
1183
  return { ok: true };
750
1184
  }
751
1185
  async function projectsSync() {
@@ -1009,6 +1443,11 @@ function canvasWriteNode(args) {
1009
1443
  }
1010
1444
  /** 读取项目文档:把"文件不存在/内容损坏"变成用户和模型都读得懂的报错(不泄漏绝对路径)。 */
1011
1445
  function readDoc(id) {
1446
+ if (sqlActive()) {
1447
+ const doc = readDocOrNull(id);
1448
+ if (!doc) throw new Error('项目不存在:' + id + '(先用 canvas_list_projects 查看可用项目 id)');
1449
+ return doc;
1450
+ }
1012
1451
  const f = projectFile(id);
1013
1452
  let raw;
1014
1453
  try {
@@ -1022,8 +1461,21 @@ function readDoc(id) {
1022
1461
  throw new Error('项目文件损坏,无法解析:' + id + '(可考虑重建该项目)');
1023
1462
  }
1024
1463
  }
1025
- /** 原子写项目文档:先写临时文件再改名,避免半写导致项目"消失"。 */
1464
+ /** 原子写项目文档:SQL 模式走单行事务;文件模式先写临时文件再改名,避免半写导致项目"消失"。 */
1026
1465
  function writeIcDoc(id, doc) {
1466
+ if (sqlActive()) {
1467
+ const db = getDb();
1468
+ db.prepare(`INSERT INTO projects (id, name, created_at, updated_at, bible_updated_at, summary, tombstones, doc)
1469
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1470
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, updated_at = excluded.updated_at,
1471
+ bible_updated_at = excluded.bible_updated_at, summary = excluded.summary,
1472
+ tombstones = excluded.tombstones, doc = excluded.doc`)
1473
+ .run(
1474
+ id, String(doc.name || id).slice(0, 80), doc.createdAt || Date.now(), doc.updatedAt || Date.now(),
1475
+ doc.bibleUpdatedAt || 0, JSON.stringify(docSummary(doc)), JSON.stringify(doc.tombstones || []), JSON.stringify(doc),
1476
+ );
1477
+ return;
1478
+ }
1027
1479
  const f = projectFile(id);
1028
1480
  const tmp = f + '.' + process.pid + '.tmp';
1029
1481
  writeFileSync(tmp, JSON.stringify(doc, null, 1), 'utf8');
@@ -1237,11 +1689,20 @@ function canvasRelayout(args) {
1237
1689
  }
1238
1690
  async function canvasDeleteProject(args) {
1239
1691
  const id = String((args && args.projectId) || '');
1240
- const f = projectFile(id);
1241
1692
  let name = id;
1242
- try { name = JSON.parse(readFileSync(f, 'utf8')).name || id; } catch { /* 本来就不存在 */ }
1243
- try { unlinkSync(f); } catch { /* 已删除 */ }
1244
- try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
1693
+ const prevDoc = readDocOrNull(id);
1694
+ if (prevDoc && prevDoc.name) name = prevDoc.name;
1695
+ if (sqlActive()) {
1696
+ const db = getDb();
1697
+ const root = dataRoot();
1698
+ const shas = db.prepare('SELECT DISTINCT sha FROM assets WHERE project_id = ? AND sha IS NOT NULL').all(id).map((r) => r.sha);
1699
+ db.prepare('DELETE FROM assets WHERE project_id = ?').run(id);
1700
+ db.prepare('DELETE FROM projects WHERE id = ?').run(id);
1701
+ if (root) for (const sha of shas) gcObjectIfUnreferenced(db, root, sha);
1702
+ } else {
1703
+ try { unlinkSync(projectFile(id)); } catch { /* 已删除 */ }
1704
+ try { removeDataRootProject(id); } catch { /* 数据目录镜像清理失败不影响删除 */ }
1705
+ }
1245
1706
  // 通知客户端把这个项目也从本地删掉,否则下次推送会把它复活 + 长效墓碑:拒收迟到的推送
1246
1707
  const cfg = readIcConfig();
1247
1708
  const prev = (cfg.ui && cfg.ui.deletedProjects) || [];
@@ -1326,10 +1787,17 @@ function storyboardConsistencyWarnings(bible, shots, extraNames) {
1326
1787
  }
1327
1788
  return warnings;
1328
1789
  }
1329
- /** 从数据目录的项目资产索引里取资产名(与客户端"设定集/资产中登记"的判定口径对齐)。 */
1790
+ /** 取资产名(SQL 模式查 assets 表;JSON 模式读数据目录索引;与客户端"设定集/资产中登记"的判定口径对齐)。 */
1330
1791
  function readAssetIndexNames(pid) {
1792
+ if (!pid) return [];
1793
+ if (sqlActive()) {
1794
+ try {
1795
+ const db = getDb();
1796
+ return db.prepare('SELECT name FROM assets WHERE project_id = ?').all(pid).map((r) => r.name).filter(Boolean);
1797
+ } catch { return []; }
1798
+ }
1331
1799
  const root = dataRoot();
1332
- if (!root || !pid) return [];
1800
+ if (!root) return [];
1333
1801
  try {
1334
1802
  const entry = readDataRootMeta().projects.find((p) => p.id === pid);
1335
1803
  if (!entry) return [];
@@ -1381,9 +1849,8 @@ async function canvasNodeStatus(args) {
1381
1849
  async function canvasOpen(args) {
1382
1850
  const pid = String((args && args.projectId) || '');
1383
1851
  if (pid) {
1384
- const f = projectFile(pid); // 校验 id 合法性
1385
1852
  // 项目必须真实存在:否则工具回"✅ 已打开",而前端因为找不到项目什么都不做(假成功)
1386
- if (!existsSync(f)) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
1853
+ if (!docExists(pid)) throw new Error('项目不存在:' + pid + '(先用 canvas_list_projects 查看可用项目 id)');
1387
1854
  }
1388
1855
  const cfg = readIcConfig();
1389
1856
  cfg.ui = { ...(cfg.ui || {}), openRequest: { projectId: pid || null, at: Date.now() } };
@@ -1588,7 +2055,7 @@ async function updateInstall() {
1588
2055
  export const inject = ['webServer', 'tools'];
1589
2056
 
1590
2057
  // 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
1591
- export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, storyboardConsistencyWarnings, readAssetIndexNames, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync };
2058
+ export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, storyboardConsistencyWarnings, readAssetIndexNames, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync, sqliteAvailable: () => !!SQLITE, getDb, closeDb, dbAssetsList, dbAssetsGet, dbAssetNames, dbAssetCreate, dbAssetUpdate, dbAssetDelete, dbObjectsGc, objectAbsFor, readDocOrNull, docExists, sqlActive, rpcMethods: () => _rpcMethods };
1592
2059
 
1593
2060
  function resolveModel(providers, want) {
1594
2061
  const byProvider = want && want.provider
@@ -1748,7 +2215,7 @@ export function apply(ctx) {
1748
2215
  const root = dataRoot();
1749
2216
  let exists = false;
1750
2217
  if (root) { try { exists = statSync(root).isDirectory(); } catch { /* 不存在 */ } }
1751
- return { dataRoot: root, exists, configPath: CONFIG_PATH };
2218
+ return { dataRoot: root, exists, configPath: CONFIG_PATH, sql: !!SQLITE, store: sqlActive() ? 'sqlite' : 'json' };
1752
2219
  },
1753
2220
  async dataRootSet(args) {
1754
2221
  return setDataRoot((args && args.root) ?? '');
@@ -1766,15 +2233,36 @@ export function apply(ctx) {
1766
2233
  } catch { /* 同步抛错同样忽略 */ }
1767
2234
  return { ok: true, dataRoot: root };
1768
2235
  },
1769
- // 资产元数据索引:客户端把资产记录(无 blob)写入 <项目文件夹>/assets.json(数据目录随拷随用)
2236
+ // 资产元数据写入(v1 兼容入口,0.18 起客户端走 assetCreate):SQL 模式 upsert 进 assets 表
1770
2237
  async assetIndexPut(args) {
1771
2238
  const projectId = String((args && args.projectId) || '');
1772
2239
  const asset = (args && args.asset) || null;
1773
2240
  if (!projectId || !asset || !asset.id) throw new Error('需要 projectId 与 asset');
2241
+ if (sqlActive()) return dbAssetCreate({ ...asset, projectId });
1774
2242
  return writeAssetIndex(projectId, String((args && args.projectName) || ''), [asset]);
1775
2243
  },
1776
2244
  async assetIndexBulkPut(args) {
1777
2245
  const items = Array.isArray(args && args.items) ? args.items : [];
2246
+ if (sqlActive()) {
2247
+ // 兼容旧客户端的"启动全量同步":逐条 upsert;空表不覆盖非空项目(防旧浏览器清库)
2248
+ const byProject = new Map();
2249
+ for (const it of items) {
2250
+ if (!it || !it.projectId) continue;
2251
+ const key = String(it.projectId);
2252
+ if (!byProject.has(key)) byProject.set(key, []);
2253
+ if (it.asset && it.asset.id) byProject.get(key).push(it.asset);
2254
+ }
2255
+ const db = getDb();
2256
+ let indexed = 0;
2257
+ for (const [pid, list] of byProject) {
2258
+ if (!list.length) {
2259
+ const n = db.prepare('SELECT COUNT(*) AS n FROM assets WHERE project_id = ?').get(pid).n;
2260
+ if (n > 0) continue; // 空表保护:绝不因旧客户端启动清掉服务端已有资产
2261
+ }
2262
+ for (const a of list) { upsertAssetRow(db, assetRowFromRecord({ ...a, projectId: pid })); indexed++; }
2263
+ }
2264
+ return { ok: true, indexed, projects: byProject.size };
2265
+ }
1778
2266
  const byProject = new Map();
1779
2267
  for (const it of items) {
1780
2268
  if (!it || !it.projectId) continue;
@@ -1790,10 +2278,54 @@ export function apply(ctx) {
1790
2278
  }
1791
2279
  return { ok: true, indexed, projects: byProject.size };
1792
2280
  },
1793
- // 一键迁移/对齐:旧 pid 文件夹 → 项目名文件夹 + 全部项目文档镜像
1794
- async dataRootMigrate() { return dataRootMigrate(); },
1795
- // 一键导入:换电脑后把数据目录拷过来 → 扫描 project.json/assets.json 合并进本机
1796
- async dataRootImport() { return dataRootImport(); },
2281
+ // ===== 资产库 v2(0.18):服务端是唯一事实源,任意浏览器/远程访问同一份 =====
2282
+ async assetsList(args) {
2283
+ if (sqlActive()) return dbAssetsList(args);
2284
+ return jsonAssetsList(args);
2285
+ },
2286
+ async assetsGet(args) {
2287
+ if (sqlActive()) return dbAssetsGet(args && args.ids);
2288
+ const ids = new Set((Array.isArray(args && args.ids) ? args.ids : []).map(String));
2289
+ const r = await jsonAssetsList({ limit: 200000 });
2290
+ return { ok: true, items: r.items.filter((a) => ids.has(String(a.id))) };
2291
+ },
2292
+ async assetNames(args) {
2293
+ if (sqlActive()) return dbAssetNames(args && args.projectId);
2294
+ const r = await jsonAssetsList({ projectId: args && args.projectId, limit: 200000 });
2295
+ return { ok: true, names: r.items.map((a) => ({ id: a.id, name: a.name })) };
2296
+ },
2297
+ async assetCreate(args) {
2298
+ const a = (args && args.asset) || null;
2299
+ if (!a || !a.id || !a.projectId) throw new Error('需要 asset.id 与 asset.projectId');
2300
+ if (sqlActive()) return dbAssetCreate(a);
2301
+ return writeAssetIndex(String(a.projectId), String((args && args.projectName) || ''), [a]);
2302
+ },
2303
+ async assetUpdate(args) {
2304
+ const id = String((args && args.id) || '');
2305
+ if (!id) throw new Error('需要 asset.id');
2306
+ if (sqlActive()) return dbAssetUpdate(id, (args && args.patch) || {});
2307
+ // JSON 降级:读旧索引改单条写回
2308
+ const r = await jsonAssetsList({ limit: 200000 });
2309
+ const cur = r.items.find((a) => String(a.id) === id);
2310
+ if (!cur) throw new Error('资产不存在:' + id);
2311
+ const patch = (args && args.patch) || {};
2312
+ const next = { ...cur, ...(patch.name !== undefined ? { name: String(patch.name).slice(0, 80) } : {}), ...(patch.category !== undefined ? { category: String(patch.category || 'general') } : {}) };
2313
+ return writeAssetIndex(String(next.projectId), '', [next]);
2314
+ },
2315
+ async objectsGc() { return dbObjectsGc(); },
2316
+ // 一键迁移/对齐:旧 pid 文件夹 → 项目名文件夹 + 全部项目文档镜像(v1 JSON 模式专用)
2317
+ async dataRootMigrate() {
2318
+ if (sqlActive()) return { ok: true, skipped: 'sqlite(数据目录即主存储,无需布局迁移)' };
2319
+ return dataRootMigrate();
2320
+ },
2321
+ // 一键导入:换电脑后把数据目录拷过来 → SQL 模式指向目录即自动挂载/迁移;JSON 模式扫描合并
2322
+ async dataRootImport() {
2323
+ if (sqlActive()) {
2324
+ const db = getDb();
2325
+ return { ok: true, mode: 'sqlite', migrated: JSON.parse(metaGet(db, 'migrated_counts') || '{}'), note: '数据目录已就绪:指向该目录后打开画布即为全部数据' };
2326
+ }
2327
+ return dataRootImport();
2328
+ },
1797
2329
  async pickFolder() {
1798
2330
  const r = await pickFolderDialog();
1799
2331
  return r; // { ok, path } | { ok:false, cancelled } | { ok:false, unsupported }
@@ -1814,6 +2346,9 @@ export function apply(ctx) {
1814
2346
  async canvasNodeStatus(args) { return canvasNodeStatus(args); },
1815
2347
  async canvasOpen(args) { return canvasOpen(args); },
1816
2348
  async assetDelete(args) {
2349
+ const id = String((args && args.id) || '');
2350
+ if (!id) return { ok: true };
2351
+ if (sqlActive()) return dbAssetDelete(id);
1817
2352
  const root = dataRoot();
1818
2353
  const rel = String((args && args.rel) || '');
1819
2354
  // 逻辑路径(pid)→ 物理路径(项目名文件夹):不映射会删不到真实文件
@@ -1826,9 +2361,8 @@ export function apply(ctx) {
1826
2361
  }
1827
2362
  // 同步清掉 assets.json 里的记录,保持数据目录与应用状态一致
1828
2363
  const pid = String((args && args.projectId) || '');
1829
- const assetId = String((args && args.id) || '');
1830
- if (pid && assetId) {
1831
- try { removeAssetIndexEntry(pid, String((args && args.projectName) || ''), assetId); } catch { /* 索引清理失败不影响 */ }
2364
+ if (pid) {
2365
+ try { removeAssetIndexEntry(pid, String((args && args.projectName) || ''), id); } catch { /* 索引清理失败不影响 */ }
1832
2366
  }
1833
2367
  return { ok: true };
1834
2368
  },
@@ -2204,8 +2738,14 @@ export function apply(ctx) {
2204
2738
  try { registerCanvasSkill(ctx, toolDisposers); } catch { /* skills 服务不可用时跳过 */ }
2205
2739
 
2206
2740
  // 数据根目录已设置时:启动即迁移/对齐布局(pid 文件夹 → 项目名文件夹 + 项目文档镜像),失败静默(下次设置/重启再试)
2207
- try { if (dataRoot()) dataRootMigrate(); } catch { /* 迁移失败不影响启动 */ }
2741
+ try {
2742
+ if (dataRoot()) {
2743
+ if (sqlActive()) getDb(); // 预热 SQLite(必要时触发 v1→v2 一次性迁移)
2744
+ else dataRootMigrate();
2745
+ }
2746
+ } catch { /* 迁移失败不影响启动 */ }
2208
2747
 
2748
+ _rpcMethods = methods;
2209
2749
  const registrations = [
2210
2750
  ctx.webServer.register({
2211
2751
  kind: 'exact',
@@ -2392,6 +2932,92 @@ export function apply(ctx) {
2392
2932
  }
2393
2933
  },
2394
2934
  }),
2935
+ // CAS 对象库(0.18):POST 上传(内容寻址去重)+ GET 读取(immutable + Range,远程访问与视频拖动友好)
2936
+ ctx.webServer.register({
2937
+ kind: 'exact',
2938
+ path: `/${NAME}/api/object`,
2939
+ async handler(req, res) {
2940
+ try {
2941
+ const root = dataRoot();
2942
+ if (!root) throw new Error('未设置数据根目录');
2943
+ if (req.method === 'POST' || req.method === 'PUT') {
2944
+ const odir = objectsDirFor(root);
2945
+ mkdirSync(odir, { recursive: true });
2946
+ const tmp = path.join(odir, '.tmp-' + process.pid + '-' + Date.now());
2947
+ const hash = createHash('sha256');
2948
+ let size = 0;
2949
+ const MAX_UPLOAD = 512 * 1024 * 1024;
2950
+ await new Promise((resolve, reject) => {
2951
+ const ws = createWriteStream(tmp);
2952
+ const fail = (err) => {
2953
+ try { ws.destroy(); } catch { /* 忽略 */ }
2954
+ try { unlinkSync(tmp); } catch { /* 忽略 */ }
2955
+ reject(err);
2956
+ };
2957
+ req.on('data', (c) => { size += c.length; hash.update(c); if (size > MAX_UPLOAD) fail(new Error('文件过大(上限 512MB)')); });
2958
+ req.on('error', fail);
2959
+ req.on('aborted', () => fail(new Error('上传中断')));
2960
+ ws.on('error', fail);
2961
+ ws.on('finish', resolve);
2962
+ req.pipe(ws);
2963
+ });
2964
+ const sha = hash.digest('hex');
2965
+ const dest = objectAbsFor(root, sha);
2966
+ let deduped = false;
2967
+ if (existsSync(dest)) {
2968
+ try { unlinkSync(tmp); } catch { /* 忽略 */ }
2969
+ deduped = true; // 内容已存在:内容寻址天然去重
2970
+ } else {
2971
+ mkdirSync(path.dirname(dest), { recursive: true });
2972
+ renameSync(tmp, dest);
2973
+ }
2974
+ res.setHeader('content-type', 'application/json; charset=utf-8');
2975
+ res.end(JSON.stringify({ ok: true, sha, size, deduped }));
2976
+ return;
2977
+ }
2978
+ if (req.method === 'GET') {
2979
+ const u = new URL(req.url, 'http://localhost');
2980
+ const sha = String(u.searchParams.get('sha') || '').toLowerCase();
2981
+ if (!/^[0-9a-f]{64}$/.test(sha)) { res.statusCode = 400; res.end('非法 sha256'); return; }
2982
+ const abs = objectAbsFor(root, sha);
2983
+ let st;
2984
+ try { st = statSync(abs); } catch { res.statusCode = 404; res.end('对象不存在'); return; }
2985
+ const mime = String(u.searchParams.get('mime') || 'application/octet-stream');
2986
+ res.setHeader('content-type', /^[\w.+-]+\/[\w.+-]+$/.test(mime) ? mime : 'application/octet-stream');
2987
+ res.setHeader('accept-ranges', 'bytes');
2988
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable'); // 内容寻址:内容永不变,可永久缓存
2989
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
2990
+ if (m && (m[1] !== '' || m[2] !== '')) {
2991
+ let start;
2992
+ let end;
2993
+ if (m[1] === '') { start = Math.max(0, st.size - Number(m[2])); end = st.size - 1; }
2994
+ else { start = parseInt(m[1], 10); end = m[2] === '' ? st.size - 1 : Math.min(parseInt(m[2], 10), st.size - 1); }
2995
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= st.size) {
2996
+ res.statusCode = 416;
2997
+ res.setHeader('content-range', 'bytes */' + st.size);
2998
+ res.end();
2999
+ return;
3000
+ }
3001
+ res.statusCode = 206;
3002
+ res.setHeader('content-range', `bytes ${start}-${end}/${st.size}`);
3003
+ res.setHeader('content-length', String(end - start + 1));
3004
+ pipeFile(abs, { start, end }, res);
3005
+ } else {
3006
+ res.setHeader('content-length', String(st.size));
3007
+ pipeFile(abs, {}, res);
3008
+ }
3009
+ return;
3010
+ }
3011
+ res.statusCode = 405;
3012
+ res.setHeader('content-type', 'application/json; charset=utf-8');
3013
+ res.end(JSON.stringify({ error: '仅接受 GET/POST/PUT' }));
3014
+ } catch (e) {
3015
+ res.statusCode = 400;
3016
+ res.setHeader('content-type', 'application/json; charset=utf-8');
3017
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
3018
+ }
3019
+ },
3020
+ }),
2395
3021
  // 插件自带静态资源(帮助与反馈用的微信群二维码等):白名单 assets/ 目录,拒绝路径穿越
2396
3022
  ctx.webServer.register({
2397
3023
  kind: 'exact',