feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download

- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

+33 -2
View File
@@ -14,6 +14,7 @@ import { hashApiKey } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import { executeRecord, type FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createFileManagerModule, type FmOverrides } from "../../src/modules/file-manager/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
@@ -47,6 +48,7 @@ type Loose = any;
export interface FmHarness {
db: Kysely<DB>;
engine: ReturnType<typeof fakeEngine>;
module: ReturnType<typeof createFileManagerModule>;
events: { type: string; payload: Loose }[];
base: string;
lib: string;
@@ -57,13 +59,16 @@ export interface FmHarness {
libraryRootId: string;
instanceId: string;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
/** 原始响应(非 JSON 接口,如 /files/download) */
raw(method: string, path: string, key?: string): Promise<Response>;
close(): Promise<void>;
}
export async function createFmHarness(
name: string,
overrides: FmOverrides = {},
opts: { catalog?: boolean } = {},
/** runTransfers(默认 true):假引擎收到 /files 移动/复制的 verifyAndImport 时就地执行(模拟 worker) */
opts: { catalog?: boolean; runTransfers?: boolean } = {},
): Promise<FmHarness> {
const schema = `fm_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new pg.Client({ connectionString: DB_URL });
@@ -144,8 +149,30 @@ export async function createFmHarness(
...overrides.ao,
options: { backgroundLoops: false, minVideoBytes: 10, verifyHash: "sha256", ...(overrides.ao?.options ?? {}) },
},
options: { backgroundLoops: false, dataDir: join(base, "data"), ...overrides.options },
options: { backgroundLoops: false, dataDir: join(base, "data"), transferWaitMs: 10_000, ...overrides.options },
});
if (opts.runTransfers !== false) {
const enqueue = engine.enqueue.bind(engine);
engine.enqueue = async (task, input, o) => {
const r = await enqueue(task, input, o);
const recordId = (input as { recordId?: string }).recordId;
if (task === "verifyAndImport" && recordId)
setTimeout(async () => {
try {
const rec = await db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", recordId)
.executeTakeFirst();
if ((rec?.plan as unknown as FrozenPlan | undefined)?.transfer)
await executeRecord(module.deps().ao, recordId);
} catch {
// 模拟 worker:失败留给测试断言(记录保持 running)
}
}, 0);
return r;
};
}
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
@@ -178,6 +205,7 @@ export async function createFmHarness(
return {
db,
engine,
module,
events,
base,
lib,
@@ -198,6 +226,9 @@ export async function createFmHarness(
});
return { status: res.status, body: await res.json() };
},
raw(method, path, key = DEV_KEY) {
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${key}` } }));
},
async close() {
await db.destroy();
const c = new pg.Client({ connectionString: DB_URL });