@musnows/scriverse 0.3.8 → 0.3.9

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
@@ -6,6 +6,8 @@ import { paginated, paginationSql } from "./pagination.js";
6
6
  import { currentRequestActor } from "./request-context.js";
7
7
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
8
8
  export const versionedEntityTypes = [
9
+ "work",
10
+ "volume",
9
11
  "setting",
10
12
  "race",
11
13
  "organization",
@@ -36,7 +38,44 @@ export class Store {
36
38
  this.db = db;
37
39
  this.backfillEntityVersionBaselines();
38
40
  }
41
+ currentEntityVersionNo(type, entityId) {
42
+ const row = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
43
+ return numberValue(row ?? {}, "version_no");
44
+ }
45
+ currentChapterVersionNo(chapterId) {
46
+ return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no");
47
+ }
48
+ currentCharacterVersionNo(characterId) {
49
+ return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM character_versions WHERE character_id = ?", characterId) ?? {}, "version_no");
50
+ }
51
+ currentCharacterSectionVersionNo(sectionId) {
52
+ return numberValue(this.db.get("SELECT MAX(version_no) AS version_no FROM character_profile_section_versions WHERE section_id = ?", sectionId) ?? {}, "version_no");
53
+ }
54
+ assertExpectedVersion(type, entityId, expectedVersionNo, entityName, currentVersionNo = this.currentEntityVersionNo(type, entityId)) {
55
+ if (expectedVersionNo === undefined || expectedVersionNo === currentVersionNo)
56
+ return;
57
+ throw new AppError(409, "VERSION_CONFLICT", `${entityName}已发生变化,请刷新后重试`, {
58
+ entityType: type,
59
+ entityId,
60
+ expectedVersionNo,
61
+ currentVersionNo
62
+ });
63
+ }
64
+ assertExpectedRevision(entityType, entityId, expectedVersionNo, entityName, currentVersionNo) {
65
+ if (expectedVersionNo === undefined || expectedVersionNo === currentVersionNo)
66
+ return;
67
+ throw new AppError(409, "VERSION_CONFLICT", `${entityName}已发生变化,请刷新后重试`, {
68
+ entityType,
69
+ entityId,
70
+ expectedVersionNo,
71
+ currentVersionNo
72
+ });
73
+ }
39
74
  versionedEntity(type, entityId) {
75
+ if (type === "work")
76
+ return this.getWork(entityId);
77
+ if (type === "volume")
78
+ return this.getVolume(entityId);
40
79
  if (type === "setting")
41
80
  return this.getSetting(entityId);
42
81
  if (type === "race")
@@ -68,6 +107,25 @@ export class Store {
68
107
  }
69
108
  }
70
109
  versionedEntitySnapshot(type, entity) {
110
+ if (type === "work")
111
+ return {
112
+ title: entity.title,
113
+ author: entity.author,
114
+ description: entity.description,
115
+ language: entity.language,
116
+ coverUrl: entity.coverUrl,
117
+ tags: entity.tags,
118
+ ownerUserId: entity.ownerUserId
119
+ };
120
+ if (type === "volume")
121
+ return {
122
+ title: entity.title,
123
+ kind: entity.kind,
124
+ source: entity.source,
125
+ description: entity.description,
126
+ keywords: entity.keywords,
127
+ sortOrder: entity.sortOrder
128
+ };
71
129
  if (type === "setting")
72
130
  return {
73
131
  title: entity.title,
@@ -165,11 +223,13 @@ export class Store {
165
223
  }
166
224
  const versionNo = latest ? numberValue(latest, "version_no") + 1 : 1;
167
225
  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);
226
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("entityVersion"), type === "work" ? entityId : String(entity.workId), type, entityId, versionNo, snapshotJson, source, sourceRef, changeNote.trim(), timestamp ?? now(), currentRequestActor()?.userId ?? null);
169
227
  return versionNo;
170
228
  }
171
229
  backfillEntityVersionBaselines() {
172
230
  const entities = [
231
+ ...this.db.all("SELECT id, updated_at FROM works").map((row) => ["work", requiredString(row, "id"), requiredString(row, "updated_at")]),
232
+ ...this.db.all("SELECT id, updated_at FROM volumes").map((row) => ["volume", requiredString(row, "id"), requiredString(row, "updated_at")]),
173
233
  ...this.db.all("SELECT id, updated_at FROM settings").map((row) => ["setting", requiredString(row, "id"), requiredString(row, "updated_at")]),
174
234
  ...this.db.all("SELECT id, updated_at FROM races").map((row) => ["race", requiredString(row, "id"), requiredString(row, "updated_at")]),
175
235
  ...this.db.all("SELECT id, updated_at FROM organizations").map((row) => ["organization", requiredString(row, "id"), requiredString(row, "updated_at")]),
@@ -228,7 +288,7 @@ export class Store {
228
288
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
229
289
  })), pagination);
230
290
  }
231
- restoreEntityVersion(type, entityId, versionNo) {
291
+ restoreEntityVersion(type, entityId, versionNo, expectedVersionNo) {
232
292
  const version = this.db.get("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? AND version_no = ?", type, entityId, versionNo);
233
293
  if (!version)
234
294
  throw notFound("历史版本");
@@ -239,31 +299,57 @@ export class Store {
239
299
  const changeNote = `恢复至 v${versionNo}`;
240
300
  const workId = requiredString(version, "work_id");
241
301
  const existing = this.tryVersionedEntity(type, entityId);
302
+ const currentVersionNo = existing
303
+ ? type === "work" ? Number(existing.versionNo) : type === "volume" ? Number(existing.versionNo) : this.currentEntityVersionNo(type, entityId)
304
+ : this.currentEntityVersionNo(type, entityId);
305
+ this.assertExpectedVersion(type, entityId, expectedVersionNo, type === "work" ? "作品" : type === "volume" ? "分卷" : "创作资料", currentVersionNo);
242
306
  let restored;
243
307
  if (!existing) {
244
308
  restored = this.recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote);
245
309
  }
310
+ else if (type === "work")
311
+ restored = this.updateWork(entityId, snapshot, expectedVersionNo, "restore", sourceRef, changeNote);
312
+ else if (type === "volume")
313
+ restored = this.updateVolume(entityId, snapshot, expectedVersionNo, "restore", sourceRef, changeNote);
246
314
  else if (type === "setting")
247
- restored = this.updateSetting(entityId, snapshot, "restore", sourceRef, changeNote);
315
+ restored = this.updateSetting(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
248
316
  else if (type === "race")
249
- restored = this.updateRace(entityId, snapshot, "restore", sourceRef, changeNote);
317
+ restored = this.updateRace(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
250
318
  else if (type === "organization")
251
- restored = this.updateOrganization(entityId, snapshot, "restore", sourceRef, changeNote);
319
+ restored = this.updateOrganization(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
252
320
  else if (type === "timeline-track")
253
- restored = this.updateTimelineTrack(entityId, snapshot, "restore", sourceRef, changeNote);
321
+ restored = this.updateTimelineTrack(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
254
322
  else if (type === "timeline-event")
255
- restored = this.updateTimelineEvent(entityId, snapshot, "restore", sourceRef, changeNote);
323
+ restored = this.updateTimelineEvent(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
256
324
  else if (type === "relationship")
257
- restored = this.updateRelationship(entityId, snapshot, "restore", sourceRef, changeNote);
325
+ restored = this.updateRelationship(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
258
326
  else if (type === "chapter-outline")
259
- restored = this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote);
327
+ restored = this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
260
328
  else
261
- restored = this.updateForeshadow(entityId, snapshot, "restore", sourceRef, changeNote);
329
+ restored = this.updateForeshadow(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
262
330
  const currentVersion = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
263
331
  return { ...restored, versionNo: numberValue(currentVersion ?? {}, "version_no") };
264
332
  }
265
333
  recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote) {
266
334
  this.getWork(workId);
335
+ if (type === "work") {
336
+ return this.db.transaction(() => {
337
+ const ownerUserId = typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null;
338
+ const timestamp = now();
339
+ this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, version_no, created_at, updated_at, owner_user_id)
340
+ 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);
341
+ if (ownerUserId) {
342
+ this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", entityId, ownerUserId, ownerUserId, timestamp);
343
+ }
344
+ const versionNo = this.recordEntityVersion("work", entityId, "restore", sourceRef, changeNote, timestamp);
345
+ this.db.run("UPDATE works SET version_no = ? WHERE id = ?", versionNo, entityId);
346
+ this.audit(entityId, "work.restored", "work", entityId, { sourceRef });
347
+ return this.getWork(entityId);
348
+ });
349
+ }
350
+ if (type === "volume") {
351
+ return this.db.transaction(() => this.insertVolumeWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote));
352
+ }
267
353
  if (type === "setting") {
268
354
  return this.insertSettingWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
269
355
  }
@@ -310,6 +396,7 @@ export class Store {
310
396
  if (actor) {
311
397
  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
398
  }
399
+ this.recordEntityVersion("work", workId, "create", null, "建立作品", timestamp);
313
400
  this.audit(workId, "work.created", "work", workId);
314
401
  });
315
402
  return this.getWork(workId);
@@ -431,34 +518,45 @@ export class Store {
431
518
  });
432
519
  return this.getWorkAiSettings(workId);
433
520
  }
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) });
521
+ updateWork(workId, input, expectedVersionNo, source = "manual", sourceRef = null, changeNote = "") {
522
+ this.db.transaction(() => {
523
+ const current = this.getWork(workId);
524
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
525
+ const timestamp = now();
526
+ this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?, version_no = version_no + 1, updated_at = ?
527
+ 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);
528
+ this.recordEntityVersion("work", workId, source, sourceRef, changeNote || "更新作品信息", timestamp);
529
+ this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input), versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
530
+ });
440
531
  return this.getWork(workId);
