@musnows/scriverse 0.3.8 → 0.3.10

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/dist/store.js CHANGED
@@ -4,8 +4,11 @@ import { AppError, notFound } from "./errors.js";
4
4
  import { accountReference, logger } from "./logger.js";
5
5
  import { paginated, paginationSql } from "./pagination.js";
6
6
  import { currentRequestActor } from "./request-context.js";
7
+ import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
7
8
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
8
9
  export const versionedEntityTypes = [
10
+ "work",
11
+ "volume",
9
12
  "setting",
10
13
  "race",
11
14
  "organization",
@@ -36,7 +39,44 @@ export class Store {
36
39
  this.db = db;
37
40
  this.backfillEntityVersionBaselines();
38
41
  }
42
+ currentEntityVersionNo(type, entityId) {
43
+ const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
44
+ return numberValue(row ?? {}, "version_no");
45
+ }
46
+ currentChapterVersionNo(chapterId) {
47
+ return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no");
48
+ }
49
+ currentCharacterVersionNo(characterId) {
50
+ return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM character_versions WHERE character_id = ?", characterId) ?? {}, "version_no");
51
+ }
52
+ currentCharacterSectionVersionNo(sectionId) {
53
+ return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM character_profile_section_versions WHERE section_id = ?", sectionId) ?? {}, "version_no");
54
+ }
55
+ assertExpectedVersion(type, entityId, expectedVersionNo, entityName, currentVersionNo = this.currentEntityVersionNo(type, entityId)) {
56
+ if (expectedVersionNo === undefined || expectedVersionNo === currentVersionNo)
57
+ return;
58
+ throw new AppError(409, "VERSION_CONFLICT", `${entityName}已发生变化,请刷新后重试`, {
59
+ entityType: type,
60
+ entityId,
61
+ expectedVersionNo,
62
+ currentVersionNo
63
+ });
64
+ }
65
+ assertExpectedRevision(entityType, entityId, expectedVersionNo, entityName, currentVersionNo) {
66
+ if (expectedVersionNo === undefined || expectedVersionNo === currentVersionNo)
67
+ return;
68
+ throw new AppError(409, "VERSION_CONFLICT", `${entityName}已发生变化,请刷新后重试`, {
69
+ entityType,
70
+ entityId,
71
+ expectedVersionNo,
72
+ currentVersionNo
73
+ });
74
+ }
39
75
  versionedEntity(type, entityId) {
76
+ if (type === "work")
77
+ return this.getWork(entityId);
78
+ if (type === "volume")
79
+ return this.getVolume(entityId);
40
80
  if (type === "setting")
41
81
  return this.getSetting(entityId);
42
82
  if (type === "race")
@@ -68,6 +108,25 @@ export class Store {
68
108
  }
69
109
  }
70
110
  versionedEntitySnapshot(type, entity) {
111
+ if (type === "work")
112
+ return {
113
+ title: entity.title,
114
+ author: entity.author,
115
+ description: entity.description,
116
+ language: entity.language,
117
+ coverUrl: entity.coverUrl,
118
+ tags: entity.tags,
119
+ ownerUserId: entity.ownerUserId
120
+ };
121
+ if (type === "volume")
122
+ return {
123
+ title: entity.title,
124
+ kind: entity.kind,
125
+ source: entity.source,
126
+ description: entity.description,
127
+ keywords: entity.keywords,
128
+ sortOrder: entity.sortOrder
129
+ };
71
130
  if (type === "setting")
72
131
  return {
73
132
  title: entity.title,
@@ -165,11 +224,13 @@ export class Store {
165
224
  }
166
225
  const versionNo = latest ? numberValue(latest, "version_no") + 1 : 1;
167
226
  this.db.run(`INSERT INTO entity_versions (id, work_id, entity_type, entity_id, version_no, snapshot_json, source, source_ref, change_note, created_at, created_by_user_id)
168
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("entityVersion"), String(entity.workId), type, entityId, versionNo, snapshotJson, source, sourceRef, changeNote.trim(), timestamp ?? now(), currentRequestActor()?.userId ?? null);
227
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("entityVersion"), type === "work" ? entityId : String(entity.workId), type, entityId, versionNo, snapshotJson, source, sourceRef, changeNote.trim(), timestamp ?? now(), currentRequestActor()?.userId ?? null);
169
228
  return versionNo;
170
229
  }
171
230
  backfillEntityVersionBaselines() {
172
231
  const entities = [
232
+ ...this.db.all("SELECT id, updated_at FROM works").map((row) => ["work", requiredString(row, "id"), requiredString(row, "updated_at")]),
233
+ ...this.db.all("SELECT id, updated_at FROM volumes").map((row) => ["volume", requiredString(row, "id"), requiredString(row, "updated_at")]),
173
234
  ...this.db.all("SELECT id, updated_at FROM settings").map((row) => ["setting", requiredString(row, "id"), requiredString(row, "updated_at")]),
174
235
  ...this.db.all("SELECT id, updated_at FROM races").map((row) => ["race", requiredString(row, "id"), requiredString(row, "updated_at")]),
175
236
  ...this.db.all("SELECT id, updated_at FROM organizations").map((row) => ["organization", requiredString(row, "id"), requiredString(row, "updated_at")]),
@@ -228,7 +289,7 @@ export class Store {
228
289
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
229
290
  })), pagination);
230
291
  }
231
- restoreEntityVersion(type, entityId, versionNo) {
292
+ restoreEntityVersion(type, entityId, versionNo, expectedVersionNo) {
232
293
  const version = this.db.get("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? AND version_no = ?", type, entityId, versionNo);
233
294
  if (!version)
234
295
  throw notFound("历史版本");
@@ -239,31 +300,57 @@ export class Store {
239
300
  const changeNote = `恢复至 v${versionNo}`;
240
301
  const workId = requiredString(version, "work_id");
241
302
  const existing = this.tryVersionedEntity(type, entityId);
303
+ const currentVersionNo = existing
304
+ ? type === "work" ? Number(existing.versionNo) : type === "volume" ? Number(existing.versionNo) : this.currentEntityVersionNo(type, entityId)
305
+ : this.currentEntityVersionNo(type, entityId);
306
+ this.assertExpectedVersion(type, entityId, expectedVersionNo, type === "work" ? "作品" : type === "volume" ? "分卷" : "创作资料", currentVersionNo);
242
307
  let restored;
243
308
  if (!existing) {
244
309
  restored = this.recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote);
245
310
  }
311
+ else if (type === "work")
312
+ restored = this.updateWork(entityId, snapshot, expectedVersionNo, "restore", sourceRef, changeNote);
313
+ else if (type === "volume")
314
+ restored = this.updateVolume(entityId, snapshot, expectedVersionNo, "restore", sourceRef, changeNote);
246
315
  else if (type === "setting")
247
- restored = this.updateSetting(entityId, snapshot, "restore", sourceRef, changeNote);
316
+ restored = this.updateSetting(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
248
317
  else if (type === "race")
249
- restored = this.updateRace(entityId, snapshot, "restore", sourceRef, changeNote);
318
+ restored = this.updateRace(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
250
319
  else if (type === "organization")
251
- restored = this.updateOrganization(entityId, snapshot, "restore", sourceRef, changeNote);
320
+ restored = this.updateOrganization(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
252
321
  else if (type === "timeline-track")
253
- restored = this.updateTimelineTrack(entityId, snapshot, "restore", sourceRef, changeNote);
322
+ restored = this.updateTimelineTrack(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
254
323
  else if (type === "timeline-event")
255
- restored = this.updateTimelineEvent(entityId, snapshot, "restore", sourceRef, changeNote);
324
+ restored = this.updateTimelineEvent(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
256
325
  else if (type === "relationship")
257
- restored = this.updateRelationship(entityId, snapshot, "restore", sourceRef, changeNote);
326
+ restored = this.updateRelationship(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
258
327
  else if (type === "chapter-outline")
259
- restored = this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote);
328
+ restored = this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
260
329
  else
261
- restored = this.updateForeshadow(entityId, snapshot, "restore", sourceRef, changeNote);
330
+ restored = this.updateForeshadow(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
262
331
  const currentVersion = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
263
332
  return { ...restored, versionNo: numberValue(currentVersion ?? {}, "version_no") };
264
333
  }
265
334
  recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote) {
266
335
  this.getWork(workId);
336
+ if (type === "work") {
337
+ return this.db.transaction(() => {
338
+ const ownerUserId = typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null;
339
+ const timestamp = now();
340
+ this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, version_no, created_at, updated_at, owner_user_id)
341
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), timestamp, timestamp, ownerUserId);
342
+ if (ownerUserId) {
343
+ this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", entityId, ownerUserId, ownerUserId, timestamp);
344
+ }
345
+ const versionNo = this.recordEntityVersion("work", entityId, "restore", sourceRef, changeNote, timestamp);
346
+ this.db.run("UPDATE works SET version_no = ? WHERE id = ?", versionNo, entityId);
347
+ this.audit(entityId, "work.restored", "work", entityId, { sourceRef });
348
+ return this.getWork(entityId);
349
+ });
350
+ }
351
+ if (type === "volume") {
352
+ return this.db.transaction(() => this.insertVolumeWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote));
353
+ }
267
354
  if (type === "setting") {
268
355
  return this.insertSettingWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
269
356
  }
@@ -310,6 +397,7 @@ export class Store {
310
397
  if (actor) {
311
398
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId, actor.userId, actor.userId, timestamp);
312
399
  }
400
+ this.recordEntityVersion("work", workId, "create", null, "建立作品", timestamp);
313
401
  this.audit(workId, "work.created", "work", workId);
314
402
  });
315
403
  return this.getWork(workId);
@@ -431,34 +519,45 @@ export class Store {
431
519
  });
432
520
  return this.getWorkAiSettings(workId);
433
521
  }
434
- updateWork(workId, input) {
435
- const current = this.getWork(workId);
436
- const timestamp = now();
437
- this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?, updated_at = ?
438
- WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), timestamp, workId);
439
- this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input) });
522
+ updateWork(workId, input, expectedVersionNo, source = "manual", sourceRef = null, changeNote = "") {
523
+ this.db.transaction(() => {
524
+ const current = this.getWork(workId);
525
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
526
+ const timestamp = now();
527
+ this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?, version_no = version_no + 1, updated_at = ?
528
+ WHERE id = ?`, input.title ?? String(current.title), input.author ?? String(current.author), input.description ?? String(current.description), input.language ?? String(current.language), input.coverUrl === undefined ? current.coverUrl : input.coverUrl, JSON.stringify(input.tags ?? current.tags), timestamp, workId);
529
+ this.recordEntityVersion("work", workId, source, sourceRef, changeNote || "更新作品信息", timestamp);
530
+ this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input), versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
531
+ });
440
532
  return this.getWork(workId);
