四端上传
This commit is contained in:
1 parent
25bbb5f9c8
commit
c6e2cb4d5b
276 files changed
+17812
-3
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# 旧库数据迁移
|
||||
|
||||
把旧系统(`api_yeomai_site`)的数据迁到新库,供新服务端使用。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 新库已建好,`DATABASE_URL` 指向它,且**已执行建表**(`pnpm prisma:migrate`)。
|
||||
2. 旧库 dump 文件位于仓库根目录 `数据库/api_yeomai_site_mysql.sql`
|
||||
(默认路径;可用环境变量 `LEGACY_DUMP` 覆盖)。
|
||||
3. `STATIC_BASE_URL` 已配置为新静态资源域名,用于改写内容字段里的旧域名。
|
||||
|
||||
## 三条命令
|
||||
|
||||
```bash
|
||||
# 1. 体检:确认旧数据能无损映射(不连数据库,纯读 dump)
|
||||
pnpm legacy:check
|
||||
|
||||
# 2. 试运行:完成全部转换,但不写库,用来核对行数
|
||||
pnpm legacy:migrate -- --dry-run
|
||||
|
||||
# 3. 正式迁移
|
||||
pnpm legacy:migrate
|
||||
```
|
||||
|
||||
如希望直连旧库读取而不是读 dump:
|
||||
|
||||
```bash
|
||||
pnpm legacy:migrate -- --from-db # 需配置 LEGACY_DATABASE_URL
|
||||
```
|
||||
|
||||
## 迁移做了什么
|
||||
|
||||
| 旧表 | 新表 | 说明 |
|
||||
|---|---|---|
|
||||
| 用户信息 | `users` | 284 行 |
|
||||
| 用户已购课程 | `entitlements` | 48 行,`source=2`(老数据),**不走支付直接标记为已拥有** |
|
||||
| 课程口令码读取信息 | `courses` | 10 行,`code` 原值保留 |
|
||||
| 视频课程数据 | `lessons` | 841 行,`章节id`/`播放id` 原值保留 |
|
||||
| 免费视频课程数据 | `free_lessons` | 12 行 |
|
||||
| 首页课程列表 | `home_courses` | 5 行,并自动关联到对应付费课程 |
|
||||
| 首页免费课程列表 | `home_free_courses` | 3 行 |
|
||||
| 首页横幅图片 | `banners` | 3 行 |
|
||||
| 网页列表 | `articles` | 2 行 |
|
||||
| 课程页面Tips | `course_tips` | 2 行 |
|
||||
| cloudconfig | `settings` | 13 行 → 12 行(ID 0 占位丢弃),数字 ID 改字符串 key |
|
||||
| 课程口令码 | — | **作废,不迁移** |
|
||||
| 各类 `xxx2` 备份表、`网页数据`、`验证码发送` | — | 废弃 |
|
||||
|
||||
## 必须知道的四件事
|
||||
|
||||
### 1. 内容字段里的旧域名会被改写
|
||||
|
||||
旧库的 `首页课程列表.内容`(44 处)和 `网页列表.网页内容`(4 处)里存的是**绝对 URL**,
|
||||
形如 `https://api.yeomai.site/Images/...`。旧域名退役后这些图片/视频会全部 404。
|
||||
|
||||
迁移时按 `STATIC_BASE_URL` 自动改写。**执行前务必确认这个变量填的是新静态域名。**
|
||||
|
||||
### 2. 时间按 +08:00 换算
|
||||
|
||||
旧库时间由服务器本地 `NOW()` 写入,是北京时间墙钟值。新库统一存 UTC,
|
||||
迁移时按 `+08:00` 换算(可用 `LEGACY_UTC_OFFSET` 覆盖)。
|
||||
|
||||
注意旧库中确实存在 `2024-02-01` 这种**只有日期没有时间**的值,已单独处理。
|
||||
|
||||
### 3. `courses.code` 绝对不能被改写
|
||||
|
||||
它同时是视频目录名(`Course/{code}/…/playlist.m3u8`)与封面名(`Course/{code}.png`)。
|
||||
迁移脚本对它逐字透传,测试中有断言保护。
|
||||
|
||||
### 4. 迁移会清空新库业务表
|
||||
|
||||
脚本采用「先清空再写入」实现幂等,失败可原样重跑。
|
||||
|
||||
**安全阀**:若新库已存在订单数据(说明已上线),脚本会拒绝执行,必须显式加 `--force`。
|
||||
|
||||
## 迁移后核对
|
||||
|
||||
脚本会自动逐表比对行数。此外建议人工抽查:
|
||||
|
||||
- 随机选几个老用户,确认其 `entitlements` 里的课程与升级前一致。
|
||||
- 打开首页,确认轮播、新课长图、试听入口图片都能加载(验证域名改写生效)。
|
||||
|
||||
## 测试
|
||||
|
||||
迁移的转换逻辑由 `transform.test.ts` 覆盖(18 项),跑的是**真实的旧库 dump**:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
其中包含行数比对、唯一性约束、域名改写、时间换算、首页长图与课程的关联完整性。
|
||||
改动迁移逻辑后请先跑测试。
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 把旧库数据编排成新库可直接写入的数据集。
|
||||
*
|
||||
* 逻辑集中在这里,migrate.ts(写库)与 transform.test.ts(校验)共用同一份实现,
|
||||
* 避免两边各写一遍导致漂移。
|
||||
*/
|
||||
|
||||
import { asString, type LegacyRows } from './load';
|
||||
import {
|
||||
HOME_COURSE_TITLE_MAP,
|
||||
transformArticle,
|
||||
transformBanner,
|
||||
transformCourse,
|
||||
transformCourseTip,
|
||||
transformEntitlement,
|
||||
transformFreeLesson,
|
||||
transformHomeCourse,
|
||||
transformHomeFreeCourse,
|
||||
transformLesson,
|
||||
transformSetting,
|
||||
transformUser,
|
||||
} from './transform';
|
||||
|
||||
export type Dataset = ReturnType<typeof buildDataset>;
|
||||
|
||||
export function buildDataset(db: LegacyRows, staticBase: string) {
|
||||
// 先建课程,得到 code → 自增 id 的映射(新库 id 从 1 开始,与插入顺序一致)
|
||||
const courses = db.课程口令码读取信息.map(transformCourse);
|
||||
const courseIdByCode = new Map(courses.map((c, i) => [c.code, i + 1]));
|
||||
const courseIdByTitle = new Map(courses.map((c, i) => [c.title, i + 1]));
|
||||
|
||||
const users = db.用户信息.map(transformUser);
|
||||
const userIdByOpenid = new Map(users.map((u, i) => [u.openid, i + 1]));
|
||||
|
||||
const skippedLessonOrphans: string[] = [];
|
||||
const lessons = db.视频课程数据.flatMap((row) => {
|
||||
const courseId = courseIdByCode.get(asString(row.课程id));
|
||||
if (!courseId) {
|
||||
skippedLessonOrphans.push(asString(row.课程id));
|
||||
return [];
|
||||
}
|
||||
return [transformLesson(row, courseId)];
|
||||
});
|
||||
|
||||
// 权益:按 (user, course) 去重,重复的只保留第一条
|
||||
const seen = new Set<string>();
|
||||
const duplicateEntitlements: string[] = [];
|
||||
const entitlements = db.用户已购课程.flatMap((row) => {
|
||||
const userId = userIdByOpenid.get(asString(row.OpenID));
|
||||
const courseId = courseIdByCode.get(asString(row.课程id));
|
||||
if (!userId || !courseId) return [];
|
||||
const key = `${userId}::${courseId}`;
|
||||
if (seen.has(key)) {
|
||||
duplicateEntitlements.push(key);
|
||||
return [];
|
||||
}
|
||||
seen.add(key);
|
||||
return [transformEntitlement(row, userId, courseId)];
|
||||
});
|
||||
|
||||
const unmappedHomeCourses: string[] = [];
|
||||
const homeCourses = db.首页课程列表.map((row) => {
|
||||
const courseName = asString(row.课程名称);
|
||||
const title = HOME_COURSE_TITLE_MAP[courseName];
|
||||
const courseId = title ? courseIdByTitle.get(title) ?? null : null;
|
||||
if (courseId === null) unmappedHomeCourses.push(courseName);
|
||||
return transformHomeCourse(row, courseId, staticBase);
|
||||
});
|
||||
|
||||
return {
|
||||
courses,
|
||||
users,
|
||||
lessons,
|
||||
entitlements,
|
||||
homeCourses,
|
||||
freeLessons: db.免费视频课程数据.map(transformFreeLesson),
|
||||
homeFreeCourses: db.首页免费课程列表.map(transformHomeFreeCourse),
|
||||
banners: db.首页横幅图片.map(transformBanner),
|
||||
articles: db.网页列表.map((row) => transformArticle(row, staticBase)),
|
||||
courseTips: db.课程页面Tips.map(transformCourseTip),
|
||||
settings: db.cloudconfig
|
||||
.map(transformSetting)
|
||||
.filter((v): v is NonNullable<typeof v> => v !== null),
|
||||
diagnostics: { skippedLessonOrphans, duplicateEntitlements, unmappedHomeCourses },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 旧库数据体检。
|
||||
*
|
||||
* 目的:在动任何数据库之前,确认旧数据能否无损映射到新模型。
|
||||
* 特别检查那些会直接导致迁移失败或用户丢课的约束冲突。
|
||||
*
|
||||
* 用法:pnpm legacy:check [--dump <path>]
|
||||
*/
|
||||
|
||||
import { asString, loadLegacyDump, DEFAULT_DUMP_PATH } from './load';
|
||||
import { COURSE_CODE_RE } from './columns';
|
||||
|
||||
type Issue = { level: 'BLOCKER' | 'WARN'; message: string };
|
||||
|
||||
function count<T>(items: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
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<T>(items: T[]): Map<string, T[]> {
|
||||
return new Map([...count(items)].filter(([, v]) => v.length > 1));
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const dumpPath = process.argv.includes('--dump')
|
||||
? process.argv[process.argv.indexOf('--dump') + 1]!
|
||||
: DEFAULT_DUMP_PATH;
|
||||
|
||||
console.log(`旧库体检\n数据源: ${dumpPath}\n`);
|
||||
const db = loadLegacyDump(dumpPath);
|
||||
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);
|
||||
line(` 用户数 ${users.length}`);
|
||||
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 (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();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 旧库表结构(来自 mysqldump 的 CREATE TABLE 顺序)。
|
||||
* 迁移与体检脚本共用,避免两处各写一份列顺序。
|
||||
*/
|
||||
|
||||
export const LEGACY_COLUMNS = {
|
||||
用户信息: [
|
||||
'OpenID',
|
||||
'YelayMaths_ID',
|
||||
'用户名',
|
||||
'是否登录',
|
||||
'是否管理员',
|
||||
'注册日期',
|
||||
'最后登录日期',
|
||||
'手机号',
|
||||
'HeadiconID',
|
||||
],
|
||||
用户已购课程: ['OpenID', '课程id', '课程', '课程介绍', '购课日期', '最后观看日期', '观看时长'],
|
||||
课程口令码读取信息: ['课程id', '课程名称', '课程介绍'],
|
||||
视频课程数据: [
|
||||
'课程id',
|
||||
'播放id',
|
||||
'章节id',
|
||||
'视频id',
|
||||
'课程名称',
|
||||
'章节名称',
|
||||
'视频名称',
|
||||
'发布日期',
|
||||
'时长',
|
||||
'视频m3u8',
|
||||
'封面图片',
|
||||
],
|
||||
免费视频课程数据: [
|
||||
'排序',
|
||||
'序号',
|
||||
'课程名称',
|
||||
'视频标题',
|
||||
'详细介绍',
|
||||
'发布人',
|
||||
'发布日期',
|
||||
'时长',
|
||||
'视频m3u8',
|
||||
'封面图片',
|
||||
],
|
||||
首页课程列表: ['课程名称', '课程介绍', '课程日期', '课程封面图片', '内容'],
|
||||
首页免费课程列表: ['序号', '课程名称', '课程介绍', '课程日期', '课程封面图片'],
|
||||
首页横幅图片: ['序号', '图片名'],
|
||||
网页列表: ['网页id', '网页标题', '发布日期', '发布人', '详细介绍', '网页内容'],
|
||||
课程页面Tips: ['id', 'content', 'title'],
|
||||
cloudconfig: ['ID', 'value', '注释'],
|
||||
课程口令码: ['课程id', '课程', '口令码', '口令起始日期', '口令结束日期', '课程介绍', '剩余有效次数'],
|
||||
} as const;
|
||||
|
||||
export type LegacyTable = keyof typeof LEGACY_COLUMNS;
|
||||
|
||||
/** 含绝对 URL 的内容字段,域名退役时必须批量改写。 */
|
||||
export const DOMAIN_BEARING_TABLES: LegacyTable[] = ['首页课程列表', '网页列表'];
|
||||
|
||||
/** 新库中 entitlements.course_id 的来源课程标识字段。 */
|
||||
export const COURSE_CODE_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 从旧库直连读取数据,返回与 loadLegacyDump 一致的结构。
|
||||
* 仅 migrate.ts 的 --from-db 模式使用。
|
||||
*/
|
||||
|
||||
import mysql from 'mysql2/promise';
|
||||
import { LEGACY_COLUMNS, type LegacyTable } from './columns';
|
||||
import type { LegacyRows } from './load';
|
||||
import type { SqlValue } from './parser';
|
||||
|
||||
export async function loadLegacyDb(url: string): Promise<LegacyRows> {
|
||||
if (!url) throw new Error('缺少 LEGACY_DATABASE_URL');
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
const result = {} as LegacyRows;
|
||||
for (const table of Object.keys(LEGACY_COLUMNS) as LegacyTable[]) {
|
||||
const [rows] = await conn.query(`SELECT * FROM \`${table}\``);
|
||||
result[table] = (rows as Record<string, unknown>[]).map((row) => {
|
||||
const obj: Record<string, SqlValue> = {};
|
||||
for (const col of LEGACY_COLUMNS[table]) {
|
||||
const v = row[col];
|
||||
obj[col] = v === null || v === undefined ? null : (v as SqlValue);
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { LEGACY_COLUMNS, type LegacyTable } from './columns';
|
||||
import { readTable, toObjects, type SqlValue } from './parser';
|
||||
|
||||
/**
|
||||
* 旧库 dump 的默认位置。
|
||||
* 兼顾两种运行方式:`pnpm legacy:check`(cwd 为包根)与 vitest(cwd 为包根)。
|
||||
* 可用 LEGACY_DUMP 环境变量覆盖。
|
||||
*/
|
||||
function resolveDefaultDumpPath(): string {
|
||||
const candidates = [
|
||||
path.resolve(process.cwd(), '../数据库/api_yeomai_site_mysql.sql'),
|
||||
path.resolve(process.cwd(), '数据库/api_yeomai_site_mysql.sql'),
|
||||
];
|
||||
return candidates.find((p) => existsSync(p)) ?? candidates[0]!;
|
||||
}
|
||||
|
||||
export const DEFAULT_DUMP_PATH = process.env.LEGACY_DUMP ?? resolveDefaultDumpPath();
|
||||
|
||||
export type LegacyRows = Record<LegacyTable, Record<string, SqlValue>[]>;
|
||||
|
||||
export function loadLegacyDump(dumpPath: string = process.env.LEGACY_DUMP ?? DEFAULT_DUMP_PATH): LegacyRows {
|
||||
const sql = readFileSync(dumpPath, 'utf8');
|
||||
const result = {} as LegacyRows;
|
||||
for (const table of Object.keys(LEGACY_COLUMNS) as LegacyTable[]) {
|
||||
result[table] = toObjects(readTable(sql, table), [...LEGACY_COLUMNS[table]]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function asString(v: SqlValue): string {
|
||||
return v === null ? '' : String(v);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 旧库 → 新库 数据迁移。
|
||||
*
|
||||
* 数据源二选一:
|
||||
* 默认 读取 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());
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 极简 mysqldump 解析器。
|
||||
*
|
||||
* 只做一件事:从 mysqldump 导出的 .sql 文本里取出 `INSERT INTO `表名` VALUES (...),(...)` 的行数据。
|
||||
* 不解析 DDL,不处理多语句逻辑。
|
||||
*/
|
||||
|
||||
export type SqlValue = string | number | null;
|
||||
|
||||
/** 解码 MySQL 字符串中的反斜杠转义。未识别的转义按 MySQL 语义保留反斜杠。 */
|
||||
function decodeEscape(e: string): string {
|
||||
switch (e) {
|
||||
case '0':
|
||||
return '\0';
|
||||
case 'b':
|
||||
return '\b';
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 'r':
|
||||
return '\r';
|
||||
case 't':
|
||||
return '\t';
|
||||
case 'Z':
|
||||
return '\x1a';
|
||||
case '\\':
|
||||
return '\\';
|
||||
case "'":
|
||||
return "'";
|
||||
case '"':
|
||||
return '"';
|
||||
default:
|
||||
return '\\' + e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `VALUES` 之后的载荷解析成二维数组。
|
||||
* 每个字段的三种形态:带引号的字符串 / NULL / 数字。
|
||||
*/
|
||||
export function parseTupleList(payload: string): SqlValue[][] {
|
||||
const rows: SqlValue[][] = [];
|
||||
const n = payload.length;
|
||||
let i = 0;
|
||||
|
||||
const skipSeparators = (): void => {
|
||||
while (i < n && (payload[i] === ',' || payload[i]! <= ' ')) i++;
|
||||
};
|
||||
|
||||
skipSeparators();
|
||||
while (i < n) {
|
||||
if (payload[i] !== '(') {
|
||||
throw new Error(`位置 ${i} 处期望 '(',实际是 ${JSON.stringify(payload[i])}`);
|
||||
}
|
||||
i++;
|
||||
|
||||
const row: SqlValue[] = [];
|
||||
let closed = false;
|
||||
while (!closed) {
|
||||
while (i < n && payload[i]! <= ' ') i++;
|
||||
|
||||
if (payload[i] === "'") {
|
||||
i++;
|
||||
let s = '';
|
||||
for (;;) {
|
||||
const c = payload[i];
|
||||
if (c === undefined) throw new Error('字符串未闭合');
|
||||
if (c === '\\') {
|
||||
s += decodeEscape(payload[i + 1] ?? '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
if (payload[i + 1] === "'") {
|
||||
s += "'";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
s += c;
|
||||
i++;
|
||||
}
|
||||
row.push(s);
|
||||
} else {
|
||||
let token = '';
|
||||
while (i < n && payload[i] !== ',' && payload[i] !== ')') {
|
||||
token += payload[i];
|
||||
i++;
|
||||
}
|
||||
token = token.trim();
|
||||
row.push(token === '' || token.toUpperCase() === 'NULL' ? null : Number(token));
|
||||
}
|
||||
|
||||
while (i < n && payload[i]! <= ' ' && payload[i] !== ',' && payload[i] !== ')') i++;
|
||||
const c = payload[i];
|
||||
if (c === ',') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ')') {
|
||||
i++;
|
||||
closed = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`位置 ${i} 处期望 ',' 或 ')',实际是 ${JSON.stringify(c)}`);
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
skipSeparators();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 取出某张表全部 INSERT 行。表不存在时返回空数组。 */
|
||||
export function readTable(sql: string, table: string): SqlValue[][] {
|
||||
const re = new RegExp('INSERT INTO `' + table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '` VALUES (.*?);\\n', 'gs');
|
||||
const rows: SqlValue[][] = [];
|
||||
for (const m of sql.matchAll(re)) {
|
||||
rows.push(...parseTupleList(m[1]!));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 转成对象数组,便于按字段名取值。 */
|
||||
export function toObjects(rows: SqlValue[][], columns: string[]): Record<string, SqlValue>[] {
|
||||
return rows.map((row) => {
|
||||
const obj: Record<string, SqlValue> = {};
|
||||
columns.forEach((col, idx) => {
|
||||
obj[col] = row[idx] ?? null;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDataset } from './build';
|
||||
import { DEFAULT_DUMP_PATH, loadLegacyDump } from './load';
|
||||
import {
|
||||
HOME_COURSE_TITLE_MAP,
|
||||
SETTINGS_KEY_MAP,
|
||||
SOURCE_LEGACY,
|
||||
parseLegacyDate,
|
||||
rewriteDomain,
|
||||
transformCourse,
|
||||
transformSetting,
|
||||
} from './transform';
|
||||
|
||||
describe('parseLegacyDate', () => {
|
||||
it('把旧库的本地墙钟时间按 +08:00 换算为 UTC', () => {
|
||||
// 2024-04-11 00:00:39(北京时间)= 2024-04-10 16:00:39 UTC
|
||||
expect(parseLegacyDate('2024-04-11 00:00:39')?.toISOString()).toBe('2024-04-10T16:00:39.000Z');
|
||||
});
|
||||
|
||||
it('空值与零日期返回 null', () => {
|
||||
expect(parseLegacyDate(null)).toBeNull();
|
||||
expect(parseLegacyDate('')).toBeNull();
|
||||
expect(parseLegacyDate('0000-00-00 00:00:00')).toBeNull();
|
||||
});
|
||||
|
||||
it('只有日期没有时间的旧值也能解析(旧库确实存在)', () => {
|
||||
expect(parseLegacyDate('2024-02-01')?.toISOString()).toBe('2024-01-31T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('无法解析的值直接抛错,而不是静默变成 Invalid Date', () => {
|
||||
expect(() => parseLegacyDate('不是时间')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformCourse', () => {
|
||||
it('清理标题首尾空白(旧数据存在带 \\r\\n 的课程名)', () => {
|
||||
const out = transformCourse({
|
||||
课程id: 'a'.repeat(64),
|
||||
课程名称: '7年级同步课程\r\n',
|
||||
课程介绍: '夯实数学基础有妙招!',
|
||||
});
|
||||
expect(out.title).toBe('7年级同步课程');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteDomain', () => {
|
||||
it('把旧域名换成新静态域名(http 与 https 都覆盖)', () => {
|
||||
const html = '<img src="https://api.yeomai.site/Images/a.png"/><img src="http://api.yeomai.site/b.png"/>';
|
||||
expect(rewriteDomain(html, 'https://data.example.com')).toBe(
|
||||
'<img src="https://data.example.com/Images/a.png"/><img src="https://data.example.com/b.png"/>',
|
||||
);
|
||||
});
|
||||
|
||||
it('新域名结尾的斜杠不会产生双斜杠', () => {
|
||||
expect(rewriteDomain('https://api.yeomai.site/x', 'https://data.example.com/')).toBe(
|
||||
'https://data.example.com/x',
|
||||
);
|
||||
});
|
||||
|
||||
it('null 原样返回', () => {
|
||||
expect(rewriteDomain(null, 'https://data.example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformSetting', () => {
|
||||
it('按映射表转换 key', () => {
|
||||
expect(transformSetting({ ID: 2, value: '付费课程', 注释: '标题' })).toEqual({
|
||||
key: 'home.title.paid',
|
||||
value: '付费课程',
|
||||
remark: '标题',
|
||||
});
|
||||
});
|
||||
|
||||
it('无意义占位(ID 0)被丢弃', () => {
|
||||
expect(transformSetting({ ID: 0, value: '占空', 注释: '占空' })).toBeNull();
|
||||
});
|
||||
|
||||
it('映射表覆盖 cloudconfig 的全部有效 ID', () => {
|
||||
expect(Object.keys(SETTINGS_KEY_MAP).map(Number).sort((a, b) => a - b)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 以下用真实旧库 dump 做端到端校验(迁移逻辑的落点检查)──────────────
|
||||
|
||||
describe('buildDataset(真实旧库数据)', () => {
|
||||
const ds = buildDataset(loadLegacyDump(DEFAULT_DUMP_PATH), 'https://data.example.com');
|
||||
|
||||
it('行数与旧库一致', () => {
|
||||
expect(ds.users).toHaveLength(284);
|
||||
expect(ds.courses).toHaveLength(10);
|
||||
expect(ds.lessons).toHaveLength(841);
|
||||
expect(ds.entitlements).toHaveLength(48);
|
||||
expect(ds.homeCourses).toHaveLength(5);
|
||||
expect(ds.freeLessons).toHaveLength(12);
|
||||
expect(ds.homeFreeCourses).toHaveLength(3);
|
||||
expect(ds.banners).toHaveLength(3);
|
||||
expect(ds.articles).toHaveLength(2);
|
||||
expect(ds.courseTips).toHaveLength(2);
|
||||
expect(ds.settings).toHaveLength(12); // cloudconfig 13 行,ID 0 占位被丢弃
|
||||
});
|
||||
|
||||
it('没有任何数据被静默丢弃', () => {
|
||||
expect(ds.diagnostics.skippedLessonOrphans).toEqual([]);
|
||||
expect(ds.diagnostics.duplicateEntitlements).toEqual([]);
|
||||
expect(ds.diagnostics.unmappedHomeCourses).toEqual([]);
|
||||
});
|
||||
|
||||
it('课程 code 是 64 位十六进制且无重复(视频路径依赖它)', () => {
|
||||
const codes = ds.courses.map((c) => c.code);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
for (const code of codes) expect(code).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('课程标题已清理首尾空白', () => {
|
||||
for (const c of ds.courses) expect(c.title).toBe(c.title.trim());
|
||||
expect(ds.courses.map((c) => c.title)).toContain('7年级同步课程');
|
||||
});
|
||||
|
||||
it('权益无重复(满足 entitlements UNIQUE(user_id, course_id))', () => {
|
||||
const keys = ds.entitlements.map((e) => `${e.user_id}::${e.course_id}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
for (const e of ds.entitlements) expect(e.source).toBe(SOURCE_LEGACY);
|
||||
});
|
||||
|
||||
it('首页 5 条长图全部关联到可购买课程', () => {
|
||||
expect(Object.keys(HOME_COURSE_TITLE_MAP)).toHaveLength(5);
|
||||
for (const h of ds.homeCourses) expect(h.course_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('内容字段中的旧域名已全部改写', () => {
|
||||
const all = [...ds.articles, ...ds.homeCourses].map((r) => r.content ?? '').join('');
|
||||
expect(all).not.toContain('api.yeomai.site');
|
||||
expect(all).toContain('https://data.example.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 旧行 → 新行 的纯转换函数。
|
||||
*
|
||||
* 本文件不碰数据库、不做 IO,所有输入输出都是普通对象,便于用测试覆盖。
|
||||
* 数据库读写与顺序编排在 migrate.ts。
|
||||
*/
|
||||
|
||||
import type { SqlValue } from './parser';
|
||||
|
||||
export const SOURCE_ORDER = 1;
|
||||
export const SOURCE_LEGACY = 2;
|
||||
export const SOURCE_MANUAL = 3;
|
||||
|
||||
/** 旧库时间的时区。旧数据由服务器本地 NOW() 写入,服务器在中国大陆。 */
|
||||
const LEGACY_UTC_OFFSET = process.env.LEGACY_UTC_OFFSET ?? '+08:00';
|
||||
|
||||
function str(v: SqlValue | undefined): string {
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
}
|
||||
|
||||
function strOrNull(v: SqlValue | undefined): string | null {
|
||||
const s = str(v).trim();
|
||||
return s === '' ? null : s;
|
||||
}
|
||||
|
||||
function int(v: SqlValue | undefined): number {
|
||||
const n = Number(str(v));
|
||||
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析旧库时间字符串。
|
||||
*
|
||||
* 旧值有两种形态,都是服务器本地墙钟时间:
|
||||
* `2024-04-11 00:00:39` 日期 + 时间
|
||||
* `2024-02-01` 仅日期(旧库中确实存在,之前未处理会解析失败)
|
||||
*
|
||||
* 需按 +08:00 换算为 UTC 存储。若值本身已带时区信息则直接采用。
|
||||
*/
|
||||
export function parseLegacyDate(v: SqlValue | undefined): Date | null {
|
||||
const raw = str(v).trim();
|
||||
if (raw === '' || raw.startsWith('0000-00-00')) return null;
|
||||
|
||||
let normalized = raw.replace(' ', 'T');
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) normalized += 'T00:00:00';
|
||||
|
||||
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(normalized);
|
||||
const date = new Date(hasZone ? normalized : `${normalized}${LEGACY_UTC_OFFSET}`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`无法解析时间:${JSON.stringify(raw)}`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/** 把内容字段里的旧域名替换为新静态资源域名。 */
|
||||
export function rewriteDomain(html: string | null, newBase: string): string | null {
|
||||
if (html === null) return null;
|
||||
return html.replace(/https?:\/\/api\.yeomai\.site/gi, newBase.replace(/\/+$/, ''));
|
||||
}
|
||||
|
||||
export type Row = Record<string, SqlValue>;
|
||||
|
||||
// ── users ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformUser(row: Row) {
|
||||
const registeredAt = parseLegacyDate(row.注册日期);
|
||||
return {
|
||||
openid: str(row.OpenID),
|
||||
nickname: strOrNull(row.用户名)?.slice(0, 32) ?? null,
|
||||
headicon_id: int(row.HeadiconID),
|
||||
status: 1,
|
||||
registered_at: registeredAt ?? new Date(0),
|
||||
last_login_at: parseLegacyDate(row.最后登录日期),
|
||||
};
|
||||
}
|
||||
|
||||
// ── courses ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformCourse(row: Row) {
|
||||
return {
|
||||
code: str(row.课程id),
|
||||
// 旧数据存在 '7年级同步课程\r\n' 这类首尾换行,必须清理
|
||||
title: str(row.课程名称).trim().slice(0, 128),
|
||||
intro: strOrNull(row.课程介绍)?.slice(0, 255) ?? null,
|
||||
cover_image: null,
|
||||
price_fen: 0,
|
||||
status: 1,
|
||||
sort_order: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── lessons ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformLesson(row: Row, courseId: number) {
|
||||
return {
|
||||
course_id: courseId,
|
||||
play_id: str(row.播放id),
|
||||
chapter_id: str(row.章节id),
|
||||
chapter_title: strOrNull(row.章节名称),
|
||||
lesson_title: strOrNull(row.视频名称),
|
||||
duration: strOrNull(row.时长),
|
||||
published_at: parseLegacyDate(row.发布日期),
|
||||
cover_image: strOrNull(row.封面图片),
|
||||
sort_order: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── entitlements ─────────────────────────────────────────────────────────
|
||||
|
||||
export function transformEntitlement(row: Row, userId: number, courseId: number) {
|
||||
const grantedAt = parseLegacyDate(row.购课日期);
|
||||
return {
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
source: SOURCE_LEGACY,
|
||||
ref_id: null,
|
||||
granted_at: grantedAt ?? new Date(0),
|
||||
expire_at: null,
|
||||
note: '旧版课程码兑换/历史购买记录',
|
||||
};
|
||||
}
|
||||
|
||||
// ── free_lessons ─────────────────────────────────────────────────────────
|
||||
|
||||
export function transformFreeLesson(row: Row) {
|
||||
return {
|
||||
series_no: int(row.序号),
|
||||
sort_order: int(row.排序),
|
||||
course_name: str(row.课程名称),
|
||||
video_title: strOrNull(row.视频标题),
|
||||
intro: strOrNull(row.详细介绍),
|
||||
publisher: strOrNull(row.发布人),
|
||||
published_at: parseLegacyDate(row.发布日期),
|
||||
duration: strOrNull(row.时长),
|
||||
video_m3u8: strOrNull(row.视频m3u8),
|
||||
cover_image: strOrNull(row.封面图片),
|
||||
};
|
||||
}
|
||||
|
||||
// ── home_courses ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 首页长图条目 → 可购买课程 的对应关系。
|
||||
*
|
||||
* 首页 5 条「新课上线」与 10 门付费课程的命名并不一致(七/7、封面/封面2 等后缀),
|
||||
* 无法用规则推导,这里显式列出,且用课程标题做键以便阅读。
|
||||
*/
|
||||
export const HOME_COURSE_TITLE_MAP: Record<string, string> = {
|
||||
几何模型封面: '几何模型',
|
||||
中考数学压轴大招封面: '中考数学压轴大招',
|
||||
七年级同步课程封面2: '7年级同步课程',
|
||||
八年级同步课程: '8年级同步课程',
|
||||
九年级同步课程: '9年级同步课程',
|
||||
};
|
||||
|
||||
export function transformHomeCourse(row: Row, courseId: number | null, staticBase: string) {
|
||||
return {
|
||||
course_name: str(row.课程名称),
|
||||
intro: strOrNull(row.课程介绍),
|
||||
published_at: parseLegacyDate(row.课程日期),
|
||||
cover_image: strOrNull(row.课程封面图片),
|
||||
content: rewriteDomain(strOrNull(row.内容), staticBase),
|
||||
course_id: courseId,
|
||||
sort_order: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── home_free_courses ────────────────────────────────────────────────────
|
||||
|
||||
export function transformHomeFreeCourse(row: Row) {
|
||||
return {
|
||||
series_no: int(row.序号),
|
||||
course_name: str(row.课程名称),
|
||||
intro: strOrNull(row.课程介绍),
|
||||
published_at: parseLegacyDate(row.课程日期),
|
||||
cover_image: strOrNull(row.课程封面图片),
|
||||
sort_order: int(row.序号),
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── banners ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformBanner(row: Row) {
|
||||
return {
|
||||
filename: str(row.图片名),
|
||||
sort_order: int(row.序号),
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── articles ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformArticle(row: Row, staticBase: string) {
|
||||
return {
|
||||
legacy_page_id: strOrNull(row.网页id),
|
||||
title: str(row.网页标题),
|
||||
intro: strOrNull(row.详细介绍),
|
||||
publisher: strOrNull(row.发布人),
|
||||
published_at: parseLegacyDate(row.发布日期),
|
||||
content: rewriteDomain(strOrNull(row.网页内容), staticBase),
|
||||
sort_order: int(row.网页id),
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── course_tips ──────────────────────────────────────────────────────────
|
||||
|
||||
export function transformCourseTip(row: Row) {
|
||||
return {
|
||||
tip_key: int(row.id),
|
||||
content: str(row.content),
|
||||
title: strOrNull(row.title),
|
||||
};
|
||||
}
|
||||
|
||||
// ── settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** cloudconfig 数字 ID → settings 字符串 key。ID 0「占空」为无意义占位,不迁移。 */
|
||||
export const SETTINGS_KEY_MAP: Record<number, string> = {
|
||||
1: 'site.version',
|
||||
2: 'home.title.paid',
|
||||
3: 'home.title.trial',
|
||||
4: 'home.title.public',
|
||||
5: 'profile.qrcode_image',
|
||||
6: 'profile.headicon_count',
|
||||
7: 'profile.menu.change_info',
|
||||
8: 'profile.menu.share',
|
||||
9: 'profile.menu.contact',
|
||||
10: 'profile.menu.wechat',
|
||||
11: 'home.footer_text',
|
||||
12: 'profile.qrcode_caption',
|
||||
};
|
||||
|
||||
export function transformSetting(row: Row): { key: string; value: string | null; remark: string | null } | null {
|
||||
const key = SETTINGS_KEY_MAP[int(row.ID)];
|
||||
if (!key) return null;
|
||||
return { key, value: strOrNull(row.value), remark: strOrNull(row.注释) };
|
||||
}
|
||||
Reference in new issue
Block a user