441
532
  }
442
- deleteWork(workId) {
533
+ deleteWork(workId, expectedVersionNo) {
443
534
  const work = this.getWork(workId);
444
535
  const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
445
536
  .map((row) => requiredString(row, "storage_key"));
446
537
  this.db.transaction(() => {
538
+ const current = this.getWork(workId);
539
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
540
+ this.recordEntityVersion("work", workId, "delete", null, "删除作品");
447
541
  this.audit(null, "work.deleted", "work", workId, { title: work.title });
448
542
  this.db.run("DELETE FROM works WHERE id = ?", workId);
449
543
  });
450
544
  return storageKeys.filter((storageKey) => Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) === 0);
451
545
  }
452
- setWorkCover(workId, mimeType, content) {
453
- this.getWork(workId);
454
- const timestamp = now();
546
+ setWorkCover(workId, mimeType, content, expectedVersionNo) {
455
547
  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 });
548
+ this.db.transaction(() => {
549
+ const current = this.getWork(workId);
550
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
551
+ const timestamp = now();
552
+ this.db.run(`INSERT INTO work_covers (work_id, mime_type, content, byte_length, sha256, updated_at)
553
+ VALUES (?, ?, ?, ?, ?, ?)
554
+ ON CONFLICT(work_id) DO UPDATE SET mime_type = excluded.mime_type, content = excluded.content,
555
+ byte_length = excluded.byte_length, sha256 = excluded.sha256, updated_at = excluded.updated_at`, workId, mimeType, content, content.byteLength, sha256, timestamp);
556
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
557
+ this.recordEntityVersion("work", workId, "manual", null, "更新作品封面", timestamp);
558
+ this.audit(workId, "work.cover.updated", "work", workId, { mimeType, byteLength: content.byteLength, sha256 });
559
+ });
462
560
  return this.getWork(workId);
463
561
  }
464
562
  getWorkCover(workId) {
@@ -474,11 +572,16 @@ export class Store {
474
572
  updatedAt: requiredString(row, "updated_at")
475
573
  };
476
574
  }
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);
575
+ deleteWorkCover(workId, expectedVersionNo) {
576
+ this.db.transaction(() => {
577
+ const current = this.getWork(workId);
578
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
579
+ const timestamp = now();
580
+ this.db.run("DELETE FROM work_covers WHERE work_id = ?", workId);
581
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
582
+ this.recordEntityVersion("work", workId, "manual", null, "删除作品封面", timestamp);
583
+ this.audit(workId, "work.cover.deleted", "work", workId);
584
+ });
482
585
  }
483
586
  getWorkTree(workId) {
484
587
  const work = this.getWork(workId);
@@ -577,7 +680,7 @@ export class Store {
577
680
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
578
681
  })), pagination);
579
682
  }
580
- restoreFileVersion(workId, fileVersionId) {
683
+ restoreFileVersion(workId, fileVersionId, expectedVersionNo) {
581
684
  this.getWork(workId);
582
685
  const version = this.db.get("SELECT * FROM file_versions WHERE id = ? AND work_id = ?", fileVersionId, workId);
583
686
  if (!version)
@@ -585,6 +688,8 @@ export class Store {
585
688
  const snapshot = json(requiredString(version, "snapshot_json"), {});
586
689
  const volumes = Array.isArray(snapshot.volumes) ? snapshot.volumes : [];
587
690
  return this.db.transaction(() => {
691
+ const current = this.getWork(workId);
692
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
588
693
  const currentTree = this.getWorkTree(workId);
589
694
  const currentChapters = this.db.all("SELECT content FROM chapters WHERE work_id = ?", workId);
590
695
  const wordCount = currentChapters.reduce((sum, row) => sum + countWords(requiredString(row, "content")), 0);
@@ -596,12 +701,21 @@ export class Store {
596
701
  const timestamp = now();
597
702
  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
703
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, restorePointId, workId, `before-restore:${requiredString(version, "file_name")}`, "snapshot", wordCount, paragraphCount, "[]", JSON.stringify(currentTree), timestamp, currentRequestActor()?.userId ?? null);
704
+ for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
705
+ this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "替换作品树前保存分卷历史");
706
+ }
599
707
  this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
600
708
  for (const volume of volumes) {
601
709
  const volumeId = id("volume");
602
710
  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);
711
+ this.insertVolumeWithId(workId, volumeId, {
712
+ title: String(volume.title ?? "正文"),
713
+ kind: String(volume.kind ?? "main"),
714
+ source: String(volume.source ?? "manual"),
715
+ description: String(volume.description ?? ""),
716
+ keywords: Array.isArray(volume.keywords) ? volume.keywords : [],
717
+ sortOrder: Number(volume.sortOrder ?? 0)
718
+ }, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`);
605
719
  for (const chapter of chapters) {
606
720
  const chapterType = (["正文", "设定", "作者的话", "其他"].includes(String(chapter.chapterType))
607
721
  ? String(chapter.chapterType)
@@ -609,7 +723,8 @@ export class Store {
609
723
  this.insertChapter(workId, volumeId, String(chapter.title ?? "未命名章节"), String(chapter.content ?? ""), Number(chapter.sortOrder ?? 0), "restore", fileVersionId, chapterType);
610
724
  }
611
725
  }
612
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
726
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
727
+ this.recordEntityVersion("work", workId, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`, timestamp);
613
728
  this.audit(workId, "file.restored", "file-version", fileVersionId, { restorePointId });
614
729
  return {
615
730
  fileVersionId: restorePointId,
@@ -618,55 +733,84 @@ export class Store {
618
733
  };
619
734
  });
620
735
  }
621
- importNovel(workId, fileName, fileType, parsed) {
736
+ importNovel(workId, fileName, fileType, parsed, mode = "overwrite", expectedVersionNo) {
622
737
  this.getWork(workId);
623
738
  let result = {};
624
- this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed); });
739
+ this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed, mode, expectedVersionNo); });
625
740
  return { ...result, tree: this.getWorkDirectory(workId) };
626
741
  }
627
742
  createImportedWork(input, fileName, fileType, parsed) {
628
743
  return this.db.transaction(() => {
629
744
  const work = this.createWork(input);
630
- const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed);
745
+ const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed, undefined, undefined, false);
631
746
  return { ...imported, work: this.getWork(String(work.id)) };
632
747
  });
633
748
  }
634
- importNovelInTransaction(workId, fileName, fileType, parsed) {
749
+ importNovelInTransaction(workId, fileName, fileType, parsed, mode = "overwrite", expectedVersionNo, bumpWorkVersion = true) {
750
+ const current = this.getWork(workId);
751
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
635
752
  const fileVersionId = id("file");
636
753
  const timestamp = now();
637
754
  const snapshot = this.getWorkTree(workId);
638
755
  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
756
  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);
757
+ let volumeOrderOffset = 0;
758
+ if (mode === "overwrite") {
759
+ for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
760
+ this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "导入前保存分卷历史");
761
+ }
762
+ this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
763
+ }
764
+ else {
765
+ const lastVolume = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ?", workId);
766
+ volumeOrderOffset = numberValue(lastVolume ?? {}, "value") + 1;
767
+ }
768
+ let firstImportedChapterId = null;
641
769
  for (const volume of parsed.volumes) {
642
770
  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);
