163 lines
7.1 KiB
TypeScript
163 lines
7.1 KiB
TypeScript
/**
|
||
* 旧库 → 新库 数据迁移。
|
||
*
|
||
* 数据源二选一:
|
||
* 默认 读取 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 = [
|
||
'paymentNotifyLog',
|
||
'order',
|
||
'entitlement',
|
||
'lesson',
|
||
'homeCourse',
|
||
'freeLesson',
|
||
'homeFreeCourse',
|
||
'banner',
|
||
'article',
|
||
'courseTip',
|
||
'setting',
|
||
'product',
|
||
'course',
|
||
'user',
|
||
] as const;
|
||
|
||
async function main(): Promise<void> {
|
||
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── 写入新库 ────────────────────────────');
|
||
await prisma.$transaction(
|
||
async (tx) => {
|
||
type Delegates = Record<string, { deleteMany: () => Promise<unknown> }>;
|
||
const delegates = tx as unknown as Delegates;
|
||
for (const model of CLEAR_ORDER) await delegates[model]!.deleteMany();
|
||
|
||
await tx.course.createMany({ data: ds.courses });
|
||
await tx.user.createMany({ data: ds.users });
|
||
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<number>]> = [
|
||
['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());
|