update_knowledge_base_2026_fall.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. #!/usr/bin/env node
  2. import assert from "node:assert/strict";
  3. import fs from "node:fs";
  4. import path from "node:path";
  5. import { fileURLToPath } from "node:url";
  6. import mysql from "mysql2/promise";
  7. import config from "../../src/config/dev.js";
  8. const taskDir = path.dirname(fileURLToPath(import.meta.url));
  9. const chineseSourcePath = path.join(taskDir, "source_chinese_2026_fall.json");
  10. const englishSourcePath = path.join(taskDir, "source_english_2026_fall.json");
  11. const chineseSources = JSON.parse(fs.readFileSync(chineseSourcePath, "utf8"));
  12. const englishSources = JSON.parse(fs.readFileSync(englishSourcePath, "utf8"));
  13. const flags = new Set(process.argv.slice(2));
  14. const knownFlags = new Set(["--dry-run", "--apply", "--audit"]);
  15. for (const flag of flags) {
  16. assert(knownFlags.has(flag), `不支持的参数:${flag}`);
  17. }
  18. assert(
  19. ["--dry-run", "--apply", "--audit"].filter((flag) => flags.has(flag)).length <= 1,
  20. "--dry-run、--apply、--audit 只能选择一个",
  21. );
  22. const mode = flags.has("--apply") ? "apply" : flags.has("--audit") ? "audit" : "dry-run";
  23. const targetMiaoguoBookIds = [...chineseSources, ...englishSources].map(
  24. (source) => source.miaoguoBookId,
  25. );
  26. const targetHanziBookIds = chineseSources.map((source) => source.bookIdOld);
  27. const targetEnglishBookIds = englishSources.map((source) => source.bookIdOld);
  28. function nowForFile() {
  29. return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
  30. }
  31. function writeJson(filePath, data) {
  32. fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
  33. }
  34. function countChineseWords(source) {
  35. if (source.unitType === 2) {
  36. return source.units.reduce(
  37. (total, unit) => total + unit.example.split(",").filter(Boolean).length,
  38. 0,
  39. );
  40. }
  41. return source.units.reduce((total, unit) => total + Array.from(unit.example).length, 0);
  42. }
  43. function flattenEnglish(source) {
  44. return source.units.flatMap((unit) =>
  45. unit.entries.map((entry, index) => ({
  46. Word: entry.word,
  47. WordRemark: null,
  48. Level: null,
  49. BookID: source.bookIdOld,
  50. LessonID: unit.lessonId,
  51. LessonName: unit.lessonName,
  52. Soundmark: entry.soundmark ?? null,
  53. Translate: entry.translate,
  54. EnglishExplanation: null,
  55. ExampleSentence: null,
  56. Sort: index + 1,
  57. })),
  58. );
  59. }
  60. function validateSources() {
  61. assert.equal(chineseSources.length, 8, "本批次应有 8 个语文数据集");
  62. assert.equal(englishSources.length, 2, "本批次应有 2 个英语数据集");
  63. assert.equal(
  64. new Set(targetMiaoguoBookIds).size,
  65. targetMiaoguoBookIds.length,
  66. "MiaoguoBook ID 不能重复",
  67. );
  68. assert.equal(
  69. new Set([...targetHanziBookIds, ...targetEnglishBookIds]).size,
  70. targetHanziBookIds.length + targetEnglishBookIds.length,
  71. "BookIDOld 不能重复",
  72. );
  73. for (const source of chineseSources) {
  74. assert([1, 2, 5].includes(source.unitType), `${source.label} UnitType 不正确`);
  75. assert(source.units.length > 0, `${source.label} 没有单元`);
  76. for (const unit of source.units) {
  77. assert(unit.name?.length > 0, `${source.label} 存在空 Name`);
  78. assert(unit.example?.length > 0, `${source.label}/${unit.name} 存在空 Example`);
  79. }
  80. assert.equal(
  81. countChineseWords(source),
  82. source.wordNum,
  83. `${source.label} 由 Example 计算出的字/词数与 WordNum 不一致`,
  84. );
  85. if (source.unitType === 1) {
  86. assert.equal(
  87. source.officialWordNum + source.blueCharacterCount,
  88. source.wordNum,
  89. `${source.label} 黑字数 + 蓝色多音字数与 WordNum 不一致`,
  90. );
  91. }
  92. }
  93. for (const source of englishSources) {
  94. assert(source.units.length > 0, `${source.label} 没有单元`);
  95. assert.deepEqual(
  96. source.units.map((unit) => unit.lessonId),
  97. source.units.map((_, index) => index + 1),
  98. `${source.label} LessonID 必须从 1 连续递增`,
  99. );
  100. for (const unit of source.units) {
  101. assert.equal(unit.lessonName, `Unit ${unit.lessonId}`, `${source.label} LessonName 不正确`);
  102. for (const entry of unit.entries) {
  103. assert(entry.word?.length > 0, `${source.label}/${unit.lessonName} 存在空 Word`);
  104. assert(entry.translate?.length > 0, `${source.label}/${unit.lessonName} 存在空 Translate`);
  105. if (entry.soundmark != null) {
  106. assert(
  107. entry.soundmark.startsWith("[") && entry.soundmark.endsWith("]"),
  108. `${source.label}/${entry.word} 音标必须使用方括号`,
  109. );
  110. }
  111. }
  112. }
  113. assert.equal(
  114. flattenEnglish(source).length,
  115. source.wordNum,
  116. `${source.label} 条目数与 WordNum 不一致`,
  117. );
  118. }
  119. }
  120. async function readState(connection, forUpdate = false) {
  121. const lock = forUpdate ? " FOR UPDATE" : "";
  122. const [books] = await connection.query(
  123. `SELECT * FROM MiaoguoBook WHERE ID IN (?) ORDER BY ID${lock}`,
  124. [targetMiaoguoBookIds],
  125. );
  126. const [hanziRows] = await connection.query(
  127. `SELECT * FROM HanziUnit WHERE HanziBookID IN (?) ORDER BY HanziBookID,OrderID,ID${lock}`,
  128. [targetHanziBookIds],
  129. );
  130. const [englishRows] = await connection.query(
  131. `SELECT * FROM Words WHERE BookID IN (?) ORDER BY BookID,LessonID,Sort,ID${lock}`,
  132. [targetEnglishBookIds],
  133. );
  134. return { books, hanziRows, englishRows };
  135. }
  136. function validateDatabaseMappings(state) {
  137. assert.equal(state.books.length, targetMiaoguoBookIds.length, "目标 MiaoguoBook 记录不完整");
  138. for (const source of [...chineseSources, ...englishSources]) {
  139. const book = state.books.find((row) => row.ID === source.miaoguoBookId);
  140. assert(book, `MiaoguoBook.ID=${source.miaoguoBookId} 不存在`);
  141. assert.equal(
  142. book.BookIDOld,
  143. source.bookIdOld,
  144. `${source.label} 的 BookIDOld 与源数据映射不一致`,
  145. );
  146. }
  147. }
  148. async function validateHanziDictionary(connection) {
  149. const chars = [
  150. ...new Set(
  151. chineseSources
  152. .filter((source) => source.unitType === 1 || source.unitType === 5)
  153. .flatMap((source) => source.units.flatMap((unit) => Array.from(unit.example))),
  154. ),
  155. ];
  156. const [rows] = await connection.query("SELECT DISTINCT Name FROM HanziWord WHERE Name IN (?)", [
  157. chars,
  158. ]);
  159. const found = new Set(rows.map((row) => row.Name));
  160. const missing = chars.filter((char) => !found.has(char));
  161. assert.equal(missing.length, 0, `HanziWord 缺少 ${missing.length} 个字:${missing.join("")}`);
  162. return { checkedDistinctCharacters: chars.length, missing: [] };
  163. }
  164. function buildChinesePlan(source, state) {
  165. const existing = state.hanziRows.filter((row) => row.HanziBookID === source.bookIdOld);
  166. assert(
  167. existing.length <= source.units.length,
  168. `${source.label} 现有 ${existing.length} 行、新版只有 ${source.units.length} 行;语文禁止删除,不能自动执行`,
  169. );
  170. const matched = Array(source.units.length).fill(null);
  171. const usedIds = new Set();
  172. const buckets = new Map();
  173. for (const row of existing) {
  174. const rows = buckets.get(row.Name) ?? [];
  175. rows.push(row);
  176. buckets.set(row.Name, rows);
  177. }
  178. for (let index = 0; index < source.units.length; index += 1) {
  179. const candidates = buckets.get(source.units[index].name) ?? [];
  180. const row = candidates.find((candidate) => !usedIds.has(candidate.ID));
  181. if (row) {
  182. matched[index] = row;
  183. usedIds.add(row.ID);
  184. }
  185. }
  186. const unusedRows = existing.filter((row) => !usedIds.has(row.ID));
  187. let unusedIndex = 0;
  188. for (let index = 0; index < source.units.length; index += 1) {
  189. if (!matched[index] && unusedIndex < unusedRows.length) {
  190. matched[index] = unusedRows[unusedIndex];
  191. usedIds.add(unusedRows[unusedIndex].ID);
  192. unusedIndex += 1;
  193. }
  194. }
  195. const operations = source.units.map((unit, index) => {
  196. const old = matched[index];
  197. return {
  198. action: old ? (old.Name === unit.name ? "UPDATE_SAME_NAME" : "UPDATE_REPURPOSE") : "INSERT",
  199. id: old?.ID ?? null,
  200. oldName: old?.Name ?? null,
  201. oldExample: old?.Example ?? null,
  202. oldIsLocked: old?.IsLocked ?? null,
  203. name: unit.name,
  204. example: unit.example,
  205. unitType: source.unitType,
  206. orderId: index + 1,
  207. isLocked: old?.IsLocked ?? 1,
  208. };
  209. });
  210. return {
  211. label: source.label,
  212. miaoguoBookId: source.miaoguoBookId,
  213. bookIdOld: source.bookIdOld,
  214. currentRows: existing.length,
  215. targetRows: source.units.length,
  216. currentWordNum: state.books.find((book) => book.ID === source.miaoguoBookId).WordNum,
  217. targetWordNum: source.wordNum,
  218. currentUnitNum: state.books.find((book) => book.ID === source.miaoguoBookId).UnitNum,
  219. targetUnitNum: source.units.length,
  220. sameNameUpdates: operations.filter((item) => item.action === "UPDATE_SAME_NAME").length,
  221. repurposedUpdates: operations.filter((item) => item.action === "UPDATE_REPURPOSE").length,
  222. inserts: operations.filter((item) => item.action === "INSERT").length,
  223. deletes: 0,
  224. operations,
  225. };
  226. }
  227. function buildPlan(state, dictionaryCheck) {
  228. const chinese = chineseSources.map((source) => buildChinesePlan(source, state));
  229. const english = englishSources.map((source) => {
  230. const currentRows = state.englishRows.filter((row) => row.BookID === source.bookIdOld);
  231. const book = state.books.find((row) => row.ID === source.miaoguoBookId);
  232. return {
  233. label: source.label,
  234. miaoguoBookId: source.miaoguoBookId,
  235. bookIdOld: source.bookIdOld,
  236. currentRows: currentRows.length,
  237. deleteRows: currentRows.length,
  238. insertRows: flattenEnglish(source).length,
  239. currentUnitNum: book.UnitNum,
  240. targetUnitNum: source.units.length,
  241. currentWordNum: book.WordNum,
  242. targetWordNum: source.wordNum,
  243. perUnit: source.units.map((unit) => ({
  244. lessonId: unit.lessonId,
  245. lessonName: unit.lessonName,
  246. count: unit.entries.length,
  247. })),
  248. sortPolicy: "每单元从 1 连续递增,接口按 LessonID,Sort,ID 排序",
  249. };
  250. });
  251. return {
  252. generatedAt: new Date().toISOString(),
  253. mode,
  254. safetyRules: {
  255. chinese: "只执行 UPDATE/INSERT,永不 DELETE",
  256. english: "事务内按 BookID 删除旧数据并重建;事务前先落盘备份",
  257. metadata: "同步更新 MiaoguoBook.UnitNum 和 WordNum",
  258. transaction: "任一写入或逐条核验失败即 ROLLBACK",
  259. },
  260. dictionaryCheck,
  261. chinese,
  262. english,
  263. };
  264. }
  265. async function applyChinese(connection, plan) {
  266. for (const bookPlan of plan.chinese) {
  267. for (const operation of bookPlan.operations) {
  268. if (operation.action === "INSERT") {
  269. const [result] = await connection.execute(
  270. `INSERT INTO HanziUnit
  271. (HanziBookID,UnitType,Name,Example,IsLocked,OrderID)
  272. VALUES (?,?,?,?,?,?)`,
  273. [
  274. bookPlan.bookIdOld,
  275. operation.unitType,
  276. operation.name,
  277. operation.example,
  278. operation.isLocked,
  279. operation.orderId,
  280. ],
  281. );
  282. operation.id = result.insertId;
  283. } else {
  284. const [result] = await connection.execute(
  285. `UPDATE HanziUnit
  286. SET HanziBookID=?,UnitType=?,Name=?,Example=?,IsLocked=?,OrderID=?
  287. WHERE ID=?`,
  288. [
  289. bookPlan.bookIdOld,
  290. operation.unitType,
  291. operation.name,
  292. operation.example,
  293. operation.isLocked,
  294. operation.orderId,
  295. operation.id,
  296. ],
  297. );
  298. assert.equal(result.affectedRows, 1, `HanziUnit.ID=${operation.id} 更新失败`);
  299. }
  300. }
  301. const [result] = await connection.execute(
  302. "UPDATE MiaoguoBook SET UnitNum=?,WordNum=? WHERE ID=? AND BookIDOld=?",
  303. [bookPlan.targetUnitNum, bookPlan.targetWordNum, bookPlan.miaoguoBookId, bookPlan.bookIdOld],
  304. );
  305. assert.equal(result.affectedRows, 1, `${bookPlan.label} MiaoguoBook 更新失败`);
  306. }
  307. }
  308. async function applyEnglish(connection, plan) {
  309. for (const source of englishSources) {
  310. await connection.execute("DELETE FROM Words WHERE BookID=?", [source.bookIdOld]);
  311. for (const row of flattenEnglish(source)) {
  312. await connection.execute(
  313. `INSERT INTO Words
  314. (Word,WordRemark,Level,BookID,LessonID,LessonName,Soundmark,Translate,
  315. EnglishExplanation,ExampleSentence,Sort)
  316. VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
  317. [
  318. row.Word,
  319. row.WordRemark,
  320. row.Level,
  321. row.BookID,
  322. row.LessonID,
  323. row.LessonName,
  324. row.Soundmark,
  325. row.Translate,
  326. row.EnglishExplanation,
  327. row.ExampleSentence,
  328. row.Sort,
  329. ],
  330. );
  331. }
  332. const englishPlan = plan.english.find((item) => item.bookIdOld === source.bookIdOld);
  333. const [result] = await connection.execute(
  334. "UPDATE MiaoguoBook SET UnitNum=?,WordNum=? WHERE ID=? AND BookIDOld=?",
  335. [
  336. englishPlan.targetUnitNum,
  337. englishPlan.targetWordNum,
  338. englishPlan.miaoguoBookId,
  339. englishPlan.bookIdOld,
  340. ],
  341. );
  342. assert.equal(result.affectedRows, 1, `${source.label} MiaoguoBook 更新失败`);
  343. }
  344. }
  345. function assertEnglishRow(actual, expected, label, index) {
  346. for (const field of [
  347. "Word",
  348. "WordRemark",
  349. "Level",
  350. "BookID",
  351. "LessonID",
  352. "LessonName",
  353. "Soundmark",
  354. "Translate",
  355. "EnglishExplanation",
  356. "ExampleSentence",
  357. "Sort",
  358. ]) {
  359. assert.equal(actual[field], expected[field], `${label} 第 ${index + 1} 行 ${field} 不一致`);
  360. }
  361. }
  362. async function auditDatabase(connection) {
  363. const state = await readState(connection);
  364. validateDatabaseMappings(state);
  365. const booksById = new Map(state.books.map((book) => [book.ID, book]));
  366. const result = { passed: true, books: [], chinese: [], english: [] };
  367. for (const source of chineseSources) {
  368. const rows = state.hanziRows.filter((row) => row.HanziBookID === source.bookIdOld);
  369. assert.equal(rows.length, source.units.length, `${source.label} HanziUnit 行数不一致`);
  370. rows.forEach((row, index) => {
  371. assert.equal(row.HanziBookID, source.bookIdOld, `${source.label} 第 ${index + 1} 行 BookID 不一致`);
  372. assert.equal(row.UnitType, source.unitType, `${source.label} 第 ${index + 1} 行 UnitType 不一致`);
  373. assert.equal(row.Name, source.units[index].name, `${source.label} 第 ${index + 1} 行 Name 不一致`);
  374. assert.equal(
  375. row.Example,
  376. source.units[index].example,
  377. `${source.label} 第 ${index + 1} 行 Example 不一致`,
  378. );
  379. assert.equal(row.OrderID, index + 1, `${source.label} 第 ${index + 1} 行 OrderID 不一致`);
  380. });
  381. const book = booksById.get(source.miaoguoBookId);
  382. assert.equal(book.UnitNum, source.units.length, `${source.label} UnitNum 不一致`);
  383. assert.equal(book.WordNum, source.wordNum, `${source.label} WordNum 不一致`);
  384. result.chinese.push({
  385. label: source.label,
  386. miaoguoBookId: source.miaoguoBookId,
  387. bookIdOld: source.bookIdOld,
  388. rows: rows.length,
  389. unitNum: book.UnitNum,
  390. wordNum: book.WordNum,
  391. orderIdRange: rows.length ? [rows[0].OrderID, rows.at(-1).OrderID] : [],
  392. duplicateOrderIds: rows.length - new Set(rows.map((row) => row.OrderID)).size,
  393. });
  394. }
  395. for (const source of englishSources) {
  396. const actual = state.englishRows.filter((row) => row.BookID === source.bookIdOld);
  397. const expected = flattenEnglish(source);
  398. assert.equal(actual.length, expected.length, `${source.label} Words 行数不一致`);
  399. actual.forEach((row, index) => assertEnglishRow(row, expected[index], source.label, index));
  400. const book = booksById.get(source.miaoguoBookId);
  401. assert.equal(book.UnitNum, source.units.length, `${source.label} UnitNum 不一致`);
  402. assert.equal(book.WordNum, source.wordNum, `${source.label} WordNum 不一致`);
  403. result.english.push({
  404. label: source.label,
  405. miaoguoBookId: source.miaoguoBookId,
  406. bookIdOld: source.bookIdOld,
  407. rows: actual.length,
  408. unitNum: book.UnitNum,
  409. wordNum: book.WordNum,
  410. perUnit: source.units.map((unit) => ({
  411. lessonId: unit.lessonId,
  412. rows: actual.filter((row) => row.LessonID === unit.lessonId).length,
  413. })),
  414. });
  415. }
  416. result.books = [...chineseSources, ...englishSources].map((source) => {
  417. const book = booksById.get(source.miaoguoBookId);
  418. return {
  419. id: book.ID,
  420. bookName: book.BookName,
  421. bookIdOld: book.BookIDOld,
  422. unitNum: book.UnitNum,
  423. wordNum: book.WordNum,
  424. };
  425. });
  426. return result;
  427. }
  428. async function auditChineseIdPreservation(connection, backupHanziRows) {
  429. const oldIds = backupHanziRows.map((row) => row.ID);
  430. const [rows] = await connection.query("SELECT ID FROM HanziUnit WHERE ID IN (?)", [oldIds]);
  431. const existingIds = new Set(rows.map((row) => row.ID));
  432. const missingIds = oldIds.filter((id) => !existingIds.has(id));
  433. assert.equal(missingIds.length, 0, `有 ${missingIds.length} 个语文旧 ID 被删除:${missingIds.join(",")}`);
  434. return {
  435. oldRows: oldIds.length,
  436. oldIdsStillPresent: rows.length,
  437. missingIds,
  438. newRows: chineseSources.reduce((total, source) => total + source.units.length, 0) - oldIds.length,
  439. };
  440. }
  441. async function main() {
  442. validateSources();
  443. const connection = await mysql.createConnection(config.database);
  444. try {
  445. const dictionaryCheck = await validateHanziDictionary(connection);
  446. const initialState = await readState(connection);
  447. validateDatabaseMappings(initialState);
  448. if (mode === "audit") {
  449. const audit = await auditDatabase(connection);
  450. const applyReportPath = path.join(taskDir, "apply_report_2026_fall.json");
  451. assert(fs.existsSync(applyReportPath), `找不到执行报告:${applyReportPath}`);
  452. const applyReport = JSON.parse(fs.readFileSync(applyReportPath, "utf8"));
  453. assert(fs.existsSync(applyReport.backupPath), `找不到更新前备份:${applyReport.backupPath}`);
  454. const backup = JSON.parse(fs.readFileSync(applyReport.backupPath, "utf8"));
  455. const chineseIdPreservation = await auditChineseIdPreservation(connection, backup.hanziRows);
  456. const report = {
  457. generatedAt: new Date().toISOString(),
  458. mode,
  459. dictionaryCheck,
  460. chineseIdPreservation,
  461. audit,
  462. };
  463. const reportPath = path.join(taskDir, "audit_report_2026_fall.json");
  464. writeJson(reportPath, report);
  465. console.log(`AUDIT PASS: ${reportPath}`);
  466. return;
  467. }
  468. let plan = buildPlan(initialState, dictionaryCheck);
  469. if (mode === "dry-run") {
  470. const reportPath = path.join(taskDir, "dry_run_report_2026_fall.json");
  471. writeJson(reportPath, plan);
  472. console.log(`DRY RUN PASS: ${reportPath}`);
  473. console.log(
  474. JSON.stringify(
  475. {
  476. chinese: plan.chinese.map((item) => ({
  477. label: item.label,
  478. rows: `${item.currentRows}->${item.targetRows}`,
  479. unitNum: `${item.currentUnitNum}->${item.targetUnitNum}`,
  480. wordNum: `${item.currentWordNum}->${item.targetWordNum}`,
  481. sameNameUpdates: item.sameNameUpdates,
  482. repurposedUpdates: item.repurposedUpdates,
  483. inserts: item.inserts,
  484. deletes: item.deletes,
  485. })),
  486. english: plan.english,
  487. dictionaryCheck,
  488. },
  489. null,
  490. 2,
  491. ),
  492. );
  493. return;
  494. }
  495. await connection.beginTransaction();
  496. try {
  497. const lockedState = await readState(connection, true);
  498. validateDatabaseMappings(lockedState);
  499. plan = buildPlan(lockedState, dictionaryCheck);
  500. const backupDir = path.join(taskDir, "backups");
  501. fs.mkdirSync(backupDir, { recursive: true });
  502. const backupPath = path.join(backupDir, `before_2026_fall_${nowForFile()}.json`);
  503. writeJson(backupPath, {
  504. createdAt: new Date().toISOString(),
  505. note: "所有目标表写入前、事务锁定后的完整备份",
  506. targetMiaoguoBookIds,
  507. targetHanziBookIds,
  508. targetEnglishBookIds,
  509. ...lockedState,
  510. });
  511. await applyChinese(connection, plan);
  512. await applyEnglish(connection, plan);
  513. const transactionAudit = await auditDatabase(connection);
  514. await connection.commit();
  515. const committedAudit = await auditDatabase(connection);
  516. const chineseIdPreservation = await auditChineseIdPreservation(
  517. connection,
  518. lockedState.hanziRows,
  519. );
  520. const report = {
  521. generatedAt: new Date().toISOString(),
  522. mode,
  523. backupPath,
  524. plan,
  525. transactionAudit,
  526. committedAudit,
  527. chineseIdPreservation,
  528. };
  529. const reportPath = path.join(taskDir, "apply_report_2026_fall.json");
  530. writeJson(reportPath, report);
  531. console.log(`APPLY AND AUDIT PASS: ${reportPath}`);
  532. console.log(`BACKUP: ${backupPath}`);
  533. } catch (error) {
  534. await connection.rollback();
  535. throw error;
  536. }
  537. } finally {
  538. await connection.end();
  539. }
  540. }
  541. main().catch((error) => {
  542. console.error(error);
  543. process.exitCode = 1;
  544. });