feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

+102 -17
View File
@@ -10,7 +10,7 @@ import {
waitFor,
} from "./harness.ts";
// E10(原编号与 e6-auto-recover 重复,W6-B 改名) W4-A 前后端集成端点:手动提交下载(与自动同流程)、候选评估去重、库存叠加补字段、
// E10(原编号与 e6-auto-recover 重复,W6-B 改名) W4-A 前后端集成端点:独立手动下载与自动订阅下载分别验证、候选评估去重、库存叠加补字段、
// 整理记录 downloadTaskId、只读资产/产物端点、偏好标签修订历史、列表带作品标题、事件 JSON 补读。
const hasPg = await pgAvailable();
@@ -29,13 +29,17 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
await e?.close();
}, 60_000);
it("POST /acquisition-plans:硬条件不满足 → 不提交并给出原因;重复评估只留一条候选记录", async () => {
it("download.plan:自动订阅硬条件不满足 → 无计划并给出原因;重复评估只留一条候选记录", async () => {
for (let i = 0; i < 2; i++) {
const r = await e.api("POST", "/acquisition-plans", { sourceReleaseId: target.r720, slotId: target.slotId });
const r = await e.api("POST", "/capabilities/download.plan/invoke", {
input: { sourceReleaseId: target.r720, slotId: target.slotId },
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ submitted: false, plan: null, taskId: null });
expect(r.body.data.rejected.length).toBeGreaterThan(0);
expect(r.body.data.rejected[0].slotId).toBe(target.slotId);
const out = r.body.data.output ?? r.body.data;
expect(out.plan).toBeNull();
expect(out.slot.id).toBe(target.slotId);
expect(out.rejected.length).toBeGreaterThan(0);
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(0);
}
const n = await sql<{ n: number }>`select count(*)::int as n from artifact
where title = 'candidate_evaluation' and subject_id = ${target.slotId}
@@ -53,23 +57,30 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
expect(c.reasons.length).toBeGreaterThan(0);
});
it("POST /acquisition-plans:按作品选 Slot,走 download.plan → download.submit,qB 收到种子;整理记录带 downloadTaskId", async () => {
const r = await e.api("POST", "/acquisition-plans", {
sourceReleaseId: target.r1080,
entityId: target.entityId,
note: "工作台手动",
it("自动订阅 download.plan → download.submit 推进 Slot,qB 收到种子;整理记录带 downloadTaskId", async () => {
const planned = await e.api("POST", "/capabilities/download.plan/invoke", {
input: { sourceReleaseId: target.r1080, slotId: target.slotId },
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ submitted: true, slotId: target.slotId });
expect(r.body.data.plan.status).toMatch(/reserved|submitted/);
expect(r.body.data.taskId).toBeTruthy();
expect(planned.status).toBe(200);
const out = planned.body.data.output ?? planned.body.data;
expect(out.plan).toMatchObject({ slotId: target.slotId, sourceReleaseId: target.r1080 });
const grant = await e.api("POST", "/automation-grants", {
action: "download.submit",
scope: { mediaEntityIds: [target.entityId] },
reason: "e10 自动订阅收录验证",
});
expect(grant.status).toBe(201);
const submitted = await e.api("POST", "/capabilities/download.submit/invoke", { input: { planId: out.plan.id } });
expect(submitted.status).toBe(200);
const accepted = submitted.body.data.output ?? submitted.body.data;
expect(accepted.planId).toBe(out.plan.id);
expect(accepted.taskId).toBeTruthy();
const audits = await e.db
.selectFrom("audit_event")
.select(["action", "target_id"])
.where("action", "in", ["capability.invoke", "acquisition_plan.manual_submit"])
.where("action", "=", "capability.invoke")
.execute();
expect(audits.filter((a) => a.target_id === "download.submit")).toHaveLength(1);
expect(audits.some((a) => a.action === "acquisition_plan.manual_submit")).toBe(true);
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const rec = await waitFor(
@@ -104,6 +115,80 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
expect(slots.body.data.items[0].currentAsset).toMatchObject({ resolution: "1080p" });
});
it("POST /acquisition-plans:没有订阅和 Slot 的作品也能独立手动下载", async () => {
const standalone = await e.db
.insertInto("media_entity")
.values({ kind: "movie", title: "独立手动电影", year: 2025, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
const site = await e.db
.selectFrom("source_release")
.select("site_id")
.where("id", "=", target.r720)
.executeTakeFirstOrThrow();
const release = await e.db
.insertInto("source_release")
.values({
site_id: site.site_id,
site_release_id: "880003",
title: "Manual.Standalone.2025.720p.WEB-DL.x264-GRP",
size_bytes: 300000,
media_entity_id: standalone.id,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: false } }),
})
.returning("id")
.executeTakeFirstOrThrow();
const before = await e.db.selectFrom("subscription").select("id").execute();
const slotBefore = await e.db
.selectFrom("acquisition_slot")
.select(["current_asset_id", "revision"])
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
const r = await e.api("POST", "/acquisition-plans", {
sourceReleaseId: release.id,
entityId: standalone.id,
note: "工作台独立手动",
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({
submitted: true,
slotId: null,
rejected: [],
plan: { slotId: null, evaluation: { manual: true, mediaEntityId: standalone.id } },
});
expect(r.body.data.taskId).toBeTruthy();
await waitFor(
async () =>
e.db
.selectFrom("download_task")
.selectAll()
.where("import_plan_id", "=", r.body.data.plan.id)
.executeTakeFirst(),
60000,
"独立手动下载已提交",
);
expect(await e.db.selectFrom("subscription").select("id").execute()).toEqual(before);
expect(
await e.db.selectFrom("acquisition_slot").select("id").where("media_entity_id", "=", standalone.id).execute(),
).toHaveLength(0);
expect(
await e.db
.selectFrom("acquisition_slot")
.select(["current_asset_id", "revision"])
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow(),
).toEqual(slotBefore);
expect(
await e.db
.selectFrom("audit_event")
.select("id")
.where("action", "=", "acquisition_plan.manual_submit")
.where("target_id", "=", r.body.data.plan.id)
.executeTakeFirst(),
).toBeTruthy();
}, 90000);
it("GET /artifacts:列表与详情", async () => {
const list = await e.api("GET", "/artifacts?kind=evidence");
expect(list.status).toBe(200);
+11 -2
View File
@@ -1,3 +1,4 @@
import { CAPABILITY_IDS } from "@umt/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { delegateScript } from "./fixtures/scripts.ts";
import {
@@ -45,8 +46,16 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
return r.body.data.id as string;
};
it("启动断言:41 个能力全部注册,并在能力目录里可见", async () => {
expect(e.booted.ctx.capabilities.list()).toHaveLength(41);
it("启动断言:合同中的能力全部注册,整理兜底不可重放,并在能力目录里可见", async () => {
expect(
e.booted.ctx.capabilities
.list()
.map((c) => c.descriptor.id)
.sort(),
).toEqual([...CAPABILITY_IDS].sort());
expect(
e.booted.ctx.capabilities.list().find((c) => c.descriptor.id === "metadata.resolve_organize")?.descriptor,
).toMatchObject({ kind: "execute", replay: "never" });
const r = await e.api("GET", "/capabilities");
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
e.booted.ctx.capabilities
+6 -3
View File
@@ -1,6 +1,7 @@
import { createQbClient } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { torrentFor } from "./fixtures/modules.ts";
import { createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts";
@@ -81,9 +82,11 @@ describe.skipIf(!hasPg)("E8 AT24 标签移除 / 任务消失", () => {
}, 60_000);
const submit = async (k: K) => {
const r = await e.api("POST", "/acquisition-plans", { sourceReleaseId: ids[k].releaseId });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.submitted).toBe(true);
const planned = await evaluateAcquisition(deps(), {
sourceReleaseId: ids[k].releaseId,
acquisitionSlotId: ids[k].slotId,
});
expect(planned.plans, JSON.stringify(planned)).toHaveLength(1);
await waitFor(async () => e.qb.get(ids[k].hash), 60_000, `qB 收到 ${k}`);
const row = await waitFor(
async () =>
@@ -254,7 +254,7 @@ describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入
30_000,
"订阅完成",
);
expect(sub.status_reason).toBe("slot_satisfied");
expect(sub.status_reason).toBe("download_accepted");
const slot = await e.db
.selectFrom("acquisition_slot")
.selectAll()
@@ -263,7 +263,7 @@ describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入
expect(slot.status).toBe("satisfied");
const hist = await e.api("GET", `/subscription-history?subscriptionId=${subId}`);
expect(hist.body.data.items).toHaveLength(1);
expect(hist.body.data.items[0]).toMatchObject({ reason: "slot_satisfied", mediaEntityId: entityId });
expect(hist.body.data.items[0]).toMatchObject({ reason: "download_accepted", mediaEntityId: entityId });
// 进度 API:三集已覆盖,总集数已知
const prog = await e.api("GET", `/subscriptions/${subId}/progress`);
expect(prog.body.data.seasons[0]).toMatchObject({ episodeTotal: 3, totalKnown: true, complete: true });