/** * 旧库数据体检。 * * 目的:在动任何数据库之前,确认旧数据能否无损映射到新模型。 * 特别检查那些会直接导致迁移失败或用户丢课的约束冲突。 * * 用法:pnpm legacy:check [--dump ] */ import { asString } from './load'; import { COURSE_CODE_RE } from './columns'; import { ensureEnvLoaded, loadLegacyFromArgs } from './source'; import { OPENID_RE } from './transform'; ensureEnvLoaded(); type Issue = { level: 'BLOCKER' | 'WARN'; message: string }; function count(items: T[]): Map { const map = new Map(); 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(items: T[]): Map { return new Map([...count(items)].filter(([, v]) => v.length > 1)); } async function main(): Promise { const { rows: db, label } = await loadLegacyFromArgs(process.argv); console.log(`旧库体检\n数据源: ${label}\n`); 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); const validOpenids = openids.filter((v) => OPENID_RE.test(v)); const invalidOpenids = openids.length - validOpenids.length; line(` 用户数 ${users.length}`); line(` 合法 openid 形态 ${validOpenids.length}`); line(` 异常值(迁移时过滤) ${invalidOpenids}`); 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 (invalidOpenids > 0) { issues.push({ level: 'WARN', message: `${invalidOpenids} 条记录不是合法 openid(旧接口无校验,多为平台安全扫描留下的探针),迁移时会自动过滤,不影响真实用户`, }); } 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().catch((err: unknown) => { console.error(`\n❌ 体检中止:${err instanceof Error ? err.message : String(err)}`); process.exitCode = 1; });