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