@musnows/scriverse 0.6.1 → 0.6.2
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 +3 -2
- package/README.md +3 -2
- package/dist/ai.js +105 -17
- package/dist/ai.js.map +1 -1
- package/dist/app.js +105 -25
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +9 -1
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +4 -0
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-core.js +6 -0
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +7 -5
- package/dist/credential-vault.js.map +1 -1
- package/dist/database.js +121 -2
- package/dist/database.js.map +1 -1
- package/dist/domain.js +9 -0
- package/dist/domain.js.map +1 -1
- package/dist/public/app.js +105 -21
- package/dist/public/index.html +4 -3
- package/dist/public/markdown.js +1 -2
- package/dist/public/styles.css +10 -10
- package/dist/security.js +45 -10
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +56 -1
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +178 -19
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +15 -66
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DRAFT_SETTING_MODULES } from "./domain.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
3
4
|
import { AppError, notFound } from "./errors.js";
|
|
@@ -7,6 +8,7 @@ import { currentRequestActor } from "./request-context.js";
|
|
|
7
8
|
import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
|
|
8
9
|
import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
|
|
9
10
|
import { buildWritingCalendar, writingDateKey } from "./writing-progress-time.js";
|
|
11
|
+
export const attachmentPermissionModules = ["prose", "drafts", "settings", "characters", "races", "organizations"];
|
|
10
12
|
const defaultPlatformPageSizes = {
|
|
11
13
|
drafts: 30,
|
|
12
14
|
settings: 30,
|
|
@@ -302,6 +304,8 @@ export class Store {
|
|
|
302
304
|
if (type === "draft")
|
|
303
305
|
return {
|
|
304
306
|
draftType: entity.draftType,
|
|
307
|
+
volumeId: entity.volumeId,
|
|
308
|
+
settingModule: entity.settingModule,
|
|
305
309
|
title: entity.title,
|
|
306
310
|
content: entity.content
|
|
307
311
|
};
|
|
@@ -817,13 +821,23 @@ export class Store {
|
|
|
817
821
|
const storageKeys = this.db.all("SELECT DISTINCT storage_key FROM attachments WHERE work_id = ?", workId)
|
|
818
822
|
.map((row) => requiredString(row, "storage_key"));
|
|
819
823
|
this.db.transaction(() => {
|
|
824
|
+
this.db.raw.exec("PRAGMA defer_foreign_keys = ON");
|
|
820
825
|
const current = this.getWork(workId);
|
|
821
826
|
this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
|
|
822
827
|
this.recordEntityVersion("work", workId, "delete", null, "删除作品");
|
|
823
828
|
this.audit(null, "work.deleted", "work", workId, { title: work.title });
|
|
829
|
+
this.db.run("DELETE FROM characters WHERE work_id = ?", workId);
|
|
830
|
+
this.db.run("DELETE FROM organizations WHERE work_id = ?", workId);
|
|
831
|
+
this.db.run("UPDATE races SET parent_race_id = NULL WHERE work_id = ? AND parent_race_id IS NOT NULL", workId);
|
|
832
|
+
this.db.run("DELETE FROM races WHERE work_id = ?", workId);
|
|
824
833
|
this.db.run("DELETE FROM works WHERE id = ?", workId);
|
|
834
|
+
this.db.run("DELETE FROM relationship_source_index_queue WHERE work_id = ?", workId);
|
|
835
|
+
for (const storageKey of storageKeys) {
|
|
836
|
+
if (!this.attachmentStorageKeyInUse(storageKey))
|
|
837
|
+
this.enqueueAttachmentCleanup(storageKey);
|
|
838
|
+
}
|
|
825
839
|
});
|
|
826
|
-
return storageKeys.filter((storageKey) =>
|
|
840
|
+
return storageKeys.filter((storageKey) => !this.attachmentStorageKeyInUse(storageKey));
|
|
827
841
|
}
|
|
828
842
|
setWorkCover(workId, mimeType, content, expectedVersionNo) {
|
|
829
843
|
const sha256 = createHash("sha256").update(content).digest("hex");
|
|
@@ -1026,6 +1040,7 @@ export class Store {
|
|
|
1026
1040
|
for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
|
|
1027
1041
|
this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "替换作品树前保存分卷历史");
|
|
1028
1042
|
}
|
|
1043
|
+
this.clearDraftVolumeBindings(workId, null, fileVersionId, "恢复文件版本时原分卷已被替换");
|
|
1029
1044
|
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
1030
1045
|
for (const volume of volumes) {
|
|
1031
1046
|
const volumeId = id("volume");
|
|
@@ -1077,6 +1092,7 @@ export class Store {
|
|
|
1077
1092
|
for (const row of this.db.all("SELECT id FROM volumes WHERE work_id = ?", workId)) {
|
|
1078
1093
|
this.recordEntityVersion("volume", requiredString(row, "id"), "delete", fileVersionId, "导入前保存分卷历史");
|
|
1079
1094
|
}
|
|
1095
|
+
this.clearDraftVolumeBindings(workId, null, fileVersionId, "覆盖导入时原分卷已被替换");
|
|
1080
1096
|
this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
|
|
1081
1097
|
}
|
|
1082
1098
|
else {
|
|
@@ -1163,6 +1179,7 @@ export class Store {
|
|
|
1163
1179
|
throw new AppError(409, "VOLUME_HAS_DELETED_CHAPTERS", "分卷回收站中仍有章节,请先彻底删除或恢复并移动这些章节");
|
|
1164
1180
|
}
|
|
1165
1181
|
this.recordEntityVersion("volume", volumeId, "delete", null, "删除分卷");
|
|
1182
|
+
this.clearDraftVolumeBindings(String(current.workId), [volumeId], null, "绑定的分卷已删除");
|
|
1166
1183
|
this.db.run("DELETE FROM volumes WHERE id = ?", volumeId);
|
|
1167
1184
|
this.audit(String(current.workId), "volume.deleted", "volume", volumeId, { versionNo: Number(current.versionNo) });
|
|
1168
1185
|
});
|
|
@@ -2276,9 +2293,10 @@ export class Store {
|
|
|
2276
2293
|
}
|
|
2277
2294
|
insertDraftWithId(workId, draftId, input, source = "create", sourceRef = null, changeNote = "") {
|
|
2278
2295
|
const timestamp = now();
|
|
2296
|
+
const binding = this.normalizeDraftBinding(workId, input.draftType, input.volumeId ?? null, input.settingModule ?? null);
|
|
2279
2297
|
this.db.transaction(() => {
|
|
2280
|
-
this.db.run(`INSERT INTO drafts (id, work_id, draft_type, title, content, created_at, updated_at)
|
|
2281
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`, draftId, workId, input.draftType, input.title, input.content, timestamp, timestamp);
|
|
2298
|
+
this.db.run(`INSERT INTO drafts (id, work_id, draft_type, volume_id, setting_module, title, content, created_at, updated_at)
|
|
2299
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, draftId, workId, input.draftType, binding.volumeId, binding.settingModule, input.title, input.content, timestamp, timestamp);
|
|
2282
2300
|
this.syncMarkdownAttachmentReferences(workId, "draft", draftId, input.content);
|
|
2283
2301
|
this.recordEntityVersion("draft", draftId, source, sourceRef, changeNote || "建立创作想法", timestamp);
|
|
2284
2302
|
this.audit(workId, source === "restore" ? "draft.restored" : "draft.created", "draft", draftId, {
|
|
@@ -2291,14 +2309,18 @@ export class Store {
|
|
|
2291
2309
|
}
|
|
2292
2310
|
listDrafts(workId, draftType, includeContent = false) {
|
|
2293
2311
|
this.getWork(workId);
|
|
2294
|
-
return this.db.all(`SELECT
|
|
2295
|
-
|
|
2312
|
+
return this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2313
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2314
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2315
|
+
ORDER BY draft.updated_at DESC, draft.title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
|
|
2296
2316
|
}
|
|
2297
2317
|
listDraftsPage(workId, pagination, draftType, includeContent = false) {
|
|
2298
2318
|
this.getWork(workId);
|
|
2299
2319
|
const page = paginationSql(pagination);
|
|
2300
|
-
const rows = this.db.all(`SELECT
|
|
2301
|
-
|
|
2320
|
+
const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2321
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2322
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2323
|
+
ORDER BY draft.updated_at DESC, draft.title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
|
|
2302
2324
|
return paginated(rows.map((row) => this.mapDraft(row, includeContent)), pagination);
|
|
2303
2325
|
}
|
|
2304
2326
|
searchDrafts(workId, query, draftType, limit = 20) {
|
|
@@ -2308,17 +2330,21 @@ export class Store {
|
|
|
2308
2330
|
const escapedQuery = normalizedQuery.replace(/[\\%_]/gu, "\\$&");
|
|
2309
2331
|
const pattern = `%${escapedQuery}%`;
|
|
2310
2332
|
const rows = normalizedQuery
|
|
2311
|
-
? this.db.all(`SELECT
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2333
|
+
? this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2334
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2335
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2336
|
+
AND (draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE OR draft.content LIKE ? ESCAPE '\\' COLLATE NOCASE)
|
|
2337
|
+
ORDER BY CASE WHEN draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, draft.updated_at DESC
|
|
2315
2338
|
LIMIT ?`, workId, draftType ?? null, draftType ?? null, pattern, pattern, pattern, safeLimit)
|
|
2316
|
-
: this.db.all(`SELECT
|
|
2317
|
-
|
|
2339
|
+
: this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2340
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id
|
|
2341
|
+
WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
|
|
2342
|
+
ORDER BY draft.updated_at DESC, draft.title LIMIT ?`, workId, draftType ?? null, draftType ?? null, safeLimit);
|
|
2318
2343
|
return rows.map((row) => this.mapDraft(row, true));
|
|
2319
2344
|
}
|
|
2320
2345
|
getDraft(draftId) {
|
|
2321
|
-
const row = this.db.get(
|
|
2346
|
+
const row = this.db.get(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
|
|
2347
|
+
LEFT JOIN volumes volume ON volume.id = draft.volume_id WHERE draft.id = ?`, draftId);
|
|
2322
2348
|
if (!row)
|
|
2323
2349
|
throw notFound("想法");
|
|
2324
2350
|
return this.mapDraft(row, true);
|
|
@@ -2326,9 +2352,15 @@ export class Store {
|
|
|
2326
2352
|
updateDraft(draftId, input, source = "manual", sourceRef = null, changeNote = "", expectedVersionNo) {
|
|
2327
2353
|
const current = this.getDraft(draftId);
|
|
2328
2354
|
const content = input.content ?? String(current.content);
|
|
2355
|
+
const draftType = input.draftType ?? current.draftType;
|
|
2356
|
+
const typeChanged = input.draftType !== undefined && input.draftType !== current.draftType;
|
|
2357
|
+
const restoreMissingBinding = source === "restore";
|
|
2358
|
+
const volumeId = Object.hasOwn(input, "volumeId") ? input.volumeId ?? null : typeChanged || restoreMissingBinding ? null : current.volumeId;
|
|
2359
|
+
const settingModule = Object.hasOwn(input, "settingModule") ? input.settingModule ?? null : typeChanged || restoreMissingBinding ? null : current.settingModule;
|
|
2360
|
+
const binding = this.normalizeDraftBinding(String(current.workId), draftType, volumeId, settingModule);
|
|
2329
2361
|
this.db.transaction(() => {
|
|
2330
2362
|
this.assertExpectedVersion("draft", draftId, expectedVersionNo, "想法");
|
|
2331
|
-
this.db.run("UPDATE drafts SET draft_type = ?, title = ?, content = ?, updated_at = ? WHERE id = ?",
|
|
2363
|
+
this.db.run("UPDATE drafts SET draft_type = ?, volume_id = ?, setting_module = ?, title = ?, content = ?, updated_at = ? WHERE id = ?", draftType, binding.volumeId, binding.settingModule, input.title ?? String(current.title), content, now(), draftId);
|
|
2332
2364
|
this.syncMarkdownAttachmentReferences(String(current.workId), "draft", draftId, content);
|
|
2333
2365
|
this.recordEntityVersion("draft", draftId, source, sourceRef, changeNote || "更新创作想法");
|
|
2334
2366
|
this.audit(String(current.workId), "draft.updated", "draft", draftId, { fields: Object.keys(input), source, sourceRef });
|
|
@@ -2351,6 +2383,9 @@ export class Store {
|
|
|
2351
2383
|
id: requiredString(row, "id"),
|
|
2352
2384
|
workId: requiredString(row, "work_id"),
|
|
2353
2385
|
draftType: requiredString(row, "draft_type"),
|
|
2386
|
+
volumeId: optionalString(row, "volume_id"),
|
|
2387
|
+
volumeTitle: optionalString(row, "volume_title"),
|
|
2388
|
+
settingModule: optionalString(row, "setting_module"),
|
|
2354
2389
|
title: requiredString(row, "title"),
|
|
2355
2390
|
...(includeContent ? { content } : { contentPreview: content.replace(/\s+/gu, " ").trim().slice(0, 320) }),
|
|
2356
2391
|
versionNo: this.currentEntityVersionNo("draft", requiredString(row, "id")),
|
|
@@ -2358,6 +2393,41 @@ export class Store {
|
|
|
2358
2393
|
updatedAt: requiredString(row, "updated_at")
|
|
2359
2394
|
};
|
|
2360
2395
|
}
|
|
2396
|
+
normalizeDraftBinding(workId, draftType, volumeId, settingModule) {
|
|
2397
|
+
if (draftType === "prose") {
|
|
2398
|
+
if (settingModule !== null) {
|
|
2399
|
+
throw new AppError(400, "DRAFT_BINDING_TYPE_MISMATCH", "正文想法不能绑定设定模块");
|
|
2400
|
+
}
|
|
2401
|
+
if (volumeId !== null) {
|
|
2402
|
+
const volume = this.getVolume(volumeId);
|
|
2403
|
+
if (volume.workId !== workId) {
|
|
2404
|
+
throw new AppError(400, "DRAFT_VOLUME_WORK_MISMATCH", "分卷不属于当前作品");
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
return { volumeId, settingModule: null };
|
|
2408
|
+
}
|
|
2409
|
+
if (volumeId !== null) {
|
|
2410
|
+
throw new AppError(400, "DRAFT_BINDING_TYPE_MISMATCH", "设定想法不能绑定分卷");
|
|
2411
|
+
}
|
|
2412
|
+
if (settingModule !== null && !DRAFT_SETTING_MODULES.includes(settingModule)) {
|
|
2413
|
+
throw new AppError(400, "DRAFT_SETTING_MODULE_INVALID", "设定想法绑定模块无效");
|
|
2414
|
+
}
|
|
2415
|
+
return { volumeId: null, settingModule };
|
|
2416
|
+
}
|
|
2417
|
+
clearDraftVolumeBindings(workId, volumeIds, sourceRef, changeNote) {
|
|
2418
|
+
const drafts = volumeIds === null
|
|
2419
|
+
? this.db.all("SELECT id FROM drafts WHERE work_id = ? AND volume_id IS NOT NULL", workId)
|
|
2420
|
+
: volumeIds.length
|
|
2421
|
+
? this.db.all(`SELECT id FROM drafts WHERE work_id = ? AND volume_id IN (${volumeIds.map(() => "?").join(", ")})`, workId, ...volumeIds)
|
|
2422
|
+
: [];
|
|
2423
|
+
const timestamp = now();
|
|
2424
|
+
for (const draft of drafts) {
|
|
2425
|
+
const draftId = requiredString(draft, "id");
|
|
2426
|
+
this.db.run("UPDATE drafts SET volume_id = NULL, updated_at = ? WHERE id = ?", timestamp, draftId);
|
|
2427
|
+
this.recordEntityVersion("draft", draftId, "manual", sourceRef, changeNote, timestamp);
|
|
2428
|
+
this.audit(workId, "draft.updated", "draft", draftId, { fields: ["volumeId"], source: "volume-deleted", sourceRef });
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2361
2431
|
createSetting(workId, input, source = "create", sourceRef = null) {
|
|
2362
2432
|
this.getWork(workId);
|
|
2363
2433
|
return this.insertSettingWithId(workId, id("setting"), input, source, sourceRef);
|
|
@@ -3224,11 +3294,16 @@ export class Store {
|
|
|
3224
3294
|
createdAt: requiredString(row, "created_at")
|
|
3225
3295
|
};
|
|
3226
3296
|
}
|
|
3227
|
-
createAttachment(workId, input) {
|
|
3297
|
+
createAttachment(workId, input, accessModule = "settings") {
|
|
3228
3298
|
this.getWork(workId);
|
|
3229
3299
|
const existing = this.db.get("SELECT * FROM attachments WHERE work_id = ? AND stored_sha256 = ?", workId, input.storedSha256);
|
|
3230
|
-
if (existing)
|
|
3300
|
+
if (existing) {
|
|
3301
|
+
this.db.transaction(() => {
|
|
3302
|
+
this.db.run("INSERT OR IGNORE INTO attachment_access_modules (attachment_id, module, created_at) VALUES (?, ?, ?)", requiredString(existing, "id"), accessModule, now());
|
|
3303
|
+
this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", requiredString(existing, "storage_key"));
|
|
3304
|
+
});
|
|
3231
3305
|
return { attachment: this.mapAttachment(existing), created: false };
|
|
3306
|
+
}
|
|
3232
3307
|
const attachmentId = id("attachment");
|
|
3233
3308
|
const timestamp = now();
|
|
3234
3309
|
this.db.transaction(() => {
|
|
@@ -3236,6 +3311,8 @@ export class Store {
|
|
|
3236
3311
|
(id, work_id, original_name, original_mime_type, stored_mime_type, original_byte_length, stored_byte_length,
|
|
3237
3312
|
original_sha256, stored_sha256, storage_key, width, height, page_count, animated, created_at, created_by_user_id)
|
|
3238
3313
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, attachmentId, workId, input.originalName, input.originalMimeType, input.storedMimeType, input.originalByteLength, input.storedByteLength, input.originalSha256, input.storedSha256, input.storageKey, input.width, input.height, input.pageCount, input.animated ? 1 : 0, timestamp, currentRequestActor()?.userId ?? null);
|
|
3314
|
+
this.db.run("INSERT INTO attachment_access_modules (attachment_id, module, created_at) VALUES (?, ?, ?)", attachmentId, accessModule, timestamp);
|
|
3315
|
+
this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", input.storageKey);
|
|
3239
3316
|
this.audit(workId, "attachment.created", "attachment", attachmentId, {
|
|
3240
3317
|
originalMimeType: input.originalMimeType,
|
|
3241
3318
|
storedMimeType: input.storedMimeType,
|
|
@@ -3262,18 +3339,100 @@ export class Store {
|
|
|
3262
3339
|
throw notFound("附件");
|
|
3263
3340
|
return this.mapAttachment(row);
|
|
3264
3341
|
}
|
|
3342
|
+
attachmentModules(attachmentId) {
|
|
3343
|
+
this.getAttachment(attachmentId);
|
|
3344
|
+
const modules = new Set();
|
|
3345
|
+
for (const row of this.db.all("SELECT module FROM attachment_access_modules WHERE attachment_id = ?", attachmentId)) {
|
|
3346
|
+
const module = String(row.module);
|
|
3347
|
+
if (attachmentPermissionModules.includes(module))
|
|
3348
|
+
modules.add(module);
|
|
3349
|
+
}
|
|
3350
|
+
const referenceModules = {
|
|
3351
|
+
chapter: "prose",
|
|
3352
|
+
draft: "drafts",
|
|
3353
|
+
setting: "settings",
|
|
3354
|
+
"character-section": "characters",
|
|
3355
|
+
race: "races",
|
|
3356
|
+
organization: "organizations"
|
|
3357
|
+
};
|
|
3358
|
+
for (const row of this.db.all("SELECT DISTINCT entity_type FROM attachment_references WHERE attachment_id = ?", attachmentId)) {
|
|
3359
|
+
const module = referenceModules[String(row.entity_type)];
|
|
3360
|
+
if (module)
|
|
3361
|
+
modules.add(module);
|
|
3362
|
+
}
|
|
3363
|
+
return [...modules];
|
|
3364
|
+
}
|
|
3365
|
+
attachmentStorageKeyInUse(storageKey) {
|
|
3366
|
+
return Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) > 0;
|
|
3367
|
+
}
|
|
3368
|
+
enqueueAttachmentCleanup(storageKey) {
|
|
3369
|
+
const timestamp = now();
|
|
3370
|
+
this.db.run(`INSERT INTO attachment_cleanup_queue (storage_key, attempts, last_error, created_at, updated_at)
|
|
3371
|
+
VALUES (?, 0, NULL, ?, ?) ON CONFLICT(storage_key) DO UPDATE SET updated_at = excluded.updated_at`, storageKey, timestamp, timestamp);
|
|
3372
|
+
}
|
|
3373
|
+
listAttachmentCleanupQueue(limit = 100) {
|
|
3374
|
+
return this.db.all("SELECT storage_key, attempts FROM attachment_cleanup_queue ORDER BY updated_at, storage_key LIMIT ?", Math.max(1, Math.min(1_000, Math.trunc(limit)))).map((row) => ({ storageKey: requiredString(row, "storage_key"), attempts: numberValue(row, "attempts") }));
|
|
3375
|
+
}
|
|
3376
|
+
attachmentCleanupStillRequired(storageKey) {
|
|
3377
|
+
return !this.attachmentStorageKeyInUse(storageKey);
|
|
3378
|
+
}
|
|
3379
|
+
completeAttachmentCleanup(storageKey) {
|
|
3380
|
+
this.db.run("DELETE FROM attachment_cleanup_queue WHERE storage_key = ?", storageKey);
|
|
3381
|
+
}
|
|
3382
|
+
failAttachmentCleanup(storageKey, message) {
|
|
3383
|
+
this.db.run("UPDATE attachment_cleanup_queue SET attempts = attempts + 1, last_error = ?, updated_at = ? WHERE storage_key = ?", message.slice(0, 500), now(), storageKey);
|
|
3384
|
+
}
|
|
3385
|
+
attachmentHistoricalReferenceCount(attachmentId) {
|
|
3386
|
+
const needle = `attachment://${attachmentId}`;
|
|
3387
|
+
const sources = [
|
|
3388
|
+
["entity_versions", "snapshot_json"],
|
|
3389
|
+
["character_profile_section_versions", "snapshot_json"],
|
|
3390
|
+
["character_versions", "snapshot_json"],
|
|
3391
|
+
["chapter_versions", "content"],
|
|
3392
|
+
["file_versions", "snapshot_json"]
|
|
3393
|
+
];
|
|
3394
|
+
return sources.reduce((count, [table, column]) => count + Number(this.db.get(`SELECT COUNT(*) AS count FROM ${table} WHERE instr(${column}, ?) > 0`, needle)?.count ?? 0), 0);
|
|
3395
|
+
}
|
|
3396
|
+
queueUnreferencedAttachments(retentionMs = 24 * 60 * 60_000, limit = 100) {
|
|
3397
|
+
const cutoff = new Date(Date.now() - Math.max(0, retentionMs)).toISOString();
|
|
3398
|
+
const candidates = this.db.all(`SELECT attachment.* FROM attachments attachment
|
|
3399
|
+
WHERE attachment.created_at <= ?
|
|
3400
|
+
AND NOT EXISTS (
|
|
3401
|
+
SELECT 1 FROM attachment_references reference WHERE reference.attachment_id = attachment.id
|
|
3402
|
+
)
|
|
3403
|
+
ORDER BY attachment.created_at, attachment.id LIMIT ?`, cutoff, Math.max(1, Math.min(1_000, Math.trunc(limit))));
|
|
3404
|
+
let queued = 0;
|
|
3405
|
+
for (const candidate of candidates) {
|
|
3406
|
+
const attachmentId = requiredString(candidate, "id");
|
|
3407
|
+
if (this.attachmentHistoricalReferenceCount(attachmentId) > 0)
|
|
3408
|
+
continue;
|
|
3409
|
+
const storageKey = requiredString(candidate, "storage_key");
|
|
3410
|
+
this.db.transaction(() => {
|
|
3411
|
+
this.db.run("DELETE FROM attachments WHERE id = ?", attachmentId);
|
|
3412
|
+
this.audit(requiredString(candidate, "work_id"), "attachment.garbage-collected", "attachment", attachmentId, { storageKey });
|
|
3413
|
+
if (!this.attachmentStorageKeyInUse(storageKey))
|
|
3414
|
+
this.enqueueAttachmentCleanup(storageKey);
|
|
3415
|
+
});
|
|
3416
|
+
queued += 1;
|
|
3417
|
+
}
|
|
3418
|
+
return queued;
|
|
3419
|
+
}
|
|
3265
3420
|
deleteAttachment(attachmentId) {
|
|
3266
3421
|
const attachment = this.getAttachment(attachmentId);
|
|
3267
3422
|
const references = Number(this.db.get("SELECT COUNT(*) AS count FROM attachment_references WHERE attachment_id = ?", attachmentId)?.count ?? 0);
|
|
3268
3423
|
if (references > 0)
|
|
3269
3424
|
throw new AppError(409, "ATTACHMENT_IN_USE", "附件仍被资料引用,无法删除");
|
|
3425
|
+
if (this.attachmentHistoricalReferenceCount(attachmentId) > 0) {
|
|
3426
|
+
throw new AppError(409, "ATTACHMENT_IN_VERSION_HISTORY", "附件仍被历史版本引用,无法删除");
|
|
3427
|
+
}
|
|
3270
3428
|
const storageKey = String(attachment.storageKey);
|
|
3271
3429
|
this.db.transaction(() => {
|
|
3272
3430
|
this.db.run("DELETE FROM attachments WHERE id = ?", attachmentId);
|
|
3273
3431
|
this.audit(String(attachment.workId), "attachment.deleted", "attachment", attachmentId, { storageKey });
|
|
3432
|
+
if (!this.attachmentStorageKeyInUse(storageKey))
|
|
3433
|
+
this.enqueueAttachmentCleanup(storageKey);
|
|
3274
3434
|
});
|
|
3275
|
-
|
|
3276
|
-
return { storageKey, removeStoredFile: remaining === 0 };
|
|
3435
|
+
return { storageKey, cleanupQueued: !this.attachmentStorageKeyInUse(storageKey) };
|
|
3277
3436
|
}
|
|
3278
3437
|
getCharacter(characterId) {
|
|
3279
3438
|
const row = this.db.get("SELECT * FROM characters WHERE id = ?", characterId);
|