771
+ this.insertVolumeWithId(workId, volumeId, {
772
+ title: volume.title,
773
+ kind: volume.kind,
774
+ source: volume.source,
775
+ sortOrder: volumeOrderOffset + volume.order
776
+ }, "import", fileVersionId, "导入分卷");
645
777
  for (const chapter of volume.chapters) {
646
- this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.order, "import", fileVersionId, chapter.chapterType);
778
+ const chapterId = this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.order, "import", fileVersionId, chapter.chapterType);
779
+ firstImportedChapterId ??= chapterId;
647
780
  }
648
781
  }
649
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
782
+ if (bumpWorkVersion) {
783
+ this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
784
+ this.recordEntityVersion("work", workId, "import", fileVersionId, "导入作品正文", timestamp);
785
+ }
650
786
  this.audit(workId, "work.imported", "file-version", fileVersionId, {
651
787
  fileName,
788
+ mode,
652
789
  volumeCount: parsed.volumes.length,
653
790
  chapterCount: parsed.volumes.reduce((sum, volume) => sum + volume.chapters.length, 0)
654
791
  });
655
792
  return {
656
793
  fileVersionId,
794
+ firstImportedChapterId,
795
+ mode,
657
796
  warnings: parsed.warnings,
658
797
  wordCount: parsed.wordCount,
659
798
  paragraphCount: parsed.paragraphCount
660
799
  };
661
800
  }
662
801
  createVolume(workId, input) {
802
+ return this.db.transaction(() => this.insertVolumeWithId(workId, id("volume"), input, "create", null, "建立分卷"));
803
+ }
804
+ insertVolumeWithId(workId, volumeId, input, source = "create", sourceRef = null, changeNote = "") {
663
805
  this.getWork(workId);
664
- const volumeId = id("volume");
665
806
  const timestamp = now();
666
807
  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);
808
+ this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, version_no, created_at, updated_at)
809
+ 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);
810
+ const versionNo = this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "建立分卷", timestamp);
811
+ if (versionNo !== 1)
812
+ this.db.run("UPDATE volumes SET version_no = ? WHERE id = ?", versionNo, volumeId);
813
+ this.audit(workId, source === "restore" ? "volume.restored" : "volume.created", "volume", volumeId, { source, sourceRef });
670
814
  return this.getVolume(volumeId);
671
815
  }
672
816
  getVolume(volumeId) {
@@ -675,20 +819,30 @@ export class Store {
675
819
  throw notFound("卷");
676
820
  return this.mapVolume(row);
677
821
  }
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);
822
+ updateVolume(volumeId, input, expectedVersionNo, source = "manual", sourceRef = null, changeNote = "") {
823
+ this.db.transaction(() => {
824
+ const current = this.getVolume(volumeId);
825
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
826
+ const timestamp = now();
827
+ 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);
828
+ this.recordEntityVersion("volume", volumeId, source, sourceRef, changeNote || "更新分卷信息", timestamp);
829
+ this.audit(String(current.workId), "volume.updated", "volume", volumeId, { ...input, versionNo: Number(current.versionNo) + 1, source, sourceRef, changeNote });
830
+ });
682
831
  return this.getVolume(volumeId);
683
832
  }
684
- deleteVolume(volumeId) {
833
+ deleteVolume(volumeId, expectedVersionNo) {
685
834
  const volume = this.getVolume(volumeId);
686
835
  const count = this.db.get("SELECT COUNT(*) AS value FROM chapters WHERE volume_id = ?", volumeId);
687
836
  if (numberValue(count ?? {}, "value") > 0) {
688
837
  throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
689
838
  }
690
- this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
691
- this.audit(String(volume.workId), "volume.deleted", "volume", volumeId);
839
+ this.db.transaction(() => {
840
+ const current = this.getVolume(volumeId);
841
+ this.assertExpectedVersion("volume", volumeId, expectedVersionNo, "分卷", Number(current.versionNo));
842
+ this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
843
+ this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
844
+ this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
845
+ });
692
846
  }
693
847
  createChapter(workId, input) {
694
848
  this.getWork(workId);
@@ -812,8 +966,9 @@ export class Store {
812
966
  summary: requiredString(row, "summary")
813
967
  }));
814
968
  }
