130 lines
6.4 KiB
TypeScript
130 lines
6.4 KiB
TypeScript
/**
|
||
* 旧库数据体检。
|
||
*
|
||
* 目的:在动任何数据库之前,确认旧数据能否无损映射到新模型。
|
||
* 特别检查那些会直接导致迁移失败或用户丢课的约束冲突。
|
||
*
|
||
* 用法: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();
|
||
|