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