@musnows/scriverse 0.2.0 → 0.3.0
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/README.en.md +13 -4
- package/README.md +13 -4
- package/dist/ai.js +3374 -0
- package/dist/ai.js.map +1 -0
- package/dist/app.js +1046 -0
- package/dist/app.js.map +1 -0
- package/dist/cli-core.js +147 -20
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +40 -0
- package/dist/credential-vault.js.map +1 -0
- package/dist/database.js +1122 -0
- package/dist/database.js.map +1 -0
- package/dist/docx-security.js +184 -0
- package/dist/docx-security.js.map +1 -0
- package/dist/domain.js +21 -0
- package/dist/domain.js.map +1 -0
- package/dist/errors.js +16 -0
- package/dist/errors.js.map +1 -0
- package/dist/image-captcha.js +173 -0
- package/dist/image-captcha.js.map +1 -0
- package/dist/image-metadata.js +128 -0
- package/dist/image-metadata.js.map +1 -0
- package/dist/import-security.js +46 -0
- package/dist/import-security.js.map +1 -0
- package/dist/parser.js +223 -0
- package/dist/parser.js.map +1 -0
- package/dist/public/ai-context-meter.js +10 -0
- package/dist/public/ai-conversation.js +3 -0
- package/dist/public/ai-mentions.js +55 -0
- package/dist/public/ai-message-actions.js +27 -0
- package/dist/public/ai-message-meta.js +15 -0
- package/dist/public/ai-message-time.js +23 -0
- package/dist/public/ai-prompt-keyboard.js +3 -0
- package/dist/public/app.js +4255 -0
- package/dist/public/character-profile.d.ts +9 -0
- package/dist/public/character-profile.js +65 -0
- package/dist/public/character-version.d.ts +2 -0
- package/dist/public/character-version.js +31 -0
- package/dist/public/entity-version.js +34 -0
- package/dist/public/icon.svg +10 -0
- package/dist/public/index.html +547 -0
- package/dist/public/line-number-layout.js +15 -0
- package/dist/public/markdown.js +199 -0
- package/dist/public/model-config.d.ts +17 -0
- package/dist/public/model-config.js +57 -0
- package/dist/public/page-route.d.ts +11 -0
- package/dist/public/page-route.js +81 -0
- package/dist/public/relationship-graph.js +2017 -0
- package/dist/public/site.webmanifest +17 -0
- package/dist/public/styles.css +1399 -0
- package/dist/public/text-formatting.d.ts +2 -0
- package/dist/public/text-formatting.js +20 -0
- package/dist/public/theme-init.js +8 -0
- package/dist/public/theme.js +13 -0
- package/dist/public/whitespace-visualization.js +20 -0
- package/dist/request-context.js +9 -0
- package/dist/request-context.js.map +1 -0
- package/dist/security.js +235 -0
- package/dist/security.js.map +1 -0
- package/dist/server-runtime.js +77 -0
- package/dist/server-runtime.js.map +1 -0
- package/dist/server.js +15 -0
- package/dist/server.js.map +1 -0
- package/dist/store.js +2492 -0
- package/dist/store.js.map +1 -0
- package/dist/user-auth.js +489 -0
- package/dist/user-auth.js.map +1 -0
- package/dist/utils.js +50 -0
- package/dist/utils.js.map +1 -0
- package/package.json +4 -9
package/dist/store.js
ADDED
|
@@ -0,0 +1,2492 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
3
|
+
import { AppError, notFound } from "./errors.js";
|
|
4
|
+
import { currentRequestActor } from "./request-context.js";
|
|
5
|
+
import { countWords, id, json, normalizeParagraphSpacing, now } from "./utils.js";
|
|
6
|
+
export const versionedEntityTypes = [
|
|
7
|
+
"setting",
|
|
8
|
+
"race",
|
|
9
|
+
"organization",
|
|
10
|
+
"timeline-track",
|
|
11
|
+
"timeline-event",
|
|
12
|
+
"relationship",
|
|
13
|
+
"chapter-outline",
|
|
14
|
+
"foreshadow"
|
|
15
|
+
];
|
|
16
|
+
function requiredString(row, key) {
|
|
17
|
+
return String(row[key] ?? "");
|
|
18
|
+
}
|
|
19
|
+
function optionalString(row, key) {
|
|
20
|
+
return row[key] === null || row[key] === undefined ? null : String(row[key]);
|
|
21
|
+
}
|
|
22
|
+
function numberValue(row, key) {
|
|
23
|
+
return Number(row[key] ?? 0);
|
|
24
|
+
}
|
|
25
|
+
function booleanValue(row, key) {
|
|
26
|
+
return Number(row[key] ?? 0) === 1;
|
|
27
|
+
}
|
|
28
|
+
export function normalizeCharacterName(value) {
|
|
29
|
+
return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("zh-CN");
|
|
30
|
+
}
|
|
31
|
+
export class Store {
|
|
32
|
+
db;
|
|
33
|
+
constructor(db) {
|
|
34
|
+
this.db = db;
|
|
35
|
+
this.backfillEntityVersionBaselines();
|
|
36
|
+
}
|
|
37
|
+
versionedEntity(type, entityId) {
|
|
38
|
+
if (type === "setting")
|
|
39
|
+
return this.getSetting(entityId);
|
|
40
|
+
if (type === "race")
|
|
41
|
+
return this.getRace(entityId);
|
|
42
|
+
if (type === "organization")
|
|
43
|
+
return this.getOrganization(entityId);
|
|
44
|
+
if (type === "timeline-track")
|
|
45
|
+
return this.getTimelineTrack(entityId);
|
|
46
|
+
if (type === "timeline-event")
|
|
47
|
+
return this.getTimelineEvent(entityId);
|
|
48
|
+
if (type === "relationship")
|
|
49
|
+
return this.getRelationship(entityId);
|
|
50
|
+
if (type === "chapter-outline") {
|
|
51
|
+
const outline = this.getChapterOutline(entityId);
|
|
52
|
+
if (!outline)
|
|
53
|
+
throw notFound("章节大纲");
|
|
54
|
+
return outline;
|
|
55
|
+
}
|
|
56
|
+
return this.getForeshadow(entityId);
|
|
57
|
+
}
|
|
58
|
+
tryVersionedEntity(type, entityId) {
|
|
59
|
+
try {
|
|
60
|
+
return this.versionedEntity(type, entityId);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof AppError && error.status === 404)
|
|
64
|
+
return null;
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
versionedEntitySnapshot(type, entity) {
|
|
69
|
+
if (type === "setting")
|
|
70
|
+
return {
|
|
71
|
+
title: entity.title,
|
|
72
|
+
category: entity.category,
|
|
73
|
+
content: entity.content,
|
|
74
|
+
tags: entity.tags,
|
|
75
|
+
status: entity.status,
|
|
76
|
+
locked: entity.locked,
|
|
77
|
+
evidence: entity.evidence,
|
|
78
|
+
scope: entity.scope,
|
|
79
|
+
authorNote: entity.authorNote
|
|
80
|
+
};
|
|
81
|
+
if (type === "race" || type === "organization")
|
|
82
|
+
return {
|
|
83
|
+
name: entity.name,
|
|
84
|
+
description: entity.description,
|
|
85
|
+
settings: entity.settings,
|
|
86
|
+
memberIds: entity.memberIds
|
|
87
|
+
};
|
|
88
|
+
if (type === "timeline-track")
|
|
89
|
+
return {
|
|
90
|
+
name: entity.name,
|
|
91
|
+
description: entity.description,
|
|
92
|
+
sortOrder: entity.sortOrder
|
|
93
|
+
};
|
|
94
|
+
if (type === "timeline-event")
|
|
95
|
+
return {
|
|
96
|
+
name: entity.name,
|
|
97
|
+
trackId: entity.trackId,
|
|
98
|
+
description: entity.description,
|
|
99
|
+
eventType: entity.eventType,
|
|
100
|
+
timeLabel: entity.timeLabel,
|
|
101
|
+
timeSort: entity.timeSort,
|
|
102
|
+
chapterIds: entity.chapterIds,
|
|
103
|
+
participantIds: entity.participantIds,
|
|
104
|
+
location: entity.location,
|
|
105
|
+
causes: entity.causes,
|
|
106
|
+
impactScope: entity.impactScope,
|
|
107
|
+
evidence: entity.evidence,
|
|
108
|
+
status: entity.status
|
|
109
|
+
};
|
|
110
|
+
if (type === "relationship")
|
|
111
|
+
return {
|
|
112
|
+
fromCharacterId: entity.fromCharacterId,
|
|
113
|
+
toCharacterId: entity.toCharacterId,
|
|
114
|
+
category: entity.category,
|
|
115
|
+
subtype: entity.subtype,
|
|
116
|
+
keywords: entity.keywords,
|
|
117
|
+
directed: entity.directed,
|
|
118
|
+
currentStatus: entity.currentStatus,
|
|
119
|
+
timeRange: entity.timeRange,
|
|
120
|
+
confidence: entity.confidence,
|
|
121
|
+
evidence: entity.evidence,
|
|
122
|
+
confirmationStatus: entity.confirmationStatus,
|
|
123
|
+
locked: entity.locked
|
|
124
|
+
};
|
|
125
|
+
if (type === "chapter-outline")
|
|
126
|
+
return {
|
|
127
|
+
goal: entity.goal,
|
|
128
|
+
conflict: entity.conflict,
|
|
129
|
+
turningPoint: entity.turningPoint,
|
|
130
|
+
notes: entity.notes,
|
|
131
|
+
status: entity.status
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
title: entity.title,
|
|
135
|
+
description: entity.description,
|
|
136
|
+
status: entity.status,
|
|
137
|
+
importance: entity.importance,
|
|
138
|
+
plannedPayoffChapterId: entity.plannedPayoffChapterId,
|
|
139
|
+
resolutionNote: entity.resolutionNote,
|
|
140
|
+
occurrences: entity.occurrences.map((occurrence) => ({
|
|
141
|
+
chapterId: occurrence.chapterId,
|
|
142
|
+
role: occurrence.role,
|
|
143
|
+
note: occurrence.note,
|
|
144
|
+
evidence: occurrence.evidence
|
|
145
|
+
}))
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
recordEntityVersion(type, entityId, source, sourceRef, changeNote, timestamp) {
|
|
149
|
+
const entity = this.versionedEntity(type, entityId);
|
|
150
|
+
const snapshot = this.versionedEntitySnapshot(type, entity);
|
|
151
|
+
const snapshotJson = JSON.stringify(snapshot);
|
|
152
|
+
const latest = this.db.get("SELECT version_no, snapshot_json FROM entity_versions WHERE entity_type = ? AND entity_id = ? ORDER BY version_no DESC LIMIT 1", type, entityId);
|
|
153
|
+
if (latest && requiredString(latest, "snapshot_json") === snapshotJson && source !== "restore" && source !== "delete") {
|
|
154
|
+
return numberValue(latest, "version_no");
|
|
155
|
+
}
|
|
156
|
+
const versionNo = latest ? numberValue(latest, "version_no") + 1 : 1;
|
|
157
|
+
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)
|
|
158
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("entityVersion"), String(entity.workId), type, entityId, versionNo, snapshotJson, source, sourceRef, changeNote.trim(), timestamp ?? now(), currentRequestActor()?.userId ?? null);
|
|
159
|
+
return versionNo;
|
|
160
|
+
}
|
|
161
|
+
backfillEntityVersionBaselines() {
|
|
162
|
+
const entities = [
|
|
163
|
+
...this.db.all("SELECT id, updated_at FROM settings").map((row) => ["setting", requiredString(row, "id"), requiredString(row, "updated_at")]),
|
|
164
|
+
...this.db.all("SELECT id, updated_at FROM races").map((row) => ["race", requiredString(row, "id"), requiredString(row, "updated_at")]),
|
|
165
|
+
...this.db.all("SELECT id, updated_at FROM organizations").map((row) => ["organization", requiredString(row, "id"), requiredString(row, "updated_at")]),
|
|
166
|
+
...this.db.all("SELECT id, updated_at FROM timeline_tracks").map((row) => ["timeline-track", requiredString(row, "id"), requiredString(row, "updated_at")]),
|
|
167
|
+
...this.db.all("SELECT id, updated_at FROM timeline_events").map((row) => ["timeline-event", requiredString(row, "id"), requiredString(row, "updated_at")]),
|
|
168
|
+
...this.db.all("SELECT id, updated_at FROM relationships").map((row) => ["relationship", requiredString(row, "id"), requiredString(row, "updated_at")]),
|
|
169
|
+
...this.db.all("SELECT chapter_id, updated_at FROM chapter_outlines").map((row) => ["chapter-outline", requiredString(row, "chapter_id"), requiredString(row, "updated_at")]),
|
|
170
|
+
...this.db.all("SELECT id, updated_at FROM foreshadows").map((row) => ["foreshadow", requiredString(row, "id"), requiredString(row, "updated_at")])
|
|
171
|
+
];
|
|
172
|
+
this.db.transaction(() => {
|
|
173
|
+
for (const [type, entityId, timestamp] of entities) {
|
|
174
|
+
this.recordEntityVersion(type, entityId, "migration", null, "建立版本基线", timestamp);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
listEntityVersions(type, entityId) {
|
|
179
|
+
const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
|
|
180
|
+
FROM entity_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
181
|
+
WHERE version.entity_type = ? AND version.entity_id = ? ORDER BY version.version_no DESC`, type, entityId);
|
|
182
|
+
if (!rows.length) {
|
|
183
|
+
this.versionedEntity(type, entityId);
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
return rows.map((row) => ({
|
|
187
|
+
id: requiredString(row, "id"),
|
|
188
|
+
workId: requiredString(row, "work_id"),
|
|
189
|
+
entityType: requiredString(row, "entity_type"),
|
|
190
|
+
entityId: requiredString(row, "entity_id"),
|
|
191
|
+
versionNo: numberValue(row, "version_no"),
|
|
192
|
+
snapshot: json(requiredString(row, "snapshot_json"), {}),
|
|
193
|
+
source: requiredString(row, "source"),
|
|
194
|
+
sourceRef: optionalString(row, "source_ref"),
|
|
195
|
+
changeNote: requiredString(row, "change_note"),
|
|
196
|
+
createdAt: requiredString(row, "created_at"),
|
|
197
|
+
actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
restoreEntityVersion(type, entityId, versionNo) {
|
|
201
|
+
const version = this.db.get("SELECT * FROM entity_versions WHERE entity_type = ? AND entity_id = ? AND version_no = ?", type, entityId, versionNo);
|
|
202
|
+
if (!version)
|
|
203
|
+
throw notFound("历史版本");
|
|
204
|
+
const snapshot = json(requiredString(version, "snapshot_json"), {});
|
|
205
|
+
if (!Object.keys(snapshot).length)
|
|
206
|
+
throw new AppError(500, "ENTITY_VERSION_INVALID", "历史版本快照无效");
|
|
207
|
+
const sourceRef = requiredString(version, "id");
|
|
208
|
+
const changeNote = `恢复至 v${versionNo}`;
|
|
209
|
+
const workId = requiredString(version, "work_id");
|
|
210
|
+
const existing = this.tryVersionedEntity(type, entityId);
|
|
211
|
+
let restored;
|
|
212
|
+
if (!existing) {
|
|
213
|
+
restored = this.recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote);
|
|
214
|
+
}
|
|
215
|
+
else if (type === "setting")
|
|
216
|
+
restored = this.updateSetting(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
217
|
+
else if (type === "race")
|
|
218
|
+
restored = this.updateRace(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
219
|
+
else if (type === "organization")
|
|
220
|
+
restored = this.updateOrganization(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
221
|
+
else if (type === "timeline-track")
|
|
222
|
+
restored = this.updateTimelineTrack(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
223
|
+
else if (type === "timeline-event")
|
|
224
|
+
restored = this.updateTimelineEvent(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
225
|
+
else if (type === "relationship")
|
|
226
|
+
restored = this.updateRelationship(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
227
|
+
else if (type === "chapter-outline")
|
|
228
|
+
restored = this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
229
|
+
else
|
|
230
|
+
restored = this.updateForeshadow(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
231
|
+
const currentVersion = this.db.get("SELECT MAX(version_no) AS version_no FROM entity_versions WHERE entity_type = ? AND entity_id = ?", type, entityId);
|
|
232
|
+
return { ...restored, versionNo: numberValue(currentVersion ?? {}, "version_no") };
|
|
233
|
+
}
|
|
234
|
+
recreateEntityFromSnapshot(type, workId, entityId, snapshot, sourceRef, changeNote) {
|
|
235
|
+
this.getWork(workId);
|
|
236
|
+
if (type === "setting") {
|
|
237
|
+
return this.insertSettingWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
|
|
238
|
+
}
|
|
239
|
+
if (type === "race") {
|
|
240
|
+
return this.insertRaceWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
|
|
241
|
+
}
|
|
242
|
+
if (type === "organization") {
|
|
243
|
+
return this.insertOrganizationWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
|
|
244
|
+
}
|
|
245
|
+
if (type === "timeline-track") {
|
|
246
|
+
return this.insertTimelineTrackWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
|
|
247
|
+
}
|
|
248
|
+
if (type === "timeline-event") {
|
|
249
|
+
return this.insertTimelineEventWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
|
|
250
|
+
}
|
|
251
|
+
if (type === "relationship") {
|
|
252
|
+
return this.insertRelationshipWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
|
|
253
|
+
}
|
|
254
|
+
if (type === "chapter-outline") {
|
|
255
|
+
this.getChapter(entityId);
|
|
256
|
+
return this.upsertChapterOutline(entityId, snapshot, "restore", sourceRef, changeNote);
|
|
257
|
+
}
|
|
258
|
+
return this.insertForeshadowWithId(workId, entityId, snapshot, "restore", sourceRef, changeNote);
|
|
259
|
+
}
|
|
260
|
+
audit(workId, action, entityType, entityId, detail = {}) {
|
|
261
|
+
const actor = currentRequestActor();
|
|
262
|
+
this.db.run("INSERT INTO audit_logs (id, work_id, action, entity_type, entity_id, actor, detail_json, created_at, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", id("audit"), workId, action, entityType, entityId, actor?.displayName || actor?.username || "system", JSON.stringify(detail), now(), actor?.userId ?? null);
|
|
263
|
+
}
|
|
264
|
+
createWork(input) {
|
|
265
|
+
const workId = id("work");
|
|
266
|
+
const timestamp = now();
|
|
267
|
+
const actor = currentRequestActor();
|
|
268
|
+
this.db.transaction(() => {
|
|
269
|
+
this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, created_at, updated_at, owner_user_id)
|
|
270
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), timestamp, timestamp, actor?.userId ?? null);
|
|
271
|
+
if (actor) {
|
|
272
|
+
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);
|
|
273
|
+
}
|
|
274
|
+
this.audit(workId, "work.created", "work", workId);
|
|
275
|
+
});
|
|
276
|
+
return this.getWork(workId);
|
|
277
|
+
}
|
|
278
|
+
listWorks() {
|
|
279
|
+
const actor = currentRequestActor();
|
|
280
|
+
if (!actor || (actor.role === "admin" && actor.authentication !== "api-key")) {
|
|
281
|
+
return this.db.all("SELECT * FROM works WHERE COALESCE(is_internal, 0) = 0 ORDER BY updated_at DESC").map((row) => this.mapWork(row));
|
|
282
|
+
}
|
|
283
|
+
return this.db.all(`SELECT DISTINCT work.* FROM works work LEFT JOIN work_memberships membership ON membership.work_id = work.id
|
|
284
|
+
WHERE COALESCE(work.is_internal, 0) = 0 AND (work.owner_user_id = ? OR membership.user_id = ?)
|
|
285
|
+
ORDER BY work.updated_at DESC`, actor.userId, actor.userId).map((row) => this.mapWork(row));
|
|
286
|
+
}
|
|
287
|
+
getWork(workId) {
|
|
288
|
+
const row = this.db.get("SELECT * FROM works WHERE id = ?", workId);
|
|
289
|
+
if (!row)
|
|
290
|
+
throw notFound("作品");
|
|
291
|
+
return this.mapWork(row);
|
|
292
|
+
}
|
|
293
|
+
getPlatformAiSettings() {
|
|
294
|
+
const row = this.db.get("SELECT * FROM platform_ai_settings WHERE id = 1");
|
|
295
|
+
return {
|
|
296
|
+
systemPrompt: String(row?.system_prompt ?? ""),
|
|
297
|
+
updatedAt: String(row?.updated_at ?? "")
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
updatePlatformAiSettings(input) {
|
|
301
|
+
const timestamp = now();
|
|
302
|
+
this.db.run(`INSERT INTO platform_ai_settings (id, system_prompt, updated_at) VALUES (1, ?, ?)
|
|
303
|
+
ON CONFLICT(id) DO UPDATE SET system_prompt = excluded.system_prompt, updated_at = excluded.updated_at`, input.systemPrompt ?? String(this.getPlatformAiSettings().systemPrompt), timestamp);
|
|
304
|
+
return this.getPlatformAiSettings();
|
|
305
|
+
}
|
|
306
|
+
getPlatformUiSettings() {
|
|
307
|
+
const row = this.db.get("SELECT * FROM platform_ui_settings WHERE id = 1");
|
|
308
|
+
return {
|
|
309
|
+
toastPosition: String(row?.toast_position) === "top-right" ? "top-right" : "bottom-right",
|
|
310
|
+
updatedAt: String(row?.updated_at ?? "")
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
updatePlatformUiSettings(input) {
|
|
314
|
+
const timestamp = now();
|
|
315
|
+
this.db.transaction(() => {
|
|
316
|
+
this.db.run(`INSERT INTO platform_ui_settings (id, toast_position, updated_at) VALUES (1, ?, ?)
|
|
317
|
+
ON CONFLICT(id) DO UPDATE SET toast_position = excluded.toast_position, updated_at = excluded.updated_at`, input.toastPosition, timestamp);
|
|
318
|
+
this.audit(PLATFORM_AI_WORK_ID, "platform.ui-settings.updated", "platform-ui-settings", "platform-ui-settings", {
|
|
319
|
+
toastPosition: input.toastPosition
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
return this.getPlatformUiSettings();
|
|
323
|
+
}
|
|
324
|
+
analysisTaskQueuedHandler = null;
|
|
325
|
+
setAnalysisTaskQueuedHandler(handler) {
|
|
326
|
+
this.analysisTaskQueuedHandler = handler;
|
|
327
|
+
}
|
|
328
|
+
notifyAnalysisTaskQueued(workId) {
|
|
329
|
+
try {
|
|
330
|
+
this.analysisTaskQueuedHandler?.(workId);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// 自动运行调度失败不影响主写入路径
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
getWorkAiSettings(workId) {
|
|
337
|
+
this.getWork(workId);
|
|
338
|
+
const row = this.db.get("SELECT * FROM work_ai_settings WHERE work_id = ?", workId);
|
|
339
|
+
return {
|
|
340
|
+
workId,
|
|
341
|
+
systemPrompt: String(row?.system_prompt ?? ""),
|
|
342
|
+
autoRunEnabled: Number(row?.auto_run_enabled ?? 0) === 1,
|
|
343
|
+
autoRunConcurrency: Math.min(8, Math.max(1, Number(row?.auto_run_concurrency ?? 2) || 2)),
|
|
344
|
+
autoRunBatchLimit: Math.min(200, Math.max(1, Number(row?.auto_run_batch_limit ?? 20) || 20)),
|
|
345
|
+
bookSummaryContextPercent: Math.min(90, Math.max(1, Number(row?.book_summary_context_percent ?? 50) || 50)),
|
|
346
|
+
contextCompactThreshold: Math.min(90, Math.max(50, Number(row?.context_compact_threshold ?? 85) || 85)),
|
|
347
|
+
agentTools: json(String(row?.agent_tools_json ?? '["story_index","read_chapters","query_story_knowledge"]'), ["story_index", "read_chapters", "query_story_knowledge"]),
|
|
348
|
+
updatedAt: String(row?.updated_at ?? "")
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
updateWorkAiSettings(workId, input) {
|
|
352
|
+
this.getWork(workId);
|
|
353
|
+
const current = this.getWorkAiSettings(workId);
|
|
354
|
+
const timestamp = now();
|
|
355
|
+
const nextPrompt = input.systemPrompt ?? String(current.systemPrompt);
|
|
356
|
+
const nextEnabled = input.autoRunEnabled ?? Boolean(current.autoRunEnabled);
|
|
357
|
+
const nextConcurrency = input.autoRunConcurrency ?? Number(current.autoRunConcurrency);
|
|
358
|
+
const nextBatchLimit = input.autoRunBatchLimit ?? Number(current.autoRunBatchLimit);
|
|
359
|
+
const nextBookSummaryContextPercent = input.bookSummaryContextPercent ?? Number(current.bookSummaryContextPercent);
|
|
360
|
+
const nextContextCompactThreshold = input.contextCompactThreshold ?? Number(current.contextCompactThreshold);
|
|
361
|
+
const nextAgentTools = input.agentTools ?? current.agentTools;
|
|
362
|
+
this.db.run(`INSERT INTO work_ai_settings (
|
|
363
|
+
work_id, system_prompt, auto_run_enabled, auto_run_concurrency, auto_run_batch_limit, book_summary_context_percent, context_compact_threshold, agent_tools_json, updated_at
|
|
364
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
365
|
+
ON CONFLICT(work_id) DO UPDATE SET
|
|
366
|
+
system_prompt = excluded.system_prompt,
|
|
367
|
+
auto_run_enabled = excluded.auto_run_enabled,
|
|
368
|
+
auto_run_concurrency = excluded.auto_run_concurrency,
|
|
369
|
+
auto_run_batch_limit = excluded.auto_run_batch_limit,
|
|
370
|
+
book_summary_context_percent = excluded.book_summary_context_percent,
|
|
371
|
+
context_compact_threshold = excluded.context_compact_threshold,
|
|
372
|
+
agent_tools_json = excluded.agent_tools_json,
|
|
373
|
+
updated_at = excluded.updated_at`, workId, nextPrompt, nextEnabled ? 1 : 0, Math.min(8, Math.max(1, nextConcurrency)), Math.min(200, Math.max(1, nextBatchLimit)), Math.min(90, Math.max(1, nextBookSummaryContextPercent)), Math.min(90, Math.max(50, nextContextCompactThreshold)), JSON.stringify(nextAgentTools), timestamp);
|
|
374
|
+
this.audit(workId, "work.ai-settings.updated", "work-ai-settings", workId, {
|
|
375
|
+
systemPromptChanged: input.systemPrompt !== undefined,
|
|
376
|
+
autoRunEnabled: nextEnabled,
|
|
377
|
+
autoRunConcurrency: Math.min(8, Math.max(1, nextConcurrency)),
|
|
378
|
+
autoRunBatchLimit: Math.min(200, Math.max(1, nextBatchLimit)),
|
|
379
|
+
bookSummaryContextPercent: Math.min(90, Math.max(1, nextBookSummaryContextPercent)),
|
|
380
|
+
contextCompactThreshold: Math.min(90, Math.max(50, nextContextCompactThreshold)),
|
|
381
|
+
agentTools: nextAgentTools
|
|
382
|
+
});
|
|
383
|
+
return this.getWorkAiSettings(workId);
|
|
384
|
+
}
|
|
385
|
+
updateWork(workId, input) {
|
|
386
|
+
const current = this.getWork(workId);
|
|
387
|
+
const timestamp = now();
|
|
388
|
+
this.db.run(`UPDATE works SET title = ?, author = ?, description = ?, language = ?, cover_url = ?, tags_json = ?, updated_at = ?
|
|
389
|
+
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);
|
|
390
|
+
this.audit(workId, "work.updated", "work", workId, { fields: Object.keys(input) });
|
|
391
|
+
return this.getWork(workId);
|
|
392
|
+
}
|
|
393
|
+
deleteWork(workId) {
|
|
394
|
+
const work = this.getWork(workId);
|
|
395
|
+
this.db.transaction(() => {
|
|
396
|
+
this.audit(null, "work.deleted", "work", workId, { title: work.title });
|
|
397
|
+
this.db.run("DELETE FROM works WHERE id = ?", workId);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
setWorkCover(workId, mimeType, content) {
|
|
401
|
+
this.getWork(workId);
|
|
402
|
+
const timestamp = now();
|
|
403
|
+
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
404
|
+
this.db.run(`INSERT INTO work_covers (work_id, mime_type, content, byte_length, sha256, updated_at)
|
|
405
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
406
|
+
ON CONFLICT(work_id) DO UPDATE SET mime_type = excluded.mime_type, content = excluded.content,
|
|
407
|
+
byte_length = excluded.byte_length, sha256 = excluded.sha256, updated_at = excluded.updated_at`, workId, mimeType, content, content.byteLength, sha256, timestamp);
|
|
408
|
+
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
|
|
409
|
+
this.audit(workId, "work.cover.updated", "work", workId, { mimeType, byteLength: content.byteLength, sha256 });
|
|
410
|
+
return this.getWork(workId);
|
|
411
|
+
}
|
|
412
|
+
getWorkCover(workId) {
|
|
413
|
+
this.getWork(workId);
|
|
414
|
+
const row = this.db.get("SELECT * FROM work_covers WHERE work_id = ?", workId);
|
|
415
|
+
if (!row)
|
|
416
|
+
throw notFound("作品封面");
|
|
417
|
+
return {
|
|
418
|
+
mimeType: requiredString(row, "mime_type"),
|
|
419
|
+
content: Buffer.from(row.content),
|
|
420
|
+
byteLength: numberValue(row, "byte_length"),
|
|
421
|
+
sha256: requiredString(row, "sha256"),
|
|
422
|
+
updatedAt: requiredString(row, "updated_at")
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
deleteWorkCover(workId) {
|
|
426
|
+
this.getWork(workId);
|
|
427
|
+
this.db.run("DELETE FROM work_covers WHERE work_id = ?", workId);
|
|
428
|
+
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", now(), workId);
|
|
429
|
+
this.audit(workId, "work.cover.deleted", "work", workId);
|
|
430
|
+
}
|
|
431
|
+
getWorkTree(workId) {
|
|
432
|
+
const work = this.getWork(workId);
|
|
433
|
+
const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
|
|
434
|
+
const chapterRows = this.db.all("SELECT * FROM chapters WHERE work_id = ? ORDER BY sort_order, created_at", workId);
|
|
435
|
+
const chaptersByVolume = new Map();
|
|
436
|
+
for (const row of chapterRows) {
|
|
437
|
+
const chapter = this.mapChapter(row);
|
|
438
|
+
const volumeId = requiredString(row, "volume_id");
|
|
439
|
+
const list = chaptersByVolume.get(volumeId) ?? [];
|
|
440
|
+
list.push(chapter);
|
|
441
|
+
chaptersByVolume.set(volumeId, list);
|
|
442
|
+
}
|
|
443
|
+
const volumes = volumeRows.map((row) => ({
|
|
444
|
+
...this.mapVolume(row),
|
|
445
|
+
chapters: chaptersByVolume.get(requiredString(row, "id")) ?? []
|
|
446
|
+
}));
|
|
447
|
+
return { ...work, volumes };
|
|
448
|
+
}
|
|
449
|
+
listFileVersions(workId) {
|
|
450
|
+
this.getWork(workId);
|
|
451
|
+
return this.db
|
|
452
|
+
.all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
|
|
453
|
+
version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
|
|
454
|
+
FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
455
|
+
WHERE version.work_id = ? ORDER BY version.created_at DESC`, workId)
|
|
456
|
+
.map((row) => ({
|
|
457
|
+
id: requiredString(row, "id"),
|
|
458
|
+
workId: requiredString(row, "work_id"),
|
|
459
|
+
fileName: requiredString(row, "file_name"),
|
|
460
|
+
fileType: requiredString(row, "file_type"),
|
|
461
|
+
wordCount: numberValue(row, "word_count"),
|
|
462
|
+
paragraphCount: numberValue(row, "paragraph_count"),
|
|
463
|
+
warnings: json(requiredString(row, "warnings_json"), []),
|
|
464
|
+
createdAt: requiredString(row, "created_at"),
|
|
465
|
+
actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
restoreFileVersion(workId, fileVersionId) {
|
|
469
|
+
this.getWork(workId);
|
|
470
|
+
const version = this.db.get("SELECT * FROM file_versions WHERE id = ? AND work_id = ?", fileVersionId, workId);
|
|
471
|
+
if (!version)
|
|
472
|
+
throw notFound("文件版本");
|
|
473
|
+
const snapshot = json(requiredString(version, "snapshot_json"), {});
|
|
474
|
+
const volumes = Array.isArray(snapshot.volumes) ? snapshot.volumes : [];
|
|
475
|
+
return this.db.transaction(() => {
|
|
476
|
+
const currentTree = this.getWorkTree(workId);
|
|
477
|
+
const currentChapters = this.db.all("SELECT content FROM chapters WHERE work_id = ?", workId);
|
|
478
|
+
const wordCount = currentChapters.reduce((sum, row) => sum + countWords(requiredString(row, "content")), 0);
|
|
479
|
+
const paragraphCount = currentChapters.reduce((sum, row) => {
|
|
480
|
+
const content = requiredString(row, "content").trim();
|
|
481
|
+
return sum + (content ? content.split(/\n+/u).filter(Boolean).length : 0);
|
|
482
|
+
}, 0);
|
|
483
|
+
const restorePointId = id("file");
|
|
484
|
+
const timestamp = now();
|
|
485
|
+
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)
|
|
486
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, restorePointId, workId, `before-restore:${requiredString(version, "file_name")}`, "snapshot", wordCount, paragraphCount, "[]", JSON.stringify(currentTree), timestamp, currentRequestActor()?.userId ?? null);
|
|
487
|
+
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
488
|
+
for (const volume of volumes) {
|
|
489
|
+
const volumeId = id("volume");
|
|
490
|
+
const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
|
|
491
|
+
this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, created_at, updated_at)
|
|
492
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, volumeId, workId, String(volume.title ?? "正文"), String(volume.kind ?? "main"), String(volume.source ?? "manual"), String(volume.description ?? ""), JSON.stringify(Array.isArray(volume.keywords) ? this.normalizeVolumeKeywords(volume.keywords) : []), Number(volume.sortOrder ?? 0), timestamp, timestamp);
|
|
493
|
+
for (const chapter of chapters) {
|
|
494
|
+
const chapterType = (["正文", "设定", "作者的话", "其他"].includes(String(chapter.chapterType))
|
|
495
|
+
? String(chapter.chapterType)
|
|
496
|
+
: "正文");
|
|
497
|
+
this.insertChapter(workId, volumeId, String(chapter.title ?? "未命名章节"), String(chapter.content ?? ""), Number(chapter.sortOrder ?? 0), "restore", fileVersionId, chapterType);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
|
|
501
|
+
this.audit(workId, "file.restored", "file-version", fileVersionId, { restorePointId });
|
|
502
|
+
return {
|
|
503
|
+
fileVersionId: restorePointId,
|
|
504
|
+
restoredFrom: fileVersionId,
|
|
505
|
+
tree: this.getWorkTree(workId)
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
importNovel(workId, fileName, fileType, parsed) {
|
|
510
|
+
this.getWork(workId);
|
|
511
|
+
let result = {};
|
|
512
|
+
this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed); });
|
|
513
|
+
return result;
|
|
514
|
+
}
|
|
515
|
+
createImportedWork(input, fileName, fileType, parsed) {
|
|
516
|
+
return this.db.transaction(() => {
|
|
517
|
+
const work = this.createWork(input);
|
|
518
|
+
const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed);
|
|
519
|
+
return { ...imported, work: this.getWork(String(work.id)) };
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
importNovelInTransaction(workId, fileName, fileType, parsed) {
|
|
523
|
+
const fileVersionId = id("file");
|
|
524
|
+
const timestamp = now();
|
|
525
|
+
const snapshot = this.getWorkTree(workId);
|
|
526
|
+
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)
|
|
527
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, fileVersionId, workId, fileName, fileType, parsed.wordCount, parsed.paragraphCount, JSON.stringify(parsed.warnings), JSON.stringify(snapshot), timestamp, currentRequestActor()?.userId ?? null);
|
|
528
|
+
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
529
|
+
for (const volume of parsed.volumes) {
|
|
530
|
+
const volumeId = id("volume");
|
|
531
|
+
this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, sort_order, created_at, updated_at)
|
|
532
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, volumeId, workId, volume.title, volume.kind, volume.source, volume.order, timestamp, timestamp);
|
|
533
|
+
for (const chapter of volume.chapters) {
|
|
534
|
+
this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.order, "import", fileVersionId, chapter.chapterType);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
|
|
538
|
+
this.audit(workId, "work.imported", "file-version", fileVersionId, {
|
|
539
|
+
fileName,
|
|
540
|
+
volumeCount: parsed.volumes.length,
|
|
541
|
+
chapterCount: parsed.volumes.reduce((sum, volume) => sum + volume.chapters.length, 0)
|
|
542
|
+
});
|
|
543
|
+
return {
|
|
544
|
+
fileVersionId,
|
|
545
|
+
warnings: parsed.warnings,
|
|
546
|
+
wordCount: parsed.wordCount,
|
|
547
|
+
paragraphCount: parsed.paragraphCount,
|
|
548
|
+
tree: this.getWorkTree(workId)
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
createVolume(workId, input) {
|
|
552
|
+
this.getWork(workId);
|
|
553
|
+
const volumeId = id("volume");
|
|
554
|
+
const timestamp = now();
|
|
555
|
+
const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM volumes WHERE work_id = ?", workId);
|
|
556
|
+
this.db.run(`INSERT INTO volumes (id, work_id, title, kind, source, description, keywords_json, sort_order, created_at, updated_at)
|
|
557
|
+
VALUES (?, ?, ?, ?, 'manual', ?, ?, ?, ?, ?)`, volumeId, workId, input.title, input.kind ?? "main", input.description?.trim() ?? "", JSON.stringify(this.normalizeVolumeKeywords(input.keywords ?? [])), numberValue(last ?? {}, "value") + 1, timestamp, timestamp);
|
|
558
|
+
this.audit(workId, "volume.created", "volume", volumeId);
|
|
559
|
+
return this.getVolume(volumeId);
|
|
560
|
+
}
|
|
561
|
+
getVolume(volumeId) {
|
|
562
|
+
const row = this.db.get("SELECT * FROM volumes WHERE id = ?", volumeId);
|
|
563
|
+
if (!row)
|
|
564
|
+
throw notFound("卷");
|
|
565
|
+
return this.mapVolume(row);
|
|
566
|
+
}
|
|
567
|
+
updateVolume(volumeId, input) {
|
|
568
|
+
const current = this.getVolume(volumeId);
|
|
569
|
+
this.db.run("UPDATE volumes SET title = ?, kind = ?, description = ?, keywords_json = ?, sort_order = ?, source = 'manual', updated_at = ? WHERE id = ?", input.title ?? String(current.title), input.kind ?? String(current.kind), input.description?.trim() ?? String(current.description), JSON.stringify(input.keywords === undefined ? current.keywords : this.normalizeVolumeKeywords(input.keywords)), input.sortOrder ?? Number(current.sortOrder), now(), volumeId);
|
|
570
|
+
this.audit(String(current.workId), "volume.updated", "volume", volumeId, input);
|
|
571
|
+
return this.getVolume(volumeId);
|
|
572
|
+
}
|
|
573
|
+
deleteVolume(volumeId) {
|
|
574
|
+
const volume = this.getVolume(volumeId);
|
|
575
|
+
const count = this.db.get("SELECT COUNT(*) AS value FROM chapters WHERE volume_id = ?", volumeId);
|
|
576
|
+
if (numberValue(count ?? {}, "value") > 0) {
|
|
577
|
+
throw new AppError(409, "VOLUME_NOT_EMPTY", "卷内仍有章节,需先移动或删除章节");
|
|
578
|
+
}
|
|
579
|
+
this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
|
|
580
|
+
this.audit(String(volume.workId), "volume.deleted", "volume", volumeId);
|
|
581
|
+
}
|
|
582
|
+
createChapter(workId, input) {
|
|
583
|
+
this.getWork(workId);
|
|
584
|
+
const volume = this.getVolume(input.volumeId);
|
|
585
|
+
if (volume.workId !== workId)
|
|
586
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
|
|
587
|
+
const last = this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS value FROM chapters WHERE volume_id = ?", input.volumeId);
|
|
588
|
+
const chapterId = this.insertChapter(workId, input.volumeId, input.title, input.content ?? "", numberValue(last ?? {}, "value") + 1, "manual", null, input.chapterType ?? "正文");
|
|
589
|
+
this.audit(workId, "chapter.created", "chapter", chapterId);
|
|
590
|
+
return this.getChapter(chapterId);
|
|
591
|
+
}
|
|
592
|
+
getChapter(chapterId) {
|
|
593
|
+
const row = this.db.get("SELECT * FROM chapters WHERE id = ?", chapterId);
|
|
594
|
+
if (!row)
|
|
595
|
+
throw notFound("章节");
|
|
596
|
+
return this.mapChapter(row);
|
|
597
|
+
}
|
|
598
|
+
findChapterVersionRows(chapterId) {
|
|
599
|
+
return this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
|
|
600
|
+
FROM chapter_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
601
|
+
WHERE version.chapter_id = ? ORDER BY version.version_no DESC`, chapterId);
|
|
602
|
+
}
|
|
603
|
+
mapChapterVersionRow(row) {
|
|
604
|
+
return {
|
|
605
|
+
id: requiredString(row, "id"),
|
|
606
|
+
workId: optionalString(row, "work_id"),
|
|
607
|
+
chapterId: requiredString(row, "chapter_id"),
|
|
608
|
+
versionNo: numberValue(row, "version_no"),
|
|
609
|
+
title: requiredString(row, "title"),
|
|
610
|
+
content: requiredString(row, "content"),
|
|
611
|
+
volumeId: optionalString(row, "volume_id"),
|
|
612
|
+
sortOrder: row.sort_order === null || row.sort_order === undefined ? null : numberValue(row, "sort_order"),
|
|
613
|
+
chapterType: optionalString(row, "chapter_type"),
|
|
614
|
+
source: requiredString(row, "source"),
|
|
615
|
+
sourceRef: optionalString(row, "source_ref"),
|
|
616
|
+
changeNote: requiredString(row, "change_note"),
|
|
617
|
+
createdAt: requiredString(row, "created_at"),
|
|
618
|
+
actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
insertChapterVersionRow(input) {
|
|
622
|
+
this.db.run(`INSERT INTO chapter_versions (
|
|
623
|
+
id, work_id, chapter_id, version_no, title, content, volume_id, sort_order, chapter_type,
|
|
624
|
+
source, source_ref, change_note, created_at, created_by_user_id
|
|
625
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("chapterVersion"), input.workId, input.chapterId, input.versionNo, input.title, input.content, input.volumeId, input.sortOrder, input.chapterType, input.source, input.sourceRef, input.changeNote.trim(), input.timestamp ?? now(), currentRequestActor()?.userId ?? null);
|
|
626
|
+
}
|
|
627
|
+
listChapterVersions(chapterId) {
|
|
628
|
+
const rows = this.findChapterVersionRows(chapterId);
|
|
629
|
+
if (!rows.length) {
|
|
630
|
+
this.getChapter(chapterId);
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
return rows.map((row) => this.mapChapterVersionRow(row));
|
|
634
|
+
}
|
|
635
|
+
listChapterInsights(chapterId) {
|
|
636
|
+
this.getChapter(chapterId);
|
|
637
|
+
return this.db
|
|
638
|
+
.all("SELECT * FROM chapter_insights WHERE chapter_id = ? ORDER BY chapter_version DESC, created_at DESC", chapterId)
|
|
639
|
+
.map((row) => ({
|
|
640
|
+
id: requiredString(row, "id"),
|
|
641
|
+
chapterId: requiredString(row, "chapter_id"),
|
|
642
|
+
chapterVersion: numberValue(row, "chapter_version"),
|
|
643
|
+
summary: requiredString(row, "summary"),
|
|
644
|
+
events: json(requiredString(row, "events_json"), []),
|
|
645
|
+
characters: json(requiredString(row, "characters_json"), []),
|
|
646
|
+
settings: json(requiredString(row, "settings_json"), []),
|
|
647
|
+
evidence: json(requiredString(row, "evidence_json"), []),
|
|
648
|
+
uncertainties: json(requiredString(row, "uncertainties_json"), []),
|
|
649
|
+
status: requiredString(row, "status"),
|
|
650
|
+
createdAt: requiredString(row, "created_at")
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
listCurrentChapterInsights(workId) {
|
|
654
|
+
this.getWork(workId);
|
|
655
|
+
return this.db.all(`SELECT insight.id, insight.chapter_id, insight.summary, chapter.title AS chapter_title,
|
|
656
|
+
volume.title AS volume_title, volume.sort_order AS volume_sort_order, chapter.sort_order AS chapter_sort_order
|
|
657
|
+
FROM chapters chapter
|
|
658
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
659
|
+
JOIN chapter_insights insight ON insight.chapter_id = chapter.id AND insight.chapter_version = chapter.version_no
|
|
660
|
+
WHERE chapter.work_id = ?
|
|
661
|
+
AND NOT EXISTS (
|
|
662
|
+
SELECT 1 FROM chapter_insights newer
|
|
663
|
+
WHERE newer.chapter_id = insight.chapter_id
|
|
664
|
+
AND newer.chapter_version = insight.chapter_version
|
|
665
|
+
AND (newer.created_at > insight.created_at OR (newer.created_at = insight.created_at AND newer.id > insight.id))
|
|
666
|
+
)
|
|
667
|
+
ORDER BY volume.sort_order, chapter.sort_order`, workId).map((row) => ({
|
|
668
|
+
id: requiredString(row, "id"),
|
|
669
|
+
chapterId: requiredString(row, "chapter_id"),
|
|
670
|
+
chapterTitle: requiredString(row, "chapter_title"),
|
|
671
|
+
volumeTitle: requiredString(row, "volume_title"),
|
|
672
|
+
summary: requiredString(row, "summary")
|
|
673
|
+
}));
|
|
674
|
+
}
|
|
675
|
+
saveChapter(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
676
|
+
const current = this.getChapter(chapterId);
|
|
677
|
+
const nextTitle = input.title ?? String(current.title);
|
|
678
|
+
const nextContent = input.content === undefined ? String(current.content) : normalizeParagraphSpacing(input.content);
|
|
679
|
+
const nextExcluded = input.excludedFromAnalysis ?? Boolean(current.excludedFromAnalysis);
|
|
680
|
+
const nextChapterType = input.chapterType ?? String(current.chapterType);
|
|
681
|
+
const hasTextChange = nextTitle !== current.title || nextContent !== current.content;
|
|
682
|
+
const hasTypeChange = nextChapterType !== current.chapterType;
|
|
683
|
+
const hasOtherChange = nextExcluded !== current.excludedFromAnalysis || hasTypeChange;
|
|
684
|
+
if (!hasTextChange && !hasOtherChange)
|
|
685
|
+
return current;
|
|
686
|
+
const timestamp = now();
|
|
687
|
+
const versionNo = Number(current.versionNo) + (hasTextChange ? 1 : 0);
|
|
688
|
+
this.db.transaction(() => {
|
|
689
|
+
this.db.run(`UPDATE chapters SET title = ?, content = ?, chapter_type = ?, word_count = ?, version_no = ?, analysis_status = ?,
|
|
690
|
+
excluded_from_analysis = ?, updated_at = ? WHERE id = ?`, nextTitle, nextContent, nextChapterType, countWords(nextContent), versionNo, hasTextChange || hasTypeChange ? "expired" : String(current.analysisStatus), nextExcluded ? 1 : 0, timestamp, chapterId);
|
|
691
|
+
if (hasTextChange) {
|
|
692
|
+
this.insertChapterVersionRow({
|
|
693
|
+
workId: String(current.workId),
|
|
694
|
+
chapterId,
|
|
695
|
+
versionNo,
|
|
696
|
+
title: nextTitle,
|
|
697
|
+
content: nextContent,
|
|
698
|
+
volumeId: String(current.volumeId),
|
|
699
|
+
sortOrder: Number(current.sortOrder),
|
|
700
|
+
chapterType: nextChapterType,
|
|
701
|
+
source,
|
|
702
|
+
sourceRef,
|
|
703
|
+
changeNote: changeNote || "更新章节正文",
|
|
704
|
+
timestamp
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
if (hasTextChange || hasTypeChange)
|
|
708
|
+
this.invalidateChapter(String(current.workId), chapterId, versionNo);
|
|
709
|
+
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, String(current.workId));
|
|
710
|
+
this.audit(String(current.workId), "chapter.saved", "chapter", chapterId, { versionNo, source, chapterType: nextChapterType, changeNote });
|
|
711
|
+
});
|
|
712
|
+
return this.getChapter(chapterId);
|
|
713
|
+
}
|
|
714
|
+
restoreChapter(chapterId, versionNo) {
|
|
715
|
+
const version = this.db.get("SELECT * FROM chapter_versions WHERE chapter_id = ? AND version_no = ?", chapterId, versionNo);
|
|
716
|
+
if (!version)
|
|
717
|
+
throw notFound("章节版本");
|
|
718
|
+
const existing = this.db.get("SELECT id FROM chapters WHERE id = ?", chapterId);
|
|
719
|
+
if (!existing) {
|
|
720
|
+
return this.recreateChapterFromVersion(chapterId, version);
|
|
721
|
+
}
|
|
722
|
+
return this.saveChapter(chapterId, { title: requiredString(version, "title"), content: requiredString(version, "content") }, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
|
|
723
|
+
}
|
|
724
|
+
recreateChapterFromVersion(chapterId, version) {
|
|
725
|
+
const workId = requiredString(version, "work_id");
|
|
726
|
+
const volumeId = optionalString(version, "volume_id");
|
|
727
|
+
if (!volumeId)
|
|
728
|
+
throw new AppError(400, "CHAPTER_RESTORE_INCOMPLETE", "历史版本缺少分卷信息,无法恢复已删除章节");
|
|
729
|
+
const volume = this.getVolume(volumeId);
|
|
730
|
+
if (volume.workId !== workId)
|
|
731
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
|
|
732
|
+
const title = requiredString(version, "title");
|
|
733
|
+
const content = requiredString(version, "content");
|
|
734
|
+
const chapterType = (optionalString(version, "chapter_type") ?? "正文");
|
|
735
|
+
const sortOrder = version.sort_order === null || version.sort_order === undefined
|
|
736
|
+
? numberValue(this.db.get("SELECT COALESCE(MAX(sort_order), -1) AS sort_order FROM chapters WHERE volume_id = ?", volumeId) ?? {}, "sort_order") + 1
|
|
737
|
+
: numberValue(version, "sort_order");
|
|
738
|
+
const timestamp = now();
|
|
739
|
+
const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM chapter_versions WHERE chapter_id = ?", chapterId) ?? {}, "version_no") + 1;
|
|
740
|
+
this.db.transaction(() => {
|
|
741
|
+
this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
|
|
742
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, chapterId, workId, volumeId, title, content, chapterType, sortOrder, countWords(content), nextVersionNo, timestamp, timestamp);
|
|
743
|
+
this.insertChapterVersionRow({
|
|
744
|
+
workId,
|
|
745
|
+
chapterId,
|
|
746
|
+
versionNo: nextVersionNo,
|
|
747
|
+
title,
|
|
748
|
+
content,
|
|
749
|
+
volumeId,
|
|
750
|
+
sortOrder,
|
|
751
|
+
chapterType,
|
|
752
|
+
source: "restore",
|
|
753
|
+
sourceRef: requiredString(version, "id"),
|
|
754
|
+
changeNote: `恢复至 v${numberValue(version, "version_no")}`,
|
|
755
|
+
timestamp
|
|
756
|
+
});
|
|
757
|
+
this.db.run("UPDATE works SET updated_at = ? WHERE id = ?", timestamp, workId);
|
|
758
|
+
this.audit(workId, "chapter.restored", "chapter", chapterId, { versionNo: nextVersionNo, fromVersion: numberValue(version, "version_no") });
|
|
759
|
+
});
|
|
760
|
+
return this.getChapter(chapterId);
|
|
761
|
+
}
|
|
762
|
+
moveChapter(chapterId, input) {
|
|
763
|
+
const chapter = this.getChapter(chapterId);
|
|
764
|
+
const volume = this.getVolume(input.volumeId);
|
|
765
|
+
if (volume.workId !== chapter.workId)
|
|
766
|
+
throw new AppError(400, "VOLUME_WORK_MISMATCH", "卷不属于当前作品");
|
|
767
|
+
this.db.transaction(() => {
|
|
768
|
+
this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
|
|
769
|
+
WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
|
|
770
|
+
AND json_extract(scope_json, '$.type') = 'volume' AND json_extract(scope_json, '$.volumeId') = ?`, now(), String(chapter.workId), String(chapter.volumeId));
|
|
771
|
+
this.db.run("UPDATE chapters SET volume_id = ?, sort_order = ?, analysis_status = 'expired', updated_at = ? WHERE id = ?", input.volumeId, input.sortOrder, now(), chapterId);
|
|
772
|
+
this.invalidateChapter(String(chapter.workId), chapterId, Number(chapter.versionNo));
|
|
773
|
+
this.audit(String(chapter.workId), "chapter.moved", "chapter", chapterId, input);
|
|
774
|
+
});
|
|
775
|
+
return this.getChapter(chapterId);
|
|
776
|
+
}
|
|
777
|
+
deleteChapter(chapterId) {
|
|
778
|
+
const chapter = this.getChapter(chapterId);
|
|
779
|
+
const timestamp = now();
|
|
780
|
+
const versionNo = Number(chapter.versionNo) + 1;
|
|
781
|
+
this.db.transaction(() => {
|
|
782
|
+
this.db.run("UPDATE chapters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, chapterId);
|
|
783
|
+
this.insertChapterVersionRow({
|
|
784
|
+
workId: String(chapter.workId),
|
|
785
|
+
chapterId,
|
|
786
|
+
versionNo,
|
|
787
|
+
title: String(chapter.title),
|
|
788
|
+
content: String(chapter.content),
|
|
789
|
+
volumeId: String(chapter.volumeId),
|
|
790
|
+
sortOrder: Number(chapter.sortOrder),
|
|
791
|
+
chapterType: String(chapter.chapterType),
|
|
792
|
+
source: "delete",
|
|
793
|
+
sourceRef: null,
|
|
794
|
+
changeNote: "删除章节",
|
|
795
|
+
timestamp
|
|
796
|
+
});
|
|
797
|
+
this.db.run("DELETE FROM chapters WHERE id = ?", chapterId);
|
|
798
|
+
this.audit(String(chapter.workId), "chapter.deleted", "chapter", chapterId, { versionNo });
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
insertChapter(workId, volumeId, title, content, sortOrder, source, sourceRef, chapterType = "正文") {
|
|
802
|
+
const chapterId = id("chapter");
|
|
803
|
+
const timestamp = now();
|
|
804
|
+
const normalizedContent = normalizeParagraphSpacing(content);
|
|
805
|
+
this.db.run(`INSERT INTO chapters (id, work_id, volume_id, title, content, chapter_type, sort_order, word_count, version_no, analysis_status, created_at, updated_at)
|
|
806
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'pending', ?, ?)`, chapterId, workId, volumeId, title, normalizedContent, chapterType, sortOrder, countWords(normalizedContent), timestamp, timestamp);
|
|
807
|
+
this.insertChapterVersionRow({
|
|
808
|
+
workId,
|
|
809
|
+
chapterId,
|
|
810
|
+
versionNo: 1,
|
|
811
|
+
title,
|
|
812
|
+
content: normalizedContent,
|
|
813
|
+
volumeId,
|
|
814
|
+
sortOrder,
|
|
815
|
+
chapterType,
|
|
816
|
+
source,
|
|
817
|
+
sourceRef,
|
|
818
|
+
changeNote: source === "import" ? "导入章节" : "建立章节",
|
|
819
|
+
timestamp
|
|
820
|
+
});
|
|
821
|
+
return chapterId;
|
|
822
|
+
}
|
|
823
|
+
invalidateChapter(workId, chapterId, versionNo) {
|
|
824
|
+
this.db.run(`UPDATE analysis_tasks SET status = 'expired', updated_at = ?
|
|
825
|
+
WHERE work_id = ? AND status IN ('pending', 'running', 'completed', 'partial', 'review')
|
|
826
|
+
AND NOT (status = 'pending' AND task_type = 'chapter-analysis'
|
|
827
|
+
AND json_extract(scope_json, '$.chapterId') = ?)
|
|
828
|
+
AND (json_extract(scope_json, '$.chapterId') = ?
|
|
829
|
+
OR json_extract(scope_json, '$.type') = 'book'
|
|
830
|
+
OR (json_extract(scope_json, '$.type') = 'volume'
|
|
831
|
+
AND json_extract(scope_json, '$.volumeId') = (SELECT volume_id FROM chapters WHERE id = ?)))`, now(), workId, chapterId, chapterId, chapterId);
|
|
832
|
+
const existing = this.db.get(`SELECT id FROM analysis_tasks WHERE work_id = ? AND task_type = 'chapter-analysis' AND status = 'pending'
|
|
833
|
+
AND json_extract(scope_json, '$.chapterId') = ?`, workId, chapterId);
|
|
834
|
+
if (!existing) {
|
|
835
|
+
const timestamp = now();
|
|
836
|
+
this.db.run(`INSERT INTO analysis_tasks (id, work_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id)
|
|
837
|
+
VALUES (?, ?, 'chapter-analysis', ?, 'pending', ?, ?, ?, ?)`, id("task"), workId, JSON.stringify({ type: "chapter", chapterId }), JSON.stringify({ [chapterId]: versionNo }), timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
this.db.run("UPDATE analysis_tasks SET source_versions_json = ?, updated_at = ? WHERE id = ?", JSON.stringify({ [chapterId]: versionNo }), now(), requiredString(existing, "id"));
|
|
841
|
+
}
|
|
842
|
+
this.notifyAnalysisTaskQueued(workId);
|
|
843
|
+
}
|
|
844
|
+
mapWork(row) {
|
|
845
|
+
const actor = currentRequestActor();
|
|
846
|
+
const ownerUserId = optionalString(row, "owner_user_id");
|
|
847
|
+
const membership = actor
|
|
848
|
+
? this.db.get("SELECT role FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
|
|
849
|
+
: undefined;
|
|
850
|
+
const accessRole = ownerUserId === actor?.userId
|
|
851
|
+
? "owner"
|
|
852
|
+
: actor?.role === "admin"
|
|
853
|
+
? "admin"
|
|
854
|
+
: String(membership?.role ?? "") === "editor" ? "editor" : null;
|
|
855
|
+
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"));
|
|
856
|
+
const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
|
|
857
|
+
return {
|
|
858
|
+
id: requiredString(row, "id"),
|
|
859
|
+
title: requiredString(row, "title"),
|
|
860
|
+
author: requiredString(row, "author"),
|
|
861
|
+
description: requiredString(row, "description"),
|
|
862
|
+
language: requiredString(row, "language"),
|
|
863
|
+
coverUrl: cover
|
|
864
|
+
? `/api/works/${encodeURIComponent(requiredString(row, "id"))}/cover?v=${encodeURIComponent(requiredString(cover, "updated_at"))}`
|
|
865
|
+
: optionalString(row, "cover_url"),
|
|
866
|
+
tags: json(requiredString(row, "tags_json"), []),
|
|
867
|
+
ownerUserId,
|
|
868
|
+
accessRole,
|
|
869
|
+
chapterCount: numberValue(count ?? {}, "chapter_count"),
|
|
870
|
+
wordCount: numberValue(count ?? {}, "word_count"),
|
|
871
|
+
createdAt: requiredString(row, "created_at"),
|
|
872
|
+
updatedAt: requiredString(row, "updated_at")
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
mapVolume(row) {
|
|
876
|
+
return {
|
|
877
|
+
id: requiredString(row, "id"),
|
|
878
|
+
workId: requiredString(row, "work_id"),
|
|
879
|
+
title: requiredString(row, "title"),
|
|
880
|
+
kind: requiredString(row, "kind"),
|
|
881
|
+
source: requiredString(row, "source"),
|
|
882
|
+
description: optionalString(row, "description") ?? "",
|
|
883
|
+
keywords: json(optionalString(row, "keywords_json"), []),
|
|
884
|
+
sortOrder: numberValue(row, "sort_order"),
|
|
885
|
+
createdAt: requiredString(row, "created_at"),
|
|
886
|
+
updatedAt: requiredString(row, "updated_at")
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
normalizeVolumeKeywords(keywords) {
|
|
890
|
+
return [...new Set(keywords.map((keyword) => keyword.normalize("NFKC").trim()).filter(Boolean))].slice(0, 100);
|
|
891
|
+
}
|
|
892
|
+
mapChapter(row) {
|
|
893
|
+
return {
|
|
894
|
+
id: requiredString(row, "id"),
|
|
895
|
+
workId: requiredString(row, "work_id"),
|
|
896
|
+
volumeId: requiredString(row, "volume_id"),
|
|
897
|
+
title: requiredString(row, "title"),
|
|
898
|
+
content: requiredString(row, "content"),
|
|
899
|
+
chapterType: requiredString(row, "chapter_type") || "正文",
|
|
900
|
+
sortOrder: numberValue(row, "sort_order"),
|
|
901
|
+
wordCount: numberValue(row, "word_count"),
|
|
902
|
+
versionNo: numberValue(row, "version_no"),
|
|
903
|
+
analysisStatus: requiredString(row, "analysis_status"),
|
|
904
|
+
excludedFromAnalysis: booleanValue(row, "excluded_from_analysis"),
|
|
905
|
+
createdAt: requiredString(row, "created_at"),
|
|
906
|
+
updatedAt: requiredString(row, "updated_at")
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
getChapterOutline(chapterId) {
|
|
910
|
+
const chapter = this.getChapter(chapterId);
|
|
911
|
+
const row = this.db.get("SELECT * FROM chapter_outlines WHERE chapter_id = ?", chapterId);
|
|
912
|
+
if (!row)
|
|
913
|
+
return null;
|
|
914
|
+
return this.mapChapterOutline(row, chapter);
|
|
915
|
+
}
|
|
916
|
+
listChapterOutlines(workId) {
|
|
917
|
+
this.getWork(workId);
|
|
918
|
+
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,
|
|
919
|
+
v.title AS volume_title, v.sort_order AS volume_order,
|
|
920
|
+
o.goal, o.conflict, o.turning_point, o.notes, o.status, o.created_at, o.updated_at,
|
|
921
|
+
(SELECT COUNT(DISTINCT fo.foreshadow_id) FROM foreshadow_occurrences fo
|
|
922
|
+
JOIN foreshadows f ON f.id = fo.foreshadow_id
|
|
923
|
+
WHERE fo.chapter_id = c.id AND f.status IN ('planned', 'planted')) AS unresolved_count
|
|
924
|
+
FROM chapters c
|
|
925
|
+
JOIN volumes v ON v.id = c.volume_id
|
|
926
|
+
LEFT JOIN chapter_outlines o ON o.chapter_id = c.id
|
|
927
|
+
WHERE c.work_id = ?
|
|
928
|
+
ORDER BY v.sort_order, c.sort_order, c.created_at`, workId);
|
|
929
|
+
return rows.map((row) => ({
|
|
930
|
+
chapterId: requiredString(row, "chapter_id"),
|
|
931
|
+
chapterTitle: requiredString(row, "chapter_title"),
|
|
932
|
+
volumeId: requiredString(row, "volume_id"),
|
|
933
|
+
volumeTitle: requiredString(row, "volume_title"),
|
|
934
|
+
goal: optionalString(row, "goal") ?? "",
|
|
935
|
+
conflict: optionalString(row, "conflict") ?? "",
|
|
936
|
+
turningPoint: optionalString(row, "turning_point") ?? "",
|
|
937
|
+
notes: optionalString(row, "notes") ?? "",
|
|
938
|
+
status: optionalString(row, "status") ?? "draft",
|
|
939
|
+
unresolvedForeshadowCount: numberValue(row, "unresolved_count"),
|
|
940
|
+
createdAt: optionalString(row, "created_at"),
|
|
941
|
+
updatedAt: optionalString(row, "updated_at")
|
|
942
|
+
}));
|
|
943
|
+
}
|
|
944
|
+
upsertChapterOutline(chapterId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
945
|
+
const chapter = this.getChapter(chapterId);
|
|
946
|
+
const current = this.getChapterOutline(chapterId);
|
|
947
|
+
const timestamp = now();
|
|
948
|
+
this.db.transaction(() => {
|
|
949
|
+
this.db.run(`INSERT INTO chapter_outlines (chapter_id, goal, conflict, turning_point, notes, status, created_at, updated_at)
|
|
950
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
951
|
+
ON CONFLICT(chapter_id) DO UPDATE SET goal = excluded.goal, conflict = excluded.conflict,
|
|
952
|
+
turning_point = excluded.turning_point, notes = excluded.notes, status = excluded.status,
|
|
953
|
+
updated_at = excluded.updated_at`, chapterId, input.goal ?? String(current?.goal ?? ""), input.conflict ?? String(current?.conflict ?? ""), input.turningPoint ?? String(current?.turningPoint ?? ""), input.notes ?? String(current?.notes ?? ""), input.status ?? String(current?.status ?? "draft"), timestamp, timestamp);
|
|
954
|
+
this.recordEntityVersion("chapter-outline", chapterId, current ? source : "create", sourceRef, changeNote || (current ? "更新章节大纲" : "建立章节大纲"), timestamp);
|
|
955
|
+
this.audit(String(chapter.workId), current ? "outline.updated" : "outline.created", "chapter-outline", chapterId, { fields: Object.keys(input), source, sourceRef });
|
|
956
|
+
});
|
|
957
|
+
return this.getChapterOutline(chapterId);
|
|
958
|
+
}
|
|
959
|
+
deleteChapterOutline(chapterId) {
|
|
960
|
+
const chapter = this.getChapter(chapterId);
|
|
961
|
+
const outline = this.getChapterOutline(chapterId);
|
|
962
|
+
if (!outline)
|
|
963
|
+
return;
|
|
964
|
+
this.db.transaction(() => {
|
|
965
|
+
this.recordEntityVersion("chapter-outline", chapterId, "delete", null, "删除章节大纲");
|
|
966
|
+
this.db.run("DELETE FROM chapter_outlines WHERE chapter_id = ?", chapterId);
|
|
967
|
+
this.audit(String(chapter.workId), "outline.deleted", "chapter-outline", chapterId);
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
mapChapterOutline(row, chapter) {
|
|
971
|
+
return {
|
|
972
|
+
chapterId: requiredString(row, "chapter_id"),
|
|
973
|
+
workId: chapter.workId,
|
|
974
|
+
chapterTitle: chapter.title,
|
|
975
|
+
volumeId: chapter.volumeId,
|
|
976
|
+
goal: requiredString(row, "goal"),
|
|
977
|
+
conflict: requiredString(row, "conflict"),
|
|
978
|
+
turningPoint: requiredString(row, "turning_point"),
|
|
979
|
+
notes: requiredString(row, "notes"),
|
|
980
|
+
status: requiredString(row, "status"),
|
|
981
|
+
createdAt: requiredString(row, "created_at"),
|
|
982
|
+
updatedAt: requiredString(row, "updated_at")
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
createForeshadow(workId, input) {
|
|
986
|
+
this.getWork(workId);
|
|
987
|
+
if (input.plannedPayoffChapterId)
|
|
988
|
+
this.assertChapterInWork(input.plannedPayoffChapterId, workId);
|
|
989
|
+
return this.insertForeshadowWithId(workId, id("foreshadow"), input, "create", null);
|
|
990
|
+
}
|
|
991
|
+
insertForeshadowWithId(workId, foreshadowId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
992
|
+
if (input.plannedPayoffChapterId)
|
|
993
|
+
this.assertChapterInWork(input.plannedPayoffChapterId, workId);
|
|
994
|
+
const timestamp = now();
|
|
995
|
+
this.db.transaction(() => {
|
|
996
|
+
this.db.run(`INSERT INTO foreshadows (id, work_id, title, description, status, importance,
|
|
997
|
+
planned_payoff_chapter_id, resolution_note, created_at, updated_at)
|
|
998
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, foreshadowId, workId, input.title, input.description ?? "", input.status ?? "planned", input.importance ?? "medium", input.plannedPayoffChapterId ?? null, input.resolutionNote ?? "", timestamp, timestamp);
|
|
999
|
+
for (const occurrence of input.occurrences ?? [])
|
|
1000
|
+
this.insertForeshadowOccurrence(foreshadowId, workId, occurrence);
|
|
1001
|
+
this.recordEntityVersion("foreshadow", foreshadowId, source, sourceRef, changeNote || "建立伏笔", timestamp);
|
|
1002
|
+
this.audit(workId, source === "restore" ? "foreshadow.restored" : "foreshadow.created", "foreshadow", foreshadowId);
|
|
1003
|
+
});
|
|
1004
|
+
return this.getForeshadow(foreshadowId);
|
|
1005
|
+
}
|
|
1006
|
+
getForeshadow(foreshadowId, currentChapterId) {
|
|
1007
|
+
const row = this.db.get("SELECT * FROM foreshadows WHERE id = ?", foreshadowId);
|
|
1008
|
+
if (!row)
|
|
1009
|
+
throw notFound("伏笔");
|
|
1010
|
+
const workId = requiredString(row, "work_id");
|
|
1011
|
+
if (currentChapterId)
|
|
1012
|
+
this.assertChapterInWork(currentChapterId, workId);
|
|
1013
|
+
const occurrences = this.db.all(`SELECT fo.*, c.title AS chapter_title, c.volume_id, c.sort_order AS chapter_order,
|
|
1014
|
+
v.title AS volume_title, v.sort_order AS volume_order
|
|
1015
|
+
FROM foreshadow_occurrences fo
|
|
1016
|
+
JOIN chapters c ON c.id = fo.chapter_id
|
|
1017
|
+
JOIN volumes v ON v.id = c.volume_id
|
|
1018
|
+
WHERE fo.foreshadow_id = ? ORDER BY v.sort_order, c.sort_order, fo.created_at`, foreshadowId).map((item) => this.mapForeshadowOccurrence(item));
|
|
1019
|
+
const status = requiredString(row, "status");
|
|
1020
|
+
const plannedPayoffChapterId = optionalString(row, "planned_payoff_chapter_id");
|
|
1021
|
+
return {
|
|
1022
|
+
id: requiredString(row, "id"),
|
|
1023
|
+
workId,
|
|
1024
|
+
title: requiredString(row, "title"),
|
|
1025
|
+
description: requiredString(row, "description"),
|
|
1026
|
+
status,
|
|
1027
|
+
importance: requiredString(row, "importance"),
|
|
1028
|
+
plannedPayoffChapterId,
|
|
1029
|
+
resolutionNote: requiredString(row, "resolution_note"),
|
|
1030
|
+
unresolved: status === "planned" || status === "planted",
|
|
1031
|
+
overdue: Boolean(currentChapterId && plannedPayoffChapterId && ["planned", "planted"].includes(status)
|
|
1032
|
+
&& this.chapterSequence(workId, plannedPayoffChapterId) < this.chapterSequence(workId, currentChapterId)),
|
|
1033
|
+
occurrences,
|
|
1034
|
+
createdAt: requiredString(row, "created_at"),
|
|
1035
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
listForeshadows(workId, status = "all", currentChapterId) {
|
|
1039
|
+
this.getWork(workId);
|
|
1040
|
+
if (currentChapterId)
|
|
1041
|
+
this.assertChapterInWork(currentChapterId, workId);
|
|
1042
|
+
const where = status === "unresolved"
|
|
1043
|
+
? "AND status IN ('planned', 'planted')"
|
|
1044
|
+
: status === "resolved" ? "AND status IN ('resolved', 'abandoned')" : "";
|
|
1045
|
+
return this.db.all(`SELECT id FROM foreshadows WHERE work_id = ? ${where}
|
|
1046
|
+
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));
|
|
1047
|
+
}
|
|
1048
|
+
updateForeshadow(foreshadowId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1049
|
+
const current = this.getForeshadow(foreshadowId);
|
|
1050
|
+
const workId = String(current.workId);
|
|
1051
|
+
if (input.plannedPayoffChapterId)
|
|
1052
|
+
this.assertChapterInWork(input.plannedPayoffChapterId, workId);
|
|
1053
|
+
this.db.transaction(() => {
|
|
1054
|
+
this.db.run(`UPDATE foreshadows SET title = ?, description = ?, status = ?, importance = ?,
|
|
1055
|
+
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);
|
|
1056
|
+
if (input.occurrences) {
|
|
1057
|
+
this.db.run("DELETE FROM foreshadow_occurrences WHERE foreshadow_id = ?", foreshadowId);
|
|
1058
|
+
for (const occurrence of input.occurrences)
|
|
1059
|
+
this.insertForeshadowOccurrence(foreshadowId, workId, occurrence);
|
|
1060
|
+
}
|
|
1061
|
+
this.recordEntityVersion("foreshadow", foreshadowId, source, sourceRef, changeNote || "更新伏笔");
|
|
1062
|
+
this.audit(workId, "foreshadow.updated", "foreshadow", foreshadowId, { fields: Object.keys(input), source, sourceRef });
|
|
1063
|
+
});
|
|
1064
|
+
return this.getForeshadow(foreshadowId);
|
|
1065
|
+
}
|
|
1066
|
+
deleteForeshadow(foreshadowId) {
|
|
1067
|
+
const current = this.getForeshadow(foreshadowId);
|
|
1068
|
+
this.db.transaction(() => {
|
|
1069
|
+
this.recordEntityVersion("foreshadow", foreshadowId, "delete", null, "删除伏笔");
|
|
1070
|
+
this.db.run("DELETE FROM foreshadows WHERE id = ?", foreshadowId);
|
|
1071
|
+
this.audit(String(current.workId), "foreshadow.deleted", "foreshadow", foreshadowId);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
createForeshadowOccurrence(foreshadowId, input) {
|
|
1075
|
+
const foreshadow = this.getForeshadow(foreshadowId);
|
|
1076
|
+
const occurrenceId = this.db.transaction(() => {
|
|
1077
|
+
const createdId = this.insertForeshadowOccurrence(foreshadowId, String(foreshadow.workId), input);
|
|
1078
|
+
this.recordEntityVersion("foreshadow", foreshadowId, "manual", createdId, "添加伏笔章节记录");
|
|
1079
|
+
this.audit(String(foreshadow.workId), "foreshadow.occurrence.created", "foreshadow-occurrence", createdId);
|
|
1080
|
+
return createdId;
|
|
1081
|
+
});
|
|
1082
|
+
return this.getForeshadowOccurrence(occurrenceId);
|
|
1083
|
+
}
|
|
1084
|
+
updateForeshadowOccurrence(occurrenceId, input) {
|
|
1085
|
+
const current = this.getForeshadowOccurrence(occurrenceId);
|
|
1086
|
+
const foreshadow = this.getForeshadow(String(current.foreshadowId));
|
|
1087
|
+
const chapterId = input.chapterId ?? String(current.chapterId);
|
|
1088
|
+
this.assertChapterInWork(chapterId, String(foreshadow.workId));
|
|
1089
|
+
this.db.transaction(() => {
|
|
1090
|
+
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);
|
|
1091
|
+
this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "更新伏笔章节记录");
|
|
1092
|
+
});
|
|
1093
|
+
return this.getForeshadowOccurrence(occurrenceId);
|
|
1094
|
+
}
|
|
1095
|
+
deleteForeshadowOccurrence(occurrenceId) {
|
|
1096
|
+
const current = this.getForeshadowOccurrence(occurrenceId);
|
|
1097
|
+
this.db.transaction(() => {
|
|
1098
|
+
this.db.run("DELETE FROM foreshadow_occurrences WHERE id = ?", occurrenceId);
|
|
1099
|
+
this.recordEntityVersion("foreshadow", String(current.foreshadowId), "manual", occurrenceId, "删除伏笔章节记录");
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
insertForeshadowOccurrence(foreshadowId, workId, input) {
|
|
1103
|
+
this.assertChapterInWork(input.chapterId, workId);
|
|
1104
|
+
const occurrenceId = id("foreshadowOccurrence");
|
|
1105
|
+
const timestamp = now();
|
|
1106
|
+
this.db.run(`INSERT INTO foreshadow_occurrences (id, foreshadow_id, chapter_id, role, note, evidence_json, created_at, updated_at)
|
|
1107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, occurrenceId, foreshadowId, input.chapterId, input.role, input.note ?? "", JSON.stringify(input.evidence ?? []), timestamp, timestamp);
|
|
1108
|
+
return occurrenceId;
|
|
1109
|
+
}
|
|
1110
|
+
getForeshadowOccurrence(occurrenceId) {
|
|
1111
|
+
const row = this.db.get(`SELECT fo.*, c.title AS chapter_title, c.volume_id, v.title AS volume_title
|
|
1112
|
+
FROM foreshadow_occurrences fo JOIN chapters c ON c.id = fo.chapter_id
|
|
1113
|
+
JOIN volumes v ON v.id = c.volume_id WHERE fo.id = ?`, occurrenceId);
|
|
1114
|
+
if (!row)
|
|
1115
|
+
throw notFound("伏笔章节记录");
|
|
1116
|
+
return this.mapForeshadowOccurrence(row);
|
|
1117
|
+
}
|
|
1118
|
+
mapForeshadowOccurrence(row) {
|
|
1119
|
+
return {
|
|
1120
|
+
id: requiredString(row, "id"),
|
|
1121
|
+
foreshadowId: requiredString(row, "foreshadow_id"),
|
|
1122
|
+
chapterId: requiredString(row, "chapter_id"),
|
|
1123
|
+
chapterTitle: requiredString(row, "chapter_title"),
|
|
1124
|
+
volumeId: requiredString(row, "volume_id"),
|
|
1125
|
+
volumeTitle: requiredString(row, "volume_title"),
|
|
1126
|
+
role: requiredString(row, "role"),
|
|
1127
|
+
note: requiredString(row, "note"),
|
|
1128
|
+
evidence: json(requiredString(row, "evidence_json"), []),
|
|
1129
|
+
createdAt: requiredString(row, "created_at"),
|
|
1130
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
assertChapterInWork(chapterId, workId) {
|
|
1134
|
+
const chapter = this.getChapter(chapterId);
|
|
1135
|
+
if (chapter.workId !== workId)
|
|
1136
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
1137
|
+
}
|
|
1138
|
+
chapterSequence(workId, chapterId) {
|
|
1139
|
+
const row = this.db.get(`SELECT v.sort_order * 1000000 + c.sort_order AS sequence
|
|
1140
|
+
FROM chapters c JOIN volumes v ON v.id = c.volume_id WHERE c.id = ? AND c.work_id = ?`, chapterId, workId);
|
|
1141
|
+
return row ? numberValue(row, "sequence") : Number.MAX_SAFE_INTEGER;
|
|
1142
|
+
}
|
|
1143
|
+
createSetting(workId, input, source = "create", sourceRef = null) {
|
|
1144
|
+
this.getWork(workId);
|
|
1145
|
+
return this.insertSettingWithId(workId, id("setting"), input, source, sourceRef);
|
|
1146
|
+
}
|
|
1147
|
+
insertSettingWithId(workId, settingId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
1148
|
+
const timestamp = now();
|
|
1149
|
+
this.db.transaction(() => {
|
|
1150
|
+
this.db.run(`INSERT INTO settings (id, work_id, title, category, content, tags_json, status, locked, evidence_json, scope_json, author_note, created_at, updated_at)
|
|
1151
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, settingId, workId, input.title, input.category, input.content, JSON.stringify(input.tags ?? []), input.status ?? "draft", input.locked ? 1 : 0, JSON.stringify(input.evidence ?? []), JSON.stringify(input.scope ?? {}), input.authorNote ?? "", timestamp, timestamp);
|
|
1152
|
+
this.recordEntityVersion("setting", settingId, source, sourceRef, changeNote || "建立世界观设定", timestamp);
|
|
1153
|
+
this.audit(workId, source === "restore" ? "setting.restored" : "setting.created", "setting", settingId, {
|
|
1154
|
+
locked: input.locked ?? false,
|
|
1155
|
+
source,
|
|
1156
|
+
sourceRef
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
return this.getSetting(settingId);
|
|
1160
|
+
}
|
|
1161
|
+
listSettings(workId) {
|
|
1162
|
+
this.getWork(workId);
|
|
1163
|
+
return this.db.all("SELECT * FROM settings WHERE work_id = ? ORDER BY locked DESC, category, title", workId).map((row) => this.mapSetting(row));
|
|
1164
|
+
}
|
|
1165
|
+
getSetting(settingId) {
|
|
1166
|
+
const row = this.db.get("SELECT * FROM settings WHERE id = ?", settingId);
|
|
1167
|
+
if (!row)
|
|
1168
|
+
throw notFound("设定");
|
|
1169
|
+
return this.mapSetting(row);
|
|
1170
|
+
}
|
|
1171
|
+
updateSetting(settingId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1172
|
+
const current = this.getSetting(settingId);
|
|
1173
|
+
this.db.transaction(() => {
|
|
1174
|
+
this.db.run(`UPDATE settings SET title = ?, category = ?, content = ?, tags_json = ?, status = ?, locked = ?,
|
|
1175
|
+
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);
|
|
1176
|
+
this.recordEntityVersion("setting", settingId, source, sourceRef, changeNote || "更新世界观设定");
|
|
1177
|
+
this.audit(String(current.workId), "setting.updated", "setting", settingId, { fields: Object.keys(input), source, sourceRef });
|
|
1178
|
+
});
|
|
1179
|
+
return this.getSetting(settingId);
|
|
1180
|
+
}
|
|
1181
|
+
deleteSetting(settingId) {
|
|
1182
|
+
const current = this.getSetting(settingId);
|
|
1183
|
+
this.db.transaction(() => {
|
|
1184
|
+
this.recordEntityVersion("setting", settingId, "delete", null, "删除世界观设定");
|
|
1185
|
+
this.db.run("DELETE FROM settings WHERE id = ?", settingId);
|
|
1186
|
+
this.audit(String(current.workId), "setting.deleted", "setting", settingId);
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
mapSetting(row) {
|
|
1190
|
+
return {
|
|
1191
|
+
id: requiredString(row, "id"),
|
|
1192
|
+
workId: requiredString(row, "work_id"),
|
|
1193
|
+
title: requiredString(row, "title"),
|
|
1194
|
+
category: requiredString(row, "category"),
|
|
1195
|
+
content: requiredString(row, "content"),
|
|
1196
|
+
tags: json(requiredString(row, "tags_json"), []),
|
|
1197
|
+
status: requiredString(row, "status"),
|
|
1198
|
+
locked: booleanValue(row, "locked"),
|
|
1199
|
+
evidence: json(requiredString(row, "evidence_json"), []),
|
|
1200
|
+
scope: json(requiredString(row, "scope_json"), {}),
|
|
1201
|
+
authorNote: requiredString(row, "author_note"),
|
|
1202
|
+
createdAt: requiredString(row, "created_at"),
|
|
1203
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
createRace(workId, input) {
|
|
1207
|
+
return this.insertRaceWithId(workId, id("race"), input, "create", null);
|
|
1208
|
+
}
|
|
1209
|
+
insertRaceWithId(workId, raceId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
1210
|
+
this.getWork(workId);
|
|
1211
|
+
const name = input.name.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
1212
|
+
const normalizedName = normalizeCharacterName(name);
|
|
1213
|
+
if (!normalizedName)
|
|
1214
|
+
throw new AppError(400, "RACE_NAME_REQUIRED", "种族名称不能为空");
|
|
1215
|
+
this.assertRaceNameAvailable(workId, normalizedName);
|
|
1216
|
+
const memberIds = [...new Set(input.memberIds ?? [])];
|
|
1217
|
+
this.assertCharactersInWork(workId, memberIds);
|
|
1218
|
+
const memberSnapshots = this.captureCharacterSnapshots(memberIds);
|
|
1219
|
+
const timestamp = now();
|
|
1220
|
+
this.db.transaction(() => {
|
|
1221
|
+
this.db.run(`INSERT INTO races (id, work_id, name, normalized_name, description, settings_json, created_at, updated_at)
|
|
1222
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, raceId, workId, name, normalizedName, input.description ?? "", JSON.stringify(input.settings ?? []), timestamp, timestamp);
|
|
1223
|
+
this.replaceRaceMembers(raceId, name, memberIds);
|
|
1224
|
+
this.recordMembershipVersions(memberSnapshots, "race", raceId, `设为种族“${name}”`);
|
|
1225
|
+
this.recordEntityVersion("race", raceId, source, sourceRef, changeNote || "建立种族档案", timestamp);
|
|
1226
|
+
this.audit(workId, source === "restore" ? "race.restored" : "race.created", "race", raceId);
|
|
1227
|
+
});
|
|
1228
|
+
return this.getRace(raceId);
|
|
1229
|
+
}
|
|
1230
|
+
listRaces(workId) {
|
|
1231
|
+
this.getWork(workId);
|
|
1232
|
+
return this.db.all("SELECT * FROM races WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapRace(row));
|
|
1233
|
+
}
|
|
1234
|
+
getRace(raceId) {
|
|
1235
|
+
const row = this.db.get("SELECT * FROM races WHERE id = ?", raceId);
|
|
1236
|
+
if (!row)
|
|
1237
|
+
throw notFound("种族");
|
|
1238
|
+
return this.mapRace(row);
|
|
1239
|
+
}
|
|
1240
|
+
updateRace(raceId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1241
|
+
const current = this.getRace(raceId);
|
|
1242
|
+
const workId = String(current.workId);
|
|
1243
|
+
const name = input.name === undefined
|
|
1244
|
+
? String(current.name)
|
|
1245
|
+
: input.name.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
1246
|
+
const normalizedName = normalizeCharacterName(name);
|
|
1247
|
+
if (!normalizedName)
|
|
1248
|
+
throw new AppError(400, "RACE_NAME_REQUIRED", "种族名称不能为空");
|
|
1249
|
+
this.assertRaceNameAvailable(workId, normalizedName, raceId);
|
|
1250
|
+
const memberIds = input.memberIds === undefined ? null : [...new Set(input.memberIds)];
|
|
1251
|
+
if (memberIds)
|
|
1252
|
+
this.assertCharactersInWork(workId, memberIds);
|
|
1253
|
+
const nameChanged = name !== current.name;
|
|
1254
|
+
const touchedMemberIds = memberIds || nameChanged
|
|
1255
|
+
? [...new Set([...current.memberIds, ...(memberIds ?? [])])]
|
|
1256
|
+
: [];
|
|
1257
|
+
const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
|
|
1258
|
+
this.db.transaction(() => {
|
|
1259
|
+
this.db.run(`UPDATE races 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(), raceId);
|
|
1260
|
+
if (nameChanged)
|
|
1261
|
+
this.db.run("UPDATE characters SET species = ?, updated_at = ? WHERE race_id = ?", name, now(), raceId);
|
|
1262
|
+
if (memberIds)
|
|
1263
|
+
this.replaceRaceMembers(raceId, name, memberIds);
|
|
1264
|
+
this.recordMembershipVersions(memberSnapshots, "race", raceId, nameChanged ? `种族更名为“${name}”` : `种族“${name}”成员关系变更`);
|
|
1265
|
+
this.recordEntityVersion("race", raceId, source, sourceRef, changeNote || "更新种族档案");
|
|
1266
|
+
this.audit(workId, "race.updated", "race", raceId, { fields: Object.keys(input), source, sourceRef });
|
|
1267
|
+
});
|
|
1268
|
+
return this.getRace(raceId);
|
|
1269
|
+
}
|
|
1270
|
+
deleteRace(raceId) {
|
|
1271
|
+
const current = this.getRace(raceId);
|
|
1272
|
+
const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
|
|
1273
|
+
this.db.transaction(() => {
|
|
1274
|
+
this.recordEntityVersion("race", raceId, "delete", null, "删除种族档案");
|
|
1275
|
+
this.db.run("UPDATE characters SET race_id = NULL, species = '', updated_at = ? WHERE race_id = ?", now(), raceId);
|
|
1276
|
+
this.db.run("DELETE FROM races WHERE id = ?", raceId);
|
|
1277
|
+
this.recordMembershipVersions(memberSnapshots, "race", raceId, `种族“${String(current.name)}”已删除`);
|
|
1278
|
+
this.audit(String(current.workId), "race.deleted", "race", raceId);
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
resolveRaceReference(workId, value) {
|
|
1282
|
+
const normalizedName = normalizeCharacterName(value);
|
|
1283
|
+
if (!normalizedName)
|
|
1284
|
+
return null;
|
|
1285
|
+
const row = this.db.get("SELECT id FROM races WHERE work_id = ? AND normalized_name = ?", workId, normalizedName);
|
|
1286
|
+
return row ? requiredString(row, "id") : null;
|
|
1287
|
+
}
|
|
1288
|
+
mapRace(row) {
|
|
1289
|
+
const members = this.db.all("SELECT id, name FROM characters WHERE race_id = ? ORDER BY name", requiredString(row, "id")).map((member) => ({
|
|
1290
|
+
characterId: requiredString(member, "id"),
|
|
1291
|
+
name: requiredString(member, "name")
|
|
1292
|
+
}));
|
|
1293
|
+
return {
|
|
1294
|
+
id: requiredString(row, "id"),
|
|
1295
|
+
workId: requiredString(row, "work_id"),
|
|
1296
|
+
name: requiredString(row, "name"),
|
|
1297
|
+
description: requiredString(row, "description"),
|
|
1298
|
+
settings: json(requiredString(row, "settings_json"), []),
|
|
1299
|
+
memberIds: members.map((member) => member.characterId),
|
|
1300
|
+
members,
|
|
1301
|
+
createdAt: requiredString(row, "created_at"),
|
|
1302
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
assertRaceNameAvailable(workId, normalizedName, excludeRaceId) {
|
|
1306
|
+
const row = this.db.get(`SELECT id FROM races WHERE work_id = ? AND normalized_name = ?${excludeRaceId ? " AND id <> ?" : ""}`, ...([workId, normalizedName, ...(excludeRaceId ? [excludeRaceId] : [])]));
|
|
1307
|
+
if (row)
|
|
1308
|
+
throw new AppError(409, "RACE_NAME_CONFLICT", "同一作品内的种族名称不能重复", { raceId: requiredString(row, "id") });
|
|
1309
|
+
}
|
|
1310
|
+
assertRaceInWork(workId, raceId) {
|
|
1311
|
+
const race = this.getRace(raceId);
|
|
1312
|
+
if (race.workId !== workId)
|
|
1313
|
+
throw new AppError(400, "RACE_WORK_MISMATCH", "角色绑定的种族不属于当前作品");
|
|
1314
|
+
return race;
|
|
1315
|
+
}
|
|
1316
|
+
replaceRaceMembers(raceId, raceName, memberIds) {
|
|
1317
|
+
const timestamp = now();
|
|
1318
|
+
this.db.run("UPDATE characters SET race_id = NULL, species = '', updated_at = ? WHERE race_id = ?", timestamp, raceId);
|
|
1319
|
+
for (const characterId of memberIds) {
|
|
1320
|
+
this.db.run("UPDATE characters SET race_id = ?, species = ?, updated_at = ? WHERE id = ?", raceId, raceName, timestamp, characterId);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
createOrganization(workId, input) {
|
|
1324
|
+
return this.insertOrganizationWithId(workId, id("organization"), input, "create", null);
|
|
1325
|
+
}
|
|
1326
|
+
insertOrganizationWithId(workId, organizationId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
1327
|
+
this.getWork(workId);
|
|
1328
|
+
const name = input.name.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
1329
|
+
const normalizedName = normalizeCharacterName(name);
|
|
1330
|
+
if (!normalizedName)
|
|
1331
|
+
throw new AppError(400, "ORGANIZATION_NAME_REQUIRED", "组织名称不能为空");
|
|
1332
|
+
this.assertOrganizationNameAvailable(workId, normalizedName);
|
|
1333
|
+
const memberIds = [...new Set(input.memberIds ?? [])];
|
|
1334
|
+
this.assertCharactersInWork(workId, memberIds);
|
|
1335
|
+
const memberSnapshots = this.captureCharacterSnapshots(memberIds);
|
|
1336
|
+
const timestamp = now();
|
|
1337
|
+
this.db.transaction(() => {
|
|
1338
|
+
this.db.run(`INSERT INTO organizations (id, work_id, name, normalized_name, description, settings_json, created_at, updated_at)
|
|
1339
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, organizationId, workId, name, normalizedName, input.description ?? "", JSON.stringify(input.settings ?? []), timestamp, timestamp);
|
|
1340
|
+
this.replaceOrganizationMembers(organizationId, memberIds);
|
|
1341
|
+
this.recordMembershipVersions(memberSnapshots, "organization", organizationId, `加入组织“${name}”`);
|
|
1342
|
+
this.recordEntityVersion("organization", organizationId, source, sourceRef, changeNote || "建立组织档案", timestamp);
|
|
1343
|
+
this.audit(workId, source === "restore" ? "organization.restored" : "organization.created", "organization", organizationId);
|
|
1344
|
+
});
|
|
1345
|
+
return this.getOrganization(organizationId);
|
|
1346
|
+
}
|
|
1347
|
+
listOrganizations(workId) {
|
|
1348
|
+
this.getWork(workId);
|
|
1349
|
+
return this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapOrganization(row));
|
|
1350
|
+
}
|
|
1351
|
+
getOrganization(organizationId) {
|
|
1352
|
+
const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
|
|
1353
|
+
if (!row)
|
|
1354
|
+
throw notFound("组织");
|
|
1355
|
+
return this.mapOrganization(row);
|
|
1356
|
+
}
|
|
1357
|
+
updateOrganization(organizationId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1358
|
+
const current = this.getOrganization(organizationId);
|
|
1359
|
+
const workId = String(current.workId);
|
|
1360
|
+
const name = input.name === undefined
|
|
1361
|
+
? String(current.name)
|
|
1362
|
+
: input.name.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
1363
|
+
const normalizedName = normalizeCharacterName(name);
|
|
1364
|
+
if (!normalizedName)
|
|
1365
|
+
throw new AppError(400, "ORGANIZATION_NAME_REQUIRED", "组织名称不能为空");
|
|
1366
|
+
this.assertOrganizationNameAvailable(workId, normalizedName, organizationId);
|
|
1367
|
+
const memberIds = input.memberIds === undefined ? null : [...new Set(input.memberIds)];
|
|
1368
|
+
if (memberIds)
|
|
1369
|
+
this.assertCharactersInWork(workId, memberIds);
|
|
1370
|
+
const touchedMemberIds = memberIds ? [...new Set([...current.memberIds, ...memberIds])] : [];
|
|
1371
|
+
const memberSnapshots = this.captureCharacterSnapshots(touchedMemberIds);
|
|
1372
|
+
this.db.transaction(() => {
|
|
1373
|
+
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);
|
|
1374
|
+
if (memberIds) {
|
|
1375
|
+
this.replaceOrganizationMembers(organizationId, memberIds);
|
|
1376
|
+
this.recordMembershipVersions(memberSnapshots, "organization", organizationId, `组织“${name}”成员关系变更`);
|
|
1377
|
+
}
|
|
1378
|
+
this.recordEntityVersion("organization", organizationId, source, sourceRef, changeNote || "更新组织档案");
|
|
1379
|
+
this.audit(workId, "organization.updated", "organization", organizationId, { fields: Object.keys(input), source, sourceRef });
|
|
1380
|
+
});
|
|
1381
|
+
return this.getOrganization(organizationId);
|
|
1382
|
+
}
|
|
1383
|
+
deleteOrganization(organizationId) {
|
|
1384
|
+
const current = this.getOrganization(organizationId);
|
|
1385
|
+
const memberSnapshots = this.captureCharacterSnapshots(current.memberIds);
|
|
1386
|
+
this.db.transaction(() => {
|
|
1387
|
+
this.recordEntityVersion("organization", organizationId, "delete", null, "删除组织档案");
|
|
1388
|
+
this.db.run("DELETE FROM organizations WHERE id = ?", organizationId);
|
|
1389
|
+
this.recordMembershipVersions(memberSnapshots, "organization", organizationId, `组织“${String(current.name)}”已删除`);
|
|
1390
|
+
this.audit(String(current.workId), "organization.deleted", "organization", organizationId);
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
mapOrganization(row) {
|
|
1394
|
+
const members = this.db.all(`SELECT c.id, c.name, m.role, m.note
|
|
1395
|
+
FROM character_organization_memberships m
|
|
1396
|
+
JOIN characters c ON c.id = m.character_id
|
|
1397
|
+
WHERE m.organization_id = ? ORDER BY c.name`, requiredString(row, "id")).map((member) => ({
|
|
1398
|
+
characterId: requiredString(member, "id"),
|
|
1399
|
+
name: requiredString(member, "name"),
|
|
1400
|
+
role: requiredString(member, "role"),
|
|
1401
|
+
note: requiredString(member, "note")
|
|
1402
|
+
}));
|
|
1403
|
+
return {
|
|
1404
|
+
id: requiredString(row, "id"),
|
|
1405
|
+
workId: requiredString(row, "work_id"),
|
|
1406
|
+
name: requiredString(row, "name"),
|
|
1407
|
+
description: requiredString(row, "description"),
|
|
1408
|
+
settings: json(requiredString(row, "settings_json"), []),
|
|
1409
|
+
memberIds: members.map((member) => member.characterId),
|
|
1410
|
+
members,
|
|
1411
|
+
createdAt: requiredString(row, "created_at"),
|
|
1412
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
assertOrganizationNameAvailable(workId, normalizedName, excludeOrganizationId) {
|
|
1416
|
+
const row = this.db.get(`SELECT id FROM organizations WHERE work_id = ? AND normalized_name = ?${excludeOrganizationId ? " AND id <> ?" : ""}`, ...([workId, normalizedName, ...(excludeOrganizationId ? [excludeOrganizationId] : [])]));
|
|
1417
|
+
if (row)
|
|
1418
|
+
throw new AppError(409, "ORGANIZATION_NAME_CONFLICT", "同一作品内的组织名称不能重复", { organizationId: requiredString(row, "id") });
|
|
1419
|
+
}
|
|
1420
|
+
assertCharactersInWork(workId, characterIds) {
|
|
1421
|
+
for (const characterId of characterIds) {
|
|
1422
|
+
const character = this.getCharacter(characterId);
|
|
1423
|
+
if (character.workId !== workId)
|
|
1424
|
+
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "组织成员不属于当前作品");
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
assertOrganizationsInWork(workId, organizationIds) {
|
|
1428
|
+
for (const organizationId of organizationIds) {
|
|
1429
|
+
const organization = this.getOrganization(organizationId);
|
|
1430
|
+
if (organization.workId !== workId)
|
|
1431
|
+
throw new AppError(400, "ORGANIZATION_WORK_MISMATCH", "角色绑定的组织不属于当前作品");
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
replaceOrganizationMembers(organizationId, memberIds) {
|
|
1435
|
+
const timestamp = now();
|
|
1436
|
+
this.db.run("DELETE FROM character_organization_memberships WHERE organization_id = ?", organizationId);
|
|
1437
|
+
for (const characterId of memberIds) {
|
|
1438
|
+
this.db.run(`INSERT INTO character_organization_memberships (character_id, organization_id, created_at, updated_at)
|
|
1439
|
+
VALUES (?, ?, ?, ?)`, characterId, organizationId, timestamp, timestamp);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
replaceCharacterOrganizations(characterId, organizationIds) {
|
|
1443
|
+
const timestamp = now();
|
|
1444
|
+
this.db.run("DELETE FROM character_organization_memberships WHERE character_id = ?", characterId);
|
|
1445
|
+
for (const organizationId of organizationIds) {
|
|
1446
|
+
this.db.run(`INSERT INTO character_organization_memberships (character_id, organization_id, created_at, updated_at)
|
|
1447
|
+
VALUES (?, ?, ?, ?)`, characterId, organizationId, timestamp, timestamp);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
characterSnapshot(character) {
|
|
1451
|
+
return {
|
|
1452
|
+
name: String(character.name),
|
|
1453
|
+
aliases: [...character.aliases],
|
|
1454
|
+
raceId: character.raceId,
|
|
1455
|
+
species: String(character.species),
|
|
1456
|
+
organizationIds: [...character.organizationIds].sort(),
|
|
1457
|
+
attributes: character.attributes,
|
|
1458
|
+
profile: character.profile,
|
|
1459
|
+
currentState: character.currentState,
|
|
1460
|
+
lockedFields: [...character.lockedFields],
|
|
1461
|
+
visibility: String(character.visibility),
|
|
1462
|
+
firstChapterId: character.firstChapterId
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
captureCharacterSnapshots(characterIds) {
|
|
1466
|
+
return new Map(characterIds.map((characterId) => [characterId, this.characterSnapshot(this.getCharacter(characterId))]));
|
|
1467
|
+
}
|
|
1468
|
+
snapshotsEqual(left, right) {
|
|
1469
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1470
|
+
}
|
|
1471
|
+
insertCharacterVersion(characterId, versionNo, source, sourceRef, changeNote, timestamp = now(), workId) {
|
|
1472
|
+
const character = this.getCharacter(characterId);
|
|
1473
|
+
const snapshot = this.characterSnapshot(character);
|
|
1474
|
+
this.db.run(`INSERT INTO character_versions (id, work_id, character_id, version_no, snapshot_json, source, source_ref, change_note, created_at, created_by_user_id)
|
|
1475
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id("characterVersion"), workId ?? String(character.workId), characterId, versionNo, JSON.stringify(snapshot), source, sourceRef, changeNote.trim(), timestamp, currentRequestActor()?.userId ?? null);
|
|
1476
|
+
}
|
|
1477
|
+
recordMembershipVersions(snapshots, source, sourceRef, changeNote) {
|
|
1478
|
+
for (const [characterId, before] of snapshots) {
|
|
1479
|
+
const current = this.getCharacter(characterId);
|
|
1480
|
+
if (this.snapshotsEqual(before, this.characterSnapshot(current)))
|
|
1481
|
+
continue;
|
|
1482
|
+
const versionNo = Number(current.versionNo) + 1;
|
|
1483
|
+
const timestamp = now();
|
|
1484
|
+
this.db.run("UPDATE characters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, characterId);
|
|
1485
|
+
this.insertCharacterVersion(characterId, versionNo, source, sourceRef, changeNote, timestamp);
|
|
1486
|
+
this.audit(String(current.workId), "character.versioned", "character", characterId, { versionNo, source, sourceRef });
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
createCharacter(workId, input) {
|
|
1490
|
+
this.getWork(workId);
|
|
1491
|
+
const characterId = id("character");
|
|
1492
|
+
const timestamp = now();
|
|
1493
|
+
const names = this.prepareCharacterNames(input.name, input.aliases ?? []);
|
|
1494
|
+
const legacySpecies = typeof input.attributes?.species === "string" ? input.attributes.species.trim() : "";
|
|
1495
|
+
const candidateSpecies = input.species?.trim() || legacySpecies;
|
|
1496
|
+
const raceId = input.raceId === undefined ? (candidateSpecies ? this.resolveRaceReference(workId, candidateSpecies) : null) : input.raceId;
|
|
1497
|
+
const race = raceId ? this.assertRaceInWork(workId, raceId) : null;
|
|
1498
|
+
const species = race ? String(race.name) : "";
|
|
1499
|
+
this.assertCharacterNamesAvailable(workId, names.entries);
|
|
1500
|
+
if (input.firstChapterId)
|
|
1501
|
+
this.assertChapterInWork(input.firstChapterId, workId);
|
|
1502
|
+
const organizationIds = [...new Set(input.organizationIds ?? [])];
|
|
1503
|
+
this.assertOrganizationsInWork(workId, organizationIds);
|
|
1504
|
+
this.db.transaction(() => {
|
|
1505
|
+
this.db.run(`INSERT INTO characters (id, work_id, name, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
|
|
1506
|
+
locked_fields_json, visibility, first_chapter_id, created_at, updated_at)
|
|
1507
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, JSON.stringify(names.aliases), species, raceId, JSON.stringify(input.attributes ?? {}), JSON.stringify(input.profile ?? {}), JSON.stringify(input.currentState ?? {}), JSON.stringify(input.lockedFields ?? []), input.visibility ?? "author", input.firstChapterId ?? null, timestamp, timestamp);
|
|
1508
|
+
this.insertCharacterNames(workId, characterId, names.entries);
|
|
1509
|
+
this.replaceCharacterOrganizations(characterId, organizationIds);
|
|
1510
|
+
this.insertCharacterVersion(characterId, 1, "create", null, "建立人物档案", timestamp);
|
|
1511
|
+
this.audit(workId, "character.created", "character", characterId);
|
|
1512
|
+
});
|
|
1513
|
+
return this.getCharacter(characterId);
|
|
1514
|
+
}
|
|
1515
|
+
listCharacters(workId) {
|
|
1516
|
+
this.getWork(workId);
|
|
1517
|
+
return this.db.all("SELECT * FROM characters WHERE work_id = ? ORDER BY name", workId).map((row) => this.mapCharacter(row));
|
|
1518
|
+
}
|
|
1519
|
+
getCharacter(characterId) {
|
|
1520
|
+
const row = this.db.get("SELECT * FROM characters WHERE id = ?", characterId);
|
|
1521
|
+
if (!row)
|
|
1522
|
+
throw notFound("角色");
|
|
1523
|
+
return this.mapCharacter(row);
|
|
1524
|
+
}
|
|
1525
|
+
updateCharacter(characterId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1526
|
+
const current = this.getCharacter(characterId);
|
|
1527
|
+
const before = this.characterSnapshot(current);
|
|
1528
|
+
const workId = String(current.workId);
|
|
1529
|
+
const names = this.prepareCharacterNames(input.name ?? String(current.name), input.aliases ?? current.aliases);
|
|
1530
|
+
const attributes = input.attributes ?? current.attributes;
|
|
1531
|
+
const legacySpecies = typeof attributes.species === "string" ? attributes.species.trim() : "";
|
|
1532
|
+
let raceId = input.raceId === undefined ? current.raceId : input.raceId;
|
|
1533
|
+
if (input.raceId === undefined && !raceId && input.species !== undefined) {
|
|
1534
|
+
raceId = this.resolveRaceReference(workId, input.species.trim() || legacySpecies);
|
|
1535
|
+
}
|
|
1536
|
+
const race = raceId ? this.assertRaceInWork(workId, raceId) : null;
|
|
1537
|
+
const species = race ? String(race.name) : "";
|
|
1538
|
+
this.assertCharacterNamesAvailable(workId, names.entries, characterId);
|
|
1539
|
+
if (input.firstChapterId)
|
|
1540
|
+
this.assertChapterInWork(input.firstChapterId, workId);
|
|
1541
|
+
const organizationIds = input.organizationIds === undefined ? null : [...new Set(input.organizationIds)];
|
|
1542
|
+
if (organizationIds)
|
|
1543
|
+
this.assertOrganizationsInWork(workId, organizationIds);
|
|
1544
|
+
this.db.transaction(() => {
|
|
1545
|
+
this.db.run(`UPDATE characters SET name = ?, aliases_json = ?, species = ?, race_id = ?, attributes_json = ?, profile_json = ?, current_state_json = ?,
|
|
1546
|
+
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);
|
|
1547
|
+
this.db.run("DELETE FROM character_names WHERE character_id = ?", characterId);
|
|
1548
|
+
this.insertCharacterNames(workId, characterId, names.entries);
|
|
1549
|
+
if (organizationIds)
|
|
1550
|
+
this.replaceCharacterOrganizations(characterId, organizationIds);
|
|
1551
|
+
const updated = this.getCharacter(characterId);
|
|
1552
|
+
if (!this.snapshotsEqual(before, this.characterSnapshot(updated))) {
|
|
1553
|
+
const versionNo = Number(current.versionNo) + 1;
|
|
1554
|
+
const timestamp = now();
|
|
1555
|
+
this.db.run("UPDATE characters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, characterId);
|
|
1556
|
+
this.insertCharacterVersion(characterId, versionNo, source, sourceRef, changeNote || "更新人物档案", timestamp);
|
|
1557
|
+
this.audit(workId, "character.updated", "character", characterId, { fields: Object.keys(input), versionNo, source, sourceRef });
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
return this.getCharacter(characterId);
|
|
1561
|
+
}
|
|
1562
|
+
listCharacterVersions(characterId) {
|
|
1563
|
+
const rows = this.db.all(`SELECT version.*, user.display_name AS actor_display_name, user.username AS actor_username
|
|
1564
|
+
FROM character_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
|
|
1565
|
+
WHERE version.character_id = ? ORDER BY version.version_no DESC`, characterId);
|
|
1566
|
+
if (!rows.length) {
|
|
1567
|
+
this.getCharacter(characterId);
|
|
1568
|
+
return [];
|
|
1569
|
+
}
|
|
1570
|
+
return rows.map((row) => ({
|
|
1571
|
+
id: requiredString(row, "id"),
|
|
1572
|
+
workId: optionalString(row, "work_id"),
|
|
1573
|
+
characterId: requiredString(row, "character_id"),
|
|
1574
|
+
versionNo: numberValue(row, "version_no"),
|
|
1575
|
+
snapshot: json(requiredString(row, "snapshot_json"), {}),
|
|
1576
|
+
source: requiredString(row, "source"),
|
|
1577
|
+
sourceRef: optionalString(row, "source_ref"),
|
|
1578
|
+
changeNote: requiredString(row, "change_note"),
|
|
1579
|
+
createdAt: requiredString(row, "created_at"),
|
|
1580
|
+
actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? "历史数据"
|
|
1581
|
+
}));
|
|
1582
|
+
}
|
|
1583
|
+
restoreCharacter(characterId, versionNo) {
|
|
1584
|
+
const version = this.db.get("SELECT * FROM character_versions WHERE character_id = ? AND version_no = ?", characterId, versionNo);
|
|
1585
|
+
if (!version)
|
|
1586
|
+
throw notFound("人物版本");
|
|
1587
|
+
const snapshot = json(requiredString(version, "snapshot_json"), {});
|
|
1588
|
+
if (!snapshot.name)
|
|
1589
|
+
throw new AppError(500, "CHARACTER_VERSION_INVALID", "人物版本快照无效");
|
|
1590
|
+
const existing = this.db.get("SELECT id FROM characters WHERE id = ?", characterId);
|
|
1591
|
+
if (!existing) {
|
|
1592
|
+
return this.recreateCharacterFromVersion(characterId, version, snapshot, versionNo);
|
|
1593
|
+
}
|
|
1594
|
+
return this.updateCharacter(characterId, snapshot, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`);
|
|
1595
|
+
}
|
|
1596
|
+
recreateCharacterFromVersion(characterId, version, snapshot, versionNo) {
|
|
1597
|
+
const workId = requiredString(version, "work_id");
|
|
1598
|
+
this.getWork(workId);
|
|
1599
|
+
const names = this.prepareCharacterNames(snapshot.name, snapshot.aliases ?? []);
|
|
1600
|
+
const raceId = snapshot.raceId ?? null;
|
|
1601
|
+
const race = raceId ? this.assertRaceInWork(workId, raceId) : null;
|
|
1602
|
+
const species = race ? String(race.name) : (snapshot.species ?? "");
|
|
1603
|
+
this.assertCharacterNamesAvailable(workId, names.entries);
|
|
1604
|
+
if (snapshot.firstChapterId)
|
|
1605
|
+
this.assertChapterInWork(snapshot.firstChapterId, workId);
|
|
1606
|
+
const organizationIds = [...new Set(snapshot.organizationIds ?? [])];
|
|
1607
|
+
this.assertOrganizationsInWork(workId, organizationIds);
|
|
1608
|
+
const timestamp = now();
|
|
1609
|
+
const nextVersionNo = numberValue(this.db.get("SELECT COALESCE(MAX(version_no), 0) AS version_no FROM character_versions WHERE character_id = ?", characterId) ?? {}, "version_no") + 1;
|
|
1610
|
+
this.db.transaction(() => {
|
|
1611
|
+
this.db.run(`INSERT INTO characters (id, work_id, name, aliases_json, species, race_id, attributes_json, profile_json, current_state_json,
|
|
1612
|
+
locked_fields_json, visibility, first_chapter_id, version_no, created_at, updated_at)
|
|
1613
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, characterId, workId, names.name, JSON.stringify(names.aliases), species, raceId, JSON.stringify(snapshot.attributes ?? {}), JSON.stringify(snapshot.profile ?? {}), JSON.stringify(snapshot.currentState ?? {}), JSON.stringify(snapshot.lockedFields ?? []), snapshot.visibility ?? "author", snapshot.firstChapterId ?? null, nextVersionNo, timestamp, timestamp);
|
|
1614
|
+
this.insertCharacterNames(workId, characterId, names.entries);
|
|
1615
|
+
this.replaceCharacterOrganizations(characterId, organizationIds);
|
|
1616
|
+
this.insertCharacterVersion(characterId, nextVersionNo, "restore", requiredString(version, "id"), `恢复至 v${versionNo}`, timestamp, workId);
|
|
1617
|
+
this.audit(workId, "character.restored", "character", characterId, { versionNo: nextVersionNo, fromVersion: versionNo });
|
|
1618
|
+
});
|
|
1619
|
+
return this.getCharacter(characterId);
|
|
1620
|
+
}
|
|
1621
|
+
deleteCharacter(characterId) {
|
|
1622
|
+
const current = this.getCharacter(characterId);
|
|
1623
|
+
const timestamp = now();
|
|
1624
|
+
const versionNo = Number(current.versionNo) + 1;
|
|
1625
|
+
this.db.transaction(() => {
|
|
1626
|
+
this.db.run("UPDATE characters SET version_no = ?, updated_at = ? WHERE id = ?", versionNo, timestamp, characterId);
|
|
1627
|
+
this.insertCharacterVersion(characterId, versionNo, "delete", null, "删除人物", timestamp);
|
|
1628
|
+
this.db.run("DELETE FROM characters WHERE id = ?", characterId);
|
|
1629
|
+
this.audit(String(current.workId), "character.deleted", "character", characterId, { versionNo });
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
mapCharacter(row) {
|
|
1633
|
+
const indexedAliases = this.db.all("SELECT display_name FROM character_names WHERE character_id = ? AND kind = 'alias' ORDER BY sort_order", requiredString(row, "id")).map((item) => requiredString(item, "display_name"));
|
|
1634
|
+
const organizations = this.db.all(`SELECT o.id, o.name, m.role, m.note
|
|
1635
|
+
FROM character_organization_memberships m
|
|
1636
|
+
JOIN organizations o ON o.id = m.organization_id
|
|
1637
|
+
WHERE m.character_id = ? ORDER BY o.name`, requiredString(row, "id")).map((item) => ({
|
|
1638
|
+
organizationId: requiredString(item, "id"),
|
|
1639
|
+
name: requiredString(item, "name"),
|
|
1640
|
+
role: requiredString(item, "role"),
|
|
1641
|
+
note: requiredString(item, "note")
|
|
1642
|
+
}));
|
|
1643
|
+
const raceId = optionalString(row, "race_id");
|
|
1644
|
+
const race = raceId ? this.db.get("SELECT id, name FROM races WHERE id = ?", raceId) : undefined;
|
|
1645
|
+
const species = race ? requiredString(race, "name") : requiredString(row, "species");
|
|
1646
|
+
return {
|
|
1647
|
+
id: requiredString(row, "id"),
|
|
1648
|
+
workId: requiredString(row, "work_id"),
|
|
1649
|
+
name: requiredString(row, "name"),
|
|
1650
|
+
aliases: indexedAliases.length > 0 ? indexedAliases : json(requiredString(row, "aliases_json"), []),
|
|
1651
|
+
raceId: race ? requiredString(race, "id") : null,
|
|
1652
|
+
race: race ? { id: requiredString(race, "id"), name: species } : null,
|
|
1653
|
+
species,
|
|
1654
|
+
organizationIds: organizations.map((organization) => organization.organizationId),
|
|
1655
|
+
organizations,
|
|
1656
|
+
attributes: json(requiredString(row, "attributes_json"), {}),
|
|
1657
|
+
profile: json(requiredString(row, "profile_json"), {}),
|
|
1658
|
+
currentState: json(requiredString(row, "current_state_json"), {}),
|
|
1659
|
+
lockedFields: json(requiredString(row, "locked_fields_json"), []),
|
|
1660
|
+
visibility: requiredString(row, "visibility"),
|
|
1661
|
+
firstChapterId: optionalString(row, "first_chapter_id"),
|
|
1662
|
+
versionNo: numberValue(row, "version_no"),
|
|
1663
|
+
createdAt: requiredString(row, "created_at"),
|
|
1664
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
resolveCharacterReference(workId, value) {
|
|
1668
|
+
const normalizedName = normalizeCharacterName(value);
|
|
1669
|
+
if (!normalizedName)
|
|
1670
|
+
return null;
|
|
1671
|
+
const row = this.db.get("SELECT character_id FROM character_names WHERE work_id = ? AND normalized_name = ?", workId, normalizedName);
|
|
1672
|
+
return row ? requiredString(row, "character_id") : null;
|
|
1673
|
+
}
|
|
1674
|
+
prepareCharacterNames(name, aliases) {
|
|
1675
|
+
const primary = name.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
1676
|
+
if (!primary)
|
|
1677
|
+
throw new AppError(400, "CHARACTER_NAME_REQUIRED", "角色标准名不能为空");
|
|
1678
|
+
const cleanedAliases = aliases.map((alias) => alias.normalize("NFKC").trim().replace(/\s+/gu, " ")).filter(Boolean);
|
|
1679
|
+
const entries = [
|
|
1680
|
+
{ normalizedName: normalizeCharacterName(primary), displayName: primary, kind: "primary", sortOrder: 0 },
|
|
1681
|
+
...cleanedAliases.map((alias, index) => ({ normalizedName: normalizeCharacterName(alias), displayName: alias, kind: "alias", sortOrder: index + 1 }))
|
|
1682
|
+
];
|
|
1683
|
+
const seen = new Map();
|
|
1684
|
+
for (const entry of entries) {
|
|
1685
|
+
const existing = seen.get(entry.normalizedName);
|
|
1686
|
+
if (existing) {
|
|
1687
|
+
throw new AppError(409, "CHARACTER_NAME_CONFLICT", `角色名或别名重复:${existing} / ${entry.displayName}`, {
|
|
1688
|
+
normalizedName: entry.normalizedName
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
seen.set(entry.normalizedName, entry.displayName);
|
|
1692
|
+
}
|
|
1693
|
+
return { name: primary, aliases: cleanedAliases, entries };
|
|
1694
|
+
}
|
|
1695
|
+
assertCharacterNamesAvailable(workId, entries, excludeCharacterId) {
|
|
1696
|
+
for (const entry of entries) {
|
|
1697
|
+
const row = this.db.get(`SELECT character_id, display_name FROM character_names
|
|
1698
|
+
WHERE work_id = ? AND normalized_name = ?${excludeCharacterId ? " AND character_id <> ?" : ""}`, ...([workId, entry.normalizedName, ...(excludeCharacterId ? [excludeCharacterId] : [])]));
|
|
1699
|
+
if (row) {
|
|
1700
|
+
throw new AppError(409, "CHARACTER_NAME_CONFLICT", `角色名或别名“${entry.displayName}”已被使用`, {
|
|
1701
|
+
conflictingCharacterId: requiredString(row, "character_id"),
|
|
1702
|
+
conflictingName: requiredString(row, "display_name")
|
|
1703
|
+
});
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
insertCharacterNames(workId, characterId, entries) {
|
|
1708
|
+
for (const entry of entries) {
|
|
1709
|
+
this.db.run(`INSERT INTO character_names (work_id, normalized_name, character_id, display_name, kind, sort_order)
|
|
1710
|
+
VALUES (?, ?, ?, ?, ?, ?)`, workId, entry.normalizedName, characterId, entry.displayName, entry.kind, entry.sortOrder);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
createTimelineTrack(workId, input, source = "create", sourceRef = null) {
|
|
1714
|
+
this.getWork(workId);
|
|
1715
|
+
return this.insertTimelineTrackWithId(workId, id("timeline-track"), input, source, sourceRef);
|
|
1716
|
+
}
|
|
1717
|
+
insertTimelineTrackWithId(workId, trackId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
1718
|
+
const timestamp = now();
|
|
1719
|
+
const fallbackOrder = Number(this.db.get("SELECT COALESCE(MAX(sort_order), -1) + 1 AS value FROM timeline_tracks WHERE work_id = ?", workId)?.value ?? 0);
|
|
1720
|
+
this.db.transaction(() => {
|
|
1721
|
+
this.db.run(`INSERT INTO timeline_tracks (id, work_id, name, description, sort_order, created_at, updated_at)
|
|
1722
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, trackId, workId, input.name, input.description ?? "", input.sortOrder ?? fallbackOrder, timestamp, timestamp);
|
|
1723
|
+
this.recordEntityVersion("timeline-track", trackId, source, sourceRef, changeNote || "建立独立时间轴", timestamp);
|
|
1724
|
+
this.audit(workId, source === "restore" ? "timeline-track.restored" : "timeline-track.created", "timeline-track", trackId, { source, sourceRef });
|
|
1725
|
+
});
|
|
1726
|
+
return this.getTimelineTrack(trackId);
|
|
1727
|
+
}
|
|
1728
|
+
listTimelineTracks(workId) {
|
|
1729
|
+
this.getWork(workId);
|
|
1730
|
+
return this.db.all("SELECT * FROM timeline_tracks WHERE work_id = ? ORDER BY sort_order, created_at", workId).map((row) => this.mapTimelineTrack(row));
|
|
1731
|
+
}
|
|
1732
|
+
getTimelineTrack(trackId) {
|
|
1733
|
+
const row = this.db.get("SELECT * FROM timeline_tracks WHERE id = ?", trackId);
|
|
1734
|
+
if (!row)
|
|
1735
|
+
throw notFound("独立时间轴");
|
|
1736
|
+
return this.mapTimelineTrack(row);
|
|
1737
|
+
}
|
|
1738
|
+
updateTimelineTrack(trackId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1739
|
+
const current = this.getTimelineTrack(trackId);
|
|
1740
|
+
this.db.transaction(() => {
|
|
1741
|
+
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);
|
|
1742
|
+
this.recordEntityVersion("timeline-track", trackId, source, sourceRef, changeNote || "更新时间轴");
|
|
1743
|
+
this.audit(String(current.workId), "timeline-track.updated", "timeline-track", trackId, { fields: Object.keys(input), source, sourceRef });
|
|
1744
|
+
});
|
|
1745
|
+
return this.getTimelineTrack(trackId);
|
|
1746
|
+
}
|
|
1747
|
+
deleteTimelineTrack(trackId) {
|
|
1748
|
+
const current = this.getTimelineTrack(trackId);
|
|
1749
|
+
this.db.transaction(() => {
|
|
1750
|
+
this.recordEntityVersion("timeline-track", trackId, "delete", null, "删除时间轴");
|
|
1751
|
+
this.db.run("DELETE FROM timeline_tracks WHERE id = ?", trackId);
|
|
1752
|
+
this.audit(String(current.workId), "timeline-track.deleted", "timeline-track", trackId);
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
createTimelineEvent(workId, input, source = "create", sourceRef = null) {
|
|
1756
|
+
this.getWork(workId);
|
|
1757
|
+
if (input.trackId) {
|
|
1758
|
+
const track = this.getTimelineTrack(input.trackId);
|
|
1759
|
+
if (track.workId !== workId)
|
|
1760
|
+
throw new AppError(400, "TIMELINE_TRACK_WORK_MISMATCH", "独立时间轴不属于当前作品");
|
|
1761
|
+
}
|
|
1762
|
+
return this.insertTimelineEventWithId(workId, id("event"), input, source, sourceRef);
|
|
1763
|
+
}
|
|
1764
|
+
insertTimelineEventWithId(workId, eventId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
1765
|
+
if (input.trackId) {
|
|
1766
|
+
const track = this.getTimelineTrack(input.trackId);
|
|
1767
|
+
if (track.workId !== workId)
|
|
1768
|
+
throw new AppError(400, "TIMELINE_TRACK_WORK_MISMATCH", "独立时间轴不属于当前作品");
|
|
1769
|
+
}
|
|
1770
|
+
const timestamp = now();
|
|
1771
|
+
this.db.transaction(() => {
|
|
1772
|
+
this.db.run(`INSERT INTO timeline_events (id, work_id, track_id, name, description, event_type, time_label, time_sort, chapter_ids_json,
|
|
1773
|
+
participant_ids_json, location, causes_json, impact_scope, evidence_json, status, created_at, updated_at)
|
|
1774
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, eventId, workId, input.trackId ?? null, input.name, input.description ?? "", input.eventType ?? "other", input.timeLabel ?? "时间待定", input.timeSort ?? null, JSON.stringify(input.chapterIds ?? []), JSON.stringify(input.participantIds ?? []), input.location ?? "", JSON.stringify(input.causes ?? []), input.impactScope ?? "personal", JSON.stringify(input.evidence ?? []), input.status ?? "candidate", timestamp, timestamp);
|
|
1775
|
+
this.recordEntityVersion("timeline-event", eventId, source, sourceRef, changeNote || (source === "analysis" ? "AI 提取时间事件" : "建立时间事件"), timestamp);
|
|
1776
|
+
this.audit(workId, source === "restore" ? "timeline.restored" : "timeline.created", "timeline-event", eventId, { source, sourceRef });
|
|
1777
|
+
});
|
|
1778
|
+
return this.getTimelineEvent(eventId);
|
|
1779
|
+
}
|
|
1780
|
+
listTimelineEvents(workId) {
|
|
1781
|
+
this.getWork(workId);
|
|
1782
|
+
return this.db
|
|
1783
|
+
.all("SELECT * FROM timeline_events WHERE work_id = ? ORDER BY time_sort IS NULL, time_sort, created_at", workId)
|
|
1784
|
+
.map((row) => this.mapTimelineEvent(row));
|
|
1785
|
+
}
|
|
1786
|
+
getTimelineEvent(eventId) {
|
|
1787
|
+
const row = this.db.get("SELECT * FROM timeline_events WHERE id = ?", eventId);
|
|
1788
|
+
if (!row)
|
|
1789
|
+
throw notFound("时间线事件");
|
|
1790
|
+
return this.mapTimelineEvent(row);
|
|
1791
|
+
}
|
|
1792
|
+
updateTimelineEvent(eventId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1793
|
+
const current = this.getTimelineEvent(eventId);
|
|
1794
|
+
if (input.trackId) {
|
|
1795
|
+
const track = this.getTimelineTrack(input.trackId);
|
|
1796
|
+
if (track.workId !== current.workId)
|
|
1797
|
+
throw new AppError(400, "TIMELINE_TRACK_WORK_MISMATCH", "独立时间轴不属于当前作品");
|
|
1798
|
+
}
|
|
1799
|
+
this.db.transaction(() => {
|
|
1800
|
+
this.db.run(`UPDATE timeline_events SET track_id = ?, name = ?, description = ?, event_type = ?, time_label = ?, time_sort = ?,
|
|
1801
|
+
chapter_ids_json = ?, participant_ids_json = ?, location = ?, causes_json = ?, impact_scope = ?, evidence_json = ?,
|
|
1802
|
+
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);
|
|
1803
|
+
this.recordEntityVersion("timeline-event", eventId, source, sourceRef, changeNote || "更新时间事件");
|
|
1804
|
+
this.audit(String(current.workId), "timeline.updated", "timeline-event", eventId, { fields: Object.keys(input), source, sourceRef });
|
|
1805
|
+
});
|
|
1806
|
+
return this.getTimelineEvent(eventId);
|
|
1807
|
+
}
|
|
1808
|
+
deleteTimelineEvent(eventId) {
|
|
1809
|
+
const current = this.getTimelineEvent(eventId);
|
|
1810
|
+
this.db.transaction(() => {
|
|
1811
|
+
this.recordEntityVersion("timeline-event", eventId, "delete", null, "删除时间事件");
|
|
1812
|
+
this.db.run("DELETE FROM timeline_events WHERE id = ?", eventId);
|
|
1813
|
+
this.audit(String(current.workId), "timeline.deleted", "timeline-event", eventId);
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
mergeTimelineEvents(workId, eventIds, input) {
|
|
1817
|
+
this.getWork(workId);
|
|
1818
|
+
const uniqueIds = [...new Set(eventIds)];
|
|
1819
|
+
if (uniqueIds.length < 2)
|
|
1820
|
+
throw new AppError(400, "EVENTS_REQUIRED", "合并时间事件至少需要选择两项");
|
|
1821
|
+
const events = uniqueIds.map((eventId) => this.getTimelineEvent(eventId));
|
|
1822
|
+
if (events.some((event) => event.workId !== workId))
|
|
1823
|
+
throw new AppError(400, "EVENT_WORK_MISMATCH", "时间事件不属于当前作品");
|
|
1824
|
+
const union = (key) => {
|
|
1825
|
+
const values = events.flatMap((event) => Array.isArray(event[key]) ? event[key] : []);
|
|
1826
|
+
return [...new Map(values.map((value) => [JSON.stringify(value), value])).values()];
|
|
1827
|
+
};
|
|
1828
|
+
const knownSorts = events.map((event) => event.timeSort).filter((value) => typeof value === "number");
|
|
1829
|
+
return this.db.transaction(() => {
|
|
1830
|
+
const merged = this.createTimelineEvent(workId, {
|
|
1831
|
+
name: input.name,
|
|
1832
|
+
trackId: events.every((event) => event.trackId === events[0]?.trackId) ? events[0]?.trackId : null,
|
|
1833
|
+
description: input.description ?? events.map((event) => String(event.description)).filter(Boolean).join("\n"),
|
|
1834
|
+
eventType: String(events[0]?.eventType ?? "other"),
|
|
1835
|
+
timeLabel: input.timeLabel ?? String(events[0]?.timeLabel ?? "时间待定"),
|
|
1836
|
+
timeSort: input.timeSort === undefined ? (knownSorts.length ? Math.min(...knownSorts) : null) : input.timeSort,
|
|
1837
|
+
chapterIds: union("chapterIds").filter((value) => typeof value === "string"),
|
|
1838
|
+
participantIds: union("participantIds").filter((value) => typeof value === "string"),
|
|
1839
|
+
location: [...new Set(events.map((event) => String(event.location)).filter(Boolean))].join(" / "),
|
|
1840
|
+
causes: union("causes").filter((value) => typeof value === "string"),
|
|
1841
|
+
impactScope: String(events[0]?.impactScope ?? "personal"),
|
|
1842
|
+
evidence: union("evidence"),
|
|
1843
|
+
status: events.every((event) => event.status === "confirmed") ? "confirmed" : "pending"
|
|
1844
|
+
}, "merge", uniqueIds.join(","));
|
|
1845
|
+
for (const eventId of uniqueIds) {
|
|
1846
|
+
this.recordEntityVersion("timeline-event", eventId, "delete", null, "删除时间事件");
|
|
1847
|
+
this.db.run("DELETE FROM timeline_events WHERE id = ?", eventId);
|
|
1848
|
+
}
|
|
1849
|
+
this.audit(workId, "timeline.merged", "timeline-event", String(merged.id), { sourceEventIds: uniqueIds });
|
|
1850
|
+
return merged;
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
splitTimelineEvent(eventId, parts) {
|
|
1854
|
+
const source = this.getTimelineEvent(eventId);
|
|
1855
|
+
if (parts.length < 2)
|
|
1856
|
+
throw new AppError(400, "EVENT_PARTS_REQUIRED", "拆分时间事件至少需要两项");
|
|
1857
|
+
return this.db.transaction(() => {
|
|
1858
|
+
const created = parts.map((part, index) => this.createTimelineEvent(String(source.workId), {
|
|
1859
|
+
name: part.name,
|
|
1860
|
+
trackId: source.trackId,
|
|
1861
|
+
description: part.description ?? String(source.description),
|
|
1862
|
+
eventType: String(source.eventType),
|
|
1863
|
+
timeLabel: part.timeLabel ?? String(source.timeLabel),
|
|
1864
|
+
timeSort: part.timeSort === undefined
|
|
1865
|
+
? (typeof source.timeSort === "number" ? source.timeSort + index / 100 : null)
|
|
1866
|
+
: part.timeSort,
|
|
1867
|
+
chapterIds: source.chapterIds,
|
|
1868
|
+
participantIds: source.participantIds,
|
|
1869
|
+
location: String(source.location),
|
|
1870
|
+
causes: source.causes,
|
|
1871
|
+
impactScope: String(source.impactScope),
|
|
1872
|
+
evidence: source.evidence,
|
|
1873
|
+
status: String(source.status)
|
|
1874
|
+
}, "split", eventId));
|
|
1875
|
+
this.recordEntityVersion("timeline-event", eventId, "delete", null, "删除时间事件");
|
|
1876
|
+
this.db.run("DELETE FROM timeline_events WHERE id = ?", eventId);
|
|
1877
|
+
this.audit(String(source.workId), "timeline.split", "timeline-event", eventId, { createdEventIds: created.map((event) => event.id) });
|
|
1878
|
+
return created;
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
mapTimelineEvent(row) {
|
|
1882
|
+
return {
|
|
1883
|
+
id: requiredString(row, "id"),
|
|
1884
|
+
workId: requiredString(row, "work_id"),
|
|
1885
|
+
trackId: row.track_id === null ? null : requiredString(row, "track_id"),
|
|
1886
|
+
name: requiredString(row, "name"),
|
|
1887
|
+
description: requiredString(row, "description"),
|
|
1888
|
+
eventType: requiredString(row, "event_type"),
|
|
1889
|
+
timeLabel: requiredString(row, "time_label"),
|
|
1890
|
+
timeSort: row.time_sort === null ? null : numberValue(row, "time_sort"),
|
|
1891
|
+
chapterIds: json(requiredString(row, "chapter_ids_json"), []),
|
|
1892
|
+
participantIds: json(requiredString(row, "participant_ids_json"), []),
|
|
1893
|
+
location: requiredString(row, "location"),
|
|
1894
|
+
causes: json(requiredString(row, "causes_json"), []),
|
|
1895
|
+
impactScope: requiredString(row, "impact_scope"),
|
|
1896
|
+
evidence: json(requiredString(row, "evidence_json"), []),
|
|
1897
|
+
status: requiredString(row, "status"),
|
|
1898
|
+
createdAt: requiredString(row, "created_at"),
|
|
1899
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
mapTimelineTrack(row) {
|
|
1903
|
+
return {
|
|
1904
|
+
id: requiredString(row, "id"),
|
|
1905
|
+
workId: requiredString(row, "work_id"),
|
|
1906
|
+
name: requiredString(row, "name"),
|
|
1907
|
+
description: requiredString(row, "description"),
|
|
1908
|
+
sortOrder: numberValue(row, "sort_order"),
|
|
1909
|
+
createdAt: requiredString(row, "created_at"),
|
|
1910
|
+
updatedAt: requiredString(row, "updated_at")
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
createRelationship(workId, input, source = "create", sourceRef = null) {
|
|
1914
|
+
this.getWork(workId);
|
|
1915
|
+
return this.insertRelationshipWithId(workId, id("relationship"), input, source, sourceRef);
|
|
1916
|
+
}
|
|
1917
|
+
insertRelationshipWithId(workId, relationshipId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
1918
|
+
let fromCharacterId = input.fromCharacterId;
|
|
1919
|
+
let toCharacterId = input.toCharacterId;
|
|
1920
|
+
if (fromCharacterId === toCharacterId)
|
|
1921
|
+
throw new AppError(400, "SELF_RELATIONSHIP", "人物关系不能指向自身");
|
|
1922
|
+
const from = this.getCharacter(fromCharacterId);
|
|
1923
|
+
const to = this.getCharacter(toCharacterId);
|
|
1924
|
+
if (from.workId !== workId || to.workId !== workId)
|
|
1925
|
+
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "关系人物不属于当前作品");
|
|
1926
|
+
if (!input.directed && fromCharacterId.localeCompare(toCharacterId) > 0)
|
|
1927
|
+
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
1928
|
+
this.assertRelationshipUnique(workId, fromCharacterId, toCharacterId, input.category, input.subtype ?? "", Boolean(input.directed));
|
|
1929
|
+
const timestamp = now();
|
|
1930
|
+
const keywords = this.normalizeRelationshipKeywords(input.keywords ?? []);
|
|
1931
|
+
this.db.transaction(() => {
|
|
1932
|
+
this.db.run(`INSERT INTO relationships (id, work_id, from_character_id, to_character_id, category, subtype, keywords_json, directed,
|
|
1933
|
+
current_status, time_range_json, confidence, evidence_json, confirmation_status, locked, created_at, updated_at)
|
|
1934
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, relationshipId, workId, fromCharacterId, toCharacterId, input.category, input.subtype ?? "", JSON.stringify(keywords), input.directed ? 1 : 0, input.currentStatus ?? "active", JSON.stringify(input.timeRange ?? {}), input.confidence ?? 0.5, JSON.stringify(input.evidence ?? []), input.confirmationStatus ?? "pending", input.locked ? 1 : 0, timestamp, timestamp);
|
|
1935
|
+
this.recordEntityVersion("relationship", relationshipId, source, sourceRef, changeNote || (source === "analysis" ? "AI 提取人物关系" : "建立人物关系"), timestamp);
|
|
1936
|
+
this.audit(workId, source === "restore" ? "relationship.restored" : "relationship.created", "relationship", relationshipId, { source, sourceRef });
|
|
1937
|
+
});
|
|
1938
|
+
return this.getRelationship(relationshipId);
|
|
1939
|
+
}
|
|
1940
|
+
listRelationships(workId, minimumConfidence = 0) {
|
|
1941
|
+
this.getWork(workId);
|
|
1942
|
+
return this.db
|
|
1943
|
+
.all("SELECT * FROM relationships WHERE work_id = ? AND confidence >= ? ORDER BY confidence DESC, created_at", workId, minimumConfidence)
|
|
1944
|
+
.map((row) => this.mapRelationship(row));
|
|
1945
|
+
}
|
|
1946
|
+
getRelationship(relationshipId) {
|
|
1947
|
+
const row = this.db.get("SELECT * FROM relationships WHERE id = ?", relationshipId);
|
|
1948
|
+
if (!row)
|
|
1949
|
+
throw notFound("人物关系");
|
|
1950
|
+
return this.mapRelationship(row);
|
|
1951
|
+
}
|
|
1952
|
+
updateRelationship(relationshipId, input, source = "manual", sourceRef = null, changeNote = "") {
|
|
1953
|
+
const current = this.getRelationship(relationshipId);
|
|
1954
|
+
let fromCharacterId = input.fromCharacterId ?? String(current.fromCharacterId);
|
|
1955
|
+
let toCharacterId = input.toCharacterId ?? String(current.toCharacterId);
|
|
1956
|
+
if (fromCharacterId === toCharacterId)
|
|
1957
|
+
throw new AppError(400, "SELF_RELATIONSHIP", "人物关系不能指向自身");
|
|
1958
|
+
const from = this.getCharacter(fromCharacterId);
|
|
1959
|
+
const to = this.getCharacter(toCharacterId);
|
|
1960
|
+
if (from.workId !== current.workId || to.workId !== current.workId)
|
|
1961
|
+
throw new AppError(400, "CHARACTER_WORK_MISMATCH", "关系人物不属于当前作品");
|
|
1962
|
+
const directed = input.directed ?? Boolean(current.directed);
|
|
1963
|
+
if (!directed && fromCharacterId.localeCompare(toCharacterId) > 0)
|
|
1964
|
+
[fromCharacterId, toCharacterId] = [toCharacterId, fromCharacterId];
|
|
1965
|
+
this.assertRelationshipUnique(String(current.workId), fromCharacterId, toCharacterId, input.category ?? String(current.category), input.subtype ?? String(current.subtype), directed, relationshipId);
|
|
1966
|
+
this.db.transaction(() => {
|
|
1967
|
+
this.db.run(`UPDATE relationships SET from_character_id = ?, to_character_id = ?, category = ?, subtype = ?, keywords_json = ?, directed = ?,
|
|
1968
|
+
current_status = ?, time_range_json = ?, confidence = ?, evidence_json = ?, confirmation_status = ?, locked = ?, updated_at = ?
|
|
1969
|
+
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);
|
|
1970
|
+
this.recordEntityVersion("relationship", relationshipId, source, sourceRef, changeNote || "更新人物关系");
|
|
1971
|
+
this.audit(String(current.workId), "relationship.updated", "relationship", relationshipId, { fields: Object.keys(input), source, sourceRef });
|
|
1972
|
+
});
|
|
1973
|
+
return this.getRelationship(relationshipId);
|
|
1974
|
+
}
|
|
1975
|
+
deleteRelationship(relationshipId) {
|
|
1976
|
+
const current = this.getRelationship(relationshipId);
|
|
1977
|
+
this.db.transaction(() => {
|
|
1978
|
+
this.recordEntityVersion("relationship", relationshipId, "delete", null, "删除人物关系");
|
|
1979
|
+
this.db.run("DELETE FROM relationships WHERE id = ?", relationshipId);
|
|
1980
|
+
this.audit(String(current.workId), "relationship.deleted", "relationship", relationshipId);
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
mapRelationship(row) {
|
|
1984
|
+
return {
|
|
1985
|
+
id: requiredString(row, "id"),
|
|
1986
|
+
workId: requiredString(row, "work_id"),
|
|
1987
|
+
fromCharacterId: requiredString(row, "from_character_id"),
|
|
1988
|
+
toCharacterId: requiredString(row, "to_character_id"),
|
|
1989
|
+
category: requiredString(row, "category"),
|
|
1990
|
+
subtype: requiredString(row, "subtype"),
|
|
1991
|
+
keywords: json(requiredString(row, "keywords_json"), []),
|
|
1992
|
+
directed: booleanValue(row, "directed"),
|
|
1993
|
+
currentStatus: requiredString(row, "current_status"),
|
|
1994
|
+
timeRange: json(requiredString(row, "time_range_json"), {}),
|
|
1995
|
+
confidence: numberValue(row, "confidence"),
|
|
1996
|
+
evidence: json(requiredString(row, "evidence_json"), []),
|
|
1997
|
+
confirmationStatus: requiredString(row, "confirmation_status"),
|
|
1998
|
+
locked: booleanValue(row, "locked"),
|
|
1999
|
+
createdAt: requiredString(row, "created_at"),
|
|
2000
|
+
updatedAt: requiredString(row, "updated_at")
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
normalizeRelationshipKeywords(keywords) {
|
|
2004
|
+
const values = keywords.map((keyword) => keyword.normalize("NFKC").trim().replace(/\s+/gu, " ")).filter(Boolean);
|
|
2005
|
+
return [...new Map(values.map((keyword) => [keyword.toLocaleLowerCase("zh-CN"), keyword])).values()].slice(0, 30);
|
|
2006
|
+
}
|
|
2007
|
+
assertRelationshipUnique(workId, fromCharacterId, toCharacterId, category, subtype, directed, excludeRelationshipId) {
|
|
2008
|
+
const normalizedSubtype = subtype.normalize("NFKC").trim().toLocaleLowerCase("zh-CN");
|
|
2009
|
+
const duplicate = this.listRelationships(workId).find((relationship) => {
|
|
2010
|
+
if (excludeRelationshipId && relationship.id === excludeRelationshipId)
|
|
2011
|
+
return false;
|
|
2012
|
+
const same = relationship.fromCharacterId === fromCharacterId && relationship.toCharacterId === toCharacterId;
|
|
2013
|
+
const reverse = !directed && !relationship.directed
|
|
2014
|
+
&& relationship.fromCharacterId === toCharacterId && relationship.toCharacterId === fromCharacterId;
|
|
2015
|
+
return (same || reverse)
|
|
2016
|
+
&& Boolean(relationship.directed) === directed
|
|
2017
|
+
&& relationship.category === category
|
|
2018
|
+
&& String(relationship.subtype).normalize("NFKC").trim().toLocaleLowerCase("zh-CN") === normalizedSubtype
|
|
2019
|
+
&& relationship.confirmationStatus !== "rejected";
|
|
2020
|
+
});
|
|
2021
|
+
if (duplicate)
|
|
2022
|
+
throw new AppError(409, "RELATIONSHIP_CONFLICT", "相同人物、类型与方向的关系已经存在", { relationshipId: duplicate.id });
|
|
2023
|
+
}
|
|
2024
|
+
createReviewItem(workId, input) {
|
|
2025
|
+
this.getWork(workId);
|
|
2026
|
+
const reviewId = id("review");
|
|
2027
|
+
const timestamp = now();
|
|
2028
|
+
this.db.run(`INSERT INTO review_items (id, work_id, item_type, severity, title, description, entity_refs_json, evidence_json,
|
|
2029
|
+
suggestion, status, resolution_note, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, reviewId, workId, input.itemType, input.severity ?? "medium", input.title, input.description ?? "", JSON.stringify(input.entityRefs ?? []), JSON.stringify(input.evidence ?? []), input.suggestion ?? "", input.status ?? "pending", input.resolutionNote ?? "", timestamp, timestamp);
|
|
2030
|
+
return this.getReviewItem(reviewId);
|
|
2031
|
+
}
|
|
2032
|
+
listReviewItems(workId, status) {
|
|
2033
|
+
this.getWork(workId);
|
|
2034
|
+
const rows = status
|
|
2035
|
+
? this.db.all("SELECT * FROM review_items WHERE work_id = ? AND status = ? ORDER BY created_at DESC", workId, status)
|
|
2036
|
+
: this.db.all("SELECT * FROM review_items WHERE work_id = ? ORDER BY created_at DESC", workId);
|
|
2037
|
+
return rows.map((row) => this.mapReviewItem(row));
|
|
2038
|
+
}
|
|
2039
|
+
getReviewItem(reviewId) {
|
|
2040
|
+
const row = this.db.get("SELECT * FROM review_items WHERE id = ?", reviewId);
|
|
2041
|
+
if (!row)
|
|
2042
|
+
throw notFound("审核项");
|
|
2043
|
+
return this.mapReviewItem(row);
|
|
2044
|
+
}
|
|
2045
|
+
updateReviewItem(reviewId, input) {
|
|
2046
|
+
const current = this.getReviewItem(reviewId);
|
|
2047
|
+
this.db.run(`UPDATE review_items SET item_type = ?, severity = ?, title = ?, description = ?, entity_refs_json = ?,
|
|
2048
|
+
evidence_json = ?, suggestion = ?, status = ?, resolution_note = ?, updated_at = ? WHERE id = ?`, input.itemType ?? String(current.itemType), input.severity ?? String(current.severity), input.title ?? String(current.title), input.description ?? String(current.description), JSON.stringify(input.entityRefs ?? current.entityRefs), JSON.stringify(input.evidence ?? current.evidence), input.suggestion ?? String(current.suggestion), input.status ?? String(current.status), input.resolutionNote ?? String(current.resolutionNote), now(), reviewId);
|
|
2049
|
+
this.audit(String(current.workId), "review.updated", "review", reviewId, { status: input.status });
|
|
2050
|
+
return this.getReviewItem(reviewId);
|
|
2051
|
+
}
|
|
2052
|
+
mapReviewItem(row) {
|
|
2053
|
+
return {
|
|
2054
|
+
id: requiredString(row, "id"),
|
|
2055
|
+
workId: requiredString(row, "work_id"),
|
|
2056
|
+
itemType: requiredString(row, "item_type"),
|
|
2057
|
+
severity: requiredString(row, "severity"),
|
|
2058
|
+
title: requiredString(row, "title"),
|
|
2059
|
+
description: requiredString(row, "description"),
|
|
2060
|
+
entityRefs: json(requiredString(row, "entity_refs_json"), []),
|
|
2061
|
+
evidence: json(requiredString(row, "evidence_json"), []),
|
|
2062
|
+
suggestion: requiredString(row, "suggestion"),
|
|
2063
|
+
status: requiredString(row, "status"),
|
|
2064
|
+
resolutionNote: requiredString(row, "resolution_note"),
|
|
2065
|
+
createdAt: requiredString(row, "created_at"),
|
|
2066
|
+
updatedAt: requiredString(row, "updated_at")
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
createContinuationGuard(input) {
|
|
2070
|
+
const suggestion = this.db.get("SELECT work_id FROM ai_suggestions WHERE id = ?", input.suggestionId);
|
|
2071
|
+
if (!suggestion)
|
|
2072
|
+
throw notFound("AI 建议");
|
|
2073
|
+
const guardId = id("guard");
|
|
2074
|
+
const contentHash = createHash("sha256").update(input.content).digest("hex");
|
|
2075
|
+
this.db.run(`INSERT INTO continuation_guard_runs (id, suggestion_id, call_id, chapter_version, content_hash,
|
|
2076
|
+
status, issues_json, context_refs_json, failure, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, guardId, input.suggestionId, input.callId ?? null, input.chapterVersion, contentHash, input.status, JSON.stringify(input.issues ?? []), JSON.stringify(input.contextRefs ?? {}), input.failure ?? null, now(), currentRequestActor()?.userId ?? null);
|
|
2077
|
+
this.audit(requiredString(suggestion, "work_id"), "continuation.guard.created", "continuation-guard", guardId, {
|
|
2078
|
+
suggestionId: input.suggestionId,
|
|
2079
|
+
status: input.status,
|
|
2080
|
+
issueCount: input.issues?.length ?? 0
|
|
2081
|
+
});
|
|
2082
|
+
return this.getContinuationGuard(guardId);
|
|
2083
|
+
}
|
|
2084
|
+
getContinuationGuard(guardId) {
|
|
2085
|
+
const row = this.db.get("SELECT * FROM continuation_guard_runs WHERE id = ?", guardId);
|
|
2086
|
+
if (!row)
|
|
2087
|
+
throw notFound("续写一致性检查");
|
|
2088
|
+
return this.mapContinuationGuard(row);
|
|
2089
|
+
}
|
|
2090
|
+
listContinuationGuards(suggestionId) {
|
|
2091
|
+
const suggestion = this.db.get("SELECT id FROM ai_suggestions WHERE id = ?", suggestionId);
|
|
2092
|
+
if (!suggestion)
|
|
2093
|
+
throw notFound("AI 建议");
|
|
2094
|
+
return this.db.all("SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC", suggestionId).map((row) => this.mapContinuationGuard(row));
|
|
2095
|
+
}
|
|
2096
|
+
getLatestContinuationGuard(suggestionId) {
|
|
2097
|
+
const row = this.db.get("SELECT * FROM continuation_guard_runs WHERE suggestion_id = ? ORDER BY created_at DESC LIMIT 1", suggestionId);
|
|
2098
|
+
return row ? this.mapContinuationGuard(row) : null;
|
|
2099
|
+
}
|
|
2100
|
+
createAiConversation(workId, title = "新对话") {
|
|
2101
|
+
this.getWork(workId);
|
|
2102
|
+
const conversationId = id("conversation");
|
|
2103
|
+
const timestamp = now();
|
|
2104
|
+
this.db.run("INSERT INTO ai_conversations (id, work_id, title, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?)", conversationId, workId, title.trim() || "新对话", timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
2105
|
+
return this.getAiConversation(conversationId);
|
|
2106
|
+
}
|
|
2107
|
+
listAiConversations(workId) {
|
|
2108
|
+
this.getWork(workId);
|
|
2109
|
+
return this.db.all(`SELECT conversation.*,
|
|
2110
|
+
(SELECT COUNT(*) FROM ai_conversation_messages message WHERE message.conversation_id = conversation.id) AS message_count,
|
|
2111
|
+
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
|
|
2112
|
+
FROM ai_conversations conversation
|
|
2113
|
+
WHERE conversation.work_id = ?
|
|
2114
|
+
ORDER BY conversation.updated_at DESC, conversation.created_at DESC
|
|
2115
|
+
LIMIT 100`, workId).map((row) => this.mapAiConversation(row));
|
|
2116
|
+
}
|
|
2117
|
+
getAiConversation(conversationId) {
|
|
2118
|
+
const row = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
2119
|
+
if (!row)
|
|
2120
|
+
throw notFound("AI 对话");
|
|
2121
|
+
const messages = this.db.all("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId).map((message) => this.mapAiConversationMessage(message));
|
|
2122
|
+
return { ...this.mapAiConversation(row), messageCount: messages.length, messages };
|
|
2123
|
+
}
|
|
2124
|
+
getAiConversationContext(conversationId, workId, excludeMessageId) {
|
|
2125
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
2126
|
+
if (!conversation)
|
|
2127
|
+
throw notFound("AI 对话");
|
|
2128
|
+
if (requiredString(conversation, "work_id") !== workId)
|
|
2129
|
+
throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
|
|
2130
|
+
const rows = this.db.all("SELECT id, role, content FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId);
|
|
2131
|
+
const compactedMessageCount = Math.min(rows.length, Math.max(0, numberValue(conversation, "compacted_message_count")));
|
|
2132
|
+
return {
|
|
2133
|
+
workId,
|
|
2134
|
+
summary: requiredString(conversation, "compacted_summary"),
|
|
2135
|
+
compactedMessageCount,
|
|
2136
|
+
totalMessageCount: rows.length,
|
|
2137
|
+
warningPending: Boolean(optionalString(conversation, "context_warning_at")),
|
|
2138
|
+
messages: rows.slice(compactedMessageCount)
|
|
2139
|
+
.filter((message) => requiredString(message, "id") !== excludeMessageId)
|
|
2140
|
+
.map((message) => ({
|
|
2141
|
+
id: requiredString(message, "id"),
|
|
2142
|
+
role: requiredString(message, "role") === "assistant" ? "assistant" : "user",
|
|
2143
|
+
content: requiredString(message, "content")
|
|
2144
|
+
}))
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
setAiConversationContextWarning(conversationId, pending) {
|
|
2148
|
+
const conversation = this.db.get("SELECT id FROM ai_conversations WHERE id = ?", conversationId);
|
|
2149
|
+
if (!conversation)
|
|
2150
|
+
throw notFound("AI 对话");
|
|
2151
|
+
this.db.run("UPDATE ai_conversations SET context_warning_at = ? WHERE id = ?", pending ? now() : null, conversationId);
|
|
2152
|
+
}
|
|
2153
|
+
saveAiConversationCompaction(conversationId, summary, compactedMessageCount) {
|
|
2154
|
+
const conversation = this.db.get("SELECT id FROM ai_conversations WHERE id = ?", conversationId);
|
|
2155
|
+
if (!conversation)
|
|
2156
|
+
throw notFound("AI 对话");
|
|
2157
|
+
this.db.run("UPDATE ai_conversations SET compacted_summary = ?, compacted_message_count = ?, context_warning_at = NULL, updated_at = ? WHERE id = ?", summary, Math.max(0, compactedMessageCount), now(), conversationId);
|
|
2158
|
+
return this.getAiConversation(conversationId);
|
|
2159
|
+
}
|
|
2160
|
+
addAiConversationMessage(conversationId, input) {
|
|
2161
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
2162
|
+
if (!conversation)
|
|
2163
|
+
throw notFound("AI 对话");
|
|
2164
|
+
const messageId = id("message");
|
|
2165
|
+
const timestamp = now();
|
|
2166
|
+
const title = requiredString(conversation, "title") === "新对话" && input.role === "user"
|
|
2167
|
+
? input.content.replace(/\s+/gu, " ").trim().slice(0, 36) || "新对话"
|
|
2168
|
+
: requiredString(conversation, "title");
|
|
2169
|
+
this.db.transaction(() => {
|
|
2170
|
+
this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", messageId, conversationId, input.role, input.content, JSON.stringify(input.citations ?? []), JSON.stringify(input.metadata ?? {}), timestamp, currentRequestActor()?.userId ?? null);
|
|
2171
|
+
this.db.run("UPDATE ai_conversations SET title = ?, updated_at = ? WHERE id = ?", title, timestamp, conversationId);
|
|
2172
|
+
});
|
|
2173
|
+
const message = this.db.get("SELECT * FROM ai_conversation_messages WHERE id = ?", messageId);
|
|
2174
|
+
if (!message)
|
|
2175
|
+
throw notFound("AI 对话消息");
|
|
2176
|
+
return this.mapAiConversationMessage(message);
|
|
2177
|
+
}
|
|
2178
|
+
forkAiConversation(conversationId, messageId, requestedTitle) {
|
|
2179
|
+
const conversation = this.db.get("SELECT * FROM ai_conversations WHERE id = ?", conversationId);
|
|
2180
|
+
if (!conversation)
|
|
2181
|
+
throw notFound("AI 对话");
|
|
2182
|
+
const messages = this.db.all("SELECT * FROM ai_conversation_messages WHERE conversation_id = ? ORDER BY created_at, rowid", conversationId);
|
|
2183
|
+
const targetIndex = messages.findIndex((message) => requiredString(message, "id") === messageId);
|
|
2184
|
+
if (targetIndex < 0)
|
|
2185
|
+
throw notFound("AI 对话消息");
|
|
2186
|
+
const forkId = id("conversation");
|
|
2187
|
+
const timestamp = now();
|
|
2188
|
+
const sourceTitle = requiredString(conversation, "title");
|
|
2189
|
+
const title = requestedTitle?.trim() || `${sourceTitle} · 分支`;
|
|
2190
|
+
const sourceCompactedCount = Math.max(0, numberValue(conversation, "compacted_message_count"));
|
|
2191
|
+
const forkCompactedCount = targetIndex + 1 >= sourceCompactedCount ? Math.min(sourceCompactedCount, targetIndex + 1) : 0;
|
|
2192
|
+
const forkSummary = forkCompactedCount ? requiredString(conversation, "compacted_summary") : "";
|
|
2193
|
+
this.db.transaction(() => {
|
|
2194
|
+
this.db.run("INSERT INTO ai_conversations (id, work_id, title, compacted_summary, compacted_message_count, created_at, updated_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", forkId, requiredString(conversation, "work_id"), title.slice(0, 200), forkSummary, forkCompactedCount, timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
2195
|
+
for (const message of messages.slice(0, targetIndex + 1)) {
|
|
2196
|
+
this.db.run("INSERT INTO ai_conversation_messages (id, conversation_id, role, content, citations_json, metadata_json, created_at, created_by_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id("message"), forkId, requiredString(message, "role"), requiredString(message, "content"), requiredString(message, "citations_json"), requiredString(message, "metadata_json"), requiredString(message, "created_at"), currentRequestActor()?.userId ?? null);
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
return this.getAiConversation(forkId);
|
|
2200
|
+
}
|
|
2201
|
+
mapAiConversation(row) {
|
|
2202
|
+
return {
|
|
2203
|
+
id: requiredString(row, "id"),
|
|
2204
|
+
workId: requiredString(row, "work_id"),
|
|
2205
|
+
title: requiredString(row, "title"),
|
|
2206
|
+
messageCount: numberValue(row, "message_count"),
|
|
2207
|
+
preview: requiredString(row, "preview"),
|
|
2208
|
+
compactedMessageCount: numberValue(row, "compacted_message_count"),
|
|
2209
|
+
hasCompactedSummary: Boolean(requiredString(row, "compacted_summary")),
|
|
2210
|
+
contextWarningPending: Boolean(optionalString(row, "context_warning_at")),
|
|
2211
|
+
createdAt: requiredString(row, "created_at"),
|
|
2212
|
+
updatedAt: requiredString(row, "updated_at")
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
mapAiConversationMessage(row) {
|
|
2216
|
+
return {
|
|
2217
|
+
id: requiredString(row, "id"),
|
|
2218
|
+
conversationId: requiredString(row, "conversation_id"),
|
|
2219
|
+
role: requiredString(row, "role"),
|
|
2220
|
+
content: requiredString(row, "content"),
|
|
2221
|
+
citations: json(requiredString(row, "citations_json"), []),
|
|
2222
|
+
metadata: json(requiredString(row, "metadata_json"), {}),
|
|
2223
|
+
createdAt: requiredString(row, "created_at")
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
hashContent(content) {
|
|
2227
|
+
return createHash("sha256").update(content).digest("hex");
|
|
2228
|
+
}
|
|
2229
|
+
mapContinuationGuard(row) {
|
|
2230
|
+
return {
|
|
2231
|
+
id: requiredString(row, "id"),
|
|
2232
|
+
suggestionId: requiredString(row, "suggestion_id"),
|
|
2233
|
+
callId: optionalString(row, "call_id"),
|
|
2234
|
+
chapterVersion: numberValue(row, "chapter_version"),
|
|
2235
|
+
contentHash: requiredString(row, "content_hash"),
|
|
2236
|
+
status: requiredString(row, "status"),
|
|
2237
|
+
issues: json(requiredString(row, "issues_json"), []),
|
|
2238
|
+
contextRefs: json(requiredString(row, "context_refs_json"), {}),
|
|
2239
|
+
failure: optionalString(row, "failure"),
|
|
2240
|
+
createdAt: requiredString(row, "created_at")
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
createTask(workId, input) {
|
|
2244
|
+
this.getWork(workId);
|
|
2245
|
+
const taskId = id("task");
|
|
2246
|
+
const timestamp = now();
|
|
2247
|
+
const scope = input.scope ?? { type: "book" };
|
|
2248
|
+
const sourceVersions = {};
|
|
2249
|
+
if (typeof scope.chapterId === "string") {
|
|
2250
|
+
const chapter = this.getChapter(scope.chapterId);
|
|
2251
|
+
if (chapter.workId !== workId)
|
|
2252
|
+
throw new AppError(400, "CHAPTER_WORK_MISMATCH", "章节不属于当前作品");
|
|
2253
|
+
sourceVersions[scope.chapterId] = Number(chapter.versionNo);
|
|
2254
|
+
}
|
|
2255
|
+
else if (scope.type === "book" || scope.type === "volume") {
|
|
2256
|
+
const tree = this.getWorkTree(workId);
|
|
2257
|
+
const volumes = tree.volumes;
|
|
2258
|
+
const selectedVolumes = scope.type === "volume"
|
|
2259
|
+
? volumes.filter((volume) => volume.id === scope.volumeId)
|
|
2260
|
+
: volumes;
|
|
2261
|
+
if (scope.type === "volume" && selectedVolumes.length === 0)
|
|
2262
|
+
throw notFound("卷");
|
|
2263
|
+
for (const chapter of selectedVolumes.flatMap((volume) => volume.chapters)) {
|
|
2264
|
+
sourceVersions[String(chapter.id)] = Number(chapter.versionNo);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
this.db.run(`INSERT INTO analysis_tasks (id, work_id, task_type, scope_json, status, source_versions_json, created_at, updated_at, created_by_user_id)
|
|
2268
|
+
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, taskId, workId, input.taskType, JSON.stringify(scope), JSON.stringify(sourceVersions), timestamp, timestamp, currentRequestActor()?.userId ?? null);
|
|
2269
|
+
this.audit(workId, "task.created", "analysis-task", taskId, { taskType: input.taskType, scope });
|
|
2270
|
+
this.notifyAnalysisTaskQueued(workId);
|
|
2271
|
+
return this.getTask(taskId);
|
|
2272
|
+
}
|
|
2273
|
+
listTasks(workId) {
|
|
2274
|
+
this.getWork(workId);
|
|
2275
|
+
return this.db.all("SELECT * FROM analysis_tasks WHERE work_id = ? ORDER BY created_at DESC", workId).map((row) => this.mapTask(row));
|
|
2276
|
+
}
|
|
2277
|
+
getTask(taskId) {
|
|
2278
|
+
const row = this.db.get("SELECT * FROM analysis_tasks WHERE id = ?", taskId);
|
|
2279
|
+
if (!row)
|
|
2280
|
+
throw notFound("分析任务");
|
|
2281
|
+
return this.mapTask(row);
|
|
2282
|
+
}
|
|
2283
|
+
countRunningTasks(workId) {
|
|
2284
|
+
const row = this.db.get("SELECT COUNT(*) AS value FROM analysis_tasks WHERE work_id = ? AND status = 'running'", workId);
|
|
2285
|
+
return numberValue(row ?? {}, "value");
|
|
2286
|
+
}
|
|
2287
|
+
listOldestPendingTaskIds(workId, limit) {
|
|
2288
|
+
if (limit <= 0)
|
|
2289
|
+
return [];
|
|
2290
|
+
return this.db.all(`SELECT id FROM analysis_tasks WHERE work_id = ? AND status = 'pending'
|
|
2291
|
+
ORDER BY created_at ASC, id ASC LIMIT ?`, workId, limit).map((row) => requiredString(row, "id"));
|
|
2292
|
+
}
|
|
2293
|
+
countPendingTasks(workId) {
|
|
2294
|
+
const row = this.db.get("SELECT COUNT(*) AS value FROM analysis_tasks WHERE work_id = ? AND status = 'pending'", workId);
|
|
2295
|
+
return numberValue(row ?? {}, "value");
|
|
2296
|
+
}
|
|
2297
|
+
isTaskSourceCurrent(taskId) {
|
|
2298
|
+
const task = this.getTask(taskId);
|
|
2299
|
+
const scope = task.scope;
|
|
2300
|
+
const expected = task.sourceVersions;
|
|
2301
|
+
let chapters = [];
|
|
2302
|
+
if (typeof scope.chapterId === "string") {
|
|
2303
|
+
const row = this.db.get("SELECT id, work_id, version_no FROM chapters WHERE id = ?", scope.chapterId);
|
|
2304
|
+
if (!row || requiredString(row, "work_id") !== task.workId)
|
|
2305
|
+
return false;
|
|
2306
|
+
chapters = [{ id: requiredString(row, "id"), versionNo: numberValue(row, "version_no") }];
|
|
2307
|
+
}
|
|
2308
|
+
else if (scope.type === "book" || scope.type === "volume") {
|
|
2309
|
+
const tree = this.getWorkTree(String(task.workId));
|
|
2310
|
+
const volumes = tree.volumes;
|
|
2311
|
+
const selectedVolumes = scope.type === "volume"
|
|
2312
|
+
? volumes.filter((volume) => volume.id === scope.volumeId)
|
|
2313
|
+
: volumes;
|
|
2314
|
+
if (scope.type === "volume" && selectedVolumes.length === 0)
|
|
2315
|
+
return false;
|
|
2316
|
+
chapters = selectedVolumes.flatMap((volume) => volume.chapters);
|
|
2317
|
+
}
|
|
2318
|
+
else {
|
|
2319
|
+
return true;
|
|
2320
|
+
}
|
|
2321
|
+
const current = Object.fromEntries(chapters.map((chapter) => [String(chapter.id), Number(chapter.versionNo)]));
|
|
2322
|
+
const expectedIds = Object.keys(expected).sort();
|
|
2323
|
+
const currentIds = Object.keys(current).sort();
|
|
2324
|
+
return expectedIds.length === currentIds.length
|
|
2325
|
+
&& expectedIds.every((chapterId, index) => chapterId === currentIds[index] && expected[chapterId] === current[chapterId]);
|
|
2326
|
+
}
|
|
2327
|
+
cancelTask(taskId) {
|
|
2328
|
+
const current = this.getTask(taskId);
|
|
2329
|
+
if (current.status === "cancelled")
|
|
2330
|
+
return current;
|
|
2331
|
+
if (current.status !== "pending" && current.status !== "running") {
|
|
2332
|
+
throw new AppError(409, "TASK_NOT_CANCELLABLE", "只有待执行或执行中的任务可以取消");
|
|
2333
|
+
}
|
|
2334
|
+
this.db.run("UPDATE analysis_tasks SET status = 'cancelled', updated_at = ? WHERE id = ?", now(), taskId);
|
|
2335
|
+
this.audit(String(current.workId), "task.cancelled", "analysis-task", taskId, { previousStatus: current.status });
|
|
2336
|
+
return this.getTask(taskId);
|
|
2337
|
+
}
|
|
2338
|
+
updateTask(taskId, input) {
|
|
2339
|
+
const current = this.getTask(taskId);
|
|
2340
|
+
const terminal = ["completed", "partial", "review", "expired", "cancelled"];
|
|
2341
|
+
if (terminal.includes(String(current.status)) && input.status !== current.status) {
|
|
2342
|
+
throw new AppError(409, "INVALID_TASK_TRANSITION", "终态任务不能再变更状态");
|
|
2343
|
+
}
|
|
2344
|
+
this.db.run("UPDATE analysis_tasks SET status = ?, progress = ?, result_json = ?, failure_json = ?, updated_at = ? WHERE id = ?", input.status, input.progress ?? Number(current.progress), JSON.stringify(input.result ?? current.result), JSON.stringify(input.failures ?? current.failures), now(), taskId);
|
|
2345
|
+
return this.getTask(taskId);
|
|
2346
|
+
}
|
|
2347
|
+
mapTask(row) {
|
|
2348
|
+
const workId = requiredString(row, "work_id");
|
|
2349
|
+
const scope = json(requiredString(row, "scope_json"), {});
|
|
2350
|
+
return {
|
|
2351
|
+
id: requiredString(row, "id"),
|
|
2352
|
+
workId,
|
|
2353
|
+
taskType: requiredString(row, "task_type"),
|
|
2354
|
+
scope,
|
|
2355
|
+
scopeSummary: this.taskScopeSummary(workId, scope),
|
|
2356
|
+
scopeDetails: this.taskScopeDetails(workId, scope),
|
|
2357
|
+
status: requiredString(row, "status"),
|
|
2358
|
+
progress: numberValue(row, "progress"),
|
|
2359
|
+
result: json(requiredString(row, "result_json"), {}),
|
|
2360
|
+
failures: json(requiredString(row, "failure_json"), []),
|
|
2361
|
+
sourceVersions: json(requiredString(row, "source_versions_json"), {}),
|
|
2362
|
+
createdAt: requiredString(row, "created_at"),
|
|
2363
|
+
updatedAt: requiredString(row, "updated_at")
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
taskScopeSummary(workId, scope) {
|
|
2367
|
+
if (typeof scope.chapterId === "string") {
|
|
2368
|
+
const chapter = this.db.get(`SELECT chapter.title AS title, volume.title AS volume_title
|
|
2369
|
+
FROM chapters chapter
|
|
2370
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2371
|
+
WHERE chapter.id = ? AND chapter.work_id = ?`, scope.chapterId, workId);
|
|
2372
|
+
if (!chapter)
|
|
2373
|
+
return "章节已删除";
|
|
2374
|
+
const title = requiredString(chapter, "title");
|
|
2375
|
+
const volumeTitle = requiredString(chapter, "volume_title");
|
|
2376
|
+
return `${volumeTitle} · ${title}`;
|
|
2377
|
+
}
|
|
2378
|
+
if (scope.type === "volume" && typeof scope.volumeId === "string") {
|
|
2379
|
+
const volume = this.db.get("SELECT title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
|
|
2380
|
+
return volume ? `分卷 · ${requiredString(volume, "title")}` : "分卷已删除";
|
|
2381
|
+
}
|
|
2382
|
+
if (scope.type === "book" || Object.keys(scope).length === 0)
|
|
2383
|
+
return "全书";
|
|
2384
|
+
return "未指定范围";
|
|
2385
|
+
}
|
|
2386
|
+
taskScopeDetails(workId, scope) {
|
|
2387
|
+
if (typeof scope.chapterId === "string") {
|
|
2388
|
+
const chapter = this.db.get(`SELECT chapter.id AS id, chapter.title AS title, chapter.version_no AS version_no,
|
|
2389
|
+
volume.id AS volume_id, volume.title AS volume_title
|
|
2390
|
+
FROM chapters chapter
|
|
2391
|
+
JOIN volumes volume ON volume.id = chapter.volume_id
|
|
2392
|
+
WHERE chapter.id = ? AND chapter.work_id = ?`, scope.chapterId, workId);
|
|
2393
|
+
if (!chapter)
|
|
2394
|
+
return [{ type: "chapter", chapterId: scope.chapterId, missing: true }];
|
|
2395
|
+
return [{
|
|
2396
|
+
type: "chapter",
|
|
2397
|
+
chapterId: requiredString(chapter, "id"),
|
|
2398
|
+
title: requiredString(chapter, "title"),
|
|
2399
|
+
versionNo: numberValue(chapter, "version_no"),
|
|
2400
|
+
volumeId: requiredString(chapter, "volume_id"),
|
|
2401
|
+
volumeTitle: requiredString(chapter, "volume_title")
|
|
2402
|
+
}];
|
|
2403
|
+
}
|
|
2404
|
+
if (scope.type === "volume" && typeof scope.volumeId === "string") {
|
|
2405
|
+
const volume = this.db.get("SELECT id, title FROM volumes WHERE id = ? AND work_id = ?", scope.volumeId, workId);
|
|
2406
|
+
if (!volume)
|
|
2407
|
+
return [{ type: "volume", volumeId: scope.volumeId, missing: true }];
|
|
2408
|
+
const chapters = this.db.all("SELECT id, title, version_no FROM chapters WHERE volume_id = ? ORDER BY sort_order, created_at", scope.volumeId);
|
|
2409
|
+
return [{
|
|
2410
|
+
type: "volume",
|
|
2411
|
+
volumeId: requiredString(volume, "id"),
|
|
2412
|
+
title: requiredString(volume, "title"),
|
|
2413
|
+
chapters: chapters.map((item) => ({
|
|
2414
|
+
chapterId: requiredString(item, "id"),
|
|
2415
|
+
title: requiredString(item, "title"),
|
|
2416
|
+
versionNo: numberValue(item, "version_no")
|
|
2417
|
+
}))
|
|
2418
|
+
}];
|
|
2419
|
+
}
|
|
2420
|
+
if (scope.type === "book" || Object.keys(scope).length === 0) {
|
|
2421
|
+
return [{ type: "book", title: "全书" }];
|
|
2422
|
+
}
|
|
2423
|
+
return [{ type: "unknown", scope }];
|
|
2424
|
+
}
|
|
2425
|
+
search(workId, query) {
|
|
2426
|
+
this.getWork(workId);
|
|
2427
|
+
const pattern = `%${query.replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
|
|
2428
|
+
const chapters = this.db.all("SELECT id, title, content, volume_id FROM chapters WHERE work_id = ? AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
|
|
2429
|
+
const races = this.db.all("SELECT id, name, description, settings_json FROM races WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR settings_json LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
|
|
2430
|
+
const settings = this.db.all("SELECT id, title, content, category FROM settings WHERE work_id = ? AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern);
|
|
2431
|
+
const characters = this.db.all("SELECT id, name, aliases_json, species FROM characters WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR aliases_json LIKE ? ESCAPE '\\' OR species LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
|
|
2432
|
+
const organizations = this.db.all("SELECT id, name, description, settings_json FROM organizations WHERE work_id = ? AND (name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR settings_json LIKE ? ESCAPE '\\') LIMIT 50", workId, pattern, pattern, pattern);
|
|
2433
|
+
const snippet = (content) => {
|
|
2434
|
+
const index = content.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
|
|
2435
|
+
const start = Math.max(0, index - 40);
|
|
2436
|
+
return content.slice(start, start + 120);
|
|
2437
|
+
};
|
|
2438
|
+
return [
|
|
2439
|
+
...characters.map((row) => ({ type: "character", id: requiredString(row, "id"), title: requiredString(row, "name"), snippet: [requiredString(row, "species"), ...json(requiredString(row, "aliases_json"), [])].filter(Boolean).join("、") })),
|
|
2440
|
+
...settings.map((row) => ({ type: "setting", id: requiredString(row, "id"), title: requiredString(row, "title"), snippet: snippet(requiredString(row, "content")), category: requiredString(row, "category") })),
|
|
2441
|
+
...races.map((row) => ({ type: "race", id: requiredString(row, "id"), title: requiredString(row, "name"), snippet: snippet(`${requiredString(row, "description")}\n${json(requiredString(row, "settings_json"), []).join("\n")}`) })),
|
|
2442
|
+
...organizations.map((row) => ({ type: "organization", id: requiredString(row, "id"), title: requiredString(row, "name"), snippet: snippet(`${requiredString(row, "description")}\n${json(requiredString(row, "settings_json"), []).join("\n")}`) })),
|
|
2443
|
+
...chapters.map((row) => ({ type: "chapter", id: requiredString(row, "id"), title: requiredString(row, "title"), snippet: snippet(requiredString(row, "content")), volumeId: requiredString(row, "volume_id") }))
|
|
2444
|
+
];
|
|
2445
|
+
}
|
|
2446
|
+
exportWork(workId) {
|
|
2447
|
+
const tree = this.getWorkTree(workId);
|
|
2448
|
+
return {
|
|
2449
|
+
schemaVersion: 6,
|
|
2450
|
+
exportedAt: now(),
|
|
2451
|
+
work: tree,
|
|
2452
|
+
settings: this.listSettings(workId),
|
|
2453
|
+
characters: this.listCharacters(workId),
|
|
2454
|
+
races: this.listRaces(workId),
|
|
2455
|
+
organizations: this.listOrganizations(workId),
|
|
2456
|
+
timelineTracks: this.listTimelineTracks(workId),
|
|
2457
|
+
timeline: this.listTimelineEvents(workId),
|
|
2458
|
+
relationships: this.listRelationships(workId),
|
|
2459
|
+
outlines: this.listChapterOutlines(workId),
|
|
2460
|
+
foreshadows: this.listForeshadows(workId),
|
|
2461
|
+
reviews: this.listReviewItems(workId)
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
exportText(workId, format) {
|
|
2465
|
+
const tree = this.getWorkTree(workId);
|
|
2466
|
+
const volumes = tree.volumes;
|
|
2467
|
+
const lines = [];
|
|
2468
|
+
for (const volume of volumes) {
|
|
2469
|
+
lines.push(format === "markdown" ? `# ${String(volume.title)}` : String(volume.title), "");
|
|
2470
|
+
for (const chapter of volume.chapters) {
|
|
2471
|
+
lines.push(format === "markdown" ? `## ${String(chapter.title)}` : String(chapter.title), "", String(chapter.content), "");
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
2475
|
+
}
|
|
2476
|
+
listAuditLogs(workId) {
|
|
2477
|
+
this.getWork(workId);
|
|
2478
|
+
return this.db.all(`SELECT log.*, user.display_name AS actor_display_name, user.username AS actor_username
|
|
2479
|
+
FROM audit_logs log LEFT JOIN users user ON user.id = log.user_id
|
|
2480
|
+
WHERE log.work_id = ? ORDER BY log.created_at DESC LIMIT 200`, workId).map((row) => ({
|
|
2481
|
+
id: requiredString(row, "id"),
|
|
2482
|
+
action: requiredString(row, "action"),
|
|
2483
|
+
entityType: requiredString(row, "entity_type"),
|
|
2484
|
+
entityId: optionalString(row, "entity_id"),
|
|
2485
|
+
actor: optionalString(row, "actor_display_name") ?? optionalString(row, "actor_username") ?? requiredString(row, "actor"),
|
|
2486
|
+
userId: optionalString(row, "user_id"),
|
|
2487
|
+
detail: json(requiredString(row, "detail_json"), {}),
|
|
2488
|
+
createdAt: requiredString(row, "created_at")
|
|
2489
|
+
}));
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
//# sourceMappingURL=store.js.map
|