Files
YelayMaths/docs/重构开发文档.md
T
2026-09-29 13:39:36 +08:00

864 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 「叶雷讲数学」小程序 重构开发文档
| 项 | 内容 |
|---|---|
| 版本 | 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 架构图
```text
微信小程序
| 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) | `users` | 迁移,结构重写 |
| `用户已购课程` (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` 均为「内容 + 排序 + 状态」结构,统一包含:
```text
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 目录结构
```text
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 统一契约
**响应体**
```json
{ "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 鉴权设计
```text
小程序
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 流程
```text
用户点「购买」
|
+- (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 签名算法
```text
# 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`)。
现有实现:
```text
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:目标仓库结构
```text
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…` | 梁老师中考数学二轮满分冲刺(视频+讲义) |