@musnows/scriverse 0.3.7 → 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
@@ -2,9 +2,12 @@ import { createHash } from "node:crypto";
2
2
  import { PLATFORM_AI_WORK_ID } from "./database.js";
3
3
  import { AppError, notFound } from "./errors.js";
4
4
  import { accountReference, logger } from "./logger.js";
5
+ import { paginated, paginationSql } from "./pagination.js";
5
6
  import { currentRequestActor } from "./request-context.js";
6
7
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
7
8
  export const versionedEntityTypes = [
9
+ "work",
10
+ "volume",
8
11
  "setting",
9
12
  "race",
10
13
  "organization",
@@ -35,7 +38,44 @@ export class Store {
35
38
  this.db = db;
36
39
  this.backfillEntityVersionBaselines();
37
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
+ }
38
74
  versionedEntity(type, entityId) {
75
+ if (type === "work")
76
+ return this.getWork(entityId);
77
+ if (type === "volume")
78
+ return this.getVolume(entityId);
39
79
  if (type === "setting")
40
80
  return this.getSetting(entityId);
41
81
  if (type === "race")
@@ -67,6 +107,25 @@ export class Store {
67
107
  }
68
108
  }
69
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
+ };
70
129
  if (type === "setting")
71
130
  return {
72
131
  title: entity.title,
@@ -164,11 +223,13 @@ export class Store {
164
223
  }
165
224
  const versionNo = latest ? numberValue(latest, "version_no") + 1 : 1;
166
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)
167
- 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);
168
227
  return versionNo;
169
228
  }
170
229
  backfillEntityVersionBaselines() {
171
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")]),
172
233
  ...this.db.all("SELECT id, updated_at FROM settings").map((row) => ["setting", requiredString(row, "id"), requiredString(row, "updated_at")]),
173
234
  ...this.db.all("SELECT id, updated_at FROM races").map((row) => ["race", requiredString(row, "id"), requiredString(row, "updated_at")]),
174
235
  ...this.db.all("SELECT id, updated_at FROM organizations").map((row) => ["organization", requiredString(row, "id"), requiredString(row, "updated_at")]),
@@ -206,7 +267,28 @@ export class Store {
206
267
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
207
268
  }));
208
269
  }
209
- restoreEntityVersion(type, entityId, versionNo) {
270
+ listEntityVersionsPage(type, entityId, pagination) {
271
+ const page = paginationSql(pagination);
272
+ const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
273
+ FROM entity_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
274
+ WHERE version.entity_type = ? AND version.entity_id = ? ORDER BY version.version_no DESC${page.sql}`, type, entityId, ...page.params);
275
+ if (!rows.length && pagination.page === 1)
276
+ this.versionedEntity(type, entityId);
277
+ return paginated(rows.map((row) => ({
278
+ id: requiredString(row, "id"),
279
+ workId: requiredString(row, "work_id"),
280
+ entityType: requiredString(row, "entity_type"),
281
+ entityId: requiredString(row, "entity_id"),
282
+ versionNo: numberValue(row, "version_no"),
283
+ snapshot: json(requiredString(row, "snapshot_json"), {}),
284
+ source: requiredString(row, "source"),
285
+ sourceRef: optionalString(row, "source_ref"),
286
+ changeNote: requiredString(row, "change_note"),
287
+ createdAt: requiredString(row, "created_at"),
288
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
289
+ })), pagination);
290
+ }
291
+ restoreEntityVersion(type, entityId, versionNo, expectedVersionNo) {
210
292
  const version = this.db.get("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? AND version_no = ?", type, entityId, versionNo);
211
293
  if (!version)
212
294
  throw notFound("历史版本");
@@ -217,31 +299,57 @@ export class Store {
217
299
  const changeNote = `恢复至 v${versionNo}`;
218
300
  const workId = requiredString(version, "work_id");
219
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);
220
306
  let restored;
221
307
  if (!existing) {
222
308
  restored = this.recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote);
223
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);
224
314
  else if (type === "setting")
225
- restored = this.updateSetting(entityId, snapshot, "restore", sourceRef, changeNote);
315
+ restored = this.updateSetting(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
226
316
  else if (type === "race")
227
- restored = this.updateRace(entityId, snapshot, "restore", sourceRef, changeNote);
317
+ restored = this.updateRace(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
228
318
  else if (type === "organization")
229
- restored = this.updateOrganization(entityId, snapshot, "restore", sourceRef, changeNote);
319
+ restored = this.updateOrganization(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
230
320
  else if (type === "timeline-track")
231
- restored = this.updateTimelineTrack(entityId, snapshot, "restore", sourceRef, changeNote);
321
+ restored = this.updateTimelineTrack(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
232
322
  else if (type === "timeline-event")
233
- restored = this.updateTimelineEvent(entityId, snapshot, "restore", sourceRef, changeNote);
323
+ restored = this.updateTimelineEvent(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
234
324
  else if (type === "relationship")
235
- restored = this.updateRelationship(entityId, snapshot, "restore", sourceRef, changeNote);
325
+ restored = this.updateRelationship(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
236
326
  else if (type === "chapter-outline")
237
- restored = this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote);
327
+ restored = this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
238
328
  else
239
- restored = this.updateForeshadow(entityId, snapshot, "restore", sourceRef, changeNote);
329
+ restored = this.updateForeshadow(entityId, snapshot, "restore", sourceRef, changeNote, expectedVersionNo);
240
330
  const currentVersion = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
241
331
  return { ...restored, versionNo: numberValue(currentVersion ?? {}, "version_no") };
242
332
  }
243
333
  recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote) {
244
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
+ }
245
353
  if (type === "setting") {
246
354
  return this.insertSettingWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
247
355
  }
@@ -288,6 +396,7 @@ export class Store {
288
396
  if (actor) {
289
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);
290
398
  }
399
+ this.recordEntityVersion("work", workId, "create", null, "建立作品", timestamp);
291
400
  this.audit(workId, "work.created", "work", workId);
292
401
  });
293
402
  return this.getWork(workId);
@@ -301,6 +410,16 @@ export class Store {
301
410
  WHERE COALESCE(work.is_internal, 0) = 0 AND (work.owner_user_id = ? OR membership.user_id = ?)
302
411
  ORDER BY work.updated_at DESC`, actor.userId, actor.userId).map((row) => this.mapWork(row));
303
412
  }
413
+ listWorksPage(pagination) {
414
+ const actor = currentRequestActor();
415
+ const page = paginationSql(pagination);
416
+ const rows = !actor || (actor.role === "admin" && actor.authentication !== "api-key")
417
+ ? this.db.all(`SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 ORDER BY updated_at DESC${page.sql}`, ...page.params)
418
+ : this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
419
+ WHERE COALESCE(work.is_internal, 0) = 0 AND (work.owner_user_id = ? OR membership.user_id = ?)
420
+ ORDER BY work.updated_at DESC${page.sql}`, actor.userId, actor.userId, ...page.params);
421
+ return paginated(rows.map((row) => this.mapWork(row)), pagination);
422
+ }
304
423
  getWork(workId) {
305
424
  const row = this.db.get("SELECT * FROM works WHERE id = ?", workId);
306
425
  if (!row)
@@ -399,34 +518,45 @@ export class Store {
399
518
  });
400
519
  return this.getWorkAiSettings(workId);
401
520
  }
402
- updateWork(workId, input) {
403
- const current = this.getWork(workId);
404
- const timestamp = now();
405
- this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?, updated_at = ?
406
- 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);
407
- 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
+ });
408
531
  return this.getWork(workId);
409
532
  }
410
- deleteWork(workId) {
533
+ deleteWork(workId, expectedVersionNo) {
411
534
  const work = this.getWork(workId);
412
535
  const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
413
536
  .map((row) => requiredString(row, "storage_key"));
414
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, "删除作品");
415
541
  this.audit(null, "work.deleted", "work", workId, { title: work.title });
416
542
  this.db.run("DELETE FROM works WHERE id = ?", workId);
417
543
  });
418
544
  return storageKeys.filter((storageKey) => Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) === 0);
419
545
  }
420
- setWorkCover(workId, mimeType, content) {
421
- this.getWork(workId);
422
- const timestamp = now();
546
+ setWorkCover(workId, mimeType, content, expectedVersionNo) {
423
547
  const sha256 = createHash("sha256").update(content).digest("hex");
424
- this.db.run(`INSERT INTO work_covers (work_id, mime_type, content, byte_length, sha256, updated_at)
425
- VALUES (?, ?, ?, ?, ?, ?)
426
- ON CONFLICT(work_id) DO UPDATE SET mime_type = excluded.mime_type, content = excluded.content,
427
- byte_length = excluded.byte_length, sha256 = excluded.sha256, updated_at = excluded.updated_at`, workId, mimeType, content, content.byteLength, sha256, timestamp);
428
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
429
- 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
+ });
430
560
  return this.getWork(workId);
431
561
  }
432
562
  getWorkCover(workId) {
@@ -442,11 +572,16 @@ export class Store {
442
572
  updatedAt: requiredString(row, "updated_at")
443
573
  };
444
574
  }
445
- deleteWorkCover(workId) {
446
- this.getWork(workId);
447
- this.db.run("DELETE FROM work_covers WHERE work_id = ?", workId);
448
- this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", now(), workId);
449
- 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
+ });
450
585
  }
451
586
  getWorkTree(workId) {
452
587
  const work = this.getWork(workId);
@@ -486,6 +621,27 @@ export class Store {
486
621
  }));
487
622
  return { ...work, volumes };
488
623
  }
624
+ getWorkDirectoryPage(workId, pagination) {
625
+ const work = this.getWork(workId);
626
+ const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
627
+ const page = paginationSql(pagination);
628
+ const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
629
+ analysis_status, excluded_from_analysis, created_at, updated_at
630
+ FROM chapters WHERE work_id = ? ORDER BY sort_order, created_at${page.sql}`, workId, ...page.params);
631
+ const chapters = chapterRows.map((row) => this.mapChapterDirectoryEntry(row));
632
+ const chaptersByVolume = new Map();
633
+ for (const chapter of chapters) {
634
+ const volumeId = String(chapter.volumeId);
635
+ const list = chaptersByVolume.get(volumeId) ?? [];
636
+ list.push(chapter);
637
+ chaptersByVolume.set(volumeId, list);
638
+ }
639
+ const volumes = volumeRows.map((row) => ({
640
+ ...this.mapVolume(row),
641
+ chapters: chaptersByVolume.get(requiredString(row, "id")) ?? []
642
+ }));
643
+ return { ...work, volumes, directoryPage: paginated(chapters, pagination) };
644
+ }
489
645
  listFileVersions(workId) {
490
646
  this.getWork(workId);
491
647
  return this.db
@@ -505,7 +661,26 @@ export class Store {
505
661
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
506
662
  }));
