Files
YelayMaths/yelaymaths_api/tools/legacy/check.ts
T
2026-09-29 15:25:40 +08:00

130 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 旧库数据体检。
*
* 目的:在动任何数据库之前,确认旧数据能否无损映射到新模型。
* 特别检查那些会直接导致迁移失败或用户丢课的约束冲突。
*
* 用法:pnpm legacy:check [--dump <path>]
*/
import { asString, loadLegacyDump, DEFAULT_DUMP_PATH } from './load';
import { COURSE_CODE_RE } from './columns';
type Issue = { level: 'BLOCKER' | 'WARN'; message: string };
function count<T>(items: T[]): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const key = String(item);
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
return map;
}
function duplicates<T>(items: T[]): Map<string, T[]> {
return new Map([...count(items)].filter(([, v]) => v.length > 1));
}
function main(): void {
const dumpPath = process.argv.includes('--dump')
? process.argv[process.argv.indexOf('--dump') + 1]!
: DEFAULT_DUMP_PATH;
console.log(`旧库体检\n数据源: ${dumpPath}\n`);
const db = loadLegacyDump(dumpPath);
const issues: Issue[] = [];
const line = (s: string): void => console.log(s);
line('── 1. 行数 ─────────────────────────────');
for (const [table, rows] of Object.entries(db)) {
line(` ${table.padEnd(22)} ${String(rows.length).padStart(5)}`);
}
const users = db.用户信息;
const owned = db.用户已购课程;
const catalog = db.课程口令码读取信息;
const lessons = db.视频课程数据;
line('\n── 2. users ────────────────────────────');
const openids = users.map((r) => asString(r.OpenID));
const emptyOpenid = openids.filter((v) => v === '').length;
const dupOpenid = duplicates(openids);
line(` 用户数 ${users.length}`);
line(` 空 openid ${emptyOpenid}`);
line(` 重复 openid ${dupOpenid.size}`);
line(` 已设置昵称 ${users.filter((r) => asString(r.用户名) !== '').length}`);
line(` 管理员 ${users.filter((r) => asString(r.是否管理员) === '1').length}`);
if (emptyOpenid > 0) issues.push({ level: 'BLOCKER', message: `存在 ${emptyOpenid} 条空 openid,违反 users.openid NOT NULL` });
if (dupOpenid.size > 0) {
issues.push({ level: 'BLOCKER', message: `存在 ${dupOpenid.size} 个重复 openid,违反 users.openid UNIQUE` });
for (const [k, v] of dupOpenid) line(` ${k} × ${v.length}`);
}
line('\n── 3. courses(来源:课程口令码读取信息)');
const codes = catalog.map((r) => asString(r.课程id));
const badCodes = codes.filter((c) => !COURSE_CODE_RE.test(c));
const dupCodes = duplicates(codes);
line(` 课程数 ${catalog.length}`);
line(` 非 64 位十六进制 code ${badCodes.length}`);
line(` 重复 code ${dupCodes.size}`);
if (badCodes.length > 0) issues.push({ level: 'BLOCKER', message: `有 ${badCodes.length} 个课程 code 不符合 64 位 sha256 格式` });
if (dupCodes.size > 0) issues.push({ level: 'BLOCKER', message: `有 ${dupCodes.size} 个重复课程 code,违反 courses.code UNIQUE` });
line('\n── 4. entitlements(来源:用户已购课程)');
const pairs = owned.map((r) => `${asString(r.OpenID)}::${asString(r.课程id)}`);
const dupPairs = duplicates(pairs);
const catalogSet = new Set(codes);
const orphanOwned = owned.filter((r) => !catalogSet.has(asString(r.课程id)));
const unknownUser = owned.filter((r) => !openids.includes(asString(r.OpenID)));
line(` 购买记录数 ${owned.length}`);
line(` 重复 (openid,课程) ${dupPairs.size}`);
line(` 指向未知课程 ${orphanOwned.length}`);
line(` openid 不在用户表 ${unknownUser.length}`);
if (dupPairs.size > 0) {
issues.push({ level: 'BLOCKER', message: `有 ${dupPairs.size} 组重复权益,违反 entitlements UNIQUE(user_id, course_id)` });
for (const [k, v] of dupPairs) line(` ${k} × ${v.length}`);
}
if (orphanOwned.length > 0) issues.push({ level: 'BLOCKER', message: `有 ${orphanOwned.length} 条购买记录指向不存在的课程` });
if (unknownUser.length > 0) issues.push({ level: 'BLOCKER', message: `有 ${unknownUser.length} 条购买记录的 openid 不在用户表中` });
line('\n── 5. lessons(来源:视频课程数据)');
const lessonKeys = lessons.map((r) => `${asString(r.课程id)}::${asString(r.章节id)}::${asString(r.播放id)}`);
const dupLessons = duplicates(lessonKeys);
const orphanLessons = lessons.filter((r) => !catalogSet.has(asString(r.课程id)));
line(` 课时数 ${lessons.length}`);
line(` 重复 (课程,章节,播放) ${dupLessons.size}`);
line(` 指向未知课程 ${orphanLessons.length}`);
if (dupLessons.size > 0) {
issues.push({ level: 'BLOCKER', message: `有 ${dupLessons.size} 组重复课时,违反 lessons UNIQUE(course_id, chapter_id, play_id)` });
for (const [k, v] of dupLessons) line(` ${k} × ${v.length}`);
}
if (orphanLessons.length > 0) issues.push({ level: 'WARN', message: `有 ${orphanLessons.length} 条课时指向不存在的课程,迁移时会被跳过` });
line('\n── 6. 内容字段中的绝对 URL(域名退役后必须改写)');
for (const table of ['首页课程列表', '网页列表'] as const) {
const hits = db[table].filter((r) => JSON.stringify(r).includes('api.yeomai.site'));
line(` ${table.padEnd(14)} ${hits.length} 行含 api.yeomai.site`);
}
line('\n── 7. 不迁移的表 ───────────────────────');
line(` 课程口令码 ${db.课程口令码.length} 行(作废,不迁移)`);
line(` cloudconfig ${db.cloudconfig.length} 行(转 settings)`);
line('\n════════════════════════════════════════');
const blockers = issues.filter((i) => i.level === 'BLOCKER');
if (blockers.length === 0) {
line(`✅ 通过:${issues.length} 条提示,0 个阻断项。数据可安全迁移。`);
} else {
line(`❌ 发现 ${blockers.length} 个阻断项:`);
for (const i of blockers) line(` [${i.level}] ${i.message}`);
process.exitCode = 1;
}
const warns = issues.filter((i) => i.level === 'WARN');
for (const w of warns) line(` [${w.level}] ${w.message}`);
}
main();