/** * 旧行 → 新行 的纯转换函数。 * * 本文件不碰数据库、不做 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; // ── 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 = { 几何模型封面: '几何模型', 中考数学压轴大招封面: '中考数学压轴大招', 七年级同步课程封面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 = { 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.注释) }; }