815
- saveChapter(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
969
+ saveChapter(chapterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
816
970
  const current = this.getChapter(chapterId);
971
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(current.versionNo));
817
972
  const nextTitle = input.title ?? String(current.title);
818
973
  const nextContent = input.content === undefined ? String(current.content) : normalizeParagraphSpacing(input.content);
819
974
  const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
@@ -826,6 +981,8 @@ export class Store {
826
981
  const timestamp = now();
827
982
  const versionNo = Number(current.versionNo) + (hasTextChange ? 1 : 0);
828
983
  this.db.transaction(() => {
984
+ const lockedCurrent = this.getChapter(chapterId);
985
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
829
986
  this.db.run(`UPDATE chapters SET title = ?, content = ?, chapter_type = ?, word_count = ?, version_no = ?, analysis_status = ?,
830
987
  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
988
  if (hasTextChange)
@@ -853,15 +1010,16 @@ export class Store {
853
1010
  });
854
1011
  return this.getChapter(chapterId);
855
1012
  }
856
- restoreChapter(chapterId, versionNo) {
1013
+ restoreChapter(chapterId, versionNo, expectedVersionNo) {
857
1014
  const version = this.db.get("SELECT * FROM chapter_versions WHERE chapter_id = ? AND version_no = ?", chapterId, versionNo);
858
1015
  if (!version)
859
1016
  throw notFound("章节版本");
860
1017
  const existing = this.db.get("SELECT id FROM chapters WHERE id = ?", chapterId);
861
1018
  if (!existing) {
1019
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", this.currentChapterVersionNo(chapterId));
862
1020
  return this.recreateChapterFromVersion(chapterId, version);
863
1021
  }
864
- return this.saveChapter(chapterId, { title: requiredString(version, "title"), content: requiredString(version, "content") }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
1022
+ return this.saveChapter(chapterId, { title: requiredString(version, "title"), content: requiredString(version, "content") }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
865
1023
  }
866
1024
  recreateChapterFromVersion(chapterId, version) {
867
1025
  const workId = requiredString(version, "work_id");
@@ -902,12 +1060,15 @@ export class Store {
902
1060
  });
903
1061
  return this.getChapter(chapterId);
904
1062
  }
905
- moveChapter(chapterId, input) {
1063
+ moveChapter(chapterId, input, expectedVersionNo) {
906
1064
  const chapter = this.getChapter(chapterId);
1065
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
907
1066
  const volume = this.getVolume(input.volumeId);
908
1067
  if (volume.workId !== chapter.workId)
909
1068
  throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
910
1069
  this.db.transaction(() => {
1070
+ const lockedChapter = this.getChapter(chapterId);
1071
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
911
1072
  this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
912
1073
  WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
913
1074
  AND json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?`, now(), String(chapter.workId), String(chapter.volumeId));
@@ -917,11 +1078,14 @@ export class Store {
917
1078
  });
918
1079
  return this.getChapter(chapterId);
919
1080
  }
920
- deleteChapter(chapterId) {
1081
+ deleteChapter(chapterId, expectedVersionNo) {
921
1082
  const chapter = this.getChapter(chapterId);
1083
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
922
1084
  const timestamp = now();
923
1085
  const versionNo = Number(chapter.versionNo) + 1;
924
1086
  this.db.transaction(() => {
1087
+ const lockedChapter = this.getChapter(chapterId);
1088
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
925
1089
  this.db.run("UPDATE chapters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
926
1090
  this.insertChapterVersionRow({
927
1091
  workId: String(chapter.workId),
@@ -1027,13 +1191,17 @@ export class Store {
1027
1191
  const actor = currentRequestActor();
1028
1192
  const ownerUserId = optionalString(row, "owner_user_id");
1029
1193
  const membership = actor
1030
- ? this.db.get("SELECT role FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
1194
+ ? this.db.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
1031
1195
  : undefined;
1196
+ const membershipPermissions = json(optionalString(membership ?? {}, "permissions_json"), {});
1197
+ const membershipRole = String(membership?.role ?? "");
1032
1198
  const accessRole = ownerUserId === actor?.userId
1033
1199
  ? "owner"
1034
1200
  : actor?.role === "admin"
1035
1201
  ? "admin"
1036
- : ["editor", "viewer"].includes(String(membership?.role ?? "")) ? String(membership?.role) : null;
1202
+ : membershipRole === "editor" && membershipPermissions.editScope === "settings"
1203
+ ? "settings-editor"
1204
+ : ["editor", "viewer"].includes(membershipRole) ? membershipRole : null;
1037
1205
  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
1206
  const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
1039
1207
  return {
@@ -1046,6 +1214,7 @@ export class Store {
1046
1214
  ? `/api/works/${encodeURIComponent(requiredString(row, "id"))}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
1047
1215
  : optionalString(row, "cover_url"),
1048
1216
  tags: json(requiredString(row, "tags_json"), []),
1217
+ versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", requiredString(row, "id")),
1049
1218
  ownerUserId,
1050
1219
  accessRole,
1051
1220
  chapterCount: numberValue(count ?? {}, "chapter_count"),
@@ -1064,6 +1233,7 @@ export class Store {
1064
1233
  description: optionalString(row, "description") ?? "",
1065
1234
  keywords: json(optionalString(row, "keywords_json"), []),
1066
1235
  sortOrder: numberValue(row, "sort_order"),
1236
+ versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("volume", requiredString(row, "id")),
1067
1237
  createdAt: requiredString(row, "created_at"),
1068
1238
  updatedAt: requiredString(row, "updated_at")
1069
1239
  };
@@ -1157,11 +1327,13 @@ export class Store {
1157
1327
  updatedAt: optionalString(row, "updated_at")
1158
1328
  })), pagination);
1159
1329
  }
1160
- upsertChapterOutline(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
1330
+ upsertChapterOutline(chapterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1161
1331
  const chapter = this.getChapter(chapterId);
1162
1332
  const current = this.getChapterOutline(chapterId);
1163
1333
  const timestamp = now();
1164
1334
  this.db.transaction(() => {
1335
+ if (current)
1336
+ this.assertExpectedVersion("chapter-outline", chapterId, expectedVersionNo, "章节大纲");
1165
1337
  this.db.run(`INSERT INTO chapter_outlines (chapter_id, goal, conflict, turning_point, notes, status, created_at, updated_at)
1166
1338
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1167
1339
  ON CONFLICT(chapter_id) DO UPDATE SET goal = excluded.goal, conflict = excluded.conflict,
@@ -1172,12 +1344,13 @@ export class Store {
1172
1344
  });
1173
1345
  return this.getChapterOutline(chapterId);
1174
1346
  }
1175
- deleteChapterOutline(chapterId) {
1347
+ deleteChapterOutline(chapterId, expectedVersionNo) {
1176
1348
  const chapter = this.getChapter(chapterId);
1177
1349
  const outline = this.getChapterOutline(chapterId);
1178
1350
  if (!outline)
1179
1351
  return;
1180
1352
  this.db.transaction(() => {
1353
+ this.assertExpectedVersion("chapter-outline", chapterId, expectedVersionNo, "章节大纲");
1181
1354
  this.recordEntityVersion("chapter-outline", chapterId, "delete", null, "删除章节大纲");
1182
1355
  this.db.run("DELETE FROM chapter_outlines WHERE chapter_id = ?", chapterId);
1183
1356
  this.audit(String(chapter.workId), "outline.deleted", "chapter-outline", chapterId);
@@ -1194,6 +1367,7 @@ export class Store {
1194
1367
  turningPoint: requiredString(row, "turning_point"),
1195
1368
  notes: requiredString(row, "notes"),
1196
1369
  status: requiredString(row, "status"),
1370
+ versionNo: this.currentEntityVersionNo("chapter-outline", requiredString(row, "chapter_id")),
1197
1371
  createdAt: requiredString(row, "created_at"),
1198
1372
  updatedAt: requiredString(row, "updated_at")
1199
1373
  };
@@ -1247,6 +1421,7 @@ export class Store {
1247
1421
  overdue: Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
1248
1422
  && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
1249
1423
  occurrences,
1424
+ versionNo: this.currentEntityVersionNo("foreshadow", foreshadowId),
1250
1425
  createdAt: requiredString(row, "created_at"),
1251
1426
  updatedAt: requiredString(row, "updated_at")
1252
1427
  };
@@ -1273,12 +1448,13 @@ export class Store {
1273
1448
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
1274
1449
  return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
1275
1450
  }
1276
- updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "") {
1451
+ updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1277
1452
  const current = this.getForeshadow(foreshadowId);
1278
1453
  const workId = String(current.workId);
1279
1454
  if (input.plannedPayoffChapterId)
1280
1455
  this.assertChapterInWork(input.plannedPayoffChapterId, workId);
1281
1456
  this.db.transaction(() => {
1457
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1282
1458
  this.db.run(`UPDATE foreshadows SET title = ?, description = ?, status = ?, importance = ?,
1283
1459
  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
1460
  if (input.occurrences) {
@@ -1291,17 +1467,19 @@ export class Store {
1291
1467
  });
1292
1468
  return this.getForeshadow(foreshadowId);
1293
1469
  }
1294
- deleteForeshadow(foreshadowId) {
1470
+ deleteForeshadow(foreshadowId, expectedVersionNo) {
1295
1471
  const current = this.getForeshadow(foreshadowId);
1296
1472
  this.db.transaction(() => {
1473
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1297
1474
  this.recordEntityVersion("foreshadow", foreshadowId, "delete", null, "删除伏笔");
1298
1475
  this.db.run("DELETE FROM foreshadows WHERE id = ?", foreshadowId);
1299
1476
  this.audit(String(current.workId), "foreshadow.deleted", "foreshadow", foreshadowId);
1300
1477
  });
1301
1478
  }
1302
- createForeshadowOccurrence(foreshadowId, input) {
1479
+ createForeshadowOccurrence(foreshadowId, input, expectedVersionNo) {
1303
1480
  const foreshadow = this.getForeshadow(foreshadowId);
1304
1481
  const occurrenceId = this.db.transaction(() => {
1482
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1305
1483
  const createdId = this.insertForeshadowOccurrence(foreshadowId, String(foreshadow.workId), input);
1306
1484
  this.recordEntityVersion("foreshadow", foreshadowId, "manual", createdId, "添加伏笔章节记录");
1307
1485
  this.audit(String(foreshadow.workId), "foreshadow.occurrence.created", "foreshadow-occurrence", createdId);
@@ -1309,20 +1487,22 @@ export class Store {
1309
1487
  });
1310
1488
  return this.getForeshadowOccurrence(occurrenceId);
1311
1489
  }
1312
- updateForeshadowOccurrence(occurrenceId, input) {
1490
+ updateForeshadowOccurrence(occurrenceId, input, expectedVersionNo) {
1313
1491
  const current = this.getForeshadowOccurrence(occurrenceId);
1314
1492
  const foreshadow = this.getForeshadow(String(current.foreshadowId));
1315
1493
  const chapterId = input.chapterId ?? String(current.chapterId);
1316
1494
  this.assertChapterInWork(chapterId, String(foreshadow.workId));
1317
1495
  this.db.transaction(() => {
1496
+ this.assertExpectedVersion("foreshadow", String(current.foreshadowId), expectedVersionNo, "伏笔");
1318
1497
  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
1498
  this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "更新伏笔章节记录");
1320
1499
  });
1321
1500
  return this.getForeshadowOccurrence(occurrenceId);
1322
1501
  }
1323
- deleteForeshadowOccurrence(occurrenceId) {
1502
+ deleteForeshadowOccurrence(occurrenceId, expectedVersionNo) {
1324
1503
  const current = this.getForeshadowOccurrence(occurrenceId);
1325
1504
  this.db.transaction(() => {
1505
+ this.assertExpectedVersion("foreshadow", String(current.foreshadowId), expectedVersionNo, "伏笔");
1326
1506
  this.db.run("DELETE FROM foreshadow_occurrences WHERE id = ?", occurrenceId);
1327
1507
  this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "删除伏笔章节记录");
1328
1508
  });
@@ -1402,9 +1582,10 @@ export class Store {
1402
1582
  throw notFound("设定");
1403
1583
  return this.mapSetting(row);
1404
1584
  }
1405
- updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "") {
1585
+ updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1406
1586
  const current = this.getSetting(settingId);
1407
1587
  this.db.transaction(() => {
1588
+ this.assertExpectedVersion("setting", settingId, expectedVersionNo, "设定");
1408
1589
  this.db.run(`UPDATE settings SET title = ?, category = ?, content = ?, tags_json = ?, status = ?, locked = ?,
1409
1590
  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
1591
  this.recordEntityVersion("setting", settingId, source, sourceRef, changeNote || "更新世界观设定");
@@ -1412,9 +1593,10 @@ export class Store {
1412
1593
  });
1413
1594
  return this.getSetting(settingId);
1414
1595
  }
1415
- deleteSetting(settingId) {
1596
+ deleteSetting(settingId, expectedVersionNo) {
1416
1597
  const current = this.getSetting(settingId);
1417
1598
  this.db.transaction(() => {
1599
+ this.assertExpectedVersion("setting", settingId, expectedVersionNo, "设定");
1418
1600
  this.recordEntityVersion("setting", settingId, "delete", null, "删除世界观设定");
1419
1601
  this.db.run("DELETE FROM settings WHERE id = ?", settingId);
1420
1602
  this.audit(String(current.workId), "setting.deleted", "setting", settingId);
@@ -1433,6 +1615,7 @@ export class Store {
1433
1615
  evidence: json(requiredString(row, "evidence_json"), []),
1434
1616
  scope: json(requiredString(row, "scope_json"), {}),
1435
1617
  authorNote: requiredString(row, "author_note"),
1618
+ versionNo: this.currentEntityVersionNo("setting", requiredString(row, "id")),
1436
1619
  createdAt: requiredString(row, "created_at"),
1437
1620
  updatedAt: requiredString(row, "updated_at")
1438
1621
  };
@@ -1479,7 +1662,7 @@ export class Store {
1479
1662
  throw notFound("种族");
1480
1663
  return this.mapRace(row);
1481
1664
  }
1482
- updateRace(raceId, input, source = "manual", sourceRef = null, changeNote = "") {
1665
+ updateRace(raceId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1483
1666
  const current = this.getRace(raceId);
1484
1667
  const workId = String(current.workId);
1485
1668
  const name = input.name === undefined
@@ -1502,6 +1685,7 @@ export class Store {
1502
1685
  : [];
1503
1686
  const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
1504
1687
  this.db.transaction(() => {
1688
+ this.assertExpectedVersion("race", raceId, expectedVersionNo, "种族");
1505
1689
  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
1690
  if (nameChanged)
1507
1691
  this.db.run("UPDATE characters SET species = ?, updated_at = ? WHERE race_id = ?", name, now(), raceId);
@@ -1513,7 +1697,7 @@ export class Store {
1513
1697
  });
1514
1698
  return this.getRace(raceId);
1515
1699
  }
1516
- deleteRace(raceId) {
1700
+ deleteRace(raceId, expectedVersionNo) {
1517
1701
  const current = this.getRace(raceId);
1518
1702
  const child = this.db.get("SELECT id FROM races WHERE parent_race_id = ? LIMIT 1", raceId);
1519
1703
  if (child) {
@@ -1521,6 +1705,7 @@ export class Store {
1521
1705
  }
1522
1706
  const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
1523
1707
  this.db.transaction(() => {
1708
+ this.assertExpectedVersion("race", raceId, expectedVersionNo, "种族");
1524
1709
  this.recordEntityVersion("race", raceId, "delete", null, "删除种族档案");
1525
1710
  this.db.run("UPDATE characters SET race_id = NULL, species = '', updated_at = ? WHERE race_id = ?", now(), raceId);
1526
1711
  this.db.run("DELETE FROM races WHERE id = ?", raceId);
@@ -1528,6 +1713,43 @@ export class Store {
1528
1713
  this.audit(String(current.workId), "race.deleted", "race", raceId);
1529
1714
  });
1530
1715
  }
1716
+ mergeRaces(sourceRaceId, targetRaceId) {
1717
+ if (sourceRaceId === targetRaceId)
1718
+ throw new AppError(400, "RACE_MERGE_SELF", "不能把种族合并到自身");
1719
+ const source = this.getRace(sourceRaceId);
1720
+ const target = this.getRace(targetRaceId);
1721
+ if (source.workId !== target.workId)
1722
+ throw new AppError(400, "RACE_WORK_MISMATCH", "待合并种族不属于同一作品");
1723
+ const workId = String(target.workId);
1724
+ const mergeId = id("raceMerge");
1725
+ const timestamp = now();
1726
+ const memberIds = [...new Set([...target.memberIds, ...source.memberIds])];
1727
+ const memberSnapshots = this.captureCharacterSnapshots(memberIds);
1728
+ const sourceChildren = this.db.all("SELECT id FROM races WHERE parent_race_id = ? ORDER BY id", sourceRaceId)
1729
+ .map((row) => requiredString(row, "id"))
1730
+ .filter((childRaceId) => childRaceId !== targetRaceId);
1731
+ const targetDescendsFromSource = target.lineage.some((race) => race.id === sourceRaceId);
1732
+ const targetParentRaceId = targetDescendsFromSource
1733
+ ? source.parentRaceId
1734
+ : target.parentRaceId;
1735
+ const descriptionParts = [String(target.description).trim(), String(source.description).trim()].filter(Boolean);
1736
+ const description = [...new Set(descriptionParts)].join("\n\n");
1737
+ const settings = [...new Set([...target.settings, ...source.settings])];
1738
+ this.db.transaction(() => {
1739
+ this.recordEntityVersion("race", sourceRaceId, "delete", mergeId, `合并至种族“${String(target.name)}”`, timestamp);
1740
+ this.db.run("UPDATE races SET parent_race_id = ?, description = ?, settings_json = ?, updated_at = ? WHERE id = ?", targetParentRaceId, description, JSON.stringify(settings), timestamp, targetRaceId);
1741
+ this.db.run("UPDATE characters SET race_id = ?, species = ?, updated_at = ? WHERE race_id = ?", targetRaceId, String(target.name), timestamp, sourceRaceId);
1742
+ for (const childRaceId of sourceChildren) {
1743
+ this.db.run("UPDATE races SET parent_race_id = ?, updated_at = ? WHERE id = ?", targetRaceId, timestamp, childRaceId);
1744
+ this.recordEntityVersion("race", childRaceId, "merge", mergeId, `因种族“${String(source.name)}”合并而迁移父种族`, timestamp);
1745
+ }
1746
+ this.db.run("DELETE FROM races WHERE id = ?", sourceRaceId);
1747
+ this.recordMembershipVersions(memberSnapshots, "race", targetRaceId, `合并种族“${String(source.name)}”`);
1748
+ this.recordEntityVersion("race", targetRaceId, "merge", mergeId, `合并种族“${String(source.name)}”`, timestamp);
1749
+ this.audit(workId, "race.merged", "race", targetRaceId, { mergeId, sourceRaceId });
1750
+ });
1751
+ return { mergeId, target: this.getRace(targetRaceId), source };
1752
+ }
1531
1753
  resolveRaceReference(workId, value) {
1532
1754
  const normalizedName = normalizeCharacterName(value);
1533
1755
  if (!normalizedName)
@@ -1558,6 +1780,7 @@ export class Store {
1558
1780
  }))),
1559
1781
  memberIds: members.map((member) => member.characterId),
1560
1782
  members,
1783
+ versionNo: this.currentEntityVersionNo("race", requiredString(row, "id")),
1561
1784
  createdAt: requiredString(row, "created_at"),
1562
1785
  updatedAt: requiredString(row, "updated_at")
1563
1786
  };
@@ -1659,7 +1882,7 @@ export class Store {
1659
1882
  throw notFound("组织");
1660
1883
  return this.mapOrganization(row);
1661
1884
  }
1662
- updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "") {
1885
+ updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1663
1886
  const current = this.getOrganization(organizationId);
1664
1887
  const workId = String(current.workId);
1665
1888
  const name = input.name === undefined
@@ -1675,6 +1898,7 @@ export class Store {
1675
1898
  const touchedMemberIds = memberIds ? [...new Set([...current.memberIds, ...memberIds])] : [];
1676
1899
  const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
1677
1900
  this.db.transaction(() => {
1901
+ this.assertExpectedVersion("organization", organizationId, expectedVersionNo, "组织");
1678
1902
  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
1903
  if (memberIds) {
1680
1904
  this.replaceOrganizationMembers(organizationId, memberIds);
@@ -1685,16 +1909,49 @@ export class Store {
1685
1909
  });
1686
1910
  return this.getOrganization(organizationId);
1687
1911
  }
1688
- deleteOrganization(organizationId) {
1912
+ deleteOrganization(organizationId, expectedVersionNo) {
1689
1913
  const current = this.getOrganization(organizationId);
1690
1914
  const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
1691
1915
  this.db.transaction(() => {
1916
+ this.assertExpectedVersion("organization", organizationId, expectedVersionNo, "组织");
1692
1917
  this.recordEntityVersion("organization", organizationId, "delete", null, "删除组织档案");
1693
1918
  this.db.run("DELETE FROM organizations WHERE id = ?", organizationId);
1694
1919
  this.recordMembershipVersions(memberSnapshots, "organization", organizationId, `组织“${String(current.name)}”已删除`);
1695
1920
  this.audit(String(current.workId), "organization.deleted", "organization", organizationId);
1696
1921
  });
1697
1922
  }
1923
+ mergeOrganizations(sourceOrganizationId, targetOrganizationId) {
1924
+ if (sourceOrganizationId === targetOrganizationId) {
1925
+ throw new AppError(400, "ORGANIZATION_MERGE_SELF", "不能把组织合并到自身");
1926
+ }
1927
+ const source = this.getOrganization(sourceOrganizationId);
1928
+ const target = this.getOrganization(targetOrganizationId);
1929
+ if (source.workId !== target.workId) {
1930
+ throw new AppError(400, "ORGANIZATION_WORK_MISMATCH", "待合并组织不属于同一作品");
1931
+ }
1932
+ const workId = String(target.workId);
1933
+ const mergeId = id("organizationMerge");
1934
+ const timestamp = now();
1935
+ const memberIds = [...new Set([...target.memberIds, ...source.memberIds])];
1936
+ const memberSnapshots = this.captureCharacterSnapshots(memberIds);
1937
+ const descriptionParts = [String(target.description).trim(), String(source.description).trim()].filter(Boolean);
1938
+ const description = [...new Set(descriptionParts)].join("\n\n");
1939
+ const settings = [...new Set([...target.settings, ...source.settings])];
1940
+ const sourceMemberships = this.db.all("SELECT character_id, role, note, created_at FROM character_organization_memberships WHERE organization_id = ?", sourceOrganizationId);
1941
+ this.db.transaction(() => {
1942
+ this.recordEntityVersion("organization", sourceOrganizationId, "delete", mergeId, `合并至组织“${String(target.name)}”`, timestamp);
1943
+ this.db.run("UPDATE organizations SET description = ?, settings_json = ?, updated_at = ? WHERE id = ?", description, JSON.stringify(settings), timestamp, targetOrganizationId);
1944
+ for (const membership of sourceMemberships) {
1945
+ this.db.run(`INSERT INTO character_organization_memberships (character_id, organization_id, role, note, created_at, updated_at)
1946
+ 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);
1947
+ }
1948
+ this.db.run("DELETE FROM organizations WHERE id = ?", sourceOrganizationId);
1949
+ this.recordMembershipVersions(memberSnapshots, "organization", targetOrganizationId, `合并组织“${String(source.name)}”`);
1950
+ this.recordEntityVersion("organization", targetOrganizationId, "merge", mergeId, `合并组织“${String(source.name)}”`, timestamp);
1951
+ this.audit(workId, "organization.merged", "organization", targetOrganizationId, { mergeId, sourceOrganizationId });
1952
+ });
1953
+ return { mergeId, target: this.getOrganization(targetOrganizationId), source };
1954
+ }
1698
1955
  mapOrganization(row) {
1699
1956
  const members = this.db.all(`SELECT c.id, c.name, m.role, m.note
1700
1957
  FROM character_organization_memberships m
@@ -1713,6 +1970,7 @@ export class Store {
1713
1970
  settings: json(requiredString(row, "settings_json"), []),
1714
1971
  memberIds: members.map((member) => member.characterId),
1715
1972
  members,
1973
+ versionNo: this.currentEntityVersionNo("organization", requiredString(row, "id")),
1716
1974
  createdAt: requiredString(row, "created_at"),
1717
1975
  updatedAt: requiredString(row, "updated_at")
1718
1976
  };
@@ -1940,10 +2198,13 @@ export class Store {
1940
2198
  });
1941
2199
  return this.getCharacterProfileSection(sectionId);
1942
2200
  }
1943
- updateCharacterProfileSection(sectionId, input, source = "manual", sourceRef = null, changeNote = "") {
2201
+ updateCharacterProfileSection(sectionId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1944
2202
  const current = this.getCharacterProfileSection(sectionId);
2203
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(current.versionNo));
1945
2204
  const timestamp = now();
1946
2205
  this.db.transaction(() => {
2206
+ const lockedCurrent = this.getCharacterProfileSection(sectionId);
2207
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(lockedCurrent.versionNo));
1947
2208
  this.db.run(`UPDATE character_profile_sections SET section_type = ?, title = ?, content_markdown = ?, summary = ?, sort_order = ?,
1948
2209
  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
2210
  const section = this.getCharacterProfileSection(sectionId);
@@ -1954,9 +2215,12 @@ export class Store {
1954
2215
  });
1955
2216
  return this.getCharacterProfileSection(sectionId);
1956
2217
  }
1957
- deleteCharacterProfileSection(sectionId) {
2218
+ deleteCharacterProfileSection(sectionId, expectedVersionNo) {
1958
2219
  const current = this.getCharacterProfileSection(sectionId);
2220
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(current.versionNo));
1959
2221
  this.db.transaction(() => {
2222
+ const lockedCurrent = this.getCharacterProfileSection(sectionId);
2223
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(lockedCurrent.versionNo));
1960
2224
  this.db.run("UPDATE character_profile_sections SET version_no = version_no + 1 WHERE id = ?", sectionId);
1961
2225
  const deleting = this.getCharacterProfileSection(sectionId);
1962
2226
  this.recordCharacterProfileSectionVersion(deleting, "delete", null, "删除人物 Markdown 章节");
@@ -2006,15 +2270,16 @@ export class Store {
2006
2270
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
2007
2271
  })), pagination);
2008
2272
  }
2009
- restoreCharacterProfileSection(sectionId, versionNo) {
2273
+ restoreCharacterProfileSection(sectionId, versionNo, expectedVersionNo) {
2010
2274
  const version = this.db.get("SELECT * FROM character_profile_section_versions WHERE section_id = ? AND version_no = ?", sectionId, versionNo);
2011
2275
  if (!version)
2012
2276
  throw notFound("人物档案章节版本");
2013
2277
  const snapshot = json(requiredString(version, "snapshot_json"), {});
2014
2278
  const existing = this.db.get("SELECT id FROM character_profile_sections WHERE id = ?", sectionId);
2015
2279
  if (existing) {
2016
- return this.updateCharacterProfileSection(sectionId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
2280
+ return this.updateCharacterProfileSection(sectionId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
2017
2281
  }
2282
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", this.currentCharacterSectionVersionNo(sectionId));
2018
2283
  const characterId = requiredString(version, "character_id");
2019
2284
  const character = this.getCharacter(characterId);
2020
2285
  const timestamp = now();
@@ -2124,8 +2389,9 @@ export class Store {
2124
2389
  throw notFound("角色");
2125
2390
  return this.mapCharacter(row);
2126
2391
  }
2127
- updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "") {
2392
+ updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2128
2393
  const current = this.getCharacter(characterId);
2394
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
2129
2395
  if (current.mergedIntoCharacterId)
2130
2396
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能直接编辑");
2131
2397
  const before = this.characterSnapshot(current);
@@ -2146,6 +2412,8 @@ export class Store {
2146
2412
  if (organizationIds)
2147
2413
  this.assertOrganizationsInWork(workId, organizationIds);
2148
2414
  this.db.transaction(() => {
2415
+ const lockedCurrent = this.getCharacter(characterId);
2416
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(lockedCurrent.versionNo));
2149
2417
  this.db.run(`UPDATE characters SET name = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
2150
2418
  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
2419
  this.db.run("DELETE FROM character_names WHERE character_id = ?", characterId);
@@ -2204,7 +2472,7 @@ export class Store {
2204
2472
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
2205
2473
  })), pagination);
2206
2474
  }
2207
- restoreCharacter(characterId, versionNo) {
2475
+ restoreCharacter(characterId, versionNo, expectedVersionNo) {
2208
2476
  const version = this.db.get("SELECT * FROM character_versions WHERE character_id = ? AND version_no = ?", characterId, versionNo);
2209
2477
  if (!version)
2210
2478
  throw notFound("人物版本");
@@ -2213,9 +2481,10 @@ export class Store {
2213
2481
  throw new AppError(500, "CHARACTER_VERSION_INVALID", "人物版本快照无效");
2214
2482
  const existing = this.db.get("SELECT id FROM characters WHERE id = ?", characterId);
2215
2483
  if (!existing) {
2484
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", this.currentCharacterVersionNo(characterId));
2216
2485
  return this.recreateCharacterFromVersion(characterId, version, snapshot, versionNo);
2217
2486
  }
2218
- return this.updateCharacter(characterId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
2487
+ return this.updateCharacter(characterId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
2219
2488
  }
2220
2489
  recreateCharacterFromVersion(characterId, version, snapshot, versionNo) {
2221
2490
  const workId = requiredString(version, "work_id");
@@ -2242,11 +2511,24 @@ export class Store {
2242
2511
  });
2243
2512
  return this.getCharacter(characterId);
2244
2513
  }
2245
- deleteCharacter(characterId) {
2514
+ deleteCharacter(characterId, expectedVersionNo) {
2246
2515
  const current = this.getCharacter(characterId);
2516
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
2247
2517
  const timestamp = now();
2248
2518
  const versionNo = Number(current.versionNo) + 1;
2519
+ const workId = String(current.workId);
2520
+ const timelineEvents = this.listTimelineEvents(workId).filter((event) => event.participantIds.includes(characterId));
2521
+ const relationships = this.listRelationships(workId).filter((relationship) => relationship.fromCharacterId === characterId || relationship.toCharacterId === characterId);
2249
2522
  this.db.transaction(() => {
2523
+ const lockedCurrent = this.getCharacter(characterId);
2524
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(lockedCurrent.versionNo));
2525
+ for (const event of timelineEvents) {
2526
+ this.updateTimelineEvent(String(event.id), {
2527
+ participantIds: event.participantIds.filter((participantId) => participantId !== characterId)
2528
+ }, "manual", characterId, `删除角色“${String(current.name)}”后移除参与者引用`);
2529
+ }
2530
+ for (const relationship of relationships)
2531
+ this.deleteRelationship(String(relationship.id));
2250
2532
  const sectionIds = this.db.all("SELECT id FROM character_profile_sections WHERE character_id = ?", characterId)
2251
2533
  .map((row) => requiredString(row, "id"));
2252
2534
  for (const sectionId of sectionIds) {
@@ -2255,7 +2537,7 @@ export class Store {
2255
2537
  this.db.run("UPDATE characters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, characterId);
2256
2538
  this.insertCharacterVersion(characterId, versionNo, "delete", null, "删除人物", timestamp);
2257
2539
  this.db.run("DELETE FROM characters WHERE id = ?", characterId);
2258
- this.audit(String(current.workId), "character.deleted", "character", characterId, { versionNo });
2540
+ this.audit(workId, "character.deleted", "character", characterId, { versionNo });
2259
2541
  });
2260
2542
  }
2261
2543
  mapCharacter(row, includeProfileSections = true) {
@@ -2330,29 +2612,31 @@ export class Store {
2330
2612
  if (input.targetCharacterId === input.sourceCharacterId) {
2331
2613
  throw new AppError(400, "CHARACTER_MERGE_SELF", "不能把角色合并到自身");
2332
2614
  }
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", "待合并角色与审核项不一致");
2615
+ const review = input.reviewId ? this.getReviewItem(input.reviewId) : null;
2616
+ if (review) {
2617
+ if (review.itemType !== "character-duplicate" || review.status !== "pending") {
2618
+ throw new AppError(409, "CHARACTER_REVIEW_DECIDED", "该角色查重项已经处理");
2619
+ }
2620
+ const reviewCharacterIds = review.entityRefs.flatMap((reference) => {
2621
+ if (!reference || typeof reference !== "object" || Array.isArray(reference))
2622
+ return [];
2623
+ const characterId = reference.id;
2624
+ return typeof characterId === "string" ? [characterId] : [];
2625
+ });
2626
+ if (!reviewCharacterIds.includes(input.targetCharacterId) || !reviewCharacterIds.includes(input.sourceCharacterId)) {
2627
+ throw new AppError(400, "CHARACTER_REVIEW_MISMATCH", "待合并角色与审核项不一致");
2628
+ }
2345
2629
  }
2346
2630
  const target = this.getCharacter(input.targetCharacterId);
2347
2631
  const source = this.getCharacter(input.sourceCharacterId);
2348
- if (target.workId !== source.workId || target.workId !== review.workId) {
2632
+ if (target.workId !== source.workId || (review && target.workId !== review.workId)) {
2349
2633
  throw new AppError(400, "CHARACTER_WORK_MISMATCH", "待合并角色不属于同一作品");
2350
2634
  }
2351
2635
  if (target.mergedIntoCharacterId || source.mergedIntoCharacterId) {
2352
2636
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "待合并角色中已有角色被合并");
2353
2637
  }
2354
2638
  if (Number(target.versionNo) !== input.expectedTargetVersionNo || Number(source.versionNo) !== input.expectedSourceVersionNo) {
2355
- throw new AppError(409, "CHARACTER_VERSION_CHANGED", "角色在审核后已发生变化,请重新运行查重");
2639
+ throw new AppError(409, "CHARACTER_VERSION_CHANGED", "角色已发生变化,请刷新后重试");
2356
2640
  }
2357
2641
  const workId = String(target.workId);
2358
2642
  const targetId = String(target.id);
@@ -2364,6 +2648,10 @@ export class Store {
2364
2648
  const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
2365
2649
  const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
2366
2650
  this.db.transaction(() => {
2651
+ const lockedTarget = this.getCharacter(targetId);
2652
+ const lockedSource = this.getCharacter(sourceId);
2653
+ this.assertExpectedRevision("character", targetId, input.expectedTargetVersionNo, "目标角色", Number(lockedTarget.versionNo));
2654
+ this.assertExpectedRevision("character", sourceId, input.expectedSourceVersionNo, "来源角色", Number(lockedSource.versionNo));
2367
2655
  this.db.run("DELETE FROM character_names WHERE character_id = ?", sourceId);
2368
2656
  const aliases = [...target.aliases, String(source.name), ...source.aliases];
2369
2657
  const uniqueAliases = [...new Map(aliases
@@ -2380,7 +2668,7 @@ export class Store {
2380
2668
  currentState: { ...source.currentState, ...target.currentState },
2381
2669
  lockedFields: [...new Set([...target.lockedFields, ...source.lockedFields])],
2382
2670
  firstChapterId: target.firstChapterId ?? source.firstChapterId
2383
- }, "merge", mergeId, `合并角色“${String(source.name)}”`);
2671
+ }, "merge", mergeId, `合并角色“${String(source.name)}”`, input.expectedTargetVersionNo);
2384
2672
  for (const event of timelineEvents) {
2385
2673
  const participantIds = [...new Set(event.participantIds.map((characterId) => characterId === sourceId ? targetId : characterId))];
2386
2674
  this.updateTimelineEvent(String(event.id), { participantIds }, "merge", mergeId, `合并角色“${String(source.name)}”`);
@@ -2422,13 +2710,18 @@ export class Store {
2422
2710
  }
2423
2711
  }
2424
2712
  this.db.run("DELETE FROM character_organization_memberships WHERE character_id = ?", sourceId);
2713
+ this.db.run("UPDATE character_profile_sections SET character_id = ?, updated_at = ? WHERE character_id = ?", targetId, timestamp, sourceId);
2714
+ this.db.run("UPDATE character_profile_section_versions SET character_id = ? WHERE character_id = ?", targetId, sourceId);
2715
+ this.db.run("UPDATE character_profile_section_search SET character_id = ? WHERE character_id = ?", targetId, sourceId);
2425
2716
  const sourceVersionNo = Number(source.versionNo) + 1;
2426
2717
  this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
2427
2718
  this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
2428
2719
  this.db.run(`INSERT INTO character_merges (id, work_id, source_character_id, target_character_id, review_id,
2429
2720
  source_snapshot_json, target_snapshot_json, reference_snapshot_json, created_at, created_by_user_id)
2430
2721
  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);
2722
+ if (input.reviewId) {
2723
+ this.db.run("UPDATE review_items SET status = 'fixed', resolution_note = ?, updated_at = ? WHERE id = ?", `已将“${String(source.name)}”合并到“${String(target.name)}”`, timestamp, input.reviewId);
2724
+ }
2432
2725
  this.audit(workId, "character.merged", "character", targetId, {
2433
2726
  mergeId,
2434
2727
  sourceCharacterId: sourceId,
@@ -2439,7 +2732,7 @@ export class Store {
2439
2732
  mergeId,
2440
2733
  target: this.getCharacter(targetId),
2441
2734
  source: this.getCharacter(sourceId),
2442
- review: this.getReviewItem(input.reviewId)
2735
+ review: input.reviewId ? this.getReviewItem(input.reviewId) : null
2443
2736
  };
2444
2737
  }
2445
2738
  resolveCharacterDuplicateReview(reviewId) {
@@ -2522,18 +2815,20 @@ export class Store {
2522
2815
  throw notFound("独立时间轴");
2523
2816
  return this.mapTimelineTrack(row);
2524
2817
  }
2525
- updateTimelineTrack(trackId, input, source = "manual", sourceRef = null, changeNote = "") {
2818
+ updateTimelineTrack(trackId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2526
2819
  const current = this.getTimelineTrack(trackId);
2527
2820
  this.db.transaction(() => {
2821
+ this.assertExpectedVersion("timeline-track", trackId, expectedVersionNo, "时间轴");
2528
2822
  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
2823
  this.recordEntityVersion("timeline-track", trackId, source, sourceRef, changeNote || "更新时间轴");
2530
2824
  this.audit(String(current.workId), "timeline-track.updated", "timeline-track", trackId, { fields: Object.keys(input), source, sourceRef });
2531
2825
  });
2532
2826
  return this.getTimelineTrack(trackId);
2533
2827
  }
2534
- deleteTimelineTrack(trackId) {
2828
+ deleteTimelineTrack(trackId, expectedVersionNo) {
2535
2829
  const current = this.getTimelineTrack(trackId);
2536
2830
  this.db.transaction(() => {
2831
+ this.assertExpectedVersion("timeline-track", trackId, expectedVersionNo, "时间轴");
2537
2832
  this.recordEntityVersion("timeline-track", trackId, "delete", null, "删除时间轴");
2538
2833
  this.db.run("DELETE FROM timeline_tracks WHERE id = ?", trackId);
2539
2834
  this.audit(String(current.workId), "timeline-track.deleted", "timeline-track", trackId);
@@ -2582,7 +2877,7 @@ export class Store {
2582
2877
  throw notFound("时间线事件");
2583
2878
  return this.mapTimelineEvent(row);
2584
2879
  }
2585
- updateTimelineEvent(eventId, input, source = "manual", sourceRef = null, changeNote = "") {
2880
+ updateTimelineEvent(eventId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2586
2881
  const current = this.getTimelineEvent(eventId);
2587
2882
  if (input.trackId) {
2588
2883
  const track = this.getTimelineTrack(input.trackId);
@@ -2590,6 +2885,7 @@ export class Store {
2590
2885
  throw new AppError(400, "TIMELINE_TRACK_WORK_MISMATCH", "独立时间轴不属于当前作品");
2591
2886
  }
2592
2887
  this.db.transaction(() => {
2888
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件");
2593
2889
  this.db.run(`UPDATE timeline_events SET track_id = ?, name = ?, description = ?, event_type = ?, time_label = ?, time_sort = ?,
2594
2890
  chapter_ids_json = ?, participant_ids_json = ?, location = ?, causes_json = ?, impact_scope = ?, evidence_json = ?,
2595
2891
  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 +2894,16 @@ export class Store {
2598
2894
  });
2599
2895
  return this.getTimelineEvent(eventId);
2600
2896
  }
2601
- deleteTimelineEvent(eventId) {
2897
+ deleteTimelineEvent(eventId, expectedVersionNo) {
2602
2898
  const current = this.getTimelineEvent(eventId);
2603
2899
  this.db.transaction(() => {
2900
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件");
2604
2901
  this.recordEntityVersion("timeline-event", eventId, "delete", null, "删除时间事件");
2605
2902
  this.db.run("DELETE FROM timeline_events WHERE id = ?", eventId);
2606
2903
  this.audit(String(current.workId), "timeline.deleted", "timeline-event", eventId);
2607
2904
  });
2608
2905
  }
2609
- mergeTimelineEvents(workId, eventIds, input) {
2906
+ mergeTimelineEvents(workId, eventIds, input, expectedVersionNos) {
2610
2907
  this.getWork(workId);
2611
2908
  const uniqueIds = [...new Set(eventIds)];
2612
2909
  if (uniqueIds.length < 2)
@@ -2620,6 +2917,9 @@ export class Store {
2620
2917
  };
2621
2918
  const knownSorts = events.map((event) => event.timeSort).filter((value) => typeof value === "number");
2622
2919
  return this.db.transaction(() => {
2920
+ for (const event of events) {
2921
+ this.assertExpectedVersion("timeline-event", String(event.id), expectedVersionNos?.[String(event.id)], "时间事件", Number(event.versionNo));
2922
+ }
2623
2923
  const merged = this.createTimelineEvent(workId, {
2624
2924
  name: input.name,
2625
2925
  trackId: events.every((event) => event.trackId === events[0]?.trackId) ? events[0]?.trackId : null,
@@ -2643,11 +2943,14 @@ export class Store {
2643
2943
  return merged;
2644
2944
  });
2645
2945
  }
2646
- splitTimelineEvent(eventId, parts) {
2946
+ splitTimelineEvent(eventId, parts, expectedVersionNo) {
2647
2947
  const source = this.getTimelineEvent(eventId);
2948
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件", Number(source.versionNo));
2648
2949
  if (parts.length < 2)
2649
2950
  throw new AppError(400, "EVENT_PARTS_REQUIRED", "拆分时间事件至少需要两项");
2650
2951
  return this.db.transaction(() => {
2952
+ const lockedSource = this.getTimelineEvent(eventId);
2953
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件", Number(lockedSource.versionNo));
2651
2954
  const created = parts.map((part, index) => this.createTimelineEvent(String(source.workId), {
2652
2955
  name: part.name,
2653
2956
  trackId: source.trackId,
@@ -2688,6 +2991,7 @@ export class Store {
2688
2991
  impactScope: requiredString(row, "impact_scope"),
2689
2992
  evidence: json(requiredString(row, "evidence_json"), []),
2690
2993
  status: requiredString(row, "status"),
2994
+ versionNo: this.currentEntityVersionNo("timeline-event", requiredString(row, "id")),
2691
2995
  createdAt: requiredString(row, "created_at"),
2692
2996
  updatedAt: requiredString(row, "updated_at")
2693
2997
  };
@@ -2699,6 +3003,7 @@ export class Store {
2699
3003
  name: requiredString(row, "name"),
2700
3004
  description: requiredString(row, "description"),
2701
3005
  sortOrder: numberValue(row, "sort_order"),
3006
+ versionNo: this.currentEntityVersionNo("timeline-track", requiredString(row, "id")),
2702
3007
  createdAt: requiredString(row, "created_at"),
2703
3008
  updatedAt: requiredString(row, "updated_at")
2704
3009
  };
@@ -2750,7 +3055,7 @@ export class Store {
2750
3055
  throw notFound("人物关系");
2751
3056
  return this.mapRelationship(row);
2752
3057
  }
2753
- updateRelationship(relationshipId, input, source = "manual", sourceRef = null, changeNote = "") {
3058
+ updateRelationship(relationshipId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2754
3059
  const current = this.getRelationship(relationshipId);
2755
3060
  let fromCharacterId = input.fromCharacterId ?? String(current.fromCharacterId);
2756
3061
  let toCharacterId = input.toCharacterId ?? String(current.toCharacterId);
@@ -2767,6 +3072,7 @@ export class Store {
2767
3072
  [fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
2768
3073
  this.assertRelationshipUnique(String(current.workId), fromCharacterId, toCharacterId, input.category ?? String(current.category), input.subtype ?? String(current.subtype), directed, relationshipId);
2769
3074
  this.db.transaction(() => {
3075
+ this.assertExpectedVersion("relationship", relationshipId, expectedVersionNo, "人物关系");
2770
3076
  this.db.run(`UPDATE relationships SET from_character_id = ?, to_character_id = ?, category = ?, subtype = ?, keywords_json = ?, directed = ?,
2771
3077
  current_status = ?, time_range_json = ?, confidence = ?, evidence_json = ?, confirmation_status = ?, locked = ?, updated_at = ?
2772
3078
  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 +3081,10 @@ export class Store {
2775
3081
  });
2776
3082
  return this.getRelationship(relationshipId);
2777
3083
  }
2778
- deleteRelationship(relationshipId) {
3084
+ deleteRelationship(relationshipId, expectedVersionNo) {
2779
3085
  const current = this.getRelationship(relationshipId);
2780
3086
  this.db.transaction(() => {
3087
+ this.assertExpectedVersion("relationship", relationshipId, expectedVersionNo, "人物关系");
2781
3088
  this.recordEntityVersion("relationship", relationshipId, "delete", null, "删除人物关系");
2782
3089
  this.db.run("DELETE FROM relationships WHERE id = ?", relationshipId);
2783
3090
  this.audit(String(current.workId), "relationship.deleted", "relationship", relationshipId);
@@ -2799,6 +3106,7 @@ export class Store {
2799
3106
  evidence: json(requiredString(row, "evidence_json"), []),
2800
3107
  confirmationStatus: requiredString(row, "confirmation_status"),
2801
3108
  locked: booleanValue(row, "locked"),
3109
+ versionNo: this.currentEntityVersionNo("relationship", requiredString(row, "id")),
2802
3110
  createdAt: requiredString(row, "created_at"),
2803
3111
  updatedAt: requiredString(row, "updated_at")
2804
3112
  };