441
533
  }
442
- deleteWork(workId) {
534
+ deleteWork(workId, expectedVersionNo) {
443
535
  const work = this.getWork(workId);
444
536
  const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
445
537
  .map((row) => requiredString(row, "storage_key"));
446
538
  this.db.transaction(() => {
539
+ const current = this.getWork(workId);
540
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
541
+ this.recordEntityVersion("work", workId, "delete", null, "删除作品");
447
542
  this.audit(null, "work.deleted", "work", workId, { title: work.title });
448
543
  this.db.run("DELETE FROM works WHERE id = ?", workId);
449
544
  });
450
545
  return storageKeys.filter((storageKey) => Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) === 0);
451
546
  }
452
- setWorkCover(workId, mimeType, content) {
453
- this.getWork(workId);
454
- const timestamp = now();
547
+ setWorkCover(workId, mimeType, content, expectedVersionNo) {
455
548
  const sha256 = createHash("sha256").update(content).digest("hex");
456
- this.db.run(`INSERT INTO work_covers (work_id, mime_type, content, byte_length, sha256, updated_at)
457
- VALUES (?, ?, ?, ?, ?, ?)
458
- ON CONFLICT(work_id) DO UPDATE SET mime_type = excluded.mime_type, content = excluded.content,
459
- byte_length = excluded.byte_length, sha256 = excluded.sha256, updated_at = excluded.updated_at`, workId, mimeType, content, content.byteLength, sha256, timestamp);
460
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
461
- this.audit(workId, "work.cover.updated", "work", workId, { mimeType, byteLength: content.byteLength, sha256 });
549
+ this.db.transaction(() => {
550
+ const current = this.getWork(workId);
551
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
552
+ const timestamp = now();
553
+ this.db.run(`INSERT INTO work_covers (work_id, mime_type, content, byte_length, sha256, updated_at)
554
+ VALUES (?, ?, ?, ?, ?, ?)
555
+ ON CONFLICT(work_id) DO UPDATE SET mime_type = excluded.mime_type, content = excluded.content,
556
+ byte_length = excluded.byte_length, sha256 = excluded.sha256, updated_at = excluded.updated_at`, workId, mimeType, content, content.byteLength, sha256, timestamp);
557
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
558
+ this.recordEntityVersion("work", workId, "manual", null, "更新作品封面", timestamp);
559
+ this.audit(workId, "work.cover.updated", "work", workId, { mimeType, byteLength: content.byteLength, sha256 });
560
+ });
462
561
  return this.getWork(workId);
463
562
  }
464
563
  getWorkCover(workId) {
@@ -474,11 +573,16 @@ export class Store {
474
573
  updatedAt: requiredString(row, "updated_at")
475
574
  };
476
575
  }
477
- deleteWorkCover(workId) {
478
- this.getWork(workId);
479
- this.db.run("DELETE FROM work_covers WHERE work_id = ?", workId);
480
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", now(), workId);
481
- this.audit(workId, "work.cover.deleted", "work", workId);
576
+ deleteWorkCover(workId, expectedVersionNo) {
577
+ this.db.transaction(() => {
578
+ const current = this.getWork(workId);
579
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
580
+ const timestamp = now();
581
+ this.db.run("DELETE FROM work_covers WHERE work_id = ?", workId);
582
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
583
+ this.recordEntityVersion("work", workId, "manual", null, "删除作品封面", timestamp);
584
+ this.audit(workId, "work.cover.deleted", "work", workId);
585
+ });
482
586
  }
483
587
  getWorkTree(workId) {
484
588
  const work = this.getWork(workId);
@@ -500,6 +604,9 @@ export class Store {
500
604
  }
501
605
  getWorkDirectory(workId) {
502
606
  const work = this.getWork(workId);
607
+ const permissions = work.modulePermissions;
608
+ if (permissions.prose === "none")
609
+ return { ...work, volumes: [] };
503
610
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
504
611
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
505
612
  analysis_status, excluded_from_analysis, created_at, updated_at
@@ -520,6 +627,9 @@ export class Store {
520
627
  }
521
628
  getWorkDirectoryPage(workId, pagination) {
522
629
  const work = this.getWork(workId);
630
+ const permissions = work.modulePermissions;
631
+ if (permissions.prose === "none")
632
+ return { ...work, volumes: [], directoryPage: paginated([], pagination) };
523
633
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
524
634
  const page = paginationSql(pagination);
525
635
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
@@ -577,7 +687,7 @@ export class Store {
577
687
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
578
688
  })), pagination);
579
689
  }
580
- restoreFileVersion(workId, fileVersionId) {
690
+ restoreFileVersion(workId, fileVersionId, expectedVersionNo) {
581
691
  this.getWork(workId);
582
692
  const version = this.db.get("SELECT * FROM file_versions WHERE id = ? AND work_id = ?", fileVersionId, workId);
583
693
  if (!version)
@@ -585,6 +695,8 @@ export class Store {
585
695
  const snapshot = json(requiredString(version, "snapshot_json"), {});
586
696
  const volumes = Array.isArray(snapshot.volumes) ? snapshot.volumes : [];
587
697
  return this.db.transaction(() => {
698
+ const current = this.getWork(workId);
699
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
588
700
  const currentTree = this.getWorkTree(workId);
589
701
  const currentChapters = this.db.all("SELECT content FROM chapters WHERE work_id = ?", workId);
590
702
  const wordCount = currentChapters.reduce((sum, row) => sum + countWords(requiredString(row, "content")), 0);
@@ -596,12 +708,21 @@ export class Store {
596
708
  const timestamp = now();
597
709
  this.db.run(`INSERT INTO file_versions (id, work_id, file_name, file_type, word_count, paragraph_count, warnings_json, snapshot_json, created_at, created_by_user_id)
598
710
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, restorePointId, workId, `before-restore:${requiredString(version, "file_name")}`, "snapshot", wordCount, paragraphCount, "[]", JSON.stringify(currentTree), timestamp, currentRequestActor()?.userId ?? null);
711
+ for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
712
+ this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "替换作品树前保存分卷历史");
713
+ }
599
714
  this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
600
715
  for (const volume of volumes) {
601
716
  const volumeId = id("volume");
602
717
  const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
603
- this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, created_at, updated_at)
604
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, volumeId, workId, String(volume.title ?? "正文"), String(volume.kind ?? "main"), String(volume.source ?? "manual"), String(volume.description ?? ""), JSON.stringify(Array.isArray(volume.keywords) ? this.normalizeVolumeKeywords(volume.keywords) : []), Number(volume.sortOrder ?? 0), timestamp, timestamp);
718
+ this.insertVolumeWithId(workId, volumeId, {
719
+ title: String(volume.title ?? "正文"),
720
+ kind: String(volume.kind ?? "main"),
721
+ source: String(volume.source ?? "manual"),
722
+ description: String(volume.description ?? ""),
723
+ keywords: Array.isArray(volume.keywords) ? volume.keywords : [],
724
+ sortOrder: Number(volume.sortOrder ?? 0)
725
+ }, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`);
605
726
  for (const chapter of chapters) {
606
727
  const chapterType = (["正文", "设定", "作者的话", "其他"].includes(String(chapter.chapterType))
607
728
  ? String(chapter.chapterType)
@@ -609,7 +730,8 @@ export class Store {
609
730
  this.insertChapter(workId, volumeId, String(chapter.title ?? "未命名章节"), String(chapter.content ?? ""), Number(chapter.sortOrder ?? 0), "restore", fileVersionId, chapterType);
610
731
  }
611
732
  }
612
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
733
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
734
+ this.recordEntityVersion("work", workId, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`, timestamp);
613
735
  this.audit(workId, "file.restored", "file-version", fileVersionId, { restorePointId });
614
736
  return {
615
737
  fileVersionId: restorePointId,
@@ -618,55 +740,84 @@ export class Store {
618
740
  };
619
741
  });
620
742
  }
621
- importNovel(workId, fileName, fileType, parsed) {
743
+ importNovel(workId, fileName, fileType, parsed, mode = "overwrite", expectedVersionNo) {
622
744
  this.getWork(workId);
623
745
  let result = {};
624
- this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed); });
746
+ this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed, mode, expectedVersionNo); });
625
747
  return { ...result, tree: this.getWorkDirectory(workId) };
626
748
  }
627
749
  createImportedWork(input, fileName, fileType, parsed) {
628
750
  return this.db.transaction(() => {
629
751
  const work = this.createWork(input);
630
- const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed);
752
+ const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed, undefined, undefined, false);
631
753
  return { ...imported, work: this.getWork(String(work.id)) };
632
754
  });
633
755
  }
634
- importNovelInTransaction(workId, fileName, fileType, parsed) {
756
+ importNovelInTransaction(workId, fileName, fileType, parsed, mode = "overwrite", expectedVersionNo, bumpWorkVersion = true) {
757
+ const current = this.getWork(workId);
758
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
635
759
  const fileVersionId = id("file");
636
760
  const timestamp = now();
637
761
  const snapshot = this.getWorkTree(workId);
638
762
  this.db.run(`INSERT INTO file_versions (id, work_id, file_name, file_type, word_count, paragraph_count, warnings_json, snapshot_json, created_at, created_by_user_id)
639
763
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, fileVersionId, workId, fileName, fileType, parsed.wordCount, parsed.paragraphCount, JSON.stringify(parsed.warnings), JSON.stringify(snapshot), timestamp, currentRequestActor()?.userId ?? null);
640
- this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
764
+ let volumeOrderOffset = 0;
765
+ if (mode === "overwrite") {
766
+ for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
767
+ this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "导入前保存分卷历史");
768
+ }
769
+ this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
770
+ }
771
+ else {
772
+ const lastVolume = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ?", workId);
773
+ volumeOrderOffset = numberValue(lastVolume ?? {}, "value") + 1;
774
+ }
775
+ let firstImportedChapterId = null;
641
776
  for (const volume of parsed.volumes) {
642
777
  const volumeId = id("volume");
643
- this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, sort_order, created_at, updated_at)
644
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, volumeId, workId, volume.title, volume.kind, volume.source, volume.order, timestamp, timestamp);
778
+ this.insertVolumeWithId(workId, volumeId, {
779
+ title: volume.title,
780
+ kind: volume.kind,
781
+ source: volume.source,
782
+ sortOrder: volumeOrderOffset + volume.order
783
+ }, "import", fileVersionId, "导入分卷");
645
784
  for (const chapter of volume.chapters) {
646
- this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.order, "import", fileVersionId, chapter.chapterType);
785
+ const chapterId = this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.order, "import", fileVersionId, chapter.chapterType);
786
+ firstImportedChapterId ??= chapterId;
647
787
  }
648
788
  }
649
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
789
+ if (bumpWorkVersion) {
790
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
791
+ this.recordEntityVersion("work", workId, "import", fileVersionId, "导入作品正文", timestamp);
792
+ }
650
793
  this.audit(workId, "work.imported", "file-version", fileVersionId, {
651
794
  fileName,
795
+ mode,
652
796
  volumeCount: parsed.volumes.length,
653
797
  chapterCount: parsed.volumes.reduce((sum, volume) => sum + volume.chapters.length, 0)
654
798
  });
655
799
  return {
656
800
  fileVersionId,
801
+ firstImportedChapterId,
802
+ mode,
657
803
  warnings: parsed.warnings,
658
804
  wordCount: parsed.wordCount,
659
805
  paragraphCount: parsed.paragraphCount
660
806
  };
661
807
  }
662
808
  createVolume(workId, input) {
809
+ return this.db.transaction(() => this.insertVolumeWithId(workId, id("volume"), input, "create", null, "建立分卷"));
810
+ }
811
+ insertVolumeWithId(workId, volumeId, input, source = "create", sourceRef = null, changeNote = "") {
663
812
  this.getWork(workId);
664
- const volumeId = id("volume");
665
813
  const timestamp = now();
666
814
  const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ?", workId);
667
- this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, created_at, updated_at)
668
- VALUES (?, ?, ?, ?, 'manual', ?, ?, ?, ?, ?)`, volumeId, workId, input.title, input.kind ?? "main", input.description?.trim() ?? "", JSON.stringify(this.normalizeVolumeKeywords(input.keywords ?? [])), numberValue(last ?? {}, "value") + 1, timestamp, timestamp);
669
- this.audit(workId, "volume.created", "volume", volumeId);
815
+ this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, version_no, created_at, updated_at)
816
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, volumeId, workId, input.title, input.kind ?? "main", input.source ?? "manual", input.description?.trim() ?? "", JSON.stringify(this.normalizeVolumeKeywords(input.keywords ?? [])), input.sortOrder ?? numberValue(last ?? {}, "value") + 1, timestamp, timestamp);
817
+ const versionNo = this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "建立分卷", timestamp);
818
+ if (versionNo !== 1)
819
+ this.db.run("UPDATE volumes SET version_no = ? WHERE id = ?", versionNo, volumeId);
820
+ this.audit(workId, source === "restore" ? "volume.restored" : "volume.created", "volume", volumeId, { source, sourceRef });
670
821
  return this.getVolume(volumeId);
671
822
  }
672
823
  getVolume(volumeId) {
@@ -675,20 +826,30 @@ export class Store {
675
826
  throw notFound("卷");
676
827
  return this.mapVolume(row);
677
828
  }
678
- updateVolume(volumeId, input) {
679
- const current = this.getVolume(volumeId);
680
- this.db.run("UPDATE volumes SET title = ?, kind = ?, description = ?, keywords_json = ?, sort_order = ?, source = 'manual', updated_at = ? WHERE id = ?", input.title ?? String(current.title), input.kind ?? String(current.kind), input.description?.trim() ?? String(current.description), JSON.stringify(input.keywords === undefined ? current.keywords : this.normalizeVolumeKeywords(input.keywords)), input.sortOrder ?? Number(current.sortOrder), now(), volumeId);
681
- this.audit(String(current.workId), "volume.updated", "volume", volumeId, input);
829
+ updateVolume(volumeId, input, expectedVersionNo, source = "manual", sourceRef = null, changeNote = "") {
830
+ this.db.transaction(() => {
831
+ const current = this.getVolume(volumeId);
832
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
833
+ const timestamp = now();
834
+ this.db.run("UPDATE volumes SET title = ?, kind = ?, description = ?, keywords_json = ?, sort_order = ?, source = ?, version_no = version_no + 1, updated_at = ? WHERE id = ?", input.title ?? String(current.title), input.kind ?? String(current.kind), input.description?.trim() ?? String(current.description), JSON.stringify(input.keywords === undefined ? current.keywords : this.normalizeVolumeKeywords(input.keywords)), input.sortOrder ?? Number(current.sortOrder), source === "restore" ? String(current.source) : "manual", timestamp, volumeId);
835
+ this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "更新分卷信息", timestamp);
836
+ this.audit(String(current.workId), "volume.updated", "volume", volumeId, { ...input, versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
837
+ });
682
838
  return this.getVolume(volumeId);
683
839
  }
684
- deleteVolume(volumeId) {
840
+ deleteVolume(volumeId, expectedVersionNo) {
685
841
  const volume = this.getVolume(volumeId);
686
842
  const count = this.db.get("SELECT COUNT(*) AS value FROM chapters WHERE volume_id = ?", volumeId);
687
843
  if (numberValue(count ?? {}, "value") > 0) {
688
844
  throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
689
845
  }
690
- this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
691
- this.audit(String(volume.workId), "volume.deleted", "volume", volumeId);
846
+ this.db.transaction(() => {
847
+ const current = this.getVolume(volumeId);
848
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
849
+ this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
850
+ this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
851
+ this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
852
+ });
692
853
  }
693
854
  createChapter(workId, input) {
694
855
  this.getWork(workId);
@@ -812,8 +973,9 @@ export class Store {
812
973
  summary: requiredString(row, "summary")
813
974
  }));
814
975
  }
815
- saveChapter(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
976
+ saveChapter(chapterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
816
977
  const current = this.getChapter(chapterId);
978
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(current.versionNo));
817
979
  const nextTitle = input.title ?? String(current.title);
818
980
  const nextContent = input.content === undefined ? String(current.content) : normalizeParagraphSpacing(input.content);
819
981
  const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
@@ -826,6 +988,8 @@ export class Store {
826
988
  const timestamp = now();
827
989
  const versionNo = Number(current.versionNo) + (hasTextChange ? 1 : 0);
828
990
  this.db.transaction(() => {
991
+ const lockedCurrent = this.getChapter(chapterId);
992
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
829
993
  this.db.run(`UPDATE chapters SET title = ?, content = ?, chapter_type = ?, word_count = ?, version_no = ?, analysis_status = ?,
830
994
  excluded_from_analysis = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, timestamp, chapterId);
