- metadata.includeSpecials (default true): full refresh and the language backfill fetch TMDB season 0 (specials) when present, like MP; season 0 stays out of the episode-total ledger - refreshMetadata promotes provisional season/episode units that match the TMDB season/episode layout to confirmed (source=tmdb), keeping ids and all references; non-matching units stay provisional - content_unit.source (migration 0053): release / file / tmdb / user - duplicate units (same entity/kind/season/episode/content type/segment) are merged as a refresh step and via `scripts/identity-cleanup.ts --merge-duplicates` (dry-run by default); keeper = most referenced, then confirmed, then earliest; references auto-discovered via FKs and repointed - ensureUnits takes a per-unit advisory xact lock to stop concurrent lazy creation from producing duplicates - runbooks updated; user DB read-only dry-run: 征途 S01E09/E10 4 units each -> 1 each (6 removed), not applied Co-Authored-By: Claude Opus 5.5 <[email protected]>
443 lines
21 KiB
TypeScript
443 lines
21 KiB
TypeScript
// 身份数据清理(FIX-5):删除冲突别名、合并重复的内容单元、删除孤立的临时内容单元。默认只读(dry-run),加 --apply 才改库。
|
|
//
|
|
// 用法(DATABASE_URL 必须显式给出,脚本不带默认库):
|
|
// DATABASE_URL=postgres://umt:***@<host>:5432/umt deno run -A scripts/identity-cleanup.ts [--dry-run] # 看清单(按类别列删除 / 保留)
|
|
// 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> # 只看 / 只处理一部作品
|
|
// DATABASE_URL=… deno run -A scripts/identity-cleanup.ts --merge-duplicates [--apply] # 只做第 2 步:合并重复单元(DH-1)
|
|
//
|
|
// 1. 冲突别名:只处理自动识别登记的站点别名(title_alias.source = 'site',作品级)。以下任一成立即删除:
|
|
// - shared:同一归一化别名还挂在别的作品上,且它不是本作品自己的名字(主标题 / 原标题 / TMDB 译名与别名);
|
|
// - script:作品有已接受的外部身份,且同一种文字(中文 / 拉丁)下已有确认的外部标题,这个别名对不上任何一个
|
|
// (中文标题的拼音写法视为相容,保留);
|
|
// - year:本作品下带这个名字的资源都解析出了年份,且都与作品年份相差超过 1 年。
|
|
// 2. 重复的内容单元(DH-1,与 refreshMetadata 共用 apps/server/.../unit-merge.ts):同一作品、同一类型、同季同集号、
|
|
// 同内容类型与分段有多条未退役单元时,保留被引用最多的那条(同数时 confirmed 优先,再取最早建的),其余的全部引用
|
|
// (资源覆盖、资产、下载计划、订阅覆盖、字段来源…按外键自动发现)改指向保留的那条后删除;
|
|
// 改指向会撞唯一约束的行(同一行已在保留的单元上)直接删掉。
|
|
// 3. 孤立的临时内容单元(--keep-orphans 跳过):只删资源解析建出的临时单元——provisional、没有任何资源 / 资产 / 订阅 /
|
|
// 下载 / 整理等引用、没有任何元数据痕迹(字段来源 / 覆盖 / 标题 / 播出日期 / 总集数来源),且作品没有外部映射或
|
|
// 这个季 / 集超出作品的外部季集结构;没有保留的子单元。TMDB / 元数据来源的单元永不删除(FIX-6)。
|
|
// 字段来源 / 覆盖 / 单元别名随之删除。
|
|
// --apply 在一个事务里执行,并写一条审计事件(action = identity.cleanup)。
|
|
|
|
import type { DB } from "@umt/db";
|
|
import { normalizeTitle, parseRelease } from "@umt/domain";
|
|
import { Kysely, PostgresDialect } from "kysely";
|
|
import pg from "pg";
|
|
import {
|
|
applyUnitMerges,
|
|
planUnitMerges,
|
|
type UnitMergeGroup,
|
|
} from "../apps/server/src/modules/sources-identity/service/unit-merge.ts";
|
|
|
|
const argv = [...Deno.args];
|
|
const APPLY = argv.includes("--apply");
|
|
if (APPLY && argv.includes("--dry-run")) {
|
|
console.error("--apply 与 --dry-run 不能同时给出");
|
|
Deno.exit(2);
|
|
}
|
|
const MERGE_ONLY = argv.includes("--merge-duplicates");
|
|
const KEEP_ORPHANS = argv.includes("--keep-orphans") || MERGE_ONLY;
|
|
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();
|
|
// 合并模块用 Kysely:与 q() 共用同一个连接(同一事务)
|
|
const kdb = new Kysely<DB>({
|
|
dialect: new PostgresDialect({
|
|
pool: { connect: async () => Object.assign(db, { release() {} }), end: async () => {} } as never,
|
|
}),
|
|
});
|
|
const q = async <T>(text: string, values: unknown[] = []) => (await db.query(text, values)).rows as T[];
|
|
console.log(
|
|
`# identity-cleanup (${APPLY ? "APPLY" : "DRY-RUN"})${MERGE_ONLY ? " --merge-duplicates" : 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 = MERGE_ONLY
|
|
? []
|
|
: 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;
|
|
if (!MERGE_ONLY) 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 merges: UnitMergeGroup[] = await planUnitMerges(kdb, ONLY_ENTITY ? { mediaEntityId: ONLY_ENTITY } : {});
|
|
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")}`;
|
|
const groupLabel = (g: UnitMergeGroup) =>
|
|
unitLabel({ kind: g.kind, season_number: g.seasonNumber, episode_number: g.episodeNumber }) +
|
|
(g.contentType !== "main" || g.segment !== "full" ? `(${g.contentType}/${g.segment})` : "");
|
|
console.log(`\n## 重复的内容单元:${merges.length} 组,删除 ${merges.reduce((s, m) => s + m.drop.length, 0)} 条`);
|
|
for (const m of merges) {
|
|
console.log(
|
|
` - 《${await entityTitle(m.mediaEntityId)}》${groupLabel(m)}:保留 ${m.keep.id}(${m.keep.status},引用 ${m.keep.refs}),` +
|
|
`合并 ${m.drop.map((d) => `${d.id}(${d.status},引用 ${d.refs})`).join("、")}`,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 3. 孤立的临时内容单元
|
|
// 只删"资源解析建出来、外部元数据不认识"的临时单元。以下任一成立即保留(FIX-6:曾把 TMDB 季集账本单元当孤立删掉):
|
|
// - metadata:单元自身带元数据痕迹——有任何字段来源 / 字段覆盖,或标题、播出日期、总集数来源、metadata 非空
|
|
// (资源解析建单元时这些都不写,有就说明来自 TMDB 等元数据或人工);
|
|
// - external:作品有已接受的外部身份,且这个季 / 集落在外部季集结构里(季号 0..季数,季数未知时整部作品都算;
|
|
// 集号不超过该季已知总集数,总集数未知时都算)。
|
|
// 保留的单元连同其季单元都不删。
|
|
const dropping = new Set(merges.flatMap((m) => m.drop.map((d) => d.id)));
|
|
type OrphanReason = "no_external" | "outside_external";
|
|
type KeepReason = "referenced" | "metadata" | "external" | "has_children";
|
|
let orphans: (UnitRow & { reason: OrphanReason })[] = [];
|
|
if (!KEEP_ORPHANS) {
|
|
const cand = await q<UnitRow & { has_meta: boolean }>(
|
|
`select u.id, u.media_entity_id, u.kind, u.season_number, u.episode_number, u.status, u.created_at,
|
|
(u.title is not null or u.air_date is not null or u.episode_total_source is not null
|
|
or u.metadata <> '{}'::jsonb
|
|
or exists (select 1 from metadata_field f where f.content_unit_id = u.id)
|
|
or exists (select 1 from field_override o where o.content_unit_id = u.id)) as has_meta
|
|
from content_unit u
|
|
where u.status = 'provisional' ${unitFilter}
|
|
order by u.media_entity_id, u.kind desc, u.season_number, u.episode_number`,
|
|
ONLY_ENTITY ? [ONLY_ENTITY] : [],
|
|
);
|
|
const candEntities = [...new Set(cand.map((u) => u.media_entity_id))];
|
|
// 外部季集结构:已接受外部身份的作品 → 季数(null = 未知)与各季已知总集数
|
|
const layout = new Map<string, { seasonCount: number | null; totals: Map<number, number> }>();
|
|
for (const r of await q<{ id: string; season_count: number | null }>(
|
|
`select e.id,
|
|
coalesce(
|
|
case when jsonb_typeof(e.metadata->'seasonCount'->'value') = 'number'
|
|
then (e.metadata->'seasonCount'->>'value')::int end,
|
|
case when jsonb_typeof(e.metadata->'seasonCount') = 'number' then (e.metadata->>'seasonCount')::int end,
|
|
(select case when jsonb_typeof(f.value) = 'number' then (f.value #>> '{}')::int end
|
|
from metadata_field f where f.media_entity_id = e.id and f.content_unit_id is null
|
|
and f.field = 'seasonCount' order by f.updated_at desc limit 1)
|
|
) as season_count
|
|
from media_entity e
|
|
where e.id = any($1::uuid[])
|
|
and exists (select 1 from external_identity x where x.media_entity_id = e.id and x.status = 'accepted')`,
|
|
[candEntities],
|
|
))
|
|
layout.set(r.id, { seasonCount: r.season_count, totals: new Map() });
|
|
for (const r of await q<{ media_entity_id: string; season_number: number; total: number }>(
|
|
`select media_entity_id, season_number, max(episode_total)::int as total from content_unit
|
|
where kind = 'season' and episode_total is not null and episode_total_source is not null
|
|
and media_entity_id = any($1::uuid[]) group by 1, 2`,
|
|
[[...layout.keys()]],
|
|
))
|
|
layout.get(r.media_entity_id)?.totals.set(r.season_number, r.total);
|
|
const inExternalLayout = (u: UnitRow): boolean | null => {
|
|
const l = layout.get(u.media_entity_id);
|
|
if (!l) return null; // 作品没有外部映射
|
|
const s = u.season_number;
|
|
if (s === null) return true;
|
|
if (l.seasonCount !== null && (s < 0 || s > l.seasonCount)) return false;
|
|
if (u.kind !== "episode" || u.episode_number === null) return true;
|
|
const total = l.totals.get(s);
|
|
return total === undefined || u.episode_number <= total;
|
|
};
|
|
|
|
const refs = await refCounts(cand.map((u) => u.id));
|
|
const keep = new Map<string, KeepReason>();
|
|
const drop = new Map<string, OrphanReason>();
|
|
for (const u of cand) {
|
|
if (dropping.has(u.id)) continue;
|
|
if ((refs.get(u.id) ?? 0) > 0) keep.set(u.id, "referenced");
|
|
else if (u.has_meta) keep.set(u.id, "metadata");
|
|
else {
|
|
const inside = inExternalLayout(u);
|
|
if (inside === true) keep.set(u.id, "external");
|
|
else drop.set(u.id, inside === null ? "no_external" : "outside_external");
|
|
}
|
|
}
|
|
// 季单元:子单元全都要删(或没有子单元)才删
|
|
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[])`,
|
|
[cand.filter((u) => u.kind === "season" && drop.has(u.id)).map((u) => u.id)],
|
|
);
|
|
for (const u of cand)
|
|
if (
|
|
u.kind === "season" &&
|
|
drop.has(u.id) &&
|
|
!children.filter((c) => c.parent_unit_id === u.id).every((c) => drop.has(c.id) || dropping.has(c.id))
|
|
) {
|
|
drop.delete(u.id);
|
|
keep.set(u.id, "has_children");
|
|
}
|
|
orphans = cand.filter((u) => drop.has(u.id)).map((u) => ({ ...u, reason: drop.get(u.id)! }));
|
|
|
|
const ORPHAN_TEXT: Record<OrphanReason, string> = {
|
|
no_external: "作品没有外部映射",
|
|
outside_external: "超出外部季集结构",
|
|
};
|
|
const KEEP_TEXT: Record<KeepReason, string> = {
|
|
referenced: "仍被资源 / 资产 / 订阅等引用",
|
|
metadata: "TMDB / 元数据 / 人工来源",
|
|
external: "在作品的外部季集结构内",
|
|
has_children: "有保留的子单元",
|
|
};
|
|
const printGroup = async (list: UnitRow[]) => {
|
|
const byEntity = new Map<string, UnitRow[]>();
|
|
for (const u of list) byEntity.set(u.media_entity_id, [...(byEntity.get(u.media_entity_id) ?? []), u]);
|
|
for (const [eid, l] of byEntity) console.log(` - 《${await entityTitle(eid)}》:${l.map(unitLabel).join(" ")}`);
|
|
};
|
|
console.log(`\n## 孤立的临时内容单元:删除 ${orphans.length} 条`);
|
|
for (const r of Object.keys(ORPHAN_TEXT) as OrphanReason[]) {
|
|
const l = orphans.filter((u) => u.reason === r);
|
|
console.log(` - 删除·${ORPHAN_TEXT[r]}:${l.length} 条`);
|
|
await printGroup(l);
|
|
}
|
|
console.log(`\n## 临时内容单元保留(不删):${keep.size} 条`);
|
|
for (const r of Object.keys(KEEP_TEXT) as KeepReason[]) {
|
|
const l = cand.filter((u) => keep.get(u.id) === r);
|
|
console.log(` - 保留·${KEEP_TEXT[r]}:${l.length} 条`);
|
|
if (r !== "referenced") await printGroup(l);
|
|
}
|
|
} else if (!MERGE_ONLY) 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)]);
|
|
const merged = await applyUnitMerges(kdb, merges);
|
|
// 孤立单元:先集后季
|
|
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) })),
|
|
mode: MERGE_ONLY ? "merge-duplicates" : "full",
|
|
orphanUnits: orphans.map((u) => ({
|
|
id: u.id,
|
|
entityId: u.media_entity_id,
|
|
unit: unitLabel(u),
|
|
reason: u.reason,
|
|
})),
|
|
}),
|
|
],
|
|
);
|
|
await db.query("commit");
|
|
console.log(
|
|
`\n已执行:删除 ${aliasDeletes.length} 个别名;合并 ${merged.merged} 个重复单元(改指向 ${merged.repointed} 行,删除冲突行 ${merged.droppedRows} 行);` +
|
|
`删除 ${orphans.length} 个孤立单元`,
|
|
);
|
|
} catch (e) {
|
|
await db.query("rollback");
|
|
throw e;
|
|
}
|
|
} else console.log("\n(dry-run:未改库;确认后加 --apply 执行)");
|
|
await db.end();
|