import { dirname } from "node:path"; import type { DB, Kysely } from "@umt/db"; import { sql } from "@umt/db"; // 运维一次性子命令(备份/恢复脚本调用):映射清单导出、恢复后打开只读对账。 // 映射清单是"路径不携带完整身份"前提下的关键资产(需求 §8.5):数据库之外再留一份可读 JSON, // 用于人工核对与恢复演练的行数校验;不含任何密钥(secret 表只随 pg_dump 走,主密钥永不入备份)。 /** 各表导出的关键列 */ export const MAPPING_TABLES = { library_root: ["id", "name", "media_kind", "local_path", "management_mode", "enabled", "sentinel_file"], path_mapping: [ "id", "view_kind", "downloader_instance_id", "media_server_instance_id", "library_root_id", "remote_prefix", "local_prefix", "priority", "enabled", ], media_entity: ["id", "kind", "title", "original_title", "year", "status", "merged_into_id", "updated_at"], external_identity: [ "id", "media_entity_id", "content_unit_id", "provider", "entity_type", "external_id", "status", "evidence_level", "locked", "revision", ], path_binding: [ "id", "media_entity_id", "content_unit_id", "season_number", "binding_kind", "library_root_id", "relative_path", "template_version", "status", "superseded_by_id", ], asset_location: [ "id", "media_asset_id", "location_kind", "library_root_id", "downloader_instance_id", "relative_path", "status", "is_primary", "size_bytes", "inode", "device", "mtime", ], acquisition_slot: [ "id", "media_entity_id", "content_unit_id", "subscription_id", "policy_revision_id", "name", "status", "current_asset_id", "revision", ], organization_record: [ "id", "media_entity_id", "content_unit_id", "download_task_id", "source_downloader_instance_id", "source_path", "target_library_root_id", "target_path", "action", "status", "plan_fingerprint", "finished_at", ], } as const satisfies Partial>; export type MappingTable = keyof typeof MAPPING_TABLES; export interface MappingExport { format: "umt-mappings/1"; exportedAt: string; appVersion: string; counts: Record; tables: Record[]>; } /** 按主键顺序导出映射清单 */ export async function exportMappings(db: Kysely, appVersion: string): Promise { const counts = {} as Record; const tables = {} as Record[]>; for (const [table, cols] of Object.entries(MAPPING_TABLES) as [MappingTable, readonly string[]][]) { const { rows } = await sql< Record >`select ${sql.join(cols.map((c) => sql.ref(c)))} from ${sql.table(table)} order by id`.execute(db); tables[table] = rows; counts[table] = rows.length; } return { format: "umt-mappings/1", exportedAt: new Date().toISOString(), appVersion, counts, tables }; } /** 写 JSON(先写临时文件再改名,避免半截文件) */ export async function writeMappingExport(path: string, data: MappingExport): Promise { await Deno.mkdir(dirname(path), { recursive: true }); const tmp = `${path}.partial`; await Deno.writeTextFile(tmp, `${JSON.stringify(data, null, 2)}\n`); await Deno.rename(tmp, path); } /** 两份清单逐表比行数,返回不一致的表 */ export function diffCounts( expected: Record, actual: Record, ): Array<{ table: string; expected: number; actual: number }> { const out: Array<{ table: string; expected: number; actual: number }> = []; for (const table of new Set([...Object.keys(expected), ...Object.keys(actual)])) { const e = expected[table] ?? -1; const a = actual[table] ?? -1; if (e !== a) out.push({ table, expected: e, actual: a }); } return out; } /** 打开 automation.readOnlyReconcile(保留该分组其它字段)并写审计 */ export async function enableReadOnlyReconcile(db: Kysely, reason: string): Promise { await db.transaction().execute(async (trx) => { await sql`insert into settings (key, value, updated_at) values ('automation', ${JSON.stringify({ readOnlyReconcile: true })}::jsonb, now()) on conflict (key) do update set value = settings.value || excluded.value, updated_at = now()`.execute(trx); await trx .insertInto("audit_event") .values({ actor_type: "system", actor_id: "restore", action: "ops.read_only_reconcile_on", target_type: "settings", target_id: "automation", payload: JSON.stringify({ reason }), }) .execute(); }); }