Files
YelayMaths/yelaymaths_api/tools/legacy/transform.ts
T
2026-09-30 03:38:10 +08:00

253 lines
9.7 KiB
TypeScript

/**
* 旧行 → 新行 的纯转换函数。
*
* 本文件不碰数据库、不做 IO,所有输入输出都是普通对象,便于用测试覆盖。
* 数据库读写与顺序编排在 migrate.ts。
*/
import type { SqlValue } from './parser';
export const SOURCE_ORDER = 1;
export const SOURCE_LEGACY = 2;
export const SOURCE_MANUAL = 3;
/**
* 合法 openid 的形态:字母、数字、下划线、中划线,20-64 位。
*
* 为什么要校验:旧版接口把前端传来的 openid 直接拼进 SQL 插入,对取值形态毫无约束。
* 实测线上库 284 行里有 95 行是自动化安全扫描留下的探针记录(SQL 注入、Log4Shell JNDI、
* 时间盲注),集中在 3 天内批量出现,并非真实用户。真实用户是 189 行。
*/
export const OPENID_RE = /^[A-Za-z0-9_-]{20,64}$/;
/** 旧库时间的时区。旧数据由服务器本地 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,
// 旧表「是否登录」:'1' 表示用户主动使用过登录功能。新版静默登录不会改这一位,
// 所以迁移时必须原样搬过来,否则老用户会被当成没登录过。
is_login: str(row.是否登录) === '1' ? 1 : 0,
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.注释) };
}