feat(identity): open identity_unresolved decisions for resources with no candidates

no_match resources (and lookup failures after auto-review gives up) now
open a decision in the inbox with options to set a TMDB ID, pick a local
work, or dismiss; ambiguous decisions get the same manual options.
Resolve accepts identity {tmdb | mediaEntityId} and assigns with a manual
lock. The inbox option dialog collects the TMDB type/ID or searches local
works; the identity pool links each pending item to its inbox filter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 13:44:57 +08:00
1 parent a0b0a64246
commit fb7727cf66
14 files changed
+280 -37

No files matched your search

@@ -11,7 +11,7 @@ import { audit } from "../service/common.ts";
import { assignReleaseIdentity, dismissPoolEntry } from "../service/identity-admin.ts";
// 待处理中心数据源:decision 列表/详情/聚合/处理。
// 身份歧义类由本模块直接执行所选候选;其它类型只记录处理结果并发 decision.updated,
// 身份类(歧义 / 待识别)由本模块直接执行所选候选或手动指定;其它类型只记录处理结果并发 decision.updated,
// 需要执行能力的选项由 W2-D 的 resolveDecision 消费。
const OPEN = ["open", "investigating", "awaiting_user"] as const;
@@ -113,8 +113,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const opt = body.optionKey ? options.find((o) => o.key === body.optionKey) : undefined;
if (body.optionKey && !opt) throw new ApiException("bad_request", `未知选项 ${body.optionKey}`);
// 身份歧义:直接执行(选候选 = 人工指定并加锁;忽略 = 待识别池 dismissed)
if (d.kind === "identity_ambiguous" && d.subject_type === "source_release" && d.subject_id) {
// 身份类:直接执行(选候选 / 手动指定 TMDB id / 选本地作品 = 人工指定并加锁;忽略 = 待识别池 dismissed)
const identityKind = d.kind === "identity_ambiguous" || d.kind === "identity_unresolved";
if (identityKind && d.subject_type === "source_release" && d.subject_id) {
const requires = opt?.input?.requires;
if (body.dismiss || opt?.key === "dismiss") {
const pool = await db()
.selectFrom("identity_candidate")
@@ -122,6 +124,28 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
.where("source_release_id", "=", d.subject_id)
.executeTakeFirst();
if (pool) await dismissPoolEntry(rt(), pool.id, actor, body.note ?? null);
} else if (requires === "tmdb") {
const tmdb = body.identity?.tmdb;
if (!tmdb) throw new ApiException("bad_request", "需要 identity.tmdb {type, id}");
await assignReleaseIdentity(
rt(),
d.subject_id,
{
external: { provider: "tmdb", entityType: tmdb.type, externalId: tmdb.id },
lock: true,
reason: body.note ?? `decision ${d.id}`,
},
actor,
);
} else if (requires === "mediaEntityId") {
const mediaEntityId = body.identity?.mediaEntityId;
if (!mediaEntityId) throw new ApiException("bad_request", "需要 identity.mediaEntityId");
await assignReleaseIdentity(
rt(),
d.subject_id,
{ mediaEntityId, lock: true, reason: body.note ?? `decision ${d.id}` },
actor,
);
} else if (opt?.input && typeof opt.input.candidateIndex === "number") {
const pool = await db()
.selectFrom("identity_candidate")