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:
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 };
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user