| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582 |
- #!/usr/bin/env node
- import assert from "node:assert/strict";
- import fs from "node:fs";
- import path from "node:path";
- import { fileURLToPath } from "node:url";
- import mysql from "mysql2/promise";
- import config from "../../src/config/dev.js";
- const taskDir = path.dirname(fileURLToPath(import.meta.url));
- const chineseSourcePath = path.join(taskDir, "source_chinese_2026_fall.json");
- const englishSourcePath = path.join(taskDir, "source_english_2026_fall.json");
- const chineseSources = JSON.parse(fs.readFileSync(chineseSourcePath, "utf8"));
- const englishSources = JSON.parse(fs.readFileSync(englishSourcePath, "utf8"));
- const flags = new Set(process.argv.slice(2));
- const knownFlags = new Set(["--dry-run", "--apply", "--audit"]);
- for (const flag of flags) {
- assert(knownFlags.has(flag), `不支持的参数:${flag}`);
- }
- assert(
- ["--dry-run", "--apply", "--audit"].filter((flag) => flags.has(flag)).length <= 1,
- "--dry-run、--apply、--audit 只能选择一个",
- );
- const mode = flags.has("--apply") ? "apply" : flags.has("--audit") ? "audit" : "dry-run";
- const targetMiaoguoBookIds = [...chineseSources, ...englishSources].map(
- (source) => source.miaoguoBookId,
- );
- const targetHanziBookIds = chineseSources.map((source) => source.bookIdOld);
- const targetEnglishBookIds = englishSources.map((source) => source.bookIdOld);
- function nowForFile() {
- return new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
- }
- function writeJson(filePath, data) {
- fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
- }
- function countChineseWords(source) {
- if (source.unitType === 2) {
- return source.units.reduce(
- (total, unit) => total + unit.example.split(",").filter(Boolean).length,
- 0,
- );
- }
- return source.units.reduce((total, unit) => total + Array.from(unit.example).length, 0);
- }
- function flattenEnglish(source) {
- return source.units.flatMap((unit) =>
- unit.entries.map((entry, index) => ({
- Word: entry.word,
- WordRemark: null,
- Level: null,
- BookID: source.bookIdOld,
- LessonID: unit.lessonId,
- LessonName: unit.lessonName,
- Soundmark: entry.soundmark ?? null,
- Translate: entry.translate,
- EnglishExplanation: null,
- ExampleSentence: null,
- Sort: index + 1,
- })),
- );
- }
- function validateSources() {
- assert.equal(chineseSources.length, 8, "本批次应有 8 个语文数据集");
- assert.equal(englishSources.length, 2, "本批次应有 2 个英语数据集");
- assert.equal(
- new Set(targetMiaoguoBookIds).size,
- targetMiaoguoBookIds.length,
- "MiaoguoBook ID 不能重复",
- );
- assert.equal(
- new Set([...targetHanziBookIds, ...targetEnglishBookIds]).size,
- targetHanziBookIds.length + targetEnglishBookIds.length,
- "BookIDOld 不能重复",
- );
- for (const source of chineseSources) {
- assert([1, 2, 5].includes(source.unitType), `${source.label} UnitType 不正确`);
- assert(source.units.length > 0, `${source.label} 没有单元`);
- for (const unit of source.units) {
- assert(unit.name?.length > 0, `${source.label} 存在空 Name`);
- assert(unit.example?.length > 0, `${source.label}/${unit.name} 存在空 Example`);
- }
- assert.equal(
- countChineseWords(source),
- source.wordNum,
- `${source.label} 由 Example 计算出的字/词数与 WordNum 不一致`,
- );
- if (source.unitType === 1) {
- assert.equal(
- source.officialWordNum + source.blueCharacterCount,
- source.wordNum,
- `${source.label} 黑字数 + 蓝色多音字数与 WordNum 不一致`,
- );
- }
- }
- for (const source of englishSources) {
- assert(source.units.length > 0, `${source.label} 没有单元`);
- assert.deepEqual(
- source.units.map((unit) => unit.lessonId),
- source.units.map((_, index) => index + 1),
- `${source.label} LessonID 必须从 1 连续递增`,
- );
- for (const unit of source.units) {
- assert.equal(unit.lessonName, `Unit ${unit.lessonId}`, `${source.label} LessonName 不正确`);
- for (const entry of unit.entries) {
- assert(entry.word?.length > 0, `${source.label}/${unit.lessonName} 存在空 Word`);
- assert(entry.translate?.length > 0, `${source.label}/${unit.lessonName} 存在空 Translate`);
- if (entry.soundmark != null) {
- assert(
- entry.soundmark.startsWith("[") && entry.soundmark.endsWith("]"),
- `${source.label}/${entry.word} 音标必须使用方括号`,
- );
- }
- }
- }
- assert.equal(
- flattenEnglish(source).length,
- source.wordNum,
- `${source.label} 条目数与 WordNum 不一致`,
- );
- }
- }
- async function readState(connection, forUpdate = false) {
- const lock = forUpdate ? " FOR UPDATE" : "";
- const [books] = await connection.query(
- `SELECT * FROM MiaoguoBook WHERE ID IN (?) ORDER BY ID${lock}`,
- [targetMiaoguoBookIds],
- );
- const [hanziRows] = await connection.query(
- `SELECT * FROM HanziUnit WHERE HanziBookID IN (?) ORDER BY HanziBookID,OrderID,ID${lock}`,
- [targetHanziBookIds],
- );
- const [englishRows] = await connection.query(
- `SELECT * FROM Words WHERE BookID IN (?) ORDER BY BookID,LessonID,Sort,ID${lock}`,
- [targetEnglishBookIds],
- );
- return { books, hanziRows, englishRows };
- }
- function validateDatabaseMappings(state) {
- assert.equal(state.books.length, targetMiaoguoBookIds.length, "目标 MiaoguoBook 记录不完整");
- for (const source of [...chineseSources, ...englishSources]) {
- const book = state.books.find((row) => row.ID === source.miaoguoBookId);
- assert(book, `MiaoguoBook.ID=${source.miaoguoBookId} 不存在`);
- assert.equal(
- book.BookIDOld,
- source.bookIdOld,
- `${source.label} 的 BookIDOld 与源数据映射不一致`,
- );
- }
- }
- async function validateHanziDictionary(connection) {
- const chars = [
- ...new Set(
- chineseSources
- .filter((source) => source.unitType === 1 || source.unitType === 5)
- .flatMap((source) => source.units.flatMap((unit) => Array.from(unit.example))),
- ),
- ];
- const [rows] = await connection.query("SELECT DISTINCT Name FROM HanziWord WHERE Name IN (?)", [
- chars,
- ]);
- const found = new Set(rows.map((row) => row.Name));
- const missing = chars.filter((char) => !found.has(char));
- assert.equal(missing.length, 0, `HanziWord 缺少 ${missing.length} 个字:${missing.join("")}`);
- return { checkedDistinctCharacters: chars.length, missing: [] };
- }
- function buildChinesePlan(source, state) {
- const existing = state.hanziRows.filter((row) => row.HanziBookID === source.bookIdOld);
- assert(
- existing.length <= source.units.length,
- `${source.label} 现有 ${existing.length} 行、新版只有 ${source.units.length} 行;语文禁止删除,不能自动执行`,
- );
- const matched = Array(source.units.length).fill(null);
- const usedIds = new Set();
- const buckets = new Map();
- for (const row of existing) {
- const rows = buckets.get(row.Name) ?? [];
- rows.push(row);
- buckets.set(row.Name, rows);
- }
- for (let index = 0; index < source.units.length; index += 1) {
- const candidates = buckets.get(source.units[index].name) ?? [];
- const row = candidates.find((candidate) => !usedIds.has(candidate.ID));
- if (row) {
- matched[index] = row;
- usedIds.add(row.ID);
- }
- }
- const unusedRows = existing.filter((row) => !usedIds.has(row.ID));
- let unusedIndex = 0;
- for (let index = 0; index < source.units.length; index += 1) {
- if (!matched[index] && unusedIndex < unusedRows.length) {
- matched[index] = unusedRows[unusedIndex];
- usedIds.add(unusedRows[unusedIndex].ID);
- unusedIndex += 1;
- }
- }
- const operations = source.units.map((unit, index) => {
- const old = matched[index];
- return {
- action: old ? (old.Name === unit.name ? "UPDATE_SAME_NAME" : "UPDATE_REPURPOSE") : "INSERT",
- id: old?.ID ?? null,
- oldName: old?.Name ?? null,
- oldExample: old?.Example ?? null,
- oldIsLocked: old?.IsLocked ?? null,
- name: unit.name,
- example: unit.example,
- unitType: source.unitType,
- orderId: index + 1,
- isLocked: old?.IsLocked ?? 1,
- };
- });
- return {
- label: source.label,
- miaoguoBookId: source.miaoguoBookId,
- bookIdOld: source.bookIdOld,
- currentRows: existing.length,
- targetRows: source.units.length,
- currentWordNum: state.books.find((book) => book.ID === source.miaoguoBookId).WordNum,
- targetWordNum: source.wordNum,
- currentUnitNum: state.books.find((book) => book.ID === source.miaoguoBookId).UnitNum,
- targetUnitNum: source.units.length,
- sameNameUpdates: operations.filter((item) => item.action === "UPDATE_SAME_NAME").length,
- repurposedUpdates: operations.filter((item) => item.action === "UPDATE_REPURPOSE").length,
- inserts: operations.filter((item) => item.action === "INSERT").length,
- deletes: 0,
- operations,
- };
- }
- function buildPlan(state, dictionaryCheck) {
- const chinese = chineseSources.map((source) => buildChinesePlan(source, state));
- const english = englishSources.map((source) => {
- const currentRows = state.englishRows.filter((row) => row.BookID === source.bookIdOld);
- const book = state.books.find((row) => row.ID === source.miaoguoBookId);
- return {
- label: source.label,
- miaoguoBookId: source.miaoguoBookId,
- bookIdOld: source.bookIdOld,
- currentRows: currentRows.length,
- deleteRows: currentRows.length,
- insertRows: flattenEnglish(source).length,
- currentUnitNum: book.UnitNum,
- targetUnitNum: source.units.length,
- currentWordNum: book.WordNum,
- targetWordNum: source.wordNum,
- perUnit: source.units.map((unit) => ({
- lessonId: unit.lessonId,
- lessonName: unit.lessonName,
- count: unit.entries.length,
- })),
- sortPolicy: "每单元从 1 连续递增,接口按 LessonID,Sort,ID 排序",
- };
- });
- return {
- generatedAt: new Date().toISOString(),
- mode,
- safetyRules: {
- chinese: "只执行 UPDATE/INSERT,永不 DELETE",
- english: "事务内按 BookID 删除旧数据并重建;事务前先落盘备份",
- metadata: "同步更新 MiaoguoBook.UnitNum 和 WordNum",
- transaction: "任一写入或逐条核验失败即 ROLLBACK",
- },
- dictionaryCheck,
- chinese,
- english,
- };
- }
- async function applyChinese(connection, plan) {
- for (const bookPlan of plan.chinese) {
- for (const operation of bookPlan.operations) {
- if (operation.action === "INSERT") {
- const [result] = await connection.execute(
- `INSERT INTO HanziUnit
- (HanziBookID,UnitType,Name,Example,IsLocked,OrderID)
- VALUES (?,?,?,?,?,?)`,
- [
- bookPlan.bookIdOld,
- operation.unitType,
- operation.name,
- operation.example,
- operation.isLocked,
- operation.orderId,
- ],
- );
- operation.id = result.insertId;
- } else {
- const [result] = await connection.execute(
- `UPDATE HanziUnit
- SET HanziBookID=?,UnitType=?,Name=?,Example=?,IsLocked=?,OrderID=?
- WHERE ID=?`,
- [
- bookPlan.bookIdOld,
- operation.unitType,
- operation.name,
- operation.example,
- operation.isLocked,
- operation.orderId,
- operation.id,
- ],
- );
- assert.equal(result.affectedRows, 1, `HanziUnit.ID=${operation.id} 更新失败`);
- }
- }
- const [result] = await connection.execute(
- "UPDATE MiaoguoBook SET UnitNum=?,WordNum=? WHERE ID=? AND BookIDOld=?",
- [bookPlan.targetUnitNum, bookPlan.targetWordNum, bookPlan.miaoguoBookId, bookPlan.bookIdOld],
- );
- assert.equal(result.affectedRows, 1, `${bookPlan.label} MiaoguoBook 更新失败`);
- }
- }
- async function applyEnglish(connection, plan) {
- for (const source of englishSources) {
- await connection.execute("DELETE FROM Words WHERE BookID=?", [source.bookIdOld]);
- for (const row of flattenEnglish(source)) {
- await connection.execute(
- `INSERT INTO Words
- (Word,WordRemark,Level,BookID,LessonID,LessonName,Soundmark,Translate,
- EnglishExplanation,ExampleSentence,Sort)
- VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
- [
- row.Word,
- row.WordRemark,
- row.Level,
- row.BookID,
- row.LessonID,
- row.LessonName,
- row.Soundmark,
- row.Translate,
- row.EnglishExplanation,
- row.ExampleSentence,
- row.Sort,
- ],
- );
- }
- const englishPlan = plan.english.find((item) => item.bookIdOld === source.bookIdOld);
- const [result] = await connection.execute(
- "UPDATE MiaoguoBook SET UnitNum=?,WordNum=? WHERE ID=? AND BookIDOld=?",
- [
- englishPlan.targetUnitNum,
- englishPlan.targetWordNum,
- englishPlan.miaoguoBookId,
- englishPlan.bookIdOld,
- ],
- );
- assert.equal(result.affectedRows, 1, `${source.label} MiaoguoBook 更新失败`);
- }
- }
- function assertEnglishRow(actual, expected, label, index) {
- for (const field of [
- "Word",
- "WordRemark",
- "Level",
- "BookID",
- "LessonID",
- "LessonName",
- "Soundmark",
- "Translate",
- "EnglishExplanation",
- "ExampleSentence",
- "Sort",
- ]) {
- assert.equal(actual[field], expected[field], `${label} 第 ${index + 1} 行 ${field} 不一致`);
- }
- }
- async function auditDatabase(connection) {
- const state = await readState(connection);
- validateDatabaseMappings(state);
- const booksById = new Map(state.books.map((book) => [book.ID, book]));
- const result = { passed: true, books: [], chinese: [], english: [] };
- for (const source of chineseSources) {
- const rows = state.hanziRows.filter((row) => row.HanziBookID === source.bookIdOld);
- assert.equal(rows.length, source.units.length, `${source.label} HanziUnit 行数不一致`);
- rows.forEach((row, index) => {
- assert.equal(row.HanziBookID, source.bookIdOld, `${source.label} 第 ${index + 1} 行 BookID 不一致`);
- assert.equal(row.UnitType, source.unitType, `${source.label} 第 ${index + 1} 行 UnitType 不一致`);
- assert.equal(row.Name, source.units[index].name, `${source.label} 第 ${index + 1} 行 Name 不一致`);
- assert.equal(
- row.Example,
- source.units[index].example,
- `${source.label} 第 ${index + 1} 行 Example 不一致`,
- );
- assert.equal(row.OrderID, index + 1, `${source.label} 第 ${index + 1} 行 OrderID 不一致`);
- });
- const book = booksById.get(source.miaoguoBookId);
- assert.equal(book.UnitNum, source.units.length, `${source.label} UnitNum 不一致`);
- assert.equal(book.WordNum, source.wordNum, `${source.label} WordNum 不一致`);
- result.chinese.push({
- label: source.label,
- miaoguoBookId: source.miaoguoBookId,
- bookIdOld: source.bookIdOld,
- rows: rows.length,
- unitNum: book.UnitNum,
- wordNum: book.WordNum,
- orderIdRange: rows.length ? [rows[0].OrderID, rows.at(-1).OrderID] : [],
- duplicateOrderIds: rows.length - new Set(rows.map((row) => row.OrderID)).size,
- });
- }
- for (const source of englishSources) {
- const actual = state.englishRows.filter((row) => row.BookID === source.bookIdOld);
- const expected = flattenEnglish(source);
- assert.equal(actual.length, expected.length, `${source.label} Words 行数不一致`);
- actual.forEach((row, index) => assertEnglishRow(row, expected[index], source.label, index));
- const book = booksById.get(source.miaoguoBookId);
- assert.equal(book.UnitNum, source.units.length, `${source.label} UnitNum 不一致`);
- assert.equal(book.WordNum, source.wordNum, `${source.label} WordNum 不一致`);
- result.english.push({
- label: source.label,
- miaoguoBookId: source.miaoguoBookId,
- bookIdOld: source.bookIdOld,
- rows: actual.length,
- unitNum: book.UnitNum,
- wordNum: book.WordNum,
- perUnit: source.units.map((unit) => ({
- lessonId: unit.lessonId,
- rows: actual.filter((row) => row.LessonID === unit.lessonId).length,
- })),
- });
- }
- result.books = [...chineseSources, ...englishSources].map((source) => {
- const book = booksById.get(source.miaoguoBookId);
- return {
- id: book.ID,
- bookName: book.BookName,
- bookIdOld: book.BookIDOld,
- unitNum: book.UnitNum,
- wordNum: book.WordNum,
- };
- });
- return result;
- }
- async function auditChineseIdPreservation(connection, backupHanziRows) {
- const oldIds = backupHanziRows.map((row) => row.ID);
- const [rows] = await connection.query("SELECT ID FROM HanziUnit WHERE ID IN (?)", [oldIds]);
- const existingIds = new Set(rows.map((row) => row.ID));
- const missingIds = oldIds.filter((id) => !existingIds.has(id));
- assert.equal(missingIds.length, 0, `有 ${missingIds.length} 个语文旧 ID 被删除:${missingIds.join(",")}`);
- return {
- oldRows: oldIds.length,
- oldIdsStillPresent: rows.length,
- missingIds,
- newRows: chineseSources.reduce((total, source) => total + source.units.length, 0) - oldIds.length,
- };
- }
- async function main() {
- validateSources();
- const connection = await mysql.createConnection(config.database);
- try {
- const dictionaryCheck = await validateHanziDictionary(connection);
- const initialState = await readState(connection);
- validateDatabaseMappings(initialState);
- if (mode === "audit") {
- const audit = await auditDatabase(connection);
- const applyReportPath = path.join(taskDir, "apply_report_2026_fall.json");
- assert(fs.existsSync(applyReportPath), `找不到执行报告:${applyReportPath}`);
- const applyReport = JSON.parse(fs.readFileSync(applyReportPath, "utf8"));
- assert(fs.existsSync(applyReport.backupPath), `找不到更新前备份:${applyReport.backupPath}`);
- const backup = JSON.parse(fs.readFileSync(applyReport.backupPath, "utf8"));
- const chineseIdPreservation = await auditChineseIdPreservation(connection, backup.hanziRows);
- const report = {
- generatedAt: new Date().toISOString(),
- mode,
- dictionaryCheck,
- chineseIdPreservation,
- audit,
- };
- const reportPath = path.join(taskDir, "audit_report_2026_fall.json");
- writeJson(reportPath, report);
- console.log(`AUDIT PASS: ${reportPath}`);
- return;
- }
- let plan = buildPlan(initialState, dictionaryCheck);
- if (mode === "dry-run") {
- const reportPath = path.join(taskDir, "dry_run_report_2026_fall.json");
- writeJson(reportPath, plan);
- console.log(`DRY RUN PASS: ${reportPath}`);
- console.log(
- JSON.stringify(
- {
- chinese: plan.chinese.map((item) => ({
- label: item.label,
- rows: `${item.currentRows}->${item.targetRows}`,
- unitNum: `${item.currentUnitNum}->${item.targetUnitNum}`,
- wordNum: `${item.currentWordNum}->${item.targetWordNum}`,
- sameNameUpdates: item.sameNameUpdates,
- repurposedUpdates: item.repurposedUpdates,
- inserts: item.inserts,
- deletes: item.deletes,
- })),
- english: plan.english,
- dictionaryCheck,
- },
- null,
- 2,
- ),
- );
- return;
- }
- await connection.beginTransaction();
- try {
- const lockedState = await readState(connection, true);
- validateDatabaseMappings(lockedState);
- plan = buildPlan(lockedState, dictionaryCheck);
- const backupDir = path.join(taskDir, "backups");
- fs.mkdirSync(backupDir, { recursive: true });
- const backupPath = path.join(backupDir, `before_2026_fall_${nowForFile()}.json`);
- writeJson(backupPath, {
- createdAt: new Date().toISOString(),
- note: "所有目标表写入前、事务锁定后的完整备份",
- targetMiaoguoBookIds,
- targetHanziBookIds,
- targetEnglishBookIds,
- ...lockedState,
- });
- await applyChinese(connection, plan);
- await applyEnglish(connection, plan);
- const transactionAudit = await auditDatabase(connection);
- await connection.commit();
- const committedAudit = await auditDatabase(connection);
- const chineseIdPreservation = await auditChineseIdPreservation(
- connection,
- lockedState.hanziRows,
- );
- const report = {
- generatedAt: new Date().toISOString(),
- mode,
- backupPath,
- plan,
- transactionAudit,
- committedAudit,
- chineseIdPreservation,
- };
- const reportPath = path.join(taskDir, "apply_report_2026_fall.json");
- writeJson(reportPath, report);
- console.log(`APPLY AND AUDIT PASS: ${reportPath}`);
- console.log(`BACKUP: ${backupPath}`);
- } catch (error) {
- await connection.rollback();
- throw error;
- }
- } finally {
- await connection.end();
- }
- }
- main().catch((error) => {
- console.error(error);
- process.exitCode = 1;
- });
|