Files
YelayMaths/docs/重构开发文档.md
2026-09-29 19:34:24 +08:00

38 KiB
Raw Permalink Blame History

「叶雷讲数学」小程序 重构开发文档

项 内容
版本 v1.0
日期 2026-09-29
状态 设计定稿,待开发
范围 小程序端 / 服务端 / 管理后台 / 数据端

1. 项目概述

1.1 背景

现有系统为「微信小程序 + 原生 PHP + MySQL 5.7 + 静态文件站点」的实现,可正常运行,但存在结构性问题:无鉴权体系、SQL 全字符串拼接、数据库无主键与约束、无正式管理后台、无支付能力、存在大量死代码与废弃表、服务端无版本控制。

本次重构的目标是:在保持小程序用户侧外观与使用体验不变的前提下,把技术实现整体替换为规范化工程,并补齐支付与管理后台能力。

1.2 目标

  1. 用户侧无感升级:页面表现一致、已有数据零丢失、已购课程权限延续。
  2. 技术实现规范化:分层、类型安全、边界校验、迁移管理、统一契约。
  3. 补齐支付能力,使课程销售可自助完成。
  4. 补齐管理后台,使运营脱离手写 SQL。

1.3 明确不做

  • 不做微服务、K8s、服务网格。
  • 不上 Redis / 消息队列(发货用「数据库状态机 + 定时查单」足够)。
  • 不上 GraphQL。
  • 不引入 Turborepo / Nx。
  • 不重写小程序为 Taro / uni-app。
  • 不补短信能力(触达改用微信订阅消息)。
  • 不做 DDD 分层、CQRS、事件溯源。

2. 决策记录(ADR)

编号 决策 理由 影响
ADR-001 放弃口令码,权益由订单驱动 支付上线后口令码仅剩线下分发用途,维护成本高于价值 课程口令码 表整体作废;已兑换用户按其 用户已购课程 记录标记为已拥有;课程口令码读取信息 升级为 courses
ADR-002 数据端保留 MySQL 5.7,不升级 用户明确要求 Prisma provider = mysql;见 4.1 约束清单
ADR-003 服务端与管理后台使用独立域名 用户要求 Nginx 分站点;后端仍为同一 Node 进程
ADR-004 小程序前端不重写,只重写 JS 层 保证「外观不变」 复用全部 .wxml / .wxss / static/
ADR-005 VideoDetailsPage 保持现有实现逻辑不变 该页为对外宣传页 内容继续硬编码在前端,不迁入数据库
ADR-006 AppID 沿用 wx3fbaf897825bf04f 换 AppID 等于重发小程序,用户与虚拟支付配置全部丢失 数据迁移无需做 openid 映射
ADR-007 技术栈换为 Node + NestJS + Prisma 用户选定 Node;管理后台与 API 可共享类型与校验 需自行维护 Node 进程守护与部署

3. 总体架构

3.1 架构图

