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