507
663
  }
508
- restoreFileVersion(workId, fileVersionId) {
664
+ listFileVersionsPage(workId, pagination) {
665
+ this.getWork(workId);
666
+ const page = paginationSql(pagination);
667
+ const rows = this.db.all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
668
+ version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
669
+ FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
670
+ WHERE version.work_id = ? ORDER BY version.created_at DESC${page.sql}`, workId, ...page.params);
671
+ return paginated(rows.map((row) => ({
672
+ id: requiredString(row, "id"),
673
+ workId: requiredString(row, "work_id"),
674
+ fileName: requiredString(row, "file_name"),
675
+ fileType: requiredString(row, "file_type"),
676
+ wordCount: numberValue(row, "word_count"),
677
+ paragraphCount: numberValue(row, "paragraph_count"),
678
+ warnings: json(requiredString(row, "warnings_json"), []),
679
+ createdAt: requiredString(row, "created_at"),
680
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
681
+ })), pagination);
682
+ }
683
+ restoreFileVersion(workId, fileVersionId, expectedVersionNo) {
509
684
  this.getWork(workId);
510
685
  const version = this.db.get("SELECT * FROM file_versions WHERE id = ? AND work_id = ?", fileVersionId, workId);
511
686
  if (!version)
@@ -513,6 +688,8 @@ export class Store {
513
688
  const snapshot = json(requiredString(version, "snapshot_json"), {});
514
689
  const volumes = Array.isArray(snapshot.volumes) ? snapshot.volumes : [];
515
690
  return this.db.transaction(() => {
691
+ const current = this.getWork(workId);
692
+ this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
516
693
  const currentTree = this.getWorkTree(workId);
517
694
  const currentChapters = this.db.all("SELECT content FROM chapters WHERE work_id = ?", workId);
518
695
  const wordCount = currentChapters.reduce((sum, row) => sum + countWords(requiredString(row, "content")), 0);
@@ -524,12 +701,21 @@ export class Store {
524
701
  const timestamp = now();
525
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)
526
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
+ }
527
707
  this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
528
708
  for (const volume of volumes) {
529
709
  const volumeId = id("volume");
530
710
  const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
531
- this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, created_at, updated_at)
532
- 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}`);
533
719
  for (const chapter of chapters) {
534
720
  const chapterType = (["正文", "设定", "作者的话", "其他"].includes(String(chapter.chapterType))
535
721
  ? String(chapter.chapterType)
@@ -537,7 +723,8 @@ export class Store {
537
723
  this.insertChapter(workId, volumeId, String(chapter.title ?? "未命名章节"), String(chapter.content ?? ""), Number(chapter.sortOrder ?? 0), "restore", fileVersionId, chapterType);
538
724
  }
539
725
  }
540
- 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);
541
728
  this.audit(workId, "file.restored", "file-version", fileVersionId, { restorePointId });
542
729
  return {
543
730
  fileVersionId: restorePointId,
@@ -546,55 +733,84 @@ export class Store {
546
733
  };
547
734
  });
548
735
  }
549
- importNovel(workId, fileName, fileType, parsed) {
736
+ importNovel(workId, fileName, fileType, parsed, mode = "overwrite", expectedVersionNo) {
550
737
  this.getWork(workId);
551
738
  let result = {};
552
- this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed); });
739
+ this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed, mode, expectedVersionNo); });
553
740
  return { ...result, tree: this.getWorkDirectory(workId) };
554
741
  }
555
742
  createImportedWork(input, fileName, fileType, parsed) {
556
743
  return this.db.transaction(() => {
557
744
  const work = this.createWork(input);
558
- const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed);
745
+ const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed, undefined, undefined, false);
559
746
  return { ...imported, work: this.getWork(String(work.id)) };
560
747
  });
561
748
  }
562
- 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));
563
752
  const fileVersionId = id("file");
564
753
  const timestamp = now();
565
754
  const snapshot = this.getWorkTree(workId);
566
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)
567
756
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, fileVersionId, workId, fileName, fileType, parsed.wordCount, parsed.paragraphCount, JSON.stringify(parsed.warnings), JSON.stringify(snapshot), timestamp, currentRequestActor()?.userId ?? null);
568
- 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;
569
769
  for (const volume of parsed.volumes) {
570
770
  const volumeId = id("volume");
571
- this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, sort_order, created_at, updated_at)
572
- 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, "导入分卷");
573
777
  for (const chapter of volume.chapters) {
574
- 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;
575
780
  }
576
781
  }
577
- 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
+ }
578
786
  this.audit(workId, "work.imported", "file-version", fileVersionId, {
579
787
  fileName,
788
+ mode,
580
789
  volumeCount: parsed.volumes.length,
581
790
  chapterCount: parsed.volumes.reduce((sum, volume) => sum + volume.chapters.length, 0)
582
791
  });
583
792
  return {
584
793
  fileVersionId,
794
+ firstImportedChapterId,
795
+ mode,
585
796
  warnings: parsed.warnings,
586
797
  wordCount: parsed.wordCount,
587
798
  paragraphCount: parsed.paragraphCount
588
799
  };
589
800
  }
590
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 = "") {
591
805
  this.getWork(workId);
592
- const volumeId = id("volume");
593
806
  const timestamp = now();
594
807
  const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ?", workId);
595
- this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, created_at, updated_at)
596
- VALUES (?, ?, ?, ?, 'manual', ?, ?, ?, ?, ?)`, volumeId, workId, input.title, input.kind ?? "main", input.description?.trim() ?? "", JSON.stringify(this.normalizeVolumeKeywords(input.keywords ?? [])), numberValue(last ?? {}, "value") + 1, timestamp, timestamp);
597
- 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 });
598
814
  return this.getVolume(volumeId);
599
815
  }
600
816
  getVolume(volumeId) {
@@ -603,20 +819,30 @@ export class Store {
603
819
  throw notFound("卷");
604
820
  return this.mapVolume(row);
605
821
  }
606
- updateVolume(volumeId, input) {
607
- const current = this.getVolume(volumeId);
608
- 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);
609
- 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
+ });
610
831
  return this.getVolume(volumeId);
611
832
  }
612
- deleteVolume(volumeId) {
833
+ deleteVolume(volumeId, expectedVersionNo) {
613
834
  const volume = this.getVolume(volumeId);
614
835
  const count = this.db.get("SELECT COUNT(*) AS value FROM chapters WHERE volume_id = ?", volumeId);
615
836
  if (numberValue(count ?? {}, "value") > 0) {
616
837
  throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
617
838
  }
618
- this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
619
- 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
+ });
620
846
  }
621
847
  createChapter(workId, input) {
622
848
  this.getWork(workId);
@@ -671,6 +897,17 @@ export class Store {
671
897
  }
672
898
  return rows.map((row) => this.mapChapterVersionRow(row));
673
899
  }
900
+ listChapterVersionsPage(chapterId, pagination) {
901
+ const page = paginationSql(pagination);
902
+ const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
903
+ FROM chapter_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
904
+ WHERE version.chapter_id = ? ORDER BY version.version_no DESC${page.sql}`, chapterId, ...page.params);
905
+ if (!rows.length) {
906
+ this.getChapter(chapterId);
907
+ return paginated([], pagination);
908
+ }
909
+ return paginated(rows.slice(pagination.offset, pagination.offset + pagination.limit + 1).map((row) => this.mapChapterVersionRow(row)), pagination);
910
+ }
674
911
  listChapterInsights(chapterId) {
675
912
  this.getChapter(chapterId);
676
913
  return this.db
@@ -689,6 +926,24 @@ export class Store {
689
926
  createdAt: requiredString(row, "created_at")
690
927
  }));
691
928
  }
929
+ listChapterInsightsPage(chapterId, pagination) {
930
+ this.getChapter(chapterId);
931
+ const page = paginationSql(pagination);
932
+ const rows = this.db.all(`SELECT * FROM chapter_insights WHERE chapter_id = ? ORDER BY chapter_version DESC, created_at DESC${page.sql}`, chapterId, ...page.params);
933
+ return paginated(rows.map((row) => ({
934
+ id: requiredString(row, "id"),
935
+ chapterId: requiredString(row, "chapter_id"),
936
+ chapterVersion: numberValue(row, "chapter_version"),
937
+ summary: requiredString(row, "summary"),
938
+ events: json(requiredString(row, "events_json"), []),
939
+ characters: json(requiredString(row, "characters_json"), []),
940
+ settings: json(requiredString(row, "settings_json"), []),
941
+ evidence: json(requiredString(row, "evidence_json"), []),
942
+ uncertainties: json(requiredString(row, "uncertainties_json"), []),
943
+ status: requiredString(row, "status"),
944
+ createdAt: requiredString(row, "created_at")
945
+ })), pagination);
946
+ }
692
947
  listCurrentChapterInsights(workId) {
693
948
  this.getWork(workId);
694
949
  return this.db.all(`SELECT insight.id, insight.chapter_id, insight.summary, chapter.title AS chapter_title,
@@ -711,8 +966,9 @@ export class Store {
711
966
  summary: requiredString(row, "summary")
712
967
  }));
713
968
  }
714
- saveChapter(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
969
+ saveChapter(chapterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
715
970
  const current = this.getChapter(chapterId);
971
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(current.versionNo));
716
972
  const nextTitle = input.title ?? String(current.title);
717
973
  const nextContent = input.content === undefined ? String(current.content) : normalizeParagraphSpacing(input.content);
718
974
  const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
@@ -725,6 +981,8 @@ export class Store {
725
981
  const timestamp = now();
726
982
  const versionNo = Number(current.versionNo) + (hasTextChange ? 1 : 0);
727
983
  this.db.transaction(() => {
984
+ const lockedCurrent = this.getChapter(chapterId);
985
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedCurrent.versionNo));
728
986
  this.db.run(`UPDATE chapters SET title = ?, content = ?, chapter_type = ?, word_count = ?, version_no = ?, analysis_status = ?,
729
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);
730
988
  if (hasTextChange)
@@ -752,15 +1010,16 @@ export class Store {
752
1010
  });