831
995
  if (hasTextChange)
@@ -853,15 +1017,16 @@ export class Store {
853
1017
  });
854
1018
  return this.getChapter(chapterId);
855
1019
  }
856
- restoreChapter(chapterId, versionNo) {
1020
+ restoreChapter(chapterId, versionNo, expectedVersionNo) {
857
1021
  const version = this.db.get("SELECT * FROM chapter_versions WHERE chapter_id = ? AND version_no = ?", chapterId, versionNo);
858
1022
  if (!version)
859
1023
  throw notFound("章节版本");
860
1024
  const existing = this.db.get("SELECT id FROM chapters WHERE id = ?", chapterId);
861
1025
  if (!existing) {
1026
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", this.currentChapterVersionNo(chapterId));
862
1027
  return this.recreateChapterFromVersion(chapterId, version);
863
1028
  }
864
- return this.saveChapter(chapterId, { title: requiredString(version, "title"), content: requiredString(version, "content") }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
1029
+ return this.saveChapter(chapterId, { title: requiredString(version, "title"), content: requiredString(version, "content") }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
865
1030
  }
866
1031
  recreateChapterFromVersion(chapterId, version) {
867
1032
  const workId = requiredString(version, "work_id");
@@ -902,12 +1067,15 @@ export class Store {
902
1067
  });
903
1068
  return this.getChapter(chapterId);
904
1069
  }
905
- moveChapter(chapterId, input) {
1070
+ moveChapter(chapterId, input, expectedVersionNo) {
906
1071
  const chapter = this.getChapter(chapterId);
1072
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
907
1073
  const volume = this.getVolume(input.volumeId);
908
1074
  if (volume.workId !== chapter.workId)
909
1075
  throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
910
1076
  this.db.transaction(() => {
1077
+ const lockedChapter = this.getChapter(chapterId);
1078
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
911
1079
  this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
912
1080
  WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
913
1081
  AND json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?`, now(), String(chapter.workId), String(chapter.volumeId));
@@ -917,11 +1085,14 @@ export class Store {
917
1085
  });
918
1086
  return this.getChapter(chapterId);
919
1087
  }
920
- deleteChapter(chapterId) {
1088
+ deleteChapter(chapterId, expectedVersionNo) {
921
1089
  const chapter = this.getChapter(chapterId);
1090
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
922
1091
  const timestamp = now();
923
1092
  const versionNo = Number(chapter.versionNo) + 1;
924
1093
  this.db.transaction(() => {
1094
+ const lockedChapter = this.getChapter(chapterId);
1095
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
925
1096
  this.db.run("UPDATE chapters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
926
1097
  this.insertChapterVersionRow({
927
1098
  workId: String(chapter.workId),
@@ -1027,13 +1198,21 @@ export class Store {
1027
1198
  const actor = currentRequestActor();
1028
1199
  const ownerUserId = optionalString(row, "owner_user_id");
1029
1200
  const membership = actor
1030
- ? this.db.get("SELECT role FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
1201
+ ? this.db.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
1031
1202
  : undefined;
1203
+ const membershipRole = String(membership?.role ?? "");
1204
+ const ownerAccess = ownerUserId === actor?.userId;
1205
+ const adminAccess = actor?.role === "admin" && actor.authentication !== "api-key";
1206
+ const modulePermissions = !actor || ownerAccess || adminAccess
1207
+ ? fullWorkModulePermissions()
1208
+ : membershipRole
1209
+ ? storedWorkModulePermissions(membershipRole, optionalString(membership ?? {}, "permissions_json"))
1210
+ : emptyWorkModulePermissions();
1032
1211
  const accessRole = ownerUserId === actor?.userId
1033
1212
  ? "owner"
1034
- : actor?.role === "admin"
1213
+ : adminAccess
1035
1214
  ? "admin"
1036
- : ["editor", "viewer"].includes(String(membership?.role ?? "")) ? String(membership?.role) : null;
1215
+ : membershipRole ? classifyWorkModulePermissions(modulePermissions) : null;
1037
1216
  const count = this.db.get("SELECT COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count FROM chapters WHERE work_id = ?", requiredString(row, "id"));
1038
1217
  const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
1039
1218
  return {
@@ -1046,10 +1225,12 @@ export class Store {
1046
1225
  ? `/api/works/${encodeURIComponent(requiredString(row, "id"))}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
1047
1226
  : optionalString(row, "cover_url"),
1048
1227
  tags: json(requiredString(row, "tags_json"), []),
1228
+ versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", requiredString(row, "id")),
1049
1229
  ownerUserId,
1050
1230
  accessRole,
1051
- chapterCount: numberValue(count ?? {}, "chapter_count"),
1052
- wordCount: numberValue(count ?? {}, "word_count"),
1231
+ modulePermissions,
1232
+ chapterCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "chapter_count"),
1233
+ wordCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "word_count"),
1053
1234
  createdAt: requiredString(row, "created_at"),
1054
1235
  updatedAt: requiredString(row, "updated_at")
1055
1236
  };
@@ -1064,6 +1245,7 @@ export class Store {
1064
1245
  description: optionalString(row, "description") ?? "",
1065
1246
  keywords: json(optionalString(row, "keywords_json"), []),
1066
1247
  sortOrder: numberValue(row, "sort_order"),
1248
+ versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("volume", requiredString(row, "id")),
1067
1249
  createdAt: requiredString(row, "created_at"),
1068
1250
  updatedAt: requiredString(row, "updated_at")
1069
1251
  };
@@ -1157,11 +1339,13 @@ export class Store {
1157
1339
  updatedAt: optionalString(row, "updated_at")
1158
1340
  })), pagination);
