/** * 旧库 → 新库 数据迁移。 * * 数据源二选一: * 默认 读取 mysqldump 导出的 .sql(无需连旧库) * --from-db 直连旧库读取(需 LEGACY_DATABASE_URL) * * 目标库为 DATABASE_URL 指向的新库。 * * 幂等策略:先清空新库业务表再写入,失败可原样重跑。 * 安全阀:新库若已存在订单(说明已上线),必须显式加 --force 才会执行。 * * 用法: * pnpm legacy:migrate 从 dump 迁移 * pnpm legacy:migrate -- --from-db 从旧库直连迁移 * pnpm legacy:migrate -- --dry-run 只转换与校验,不写库 */ import { PrismaClient } from '@prisma/client'; import { buildDataset } from './build'; import { ensureEnvLoaded, loadLegacyFromArgs } from './source'; import { SOURCE_LEGACY } from './transform'; // 必须在创建 PrismaClient 之前加载,否则读不到 DATABASE_URL ensureEnvLoaded(); const prisma = new PrismaClient(); const flag = (name: string): boolean => process.argv.includes(`--${name}`); /** * 清空顺序:先子后父(有外键,顺序不能乱)。 * 同时给出真实表名,用于重置自增。 */ const CLEAR_ORDER: Array<{ model: string; table: string }> = [ { model: 'paymentNotifyLog', table: 'payment_notify_log' }, { model: 'order', table: 'orders' }, { model: 'entitlement', table: 'entitlements' }, { model: 'lesson', table: 'lessons' }, { model: 'homeCourse', table: 'home_courses' }, { model: 'freeLesson', table: 'free_lessons' }, { model: 'homeFreeCourse', table: 'home_free_courses' }, { model: 'banner', table: 'banners' }, { model: 'article', table: 'articles' }, { model: 'courseTip', table: 'course_tips' }, { model: 'setting', table: 'settings' }, { model: 'product', table: 'products' }, { model: 'course', table: 'courses' }, { model: 'user', table: 'users' }, ]; async function main(): Promise { const dryRun = flag('dry-run'); const force = flag('force'); const staticBase = process.env.STATIC_BASE_URL ?? 'https://api.yeomai.site'; console.log('旧库 → 新库 数据迁移'); const { rows: db, label: sourceLabel } = await loadLegacyFromArgs(process.argv); console.log(`数据源 : ${sourceLabel}`); console.log(`域名改写 : api.yeomai.site → ${staticBase}`); console.log(`模式 : ${dryRun ? '试运行(不写库)' : '正式写入'}\n`); const ds = buildDataset(db, staticBase); const { diagnostics } = ds; console.log('── 转换结果 ────────────────────────────'); const rows: Array<[string, number]> = [ ['users', ds.users.length], ['courses', ds.courses.length], ['lessons', ds.lessons.length], ['entitlements', ds.entitlements.length], ['home_courses', ds.homeCourses.length], ['free_lessons', ds.freeLessons.length], ['home_free_courses', ds.homeFreeCourses.length], ['banners', ds.banners.length], ['articles', ds.articles.length], ['course_tips', ds.courseTips.length], ['settings', ds.settings.length], ]; for (const [name, n] of rows) console.log(` ${name.padEnd(18)} ${String(n).padStart(5)}`); const mapped = ds.homeCourses.filter((h) => h.course_id !== null).length; console.log(`\n 首页长图已关联课程 ${mapped}/${ds.homeCourses.length}`); console.log(` 非真实用户已过滤 ${diagnostics.skippedUsers.length}`); console.log(` 孤儿权益已跳过 ${diagnostics.droppedEntitlements.length}`); console.log(` 重复权益已跳过 ${diagnostics.duplicateEntitlements.length}`); console.log(` 孤儿课时已跳过 ${diagnostics.skippedLessonOrphans.length}`); if (diagnostics.skippedUsers.length > 0) { console.log( `\n ℹ 过滤说明:旧接口对 openid 无校验,用户表里混入了 ${diagnostics.skippedUsers.length} 条安全扫描探针记录`, ); console.log(' (微信平台上架时的安全验证与公网扫描器都会产生这类记录),不是真实用户,因此不迁移。'); console.log(' 已确认所有购买记录都挂在真实用户上,不会丢课。'); } if (diagnostics.unmappedHomeCourses.length > 0) { console.log(` ⚠ 未关联课程的首页长图: ${diagnostics.unmappedHomeCourses.join('、')}`); } if (dryRun) { console.log('\n试运行结束,未写入数据库。'); return; } // ── 安全阀 ───────────────────────────────────────────────────────────── const existingOrders = await prisma.order.count(); if (existingOrders > 0 && !force) { throw new Error( `新库已存在 ${existingOrders} 条订单,疑似已上线;迁移会清空业务表。确认覆盖请加 --force。`, ); } // ── 写库 ─────────────────────────────────────────────────────────────── console.log('\n── 写入新库 ────────────────────────────'); type Delegates = Record Promise }>; const delegates = prisma as unknown as Delegates; // 清空与重置自增必须放在事务外:MySQL 的 ALTER TABLE 会隐式提交事务。 for (const { model } of CLEAR_ORDER) await delegates[model]!.deleteMany(); // 关键:deleteMany 不会重置自增计数器。 // 上一次失败的迁移虽然回滚了数据,但计数器已经前进, // 导致再次插入时 id 不是从 1 开始,与 buildDataset 里按顺序推导的映射对不上, // 进而触发外键约束失败。 for (const { table } of CLEAR_ORDER) { await prisma.$executeRawUnsafe(`ALTER TABLE \`${table}\` AUTO_INCREMENT = 1`); } await prisma.$transaction( async (tx) => { await tx.course.createMany({ data: ds.courses }); await tx.user.createMany({ data: ds.users }); // 校验实际自增 id 与推导出的映射一致。 // 若不一致,lessons / entitlements 会引用到错误的课程——这类错误外键查不出来, // 但会让用户点开 A 课程看到 B 课程的内容,所以必须显式拦住。 const createdCourses = await tx.course.findMany({ select: { id: true, code: true } }); const idByCode = new Map(createdCourses.map((c) => [c.code, c.id])); ds.courses.forEach((course, index) => { const actual = idByCode.get(course.code); if (actual !== index + 1) { throw new Error( `课程 id 与预期不符:${course.title} 期望 id=${index + 1},实际 id=${actual ?? '缺失'}。` + '为避免课程数据错位,迁移已中止。请清空新库后重跑。', ); } }); await tx.lesson.createMany({ data: ds.lessons }); await tx.entitlement.createMany({ data: ds.entitlements }); await tx.homeCourse.createMany({ data: ds.homeCourses }); await tx.freeLesson.createMany({ data: ds.freeLessons }); await tx.homeFreeCourse.createMany({ data: ds.homeFreeCourses }); await tx.banner.createMany({ data: ds.banners }); await tx.article.createMany({ data: ds.articles }); await tx.courseTip.createMany({ data: ds.courseTips }); await tx.setting.createMany({ data: ds.settings }); }, { timeout: 180_000 }, ); // ── 写后校验 ─────────────────────────────────────────────────────────── console.log('\n── 写后校验 ────────────────────────────'); const checks: Array<[string, number, Promise]> = [ ['users', ds.users.length, prisma.user.count()], ['courses', ds.courses.length, prisma.course.count()], ['lessons', ds.lessons.length, prisma.lesson.count()], ['entitlements', ds.entitlements.length, prisma.entitlement.count()], ['home_courses', ds.homeCourses.length, prisma.homeCourse.count()], ['free_lessons', ds.freeLessons.length, prisma.freeLesson.count()], ['banners', ds.banners.length, prisma.banner.count()], ['articles', ds.articles.length, prisma.article.count()], ['settings', ds.settings.length, prisma.setting.count()], ]; let ok = true; for (const [name, want, pending] of checks) { const got = await pending; if (want !== got) ok = false; console.log(` ${want === got ? '✓' : '✗'} ${name.padEnd(16)} 期望 ${want} 实际 ${got}`); } if (!ok) throw new Error('行数校验不通过,迁移结果不可信。'); console.log(`\n✅ 迁移完成。老数据权益标记为 source=${SOURCE_LEGACY}。`); } main() .catch((err: unknown) => { console.error(`\n❌ 迁移失败:${err instanceof Error ? err.message : String(err)}`); process.exitCode = 1; }) .finally(() => void prisma.$disconnect());