753
1011
  return this.getChapter(chapterId);
754
1012
  }
755
- restoreChapter(chapterId, versionNo) {
1013
+ restoreChapter(chapterId, versionNo, expectedVersionNo) {
756
1014
  const version = this.db.get("SELECT * FROM chapter_versions WHERE chapter_id = ? AND version_no = ?", chapterId, versionNo);
757
1015
  if (!version)
758
1016
  throw notFound("章节版本");
759
1017
  const existing = this.db.get("SELECT id FROM chapters WHERE id = ?", chapterId);
760
1018
  if (!existing) {
1019
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", this.currentChapterVersionNo(chapterId));
761
1020
  return this.recreateChapterFromVersion(chapterId, version);
762
1021
  }
763
- 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);
764
1023
  }
765
1024
  recreateChapterFromVersion(chapterId, version) {
766
1025
  const workId = requiredString(version, "work_id");
@@ -801,12 +1060,15 @@ export class Store {
801
1060
  });
802
1061
  return this.getChapter(chapterId);
803
1062
  }
804
- moveChapter(chapterId, input) {
1063
+ moveChapter(chapterId, input, expectedVersionNo) {
805
1064
  const chapter = this.getChapter(chapterId);
1065
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
806
1066
  const volume = this.getVolume(input.volumeId);
807
1067
  if (volume.workId !== chapter.workId)
808
1068
  throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
809
1069
  this.db.transaction(() => {
1070
+ const lockedChapter = this.getChapter(chapterId);
1071
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
810
1072
  this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
811
1073
  WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
812
1074
  AND json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?`, now(), String(chapter.workId), String(chapter.volumeId));
@@ -816,11 +1078,14 @@ export class Store {
816
1078
  });
817
1079
  return this.getChapter(chapterId);
818
1080
  }
819
- deleteChapter(chapterId) {
1081
+ deleteChapter(chapterId, expectedVersionNo) {
820
1082
  const chapter = this.getChapter(chapterId);
1083
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(chapter.versionNo));
821
1084
  const timestamp = now();
822
1085
  const versionNo = Number(chapter.versionNo) + 1;
823
1086
  this.db.transaction(() => {
1087
+ const lockedChapter = this.getChapter(chapterId);
1088
+ this.assertExpectedRevision("chapter", chapterId, expectedVersionNo, "章节", Number(lockedChapter.versionNo));
824
1089
  this.db.run("UPDATE chapters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
825
1090
  this.insertChapterVersionRow({
826
1091
  workId: String(chapter.workId),
@@ -926,13 +1191,17 @@ export class Store {
926
1191
  const actor = currentRequestActor();
927
1192
  const ownerUserId = optionalString(row, "owner_user_id");
928
1193
  const membership = actor
929
- ? 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)
930
1195
  : undefined;
1196
+ const membershipPermissions = json(optionalString(membership ?? {}, "permissions_json"), {});
1197
+ const membershipRole = String(membership?.role ?? "");
931
1198
  const accessRole = ownerUserId === actor?.userId
932
1199
  ? "owner"
933
1200
  : actor?.role === "admin"
934
1201
  ? "admin"
935
- : ["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;
936
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"));
937
1206
  const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
938
1207
  return {
@@ -945,6 +1214,7 @@ export class Store {
945
1214
  ? `/api/works/${encodeURIComponent(requiredString(row, "id"))}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
946
1215
  : optionalString(row, "cover_url"),
947
1216
  tags: json(requiredString(row, "tags_json"), []),
1217
+ versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", requiredString(row, "id")),
948
1218
  ownerUserId,
949
1219
  accessRole,
950
1220
  chapterCount: numberValue(count ?? {}, "chapter_count"),
@@ -963,6 +1233,7 @@ export class Store {
963
1233
  description: optionalString(row, "description") ?? "",
964
1234
  keywords: json(optionalString(row, "keywords_json"), []),
965
1235
  sortOrder: numberValue(row, "sort_order"),
1236
+ versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("volume", requiredString(row, "id")),
966
1237
  createdAt: requiredString(row, "created_at"),
967
1238
  updatedAt: requiredString(row, "updated_at")
968
1239
  };
@@ -1027,11 +1298,42 @@ export class Store {
1027
1298
  updatedAt: optionalString(row, "updated_at")
1028
1299
  }));
1029
1300
  }
1030
- upsertChapterOutline(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
1301
+ listChapterOutlinesPage(workId, pagination) {
1302
+ this.getWork(workId);
1303
+ const page = paginationSql(pagination);
1304
+ const rows = this.db.all(`SELECT c.id AS chapter_id, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
1305
+ v.title AS volume_title, v.sort_order AS volume_order,
1306
+ o.goal, o.conflict, o.turning_point, o.notes, o.status, o.created_at, o.updated_at,
1307
+ (SELECT COUNT(DISTINCT fo.foreshadow_id) FROM foreshadow_occurrences fo
1308
+ JOIN foreshadows f ON f.id = fo.foreshadow_id
1309
+ WHERE fo.chapter_id = c.id AND f.status IN ('planned', 'planted')) AS unresolved_count
1310
+ FROM chapters c
1311
+ JOIN volumes v ON v.id = c.volume_id
1312
+ LEFT JOIN chapter_outlines o ON o.chapter_id = c.id
1313
+ WHERE c.work_id = ?
1314
+ ORDER BY v.sort_order, c.sort_order, c.created_at${page.sql}`, workId, ...page.params);
1315
+ return paginated(rows.map((row) => ({
1316
+ chapterId: requiredString(row, "chapter_id"),
1317
+ chapterTitle: requiredString(row, "chapter_title"),
1318
+ volumeId: requiredString(row, "volume_id"),
1319
+ volumeTitle: requiredString(row, "volume_title"),
1320
+ goal: optionalString(row, "goal") ?? "",
1321
+ conflict: optionalString(row, "conflict") ?? "",
1322
+ turningPoint: optionalString(row, "turning_point") ?? "",
1323
+ notes: optionalString(row, "notes") ?? "",
1324
+ status: optionalString(row, "status") ?? "draft",
1325
+ unresolvedForeshadowCount: numberValue(row, "unresolved_count"),
1326
+ createdAt: optionalString(row, "created_at"),
1327
+ updatedAt: optionalString(row, "updated_at")
1328
+ })), pagination);
1329
+ }
1330
+ upsertChapterOutline(chapterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1031
1331
  const chapter = this.getChapter(chapterId);
1032
1332
  const current = this.getChapterOutline(chapterId);
1033
1333
  const timestamp = now();
1034
1334
  this.db.transaction(() => {
1335
+ if (current)
1336
+ this.assertExpectedVersion("chapter-outline", chapterId, expectedVersionNo, "章节大纲");
1035
1337
  this.db.run(`INSERT INTO chapter_outlines (chapter_id, goal, conflict, turning_point, notes, status, created_at, updated_at)
1036
1338
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1037
1339
  ON CONFLICT(chapter_id) DO UPDATE SET goal = excluded.goal, conflict = excluded.conflict,
@@ -1042,12 +1344,13 @@ export class Store {
1042
1344
  });
1043
1345
  return this.getChapterOutline(chapterId);
1044
1346
  }
1045
- deleteChapterOutline(chapterId) {
1347
+ deleteChapterOutline(chapterId, expectedVersionNo) {
1046
1348
  const chapter = this.getChapter(chapterId);
1047
1349
  const outline = this.getChapterOutline(chapterId);
1048
1350
  if (!outline)
1049
1351
  return;
1050
1352
  this.db.transaction(() => {
1353
+ this.assertExpectedVersion("chapter-outline", chapterId, expectedVersionNo, "章节大纲");
1051
1354
  this.recordEntityVersion("chapter-outline", chapterId, "delete", null, "删除章节大纲");
1052
1355
  this.db.run("DELETE FROM chapter_outlines WHERE chapter_id = ?", chapterId);
1053
1356
  this.audit(String(chapter.workId), "outline.deleted", "chapter-outline", chapterId);
@@ -1064,6 +1367,7 @@ export class Store {
1064
1367
  turningPoint: requiredString(row, "turning_point"),
1065
1368
  notes: requiredString(row, "notes"),
1066
1369
  status: requiredString(row, "status"),
1370
+ versionNo: this.currentEntityVersionNo("chapter-outline", requiredString(row, "chapter_id")),
1067
1371
  createdAt: requiredString(row, "created_at"),
1068
1372
  updatedAt: requiredString(row, "updated_at")
1069
1373
  };
@@ -1117,6 +1421,7 @@ export class Store {
1117
1421
  overdue: Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
1118
1422
  && this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
1119
1423
  occurrences,
1424
+ versionNo: this.currentEntityVersionNo("foreshadow", foreshadowId),
1120
1425
  createdAt: requiredString(row, "created_at"),
1121
1426
  updatedAt: requiredString(row, "updated_at")
1122
1427
  };
@@ -1131,12 +1436,25 @@ export class Store {
1131
1436
  return this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
1132
1437
  ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at`, workId).map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId));
1133
1438
  }
1134
- updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "") {
1439
+ listForeshadowsPage(workId, pagination, status = "all", currentChapterId) {
1440
+ this.getWork(workId);
1441
+ if (currentChapterId)
1442
+ this.assertChapterInWork(currentChapterId, workId);
1443
+ const where = status === "unresolved"
1444
+ ? "AND status IN ('planned', 'planted')"
1445
+ : status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
1446
+ const page = paginationSql(pagination);
1447
+ const rows = this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
1448
+ ORDER BY CASE importance WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, created_at${page.sql}`, workId, ...page.params);
1449
+ return paginated(rows.map((row) => this.getForeshadow(requiredString(row, "id"), currentChapterId)), pagination);
1450
+ }
1451
+ updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1135
1452
  const current = this.getForeshadow(foreshadowId);
1136
1453
  const workId = String(current.workId);
1137
1454
  if (input.plannedPayoffChapterId)
1138
1455
  this.assertChapterInWork(input.plannedPayoffChapterId, workId);
1139
1456
  this.db.transaction(() => {
1457
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1140
1458
  this.db.run(`UPDATE foreshadows SET title = ?, description = ?, status = ?, importance = ?,
1141
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);
1142
1460
  if (input.occurrences) {
@@ -1149,17 +1467,19 @@ export class Store {
1149
1467
  });
1150
1468
  return this.getForeshadow(foreshadowId);
1151
1469
  }
1152
- deleteForeshadow(foreshadowId) {
1470
+ deleteForeshadow(foreshadowId, expectedVersionNo) {
1153
1471
  const current = this.getForeshadow(foreshadowId);
1154
1472
  this.db.transaction(() => {
1473
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1155
1474
  this.recordEntityVersion("foreshadow", foreshadowId, "delete", null, "删除伏笔");
1156
1475
  this.db.run("DELETE FROM foreshadows WHERE id = ?", foreshadowId);
1157
1476
  this.audit(String(current.workId), "foreshadow.deleted", "foreshadow", foreshadowId);
1158
1477
  });
1159
1478
  }
1160
- createForeshadowOccurrence(foreshadowId, input) {
1479
+ createForeshadowOccurrence(foreshadowId, input, expectedVersionNo) {
1161
1480
  const foreshadow = this.getForeshadow(foreshadowId);
1162
1481
  const occurrenceId = this.db.transaction(() => {
1482
+ this.assertExpectedVersion("foreshadow", foreshadowId, expectedVersionNo, "伏笔");
1163
1483
  const createdId = this.insertForeshadowOccurrence(foreshadowId, String(foreshadow.workId), input);
1164
1484
  this.recordEntityVersion("foreshadow", foreshadowId, "manual", createdId, "添加伏笔章节记录");
1165
1485
  this.audit(String(foreshadow.workId), "foreshadow.occurrence.created", "foreshadow-occurrence", createdId);
@@ -1167,20 +1487,22 @@ export class Store {
1167
1487
  });
1168
1488
  return this.getForeshadowOccurrence(occurrenceId);
1169
1489
  }
1170
- updateForeshadowOccurrence(occurrenceId, input) {
1490
+ updateForeshadowOccurrence(occurrenceId, input, expectedVersionNo) {
1171
1491
  const current = this.getForeshadowOccurrence(occurrenceId);
1172
1492
  const foreshadow = this.getForeshadow(String(current.foreshadowId));
1173
1493
  const chapterId = input.chapterId ?? String(current.chapterId);
1174
1494
  this.assertChapterInWork(chapterId, String(foreshadow.workId));
1175
1495
  this.db.transaction(() => {
1496
+ this.assertExpectedVersion("foreshadow", String(current.foreshadowId), expectedVersionNo, "伏笔");
1176
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);
1177
1498
  this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "更新伏笔章节记录");
1178
1499
  });
1179
1500
  return this.getForeshadowOccurrence(occurrenceId);
1180
1501
  }
1181
- deleteForeshadowOccurrence(occurrenceId) {
1502
+ deleteForeshadowOccurrence(occurrenceId, expectedVersionNo) {
1182
1503
  const current = this.getForeshadowOccurrence(occurrenceId);
1183
1504
  this.db.transaction(() => {
1505
+ this.assertExpectedVersion("foreshadow", String(current.foreshadowId), expectedVersionNo, "伏笔");
1184
1506
  this.db.run("DELETE FROM foreshadow_occurrences WHERE id = ?", occurrenceId);
1185
1507
  this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "删除伏笔章节记录");
1186
1508
  });
@@ -1248,15 +1570,22 @@ export class Store {
1248
1570
  this.getWork(workId);
1249
1571
  return this.db.all("SELECT * FROM settings WHERE work_id = ? ORDER BY locked DESC, category, title", workId).map((row) => this.mapSetting(row));
1250
1572
  }
1573
+ listSettingsPage(workId, pagination) {
1574
+ this.getWork(workId);
1575
+ const page = paginationSql(pagination);
1576
+ const rows = this.db.all(`SELECT * FROM settings WHERE work_id = ? ORDER BY locked DESC, category, title${page.sql}`, workId, ...page.params);
1577
+ return paginated(rows.map((row) => this.mapSetting(row)), pagination);
1578
+ }
1251
1579
  getSetting(settingId) {
1252
1580
  const row = this.db.get("SELECT * FROM settings WHERE id = ?", settingId);
1253
1581
  if (!row)
1254
1582
  throw notFound("设定");
1255
1583
  return this.mapSetting(row);
1256
1584
  }
1257
- updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "") {
1585
+ updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1258
1586
  const current = this.getSetting(settingId);
1259
1587
  this.db.transaction(() => {
1588
+ this.assertExpectedVersion("setting", settingId, expectedVersionNo, "设定");
1260
1589
  this.db.run(`UPDATE settings SET title = ?, category = ?, content = ?, tags_json = ?, status = ?, locked = ?,
1261
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);
1262
1591
  this.recordEntityVersion("setting", settingId, source, sourceRef, changeNote || "更新世界观设定");
@@ -1264,9 +1593,10 @@ export class Store {
1264
1593
  });
1265
1594
  return this.getSetting(settingId);
1266
1595
  }
1267
- deleteSetting(settingId) {
1596
+ deleteSetting(settingId, expectedVersionNo) {
1268
1597
  const current = this.getSetting(settingId);
1269
1598
  this.db.transaction(() => {
1599
+ this.assertExpectedVersion("setting", settingId, expectedVersionNo, "设定");
1270
1600
  this.recordEntityVersion("setting", settingId, "delete", null, "删除世界观设定");
1271
1601
  this.db.run("DELETE FROM settings WHERE id = ?", settingId);
1272
1602
  this.audit(String(current.workId), "setting.deleted", "setting", settingId);
@@ -1285,6 +1615,7 @@ export class Store {
1285
1615
  evidence: json(requiredString(row, "evidence_json"), []),
1286
1616
  scope: json(requiredString(row, "scope_json"), {}),
1287
1617
  authorNote: requiredString(row, "author_note"),
1618
+ versionNo: this.currentEntityVersionNo("setting", requiredString(row, "id")),
1288
1619
  createdAt: requiredString(row, "created_at"),
1289
1620
  updatedAt: requiredString(row, "updated_at")
1290
1621
  };
@@ -1319,13 +1650,19 @@ export class Store {
1319
1650
  this.getWork(workId);
1320
1651
  return this.db.all("SELECT * FROM races WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapRace(row));
1321
1652
  }
1653
+ listRacesPage(workId, pagination) {
1654
+ this.getWork(workId);
1655
+ const page = paginationSql(pagination);
1656
+ const rows = this.db.all(`SELECT * FROM races WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
1657
+ return paginated(rows.map((row) => this.mapRace(row)), pagination);
1658
+ }
1322
1659
  getRace(raceId) {
1323
1660
  const row = this.db.get("SELECT * FROM races WHERE id = ?", raceId);
1324
1661
  if (!row)
1325
1662
  throw notFound("种族");
1326
1663
  return this.mapRace(row);
1327
1664
  }
1328
- updateRace(raceId, input, source = "manual", sourceRef = null, changeNote = "") {
1665
+ updateRace(raceId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1329
1666
  const current = this.getRace(raceId);
1330
1667
  const workId = String(current.workId);
1331
1668
  const name = input.name === undefined
@@ -1348,6 +1685,7 @@ export class Store {
1348
1685
  : [];
1349
1686
  const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
1350
1687
  this.db.transaction(() => {
1688
+ this.assertExpectedVersion("race", raceId, expectedVersionNo, "种族");
1351
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);
1352
1690
  if (nameChanged)
1353
1691
  this.db.run("UPDATE characters SET species = ?, updated_at = ? WHERE race_id = ?", name, now(), raceId);
@@ -1359,7 +1697,7 @@ export class Store {
1359
1697
  });
1360
1698
  return this.getRace(raceId);
1361
1699
  }
1362
- deleteRace(raceId) {
1700
+ deleteRace(raceId, expectedVersionNo) {
1363
1701
  const current = this.getRace(raceId);
1364
1702
  const child = this.db.get("SELECT id FROM races WHERE parent_race_id = ? LIMIT 1", raceId);
1365
1703
  if (child) {
@@ -1367,6 +1705,7 @@ export class Store {
1367
1705
  }
1368
1706
  const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
1369
1707
  this.db.transaction(() => {
1708
+ this.assertExpectedVersion("race", raceId, expectedVersionNo, "种族");
1370
1709
  this.recordEntityVersion("race", raceId, "delete", null, "删除种族档案");
1371
1710
  this.db.run("UPDATE characters SET race_id = NULL, species = '', updated_at = ? WHERE race_id = ?", now(), raceId);
1372
1711
  this.db.run("DELETE FROM races WHERE id = ?", raceId);
@@ -1374,6 +1713,43 @@ export class Store {
1374
1713
  this.audit(String(current.workId), "race.deleted", "race", raceId);
1375
1714
  });
1376
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
+ }
1377
1753
  resolveRaceReference(workId, value) {
1378
1754
  const normalizedName = normalizeCharacterName(value);
1379
1755
  if (!normalizedName)
@@ -1404,6 +1780,7 @@ export class Store {
1404
1780
  }))),
1405
1781
  memberIds: members.map((member) => member.characterId),
1406
1782
  members,
1783
+ versionNo: this.currentEntityVersionNo("race", requiredString(row, "id")),
1407
1784
  createdAt: requiredString(row, "created_at"),
1408
1785
  updatedAt: requiredString(row, "updated_at")
1409
1786
  };
@@ -1493,13 +1870,19 @@ export class Store {
1493
1870
  this.getWork(workId);
1494
1871
  return this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapOrganization(row));
1495
1872
  }
1873
+ listOrganizationsPage(workId, pagination) {
1874
+ this.getWork(workId);
1875
+ const page = paginationSql(pagination);
1876
+ const rows = this.db.all(`SELECT * FROM organizations WHERE work_id = ? ORDER BY name${page.sql}`, workId, ...page.params);
1877
+ return paginated(rows.map((row) => this.mapOrganization(row)), pagination);
1878
+ }
1496
1879
  getOrganization(organizationId) {
1497
1880
  const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
1498
1881
  if (!row)
1499
1882
  throw notFound("组织");
1500
1883
  return this.mapOrganization(row);
1501
1884
  }
1502
- updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "") {
1885
+ updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1503
1886
  const current = this.getOrganization(organizationId);
1504
1887
  const workId = String(current.workId);
1505
1888
  const name = input.name === undefined
@@ -1515,6 +1898,7 @@ export class Store {
1515
1898
  const touchedMemberIds = memberIds ? [...new Set([...current.memberIds, ...memberIds])] : [];
1516
1899
  const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
1517
1900
  this.db.transaction(() => {
1901
+ this.assertExpectedVersion("organization", organizationId, expectedVersionNo, "组织");
1518
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);
1519
1903
  if (memberIds) {
1520
1904
  this.replaceOrganizationMembers(organizationId, memberIds);
@@ -1525,16 +1909,49 @@ export class Store {
1525
1909
  });
1526
1910
  return this.getOrganization(organizationId);
1527
1911
  }
1528
- deleteOrganization(organizationId) {
1912
+ deleteOrganization(organizationId, expectedVersionNo) {
1529
1913
  const current = this.getOrganization(organizationId);
1530
1914
  const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
1531
1915
  this.db.transaction(() => {
1916
+ this.assertExpectedVersion("organization", organizationId, expectedVersionNo, "组织");
1532
1917
  this.recordEntityVersion("organization", organizationId, "delete", null, "删除组织档案");
1533
1918
  this.db.run("DELETE FROM organizations WHERE id = ?", organizationId);
1534
1919
  this.recordMembershipVersions(memberSnapshots, "organization", organizationId, `组织“${String(current.name)}”已删除`);
1535
1920
  this.audit(String(current.workId), "organization.deleted", "organization", organizationId);
1536
1921
  });
1537
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
+ }
1538
1955
  mapOrganization(row) {
1539
1956
  const members = this.db.all(`SELECT c.id, c.name, m.role, m.note
1540
1957
  FROM character_organization_memberships m
@@ -1553,6 +1970,7 @@ export class Store {
1553
1970
  settings: json(requiredString(row, "settings_json"), []),
1554
1971
  memberIds: members.map((member) => member.characterId),
1555
1972
  members,
1973
+ versionNo: this.currentEntityVersionNo("organization", requiredString(row, "id")),
1556
1974
  createdAt: requiredString(row, "created_at"),
1557
1975
  updatedAt: requiredString(row, "updated_at")
1558
1976
  };
@@ -1666,6 +2084,12 @@ export class Store {
1666
2084
  return this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY name`, workId)
1667
2085
  .map((row) => this.mapCharacter(row, includeProfileSections));
1668
2086
  }
2087
+ listCharactersPage(workId, pagination, includeProfileSections = false, includeMerged = false) {
2088
+ this.getWork(workId);
2089
+ const page = paginationSql(pagination);
2090
+ const rows = this.db.all(`SELECT * FROM characters WHERE work_id = ?${includeMerged ? "" : " AND merged_into_character_id IS NULL"} ORDER BY name${page.sql}`, workId, ...page.params);
2091
+ return paginated(rows.map((row) => this.mapCharacter(row, includeProfileSections)), pagination);
2092
+ }
1669
2093
  mapCharacterProfileSection(row) {
1670
2094
  return {
1671
2095
  id: requiredString(row, "id"),
@@ -1687,6 +2111,12 @@ export class Store {
1687
2111
  this.getCharacter(characterId);
1688
2112
  return this.db.all("SELECT * FROM character_profile_sections WHERE character_id = ? ORDER BY sort_order, created_at", characterId).map((row) => this.mapCharacterProfileSection(row));
1689
2113
  }
2114
+ listCharacterProfileSectionsPage(characterId, pagination) {
2115
+ this.getCharacter(characterId);
2116
+ const page = paginationSql(pagination);
2117
+ const rows = this.db.all(`SELECT * FROM character_profile_sections WHERE character_id = ? ORDER BY sort_order, created_at${page.sql}`, characterId, ...page.params);
2118
+ return paginated(rows.map((row) => this.mapCharacterProfileSection(row)), pagination);
2119
+ }
1690
2120
  listCharacterProfileSectionCatalog(characterId) {
1691
2121
  this.getCharacter(characterId);
1692
2122
  return this.db.all(`SELECT id, character_id, section_type, title, summary, sort_order, version_no
@@ -1768,10 +2198,13 @@ export class Store {
1768
2198
  });
1769
2199
  return this.getCharacterProfileSection(sectionId);
1770
2200
  }
1771
- updateCharacterProfileSection(sectionId, input, source = "manual", sourceRef = null, changeNote = "") {
2201
+ updateCharacterProfileSection(sectionId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1772
2202
  const current = this.getCharacterProfileSection(sectionId);
2203
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(current.versionNo));
1773
2204
  const timestamp = now();
1774
2205
  this.db.transaction(() => {
2206
+ const lockedCurrent = this.getCharacterProfileSection(sectionId);
2207
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(lockedCurrent.versionNo));
1775
2208
  this.db.run(`UPDATE character_profile_sections SET section_type = ?, title = ?, content_markdown = ?, summary = ?, sort_order = ?,
1776
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);
1777
2210
  const section = this.getCharacterProfileSection(sectionId);
@@ -1782,9 +2215,12 @@ export class Store {
1782
2215
  });
1783
2216
  return this.getCharacterProfileSection(sectionId);
1784
2217
  }
1785
- deleteCharacterProfileSection(sectionId) {
2218
+ deleteCharacterProfileSection(sectionId, expectedVersionNo) {
1786
2219
  const current = this.getCharacterProfileSection(sectionId);
2220
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(current.versionNo));
1787
2221
  this.db.transaction(() => {
2222
+ const lockedCurrent = this.getCharacterProfileSection(sectionId);
2223
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", Number(lockedCurrent.versionNo));
1788
2224
  this.db.run("UPDATE character_profile_sections SET version_no = version_no + 1 WHERE id = ?", sectionId);
1789
2225
  const deleting = this.getCharacterProfileSection(sectionId);
1790
2226
  this.recordCharacterProfileSectionVersion(deleting, "delete", null, "删除人物 Markdown 章节");
@@ -1813,15 +2249,37 @@ export class Store {
1813
2249
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
1814
2250
  }));
1815
2251
  }
1816
- restoreCharacterProfileSection(sectionId, versionNo) {
2252
+ listCharacterProfileSectionVersionsPage(sectionId, pagination) {
2253
+ const page = paginationSql(pagination);
2254
+ const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
2255
+ FROM character_profile_section_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
2256
+ WHERE version.section_id = ? ORDER BY version.version_no DESC${page.sql}`, sectionId, ...page.params);
2257
+ if (!rows.length && pagination.page === 1)
2258
+ this.getCharacterProfileSection(sectionId);
2259
+ return paginated(rows.map((row) => ({
2260
+ id: requiredString(row, "id"),
2261
+ workId: requiredString(row, "work_id"),
2262
+ characterId: requiredString(row, "character_id"),
2263
+ sectionId: requiredString(row, "section_id"),
2264
+ versionNo: numberValue(row, "version_no"),
2265
+ snapshot: json(requiredString(row, "snapshot_json"), {}),
2266
+ source: requiredString(row, "source"),
2267
+ sourceRef: optionalString(row, "source_ref"),
2268
+ changeNote: requiredString(row, "change_note"),
2269
+ createdAt: requiredString(row, "created_at"),
2270
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
2271
+ })), pagination);
2272
+ }
2273
+ restoreCharacterProfileSection(sectionId, versionNo, expectedVersionNo) {
1817
2274
  const version = this.db.get("SELECT * FROM character_profile_section_versions WHERE section_id = ? AND version_no = ?", sectionId, versionNo);
1818
2275
  if (!version)
1819
2276
  throw notFound("人物档案章节版本");
1820
2277
  const snapshot = json(requiredString(version, "snapshot_json"), {});
1821
2278
  const existing = this.db.get("SELECT id FROM character_profile_sections WHERE id = ?", sectionId);
1822
2279
  if (existing) {
1823
- return this.updateCharacterProfileSection(sectionId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
2280
+ return this.updateCharacterProfileSection(sectionId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
1824
2281
  }
2282
+ this.assertExpectedRevision("character-section", sectionId, expectedVersionNo, "人物档案章节", this.currentCharacterSectionVersionNo(sectionId));
1825
2283
  const characterId = requiredString(version, "character_id");
1826
2284
  const character = this.getCharacter(characterId);
1827
2285
  const timestamp = now();
@@ -1900,6 +2358,12 @@ export class Store {
1900
2358
  this.getWork(workId);
1901
2359
  return this.db.all("SELECT * FROM attachments WHERE work_id = ? ORDER BY created_at DESC", workId).map((row) => this.mapAttachment(row));
1902
2360
  }
2361
+ listAttachmentsPage(workId, pagination) {
2362
+ this.getWork(workId);
2363
+ const page = paginationSql(pagination);
2364
+ const rows = this.db.all(`SELECT * FROM attachments WHERE work_id = ? ORDER BY created_at DESC${page.sql}`, workId, ...page.params);
2365
+ return paginated(rows.map((row) => this.mapAttachment(row)), pagination);
2366
+ }
1903
2367
  getAttachment(attachmentId) {
1904
2368
  const row = this.db.get("SELECT * FROM attachments WHERE id = ?", attachmentId);
1905
2369
  if (!row)
@@ -1925,8 +2389,9 @@ export class Store {
1925
2389
  throw notFound("角色");
1926
2390
  return this.mapCharacter(row);
1927
2391
  }
1928
- updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "") {
2392
+ updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
1929
2393
  const current = this.getCharacter(characterId);
2394
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
1930
2395
  if (current.mergedIntoCharacterId)
1931
2396
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能直接编辑");
1932
2397
  const before = this.characterSnapshot(current);
@@ -1947,6 +2412,8 @@ export class Store {
1947
2412
  if (organizationIds)
1948
2413
  this.assertOrganizationsInWork(workId, organizationIds);
1949
2414
  this.db.transaction(() => {
2415
+ const lockedCurrent = this.getCharacter(characterId);
2416
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(lockedCurrent.versionNo));
1950
2417
  this.db.run(`UPDATE characters SET name = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
1951
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);
1952
2419
  this.db.run("DELETE FROM character_names WHERE character_id = ?", characterId);
@@ -1985,7 +2452,27 @@ export class Store {
1985
2452
  actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
1986
2453
  }));
1987
2454
  }
1988
- restoreCharacter(characterId, versionNo) {
2455
+ listCharacterVersionsPage(characterId, pagination) {
2456
+ const page = paginationSql(pagination);
2457
+ const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
2458
+ FROM character_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
2459
+ WHERE version.character_id = ? ORDER BY version.version_no DESC${page.sql}`, characterId, ...page.params);
2460
+ if (!rows.length && pagination.page === 1)
2461
+ this.getCharacter(characterId);
2462
+ return paginated(rows.map((row) => ({
2463
+ id: requiredString(row, "id"),
2464
+ workId: optionalString(row, "work_id"),
2465
+ characterId: requiredString(row, "character_id"),
2466
+ versionNo: numberValue(row, "version_no"),
2467
+ snapshot: json(requiredString(row, "snapshot_json"), {}),
2468
+ source: requiredString(row, "source"),
2469
+ sourceRef: optionalString(row, "source_ref"),
2470
+ changeNote: requiredString(row, "change_note"),
2471
+ createdAt: requiredString(row, "created_at"),
2472
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
2473
+ })), pagination);
2474
+ }
2475
+ restoreCharacter(characterId, versionNo, expectedVersionNo) {
1989
2476
  const version = this.db.get("SELECT * FROM character_versions WHERE character_id = ? AND version_no = ?", characterId, versionNo);
1990
2477
  if (!version)
1991
2478
  throw notFound("人物版本");
@@ -1994,9 +2481,10 @@ export class Store {
1994
2481
  throw new AppError(500, "CHARACTER_VERSION_INVALID", "人物版本快照无效");
1995
2482
  const existing = this.db.get("SELECT id FROM characters WHERE id = ?", characterId);
1996
2483
  if (!existing) {
2484
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", this.currentCharacterVersionNo(characterId));
1997
2485
  return this.recreateCharacterFromVersion(characterId, version, snapshot, versionNo);
1998
2486
  }
1999
- return this.updateCharacter(characterId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
2487
+ return this.updateCharacter(characterId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, expectedVersionNo);
2000
2488
  }
2001
2489
  recreateCharacterFromVersion(characterId, version, snapshot, versionNo) {
2002
2490
  const workId = requiredString(version, "work_id");
@@ -2023,11 +2511,24 @@ export class Store {
2023
2511
  });
2024
2512
  return this.getCharacter(characterId);
2025
2513
  }
2026
- deleteCharacter(characterId) {
2514
+ deleteCharacter(characterId, expectedVersionNo) {
2027
2515
  const current = this.getCharacter(characterId);
2516
+ this.assertExpectedRevision("character", characterId, expectedVersionNo, "人物", Number(current.versionNo));
2028
2517
  const timestamp = now();
2029
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);
2030
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));
2031
2532
  const sectionIds = this.db.all("SELECT id FROM character_profile_sections WHERE character_id = ?", characterId)
2032
2533
  .map((row) => requiredString(row, "id"));
2033
2534
  for (const sectionId of sectionIds) {
@@ -2036,7 +2537,7 @@ export class Store {
2036
2537
  this.db.run("UPDATE characters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, characterId);
2037
2538
  this.insertCharacterVersion(characterId, versionNo, "delete", null, "删除人物", timestamp);
2038
2539
  this.db.run("DELETE FROM characters WHERE id = ?", characterId);
2039
- this.audit(String(current.workId), "character.deleted", "character", characterId, { versionNo });
2540
+ this.audit(workId, "character.deleted", "character", characterId, { versionNo });
2040
2541
  });
2041
2542
  }
2042
2543
  mapCharacter(row, includeProfileSections = true) {
@@ -2111,29 +2612,31 @@ export class Store {
2111
2612
  if (input.targetCharacterId === input.sourceCharacterId) {
2112
2613
  throw new AppError(400, "CHARACTER_MERGE_SELF", "不能把角色合并到自身");
2113
2614
  }
2114
- const review = this.getReviewItem(input.reviewId);
2115
- if (review.itemType !== "character-duplicate" || review.status !== "pending") {
2116
- throw new AppError(409, "CHARACTER_REVIEW_DECIDED", "该角色查重项已经处理");
2117
- }
2118
- const reviewCharacterIds = review.entityRefs.flatMap((reference) => {
2119
- if (!reference || typeof reference !== "object" || Array.isArray(reference))
2120
- return [];
2121
- const characterId = reference.id;
2122
- return typeof characterId === "string" ? [characterId] : [];
2123
- });
2124
- if (!reviewCharacterIds.includes(input.targetCharacterId) || !reviewCharacterIds.includes(input.sourceCharacterId)) {
2125
- 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
+ }
2126
2629
  }
2127
2630
  const target = this.getCharacter(input.targetCharacterId);
2128
2631
  const source = this.getCharacter(input.sourceCharacterId);
2129
- if (target.workId !== source.workId || target.workId !== review.workId) {
2632
+ if (target.workId !== source.workId || (review && target.workId !== review.workId)) {
2130
2633
  throw new AppError(400, "CHARACTER_WORK_MISMATCH", "待合并角色不属于同一作品");
2131
2634
  }
2132
2635
  if (target.mergedIntoCharacterId || source.mergedIntoCharacterId) {
2133
2636
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "待合并角色中已有角色被合并");
2134
2637
  }
2135
2638
  if (Number(target.versionNo) !== input.expectedTargetVersionNo || Number(source.versionNo) !== input.expectedSourceVersionNo) {
2136
- throw new AppError(409, "CHARACTER_VERSION_CHANGED", "角色在审核后已发生变化,请重新运行查重");
2639
+ throw new AppError(409, "CHARACTER_VERSION_CHANGED", "角色已发生变化,请刷新后重试");
2137
2640
  }
2138
2641
  const workId = String(target.workId);
2139
2642
  const targetId = String(target.id);
@@ -2145,6 +2648,10 @@ export class Store {
2145
2648
  const sourceMemberships = this.db.all("SELECT * FROM character_organization_memberships WHERE character_id = ? ORDER BY organization_id", sourceId);
2146
2649
  const referenceSnapshot = { relationships: sourceRelationships, timelineEvents, memberships: sourceMemberships };
2147
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));
2148
2655
  this.db.run("DELETE FROM character_names WHERE character_id = ?", sourceId);
2149
2656
  const aliases = [...target.aliases, String(source.name), ...source.aliases];
2150
2657
  const uniqueAliases = [...new Map(aliases
@@ -2161,7 +2668,7 @@ export class Store {
2161
2668
  currentState: { ...source.currentState, ...target.currentState },
2162
2669
  lockedFields: [...new Set([...target.lockedFields, ...source.lockedFields])],
2163
2670
  firstChapterId: target.firstChapterId ?? source.firstChapterId
2164
- }, "merge", mergeId, `合并角色“${String(source.name)}”`);
2671
+ }, "merge", mergeId, `合并角色“${String(source.name)}”`, input.expectedTargetVersionNo);
2165
2672
  for (const event of timelineEvents) {
2166
2673
  const participantIds = [...new Set(event.participantIds.map((characterId) => characterId === sourceId ? targetId : characterId))];
2167
2674
  this.updateTimelineEvent(String(event.id), { participantIds }, "merge", mergeId, `合并角色“${String(source.name)}”`);
@@ -2203,13 +2710,18 @@ export class Store {
2203
2710
  }
2204
2711
  }
2205
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);
2206
2716
  const sourceVersionNo = Number(source.versionNo) + 1;
2207
2717
  this.db.run("UPDATE characters SET merged_into_character_id = ?, merged_at = ?, version_no = ?, updated_at = ? WHERE id = ?", targetId, timestamp, sourceVersionNo, timestamp, sourceId);
2208
2718
  this.insertCharacterVersion(sourceId, sourceVersionNo, "merge", mergeId, `合并至角色“${String(target.name)}”`, timestamp);
2209
2719
  this.db.run(`INSERT INTO character_merges (id, work_id, source_character_id, target_character_id, review_id,
2210
2720
  source_snapshot_json, target_snapshot_json, reference_snapshot_json, created_at, created_by_user_id)
2211
2721
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, mergeId, workId, sourceId, targetId, input.reviewId, JSON.stringify(source), JSON.stringify(target), JSON.stringify(referenceSnapshot), timestamp, currentRequestActor()?.userId ?? null);
2212
- 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
+ }
2213
2725
  this.audit(workId, "character.merged", "character", targetId, {
2214
2726
  mergeId,
2215
2727
  sourceCharacterId: sourceId,
@@ -2220,7 +2732,7 @@ export class Store {
2220
2732
  mergeId,
2221
2733
  target: this.getCharacter(targetId),
2222
2734
  source: this.getCharacter(sourceId),
2223
- review: this.getReviewItem(input.reviewId)
2735
+ review: input.reviewId ? this.getReviewItem(input.reviewId) : null
2224
2736
  };
2225
2737
  }
2226
2738
  resolveCharacterDuplicateReview(reviewId) {
@@ -2291,24 +2803,32 @@ export class Store {
2291
2803
  this.getWork(workId);
2292
2804
  return this.db.all("SELECT * FROM timeline_tracks WHERE work_id = ? ORDER BY sort_order, created_at", workId).map((row) => this.mapTimelineTrack(row));
2293
2805
  }
2806
+ listTimelineTracksPage(workId, pagination) {
2807
+ this.getWork(workId);
2808
+ const page = paginationSql(pagination);
2809
+ const rows = this.db.all(`SELECT * FROM timeline_tracks WHERE work_id = ? ORDER BY sort_order, created_at${page.sql}`, workId, ...page.params);
2810
+ return paginated(rows.map((row) => this.mapTimelineTrack(row)), pagination);
2811
+ }
2294
2812
  getTimelineTrack(trackId) {
2295
2813
  const row = this.db.get("SELECT * FROM timeline_tracks WHERE id = ?", trackId);
2296
2814
  if (!row)
2297
2815
  throw notFound("独立时间轴");
2298
2816
  return this.mapTimelineTrack(row);
2299
2817
  }
2300
- updateTimelineTrack(trackId, input, source = "manual", sourceRef = null, changeNote = "") {
2818
+ updateTimelineTrack(trackId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2301
2819
  const current = this.getTimelineTrack(trackId);
2302
2820
  this.db.transaction(() => {
2821
+ this.assertExpectedVersion("timeline-track", trackId, expectedVersionNo, "时间轴");
2303
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);
2304
2823
  this.recordEntityVersion("timeline-track", trackId, source, sourceRef, changeNote || "更新时间轴");
2305
2824
  this.audit(String(current.workId), "timeline-track.updated", "timeline-track", trackId, { fields: Object.keys(input), source, sourceRef });
2306
2825
  });
2307
2826
  return this.getTimelineTrack(trackId);
2308
2827
  }
2309
- deleteTimelineTrack(trackId) {
2828
+ deleteTimelineTrack(trackId, expectedVersionNo) {
2310
2829
  const current = this.getTimelineTrack(trackId);
2311
2830
  this.db.transaction(() => {
2831
+ this.assertExpectedVersion("timeline-track", trackId, expectedVersionNo, "时间轴");
2312
2832
  this.recordEntityVersion("timeline-track", trackId, "delete", null, "删除时间轴");
2313
2833
  this.db.run("DELETE FROM timeline_tracks WHERE id = ?", trackId);
2314
2834
  this.audit(String(current.workId), "timeline-track.deleted", "timeline-track", trackId);
@@ -2345,13 +2865,19 @@ export class Store {
2345
2865
  .all("SELECT * FROM timeline_events WHERE work_id = ? ORDER BY time_sort IS NULL, time_sort, created_at", workId)
2346
2866
  .map((row) => this.mapTimelineEvent(row));
2347
2867
  }
2868
+ listTimelineEventsPage(workId, pagination) {
2869
+ this.getWork(workId);
2870
+ const page = paginationSql(pagination);
2871
+ const rows = this.db.all(`SELECT * FROM timeline_events WHERE work_id = ? ORDER BY time_sort IS NULL, time_sort, created_at${page.sql}`, workId, ...page.params);
2872
+ return paginated(rows.map((row) => this.mapTimelineEvent(row)), pagination);
2873
+ }
2348
2874
  getTimelineEvent(eventId) {
2349
2875
  const row = this.db.get("SELECT * FROM timeline_events WHERE id = ?", eventId);
2350
2876
  if (!row)
2351
2877
  throw notFound("时间线事件");
2352
2878
  return this.mapTimelineEvent(row);
2353
2879
  }
2354
- updateTimelineEvent(eventId, input, source = "manual", sourceRef = null, changeNote = "") {
2880
+ updateTimelineEvent(eventId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2355
2881
  const current = this.getTimelineEvent(eventId);
2356
2882
  if (input.trackId) {
2357
2883
  const track = this.getTimelineTrack(input.trackId);
@@ -2359,6 +2885,7 @@ export class Store {
2359
2885
  throw new AppError(400, "TIMELINE_TRACK_WORK_MISMATCH", "独立时间轴不属于当前作品");
2360
2886
  }
2361
2887
  this.db.transaction(() => {
2888
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件");
2362
2889
  this.db.run(`UPDATE timeline_events SET track_id = ?, name = ?, description = ?, event_type = ?, time_label = ?, time_sort = ?,
2363
2890
  chapter_ids_json = ?, participant_ids_json = ?, location = ?, causes_json = ?, impact_scope = ?, evidence_json = ?,
2364
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);
@@ -2367,15 +2894,16 @@ export class Store {
2367
2894
  });
2368
2895
  return this.getTimelineEvent(eventId);
2369
2896
  }
2370
- deleteTimelineEvent(eventId) {
2897
+ deleteTimelineEvent(eventId, expectedVersionNo) {
2371
2898
  const current = this.getTimelineEvent(eventId);
2372
2899
  this.db.transaction(() => {
2900
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件");
2373
2901
  this.recordEntityVersion("timeline-event", eventId, "delete", null, "删除时间事件");
2374
2902
  this.db.run("DELETE FROM timeline_events WHERE id = ?", eventId);
2375
2903
  this.audit(String(current.workId), "timeline.deleted", "timeline-event", eventId);
2376
2904
  });
2377
2905
  }
2378
- mergeTimelineEvents(workId, eventIds, input) {
2906
+ mergeTimelineEvents(workId, eventIds, input, expectedVersionNos) {
2379
2907
  this.getWork(workId);
2380
2908
  const uniqueIds = [...new Set(eventIds)];
2381
2909
  if (uniqueIds.length < 2)
@@ -2389,6 +2917,9 @@ export class Store {
2389
2917
  };
2390
2918
  const knownSorts = events.map((event) => event.timeSort).filter((value) => typeof value === "number");
2391
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
+ }
2392
2923
  const merged = this.createTimelineEvent(workId, {
2393
2924
  name: input.name,
2394
2925
  trackId: events.every((event) => event.trackId === events[0]?.trackId) ? events[0]?.trackId : null,
@@ -2412,11 +2943,14 @@ export class Store {
2412
2943
  return merged;
2413
2944
  });
2414
2945
  }
2415
- splitTimelineEvent(eventId, parts) {
2946
+ splitTimelineEvent(eventId, parts, expectedVersionNo) {
2416
2947
  const source = this.getTimelineEvent(eventId);
2948
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件", Number(source.versionNo));
2417
2949
  if (parts.length < 2)
2418
2950
  throw new AppError(400, "EVENT_PARTS_REQUIRED", "拆分时间事件至少需要两项");
2419
2951
  return this.db.transaction(() => {
2952
+ const lockedSource = this.getTimelineEvent(eventId);
2953
+ this.assertExpectedVersion("timeline-event", eventId, expectedVersionNo, "时间事件", Number(lockedSource.versionNo));
2420
2954
  const created = parts.map((part, index) => this.createTimelineEvent(String(source.workId), {
2421
2955
  name: part.name,
2422
2956
  trackId: source.trackId,
@@ -2457,6 +2991,7 @@ export class Store {
2457
2991
  impactScope: requiredString(row, "impact_scope"),
2458
2992
  evidence: json(requiredString(row, "evidence_json"), []),
2459
2993
  status: requiredString(row, "status"),
2994
+ versionNo: this.currentEntityVersionNo("timeline-event", requiredString(row, "id")),
2460
2995
  createdAt: requiredString(row, "created_at"),
2461
2996
  updatedAt: requiredString(row, "updated_at")
2462
2997
  };
@@ -2468,6 +3003,7 @@ export class Store {
2468
3003
  name: requiredString(row, "name"),
2469
3004
  description: requiredString(row, "description"),
2470
3005
  sortOrder: numberValue(row, "sort_order"),
3006
+ versionNo: this.currentEntityVersionNo("timeline-track", requiredString(row, "id")),
2471
3007
  createdAt: requiredString(row, "created_at"),
2472
3008
  updatedAt: requiredString(row, "updated_at")
2473
3009
  };
@@ -2507,13 +3043,19 @@ export class Store {
2507
3043
  .all("SELECT * FROM relationships WHERE work_id = ? AND confidence >= ? ORDER BY confidence DESC, created_at", workId, minimumConfidence)
2508
3044
  .map((row) => this.mapRelationship(row));
2509
3045
  }
3046
+ listRelationshipsPage(workId, pagination, minimumConfidence = 0) {
3047
+ this.getWork(workId);
3048
+ const page = paginationSql(pagination);
3049
+ const rows = this.db.all(`SELECT * FROM relationships WHERE work_id = ? AND confidence >= ? ORDER BY confidence DESC, created_at${page.sql}`, workId, minimumConfidence, ...page.params);
3050
+ return paginated(rows.map((row) => this.mapRelationship(row)), pagination);
3051
+ }
2510
3052
  getRelationship(relationshipId) {
2511
3053
  const row = this.db.get("SELECT * FROM relationships WHERE id = ?", relationshipId);
2512
3054
  if (!row)
2513
3055
  throw notFound("人物关系");
2514
3056
  return this.mapRelationship(row);
2515
3057
  }
2516
- updateRelationship(relationshipId, input, source = "manual", sourceRef = null, changeNote = "") {
3058
+ updateRelationship(relationshipId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
2517
3059
  const current = this.getRelationship(relationshipId);
2518
3060
  let fromCharacterId = input.fromCharacterId ?? String(current.fromCharacterId);
2519
3061
  let toCharacterId = input.toCharacterId ?? String(current.toCharacterId);
@@ -2530,6 +3072,7 @@ export class Store {
2530
3072
  [fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
2531
3073
  this.assertRelationshipUnique(String(current.workId), fromCharacterId, toCharacterId, input.category ?? String(current.category), input.subtype ?? String(current.subtype), directed, relationshipId);
2532
3074
  this.db.transaction(() => {
3075
+ this.assertExpectedVersion("relationship", relationshipId, expectedVersionNo, "人物关系");
2533
3076
  this.db.run(`UPDATE relationships SET from_character_id = ?, to_character_id = ?, category = ?, subtype = ?, keywords_json = ?, directed = ?,
2534
3077
  current_status = ?, time_range_json = ?, confidence = ?, evidence_json = ?, confirmation_status = ?, locked = ?, updated_at = ?
2535
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);
@@ -2538,9 +3081,10 @@ export class Store {
2538
3081
  });
2539
3082
  return this.getRelationship(relationshipId);
2540
3083
  }
2541
- deleteRelationship(relationshipId) {
3084
+ deleteRelationship(relationshipId, expectedVersionNo) {
2542
3085
  const current = this.getRelationship(relationshipId);
2543
3086
  this.db.transaction(() => {
3087
+ this.assertExpectedVersion("relationship", relationshipId, expectedVersionNo, "人物关系");
2544
3088
  this.recordEntityVersion("relationship", relationshipId, "delete", null, "删除人物关系");
2545
3089
  this.db.run("DELETE FROM relationships WHERE id = ?", relationshipId);
2546
3090
  this.audit(String(current.workId), "relationship.deleted", "relationship", relationshipId);
@@ -2562,6 +3106,7 @@ export class Store {
2562
3106
  evidence: json(requiredString(row, "evidence_json"), []),
2563
3107
  confirmationStatus: requiredString(row, "confirmation_status"),
2564
3108
  locked: booleanValue(row, "locked"),
3109
+ versionNo: this.currentEntityVersionNo("relationship", requiredString(row, "id")),
2565
3110
  createdAt: requiredString(row, "created_at"),
2566
3111
  updatedAt: requiredString(row, "updated_at")
2567
3112
  };
@@ -2602,6 +3147,14 @@ export class Store {
2602
3147
  : this.db.all("SELECT * FROM review_items WHERE work_id = ? ORDER BY created_at DESC", workId);
2603
3148
  return rows.map((row) => this.mapReviewItem(row));
2604
3149
  }
3150
+ listReviewItemsPage(workId, pagination, status) {
3151
+ this.getWork(workId);
3152
+ const page = paginationSql(pagination);
3153
+ const rows = status
3154
+ ? this.db.all(`SELECT * FROM review_items WHERE work_id = ? AND status = ? ORDER BY created_at DESC${page.sql}`, workId, status, ...page.params)
3155
+ : this.db.all(`SELECT * FROM review_items WHERE work_id = ? ORDER BY created_at DESC${page.sql}`, workId, ...page.params);
3156
+ return paginated(rows.map((row) => this.mapReviewItem(row)), pagination);
3157
+ }
2605
3158
  getReviewItem(reviewId) {
2606
3159
  const row = this.db.get("SELECT * FROM review_items WHERE id = ?", reviewId);
2607
3160
  if (!row)
@@ -2659,6 +3212,14 @@ export class Store {
2659
3212
  throw notFound("AI 建议");
2660
3213
  return this.db.all("SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC", suggestionId).map((row) => this.mapContinuationGuard(row));
2661
3214
  }
3215
+ listContinuationGuardsPage(suggestionId, pagination) {
3216
+ const suggestion = this.db.get("SELECT id FROM ai_suggestions WHERE id = ?", suggestionId);
3217
+ if (!suggestion)
3218
+ throw notFound("AI 建议");
3219
+ const page = paginationSql(pagination);
3220
+ const rows = this.db.all(`SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC${page.sql}`, suggestionId, ...page.params);
3221
+ return paginated(rows.map((row) => this.mapContinuationGuard(row)), pagination);
3222
+ }
2662
3223
  getLatestContinuationGuard(suggestionId) {
2663
3224
  const row = this.db.get("SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC LIMIT 1", suggestionId);
2664
3225
  return row ? this.mapContinuationGuard(row) : null;
@@ -2680,6 +3241,17 @@ export class Store {
2680
3241
  ORDER BY conversation.updated_at DESC, conversation.created_at DESC
2681
3242
  LIMIT 100`, workId).map((row) => this.mapAiConversation(row));
2682
3243
  }
3244
+ listAiConversationsPage(workId, pagination) {
3245
+ this.getWork(workId);
3246
+ const page = paginationSql(pagination);
3247
+ const rows = this.db.all(`SELECT conversation.*,
3248
+ (SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count,
3249
+ COALESCE((SELECT content FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id ORDER BY message.created_at DESC, message.rowid DESC LIMIT 1), '') AS preview
3250
+ FROM ai_conversations conversation
3251
+ WHERE conversation.work_id = ?
3252
+ ORDER BY conversation.updated_at DESC, conversation.created_at DESC${page.sql}`, workId, ...page.params);
3253
+ return paginated(rows.map((row) => this.mapAiConversation(row)), pagination);
3254
+ }
2683
3255
  getAiConversation(conversationId) {
2684
3256
  const row = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
2685
3257
  if (!row)
@@ -2687,6 +3259,22 @@ export class Store {
2687
3259
  const messages = this.db.all("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId).map((message) => this.mapAiConversationMessage(message));
2688
3260
  return { ...this.mapAiConversation(row), messageCount: messages.length, messages };
2689
3261
  }
3262
+ getAiConversationPage(conversationId, pagination) {
3263
+ const row = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
3264
+ if (!row)
3265
+ throw notFound("AI 对话");
3266
+ const countRow = this.db.get("SELECT COUNT(*) AS count FROM ai_conversation_messages WHERE conversation_id = ?", conversationId);
3267
+ const page = paginationSql(pagination);
3268
+ const rows = this.db.all(`SELECT * FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC${page.sql}`, conversationId, ...page.params);
3269
+ const messagesPage = paginated(rows.map((message) => this.mapAiConversationMessage(message)), pagination);
3270
+ messagesPage.items.reverse();
3271
+ return {
3272
+ ...this.mapAiConversation(row),
3273
+ messageCount: Number(countRow?.count ?? 0),
3274
+ messages: messagesPage.items,
3275
+ messagesPage
3276
+ };
3277
+ }
2690
3278
  getAiConversationContext(conversationId, workId, excludeMessageId) {
2691
3279
  const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
2692
3280
  if (!conversation)
@@ -2840,6 +3428,12 @@ export class Store {
2840
3428
  this.getWork(workId);
2841
3429
  return this.db.all("SELECT * FROM analysis_tasks WHERE work_id = ? ORDER BY created_at DESC", workId).map((row) => this.mapTask(row));
2842
3430
  }
3431
+ listTasksPage(workId, pagination) {
3432
+ this.getWork(workId);
3433
+ const page = paginationSql(pagination);
3434
+ const rows = this.db.all(`SELECT * FROM analysis_tasks WHERE work_id = ? ORDER BY created_at DESC${page.sql}`, workId, ...page.params);
3435
+ return paginated(rows.map((row) => this.mapTask(row)), pagination);
3436
+ }
2843
3437
  getTask(taskId) {
2844
3438
  const row = this.db.get("SELECT * FROM analysis_tasks WHERE id = ?", taskId);
2845
3439
  if (!row)
@@ -3107,5 +3701,22 @@ export class Store {
3107
3701
  createdAt: requiredString(row, "created_at")
3108
3702
  }));
3109
3703
  }
3704
+ listAuditLogsPage(workId, pagination) {
3705
+ this.getWork(workId);
3706
+ const page = paginationSql(pagination);
3707
+ const rows = this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username
3708
+ FROM audit_logs log LEFT JOIN users user ON user.id = log.user_id
3709
+ WHERE log.work_id = ? ORDER BY log.created_at DESC${page.sql}`, workId, ...page.params);
3710
+ return paginated(rows.map((row) => ({
3711
+ id: requiredString(row, "id"),
3712
+ action: requiredString(row, "action"),
3713
+ entityType: requiredString(row, "entity_type"),
3714
+ entityId: optionalString(row, "entity_id"),
3715
+ actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? requiredString(row, "actor"),
3716
+ userId: optionalString(row, "user_id"),
3717
+ detail: json(requiredString(row, "detail_json"), {}),
3718
+ createdAt: requiredString(row, "created_at")
3719
+ })), pagination);
3720
+ }
3110
3721
  }
3111
3722
  //# sourceMappingURL=store.js.map