Files
UHD_Media_Tool/apps/server/src/modules/file-manager/routes.ts
T
rootandClaude Opus 5.5 fd2f7bc33d perf(server): parallel media-server inventory, queued identify remote lookups, dev DB cleanup
- GET /inventory/media-servers: per-instance Promise.allSettled, per-instance
  concurrency cap (2) and timeout (8s); failed/timed-out instances are unknown,
  response carries partial=true (field not yet in contracts)
- S-7: POST /files/identify returns local matches plus remoteTaskId/remotePending
  and enqueues identifyRemote; the worker runs the TMDB lookups and publishes SSE
  identify.completed {taskId,status,items,truncated,excludedBySize,error};
  ?wait=true keeps the synchronous path, capped at 15s overall
- catalog.lookup / release.search honour the caller's abort signal
- scripts/dev-db-clean.ts: dry-run by default, --yes to drop leftover test
  schemas and w32-/w33-/fe4-/qa- prefixed entities; refuses non localhost:55433
- runbook: dev DB cleanup

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 19:47:41 +08:00

125 lines
6.2 KiB
TypeScript

import { createRoute } from "@hono/zod-openapi";
import { Api, apiResponse, type EndpointDef } from "@umt/contracts";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireAdminSession, requireScope } from "../../http/auth.ts";
import { errorBody } from "../../http/errors.ts";
import { PathGuardError } from "../acquisition-organize/services/organize-guard.ts";
import type { ModuleApp } from "../types.ts";
import { entityFromCandidate } from "./candidate.ts";
import { FilesError, type FmDeps, fsErrorToFiles } from "./common.ts";
import { downloadFile } from "./download.ts";
import { identify } from "./identify.ts";
import { idempotent } from "./ledger.ts";
import { listDir, listRoots, statEntry } from "./list.ts";
import { mkdirIn, renameEntry } from "./mutate.ts";
import { organizeFiles } from "./organize.ts";
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
import { findRecords } from "./records.ts";
import { suggestName } from "./suggest-name.ts";
import { transfer } from "./transfer.ts";
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
import { recordSources, treeOf } from "./tree.ts";
// /files 路由:scope 由 contracts 端点定义(读 organize.read / 写 organize.execute),处理器内再按授权矩阵细分。
// FilesError → 信封 + 对应状态(根离线 503,message 以 `<reason>: ` 开头)。
// biome-ignore lint/suspicious/noExplicitAny: 入参已按 contracts schema 校验
type Input = { query: any; body: any };
type Handler = (c: Context<AuthEnv>, input: Input) => Promise<unknown>;
function mount(app: ModuleApp, def: EndpointDef, handler: Handler, raw = false) {
const route = createRoute({
method: def.method,
path: def.path,
tags: ["files"],
summary: def.summary,
// auth:"session" 的端点(download / trash purge)由中间件统一只放行管理员控制台会话,与注册表标注同源
middleware: [requireScope(def.scope), ...(def.auth === "session" ? [requireAdminSession()] : [])] as const,
request: {
...(def.query ? { query: def.query as z.ZodObject } : {}),
...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}),
},
responses: {
200: raw
? { description: "文件流", content: { "application/octet-stream": { schema: def.response } } }
: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
app.openapi(route, (async (c: Context<AuthEnv>) => {
const v = c.req as unknown as { valid(t: string): unknown };
try {
const data = await handler(c, {
query: def.query ? v.valid("query") : {},
body: def.body ? v.valid("json") : undefined,
});
if (raw) return data as Response;
return c.json({ ok: true as const, data }, 200);
} catch (err) {
// 检查与使用之间的文件系统竞态(目录被删、挂载掉线)也按业务错误回,不给 500
const fe = err instanceof PathGuardError ? new FilesError("path_escape", err.message) : fsErrorToFiles(err);
if (fe) return c.json(errorBody(fe.code, `${fe.reason}: ${fe.message}`), fe.status as 400);
throw err;
}
}) as never);
}
/** 写操作的幂等包装(dryRun 不占键) */
const idem = <B extends { idempotencyKey: string; dryRun?: boolean }>(
d: FmDeps,
c: Context<AuthEnv>,
op: string,
body: B,
fn: () => Promise<unknown>,
) => {
if (body.dryRun) return fn();
const { idempotencyKey, ...rest } = body;
return idempotent(d, c.get("principal"), op, idempotencyKey, rest, fn);
};
export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
const F = Api.files;
mount(app, F.roots, async () => listRoots(d));
mount(app, F.list, async (_c, { query }) => listDir(d, query));
mount(app, F.stat, async (_c, { query }) => statEntry(d, query.root, query.path));
mount(app, F.mkdir, async (c, { body }) => idem(d, c, "mkdir", body, () => mkdirIn(d, c.get("principal"), body)));
mount(app, F.rename, async (c, { body }) =>
idem(d, c, "rename", body, () => renameEntry(d, c.get("principal"), body)),
);
mount(app, F.move, async (c, { body }) => idem(d, c, "move", body, () => transfer(d, c.get("principal"), body)));
mount(app, F.copy, async (c, { body }) => {
const b = { ...body, mode: "copy" as const };
return idem(d, c, "copy", b, () => transfer(d, c.get("principal"), b));
});
mount(app, F.organize, async (c, { body }) =>
idem(d, c, "organize", body, () => organizeFiles(d, c.get("principal"), body)),
);
mount(app, F.delete, async (c, { body }) =>
idem(d, c, "delete", body, () => deleteFiles(d, c.get("principal"), body)),
);
mount(app, F.trash, async (_c, { query }) => listTrash(d, query));
mount(app, F.trashRestore, async (c, { body }) =>
idem(d, c, "trash_restore", body, () => restoreTrash(d, c.get("principal"), body)),
);
// 授权矩阵:清空回收站仅 admin 会话(注册表 auth:"session",中间件执行),API Key 与 DEV key 一律拒绝
mount(app, F.trashPurge, async (c, { body }) =>
idem(d, c, "trash_purge", body, () => purgeTrash(d, c.get("principal"), body)),
);
// S-7:缺省只回本地命中 + remoteTaskId(远端结果经 SSE identify.completed 推送);?wait=true 请求内查远端,≤15s
mount(app, F.identify, async (c, { body }) =>
identify(d, c.get("principal"), body, { wait: /^(1|true)$/i.test(c.req.query("wait") ?? "") }),
);
mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body)));
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
// W9-H 文件管理 v2
mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body));
mount(app, F.scrapeBatch, async (c, { body }) =>
idem(d, c, "scrape_batch", body, () => scrapeBatch(d, c.get("principal"), body)),
);
mount(app, F.tree, async (_c, { query }) => treeOf(d, query));
mount(app, F.recordSources, async (_c, { query }) => recordSources(d, query.recordId));
mount(app, F.suggestName, async (_c, { body }) => suggestName(d, body));
}