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

157 lines
6.5 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.
/**
* 旧库 → 新库 数据迁移。
*
* 数据源二选一:
* 默认 读取 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 { loadLegacyDump, type LegacyRows } from './load';
import { loadLegacyDb } from './load-db';
import { SOURCE_LEGACY } from './transform';
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 fromDb = flag('from-db');
const force = flag('force');
const staticBase = process.env.STATIC_BASE_URL ?? 'https://api.yeomai.site';
console.log('旧库 → 新库 数据迁移');
console.log(`数据源 : ${fromDb ? '直连旧库' : 'dump 文件'}`);
console.log(`域名改写 : api.yeomai.site → ${staticBase}`);
console.log(`模式 : ${dryRun ? '试运行(不写库)' : '正式写入'}\n`);
const db: LegacyRows = fromDb
? await loadLegacyDb(process.env.LEGACY_DATABASE_URL ?? '')
: loadLegacyDump();
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.duplicateEntitlements.length}`);
console.log(` 孤儿课时已跳过 ${diagnostics.skippedLessonOrphans.length}`);
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());