feat(ao): W4-E3 manual organize guard, series completion, engine retry, v0.1.0

- POST /organization-records: sourcePaths must resolve (realpath) under a
  registered library_root or downloader path-mapping host path; `..` and
  symlink escapes rejected (400), dryRun included. Move requires an admin
  session, or an API key with an automation_grant covering the entity or a
  matching one-time approval (consumed on use), else 403. Manual Move from a
  managed download_task with unsatisfied H&R returns 409 and opens a
  decision; never silently downgraded to Copy.
- Series slot completion: total episode count is unknown while units are
  provisional, so the slot is not satisfied and the subscription keeps
  tracking; coverage is registered per actual file (creating provisional
  episode units), and already_in_library files count as verified presence.
- POST /engine/runs/{id}/retry (admin): re-enqueue by original task name and
  raw input with idempotency key retry:<id>:<n>, audited; retry button on
  the run detail page.
- E7 regular series e2e, E8 AT24 tag removal / task disappearance e2e.
- All workspace members and root set to version 0.1.0; CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:47:11 +08:00
1 parent 591cc78c7d
commit ceff9cf51f
25 files changed
+1566 -57

No files matched your search

@@ -0,0 +1,275 @@
import { existsSync } from "node:fs";
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { sql } from "@umt/db";
import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { evaluateAcquisition } from "../services/acquisition.ts";
import { submitDownload, syncDownloads } from "../services/downloads.ts";
import {
createHarness,
EXEC_KEY,
fakeDisk,
type Harness,
pgAvailable,
seedBase,
seedEntity,
seedRelease,
seedSlot,
} from "./harness.ts";
// 验收 R4:POST /organization-records 的三道闸。
// 路径守卫(400):根外路径 / `..` / 符号链接逃逸(顶层与目录内)/ 不存在;dryRun 同样受限。
// Move 授权(403):API Key 无授权、授权范围不含目标作品 → 拒绝且不留记录;覆盖作品的持续授权、
// 与本次请求一致的一次性审批(用后 consumed)、管理员 → 放行;Copy 与 dryRun 不要求授权。
// 做种义务(409):来源是纳管下载任务(按 ID 或按路径命中)且 H&R 未满足 → 冲突 + 待处理,不改 Copy、不动 qB。
const hasPg = await pgAvailable();
const SIZE = 150_000;
describe.skipIf(!hasPg)("手动整理守卫:路径 / Move 授权 / 做种义务", () => {
let h: Harness;
let qb: MockQb;
let lib: TempTree;
let cache: TempTree;
let outside: TempTree;
const disk = fakeDisk(100_000_000);
const torrents = new Map<string, Uint8Array>();
let base: Awaited<ReturnType<typeof seedBase>>;
let movie: string;
const post = (body: Record<string, unknown>, key?: string) => h.api("POST", "/organization-records", body, key);
const records = async (entityId: string) =>
h.db.selectFrom("organization_record").select(["id", "action"]).where("media_entity_id", "=", entityId).execute();
const file = async (rel: string) => cache.write(rel, SIZE);
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
lib = await createTempTree({}, { sentinel: true, prefix: "umt-glib-" });
cache = await createTempTree({}, { prefix: "umt-gcache-" });
outside = await createTempTree(
{ "Secret.2020.1080p.BluRay-X.mkv": SIZE, "etc/passwd": 100 },
{ prefix: "umt-gout-" },
);
disk.state.watch = [cache.root];
disk.setPct(10);
h = await createHarness("guard", {
diskUsage: disk.fn,
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
});
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
movie = await seedEntity(h.db, { kind: "movie", title: "守卫片", year: 2020 });
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
for (const t of [lib, cache, outside]) await t?.cleanup();
});
it("路径守卫:根外 / `..` / 符号链接逃逸 / 不存在 → 400,dryRun 也不例外,且不留记录", async () => {
const outFile = outside.path("Secret.2020.1080p.BluRay-X.mkv");
for (const dryRun of [true, false]) {
const r = await post({ sourcePaths: [outFile], mediaEntityId: movie, action: "copy", dryRun });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("不在已登记的媒体库根或下载器路径映射之下");
}
// `..` 即使规范化后仍在根内也拒绝
const inside = await file("Dots.2020.1080p.BluRay-X.mkv");
const dotted = `${cache.root}/sub/../Dots.2020.1080p.BluRay-X.mkv`;
expect((await post({ sourcePaths: [dotted], mediaEntityId: movie, action: "copy" })).status).toBe(400);
// 顶层符号链接:根内的链接指向根外文件
const link = join(cache.root, "Link.2020.1080p.BluRay-X.mkv");
await symlink(outFile, link);
const r1 = await post({ sourcePaths: [link], mediaEntityId: movie, action: "copy", dryRun: true });
expect(r1.status).toBe(400);
// 目录内的符号链接:遍历用 lstat,不跟随链接(文件链接、目录链接都不进计划),根外内容不会被列出
const dir = join(cache.root, "Pack.2020.1080p.BluRay-X");
await mkdir(dir);
await writeFile(join(dir, "Pack.2020.1080p.BluRay-X.mkv"), Buffer.alloc(SIZE, 1));
await symlink(outside.root, join(dir, "escape"));
await symlink(outFile, join(dir, "Pack.2020.1080p.BluRay-X.sample.mkv"));
const r2 = await post({ sourcePaths: [dir], mediaEntityId: movie, action: "copy", dryRun: true });
expect(r2.status).toBe(201);
const planArt = await h.db
.selectFrom("artifact")
.select("content")
.where("id", "=", r2.body.data.planId)
.executeTakeFirstOrThrow();
const srcs = (planArt.content as { plan: { organize: { files: { src: string }[] } } }).plan.organize.files.map(
(f) => f.src,
);
expect(srcs).toEqual([join(dir, "Pack.2020.1080p.BluRay-X.mkv")]);
expect(JSON.stringify(planArt.content)).not.toContain(outside.root);
// 不存在
expect(
(await post({ sourcePaths: [join(cache.root, "nope.mkv")], mediaEntityId: movie, action: "copy" })).status,
).toBe(400);
expect(await records(movie)).toHaveLength(0);
expect(existsSync(outFile)).toBe(true);
// 根内正常路径:dryRun 通过
const ok = await post({ sourcePaths: [inside], mediaEntityId: movie, action: "copy", dryRun: true }, EXEC_KEY);
expect(ok.status).toBe(201);
expect(ok.body.data.planId).toBeTruthy();
});
it("Move 授权:API Key 无授权 / 范围不含作品 → 403 不留记录;dryRun 与 Copy 不要求授权", async () => {
const src = await file("Auth.2020.1080p.BluRay-X.mkv");
const denied = await post({ sourcePaths: [src], mediaEntityId: movie, action: "move" }, EXEC_KEY);
expect(denied.status).toBe(403);
expect(denied.body.error.message).toContain("需要");
expect(await records(movie)).toHaveLength(0);
expect(existsSync(src)).toBe(true);
// 授权范围只含别的作品
const other = await seedEntity(h.db, { kind: "movie", title: "别的片", year: 2019 });
const g = await h.db
.insertInto("automation_grant")
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [other] }), granted_by: "test" })
.returning("id")
.executeTakeFirstOrThrow();
const scoped = await post({ sourcePaths: [src], mediaEntityId: movie, action: "move" }, EXEC_KEY);
expect(scoped.status).toBe(403);
expect(scoped.body.error.message).toContain("对象范围不含目标作品");
// 没有目标作品时持续授权无从核对
expect((await post({ sourcePaths: [src], action: "move" }, EXEC_KEY)).status).toBe(403);
// dryRun Move / Copy:只要 organize.execute
expect(
(await post({ sourcePaths: [src], mediaEntityId: movie, action: "move", dryRun: true }, EXEC_KEY)).status,
).toBe(201);
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", g.id).execute();
const audits = await sql<{ n: number }>`
select count(*)::int as n from audit_event where action = 'organize.move_denied'`.execute(h.db);
expect(audits.rows[0]!.n).toBe(3);
});
it("Move 授权:覆盖作品的持续授权放行;策略版本非发布状态不算", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "授权片", year: 2021 });
const src = await file("Granted.2021.1080p.BluRay-X.mkv");
const pol = await h.db
.insertInto("policy_revision")
.values({ policy_key: "g", revision: 1, kind: "acquisition", title: "g", status: "draft" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("automation_grant")
.values({
action: "policy.acquisition",
policy_revision_id: pol.id,
scope: JSON.stringify({ mediaKinds: ["movie"] }),
granted_by: "policy:g",
})
.execute();
const draft = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" }, EXEC_KEY);
expect(draft.status).toBe(403);
expect(draft.body.error.message).toContain("不是发布状态");
const g = await h.db
.insertInto("automation_grant")
.values({ action: "organize.execute", scope: JSON.stringify({ mediaEntityIds: [e] }), granted_by: "test" })
.returning("id")
.executeTakeFirstOrThrow();
const ok = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" }, EXEC_KEY);
expect(ok.status).toBe(201);
expect((await records(e)).map((r) => r.action)).toEqual(["move"]);
await h.db.updateTable("automation_grant").set({ status: "revoked" }).where("id", "=", g.id).execute();
});
it("Move 授权:一次性审批绑定 dryRun 计划,请求一致才放行,用后 consumed 不可复用", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "审批片", year: 2022 });
const src = await file("Approved.2022.1080p.BluRay-X.mkv");
const req = { sourcePaths: [src], mediaEntityId: e, action: "move" as const };
const dry = await post({ ...req, dryRun: true }, EXEC_KEY);
expect(dry.status).toBe(201);
const art = await h.db
.selectFrom("artifact")
.select(["id", "content_hash", "content"])
.where("id", "=", dry.body.data.planId)
.executeTakeFirstOrThrow();
expect(art.content).toMatchObject({ mediaEntityId: e, mediaKind: "movie" });
const approve = (hash: string) =>
h.db
.insertInto("approval")
.values({
plan_type: "organize_plan",
plan_id: art.id,
payload_hash: hash,
expected_revision: 1,
status: "approved",
requested_by: "api_key:x",
decided_by: "admin_session:a",
})
.returning("id")
.executeTakeFirstOrThrow();
// 请求与计划不一致(换了目标库根字段)→ 不算
const a = await approve(art.content_hash);
const mismatch = await post({ ...req, action: "move", targetLibraryRootId: base.rootId }, EXEC_KEY);
expect(mismatch.status).toBe(403);
const ok = await post(req, EXEC_KEY);
expect(ok.status).toBe(201);
const used = await h.db.selectFrom("approval").select("status").where("id", "=", a.id).executeTakeFirstOrThrow();
expect(used.status).toBe("consumed");
// 用过的审批不能再放行第二次
const again = await post(req, EXEC_KEY);
expect(again.status).toBe(403);
// payloadHash 与计划内容不符的审批无效
await approve("tampered");
expect((await post(req, EXEC_KEY)).status).toBe(403);
});
it("做种义务:纳管下载任务 H&R 未满足 → 409 + 待处理,不改 Copy、不动 qB;管理员 Copy 可以", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "做种片", year: 2023 });
await seedSlot(h.db, { entityId: e });
const t = makeTorrent({ name: "Seeding.2023.1080p.BluRay-HR.mkv", length: SIZE, pieceSeed: 11 });
const relId = await seedRelease(h.db, {
siteId: base.siteId,
entityId: e,
title: "Seeding.2023.1080p.BluRay-HR",
size: SIZE,
hash: t.hash,
hr: true,
});
torrents.set(relId, t.bytes);
const ev = await evaluateAcquisition(h.d, { sourceReleaseId: relId });
const sub = await submitDownload(h.d, ev.plans[0]!);
await qb.complete(t.hash, { writeFiles: true });
await syncDownloads(h.d);
const dtId = sub.downloadTaskId!;
const deletesBefore = qb.callsTo("torrents/delete").length;
// 按下载任务 ID(管理员也一样被做种义务拦下)
const byId = await post({ downloadTaskId: dtId, action: "move" });
expect(byId.status).toBe(409);
expect(byId.body.error.message).toContain("未改为 Copy");
// 按路径命中纳管任务的内容
const content = join(cache.root, "Seeding.2023.1080p.BluRay-HR.mkv");
expect(existsSync(content)).toBe(true);
const byPath = await post({ sourcePaths: [content], mediaEntityId: e, action: "move" });
expect(byPath.status).toBe(409);
const decisions = await h.db
.selectFrom("decision")
.select(["kind", "status", "attempts"])
.where("dedupe_key", "=", `manual_move_hnr:${dtId}`)
.execute();
expect(decisions).toMatchObject([{ kind: "approval_required", status: "open" }]);
expect(Number(decisions[0]!.attempts)).toBeGreaterThanOrEqual(1);
expect(await records(e)).toHaveLength(0);
expect(qb.callsTo("torrents/delete")).toHaveLength(deletesBefore);
expect(qb.get(t.hash)).toBeDefined();
expect(existsSync(content)).toBe(true);
// 同一来源 Copy 不受做种义务限制
const copy = await post({ downloadTaskId: dtId, action: "copy" });
expect(copy.status).toBe(201);
expect((await records(e)).map((r) => r.action)).toEqual(["copy"]);
}, 60_000);
it("管理员会话(dev admin)按路径 Move:放行并记审计", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "管理员片", year: 2024 });
const src = await file("Admin.2024.1080p.BluRay-X.mkv");
const r = await post({ sourcePaths: [src], mediaEntityId: e, action: "move" });
expect(r.status).toBe(201);
const a = await sql<{ payload: { via: string } }>`
select payload from audit_event where action = 'organize.move_authorized' and target_id = ${e}`.execute(h.db);
expect(a.rows.map((x) => x.payload.via)).toEqual(["admin_session"]);
});
});
@@ -56,7 +56,8 @@ describe.skipIf(!hasPg)("整理:跨卷 Move 恢复 / 路径绑定固定 / 手动
"Show.S02E01.1080p.WEB-DL.x264-GRP.mkv": SIZE,
"Show.S01E01.1080p.WEB-DL.x264-GRP.chs.srt": 2_000,
},
{ prefix: "umt-src-" },
// 手动整理的源路径必须在已登记的根下(验收 R4):放在下载器保存路径里
{ prefix: "umt-src-", base: cache.root },
);
disk.state.watch = [cache.root];
disk.setPct(30);
@@ -5,7 +5,16 @@ import { ApiException } from "../../../http/errors.ts";
import type { ModuleApp } from "../../types.ts";
import { type AoDeps, ensureTaskRow, markTask } from "../services/common.ts";
import { loadRecordsPublic, planImport } from "../services/organize.ts";
import { actorOf, errMessage, jsonb, notFound } from "../util.ts";
import {
authorizeMove,
hnrBlockForMove,
managedTasksUnder,
PathGuardError,
requestEntityId,
type SourceGuard,
sourceGuard,
} from "../services/organize-guard.ts";
import { actorOf, errMessage, hashJson, jsonb, notFound } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
// 整理记录公开 API(organize.read / organize.execute)。changes 必须先于 {id} 注册。
@@ -80,13 +89,65 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG },
);
// 手动发起整理:与自动整理走同一冻结计划流程;返回 record/task ID,由任务页追踪
// 手动发起整理:与自动整理走同一冻结计划流程;返回 record/task ID,由任务页追踪。
// 先过三道闸(organize-guard.ts):路径守卫(400)→ Move 授权(403)→ 纳管来源的做种义务(409 + 待处理)。
mount(
app,
Api.organization.create,
async (c, { body }) => {
const principal = c.get("principal");
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
// ① 路径守卫:dryRun 也受限(否则可借计划枚举任意目录)
let guard: SourceGuard | null = null;
const realPaths: string[] = [];
if (body.sourcePaths) {
guard = await sourceGuard(db());
try {
for (const p of body.sourcePaths) realPaths.push(await guard.check(p));
} catch (err) {
if (err instanceof PathGuardError) throw new ApiException("bad_request", err.message);
throw err;
}
}
if (body.downloadTaskId) {
const dt = await db()
.selectFrom("download_task")
.select(["status"])
.where("id", "=", body.downloadTaskId)
.executeTakeFirst();
if (!dt) throw new ApiException("not_found", "下载任务不存在");
if (!["completed", "importing", "imported"].includes(dt.status))
throw new ApiException("precondition_failed", `下载任务尚未完成(${dt.status})`);
}
// ② / ③ Move:删源 + 移除 qB 任务记录,必须有授权;纳管来源还要满足做种义务
if (!body.dryRun && body.action === "move") {
const managed = body.downloadTaskId ? [body.downloadTaskId] : await managedTasksUnder(db(), realPaths);
const entityId = await requestEntityId(db(), {
mediaEntityId: body.mediaEntityId,
downloadTaskIds: managed,
});
const auth = await authorizeMove(d, principal, body, entityId);
if (!auth.allowed) {
await audit(d, actorOf(c), "organize.move_denied", { reason: auth.reason, entityId });
throw new ApiException("forbidden", auth.reason);
}
const block = await hnrBlockForMove(d, managed, { entityId, actor: actorOf(c) });
if (block) {
throw new ApiException(
"conflict",
`来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},` +
`拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`,
);
}
await audit(d, actorOf(c), "organize.move_authorized", {
via: auth.via,
...(auth.id ? { id: auth.id } : {}),
entityId,
});
}
const idem =
body.downloadTaskId && !body.dryRun
? `manual:${body.downloadTaskId}:${randomUUID()}`
@@ -99,16 +160,6 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
subjectId: body.downloadTaskId ?? null,
triggeredBy: actorOf(c),
});
if (body.downloadTaskId) {
const dt = await db()
.selectFrom("download_task")
.select(["status"])
.where("id", "=", body.downloadTaskId)
.executeTakeFirst();
if (!dt) throw new ApiException("not_found", "下载任务不存在");
if (!["completed", "importing", "imported"].includes(dt.status))
throw new ApiException("precondition_failed", `下载任务尚未完成(${dt.status})`);
}
let plan: Awaited<ReturnType<typeof planImport>>;
try {
plan = await planImport(d, {
@@ -123,13 +174,31 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
actorId: actorOf(c),
taskId,
dryRun: body.dryRun,
...(guard ? { pathGuard: guard.check } : {}),
});
} catch (err) {
await markTask(db(), taskId, "failed", { error: errMessage(err) });
throw new ApiException("bad_request", errMessage(err));
}
if (body.dryRun) {
// 只生成计划:冻结为 artifact(kind=plan),planId 供 organize.execute 使用
// 只生成计划:冻结为 artifact(kind=plan),planId 供一次性审批 / organize.execute 使用。
// content_hash 由内容确定(审批绑定 payloadHash);mediaEntityId/mediaKind 供授权范围核对。
const entity = plan.plan
? await db()
.selectFrom("media_entity")
.select(["kind"])
.where("id", "=", plan.plan.entityId)
.executeTakeFirst()
: undefined;
const content = {
request: body,
plan: plan.plan ?? null,
reason: plan.reason,
status: plan.status,
truncated: plan.truncated === true,
mediaEntityId: plan.plan?.entityId ?? body.mediaEntityId ?? null,
mediaKind: entity?.kind ?? null,
};
const art = await db()
.insertInto("artifact")
.values({
@@ -137,14 +206,8 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
task_id: taskId,
subject_type: "organize_request",
title: "organize_plan",
content: jsonb({
request: body,
plan: plan.plan ?? null,
reason: plan.reason,
status: plan.status,
truncated: plan.truncated === true,
}),
content_hash: randomUUID(),
content: jsonb(content),
content_hash: hashJson({ ...content, taskId }),
})
.returning("id")
.executeTakeFirstOrThrow();
@@ -175,3 +238,17 @@ export function registerOrganizationRoutes(app: ModuleApp, d: AoDeps) {
{ tag: TAG, status: 201 },
);
}
async function audit(d: AoDeps, actor: string, action: string, detail: Record<string, unknown>) {
await d.ctx.db
.insertInto("audit_event")
.values({
actor_type: actor.startsWith("api_key:") ? "api_key" : "user",
actor_id: actor,
action,
target_type: "organize_request",
target_id: typeof detail.entityId === "string" ? detail.entityId : null,
payload: jsonb(detail),
})
.execute();
}
@@ -0,0 +1,297 @@
import { realpath } from "node:fs/promises";
import { isAbsolute, resolve, sep } from "node:path";
import type { Principal } from "../../../http/auth.ts";
import { canonicalJson } from "../util.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
import { hnrSatisfied } from "./retention.ts";
// 手动整理(POST /organization-records)的三道闸(验收 R4,REQ §2.1 原则 5、§8.3):
// 1) 路径守卫:sourcePaths 必须落在已登记的 library_root 或下载器路径映射的本机路径之下
// (realpath 规范化后按目录前缀匹配;拒绝 `..` 段与符号链接逃逸,遍历时逐个文件复核)。dryRun 同样受限。
// 2) 授权:Move 会删源、会移除 qB 任务记录,必须是管理员会话,或 API Key 同时有覆盖目标作品的
// automation_grant(发布策略生成)/ 与本次请求一致的一次性 approval;Copy 只需 organize.execute scope。
// 3) 做种义务:来源是纳管下载任务时,Move 前按 verifyAndImport 同一判定检查 H&R;未满足 → 409 + 待处理,
// 不静默改成 Copy。
export class PathGuardError extends Error {
override name = "PathGuardError";
constructor(
message: string,
readonly path: string,
) {
super(message);
}
}
const within = (p: string, root: string) => p === root || p.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
async function real(p: string): Promise<string> {
try {
return await realpath(p);
} catch {
return resolve(p);
}
}
/** 允许作为整理来源的本机根:媒体库根 + 下载器路径映射的本机前缀(无映射实例 = 同机同路径,取保存路径) */
export async function allowedSourceRoots(exec: Exec): Promise<string[]> {
const roots = new Set<string>();
for (const r of await exec.selectFrom("library_root").select("local_path").execute()) roots.add(r.local_path);
for (const t of await loadInstances(exec, { enabledOnly: false })) {
for (const m of t.mappings) roots.add(m.hostPath);
if (t.mappings.length === 0 && t.localSavePath) roots.add(t.localSavePath);
}
const out: string[] = [];
for (const r of roots) if (r && isAbsolute(r)) out.push(await real(r.replace(/\/+$/, "") || "/"));
// 根目录本身("/")不能作为白名单,否则等于不设防
return [...new Set(out)].filter((r) => r !== "/");
}
export interface SourceGuard {
roots: string[];
/** 返回 realpath;不在任何根下抛 PathGuardError */
check(p: string): Promise<string>;
}
export async function sourceGuard(exec: Exec): Promise<SourceGuard> {
const roots = await allowedSourceRoots(exec);
return {
roots,
async check(p) {
if (!isAbsolute(p)) throw new PathGuardError(`源路径必须是绝对路径: ${p}`, p);
if (p.split("/").some((seg) => seg === "..")) throw new PathGuardError(`源路径不允许包含 "..": ${p}`, p);
let r: string;
try {
r = await realpath(p);
} catch {
throw new PathGuardError(`源路径不存在或不可访问: ${p}`, p);
}
if (!roots.some((root) => within(r, root)))
throw new PathGuardError(`源路径不在已登记的媒体库根或下载器路径映射之下: ${p}`, p);
return r;
},
};
}
// ── 目标作品与来源下载任务 ──
/** 手动请求的目标作品:显式 mediaEntityId,否则取下载任务的计划 Slot / 来源资源的作品 */
export async function requestEntityId(
exec: Exec,
req: { mediaEntityId?: string | undefined; downloadTaskIds: string[] },
): Promise<string | null> {
if (req.mediaEntityId) return req.mediaEntityId;
for (const id of req.downloadTaskIds) {
const r = await exec
.selectFrom("download_task as dt")
.leftJoin("acquisition_plan as p", "p.id", "dt.acquisition_plan_id")
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
.leftJoin("source_release as sr", "sr.id", "dt.source_release_id")
.select(["s.media_entity_id as slot_entity", "sr.media_entity_id as release_entity"])
.where("dt.id", "=", id)
.executeTakeFirst();
const e = r?.slot_entity ?? r?.release_entity;
if (e) return e;
}
return null;
}
/** 与这些源路径重叠的纳管下载任务(源路径在任务内容里,或任务内容在源路径下) */
export async function managedTasksUnder(exec: Exec, realPaths: string[]): Promise<string[]> {
const rows = await exec
.selectFrom("download_task")
.select(["id", "instance_id", "content_path", "save_path", "name"])
.where("managed", "=", true)
.where("removed_at", "is", null)
.where("status", "!=", "removed")
.execute();
if (rows.length === 0) return [];
const topo = new Map((await loadInstances(exec, { enabledOnly: false })).map((t) => [t.inst.id, t]));
const hit: string[] = [];
for (const r of rows) {
const t = topo.get(r.instance_id);
if (!t) continue;
const content = r.content_path ?? (r.save_path && r.name ? `${r.save_path.replace(/\/+$/, "")}/${r.name}` : null);
const host = content ? hostPathOf(t, content) : null;
if (!host) continue;
const h = await real(host);
if (realPaths.some((p) => within(p, h) || within(h, p))) hit.push(r.id);
}
return hit;
}
// ── 授权 ──
const GRANT_ACTIONS = ["organize.execute", "policy.acquisition", "policy.organize", "*"];
function kindCovered(kinds: readonly string[], entityKind: string): boolean {
if (kinds.includes(entityKind)) return true;
return entityKind !== "movie" && entityKind !== "variety" && kinds.includes("series");
}
/** 手动请求里参与"是否同一请求"比对的字段(不含 dryRun) */
export function requestKey(body: {
downloadTaskId?: string | undefined;
sourcePaths?: string[] | undefined;
mediaEntityId?: string | undefined;
contentUnitId?: string | undefined;
action: "copy" | "move";
targetLibraryRootId?: string | undefined;
}): string {
return canonicalJson({
downloadTaskId: body.downloadTaskId,
sourcePaths: body.sourcePaths,
mediaEntityId: body.mediaEntityId,
contentUnitId: body.contentUnitId,
action: body.action,
targetLibraryRootId: body.targetLibraryRootId,
});
}
export type MoveAuthorization =
| { allowed: true; via: "admin_session" | "grant" | "approval"; id?: string }
| { allowed: false; reason: string };
/**
* Move 授权:管理员会话直接放行;API Key 需要覆盖目标作品的持续授权,或一次性审批——
* 审批绑定的是 dryRun 生成的整理计划(artifact kind=plan),计划里冻结的请求须与本次请求一致;用后标 consumed。
*/
export async function authorizeMove(
d: AoDeps,
principal: Principal | undefined,
body: Parameters<typeof requestKey>[0],
entityId: string | null,
): Promise<MoveAuthorization> {
if (principal?.kind === "admin_session" || principal?.kind === "dev_admin")
return { allowed: true, via: "admin_session" };
const db = d.ctx.db;
const now = d.ports.now();
const entity = entityId
? await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "=", entityId).executeTakeFirst()
: undefined;
const reasons: string[] = [];
// 1) 一次性审批
const key = requestKey(body);
const approvals = await db
.selectFrom("approval as a")
.innerJoin("artifact as art", "art.id", "a.plan_id")
.select(["a.id", "a.payload_hash", "a.expires_at", "art.content_hash", "art.content"])
.where("a.plan_type", "=", "organize_plan")
.where("a.status", "=", "approved")
.where("art.kind", "=", "plan")
.where((eb) => eb.or([eb("a.expires_at", "is", null), eb("a.expires_at", ">", now)]))
.orderBy("a.created_at", "desc")
.execute();
for (const a of approvals) {
const req = ((a.content ?? {}) as { request?: Parameters<typeof requestKey>[0] }).request;
if (!req || requestKey(req) !== key) continue;
if (a.payload_hash !== a.content_hash) {
reasons.push(`审批 ${a.id} 绑定的计划内容已变化`);
continue;
}
const used = await db
.updateTable("approval")
.set({ status: "consumed" })
.where("id", "=", a.id)
.where("status", "=", "approved")
.executeTakeFirst();
if (Number(used.numUpdatedRows ?? 0n) === 1) return { allowed: true, via: "approval", id: a.id };
}
// 2) 持续授权
if (!entity) {
reasons.push("无法确定目标作品,持续授权无从核对范围");
} else {
const grants = await db
.selectFrom("automation_grant as g")
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
.select(["g.id", "g.scope", "g.policy_revision_id", "p.status as policy_status"])
.where("g.status", "=", "active")
.where("g.action", "in", GRANT_ACTIONS)
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
.orderBy("g.created_at", "desc")
.execute();
for (const g of grants) {
if (g.policy_revision_id && g.policy_status !== "published") {
reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
continue;
}
const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
if (scope.capabilities?.length && !scope.capabilities.includes("organize.execute")) {
reasons.push(`授权 ${g.id} 不含 organize.execute`);
continue;
}
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(entity.id)) {
reasons.push(`授权 ${g.id} 的对象范围不含目标作品`);
continue;
}
if (scope.mediaKinds?.length && !kindCovered(scope.mediaKinds, entity.kind)) {
reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${entity.kind}`);
continue;
}
return { allowed: true, via: "grant", id: g.id };
}
}
return {
allowed: false,
reason:
reasons.length > 0
? `Move 需要授权:${reasons.join(";")}`
: "Move 会删除源文件并移除下载器任务记录,需要管理员会话,或覆盖目标作品的持续授权 / 一次性审批",
};
}
// ── 做种义务 ──
export interface HnrBlock {
downloadTaskId: string;
hnr: false | "unknown";
decisionId: string;
}
/** 纳管下载任务的 H&R / 保留要求:未满足(含未知)→ 建待处理并返回阻止信息 */
export async function hnrBlockForMove(
d: AoDeps,
downloadTaskIds: string[],
ctx: { entityId: string | null; actor: string },
): Promise<HnrBlock | null> {
const db = d.ctx.db;
for (const id of downloadTaskIds) {
const dt = await db.selectFrom("download_task").selectAll().where("id", "=", id).executeTakeFirst();
if (!dt) continue;
const rel = dt.source_release_id
? await db.selectFrom("source_release").selectAll().where("id", "=", dt.source_release_id).executeTakeFirst()
: undefined;
const site = rel
? await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst()
: undefined;
const hnr = hnrSatisfied(dt, rel, site);
if (hnr === true) continue;
const decisionId = await upsertDecision(d, db, {
kind: "approval_required",
title: `手动 Move 被做种义务阻止:${dt.name ?? dt.torrent_hash}`,
summary:
hnr === false
? "来源是纳管下载任务,H&R / 做种要求尚未满足;Move 会删源并移除任务,已拒绝(未改为 Copy)"
: "来源是纳管下载任务,H&R 要求未知,按未满足处理;Move 已拒绝(未改为 Copy)",
subjectType: "download_task",
subjectId: dt.id,
dedupeKey: `manual_move_hnr:${dt.id}`,
evidence: {
downloadTaskId: dt.id,
hnr,
seedingTimeSec: dt.seeding_time_sec,
ratio: dt.ratio,
mediaEntityId: ctx.entityId,
requestedBy: ctx.actor,
},
options: [
{ key: "wait", label: "等待做种义务满足后再 Move" },
{ key: "copy", label: "改用 Copy 入库(需重新发起)" },
],
});
return { downloadTaskId: dt.id, hnr, decisionId };
}
return null;
}
@@ -37,6 +37,7 @@ import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
import { applySlotEvent } from "./downloads.ts";
import { PathGuardError } from "./organize-guard.ts";
import { hnrSatisfied } from "./retention.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
@@ -56,6 +57,8 @@ export interface ImportRequest {
taskId?: string | null;
/** 只生成计划,不写记录、不建待处理 */
dryRun?: boolean;
/** 手动按路径整理:遍历到的每个文件都经它复核(realpath 在允许的根下),越界抛 PathGuardError */
pathGuard?: ((p: string) => Promise<string>) | undefined;
}
/** organization_record.plan 的冻结内容 */
@@ -111,10 +114,17 @@ function segmentsWithin(path: string, root: string | null) {
}
/** 递归列出文件(跳过隐藏项),最多 limit 个;调用方传 上限+1 判断是否截断 */
async function walk(p: string, out: string[], limit: number): Promise<void> {
async function walk(
p: string,
out: string[],
limit: number,
guard?: ((p: string) => Promise<string>) | undefined,
): Promise<void> {
const st = await statSafe(p);
if (st.kind !== "ok") throw new FsError("ENOENT", `源路径不可访问: ${p} (${st.kind})`, { path: p });
if (st.facts.isFile) {
// 目录里的符号链接可能指向根外:逐个文件复核 realpath
if (guard) await guard(p);
out.push(p);
return;
}
@@ -123,7 +133,7 @@ async function walk(p: string, out: string[], limit: number): Promise<void> {
for (const n of names) {
if (out.length >= limit) return;
if (n.startsWith(".")) continue;
await walk(join(p, n), out, limit);
await walk(join(p, n), out, limit, guard);
}
}
@@ -235,8 +245,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const max = d.ports.options.walkMaxFiles;
try {
// 多走一个用来判断是否超限
for (const p of req.sourcePaths) await walk(p, paths, max + 1);
for (const p of req.sourcePaths) await walk(p, paths, max + 1, req.pathGuard);
} catch (err) {
if (err instanceof PathGuardError) throw err;
return fail("source_missing", { message: errMessage(err) });
}
if (paths.length > max) {
@@ -298,7 +309,8 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
: undefined;
const hnr = dt ? hnrSatisfied(dt, rel, site) : true;
let downgradedFromMove: string | undefined;
if (action === "move" && hnr !== true && policy?.rules.allowCopyWhenHnrPending) {
// 手动整理显式要求 Move 时不降级(未满足由路由 409 + 待处理拦下);只有自动整理按策略预授权改 Copy
if (action === "move" && hnr !== true && req.trigger !== "manual" && policy?.rules.allowCopyWhenHnrPending) {
// 策略预先授权:H&R 未满足时改用 Copy(显式记录,不是静默降级)
action = "copy";
downgradedFromMove = hnr === false ? "hnr_unsatisfied" : "hnr_unknown";
@@ -942,7 +954,30 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
const planFiles = new Map(frozen.organize.files.map((f) => [f.src, f]));
const assetBySrc = new Map<string, { assetId: string; locationId: string }>();
const mainAssets: string[] = [];
const presentAssets: string[] = [];
for (const f of files) {
// 目标已在库且全量 sha256 相同(already_in_library):不重复登记,但已登记的资产算作本次核验到的内容
// (例如整季包里与单集入库相同的文件),参与 Slot 覆盖判断
if (f.status === "skipped" && f.skip_reason === "already_in_library" && f.role === "main" && f.target_path) {
const have = await trx
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select("a.id")
.where("l.library_root_id", "=", frozen.libraryRootId)
.where("l.relative_path", "=", f.target_path)
.where("l.status", "!=", "removed")
.where("a.status", "=", "active")
.executeTakeFirst();
if (have) {
presentAssets.push(have.id);
await trx
.updateTable("organization_file_result")
.set({ media_asset_id: have.id })
.where("id", "=", f.id)
.execute();
}
continue;
}
if (f.status !== "succeeded" || !f.target_path) continue;
const pf = planFiles.get(f.source_path);
const isMain = f.role === "main";
@@ -1018,16 +1053,9 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.returning("id")
.executeTakeFirstOrThrow();
locationId = loc.id;
if (isMain && fx?.episodes) {
const units = await trx
.selectFrom("content_unit")
.select("id")
.where("media_entity_id", "=", frozen.entityId)
.where("kind", "=", "episode")
.where("season_number", "=", fx.season ?? 1)
.where("episode_number", ">=", fx.episodes.start)
.where("episode_number", "<=", fx.episodes.end)
.execute();
if (isMain && fx?.episodes && frozen.mediaType === "tv") {
// 覆盖按实际文件:整季包里的每个文件按文件名解析出的集号登记;单元未建档的建临时单元(provisional)
const units = await ensureEpisodeUnits(trx, frozen.entityId, fx.season ?? 1, fx.episodes);
for (const u of units) {
await trx
.insertInto("asset_unit_coverage")
@@ -1058,7 +1086,8 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
file_failed: statuses.filter((s) => s === "failed").length,
file_skipped: statuses.filter((s) => s === "skipped").length,
};
const okAny = mainAssets.length > 0;
const verifiedAssets = [...mainAssets, ...presentAssets];
const okAny = verifiedAssets.length > 0;
await trx
.updateTable("organization_record")
.set({
@@ -1096,16 +1125,16 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.where("id", "=", plan.slot_id)
.executeTakeFirst();
if (slot && okAny) {
const complete = await slotComplete(trx, slot, frozen, mainAssets);
const complete = await slotComplete(trx, slot, frozen, verifiedAssets);
const old = slot.current_asset_id;
await applySlotEvent(
d,
trx,
slot.id,
{ type: "verify_passed", complete },
{ currentAssetId: mainAssets[0]!, reason: complete ? "verified" : "verified_partial" },
{ currentAssetId: verifiedAssets[0]!, reason: complete ? "verified" : "verified_partial" },
);
if (old && old !== mainAssets[0] && plan.purpose === "upgrade") {
if (old && old !== verifiedAssets[0] && plan.purpose === "upgrade") {
// 升级:旧版本保留,处置只记待处理,不删
await upsertDecision(d, trx, {
kind: "other",
@@ -1184,13 +1213,18 @@ async function slotComplete(
// 剧集/综艺:按内容单元覆盖;单元未建档(总集数未知)时继续追踪,不声称完整
let q = exec
.selectFrom("content_unit")
.select("id")
.select(["id", "status"])
.where("media_entity_id", "=", slot.media_entity_id)
.where("kind", "=", "episode")
.where("content_type", "=", "main");
if (slot.content_unit_id) q = q.where("parent_unit_id", "=", slot.content_unit_id);
const required = (await q.execute()).map((u) => u.id);
const units = await q.where("status", "!=", "retired").execute();
const required = units.map((u) => u.id);
if (required.length === 0) return false;
// 总集数只有在单元都已确认(元数据/人工)时才算已知;由发布标题或文件名临时建出的单元(provisional)
// 只说明"见过这些集",不说明"只有这些集":总集数未知时不判完整,订阅继续追踪(REQ §7.1.1)
const totalKnown = units.every((u) => u.status === "confirmed");
if (!totalKnown) return false;
const cov = await sql<{ asset_id: string; unit_id: string }>`
select c.media_asset_id as asset_id, c.content_unit_id as unit_id from asset_unit_coverage c
join media_asset a on a.id = c.media_asset_id
@@ -1200,10 +1234,57 @@ async function slotComplete(
return unitCompletion(
required,
[...byAsset].map(([assetId, fileUnits]) => ({ assetId, verified: true, fileUnits })),
{ totalKnown: true },
{ totalKnown },
).complete;
}
/** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */
async function ensureEpisodeUnits(
exec: Exec,
entityId: string,
season: number,
eps: { start: number; end: number },
): Promise<{ id: string }[]> {
const find = (kind: "season" | "episode", ep: number | null) => {
let q = exec
.selectFrom("content_unit")
.select("id")
.where("media_entity_id", "=", entityId)
.where("kind", "=", kind)
.where("season_number", "=", season)
.where("status", "!=", "retired");
q = ep === null ? q.where("episode_number", "is", null) : q.where("episode_number", "=", ep);
return q.orderBy("created_at").executeTakeFirst();
};
const create = async (kind: "season" | "episode", ep: number | null, parent: string | null) =>
exec
.insertInto("content_unit")
.values({
media_entity_id: entityId,
parent_unit_id: parent,
kind,
season_number: season,
episode_number: ep,
status: "provisional",
display_order: ep ?? season,
})
.returning("id")
.executeTakeFirstOrThrow();
let seasonUnit: { id: string } | undefined;
const out: { id: string }[] = [];
const end = Math.min(eps.end, eps.start + 199);
for (let e = eps.start; e <= end; e++) {
const hit = await find("episode", e);
if (hit) {
out.push(hit);
continue;
}
seasonUnit ??= (await find("season", null)) ?? (await create("season", null, null));
out.push(await create("episode", e, seasonUnit.id));
}
return out;
}
// ── 公开记录映射 ──
export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[], includeFiles: boolean) {
@@ -56,8 +56,20 @@ const D = {
response: EngineCancelResult,
tags,
},
retry: {
method: "post",
path: "/engine/runs/{id}/retry",
scope: "admin",
summary: "按原任务名与输入重新入队(新幂等键 retry:<id>:<序号>)",
params: RunIdParam,
response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }),
tags,
},
} as const satisfies Record<string, Def>;
/** 可重跑的终态(运行中/待执行的 run 先取消再重跑) */
const RETRYABLE = new Set(["completed", "succeeded", "failed", "canceled"]);
const NAMESPACE = "default";
const iso = (d: Date | null) => (d ? d.toISOString() : null);
@@ -257,4 +269,39 @@ export function registerEngineRoutes(app: ModuleApp, ctx: ServerContext, opts: {
);
return { canceled };
});
// 重跑:读原 run 的任务名与信封里的原始输入(不经脱敏),以新幂等键重新入队;业务侧幂等仍由各任务的 op 账本保证
endpoint(app, D.retry, async ({ params, actor }) => {
const t = sql.raw(`"${opts.schema ?? "openworkflow"}".workflow_runs`);
const r = await sql<{ workflow_name: string; status: string; input: unknown }>`
select workflow_name, status, input from ${t} where namespace_id = ${NAMESPACE} and id = ${params.id}`.execute(
ctx.db,
);
const run = r.rows[0];
if (!run) throw new ApiException("not_found", `run ${params.id} 不存在`);
if (!RETRYABLE.has(run.status))
throw new ApiException("conflict", `run 状态为 ${run.status},只能重跑已结束的 run(可先取消)`);
const env = (run.input ?? {}) as { input?: unknown; traceId?: string; priority?: number };
const prefix = `retry:${params.id}:`;
const prev = await sql<{ n: number }>`
select count(*)::int as n from tasks_run_meta where idempotency_key like ${`${prefix}%`}`.execute(ctx.db);
const attempt = (prev.rows[0]?.n ?? 0) + 1;
let runId: string;
try {
({ runId } = await ctx.engine.enqueue(run.workflow_name, "input" in env ? env.input : {}, {
idempotencyKey: `${prefix}${attempt}`,
traceId: env.traceId ?? `retry:${params.id}`,
...(typeof env.priority === "number" ? { priority: env.priority } : {}),
}));
} catch (err) {
throw new ApiException("precondition_failed", `无法重新入队:${(err as Error).message}`);
}
await audit(
ctx,
actor,
"engine.run_retry",
{ type: "engine_run", id: params.id },
{ workflowName: run.workflow_name, newRunId: runId, attempt },
);
return { runId, retryOf: params.id, attempt };
});
}