@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/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) => Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0) === 0);
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 * FROM drafts WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2295
- ORDER BY updated_at DESC, title`, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
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 * FROM drafts WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2301
- ORDER BY updated_at DESC, title${page.sql}`, workId, draftType ?? null, draftType ?? null, ...page.params);
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 * FROM drafts
2312
- WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2313
- AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR content LIKE ? ESCAPE '\\' COLLATE NOCASE)
2314
- ORDER BY CASE WHEN title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, updated_at DESC
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 * FROM drafts WHERE work_id = ? AND (? IS NULL OR draft_type = ?)
2317
- ORDER BY updated_at DESC, title LIMIT ?`, workId, draftType ?? null, draftType ?? null, safeLimit);
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("SELECT * FROM drafts WHERE id = ?", draftId);
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 = ?", input.draftType ?? String(current.draftType), input.title ?? String(current.title), content, now(), draftId);
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
- const remaining = Number(this.db.get("SELECT COUNT(*) AS count FROM attachments WHERE storage_key = ?", storageKey)?.count ?? 0);
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);