1159
1341
  }
1160
- upsertChapterOutline(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
1342
+ upsertChapterOutline(chapterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1161
1343
  const chapter = this.getChapter(chapterId);
1162
1344
  const current = this.getChapterOutline(chapterId);
1163
1345
  const timestamp = now();
1164
1346
  this.db.transaction(() => {
1347
+ if (current)
1348
+ this.assertExpectedVersion("chapter-outline", chapterId, expectedVersionNo, "章节大纲");
1165
1349
  this.db.run(`INSERT INTO chapter_outlines (chapter_id, goal, conflict, turning_point, notes, status, created_at, updated_at)
1166
1350
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1167
1351
  ON CONFLICT(chapter_id) DO UPDATE SET goal = excluded.goal, conflict = excluded.conflict,
@@ -1172,12 +1356,13 @@ export class Store {
1172
1356
  });
1173
1357
  return this.getChapterOutline(chapterId);
1174
1358
  }
1175
- deleteChapterOutline(chapterId) {
1359
+ deleteChapterOutline(chapterId, expectedVersionNo) {
1176
1360
  const chapter = this.getChapter(chapterId);
1177
1361
  const outline = this.getChapterOutline(chapterId);
1178
1362
  if (!outline)
1179
1363
  return;
1180
1364
  this.db.transaction(() => {
1365
+ this.assertExpectedVersion("chapter-outline", chapterId, expectedVersionNo, "章节大纲");
1181
1366
  this.recordEntityVersion("chapter-outline", chapterId, "delete", null, "删除章节大纲");
1182
1367
  this.db.run("DELETE FROM chapter_outlines WHERE chapter_id = ?", chapterId);
1183
1368
  this.audit(String(chapter.workId), "outline.deleted", "chapter-outline", chapterId);
@@ -1194,6 +1379,7 @@ export class Store {
1194
1379
  turningPoint: requiredString(row, "turning_point"),
1195
1380
  notes: requiredString(row, "notes"),
1196
1381
  status: requiredString(row, "status"),
1382
+ versionNo: this.currentEntityVersionNo("chapter-outline", requiredString(row, "chapter_id")),
1197
1383
  createdAt: requiredString(row, "created_at"),
1198
1384
  updatedAt: requiredString(row, "updated_at")
1199
1385
  };
@@ -1247,6 +1433,7 @@ export class Store {
1247
1433
  overdue: Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
1248
1434
  && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
1249
1435
  occurrences,
1436
+ versionNo: this.currentEntityVersionNo("foreshadow", foreshadowId),
1250
1437
  createdAt: requiredString(row, "created_at"),
1251
1438
  updatedAt: requiredString(row, "updated_at")
1252
1439
  };
@@ -1273,12 +1460,13 @@ export class Store {
1273
1460
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
1274
1461
  return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
1275
1462
  }
1276
- updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "") {
1463
+ updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1277
1464
  const current = this.getForeshadow(foreshadowId);
1278
1465
  const workId = String(current.workId);
1279
1466
  if (input.plannedPayoffChapterId)
1280
1467
  this.assertChapterInWork(input.plannedPayoffChapterId, workId);
1281
1468
  this.db.transaction(() => {
1469
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1282
1470
  this.db.run(`UPDATE foreshadows SET title = ?, description = ?, status = ?, importance = ?,
1283
1471
  planned_payoff_chapter_id = ?, resolution_note = ?, updated_at = ? WHERE id = ?`, input.title ?? String(current.title), input.description ?? String(current.description), input.status ?? String(current.status), input.importance ?? String(current.importance), input.plannedPayoffChapterId === undefined ? current.plannedPayoffChapterId : input.plannedPayoffChapterId, input.resolutionNote ?? String(current.resolutionNote), now(), foreshadowId);
1284
1472
  if (input.occurrences) {
@@ -1291,17 +1479,19 @@ export class Store {
1291
1479
  });
1292
1480
  return this.getForeshadow(foreshadowId);
1293
1481
  }
1294
- deleteForeshadow(foreshadowId) {
1482
+ deleteForeshadow(foreshadowId, expectedVersionNo) {
1295
1483
  const current = this.getForeshadow(foreshadowId);
1296
1484
  this.db.transaction(() => {
1485
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1297
1486
  this.recordEntityVersion("foreshadow", foreshadowId, "delete", null, "删除伏笔");
1298
1487
  this.db.run("DELETE FROM foreshadows WHERE id = ?", foreshadowId);
1299
1488
  this.audit(String(current.workId), "foreshadow.deleted", "foreshadow", foreshadowId);
1300
1489
  });
1301
1490
  }
1302
- createForeshadowOccurrence(foreshadowId, input) {
1491
+ createForeshadowOccurrence(foreshadowId, input, expectedVersionNo) {
1303
1492
  const foreshadow = this.getForeshadow(foreshadowId);
1304
1493
  const occurrenceId = this.db.transaction(() => {
1494
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1305
1495
  const createdId = this.insertForeshadowOccurrence(foreshadowId, String(foreshadow.workId), input);
1306
1496
  this.recordEntityVersion("foreshadow", foreshadowId, "manual", createdId, "添加伏笔章节记录");
1307
1497
  this.audit(String(foreshadow.workId), "foreshadow.occurrence.created", "foreshadow-occurrence", createdId);
@@ -1309,20 +1499,22 @@ export class Store {
1309
1499
  });
1310
1500
  return this.getForeshadowOccurrence(occurrenceId);
1311
1501
  }
1312
- updateForeshadowOccurrence(occurrenceId, input) {
1502
+ updateForeshadowOccurrence(occurrenceId, input, expectedVersionNo) {
1313
1503
  const current = this.getForeshadowOccurrence(occurrenceId);
1314
1504
  const foreshadow = this.getForeshadow(String(current.foreshadowId));
1315
1505
  const chapterId = input.chapterId ?? String(current.chapterId);
1316
1506
  this.assertChapterInWork(chapterId, String(foreshadow.workId));
1317
1507
  this.db.transaction(() => {
1508
+ this.assertExpectedVersion("foreshadow", String(current.foreshadowId), expectedVersionNo, "伏笔");
1318
1509
  this.db.run(`UPDATE foreshadow_occurrences SET chapter_id = ?, role = ?, note = ?, evidence_json = ?, updated_at = ? WHERE id = ?`, chapterId, input.role ?? String(current.role), input.note ?? String(current.note), JSON.stringify(input.evidence ?? current.evidence), now(), occurrenceId);
1319
1510
  this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "更新伏笔章节记录");
1320
1511
  });
1321
1512
  return this.getForeshadowOccurrence(occurrenceId);
1322
1513
  }
1323
- deleteForeshadowOccurrence(occurrenceId) {
1514
+ deleteForeshadowOccurrence(occurrenceId, expectedVersionNo) {
1324
1515
  const current = this.getForeshadowOccurrence(occurrenceId);
1325
1516
  this.db.transaction(() => {
1517
+ this.assertExpectedVersion("foreshadow", String(current.foreshadowId), expectedVersionNo, "伏笔");
1326
1518
  this.db.run("DELETE FROM foreshadow_occurrences WHERE id = ?", occurrenceId);
1327
1519
  this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "删除伏笔章节记录");
1328
1520
  });
@@ -1402,9 +1594,10 @@ export class Store {
1402
1594
  throw notFound("设定");
1403
1595
  return this.mapSetting(row);
1404
1596
  }
1405
- updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "") {
1597
+ updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1406
1598
  const current = this.getSetting(settingId);
1407
1599
  this.db.transaction(() => {
1600
+ this.assertExpectedVersion("setting", settingId, expectedVersionNo, "设定");
1408
1601
  this.db.run(`UPDATE settings SET title = ?, category = ?, content = ?, tags_json = ?, status = ?, locked = ?,
1409
1602
  evidence_json = ?, scope_json = ?, author_note = ?, updated_at = ? WHERE id = ?`, input.title ?? String(current.title), input.category ?? String(current.category), input.content ?? String(current.content), JSON.stringify(input.tags ?? current.tags), input.status ?? String(current.status), (input.locked ?? Boolean(current.locked)) ? 1 : 0, JSON.stringify(input.evidence ?? current.evidence), JSON.stringify(input.scope ?? current.scope), input.authorNote ?? String(current.authorNote), now(), settingId);
1410
1603
  this.recordEntityVersion("setting", settingId, source, sourceRef, changeNote || "更新世界观设定");
@@ -1412,9 +1605,10 @@ export class Store {
1412
1605
  });
1413
1606
  return this.getSetting(settingId);
1414
1607
  }
1415
- deleteSetting(settingId) {
1608
+ deleteSetting(settingId, expectedVersionNo) {
1416
1609
  const current = this.getSetting(settingId);
1417
1610
  this.db.transaction(() => {
1611
+ this.assertExpectedVersion("setting", settingId, expectedVersionNo, "设定");
1418
1612
  this.recordEntityVersion("setting", settingId, "delete", null, "删除世界观设定");
1419
1613
  this.db.run("DELETE FROM settings WHERE id = ?", settingId);
1420
1614
  this.audit(String(current.workId), "setting.deleted", "setting", settingId);
@@ -1433,6 +1627,7 @@ export class Store {
1433
1627
  evidence: json(requiredString(row, "evidence_json"), []),
1434
1628
  scope: json(requiredString(row, "scope_json"), {}),
1435
1629
  authorNote: requiredString(row, "author_note"),
1630
+ versionNo: this.currentEntityVersionNo("setting", requiredString(row, "id")),
1436
1631
  createdAt: requiredString(row, "created_at"),
1437
1632
  updatedAt: requiredString(row, "updated_at")
1438
1633
  };
@@ -1479,7 +1674,7 @@ export class Store {
1479
1674
  throw notFound("种族");
1480
1675
  return this.mapRace(row);
1481
1676
  }
1482
- updateRace(raceId, input, source = "manual", sourceRef = null, changeNote = "") {
1677
+ updateRace(raceId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1483
1678
  const current = this.getRace(raceId);
1484
1679
  const workId = String(current.workId);
1485
1680
  const name = input.name === undefined
@@ -1502,6 +1697,7 @@ export class Store {
1502
1697
  : [];
1503
1698
  const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
1504
1699
  this.db.transaction(() => {
1700
+ this.assertExpectedVersion("race", raceId, expectedVersionNo, "种族");
1505
1701
  this.db.run(`UPDATE races SET parent_race_id = ?, name = ?, normalized_name = ?, description = ?, settings_json = ?, updated_at = ? WHERE id = ?`, parentRaceId, name, normalizedName, input.description ?? String(current.description), JSON.stringify(input.settings ?? current.settings), now(), raceId);
1506
1702
  if (nameChanged)
1507
1703
  this.db.run("UPDATE characters SET species = ?, updated_at = ? WHERE race_id = ?", name, now(), raceId);
@@ -1513,7 +1709,7 @@ export class Store {
1513
1709
  });
1514
1710
  return this.getRace(raceId);
1515
1711
  }
1516
- deleteRace(raceId) {
1712
+ deleteRace(raceId, expectedVersionNo) {
1517
1713
  const current = this.getRace(raceId);
1518
1714
  const child = this.db.get("SELECT id FROM races WHERE parent_race_id = ? LIMIT 1", raceId);
1519
1715
  if (child) {
@@ -1521,6 +1717,7 @@ export class Store {
1521
1717
  }
1522
1718
  const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
1523
1719
  this.db.transaction(() => {
1720
+ this.assertExpectedVersion("race", raceId, expectedVersionNo, "种族");
1524
1721
  this.recordEntityVersion("race", raceId, "delete", null, "删除种族档案");
1525
1722
  this.db.run("UPDATE characters SET race_id = NULL, species = '', updated_at = ? WHERE race_id = ?", now(), raceId);
1526
1723
  this.db.run("DELETE FROM races WHERE id = ?", raceId);
@@ -1528,6 +1725,43 @@ export class Store {
1528
1725
  this.audit(String(current.workId), "race.deleted", "race", raceId);
1529
1726
  });
1530
1727
  }
1728
+ mergeRaces(sourceRaceId, targetRaceId) {
1729
+ if (sourceRaceId === targetRaceId)
1730
+ throw new AppError(400, "RACE_MERGE_SELF", "不能把种族合并到自身");
1731
+ const source = this.getRace(sourceRaceId);
1732
+ const target = this.getRace(targetRaceId);
1733
+ if (source.workId !== target.workId)
1734
+ throw new AppError(400, "RACE_WORK_MISMATCH", "待合并种族不属于同一作品");
1735
+ const workId = String(target.workId);
1736
+ const mergeId = id("raceMerge");
1737
+ const timestamp = now();
1738
+ const memberIds = [...new Set([...target.memberIds, ...source.memberIds])];
1739
+ const memberSnapshots = this.captureCharacterSnapshots(memberIds);
1740
+ const sourceChildren = this.db.all("SELECT id FROM races WHERE parent_race_id = ? ORDER BY id", sourceRaceId)
1741
+ .map((row) => requiredString(row, "id"))
1742
+ .filter((childRaceId) => childRaceId !== targetRaceId);
1743
+ const targetDescendsFromSource = target.lineage.some((race) => race.id === sourceRaceId);
1744
+ const targetParentRaceId = targetDescendsFromSource
1745
+ ? source.parentRaceId
1746
+ : target.parentRaceId;
1747
+ const descriptionParts = [String(target.description).trim(), String(source.description).trim()].filter(Boolean);
1748
+ const description = [...new Set(descriptionParts)].join("\n\n");
1749
+ const settings = [...new Set([...target.settings, ...source.settings])];
1750
+ this.db.transaction(() => {
1751
+ this.recordEntityVersion("race", sourceRaceId, "delete", mergeId, `合并至种族“${String(target.name)}”`, timestamp);
1752
+ this.db.run("UPDATE races SET parent_race_id = ?, description = ?, settings_json = ?, updated_at = ? WHERE id = ?", targetParentRaceId, description, JSON.stringify(settings), timestamp, targetRaceId);
1753
+ this.db.run("UPDATE characters SET race_id = ?, species = ?, updated_at = ? WHERE race_id = ?", targetRaceId, String(target.name), timestamp, sourceRaceId);
1754
+ for (const childRaceId of sourceChildren) {
1755
+ this.db.run("UPDATE races SET parent_race_id = ?, updated_at = ? WHERE id = ?", targetRaceId, timestamp, childRaceId);
1756
+ this.recordEntityVersion("race", childRaceId, "merge", mergeId, `因种族“${String(source.name)}”合并而迁移父种族`, timestamp);
1757
+ }
1758
+ this.db.run("DELETE FROM races WHERE id = ?", sourceRaceId);
1759
+ this.recordMembershipVersions(memberSnapshots, "race", targetRaceId, `合并种族“${String(source.name)}”`);
1760
+ this.recordEntityVersion("race", targetRaceId, "merge", mergeId, `合并种族“${String(source.name)}”`, timestamp);
1761
+ this.audit(workId, "race.merged", "race", targetRaceId, { mergeId, sourceRaceId });
1762
+ });
1763
+ return { mergeId, target: this.getRace(targetRaceId), source };
1764
+ }
1531
1765
  resolveRaceReference(workId, value) {
1532
1766
  const normalizedName = normalizeCharacterName(value);
1533
1767
  if (!normalizedName)
@@ -1558,6 +1792,7 @@ export class Store {
1558
1792
  }))),
1559
1793
  memberIds: members.map((member) => member.characterId),
1560
1794
  members,
1795
+ versionNo: this.currentEntityVersionNo("race", requiredString(row, "id")),
1561
1796
  createdAt: requiredString(row, "created_at"),
1562
1797
  updatedAt: requiredString(row, "updated_at")
1563
1798
  };
@@ -1659,7 +1894,7 @@ export class Store {
1659
1894
  throw notFound("组织");
1660
1895
  return this.mapOrganization(row);
1661
1896
  }
1662
- updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "") {
1897
+ updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1663
1898
  const current = this.getOrganization(organizationId);
1664
1899
  const workId = String(current.workId);
1665
1900
  const name = input.name === undefined
@@ -1675,6 +1910,7 @@ export class Store {
1675
1910
  const touchedMemberIds = memberIds ? [...new Set([...current.memberIds, ...memberIds])] : [];
1676
1911
  const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
1677
1912
  this.db.transaction(() => {
1913
+ this.assertExpectedVersion("organization", organizationId, expectedVersionNo, "组织");
1678
1914
  this.db.run(`UPDATE organizations SET name = ?, normalized_name = ?, description = ?, settings_json = ?, updated_at = ? WHERE id = ?`, name, normalizedName, input.description ?? String(current.description), JSON.stringify(input.settings ?? current.settings), now(), organizationId);
1679
1915
  if (memberIds) {
1680
1916
  this.replaceOrganizationMembers(organizationId, memberIds);
@@ -1685,16 +1921,49 @@ export class Store {
1685
1921
  });
1686
1922
  return this.getOrganization(organizationId);
1687
1923
  }
1688
- deleteOrganization(organizationId) {
1924
+ deleteOrganization(organizationId, expectedVersionNo) {
1689
1925
  const current = this.getOrganization(organizationId);
1690
1926
  const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
1691
1927
  this.db.transaction(() => {
1928
+ this.assertExpectedVersion("organization", organizationId, expectedVersionNo, "组织");
1692
1929
  this.recordEntityVersion("organization", organizationId, "delete", null, "删除组织档案");
1693
1930
  this.db.run("DELETE FROM organizations WHERE id = ?", organizationId);
1694
1931
  this.recordMembershipVersions(memberSnapshots, "organization", organizationId, `组织“${String(current.name)}”已删除`);
1695
1932
  this.audit(String(current.workId), "organization.deleted", "organization", organizationId);
1696
1933
  });
1697
1934
  }
1935
+ mergeOrganizations(sourceOrganizationId, targetOrganizationId) {
1936
+ if (sourceOrganizationId === targetOrganizationId) {
1937
+ throw new AppError(400, "ORGANIZATION_MERGE_SELF", "不能把组织合并到自身");
1938
+ }
1939
+ const source = this.getOrganization(sourceOrganizationId);
1940
+ const target = this.getOrganization(targetOrganizationId);
1941
+ if (source.workId !== target.workId) {
1942
+ throw new AppError(400, "ORGANIZATION_WORK_MISMATCH", "待合并组织不属于同一作品");
1943
+ }
1944
+ const workId = String(target.workId);
1945
+ const mergeId = id("organizationMerge");
1946
+ const timestamp = now();
1947
+ const memberIds = [...new Set([...target.memberIds, ...source.memberIds])];
1948
+ const memberSnapshots = this.captureCharacterSnapshots(memberIds);
1949
+ const descriptionParts = [String(target.description).trim(), String(source.description).trim()].filter(Boolean);
1950
+ const description = [...new Set(descriptionParts)].join("\n\n");
1951
+ const settings = [...new Set([...target.settings, ...source.settings])];
1952
+ const sourceMemberships = this.db.all("SELECT character_id, role, note, created_at FROM character_organization_memberships WHERE organization_id = ?", sourceOrganizationId);
1953
+ this.db.transaction(() => {
1954
+ this.recordEntityVersion("organization", sourceOrganizationId, "delete", mergeId, `合并至组织“${String(target.name)}”`, timestamp);
1955
+ this.db.run("UPDATE organizations SET description = ?, settings_json = ?, updated_at = ? WHERE id = ?", description, JSON.stringify(settings), timestamp, targetOrganizationId);
1956
+ for (const membership of sourceMemberships) {
1957
+ this.db.run(`INSERT INTO character_organization_memberships (character_id, organization_id, role, note, created_at, updated_at)
1958
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(character_id, organization_id) DO NOTHING`, requiredString(membership, "character_id"), targetOrganizationId, requiredString(membership, "role"), requiredString(membership, "note"), requiredString(membership, "created_at"), timestamp);
1959
+ }
1960
+ this.db.run("DELETE FROM organizations WHERE id = ?", sourceOrganizationId);
1961
+ this.recordMembershipVersions(memberSnapshots, "organization", targetOrganizationId, `合并组织“${String(source.name)}”`);
1962
+ this.recordEntityVersion("organization", targetOrganizationId, "merge", mergeId, `合并组织“${String(source.name)}”`, timestamp);
1963
+ this.audit(workId, "organization.merged", "organization", targetOrganizationId, { mergeId, sourceOrganizationId });
1964
+ });
1965
+ return { mergeId, target: this.getOrganization(targetOrganizationId), source };
1966
+ }
1698
1967
  mapOrganization(row) {
1699
1968
  const members = this.db.all(`SELECT c.id, c.name, m.role, m.note
1700
1969
  FROM character_organization_memberships m
@@ -1713,6 +1982,7 @@ export class Store {
1713
1982
  settings: json(requiredString(row, "settings_json"), []),
1714
1983
  memberIds: members.map((member) => member.characterId),
1715
1984
  members,
1985
+ versionNo: this.currentEntityVersionNo("organization", requiredString(row, "id")),
1716
1986
  createdAt: requiredString(row, "created_at"),
1717
1987
  updatedAt: requiredString(row, "updated_at")
1718
1988
  };
@@ -1940,10 +2210,13 @@ export class Store {
1940
2210
  });
1941
2211
  return this.getCharacterProfileSection(sectionId);
1942
2212
  }
1943
- updateCharacterProfileSection(sectionId, input, source = "manual", sourceRef = null, changeNote = "") {
2213
+ updateCharacterProfileSection(sectionId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1944
2214
  const current = this.getCharacterProfileSection(sectionId);
2215
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(current.versionNo));
1945
2216
  const timestamp = now();
1946
2217
  this.db.transaction(() => {
2218
+ const lockedCurrent = this.getCharacterProfileSection(sectionId);
2219
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(lockedCurrent.versionNo));
1947
2220
  this.db.run(`UPDATE character_profile_sections SET section_type = ?, title = ?, content_markdown = ?, summary = ?, sort_order = ?,
1948
2221
  source_path = ?, source_hash = ?, version_no = version_no + 1, updated_at = ? WHERE id = ?`, input.sectionType ?? String(current.sectionType), input.title ?? String(current.title), input.contentMarkdown ?? String(current.contentMarkdown), input.summary ?? String(current.summary), input.sortOrder ?? Number(current.sortOrder), input.sourcePath === undefined ? current.sourcePath : input.sourcePath, input.sourceHash === undefined ? current.sourceHash : input.sourceHash, timestamp, sectionId);
1949
2222
  const section = this.getCharacterProfileSection(sectionId);
@@ -1954,9 +2227,12 @@ export class Store {
1954
2227
  });
1955
2228
  return this.getCharacterProfileSection(sectionId);
1956
2229
  }
1957
- deleteCharacterProfileSection(sectionId) {
2230
+ deleteCharacterProfileSection(sectionId, expectedVersionNo) {
1958
2231
  const current = this.getCharacterProfileSection(sectionId);
2232
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(current.versionNo));
1959
2233
  this.db.transaction(() => {
2234
+ const lockedCurrent = this.getCharacterProfileSection(sectionId);
2235
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(lockedCurrent.versionNo));
1960
2236
  this.db.run("UPDATE character_profile_sections SET version_no = version_no + 1 WHERE id = ?", sectionId);
1961
2237
  const deleting = this.getCharacterProfileSection(sectionId);
1962
2238
  this.recordCharacterProfileSectionVersion(deleting, "delete", null, "删除人物 Markdown 章节");
@@ -2006,15 +2282,16 @@ export class Store {
2006
2282
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
2007
2283
  })), pagination);
2008
2284
  }
2009
- restoreCharacterProfileSection(sectionId, versionNo) {
2285
+ restoreCharacterProfileSection(sectionId, versionNo, expectedVersionNo) {
2010
2286
  const version = this.db.get("SELECT * FROM character_profile_section_versions WHERE section_id = ? AND version_no = ?", sectionId, versionNo);
2011
2287
  if (!version)
2012
2288
  throw notFound("人物档案章节版本");
2013
2289
  const snapshot = json(requiredString(version, "snapshot_json"), {});
2014
2290
  const existing = this.db.get("SELECT id FROM character_profile_sections WHERE id = ?", sectionId);
2015
2291
  if (existing) {
2016
- return this.updateCharacterProfileSection(sectionId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
2292
+ return this.updateCharacterProfileSection(sectionId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
2017
2293
  }
2294
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", this.currentCharacterSectionVersionNo(sectionId));
2018
2295
  const characterId = requiredString(version, "character_id");
2019
2296
  const character = this.getCharacter(characterId);
2020
2297
  const timestamp = now();
@@ -2124,8 +2401,9 @@ export class Store {
2124
2401
  throw notFound("角色");
2125
2402
  return this.mapCharacter(row);
2126
2403
  }
2127
- updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "") {
2404
+ updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2128
2405
  const current = this.getCharacter(characterId);
2406
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
2129
2407
  if (current.mergedIntoCharacterId)
2130
2408
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能直接编辑");
2131
2409
  const before = this.characterSnapshot(current);
@@ -2146,6 +2424,8 @@ export class Store {
2146
2424
  if (organizationIds)
2147
2425
  this.assertOrganizationsInWork(workId, organizationIds);
2148
2426
  this.db.transaction(() => {
2427
+ const lockedCurrent = this.getCharacter(characterId);
2428
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(lockedCurrent.versionNo));
2149
2429
  this.db.run(`UPDATE characters SET name = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
2150
2430
  locked_fields_json = ?, visibility = ?, first_chapter_id = ?, updated_at = ? WHERE id = ?`, names.name, JSON.stringify(names.aliases), species, raceId, JSON.stringify(attributes), JSON.stringify(input.profile ?? current.profile), JSON.stringify(input.currentState ?? current.currentState), JSON.stringify(input.lockedFields ?? current.lockedFields), input.visibility ?? String(current.visibility), input.firstChapterId === undefined ? current.firstChapterId : input.firstChapterId, now(), characterId);
2151
2431
  this.db.run("DELETE FROM character_names WHERE character_id = ?", characterId);
@@ -2204,7 +2484,7 @@ export class Store {
2204
2484
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
2205
2485
  })), pagination);
2206
2486
  }
2207
- restoreCharacter(characterId, versionNo) {
2487
+ restoreCharacter(characterId, versionNo, expectedVersionNo) {
2208
2488
  const version = this.db.get("SELECT * FROM character_versions WHERE character_id = ? AND version_no = ?", characterId, versionNo);
2209
2489
  if (!version)
2210
2490
  throw notFound("人物版本");
@@ -2213,9 +2493,10 @@ export class Store {
2213
2493
  throw new AppError(500, "CHARACTER_VERSION_INVALID", "人物版本快照无效");
2214
2494
  const existing = this.db.get("SELECT id FROM characters WHERE id = ?", characterId);
2215
2495
  if (!existing) {
2496
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", this.currentCharacterVersionNo(characterId));
2216
2497
  return this.recreateCharacterFromVersion(characterId, version, snapshot, versionNo);
2217
2498
  }
2218
- return this.updateCharacter(characterId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
2499
+ return this.updateCharacter(characterId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
2219
2500
  }
2220
2501
  recreateCharacterFromVersion(characterId, version, snapshot, versionNo) {
2221
2502
  const workId = requiredString(version, "work_id");
@@ -2242,11 +2523,24 @@ export class Store {
2242
2523
  });
2243
2524
  return this.getCharacter(characterId);
2244
2525
  }
2245
- deleteCharacter(characterId) {
2526
+ deleteCharacter(characterId, expectedVersionNo) {
2246
2527
  const current = this.getCharacter(characterId);
2528
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
2247
2529
  const timestamp = now();
2248
2530
  const versionNo = Number(current.versionNo) + 1;
2531
+ const workId = String(current.workId);
2532
+ const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(characterId));
2533
+ const relationships = this.listRelationships(workId).filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
2249
2534
  this.db.transaction(() => {
2535
+ const lockedCurrent = this.getCharacter(characterId);
2536
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(lockedCurrent.versionNo));
2537
+ for (const event of timelineEvents) {
2538
+ this.updateTimelineEvent(String(event.id), {
2539
+ participantIds: event.participantIds.filter((participantId) => participantId !== characterId)
2540
+ }, "manual", characterId, `删除角色“${String(current.name)}”后移除参与者引用`);
2541
+ }
2542
+ for (const relationship of relationships)
2543
+ this.deleteRelationship(String(relationship.id));
2250
2544
  const sectionIds = this.db.all("SELECT id FROM character_profile_sections WHERE character_id = ?", characterId)
2251
2545
  .map((row) => requiredString(row, "id"));
2252
2546
  for (const sectionId of sectionIds) {
@@ -2255,7 +2549,7 @@ export class Store {
2255
2549
  this.db.run("UPDATE characters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, characterId);
2256
2550
  this.insertCharacterVersion(characterId, versionNo, "delete", null, "删除人物", timestamp);
2257
2551
  this.db.run("DELETE FROM characters WHERE id = ?", characterId);
2258
- this.audit(String(current.workId), "character.deleted", "character", characterId, { versionNo });
2552
+ this.audit(workId, "character.deleted", "character", characterId, { versionNo });
2259
2553
  });
2260
2554
  }
2261
2555
  mapCharacter(row, includeProfileSections = true) {
@@ -2330,29 +2624,31 @@ export class Store {
2330
2624
  if (input.targetCharacterId === input.sourceCharacterId) {
2331
2625
  throw new AppError(400, "CHARACTER_MERGE_SELF", "不能把角色合并到自身");
2332
2626
  }
2333
- const review = this.getReviewItem(input.reviewId);
2334
- if (review.itemType !== "character-duplicate" || review.status !== "pending") {
2335
- throw new AppError(409, "CHARACTER_REVIEW_DECIDED", "该角色查重项已经处理");
2336
- }
2337
- const reviewCharacterIds = review.entityRefs.flatMap((reference) => {
2338
- if (!reference || typeof reference !== "object" || Array.isArray(reference))
2339
- return [];
2340
- const characterId = reference.id;
2341
- return typeof characterId === "string" ? [characterId] : [];
2342
- });
2343
- if (!reviewCharacterIds.includes(input.targetCharacterId) || !reviewCharacterIds.includes(input.sourceCharacterId)) {
2344
- throw new AppError(400, "CHARACTER_REVIEW_MISMATCH", "待合并角色与审核项不一致");
2627
+ const review = input.reviewId ? this.getReviewItem(input.reviewId) : null;
2628
+ if (review) {
2629
+ if (review.itemType !== "character-duplicate" || review.status !== "pending") {
2630
+ throw new AppError(409, "CHARACTER_REVIEW_DECIDED", "该角色查重项已经处理");
2631
+ }
2632
+ const reviewCharacterIds = review.entityRefs.flatMap((reference) => {
2633
+ if (!reference || typeof reference !== "object" || Array.isArray(reference))
2634
+ return [];
2635
+ const characterId = reference.id;
2636
+ return typeof characterId === "string" ? [characterId] : [];
2637
+ });
2638
+ if (!reviewCharacterIds.includes(input.targetCharacterId) || !reviewCharacterIds.includes(input.sourceCharacterId)) {
2639
+ throw new AppError(400, "CHARACTER_REVIEW_MISMATCH", "待合并角色与审核项不一致");
2640
+ }
2345
2641
  }
2346
2642
  const target = this.getCharacter(input.targetCharacterId);
2347
2643
  const source = this.getCharacter(input.sourceCharacterId);
2348
- if (target.workId !== source.workId || target.workId !== review.workId) {
2644
+ if (target.workId !== source.workId || (review && target.workId !== review.workId)) {
2349
2645
  throw new AppError(400, "CHARACTER_WORK_MISMATCH", "待合并角色不属于同一作品");
2350
2646
  }
2351
2647
  if (target.mergedIntoCharacterId || source.mergedIntoCharacterId) {
2352
2648
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "待合并角色中已有角色被合并");
2353
2649
  }
2354
2650
  if (Number(target.versionNo) !== input.expectedTargetVersionNo || Number(source.versionNo) !== input.expectedSourceVersionNo) {
2355
- throw new AppError(409, "CHARACTER_VERSION_CHANGED", "角色在审核后已发生变化,请重新运行查重");
2651
+ throw new AppError(409, "CHARACTER_VERSION_CHANGED", "角色已发生变化,请刷新后重试");
2356
2652
  }
2357
2653
  const workId = String(target.workId);
2358
2654
  const targetId = String(target.id);
@@ -2364,6 +2660,10 @@ export class Store {
2364
2660
  const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
2365
2661
  const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
2366
2662
  this.db.transaction(() => {
2663
+ const lockedTarget = this.getCharacter(targetId);
2664
+ const lockedSource = this.getCharacter(sourceId);
2665
+ this.assertExpectedRevision("character", targetId, input.expectedTargetVersionNo, "目标角色", Number(lockedTarget.versionNo));
2666
+ this.assertExpectedRevision("character", sourceId, input.expectedSourceVersionNo, "来源角色", Number(lockedSource.versionNo));
2367
2667
  this.db.run("DELETE FROM character_names WHERE character_id = ?", sourceId);
2368
2668
  const aliases = [...target.aliases, String(source.name), ...source.aliases];
2369
2669
  const uniqueAliases = [...new Map(aliases
@@ -2380,7 +2680,7 @@ export class Store {
2380
2680
  currentState: { ...source.currentState, ...target.currentState },
2381
2681
  lockedFields: [...new Set([...target.lockedFields, ...source.lockedFields])],
2382
2682
  firstChapterId: target.firstChapterId ?? source.firstChapterId
2383
- }, "merge", mergeId, `合并角色“${String(source.name)}”`);
2683
+ }, "merge", mergeId, `合并角色“${String(source.name)}”`, input.expectedTargetVersionNo);
2384
2684
  for (const event of timelineEvents) {
2385
2685
  const participantIds = [...new Set(event.participantIds.map((characterId) => characterId === sourceId ? targetId : characterId))];
2386
2686
  this.updateTimelineEvent(String(event.id), { participantIds }, "merge", mergeId, `合并角色“${String(source.name)}”`);
@@ -2422,13 +2722,18 @@ export class Store {
2422
2722
  }
2423
2723
  }
2424
2724
  this.db.run("DELETE FROM character_organization_memberships WHERE character_id = ?", sourceId);
2725
+ this.db.run("UPDATE character_profile_sections SET character_id = ?, updated_at = ? WHERE character_id = ?", targetId, timestamp, sourceId);
2726
+ this.db.run("UPDATE character_profile_section_versions SET character_id = ? WHERE character_id = ?", targetId, sourceId);
2727
+ this.db.run("UPDATE character_profile_section_search SET character_id = ? WHERE character_id = ?", targetId, sourceId);
2425
2728
  const sourceVersionNo = Number(source.versionNo) + 1;
2426
2729
  this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
2427
2730
  this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
2428
2731
  this.db.run(`INSERT INTO character_merges (id, work_id, source_character_id, target_character_id, review_id,
2429
2732
  source_snapshot_json, target_snapshot_json, reference_snapshot_json, created_at, created_by_user_id)
2430
2733
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, mergeId, workId, sourceId, targetId, input.reviewId, JSON.stringify(source), JSON.stringify(target), JSON.stringify(referenceSnapshot), timestamp, currentRequestActor()?.userId ?? null);
2431
- this.db.run("UPDATE review_items SET status = 'fixed', resolution_note = ?, updated_at = ? WHERE id = ?", `已将“${String(source.name)}”合并到“${String(target.name)}”`, timestamp, input.reviewId);
2734
+ if (input.reviewId) {
2735
+ this.db.run("UPDATE review_items SET status = 'fixed', resolution_note = ?, updated_at = ? WHERE id = ?", `已将“${String(source.name)}”合并到“${String(target.name)}”`, timestamp, input.reviewId);
2736
+ }
2432
2737
  this.audit(workId, "character.merged", "character", targetId, {
2433
2738
  mergeId,
2434
2739
  sourceCharacterId: sourceId,
@@ -2439,7 +2744,7 @@ export class Store {
2439
2744
  mergeId,
2440
2745
  target: this.getCharacter(targetId),
2441
2746
  source: this.getCharacter(sourceId),
2442
- review: this.getReviewItem(input.reviewId)
2747
+ review: input.reviewId ? this.getReviewItem(input.reviewId) : null
2443
2748
  };
2444
2749
  }
2445
2750
  resolveCharacterDuplicateReview(reviewId) {
@@ -2522,18 +2827,20 @@ export class Store {
2522
2827
  throw notFound("独立时间轴");
2523
2828
  return this.mapTimelineTrack(row);
2524
2829
  }
2525
- updateTimelineTrack(trackId, input, source = "manual", sourceRef = null, changeNote = "") {
2830
+ updateTimelineTrack(trackId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2526
2831
  const current = this.getTimelineTrack(trackId);
2527
2832
  this.db.transaction(() => {
2833
+ this.assertExpectedVersion("timeline-track", trackId, expectedVersionNo, "时间轴");
2528
2834
  this.db.run("UPDATE timeline_tracks SET name = ?, description = ?, sort_order = ?, updated_at = ? WHERE id = ?", input.name ?? String(current.name), input.description ?? String(current.description), input.sortOrder ?? Number(current.sortOrder), now(), trackId);
2529
2835
  this.recordEntityVersion("timeline-track", trackId, source, sourceRef, changeNote || "更新时间轴");
2530
2836
  this.audit(String(current.workId), "timeline-track.updated", "timeline-track", trackId, { fields: Object.keys(input), source, sourceRef });
2531
2837
  });
2532
2838
  return this.getTimelineTrack(trackId);
2533
2839
  }
2534
- deleteTimelineTrack(trackId) {
2840
+ deleteTimelineTrack(trackId, expectedVersionNo) {
2535
2841
  const current = this.getTimelineTrack(trackId);
2536
2842
  this.db.transaction(() => {
2843
+ this.assertExpectedVersion("timeline-track", trackId, expectedVersionNo, "时间轴");
2537
2844
  this.recordEntityVersion("timeline-track", trackId, "delete", null, "删除时间轴");
2538
2845
  this.db.run("DELETE FROM timeline_tracks WHERE id = ?", trackId);
2539
2846
  this.audit(String(current.workId), "timeline-track.deleted", "timeline-track", trackId);
@@ -2582,7 +2889,7 @@ export class Store {
2582
2889
  throw notFound("时间线事件");
2583
2890
  return this.mapTimelineEvent(row);
2584
2891
  }
2585
- updateTimelineEvent(eventId, input, source = "manual", sourceRef = null, changeNote = "") {
2892
+ updateTimelineEvent(eventId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2586
2893
  const current = this.getTimelineEvent(eventId);
2587
2894
  if (input.trackId) {
2588
2895
  const track = this.getTimelineTrack(input.trackId);
@@ -2590,6 +2897,7 @@ export class Store {
2590
2897
  throw new AppError(400, "TIMELINE_TRACK_WORK_MISMATCH", "独立时间轴不属于当前作品");
2591
2898
  }
2592
2899
  this.db.transaction(() => {
2900
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件");
2593
2901
  this.db.run(`UPDATE timeline_events SET track_id = ?, name = ?, description = ?, event_type = ?, time_label = ?, time_sort = ?,
2594
2902
  chapter_ids_json = ?, participant_ids_json = ?, location = ?, causes_json = ?, impact_scope = ?, evidence_json = ?,
2595
2903
  status = ?, updated_at = ? WHERE id = ?`, input.trackId === undefined ? current.trackId : input.trackId, input.name ?? String(current.name), input.description ?? String(current.description), input.eventType ?? String(current.eventType), input.timeLabel ?? String(current.timeLabel), input.timeSort === undefined ? current.timeSort : input.timeSort, JSON.stringify(input.chapterIds ?? current.chapterIds), JSON.stringify(input.participantIds ?? current.participantIds), input.location ?? String(current.location), JSON.stringify(input.causes ?? current.causes), input.impactScope ?? String(current.impactScope), JSON.stringify(input.evidence ?? current.evidence), input.status ?? String(current.status), now(), eventId);
@@ -2598,15 +2906,16 @@ export class Store {
2598
2906
  });
2599
2907
  return this.getTimelineEvent(eventId);
2600
2908
  }
2601
- deleteTimelineEvent(eventId) {
2909
+ deleteTimelineEvent(eventId, expectedVersionNo) {
2602
2910
  const current = this.getTimelineEvent(eventId);
2603
2911
  this.db.transaction(() => {
2912
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件");
2604
2913
  this.recordEntityVersion("timeline-event", eventId, "delete", null, "删除时间事件");
2605
2914
  this.db.run("DELETE FROM timeline_events WHERE id = ?", eventId);
2606
2915
  this.audit(String(current.workId), "timeline.deleted", "timeline-event", eventId);
2607
2916
  });
2608
2917
  }
2609
- mergeTimelineEvents(workId, eventIds, input) {
2918
+ mergeTimelineEvents(workId, eventIds, input, expectedVersionNos) {
2610
2919
  this.getWork(workId);
2611
2920
  const uniqueIds = [...new Set(eventIds)];
2612
2921
  if (uniqueIds.length < 2)
@@ -2620,6 +2929,9 @@ export class Store {
2620
2929
  };
2621
2930
  const knownSorts = events.map((event) => event.timeSort).filter((value) => typeof value === "number");
2622
2931
  return this.db.transaction(() => {
2932
+ for (const event of events) {
2933
+ this.assertExpectedVersion("timeline-event", String(event.id), expectedVersionNos?.[String(event.id)], "时间事件", Number(event.versionNo));
2934
+ }
2623
2935
  const merged = this.createTimelineEvent(workId, {
2624
2936
  name: input.name,
2625
2937
  trackId: events.every((event) => event.trackId === events[0]?.trackId) ? events[0]?.trackId : null,
@@ -2643,11 +2955,14 @@ export class Store {
2643
2955
  return merged;
2644
2956
  });
2645
2957
  }
2646
- splitTimelineEvent(eventId, parts) {
2958
+ splitTimelineEvent(eventId, parts, expectedVersionNo) {
2647
2959
  const source = this.getTimelineEvent(eventId);
2960
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件", Number(source.versionNo));
2648
2961
  if (parts.length < 2)
2649
2962
  throw new AppError(400, "EVENT_PARTS_REQUIRED", "拆分时间事件至少需要两项");
2650
2963
  return this.db.transaction(() => {
2964
+ const lockedSource = this.getTimelineEvent(eventId);
2965
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件", Number(lockedSource.versionNo));
2651
2966
  const created = parts.map((part, index) => this.createTimelineEvent(String(source.workId), {
2652
2967
  name: part.name,
2653
2968
  trackId: source.trackId,
@@ -2688,6 +3003,7 @@ export class Store {
2688
3003
  impactScope: requiredString(row, "impact_scope"),
2689
3004
  evidence: json(requiredString(row, "evidence_json"), []),
2690
3005
  status: requiredString(row, "status"),
3006
+ versionNo: this.currentEntityVersionNo("timeline-event", requiredString(row, "id")),
2691
3007
  createdAt: requiredString(row, "created_at"),
2692
3008
  updatedAt: requiredString(row, "updated_at")
2693
3009
  };
@@ -2699,6 +3015,7 @@ export class Store {
2699
3015
  name: requiredString(row, "name"),
2700
3016
  description: requiredString(row, "description"),
2701
3017
  sortOrder: numberValue(row, "sort_order"),
3018
+ versionNo: this.currentEntityVersionNo("timeline-track", requiredString(row, "id")),
2702
3019
  createdAt: requiredString(row, "created_at"),
2703
3020
  updatedAt: requiredString(row, "updated_at")
2704
3021
  };
@@ -2750,7 +3067,7 @@ export class Store {
2750
3067
  throw notFound("人物关系");
2751
3068
  return this.mapRelationship(row);
2752
3069
  }
2753
- updateRelationship(relationshipId, input, source = "manual", sourceRef = null, changeNote = "") {
3070
+ updateRelationship(relationshipId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2754
3071
  const current = this.getRelationship(relationshipId);
2755
3072
  let fromCharacterId = input.fromCharacterId ?? String(current.fromCharacterId);
2756
3073
  let toCharacterId = input.toCharacterId ?? String(current.toCharacterId);
@@ -2767,6 +3084,7 @@ export class Store {
2767
3084
  [fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
2768
3085
  this.assertRelationshipUnique(String(current.workId), fromCharacterId, toCharacterId, input.category ?? String(current.category), input.subtype ?? String(current.subtype), directed, relationshipId);
2769
3086
  this.db.transaction(() => {
3087
+ this.assertExpectedVersion("relationship", relationshipId, expectedVersionNo, "人物关系");
2770
3088
  this.db.run(`UPDATE relationships SET from_character_id = ?, to_character_id = ?, category = ?, subtype = ?, keywords_json = ?, directed = ?,
2771
3089
  current_status = ?, time_range_json = ?, confidence = ?, evidence_json = ?, confirmation_status = ?, locked = ?, updated_at = ?
2772
3090
  WHERE id = ?`, fromCharacterId, toCharacterId, input.category ?? String(current.category), input.subtype ?? String(current.subtype), JSON.stringify(this.normalizeRelationshipKeywords(input.keywords ?? current.keywords)), directed ? 1 : 0, input.currentStatus ?? String(current.currentStatus), JSON.stringify(input.timeRange ?? current.timeRange), input.confidence ?? Number(current.confidence), JSON.stringify(input.evidence ?? current.evidence), input.confirmationStatus ?? String(current.confirmationStatus), (input.locked ?? Boolean(current.locked)) ? 1 : 0, now(), relationshipId);
@@ -2775,9 +3093,10 @@ export class Store {
2775
3093
  });
2776
3094
  return this.getRelationship(relationshipId);
2777
3095
  }
2778
- deleteRelationship(relationshipId) {
3096
+ deleteRelationship(relationshipId, expectedVersionNo) {
2779
3097
  const current = this.getRelationship(relationshipId);
2780
3098
  this.db.transaction(() => {
3099
+ this.assertExpectedVersion("relationship", relationshipId, expectedVersionNo, "人物关系");
2781
3100
  this.recordEntityVersion("relationship", relationshipId, "delete", null, "删除人物关系");
2782
3101
  this.db.run("DELETE FROM relationships WHERE id = ?", relationshipId);
2783
3102
  this.audit(String(current.workId), "relationship.deleted", "relationship", relationshipId);
@@ -2799,6 +3118,7 @@ export class Store {
2799
3118
  evidence: json(requiredString(row, "evidence_json"), []),
2800
3119
  confirmationStatus: requiredString(row, "confirmation_status"),
2801
3120
  locked: booleanValue(row, "locked"),
3121
+ versionNo: this.currentEntityVersionNo("relationship", requiredString(row, "id")),
2802
3122
  createdAt: requiredString(row, "created_at"),
2803
3123
  updatedAt: requiredString(row, "updated_at")
2804
3124
  };