250 lines
9.4 KiB
TypeScript
250 lines
9.4 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,
|
|
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.注释) };
|
|
}
|