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:
1 parent
3641ab9573
commit
36aa99b8aa
25 files changed
+1766
-271
No files matched your search
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user