38 KiB
「叶雷讲数学」小程序 重构开发文档
| 项 | 内容 |
|---|---|
| 版本 | v1.0 |
| 日期 | 2026-09-29 |
| 状态 | 设计定稿,待开发 |
| 范围 | 小程序端 / 服务端 / 管理后台 / 数据端 |
1. 项目概述
1.1 背景
现有系统为「微信小程序 + 原生 PHP + MySQL 5.7 + 静态文件站点」的实现,可正常运行,但存在结构性问题:无鉴权体系、SQL 全字符串拼接、数据库无主键与约束、无正式管理后台、无支付能力、存在大量死代码与废弃表、服务端无版本控制。
本次重构的目标是:在保持小程序用户侧外观与使用体验不变的前提下,把技术实现整体替换为规范化工程,并补齐支付与管理后台能力。
1.2 目标
- 用户侧无感升级:页面表现一致、已有数据零丢失、已购课程权限延续。
- 技术实现规范化:分层、类型安全、边界校验、迁移管理、统一契约。
- 补齐支付能力,使课程销售可自助完成。
- 补齐管理后台,使运营脱离手写 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 用户默认没有该权限。解决方案(二选一):
- 为迁移专用账号授予
CREATE/DROP权限(推荐,本机开发用)。- 在本地生成迁移 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
要点:
- openid 不再作为身份凭证传递,改为 JWT。解决现网「拿到别人 openid 即可查其已购课程」的问题。
- sessionKey 必须服务端托管,因为虚拟支付
signature依赖它。 - 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 必须遵守的纪律
- 发货只认「发货推送」和「查单」。前端支付
success回调不作为发货依据——用户中途退出会丢失回调。 - 幂等键使用平台单号
wx_order_id,不是自己的out_trade_no。 - 签名
post_body必须与实际发出的字符串逐字节一致:不格式化、不改键顺序。 - 状态机严格单向:
created → paid → delivered → refunded,禁止跳状态。 - 发货推送与应答消息体均为 XML。
- 查单接口参数名是
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 功能清单
- 数据看板:用户数、订单数、成交额、课程销量。
- 用户管理:查询、改昵称头像、封禁。
- 课程管理:课程 CRUD、上下架、排序、封面。
- 课时管理:按课程列出课时,编辑标题/章节/排序。
- 权益管理:手动发放、回收、按用户查权益(替代原发码功能)。
- 订单管理:列表、详情、状态、发起退款(Android)。
- 内容管理:Banner、首页新课长图、首页试听入口、文章、课程页文案。
- 系统配置:
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 需要重写的部分
- 请求层:新增统一
request封装,自动附带 token、统一错误提示、401 自动重新登录。替代现有 21 个函数各自手写wx.request+ Promise 的写法。 - 状态层:新增极简状态模块,替代散落在各页面的
wx.getStorageSync('UserInfoCache')。 - Parser:自研
Parser/组件替换为 npm 维护版mp-html(对外行为一致,VideoDetailsPage与文章页不受影响)。 - 移除轮询:删除
kecheng.js中 10ms × 500 次的登录等待轮询,改为 Promise 完成后驱动。 - 修复首启竞态:
index.js/wode.js在data中同步读取异步写入的Config缓存,首次安装必然报错。 - 清理:删除
LoginOrOutDetails.js中大段注释掉的旧上传代码。
8.5 购买入口(唯一新增 UI)
⚠️ 加入支付后需要新增购买入口,这是「外观完全不变」的唯一例外——现有系统没有任何商品列表或购买按钮,付费课全靠口令码兑换。
采用最小侵入方案,复用现有视觉语言:
- 首页长图页(
IndexNewCourseDetails)底部增加「立即购买」按钮,对应home_courses.course_id。 - 知识页(
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 迁移步骤
- 全量备份生产库与
Images/、WebPages/、Course/、FreeVideos/目录。 - 建立新库与数据库账号。
- 执行
prisma migrate deploy建表。 - 执行迁移脚本(
tools/migrate-legacy/),要求:幂等、可重复执行、逐步计数校验。 - 数据校验:
- 逐表行数比对。
- 随机抽取 10 个用户,比对迁移前后「已购课程集合」是否完全一致。
- 校验每个
entitlement.course_id在courses中均存在。 - 校验
courses.code与源数据逐字节一致。
- 在 staging(
api_yeomai_site_develop)跑通全部接口。 - 生产切换,旧库保留只读作为回滚路径。
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… |
梁老师中考数学二轮满分冲刺(视频+讲义) |