Files
YelayMaths/yelaymaths_api/tools/legacy/migrate.ts
T
2026-09-29 19:34:24 +08:00

163 lines
7.1 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 { 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());