fix(organize,identity,sites,pi): W10-pre backend fixes from v0.2 visual QA

- slot: new manual_verify event (attention -> verifying, with reason); a
  successful organize of a download whose slot is in attention goes back
  through verifying and lands by verify_passed on the actually imported
  assets; the download's organize_blocked decision is resolved with
  resolution {key: manual_organize | organize_retry, organizationRecordId} (U1)
- acquisition: plan purpose is upgrade only when the slot already has a
  current asset; a movie slot gap is a fill (U7)
- identity: entities created from TMDB details get metadata fields projected
  in the same transaction; TMDB aliases deduped by normalized text, site-title
  aliases skipped when the text already exists (U9)
- pi: auth-mode test of a custom Base URL no longer marks the upstream ok, and
  says to use the minimal completion to probe the endpoint (U2)
- sites: PUT /sites/{id}/credentials replaces same-kind secrets in place and
  names new secrets with site id + random suffix (secret.name is unique; was a
  500 on update); M-Team user data uses config/definition apiBaseUrl instead
  of deriving api.<domain> from the site URL (Invalid URL on IP/local bases)
- copy: setup error says 初始化口令; backup job points at umt backup
- tests: schema expected tables add site_user_data; migration round-trip
  timeout 60s; fake secrets honor label as name; vertical/resolve/pi/slot/
  site-center tests for the above
- docs: 09-visual-qa W10-pre status table (U3 root cause: outbox events are
  never mirrored to SSE)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:29:29 +08:00
1 parent b7a7fd3958
commit 2b8fe667a5
20 files changed
+298 -42

No files matched your search

@@ -459,4 +459,46 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临
const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`);
expect(ms.body.data).toEqual({ mediaEntityId: movieId, instances: [] });
});
it("自动整理被拦(attention)后手动整理成功:Slot 经 verifying 落 satisfied,整理事项自动关闭(QA U1)", async () => {
await h.setSettings("organize", { defaultMode: "move" });
const e = await seedEntity(h.db, { kind: "movie", title: "被拦片", year: 2021 });
const slot = await seedSlot(h.db, { entityId: e });
const { dtId, planId } = await runToCompleted(e, "Blocked.Movie.2021.1080p.BluRay-HR", { hr: true });
// 新作品首次下载:计划用途是补缺,不是升级(QA U7)
expect(
(await h.db.selectFrom("acquisition_plan").select("purpose").where("id", "=", planId).executeTakeFirstOrThrow())
.purpose,
).toBe("fill");
const auto = await planImport(h.d, { downloadTaskId: dtId, trigger: "auto", actorType: "system" });
expect(auto.status).toBe("failed");
const slotRow = () =>
h.db.selectFrom("acquisition_slot").selectAll().where("id", "=", slot).executeTakeFirstOrThrow();
expect((await slotRow()).status).toBe("attention");
const dec = await h.db
.selectFrom("decision")
.selectAll()
.where("dedupe_key", "=", `organize_blocked:${dtId}`)
.executeTakeFirstOrThrow();
expect(dec.status).toBe("open");
await h.setSettings("organize", { defaultMode: "copy" });
const manual = await planImport(h.d, { downloadTaskId: dtId, trigger: "manual", actorType: "user", actorId: null });
expect(manual.status).toBe("running");
const done = await executeRecord(h.d, manual.recordId!);
expect(done.status).toBe("succeeded");
const after = await slotRow();
expect(after.status).toBe("satisfied");
expect(after.current_asset_id).not.toBeNull();
const asset = await h.db
.selectFrom("media_asset")
.select("media_entity_id")
.where("id", "=", after.current_asset_id!)
.executeTakeFirstOrThrow();
expect(asset.media_entity_id).toBe(e);
const closed = await h.db.selectFrom("decision").selectAll().where("id", "=", dec.id).executeTakeFirstOrThrow();
expect(closed.status).toBe("resolved");
expect(closed.resolution).toMatchObject({ key: "manual_organize", organizationRecordId: manual.recordId });
expect(closed.resolved_by).toBe("user");
}, 30_000);
});
@@ -967,7 +967,8 @@ async function reserve(
savePath: p.savePath,
...(p.evaluationExtra ?? {}),
});
const purpose = p.mode === "upgrade" ? ("upgrade" as const) : ("fill" as const);
// 升级 = 替换已有资产;电影 Slot 缺口(无资产)的 mode 虽记 upgrade,用途仍是补缺
const purpose = p.mode === "upgrade" && p.slot.current_asset_id ? ("upgrade" as const) : ("fill" as const);
if (c.draft) {
// 草案:同一 Slot+发布复用未提交的草案;Slot 状态与预算不动,expected_revision 记下当前 Slot 版本 +1
const key = `draft:${p.slot.id}:${p.rel.id}`;
@@ -168,7 +168,7 @@ export async function applySlotEvent(
? completion?.complete
? "verified"
: "verified_partial"
: (extra.reason ?? event.type),
: (extra.reason ?? (event.type === "manual_verify" ? event.reason : event.type)),
...(ended ? { next_evaluate_at: d.ports.now() } : {}),
})
.where("id", "=", slotId)
@@ -1509,6 +1509,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.executeTakeFirst();
if (slot && okAny) {
const old = slot.current_asset_id;
// 需处理的 Slot(如自动整理被拦)经人工整理入库:先回到核验,再由 verify_passed 按实际入库资产定终态
if (slot.status === "attention")
await applySlotEvent(d, trx, slot.id, {
type: "manual_verify",
reason: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
});
// 完成判定与订阅完成(含历史)统一由 applySlotEvent(verify_passed) 按总集数账本重算,这里不再本地判定
await applySlotEvent(
d,
@@ -1540,6 +1546,8 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
}
}
}
// 本下载任务先前被拦的整理事项:已有资产入库即视为处理完毕
if (okAny) await closeOrganizeDecisions(d, trx, dt.id, rec);
}
// 手动整理指定的 Slot(文件管理整理向导):核验通过后以新资产满足该 Slot(获取中的 Slot 不动,由其计划推进)
if (!dt && frozen.slotId && okAny) {
@@ -1590,6 +1598,36 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
}
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动"事项(resolution.key=manual_organize,记整理记录 id) */
async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
const rows = await trx
.updateTable("decision")
.set({
status: "resolved",
resolved_by: by,
resolved_at: d.ports.now(),
resolution: jsonb({
key: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
optionKey: null,
by,
note: "整理已成功入库",
organizationRecordId: rec.id,
}),
})
.where("dedupe_key", "=", `organize_blocked:${downloadTaskId}`)
.where("status", "in", ["open", "investigating", "awaiting_user"])
.returning("id")
.execute();
for (const r of rows)
await d.ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: r.id, status: "resolved" },
{ idempotencyKey: `decision.updated:${r.id}:resolved` },
);
}
/** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */
async function ensureEpisodeUnits(
exec: Exec,