fix(identity): guard site aliases and add identity-cleanup script (FIX-5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a10a39fde1
commit
a51dc1590f
5 files changed
+683
-13
No files matched your search
@@ -0,0 +1,140 @@
|
|||||||
|
import { isGenericAliasName, normalizeTitle, type ParsedRelease, type RankedCandidate } from "@umt/domain";
|
||||||
|
import type { Exec } from "./common.ts";
|
||||||
|
import { addAliasIfAbsent } from "./entities.ts";
|
||||||
|
|
||||||
|
// FIX-5 别名守卫:自动识别绑定后,把资源标题解析出的中 / 英文名登记为作品别名之前再过一道检查。
|
||||||
|
// 背景:VER-4 真机《东北有个周东北》挂着《征途》等别的作品的英文名(Spark of the Long March 等),
|
||||||
|
// 是站点标题"中文名 + 另一部作品的英文名"只靠中文名命中后,英文名被一并登记、再链式召回造成的。
|
||||||
|
|
||||||
|
/** 站点名称不登记为别名的原因(记在 resolution_attempt.best.aliases.skipped) */
|
||||||
|
export type SiteAliasSkipReason =
|
||||||
|
| "low_evidence"
|
||||||
|
| "year_conflict"
|
||||||
|
| "conflicts_external_title"
|
||||||
|
| "owned_by_other"
|
||||||
|
| "generic";
|
||||||
|
|
||||||
|
export interface SiteAliasGuardInput {
|
||||||
|
/** 本次识别最佳候选的证据等级 */
|
||||||
|
level: string;
|
||||||
|
/** 候选的年份比对结果 */
|
||||||
|
yearMatch: string;
|
||||||
|
/** 资源标题解析出的年份 / 作品年份 */
|
||||||
|
parsedYear: number | null;
|
||||||
|
entityYear: number | null;
|
||||||
|
seasonYears?: readonly number[] | undefined;
|
||||||
|
/** 已归一化的站点名称与它的文字 */
|
||||||
|
normalized: string;
|
||||||
|
script: "han" | "latin";
|
||||||
|
/** 作品已确认的外部标题(有已接受的外部身份时:主标题 / 原标题 / TMDB 译名与别名),按文字分组,已归一化 */
|
||||||
|
externalNames: { han: ReadonlySet<string>; latin: ReadonlySet<string> } | null;
|
||||||
|
/** 这个名称已挂在别的作品上 */
|
||||||
|
ownedByOther: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scriptOf = (s: string): "han" | "latin" => (/\p{Script=Han}/u.test(s) ? "han" : "latin");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯函数:证据等级不是最高(rule_confirmed)、年份与作品不相容、与作品已确认的同一种文字的外部标题对不上、
|
||||||
|
* 或已属于别的作品 → 返回不登记的原因;可以登记返回 null。
|
||||||
|
*/
|
||||||
|
export function siteAliasGuard(i: SiteAliasGuardInput): SiteAliasSkipReason | null {
|
||||||
|
if (!i.normalized || isGenericAliasName(i.normalized)) return "generic";
|
||||||
|
if (i.level !== "rule_confirmed") return "low_evidence";
|
||||||
|
if (i.yearMatch === "mismatch") return "year_conflict";
|
||||||
|
if (
|
||||||
|
i.parsedYear !== null &&
|
||||||
|
i.entityYear !== null &&
|
||||||
|
Math.abs(i.parsedYear - i.entityYear) > 1 &&
|
||||||
|
!(i.seasonYears ?? []).includes(i.parsedYear)
|
||||||
|
)
|
||||||
|
return "year_conflict";
|
||||||
|
const ext = i.externalNames?.[i.script];
|
||||||
|
if (ext && ext.size > 0 && !ext.has(i.normalized)) return "conflicts_external_title";
|
||||||
|
if (i.ownedByOther) return "owned_by_other";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作品已确认的外部标题(按文字分组);没有已接受的外部身份时为 null */
|
||||||
|
export async function confirmedExternalNames(
|
||||||
|
exec: Exec,
|
||||||
|
entityId: string,
|
||||||
|
): Promise<{ han: Set<string>; latin: Set<string> } | null> {
|
||||||
|
const ident = await exec
|
||||||
|
.selectFrom("external_identity")
|
||||||
|
.select("id")
|
||||||
|
.where("media_entity_id", "=", entityId)
|
||||||
|
.where("status", "=", "accepted")
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!ident) return null;
|
||||||
|
const e = await exec
|
||||||
|
.selectFrom("media_entity")
|
||||||
|
.select(["title", "original_title"])
|
||||||
|
.where("id", "=", entityId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
const aliases = await exec
|
||||||
|
.selectFrom("title_alias")
|
||||||
|
.select(["alias"])
|
||||||
|
.where("media_entity_id", "=", entityId)
|
||||||
|
.where("content_unit_id", "is", null)
|
||||||
|
.where("source", "=", "tmdb")
|
||||||
|
.execute();
|
||||||
|
const out = { han: new Set<string>(), latin: new Set<string>() };
|
||||||
|
for (const t of [e?.title, e?.original_title, ...aliases.map((a) => a.alias)]) {
|
||||||
|
if (!t) continue;
|
||||||
|
const n = normalizeTitle(t);
|
||||||
|
if (n) out[scriptOf(t)].add(n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteAliasOutcome {
|
||||||
|
added: string[];
|
||||||
|
skipped: { alias: string; reason: SiteAliasSkipReason }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按守卫登记资源标题里的中 / 英文名;返回登记与跳过的清单 */
|
||||||
|
export async function addSiteAliases(
|
||||||
|
exec: Exec,
|
||||||
|
entityId: string,
|
||||||
|
best: RankedCandidate,
|
||||||
|
parsed: ParsedRelease,
|
||||||
|
): Promise<SiteAliasOutcome> {
|
||||||
|
const out: SiteAliasOutcome = { added: [], skipped: [] };
|
||||||
|
const names: [string, "zh" | "en"][] = [];
|
||||||
|
if (parsed.titleCn) names.push([parsed.titleCn, "zh"]);
|
||||||
|
if (parsed.titleEn) names.push([parsed.titleEn, "en"]);
|
||||||
|
if (!names.length) return out;
|
||||||
|
const entity = await exec.selectFrom("media_entity").select(["year"]).where("id", "=", entityId).executeTakeFirst();
|
||||||
|
const external = await confirmedExternalNames(exec, entityId);
|
||||||
|
for (const [t, lang] of names) {
|
||||||
|
const normalized = normalizeTitle(t);
|
||||||
|
const other = normalized
|
||||||
|
? await exec
|
||||||
|
.selectFrom("title_alias")
|
||||||
|
.select("id")
|
||||||
|
.where("normalized", "=", normalized)
|
||||||
|
.where("content_unit_id", "is", null)
|
||||||
|
.where("media_entity_id", "<>", entityId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
: undefined;
|
||||||
|
const reason = siteAliasGuard({
|
||||||
|
level: best.level,
|
||||||
|
yearMatch: best.yearMatch,
|
||||||
|
parsedYear: parsed.year ?? null,
|
||||||
|
entityYear: entity?.year ?? null,
|
||||||
|
seasonYears: best.candidate.seasonYears,
|
||||||
|
normalized,
|
||||||
|
script: scriptOf(t),
|
||||||
|
externalNames: external,
|
||||||
|
ownedByOther: !!other,
|
||||||
|
});
|
||||||
|
if (reason) {
|
||||||
|
if (reason !== "generic") out.skipped.push({ alias: t, reason });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await addAliasIfAbsent(exec, entityId, t, "site_title", "site", lang);
|
||||||
|
out.added.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { type SourceReleaseRow, sql } from "@umt/db";
|
|||||||
import {
|
import {
|
||||||
type IdentityCandidate as DomainCandidate,
|
type IdentityCandidate as DomainCandidate,
|
||||||
type IdentityResolution,
|
type IdentityResolution,
|
||||||
isGenericAliasName,
|
|
||||||
normalizeTitle,
|
normalizeTitle,
|
||||||
PARSER_VERSION,
|
PARSER_VERSION,
|
||||||
type ParsedRelease,
|
type ParsedRelease,
|
||||||
@@ -23,11 +22,11 @@ import {
|
|||||||
} from "@umt/mapping";
|
} from "@umt/mapping";
|
||||||
import { jsonb } from "../rows.ts";
|
import { jsonb } from "../rows.ts";
|
||||||
import type { SourcesRuntime } from "../runtime.ts";
|
import type { SourcesRuntime } from "../runtime.ts";
|
||||||
|
import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts";
|
||||||
import { type Actor, closeDecisions, type Exec, SYSTEM } from "./common.ts";
|
import { type Actor, closeDecisions, type Exec, SYSTEM } from "./common.ts";
|
||||||
import { siteExplicitIds } from "./detail-page.ts";
|
import { siteExplicitIds } from "./detail-page.ts";
|
||||||
import { maybeAutoEnrich } from "./enrich.ts";
|
import { maybeAutoEnrich } from "./enrich.ts";
|
||||||
import {
|
import {
|
||||||
addAliasIfAbsent,
|
|
||||||
createEntityFromTmdb,
|
createEntityFromTmdb,
|
||||||
ensureUnits,
|
ensureUnits,
|
||||||
entityByExternal,
|
entityByExternal,
|
||||||
@@ -545,16 +544,11 @@ async function bindAuto(
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
const units = await ensureUnits(trx, entityId, entity.kind, parsed);
|
const units = await ensureUnits(trx, entityId, entity.kind, parsed);
|
||||||
// 站点标题作为别名:下次同名资源可直接本地召回。只在名称本身命中过(不是仅靠别名单线命中)时登记,
|
// 站点标题作为别名:下次同名资源可直接本地召回。只在名称本身命中过(不是仅靠别名单线命中)时登记,
|
||||||
// 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)
|
// 且通用词(分类词 / 单字 / 停用词)不登记,避免错误别名链式扩散(ID-2)。
|
||||||
if (siteAliasAllowed(best)) {
|
// FIX-5:证据等级非最高、年份不相容、与已确认外部标题冲突、已属于别的作品的名称也不登记(alias-guard.ts)
|
||||||
for (const [t, lang] of [
|
const aliasOutcome: SiteAliasOutcome = siteAliasAllowed(best)
|
||||||
[parsed.titleCn, "zh"],
|
? await addSiteAliases(trx, entityId, best, parsed)
|
||||||
[parsed.titleEn, "en"],
|
: { added: [], skipped: [] };
|
||||||
] as const) {
|
|
||||||
if (t && !isGenericAliasName(normalizeTitle(t)))
|
|
||||||
await addAliasIfAbsent(trx, entityId, t, "site_title", "site", lang);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const evidence = {
|
const evidence = {
|
||||||
level,
|
level,
|
||||||
@@ -606,7 +600,10 @@ async function bindAuto(
|
|||||||
version,
|
version,
|
||||||
resolution.ranked.length,
|
resolution.ranked.length,
|
||||||
entityId,
|
entityId,
|
||||||
rankedSummary(best),
|
{
|
||||||
|
...rankedSummary(best),
|
||||||
|
...(aliasOutcome.added.length || aliasOutcome.skipped.length ? { aliases: aliasOutcome } : {}),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await ctx.outbox.emit(
|
await ctx.outbox.emit(
|
||||||
trx,
|
trx,
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { normalizeTitle } from "@umt/domain";
|
||||||
|
import { createMockTmdb, type MockTmdb, type TmdbFixture } from "@umt/testkit";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { siteAliasGuard } from "../../src/modules/sources-identity/service/alias-guard.ts";
|
||||||
|
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
|
||||||
|
|
||||||
|
// FIX-5 别名守卫:站点标题里另一部作品的英文名不能挂到按中文名命中的作品上。
|
||||||
|
|
||||||
|
const up = await pgAvailable();
|
||||||
|
|
||||||
|
const cnTv = (id: number, zh: string, en: string | null, date: string): TmdbFixture => ({
|
||||||
|
type: "tv",
|
||||||
|
id,
|
||||||
|
original_title: zh,
|
||||||
|
original_language: "zh",
|
||||||
|
origin_country: ["CN"],
|
||||||
|
date,
|
||||||
|
titles: en ? { "zh-CN": zh, "en-US": en } : { "zh-CN": zh },
|
||||||
|
popularity: 5,
|
||||||
|
alternative_titles: [],
|
||||||
|
external_ids: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("siteAliasGuard(纯函数)", () => {
|
||||||
|
const base = {
|
||||||
|
level: "rule_confirmed",
|
||||||
|
yearMatch: "exact",
|
||||||
|
parsedYear: 2026,
|
||||||
|
entityYear: 2026,
|
||||||
|
normalized: "spark of the long march",
|
||||||
|
script: "latin" as const,
|
||||||
|
externalNames: null,
|
||||||
|
ownedByOther: false,
|
||||||
|
};
|
||||||
|
it("证据等级非最高、年份不相容、与外部标题冲突、已属于别的作品都不登记", () => {
|
||||||
|
expect(siteAliasGuard(base)).toBeNull();
|
||||||
|
expect(siteAliasGuard({ ...base, level: "multi_evidence" })).toBe("low_evidence");
|
||||||
|
expect(siteAliasGuard({ ...base, parsedYear: 2019 })).toBe("year_conflict");
|
||||||
|
expect(siteAliasGuard({ ...base, parsedYear: 2019, seasonYears: [2019] })).toBeNull();
|
||||||
|
expect(
|
||||||
|
siteAliasGuard({
|
||||||
|
...base,
|
||||||
|
externalNames: { han: new Set(["东北有个周东北"]), latin: new Set(["zhou dongbei"]) },
|
||||||
|
}),
|
||||||
|
).toBe("conflicts_external_title");
|
||||||
|
// 外部标题只有中文时,英文名不按外部标题判冲突
|
||||||
|
expect(siteAliasGuard({ ...base, externalNames: { han: new Set(["长河"]), latin: new Set() } })).toBeNull();
|
||||||
|
expect(siteAliasGuard({ ...base, ownedByOther: true })).toBe("owned_by_other");
|
||||||
|
expect(siteAliasGuard({ ...base, normalized: "电视剧" })).toBe("generic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!up)("别名守卫(resolveObservation)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let tmdb: MockTmdb;
|
||||||
|
let siteId: string;
|
||||||
|
let n = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmdb = await createMockTmdb({
|
||||||
|
fixtures: [
|
||||||
|
cnTv(990001, "东北有个周东北", "Zhou Dongbei in the Northeast", "2026-01-10"),
|
||||||
|
cnTv(990003, "长河", null, "2026-02-01"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
h = await createHarness("aliasguard", { tmdbUrl: tmdb.url });
|
||||||
|
siteId = (await seedSite(h, { baseUrl: "https://guard.example/" })).site.id;
|
||||||
|
await h.db
|
||||||
|
.insertInto("settings")
|
||||||
|
.values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
await tmdb?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = (title: string) =>
|
||||||
|
h.db
|
||||||
|
.insertInto("source_release")
|
||||||
|
.values({ site_id: siteId, site_release_id: String(880000 + ++n), title })
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const aliasesOf = async (entityId: string) =>
|
||||||
|
(
|
||||||
|
await h.db.selectFrom("title_alias").select(["alias", "source"]).where("media_entity_id", "=", entityId).execute()
|
||||||
|
).map((a) => a.alias);
|
||||||
|
|
||||||
|
const lastAttempt = (releaseId: string) =>
|
||||||
|
h.db
|
||||||
|
.selectFrom("resolution_attempt")
|
||||||
|
.select(["best"])
|
||||||
|
.where("source_release_id", "=", releaseId)
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
it("与作品已确认的英文名对不上的站点英文名不登记,记入 resolution_attempt", async () => {
|
||||||
|
const r = await release("东北有个周东北.Spark.of.the.Long.March.2026.S01E38.1080p.WEB-DL.H264-GRP");
|
||||||
|
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||||
|
expect(res.outcome).toBe("auto");
|
||||||
|
const aliases = await aliasesOf(res.mediaEntityId!);
|
||||||
|
expect(aliases).toContain("东北有个周东北");
|
||||||
|
expect(aliases).not.toContain("Spark of the Long March");
|
||||||
|
const best = (await lastAttempt(r.id)).best as { aliases?: { skipped: { alias: string; reason: string }[] } };
|
||||||
|
expect(best.aliases?.skipped).toEqual([{ alias: "Spark of the Long March", reason: "conflicts_external_title" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已属于别的作品的站点名称不登记", async () => {
|
||||||
|
const other = await h.db
|
||||||
|
.insertInto("media_entity")
|
||||||
|
.values({ kind: "series", title: "逆流", status: "active" } as never)
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("title_alias")
|
||||||
|
.values({
|
||||||
|
media_entity_id: other.id,
|
||||||
|
alias: "Against the Current",
|
||||||
|
normalized: normalizeTitle("Against the Current"),
|
||||||
|
kind: "other",
|
||||||
|
source: "user",
|
||||||
|
} as never)
|
||||||
|
.execute();
|
||||||
|
const r = await release("长河.Against.the.Current.2026.S01E01.1080p.WEB-DL.H264-GRP");
|
||||||
|
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
|
||||||
|
expect(res.outcome).toBe("auto");
|
||||||
|
expect(res.mediaEntityId).not.toBe(other.id);
|
||||||
|
expect(await aliasesOf(res.mediaEntityId!)).not.toContain("Against the Current");
|
||||||
|
const best = (await lastAttempt(r.id)).best as { aliases?: { skipped: { alias: string; reason: string }[] } };
|
||||||
|
expect(best.aliases?.skipped).toEqual([{ alias: "Against the Current", reason: "owned_by_other" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,10 @@
|
|||||||
交给 `domain.resolveIdentity` 打分:auto 建档 / 关联并发 `release.resolved`;否则进池。打分规则与 MP 的对照见
|
交给 `domain.resolveIdentity` 打分:auto 建档 / 关联并发 `release.resolved`;否则进池。打分规则与 MP 的对照见
|
||||||
`docs/porting/moviepilot-identity-match.md`(ID-2)。
|
`docs/porting/moviepilot-identity-match.md`(ID-2)。
|
||||||
4. 自动绑定时把站点标题登记为作品别名,只在标题精确命中(或别名命中且另一种文字的名称也命中)时登记,分类词 / 单字 / 停用词不登记。
|
4. 自动绑定时把站点标题登记为作品别名,只在标题精确命中(或别名命中且另一种文字的名称也命中)时登记,分类词 / 单字 / 停用词不登记。
|
||||||
|
FIX-5 守卫(`service/alias-guard.ts`):逐个名称再查一遍,以下任一成立就不登记,原因记在 `resolution_attempt.best.aliases.skipped`
|
||||||
|
(`added` 是登记了的):证据等级不是 `rule_confirmed`(`low_evidence`);资源年份与作品年份差超过 1 年且不是季年份(`year_conflict`);
|
||||||
|
作品有已接受的外部身份,且同一种文字(中文 / 拉丁)下已有确认的外部标题(主标题 / 原标题 / TMDB 译名与别名),这个名称对不上
|
||||||
|
(`conflicts_external_title`);这个名称已经挂在别的作品上(`owned_by_other`)。
|
||||||
|
|
||||||
标题更新(RSS 标题与列表标题不同、站点改标题):按新标题重新解析,发 `release.observed(isNew=false)`,以 `force` 重新识别
|
标题更新(RSS 标题与列表标题不同、站点改标题):按新标题重新解析,发 `release.observed(isNew=false)`,以 `force` 重新识别
|
||||||
(已识别的也不短路,触发来源仍是 observed);重新识别不能确认时撤销原自动绑定、进池。人工锁定的不动。
|
(已识别的也不短路,触发来源仍是 observed);重新识别不能确认时撤销原自动绑定、进池。人工锁定的不动。
|
||||||
@@ -99,6 +103,24 @@ DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity
|
|||||||
`resolution_attempt(outcome = manual, reason = rollback_wrong_alias)`。内容单元不删,只报告无资源引用的数量;引用了这些资源的其他表只报告。
|
`resolution_attempt(outcome = manual, reason = rollback_wrong_alias)`。内容单元不删,只报告无资源引用的数量;引用了这些资源的其他表只报告。
|
||||||
- 2026-09-29 用户库副本演练:《东北有个周东北》29 条绑定里 23 条误并入,删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名)。
|
- 2026-09-29 用户库副本演练:《东北有个周东北》29 条绑定里 23 条误并入,删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名)。
|
||||||
|
|
||||||
|
## 冲突别名与临时内容单元清理(FIX-5,`scripts/identity-cleanup.ts`)
|
||||||
|
|
||||||
|
守卫上线前登记的错误别名、重复 / 孤立的临时内容单元用这个脚本清理。缺省只读,`--apply` 在一个事务里执行并写审计
|
||||||
|
`identity.cleanup`;`--keep-orphans` 不删孤立单元;`--entity <id>` 只处理一部作品。连接方式同上。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-cleanup.ts # 看清单
|
||||||
|
DATABASE_URL=postgres://umt:<密码>@$PGIP:5432/umt deno run -A scripts/identity-cleanup.ts --apply # 确认后执行
|
||||||
|
```
|
||||||
|
|
||||||
|
- 冲突别名:只动 `source = site` 的作品级别名,且不是作品自己的名字(主标题 / 原标题 / TMDB 别名)。同一归一化别名还挂在别的作品上
|
||||||
|
(或是别的作品的标题)→ 删;作品有已接受外部身份、同一种文字下已有确认外部标题而它对不上 → 删(中文标题的拼音写法保留);
|
||||||
|
本作品下带这个名字的资源都解析出年份且都与作品年份差超过 1 年 → 删。
|
||||||
|
- 重复的临时内容单元:同作品同类型同季同集号(未退役)有多条且有临时的,保留被引用最多的(同数时 confirmed 优先、再取最早的),
|
||||||
|
其余的引用改指向保留的那条(撞唯一约束的元数据行直接删),子单元改挂,然后删除。
|
||||||
|
- 孤立的临时内容单元:`provisional`、没有资源 / 资产 / 订阅 / 下载 / 整理等任何引用、没有 TMDB 来源字段(不是 TMDB 已知的集)、
|
||||||
|
没有保留的子单元 → 删除(字段来源 / 覆盖 / 单元别名一并删)。TMDB 已知的集即使没有资源也保留。
|
||||||
|
|
||||||
## 排查
|
## 排查
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
// 身份数据清理(FIX-5):删除冲突别名、合并重复的临时内容单元、删除孤立的临时内容单元。默认只读(dry-run),加 --apply 才改库。
|
||||||
|
//
|
||||||
|
// 用法(DATABASE_URL 必须显式给出,脚本不带默认库):
|
||||||
|
// DATABASE_URL=postgres://umt:***@<host>:5432/umt deno run -A scripts/identity-cleanup.ts # 看清单
|
||||||
|
// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --apply # 执行(单个事务)
|
||||||
|
// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --keep-orphans # 不删孤立单元
|
||||||
|
// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --entity <作品 id> # 只看 / 只处理一部作品
|
||||||
|
//
|
||||||
|
// 1. 冲突别名:只处理自动识别登记的站点别名(title_alias.source = 'site',作品级)。以下任一成立即删除:
|
||||||
|
// - shared:同一归一化别名还挂在别的作品上,且它不是本作品自己的名字(主标题 / 原标题 / TMDB 译名与别名);
|
||||||
|
// - script:作品有已接受的外部身份,且同一种文字(中文 / 拉丁)下已有确认的外部标题,这个别名对不上任何一个
|
||||||
|
// (中文标题的拼音写法视为相容,保留);
|
||||||
|
// - year:本作品下带这个名字的资源都解析出了年份,且都与作品年份相差超过 1 年。
|
||||||
|
// 2. 重复的临时内容单元:同一作品、同一类型、同季同集号有多条(未退役)且至少一条是临时(provisional)的,
|
||||||
|
// 保留被引用最多的那条(同数时 confirmed 优先,再取最早建的),其余的引用改指向保留的那条后删除;
|
||||||
|
// 改指向会撞唯一约束的行(同一字段来源 / 别名已在保留的单元上)直接删掉。
|
||||||
|
// 3. 孤立的临时内容单元(--keep-orphans 跳过):provisional、没有任何资源 / 资产 / 订阅 / 下载 / 整理等引用、
|
||||||
|
// 没有 TMDB 来源的字段(不是 TMDB 已知的季 / 集)、也没有子单元的,删除;字段来源 / 覆盖 / 单元别名随之删除。
|
||||||
|
// --apply 在一个事务里执行,并写一条审计事件(action = identity.cleanup)。
|
||||||
|
|
||||||
|
import { normalizeTitle, parseRelease } from "@umt/domain";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const argv = [...Deno.args];
|
||||||
|
const APPLY = argv.includes("--apply");
|
||||||
|
const KEEP_ORPHANS = argv.includes("--keep-orphans");
|
||||||
|
const entityArg = argv.indexOf("--entity");
|
||||||
|
const ONLY_ENTITY = entityArg >= 0 ? (argv[entityArg + 1] ?? null) : null;
|
||||||
|
const url = Deno.env.get("DATABASE_URL");
|
||||||
|
if (!url) {
|
||||||
|
console.error("需要 DATABASE_URL(脚本不带默认库)");
|
||||||
|
Deno.exit(2);
|
||||||
|
}
|
||||||
|
const db = new pg.Client({ connectionString: url });
|
||||||
|
await db.connect();
|
||||||
|
const q = async <T>(text: string, values: unknown[] = []) => (await db.query(text, values)).rows as T[];
|
||||||
|
console.log(`# identity-cleanup (${APPLY ? "APPLY" : "DRY-RUN"})${KEEP_ORPHANS ? " --keep-orphans" : ""}\n`);
|
||||||
|
|
||||||
|
const isHan = (s: string) => /\p{Script=Han}/u.test(s);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 拼音判断(中文标题的拉丁写法视为相容)
|
||||||
|
const SYLLABLE =
|
||||||
|
/^(?:(?:zh|ch|sh|[bpmfdtnlgkhjqxrzcsyw])?(?:iang|iong|uang|ueng|ang|eng|ing|ong|iao|ian|uai|uan|van|ai|ei|ui|ao|ou|iu|ie|ve|ue|er|an|en|in|un|vn|ia|ua|uo|a|o|e|i|u|v))$/;
|
||||||
|
/** 全部由拼音音节组成(允许音节连写,如 Dongbei) */
|
||||||
|
function looksLikePinyin(text: string): boolean {
|
||||||
|
const words = text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z\s'-]/g, " ")
|
||||||
|
.split(/[\s'-]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!words.length || /[^a-z\s'-]/i.test(text.replace(/[\s\d.:,!?·]/g, ""))) return false;
|
||||||
|
const seg = (w: string): boolean => {
|
||||||
|
if (!w) return true;
|
||||||
|
for (let k = Math.min(6, w.length); k >= 1; k--) if (SYLLABLE.test(w.slice(0, k)) && seg(w.slice(k))) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return words.every(seg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 1. 冲突别名
|
||||||
|
interface AliasRow {
|
||||||
|
id: string;
|
||||||
|
media_entity_id: string;
|
||||||
|
alias: string;
|
||||||
|
normalized: string;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
interface EntityRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
original_title: string | null;
|
||||||
|
year: number | null;
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
const entityFilter = ONLY_ENTITY ? "and a.media_entity_id = $1" : "";
|
||||||
|
const entityVals = ONLY_ENTITY ? [ONLY_ENTITY] : [];
|
||||||
|
const siteAliases = await q<AliasRow>(
|
||||||
|
`select a.id, a.media_entity_id, a.alias, a.normalized, a.source from title_alias a
|
||||||
|
where a.source = 'site' and a.content_unit_id is null ${entityFilter}`,
|
||||||
|
entityVals,
|
||||||
|
);
|
||||||
|
const entityIds = [...new Set(siteAliases.map((a) => a.media_entity_id))];
|
||||||
|
const entities = new Map(
|
||||||
|
(
|
||||||
|
await q<EntityRow>(
|
||||||
|
`select e.id, e.title, e.original_title, e.year,
|
||||||
|
exists(select 1 from external_identity x where x.media_entity_id = e.id and x.status = 'accepted') as confirmed
|
||||||
|
from media_entity e where e.id = any($1::uuid[])`,
|
||||||
|
[entityIds],
|
||||||
|
)
|
||||||
|
).map((e) => [e.id, e]),
|
||||||
|
);
|
||||||
|
const tmdbAliases = await q<{ media_entity_id: string; alias: string }>(
|
||||||
|
`select media_entity_id, alias from title_alias where source = 'tmdb' and content_unit_id is null
|
||||||
|
and media_entity_id = any($1::uuid[])`,
|
||||||
|
[entityIds],
|
||||||
|
);
|
||||||
|
/** 作品自己的名字(归一化),按文字分组 */
|
||||||
|
const ownNames = new Map<string, { han: Set<string>; latin: Set<string> }>();
|
||||||
|
for (const e of entities.values()) {
|
||||||
|
const own = { han: new Set<string>(), latin: new Set<string>() };
|
||||||
|
for (const t of [
|
||||||
|
e.title,
|
||||||
|
e.original_title,
|
||||||
|
...tmdbAliases.filter((a) => a.media_entity_id === e.id).map((a) => a.alias),
|
||||||
|
])
|
||||||
|
if (t) {
|
||||||
|
const n = normalizeTitle(t);
|
||||||
|
if (n) own[isHan(t) ? "han" : "latin"].add(n);
|
||||||
|
}
|
||||||
|
ownNames.set(e.id, own);
|
||||||
|
}
|
||||||
|
const shared = new Map<string, Set<string>>();
|
||||||
|
for (const r of await q<{ normalized: string; media_entity_id: string }>(
|
||||||
|
`select distinct a.normalized, a.media_entity_id from title_alias a join media_entity e on e.id = a.media_entity_id
|
||||||
|
where a.content_unit_id is null and e.status <> 'merged' and a.normalized = any($1::text[])`,
|
||||||
|
[[...new Set(siteAliases.map((a) => a.normalized))]],
|
||||||
|
)) {
|
||||||
|
const s = shared.get(r.normalized) ?? new Set<string>();
|
||||||
|
s.add(r.media_entity_id);
|
||||||
|
shared.set(r.normalized, s);
|
||||||
|
}
|
||||||
|
// 同名作品的主标题也算"别的作品的名字"
|
||||||
|
for (const r of await q<{ id: string; title: string; original_title: string | null }>(
|
||||||
|
`select id, title, original_title from media_entity where status <> 'merged'`,
|
||||||
|
)) {
|
||||||
|
for (const t of [r.title, r.original_title]) {
|
||||||
|
if (!t) continue;
|
||||||
|
const n = normalizeTitle(t);
|
||||||
|
const s = shared.get(n);
|
||||||
|
if (s) s.add(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const releasesByEntity = new Map<string, { title: string; subtitle: string | null }[]>();
|
||||||
|
for (const r of await q<{ media_entity_id: string; title: string; subtitle: string | null }>(
|
||||||
|
`select media_entity_id, title, subtitle from source_release where media_entity_id = any($1::uuid[])`,
|
||||||
|
[entityIds],
|
||||||
|
)) {
|
||||||
|
const l = releasesByEntity.get(r.media_entity_id) ?? [];
|
||||||
|
l.push(r);
|
||||||
|
releasesByEntity.set(r.media_entity_id, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AliasReason = "shared" | "script" | "year";
|
||||||
|
const aliasDeletes: (AliasRow & { reason: AliasReason; others?: string[] })[] = [];
|
||||||
|
for (const a of siteAliases) {
|
||||||
|
const e = entities.get(a.media_entity_id);
|
||||||
|
if (!e) continue;
|
||||||
|
const own = ownNames.get(e.id)!;
|
||||||
|
const n = normalizeTitle(a.alias) || a.normalized;
|
||||||
|
const script = isHan(a.alias) ? "han" : "latin";
|
||||||
|
if (own.han.has(n) || own.latin.has(n)) continue;
|
||||||
|
const others = [...(shared.get(a.normalized) ?? [])].filter((id) => id !== e.id);
|
||||||
|
if (others.length) {
|
||||||
|
aliasDeletes.push({ ...a, reason: "shared", others });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (e.confirmed && own[script].size > 0 && !(script === "latin" && looksLikePinyin(a.alias))) {
|
||||||
|
aliasDeletes.push({ ...a, reason: "script" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (e.year !== null) {
|
||||||
|
const years = (releasesByEntity.get(e.id) ?? [])
|
||||||
|
.map((r) => parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}))
|
||||||
|
.filter((p) => [p.titleCn, p.titleEn, p.title].some((t) => t && normalizeTitle(t) === n))
|
||||||
|
.map((p) => p.year ?? null);
|
||||||
|
if (years.length && years.every((y) => y !== null && Math.abs(y - e.year!) > 1))
|
||||||
|
aliasDeletes.push({ ...a, reason: "year" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const REASON_TEXT: Record<AliasReason, string> = {
|
||||||
|
shared: "同名别名挂在别的作品上",
|
||||||
|
script: "与已确认的外部标题对不上",
|
||||||
|
year: "带这个名字的资源年份都与作品不符",
|
||||||
|
};
|
||||||
|
const entityTitle = async (id: string) =>
|
||||||
|
entities.get(id)?.title ??
|
||||||
|
(await q<{ title: string }>(`select title from media_entity where id = $1`, [id]))[0]?.title ??
|
||||||
|
id;
|
||||||
|
console.log(`## 冲突别名:${aliasDeletes.length} 个`);
|
||||||
|
for (const eid of [...new Set(aliasDeletes.map((a) => a.media_entity_id))]) {
|
||||||
|
const e = entities.get(eid)!;
|
||||||
|
console.log(`\n### ${e.title}(${e.year ?? "-"}) ${eid}`);
|
||||||
|
for (const a of aliasDeletes.filter((x) => x.media_entity_id === eid)) {
|
||||||
|
const also = a.others?.length
|
||||||
|
? `:${(await Promise.all(a.others.slice(0, 3).map(entityTitle))).map((t) => `《${t}》`).join("")}`
|
||||||
|
: "";
|
||||||
|
console.log(` - 删除「${a.alias}」(${REASON_TEXT[a.reason]}${also})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 引用 content_unit 的表
|
||||||
|
interface FkRef {
|
||||||
|
table: string;
|
||||||
|
column: string;
|
||||||
|
}
|
||||||
|
const fks = await q<FkRef>(
|
||||||
|
`select c.conrelid::regclass::text as table, a.attname as column
|
||||||
|
from pg_constraint c
|
||||||
|
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = any(c.conkey)
|
||||||
|
where c.contype = 'f' and c.confrelid = 'content_unit'::regclass`,
|
||||||
|
);
|
||||||
|
/** 只是单元自身的元数据(随单元删除),不算"被使用" */
|
||||||
|
const META_TABLES = new Set(["metadata_field", "field_override", "title_alias"]);
|
||||||
|
const usageRefs = fks.filter((f) => !META_TABLES.has(f.table.replace(/^.*\./, "")) && f.table !== "content_unit");
|
||||||
|
const quoteIdent = (s: string) =>
|
||||||
|
s
|
||||||
|
.split(".")
|
||||||
|
.map((p) => `"${p.replace(/"/g, '""')}"`)
|
||||||
|
.join(".");
|
||||||
|
async function refCounts(ids: string[]): Promise<Map<string, number>> {
|
||||||
|
const out = new Map<string, number>(ids.map((id) => [id, 0]));
|
||||||
|
if (!ids.length) return out;
|
||||||
|
for (const f of usageRefs) {
|
||||||
|
const rows = await q<{ id: string; n: number }>(
|
||||||
|
`select ${quoteIdent(f.column)} as id, count(*)::int as n from ${quoteIdent(f.table)}
|
||||||
|
where ${quoteIdent(f.column)} = any($1::uuid[]) group by 1`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
for (const r of rows) out.set(r.id, (out.get(r.id) ?? 0) + r.n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 2. 重复的临时内容单元
|
||||||
|
interface UnitRow {
|
||||||
|
id: string;
|
||||||
|
media_entity_id: string;
|
||||||
|
kind: string;
|
||||||
|
season_number: number | null;
|
||||||
|
episode_number: number | null;
|
||||||
|
status: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
const unitFilter = ONLY_ENTITY ? "and u.media_entity_id = $1" : "";
|
||||||
|
const dupRows = await q<UnitRow>(
|
||||||
|
`select u.id, u.media_entity_id, u.kind, u.season_number, u.episode_number, u.status, u.created_at
|
||||||
|
from content_unit u
|
||||||
|
join (select media_entity_id, kind, season_number, episode_number from content_unit
|
||||||
|
where status <> 'retired' group by 1, 2, 3, 4
|
||||||
|
having count(*) > 1 and bool_or(status = 'provisional')) g
|
||||||
|
on g.media_entity_id = u.media_entity_id and g.kind = u.kind
|
||||||
|
and g.season_number is not distinct from u.season_number and g.episode_number is not distinct from u.episode_number
|
||||||
|
where u.status <> 'retired' ${unitFilter}
|
||||||
|
order by u.media_entity_id, u.season_number, u.episode_number, u.created_at`,
|
||||||
|
ONLY_ENTITY ? [ONLY_ENTITY] : [],
|
||||||
|
);
|
||||||
|
const dupRefs = await refCounts(dupRows.map((u) => u.id));
|
||||||
|
const groups = new Map<string, UnitRow[]>();
|
||||||
|
for (const u of dupRows) {
|
||||||
|
const k = `${u.media_entity_id}|${u.kind}|${u.season_number}|${u.episode_number}`;
|
||||||
|
groups.set(k, [...(groups.get(k) ?? []), u]);
|
||||||
|
}
|
||||||
|
const merges: { keep: UnitRow; drop: UnitRow[] }[] = [];
|
||||||
|
for (const list of groups.values()) {
|
||||||
|
const sorted = [...list].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(dupRefs.get(b.id) ?? 0) - (dupRefs.get(a.id) ?? 0) ||
|
||||||
|
Number(b.status === "confirmed") - Number(a.status === "confirmed") ||
|
||||||
|
new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||||
|
);
|
||||||
|
merges.push({ keep: sorted[0]!, drop: sorted.slice(1) });
|
||||||
|
}
|
||||||
|
const unitLabel = (u: Pick<UnitRow, "kind" | "season_number" | "episode_number">) =>
|
||||||
|
u.kind === "season"
|
||||||
|
? `S${String(u.season_number ?? 0).padStart(2, "0")}`
|
||||||
|
: `S${String(u.season_number ?? 0).padStart(2, "0")}E${String(u.episode_number ?? 0).padStart(2, "0")}`;
|
||||||
|
console.log(`\n## 重复的临时内容单元:${merges.length} 组,删除 ${merges.reduce((s, m) => s + m.drop.length, 0)} 条`);
|
||||||
|
for (const m of merges) {
|
||||||
|
console.log(
|
||||||
|
` - 《${await entityTitle(m.keep.media_entity_id)}》${unitLabel(m.keep)}:保留 ${m.keep.id}(${m.keep.status},引用 ${dupRefs.get(m.keep.id) ?? 0}),` +
|
||||||
|
`合并 ${m.drop.map((d) => `${d.id}(${d.status},引用 ${dupRefs.get(d.id) ?? 0})`).join("、")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 3. 孤立的临时内容单元
|
||||||
|
const dropping = new Set(merges.flatMap((m) => m.drop.map((d) => d.id)));
|
||||||
|
let orphans: UnitRow[] = [];
|
||||||
|
if (!KEEP_ORPHANS) {
|
||||||
|
const cand = await q<UnitRow>(
|
||||||
|
`select u.id, u.media_entity_id, u.kind, u.season_number, u.episode_number, u.status, u.created_at
|
||||||
|
from content_unit u
|
||||||
|
where u.status = 'provisional' ${unitFilter}
|
||||||
|
and not exists (select 1 from metadata_field f where f.content_unit_id = u.id and f.source = 'tmdb')
|
||||||
|
order by u.media_entity_id, u.kind desc, u.season_number, u.episode_number`,
|
||||||
|
ONLY_ENTITY ? [ONLY_ENTITY] : [],
|
||||||
|
);
|
||||||
|
const refs = await refCounts(cand.map((u) => u.id));
|
||||||
|
const unused = cand.filter((u) => (refs.get(u.id) ?? 0) === 0 && !dropping.has(u.id));
|
||||||
|
const unusedIds = new Set(unused.map((u) => u.id));
|
||||||
|
// 季单元:子单元全都要删(或没有子单元)才删
|
||||||
|
const children = await q<{ parent_unit_id: string; id: string }>(
|
||||||
|
`select parent_unit_id, id from content_unit where parent_unit_id = any($1::uuid[])`,
|
||||||
|
[unused.filter((u) => u.kind === "season").map((u) => u.id)],
|
||||||
|
);
|
||||||
|
orphans = unused.filter(
|
||||||
|
(u) =>
|
||||||
|
u.kind !== "season" ||
|
||||||
|
children.filter((c) => c.parent_unit_id === u.id).every((c) => unusedIds.has(c.id) || dropping.has(c.id)),
|
||||||
|
);
|
||||||
|
console.log(`\n## 孤立的临时内容单元:${orphans.length} 条`);
|
||||||
|
const byEntity = new Map<string, UnitRow[]>();
|
||||||
|
for (const u of orphans) byEntity.set(u.media_entity_id, [...(byEntity.get(u.media_entity_id) ?? []), u]);
|
||||||
|
for (const [eid, list] of byEntity) console.log(` - 《${await entityTitle(eid)}》:${list.map(unitLabel).join(" ")}`);
|
||||||
|
} else console.log("\n## 孤立的临时内容单元:跳过(--keep-orphans)");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 执行
|
||||||
|
if (APPLY) {
|
||||||
|
await db.query("begin");
|
||||||
|
try {
|
||||||
|
if (aliasDeletes.length)
|
||||||
|
await q(`delete from title_alias where id = any($1::uuid[])`, [aliasDeletes.map((a) => a.id)]);
|
||||||
|
let repointed = 0;
|
||||||
|
let droppedRows = 0;
|
||||||
|
const allRefTables = fks.filter((f) => f.table !== "content_unit");
|
||||||
|
for (const m of merges) {
|
||||||
|
for (const d of m.drop) {
|
||||||
|
for (const f of allRefTables) {
|
||||||
|
const rows = await q<{ ctid: string }>(
|
||||||
|
`select ctid::text from ${quoteIdent(f.table)} where ${quoteIdent(f.column)} = $1`,
|
||||||
|
[d.id],
|
||||||
|
);
|
||||||
|
for (const r of rows) {
|
||||||
|
await db.query("savepoint sp");
|
||||||
|
try {
|
||||||
|
await q(`update ${quoteIdent(f.table)} set ${quoteIdent(f.column)} = $1 where ctid = $2::tid`, [
|
||||||
|
m.keep.id,
|
||||||
|
r.ctid,
|
||||||
|
]);
|
||||||
|
await db.query("release savepoint sp");
|
||||||
|
repointed++;
|
||||||
|
} catch (e) {
|
||||||
|
await db.query("rollback to savepoint sp");
|
||||||
|
if ((e as { code?: string }).code !== "23505") throw e;
|
||||||
|
await q(`delete from ${quoteIdent(f.table)} where ctid = $1::tid`, [r.ctid]);
|
||||||
|
droppedRows++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await q(`update content_unit set parent_unit_id = $1 where parent_unit_id = $2`, [m.keep.id, d.id]);
|
||||||
|
await q(`delete from content_unit where id = $1`, [d.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 孤立单元:先集后季
|
||||||
|
const orphanIds = orphans
|
||||||
|
.sort((a, b) => Number(a.kind === "season") - Number(b.kind === "season"))
|
||||||
|
.map((u) => u.id);
|
||||||
|
for (const t of META_TABLES)
|
||||||
|
if (fks.some((f) => f.table.replace(/^.*\./, "") === t))
|
||||||
|
await q(`delete from ${quoteIdent(t)} where content_unit_id = any($1::uuid[])`, [orphanIds]);
|
||||||
|
for (const u of orphans.filter((x) => x.kind !== "season"))
|
||||||
|
await q(`delete from content_unit where id = $1`, [u.id]);
|
||||||
|
for (const u of orphans.filter((x) => x.kind === "season"))
|
||||||
|
await q(`delete from content_unit where id = $1`, [u.id]);
|
||||||
|
await q(
|
||||||
|
`insert into audit_event (actor_type, actor_id, action, target_type, target_id, payload)
|
||||||
|
values ('system', 'identity-cleanup', 'identity.cleanup', 'media_entity', $1, $2::jsonb)`,
|
||||||
|
[
|
||||||
|
ONLY_ENTITY,
|
||||||
|
JSON.stringify({
|
||||||
|
aliases: aliasDeletes.map((a) => ({ entityId: a.media_entity_id, alias: a.alias, reason: a.reason })),
|
||||||
|
mergedUnits: merges.map((m) => ({ keep: m.keep.id, drop: m.drop.map((d) => d.id) })),
|
||||||
|
orphanUnits: orphans.length,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await db.query("commit");
|
||||||
|
console.log(
|
||||||
|
`\n已执行:删除 ${aliasDeletes.length} 个别名;合并 ${dropping.size} 个重复单元(改指向 ${repointed} 行,删除冲突行 ${droppedRows} 行);` +
|
||||||
|
`删除 ${orphans.length} 个孤立单元`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
await db.query("rollback");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} else console.log("\n(dry-run:未改库;确认后加 --apply 执行)");
|
||||||
|
await db.end();
|
||||||
Reference in new issue
Block a user