微信小程序
   |  https  api.yeomai.site/v1/*
   |  Authorization: Bearer <JWT>
   v
+----------------------------------------------------+
|  NestJS 单进程  :3000                              |
|   + /v1/*            小程序端接口                  |
|   + /admin/v1/*      管理后台接口(独立 JWT)      |
|   + /pay/notify      虚拟支付发货推送(XML 应答)  |
|   + @nestjs/schedule 每 5 分钟兜底查单             |
+----------------------------------------------------+
        |                 |                  |
        v                 v                  v
   MySQL 5.7        对象存储 / CDN      微信虚拟支付
   (宝塔)        static.yeomai.site   下单 / 推送 / 查单

管理后台(Next.js :3001)
   admin.yeomai.site        -> 后台前端
   admin-api.yeomai.site    -> 反代到同一 Node 进程 /admin/v1/*

3.2 域名规划

域名 Nginx 指向 用途
api.yeomai.site 127.0.0.1:3000(/v1/*) 小程序接口
admin-api.yeomai.site 127.0.0.1:3000(/admin/v1/*) 管理后台接口
admin.yeomai.site 127.0.0.1:3001 管理后台前端
static.yeomai.site 对象存储 / CDN 图片与 HLS 视频

api.yeomai.site 沿用现有域名,小程序「request 合法域名」白名单无需改动。

3.3 小程序侧 MP 配置变更清单

项 是否需要改 说明
request 合法域名 沿用则不用改 仅 wx.request / uploadFile / downloadFile / connectSocket 受白名单约束
<image> / <video> 资源域名 不需要 组件引用的图片与音视频资源不受合法域名限制,故视频迁 CDN 无需改 MP 配置
虚拟支付发货推送 URL 需要 填在【虚拟支付 → 基本配置】,非域名白名单
小程序简称 iOS 支付需要 虚拟支付 → 基础配置
苹果 IAP 支付开关 iOS 支付需要 虚拟支付 → 基础配置

3.4 宝塔部署拓扑

软件依赖:MySQL 5.7(沿用)、Node 版本管理器(Node 22 LTS)、Nginx。

站点:api / admin-api / admin 三个站点,各配 Let's Encrypt 证书。

进程:api 与 admin 分别用宝塔「Node 项目」+ PM2 守护。

定时任务:兜底查单使用进程内 @nestjs/schedule,不额外依赖系统 cron。数据库备份使用宝塔计划任务 mysqldump。

⚠️ 阶段 0 必须先验证:宝塔的 Node 项目管理器对 pnpm workspace 支持有限。备选方案为「本地或 CI 构建后仅部署产物」,或将 apps/api 配置为独立启动目录。此项若不通,会阻塞整个部署流程。


4. 数据端设计

4.1 MySQL 5.7 约束清单

保留 5.7 意味着以下限制必须写进开发规范:

约束 影响 应对
无 CHECK 约束(语法可解析但不生效) 不能用数据库层做值域校验 校验下沉到应用层(zod);关键枚举值靠代码约束
无 utf8mb4_0900_ai_ci(8.0 才有) 排序规则需换 统一使用 utf8mb4_general_ci
无窗口函数 统计类 SQL 受限 统计数据在应用层聚合
单列索引键长限制 若行格式为 COMPACT/REDUNDANT,键上限 767 字节,utf8mb4 下 VARCHAR(255) 建唯一索引会失败 所有需建索引的字符字段长度控制在 VARCHAR(64) 以内
已 EOL(2023-10) 无安全补丁 接受风险;必须保证每日备份可用

⚠️ Prisma 与 5.7 的实际坑:prisma migrate dev 需要创建 shadow database,要求数据库账号具备 CREATE DATABASE 权限。宝塔创建的 MySQL 用户默认没有该权限。

解决方案(二选一):

  1. 为迁移专用账号授予 CREATE / DROP 权限(推荐,本机开发用)。
  2. 在本地生成迁移 SQL(prisma migrate diff),生产端仅执行 prisma migrate deploy。

4.2 表清单与映射:20 张 → 15 张

旧表 新表 处理
用户信息 (284 行 / 189 个真实用户) users 迁移,结构重写;非 openid 形态的 95 行攻击载荷会被过滤
用户已购课程 (48) entitlements 迁移为 source = legacy
课程口令码读取信息 (10) courses 升级为正式课程表
视频课程数据 (841) lessons 迁移
免费视频课程数据 free_lessons 迁移
首页课程列表 (5) home_courses 迁移
首页免费课程列表 (3) home_free_courses 迁移
首页横幅图片 (3) banners 迁移
网页列表 (2) articles 迁移
课程页面Tips (2) course_tips 迁移
cloudconfig (13) settings 迁移,数字 ID 改字符串 key
课程口令码 (5) — 废弃(口令码作废,见 9.4)
网页数据 — 废弃
课程页面Tips2 — 废弃
首页课程列表2 — 废弃
首页免费课程列表2 — 废弃
首页横幅图片2 — 废弃
网页列表2 — 废弃
免费视频课程数据2 — 废弃
验证码发送 — 废弃(不做短信)
— orders 新增
— payment_notify_log 新增
— products 新增
— admin_users 新增

4.3 通用规范

  • 表名、字段名一律英文 snake_case。
  • 主键统一 id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY。
  • 时间统一 DATETIME,存 UTC,展示层转 Asia/Shanghai。
  • 金额统一 INT UNSIGNED,单位分,全程不换算。
  • 状态字段用 TINYINT + 代码内枚举,不使用中文字符串。
  • 字符集 utf8mb4,排序规则 utf8mb4_general_ci。
  • 外键约束一律不建(由应用层保证),避免迁移与批量操作受限。

4.4 关键表结构

users

字段 类型 约束 说明
id BIGINT UNSIGNED PK
openid VARCHAR(64) NOT NULL UNIQUE 微信 openid,最长 28 字符
nickname VARCHAR(32) NULL 用户名,前端限制 8 字符
headicon_id INT UNSIGNED NOT NULL DEFAULT 0 头像序号
wx_session_key VARCHAR(255) NULL 加密存储,虚拟支付签名必需
session_key_updated_at DATETIME NULL 用于判断是否需重新登录刷新
status TINYINT NOT NULL DEFAULT 1 1 正常 / 0 封禁
registered_at DATETIME NOT NULL
last_login_at DATETIME NULL
created_at / updated_at DATETIME NOT NULL

wx_session_key 必须加密存储(AES-256-GCM,密钥来自环境变量)。明文存储等同于泄露用户态签名能力。

courses

字段 类型 约束 说明
id BIGINT UNSIGNED PK
code VARCHAR(64) NOT NULL UNIQUE 保留原 64 位 sha256 串原值
title VARCHAR(128) NOT NULL
intro VARCHAR(255) NULL
cover_image VARCHAR(255) NULL 封面文件名
price_fen INT UNSIGNED NOT NULL DEFAULT 0 展示价,实际以 products 为准
status TINYINT NOT NULL DEFAULT 1 0 下架 / 1 上架
sort_order INT NOT NULL DEFAULT 0
created_at / updated_at DATETIME NOT NULL

🔴 code 字段绝对不能改写。 它同时是视频目录名(Course/{code}/{chapter}/{play}/playlist.m3u8)与封面名(Course/{code}.png),一旦改动,全部付费媒体资源立即失效。

lessons(付费课时)

字段 类型 约束 说明
id BIGINT UNSIGNED PK
course_id BIGINT UNSIGNED NOT NULL → courses.id
play_id VARCHAR(64) NOT NULL 原值保留,参与视频路径
chapter_id VARCHAR(64) NOT NULL 原值保留,参与视频路径
chapter_title VARCHAR(255) NULL
lesson_title VARCHAR(255) NULL
duration VARCHAR(16) NULL 原为字符串,保留
published_at DATETIME NULL
cover_image VARCHAR(255) NULL
sort_order INT NOT NULL DEFAULT 0

索引:UNIQUE(course_id, chapter_id, play_id)、INDEX(course_id, sort_order)。

entitlements(权益 — 播放鉴权唯一入口)

字段 类型 约束 说明
id BIGINT UNSIGNED PK
user_id BIGINT UNSIGNED NOT NULL → users.id
course_id BIGINT UNSIGNED NOT NULL → courses.id
source TINYINT NOT NULL 1 订单 / 2 老数据(历史已购)/ 3 后台发放
ref_id BIGINT UNSIGNED NULL 订单 id 或操作人 id
granted_at DATETIME NOT NULL
expire_at DATETIME NULL NULL 表示永久
note VARCHAR(255) NULL

索引:UNIQUE(user_id, course_id)、INDEX(user_id)。

抽掉口令码后,权益来源仍有三种(订单 / 老数据 / 后台发放)。播放鉴权只查这一张表,避免三条分支三处 bug。

orders

字段 类型 约束 说明
id BIGINT UNSIGNED PK
out_trade_no VARCHAR(32) NOT NULL UNIQUE 本地业务单号
user_id BIGINT UNSIGNED NOT NULL
course_id BIGINT UNSIGNED NOT NULL
product_id VARCHAR(64) NOT NULL 微信虚拟支付道具 ID
quantity INT UNSIGNED NOT NULL DEFAULT 1
amount_fen INT UNSIGNED NOT NULL
status TINYINT NOT NULL 0 待支付 / 1 已支付 / 2 已发货 / 3 已退款 / 4 已关闭
env TINYINT NOT NULL DEFAULT 0 固定 0
wx_order_id VARCHAR(64) NULL UNIQUE 平台单号,幂等依据
attach VARCHAR(255) NULL 透传字段
created_at / paid_at / delivered_at / refunded_at DATETIME NULL

索引:INDEX(user_id, created_at)、INDEX(status, created_at)。

payment_notify_log

字段 类型 说明
id BIGINT UNSIGNED PK
wx_order_id VARCHAR(64)
out_trade_no VARCHAR(32)
event VARCHAR(64) 如 xpay_goods_deliver_notify
raw_body MEDIUMTEXT 原始 XML,用于重放与排查
processed TINYINT
err_msg VARCHAR(255)
created_at DATETIME

索引:INDEX(wx_order_id)。

products

字段 类型 说明
id BIGINT UNSIGNED PK
course_id BIGINT UNSIGNED → courses.id
wx_product_id VARCHAR(64) UNIQUE MP 后台道具 ID
name VARCHAR(128)
price_fen INT UNSIGNED 必须与 MP 后台道具价格一致
status TINYINT

admin_users

字段 类型 说明
id BIGINT UNSIGNED PK
username VARCHAR(64) UNIQUE
password_hash VARCHAR(255) argon2id
display_name VARCHAR(64)
role TINYINT 1 超管 / 2 运营
totp_secret VARCHAR(64) NULL 二次验证
status TINYINT
last_login_at DATETIME

settings

数字 ID 迁移为字符串 key,附映射关系(见 9.3)。

字段 类型 说明
id BIGINT UNSIGNED PK
key VARCHAR(64) UNIQUE 如 site.version
value VARCHAR(255)
remark VARCHAR(255) 原「注释」字段

内容类表(结构相近)

home_courses、home_free_courses、banners、articles、course_tips、free_lessons 均为「内容 + 排序 + 状态」结构,统一包含:

id, <业务字段...>, cover_image/filename, sort_order, status, created_at, updated_at

其中 articles 额外包含 legacy_page_id VARCHAR(32)。

🔴 封面与图片文件名迁移时一律原样保留。 原因:小程序里的图片地址是拼出来的——

  • 首页新课:Images/NewCourse/{课程名称}.png
  • 首页试听:Images/FreeCourse/{课程名称}.png
  • 轮播:Images/Banner/{图片名}
  • 知识速通:WebPages/{网页id}.png
  • 付费课封面:Course/{course.code}.png

任何字段被「规范化改写」,对应图片立刻 404。

4.5 首页内容块与课程的对应关系

注意首页「新课上线」与付费课程不是一一对应:

首页新课条目(5 条) 对应付费课程
几何模型封面 几何模型
中考数学压轴大招封面 中考数学压轴大招
七年级同步课程封面2 7年级同步课程
八年级同步课程 8年级同步课程
九年级同步课程 9年级同步课程

以下 5 门课程不在首页展示,仅在「知识」页与购买入口出现:7/8/9 年级数学同步视频课(北师大)、梁老师中考数学一轮/二轮满分冲刺。

home_courses 需要新增 course_id 可空字段,建立「首页长图页 → 可购买课程」的关联,用于挂载购买按钮。


5. 服务端设计

5.1 技术栈

项 选型
运行时 Node 22 LTS
语言 TypeScript(strict,禁止 any)
框架 NestJS
ORM / 迁移 Prisma(provider = mysql)
校验 zod(与管理后台共享 packages/shared)
鉴权 @nestjs/jwt + argon2
日志 pino
定时任务 @nestjs/schedule
文档 Swagger 自动生成
测试 vitest + supertest
仓库 pnpm workspaces 单仓

5.2 目录结构

apps/api/src/
├─ modules/
│  ├─ auth/           code2Session、sessionKey 托管、JWT 签发
│  ├─ users/
│  ├─ courses/        课程与课时
│  ├─ entitlements/   权益(播放鉴权唯一入口)
│  ├─ home/           banner / 新课 / 试听 / 文章 / 配置
│  ├─ orders/         下单、订单查询
│  ├─ payments/       签名、发货推送、兜底查单
│  ├─ media/          签名播放地址签发
│  └─ admin/          管理后台接口(独立守卫)
├─ legacy/            /API/*.php 兼容层(过渡用,阶段 8 删除)
├─ common/            统一响应拦截器 / 异常过滤器 / 守卫 / 装饰器
└─ main.ts

5.3 接口清单

小程序端 /v1/*

方法 路径 鉴权 说明
POST /v1/auth/wx-login 否 code 换 token,同时托管 sessionKey
POST /v1/auth/refresh 否 刷新 token
POST /v1/auth/logout 是 注销
GET /v1/config 否 站点配置
GET /v1/banners 否 首页轮播
GET /v1/home/courses 否 首页新课列表
GET /v1/home/free-courses 否 首页免费试听入口
GET /v1/articles 否 知识速通列表(分页)
GET /v1/articles/:id 否 文章详情
GET /v1/tips 否 课程页文案
GET /v1/courses 可选 课程列表(含未购买,用于购买入口)
GET /v1/courses/:code 可选 课程详情
GET /v1/courses/:code/lessons 是 课时列表,校验权益
GET /v1/courses/:code/play 是 签发播放地址,校验权益 + URL 签名
GET /v1/free-lessons 否 免费试听课时
GET /v1/me 是 个人资料
PATCH /v1/me 是 修改昵称 / 头像
GET /v1/me/entitlements 是 我的课程
POST /v1/orders 是 下单,返回 payData
GET /v1/orders/:outTradeNo 是 查询订单状态
POST /v1/payments/notify 签名 发货推送(微信调用,返回 XML)

管理后台 /admin/v1/*

方法 路径 说明
POST /admin/v1/auth/login 管理员登录
GET /admin/v1/stats 数据看板
GET/PATCH /admin/v1/users、/users/:id 用户管理
CRUD /admin/v1/courses 课程管理
CRUD /admin/v1/lessons 课时管理
CRUD /admin/v1/home-courses 首页新课长图
CRUD /admin/v1/home-free-courses 首页试听
CRUD /admin/v1/banners 轮播图
CRUD /admin/v1/articles 文章
GET/PUT /admin/v1/settings 系统配置
GET /admin/v1/orders 订单列表
POST /admin/v1/orders/:id/refund 发起退款(Android)
GET/POST/DELETE /admin/v1/entitlements 权益发放 / 回收

5.4 统一契约

响应体

{ "code": 0, "message": "ok", "data": {}, "requestId": "..." }

错误码分段

区间 含义
0 成功
1000–1999 通用(参数错误、资源不存在)
2000–2999 鉴权(未登录、token 过期、无权限)
3000–3999 业务(无权益、课程下架)
4000–4999 支付(订单状态非法、签名失败)
5000+ 服务端异常

HTTP 状态码独立正确(401 / 403 / 404 / 422 / 500)。分页统一 ?page=&pageSize=,返回 { items, total, page, pageSize }。

5.5 鉴权设计

小程序
  wx.login -> code
    -> POST /v1/auth/wx-login
    -> 服务端 code2Session 换 openid + sessionKey
    -> upsert users,加密存储 sessionKey
    -> 返回 accessToken(2h) + refreshToken(30d)

管理员
  admin_users + argon2 校验 + 独立 JWT(不同 audience)
  小程序 token 访问 /admin/v1/* 一律 403

要点:

  1. openid 不再作为身份凭证传递,改为 JWT。解决现网「拿到别人 openid 即可查其已购课程」的问题。
  2. sessionKey 必须服务端托管,因为虚拟支付 signature 依赖它。
  3. accessToken 与 refreshToken 使用不同密钥或不同 audience。

5.6 兼容层策略

在 /API/*.php 原路径上原样实现现有接口,返回体与现网逐字节一致。

目的:让小程序零改动即可切到新后端。风险从「全站重写」降为「换后端,先验证再切流量」。

性质:临时产物。随阶段 8 的前端改造一并删除。

接口对照表

现有前端函数 现路径 新接口 备注
ApiGetConfig /API/ApiGetConfig.php GET /v1/config
ApiGetUserOpenID /API/ApiGetUserOpenID_Yelay.php 内部化,不对外
ApiGetUserDataFromDB /API/ApiGetOrInsertUserDataFromDB.php GET /v1/me
ApiGetUserCourseDataFromDB /API/ApiGetUserCourseDataFromDB.php GET /v1/me/entitlements
ApiGetBannerImg /API/ApiGetBannerImg.php GET /v1/banners
ApiGetIndexCourseList /API/ApiGetIndexCourseList.php GET /v1/home/courses
ApiGetIndexFreeCourseList /API/ApiGetIndexFreeCourseList.php GET /v1/home/free-courses
ApiGetWebPagesFromDB /API/ApiGetWebPagesFromDB.php GET /v1/articles 顺带修掉 varchar 排序 bug
ApiGetWebPageContent 已弃用 不实现
ApiGetFreeCourseData /API/ApiGetFreeCourseData.php GET /v1/free-lessons
ApiGetCourseData /API/ApiGetCourseData.php GET /v1/courses/:code/lessons 鉴权改为查 entitlements
ApiLogin /API/ApiLogin.php POST /v1/auth/wx-login 语义变化
ApiLogout /API/ApiLogout.php POST /v1/auth/logout
ApiChangeInfo /API/ApiChangeInfo.php PATCH /v1/me
ApiGetTips /API/ApiGetTips.php GET /v1/tips
ApiPasswordCodeValidate /API/ApiPasswordCodeValidate.php 废弃
ApiGetPasswordCode /API/ApiGetPasswordCode.php 废弃
ApiAddPasswordCode /API/ApiAddPasswordCode.php 废弃
ApiAddPasswordCodeBefor /API/ApiAddPasswordCodeBefor.php 废弃
ApiChangePasswordCode /API/ApiChangePasswordCode.php 废弃
ApiDeletePasswordCode /API/ApiDeletePasswordCode.php 废弃

服务端直接删除的文件

SMS/(整套)、mail.php、ApiGetUUID.php、ApiGetShortUUID.php、ApiGetContact.php、ApiChangeUserInfo.php、ApiChangeUserInfo_yelay.php、ApiGetUserOpenID.php(旧 AppID)、已弃用ApiGetWebPageContent.php、ApiGetCourseID.php。


6. 支付设计

6.1 流程

用户点「购买」
  |
  +- (1) POST /v1/orders { courseCode }
  |      服务端生成唯一 out_trade_no,订单落库 status=created
  |      返回 signData + paySig + signature
  |
  +- (2) 前端 wx.requestVirtualPayment({ mode: 'short_series_goods', ... })
  |
  +- (3) 支付成功后,微信推送发货通知
  |      POST /v1/payments/notify(XML)
  |      按 wx_order_id 幂等 -> 写 entitlements -> order.status=delivered
  |      返回 <xml><ErrCode>0</ErrCode>...</xml>
  |
  +- (4) 推送丢失时,@nestjs/schedule 每 5 分钟 query_order 补发

6.2 必须遵守的纪律

  1. 发货只认「发货推送」和「查单」。前端支付 success 回调不作为发货依据——用户中途退出会丢失回调。
  2. 幂等键使用平台单号 wx_order_id,不是自己的 out_trade_no。
  3. 签名 post_body 必须与实际发出的字符串逐字节一致:不格式化、不改键顺序。
  4. 状态机严格单向:created → paid → delivered → refunded,禁止跳状态。
  5. 发货推送与应答消息体均为 XML。
  6. 查单接口参数名是 order_id(传 out_trade_no),不是 out_trade_no。

6.3 签名算法

# paySig(服务端计算,密钥为 AppKey)
msg     = uri + '&' + post_body
paySig  = HMAC_SHA256(appkey, msg)

# signature(用户态签名,密钥为该用户的 sessionKey)
signature = HMAC_SHA256(session_key, post_body)
  • C 端下单:uri 固定为 requestVirtualPayment
  • B 端接口:uri 为实际路径,如 /xpay/query_order

6.4 商品配置

10 门课程 = 10 个道具,需先在 MP 后台【道具管理】创建并发布,拿到道具 ID 与价格,写入 products 表。signData.goodsPrice 必须与后台价格一致(单位:分)。

6.5 费率与限制

项 值
个人主体月限额 10 万元(全终端合计)
Android 结算 T+3,费率 1%
iOS 结算 约 45–60 天,Apple 佣金 12%
iOS 最低微信版本 8.0.68
iOS 退款 用户向 App Store 申请,开发者无法主动退
Android 退款 开发者可主动发起

定价必须把 iOS 的 12% 佣金计入成本。


7. 管理后台设计

7.1 概况

项 内容
域名 admin.yeomai.site
接口 admin-api.yeomai.site → 同一 Node 进程 /admin/v1/*
技术 Next.js + TypeScript + shadcn/ui + TanStack Table
登录 独立账号密码(argon2),与微信用户体系完全隔离;可选 TOTP

7.2 功能清单

  1. 数据看板:用户数、订单数、成交额、课程销量。
  2. 用户管理:查询、改昵称头像、封禁。
  3. 课程管理:课程 CRUD、上下架、排序、封面。
  4. 课时管理:按课程列出课时,编辑标题/章节/排序。
  5. 权益管理:手动发放、回收、按用户查权益(替代原发码功能)。
  6. 订单管理:列表、详情、状态、发起退款(Android)。
  7. 内容管理:Banner、首页新课长图、首页试听入口、文章、课程页文案。
  8. 系统配置:settings 表全部键值。

8. 小程序端设计

8.1 保持不变的项

  • 全部 .wxml / .wxss / static/ 图片 / 图标 / 布局 / 交互手势。
  • tabBar 三项:首页、知识、我的。
  • 页面跳转关系与参数。
  • 所有对外图片与视频资源地址。

「外观不变」的实现方式:样式与布局文件直接复用,只重写 JS 层。这样外观天然一致,无需做像素级走查。

8.2 页面清单:11 → 10

页面 路径 处理
首页 pages/index/index 保留
知识 pages/kecheng/kecheng 保留 + 扩展购买入口
我的 pages/wode/wode 保留
登录/退出 pages/LoginOrOutDetails/LoginOrOutDetails 保留,逻辑简化
文章详情 pages/WebPageDetails/WebPageDetails 保留
付费课播放 pages/VideoDetails/VideoDetails 保留 + 购买按钮
新课长图 pages/IndexNewCourseDetails/IndexNewCourseDetails 保留 + 购买按钮
免费试听播放 pages/IndexFreeCourseDetails/IndexFreeCourseDetails 保留
修改资料 pages/ChangeUserInfo/ChangeUserInfo 保留
介绍宣传页 pages/VideoDetailsPage/VideoDetailsPage 保留,实现逻辑不变
隐藏管理台 pages/SetPasswordCode/SetPasswordCode 删除(功能迁至管理后台)

8.3 VideoDetailsPage 特殊说明

该页面是对外宣传页(初中数学公式定理资料,21 张图片,指向 WebPages/2/1.jpg ~ 21.jpg)。

现有实现:

VideoDetailsPage.wxml
  1  <text>VideoDetailsPage</text>
  2  <text>富文本测试</text>
  3  <Parser html="{{htmlSnip}}" />

正文 HTML 硬编码在 VideoDetailsPage.js 的 htmlSnip 常量中,经 Parser 组件渲染。

处理方式(ADR-005):实现逻辑保持不变——不迁入数据库、不改由后台维护。Parser 组件替换为 npm 维护版后,该页对外行为完全一致。

⚠️ 该页路径必须原样保留。 代码中虽然没有跳转引用,但 sitemap.json 为全量允许索引,且可通过小程序码、分享、搜一搜直达。路径变更会导致已分发的入口失效。

8.4 需要重写的部分

  1. 请求层:新增统一 request 封装,自动附带 token、统一错误提示、401 自动重新登录。替代现有 21 个函数各自手写 wx.request + Promise 的写法。
  2. 状态层:新增极简状态模块,替代散落在各页面的 wx.getStorageSync('UserInfoCache')。
  3. Parser:自研 Parser/ 组件替换为 npm 维护版 mp-html(对外行为一致,VideoDetailsPage 与文章页不受影响)。
  4. 移除轮询:删除 kecheng.js 中 10ms × 500 次的登录等待轮询,改为 Promise 完成后驱动。
  5. 修复首启竞态:index.js / wode.js 在 data 中同步读取异步写入的 Config 缓存,首次安装必然报错。
  6. 清理:删除 LoginOrOutDetails.js 中大段注释掉的旧上传代码。

8.5 购买入口(唯一新增 UI)

⚠️ 加入支付后需要新增购买入口,这是「外观完全不变」的唯一例外——现有系统没有任何商品列表或购买按钮,付费课全靠口令码兑换。

采用最小侵入方案,复用现有视觉语言:

  1. 首页长图页(IndexNewCourseDetails)底部增加「立即购买」按钮,对应 home_courses.course_id。
  2. 知识页(kecheng)在有课时把未购课程一并列出(锁定态),点入课程详情页购买。

不新增 tab、不新增页面类型。


9. 数据迁移方案

9.1 迁移前体检结论

对生产库做过完整性校验,结果如下:

检查项 结果
课程目录数 10 门
有课时的课程 10 门
被购买过的课程 10 门
购买记录指向但目录中不存在的课程 无
有课时但目录中不存在的课程 无
目录中有但无课时的课程 无

结论:零孤儿数据,映射关系完整,迁移无需人工补救。

9.2 迁移映射

源 目标 关键处理
用户信息 users 字段改名;时间字段转 UTC
用户已购课程 entitlements source = legacy,granted_at = 购课日期;48 条
课程口令码读取信息 courses 课程id → code(原值保留)
视频课程数据 lessons 章节id / 播放id 原值保留;841 条
免费视频课程数据 free_lessons
首页课程列表 home_courses 课程名称 原值保留(图片路径依赖)
首页免费课程列表 home_free_courses 同上
首页横幅图片 banners 图片名 原值保留
网页列表 articles 网页id → legacy_page_id(图片路径依赖)
课程页面Tips course_tips
cloudconfig settings 数字 ID → 字符串 key,见 9.3
课程口令码 — 废弃(不迁移,见 9.4)

9.3 settings 键映射

原 ID 原 value 新 key 说明
0 占空 — 废弃
1 V0.0.9 site.version 版本
2 付费课程 home.title.paid 标题
3 试听课程 home.title.trial 标题
4 公开课程 home.title.public 标题
5 Yelaymaths profile.qrcode_image 二维码图片名
6 50 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 图片标题

9.4 旧口令码处理:整体作废,已兑换用户直接标记为已拥有

已确认的处理方式:口令码机制整体废弃,课程口令码 表不迁入新库。

已兑换用户如何处理:历史上任何用户使用课程码兑换成功,服务端都会写入一条 用户已购课程 记录。因此**「谁用过课程码」这件事已经完整落在 用户已购课程 表中**,共 48 条,无需从口令码反推。

迁移时逐条转为 entitlements:

源字段 目标字段 取值
OpenID user_id 按 openid 匹配 users
课程id course_id 按 code 匹配 courses
(固定) source 2(老数据)
购课日期 granted_at 原值(转 UTC)
课程介绍 note 原值(可选)

用户侧表现:这些用户升级后打开「知识」页,看到的已购课程与升级前完全一致,无需任何操作,也不经过支付。这就是「不走支付、直接标记为已拥有」。

作废部分:课程口令码 表中 5 条记录、合计 18 次未使用的剩余次数,随机制一并作废,不迁移、不补偿。此项为明确决策,非疏漏。

口令码 课程 未使用次数(作废)
202601 7年级数学同步视频课(北师大) 3
202602 8年级数学同步视频课(北师大) 3
202603 9年级数学同步视频课(北师大) 3
202604 梁老师中考数学一轮满分冲刺 5
202605 梁老师中考数学二轮满分冲刺 4

若后续有人反馈持有未使用的旧码,用管理后台的「权益手动发放」补发即可,无需为它保留专门的核销功能与数据表。

9.5 迁移步骤

  1. 全量备份生产库与 Images/、WebPages/、Course/、FreeVideos/ 目录。
  2. 建立新库与数据库账号。
  3. 执行 prisma migrate deploy 建表。
  4. 执行迁移脚本(tools/migrate-legacy/),要求:幂等、可重复执行、逐步计数校验。
  5. 数据校验:
    • 逐表行数比对。
    • 随机抽取 10 个用户,比对迁移前后「已购课程集合」是否完全一致。
    • 校验每个 entitlement.course_id 在 courses 中均存在。
    • 校验 courses.code 与源数据逐字节一致。
  6. 在 staging(api_yeomai_site_develop)跑通全部接口。
  7. 生产切换,旧库保留只读作为回滚路径。

9.6 回滚方案

  • 切换期 PHP 服务与 Node 服务在同一台机器共存。
  • 小程序端在兼容层阶段未改动,回滚只需切换域名解析。
  • 数据库切换前做全量快照,出问题直接回退。

10. 实施路线图

阶段 内容 工期 交付物
0 准备 宝塔 Node 环境试通(最大未知项);备份;建 monorepo;确认 MySQL 账号权限 1–2 天 可运行的部署链路
1 数据端 Prisma schema + 迁移 + 数据字典 + 迁移脚本 + 校验 3–4 天 新库可用,数据完整
2 服务端骨架 NestJS + Prisma + zod + pino + Swagger;统一响应/异常/守卫;鉴权 + sessionKey 托管 4–5 天 /v1 核心接口可用
3 兼容层 /API/*.php 原样实现 + 逐接口对拍测试;staging 用小体验证 5–7 天 小程序可切新后端
4 生产切换 切域名,保留回滚路径 1 天 生产运行在 Node 上
5 管理后台 Next.js 后台全功能;删除小程序隐藏管理页 1–2 周 运营可脱离 SQL
6 支付 开通虚拟支付 → 道具 → 下单/推送/幂等/兜底 → 订单状态机 1–2 周 可自助购买
7 视频防盗链 对象存储 + CDN 签名 + /play 接口 3–5 天 付费内容不可直链
8 前端规范化 request 封装、状态模块、去轮询、换 Parser、新增购买入口;删兼容层与死代码 4–5 天 全量切 /v1

总计约 6–8 周。

顺序原则:先让新后端原样接管(阶段 1–4),再谈支付(阶段 6)。 支付本身不难,难的是把它塞进一个没有鉴权、没有订单表、没有后台的系统里。


11. 风险登记册

编号 风险 影响 应对 阶段
R1 宝塔 Node 项目管理器对 pnpm workspace 支持不足 阻塞部署 阶段 0 先行验证;备选「CI 构建后仅部署产物」 0
R2 MySQL 5.7 账号无 CREATE DATABASE 权限导致 Prisma shadow DB 失败 阻塞迁移 授权,或本地 migrate diff + 生产 migrate deploy 1
R3 courses.code 被改写 全部视频资源失效 迁移脚本禁止改写;加校验断言 1
R4 封面 / 图片文件名被改写 图片 404 迁移脚本原值透传;加校验断言 1
R5 时间字段未做 UTC 转换 订单时间偏移 8 小时 迁移时显式转换 + 抽样核对 1
R6 18 次未使用的旧口令码随机制作废 持码人无法兑换 已确认为接受项;若有人反馈,用后台「权益手动发放」补发 1
R7 VideoDetailsPage 路径变更 已分发的入口失效 路径原样保留 8
R8 sessionKey 泄漏 用户态签名可被伪造 加密存储;日志脱敏 2
R9 发货推送丢失 用户付款未开通 双通道:推送 + 定时查单 6
R10 iOS 12% 佣金与 45–60 天结算 毛利与现金流 定价计入佣金;预留周转 6
R11 宝塔 MySQL 5.7 已 EOL 无安全补丁 接受;保证每日备份可恢复 全部
R12 生产密钥曾在 git / dump 中泄露 数据与资金风险 全部轮换:DB 密码、阿里云 AK/SK、邮箱授权码、小程序 AppSecret 0

12. 待确认事项

编号 事项 现状 需要
Q1 VideoDetailsPage 的实际访问入口 代码中无跳转引用,但可能经小程序码 / 搜索直达 确认是否对外分发过;若通过小程序码分发,路径必须保留
Q2 头像资源数量 cloudconfig 记录为 50,Images/Headicons/ 实际 51 个文件 确认有效范围
Q3 三大内容块是否全保留 默认全部保留 确认无内容需要下线

附录 A:目标仓库结构

yelaymaths/
├─ apps/
│  ├─ api/                    # NestJS 服务端
│  │  ├─ src/
│  │  │  ├─ modules/
│  │  │  ├─ legacy/           # 兼容层(临时)
│  │  │  ├─ common/
│  │  │  └─ main.ts
│  │  ├─ prisma/
│  │  │  ├─ schema.prisma
│  │  │  └─ migrations/
│  │  └─ test/
│  ├─ admin/                  # Next.js 管理后台
│  └─ mp/                     # 微信小程序(原生)
├─ packages/
│  ├─ shared/                 # zod schema、枚举、DTO 类型
│  └─ tsconfig/               # 共享 TS / ESLint 预设
├─ tools/
│  └─ migrate-legacy/         # 老库 → 新库迁移脚本
├─ docs/
│  └─ 重构开发文档.md
├─ docker-compose.yml
└─ .gitlab-ci.yml

附录 B:现有 10 门付费课程

course.code 前缀 课程名
98869f2f771e… 几何模型
1e7d43480f4e… 中考数学压轴大招
1da939cf5f61… 7年级同步课程
0656b64f6e60… 8年级同步课程
2e52fbb3c8d9… 9年级同步课程
cf944197d5fb… 7年级数学同步视频课(北师大)
b711b037fb7b… 8年级数学同步视频课(北师大)
6ec33e18e8ab… 9年级数学同步视频课(北师大)
25caa811c67c… 梁老师中考数学一轮满分冲刺(视频+讲义)
77d091c27378… 梁老师中考数学二轮满分冲刺(视频+讲义)