864 lines
38 KiB
Markdown
864 lines
38 KiB
Markdown
# 「叶雷讲数学」小程序 重构开发文档
|
||
|
||
| 项 | 内容 |
|
||
|---|---|
|
||
| 版本 | 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 行 / 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` 均为「内容 + 排序 + 状态」结构,统一包含:
|
||
|
||
```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…` | 梁老师中考数学二轮满分冲刺(视频+讲义) |
|