903 lines
35 KiB
Markdown
903 lines
35 KiB
Markdown
# Doodle Mini — 后端部署方案(NestJS + 自有云服务器)
|
||
|
||
> 版本:v1.0
|
||
> 最后更新:2026-03-27
|
||
> **定位**:**远期 / 阶段到期后的迁移方案**(自有服务器 + NestJS)。
|
||
> **现阶段**:小程序后端以 [小程序云开发方案](./小程序云开发方案.md) 为准,详见 [技术架构设计文档](./技术架构设计文档.md) 中的阶段说明(当前阶段有效期至 **2026-09-15**)。
|
||
> 配套文档:[技术架构设计文档](./技术架构设计文档.md) | [小程序云开发方案](./小程序云开发方案.md) | [产品设计文档](./产品设计文档.md) | [PC-Web端技术预案](./PC-Web端技术预案.md)
|
||
|
||
---
|
||
|
||
## 一、方案决策
|
||
|
||
### 1.1 为什么选择自建后端(何时采用)
|
||
|
||
| 考量 | 说明 |
|
||
| -------------- | --------------------------------------------------------------------------------- |
|
||
| **零增量成本** | 已有云服务器,不新增费用;微信云开发免费额度有限,超出需持续付费 |
|
||
| **灵活性强** | NestJS 是完整的 Node.js 框架,不受云开发 SDK 限制,可自由选择数据库、缓存、队列等 |
|
||
| **多端友好** | RESTful API 天然支持小程序、PC Web、App 等多端接入,无需桥接层 |
|
||
| **数据自主** | 数据存储在自有服务器,不锁定平台,迁移自由 |
|
||
| **技术栈统一** | 前后端均使用 TypeScript,共享类型定义和数据模型,开发效率高 |
|
||
|
||
### 1.2 技术选型
|
||
|
||
| 层级 | 技术 | 说明 |
|
||
| -------- | ----------------------- | ------------------------------------------------------------ |
|
||
| 运行时 | Node.js 20 LTS | 长期支持版本,稳定可靠 |
|
||
| 框架 | NestJS 10+ | 企业级 Node.js 框架,模块化架构,内置 DI/AOP/中间件等 |
|
||
| 数据库 | MySQL 8.0 | 成熟稳定的关系型数据库,适合结构化的题型/用户数据 |
|
||
| ORM | Prisma | 类型安全的 ORM,自动生成 TS 类型,迁移管理方便 |
|
||
| 缓存 | Redis(可选) | 热门题型列表、配置数据缓存,降低数据库压力;初期可不引入 |
|
||
| 文件存储 | 本地磁盘 + Nginx 静态 | 素材文件存服务器本地,Nginx 直接提供静态文件服务,零成本 CDN |
|
||
| 进程管理 | PM2 | 守护进程、自动重启、日志管理 |
|
||
| 反向代理 | Nginx | HTTPS 终止、静态文件、反向代理、Gzip 压缩 |
|
||
| 容器化 | Docker + docker-compose | 可选,方便环境一致性和部署自动化 |
|
||
|
||
---
|
||
|
||
## 二、系统架构
|
||
|
||
```
|
||
┌───────────────────────────────────────────────────────────────┐
|
||
│ 客户端 │
|
||
│ │
|
||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||
│ │ 微信小程序 │ │ PC Web (远期) │ │
|
||
│ │ wx.request() │ │ axios/fetch │ │
|
||
│ └────────┬────────┘ └────────┬────────┘ │
|
||
│ │ │ │
|
||
└───────────┼───────────────────────────┼───────────────────────┘
|
||
│ HTTPS │
|
||
▼ ▼
|
||
┌───────────────────────────────────────────────────────────────┐
|
||
│ 云服务器 │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||
│ │ Nginx │ │
|
||
│ │ • HTTPS 终止(Let's Encrypt 证书) │ │
|
||
│ │ • /api/* → 反向代理到 NestJS (localhost:3000) │ │
|
||
│ │ • /static/* → 直接提供静态素材文件 │ │
|
||
│ │ • Gzip 压缩、请求限流 │ │
|
||
│ └───────────┬────────────────────────┬────────────────────┘ │
|
||
│ │ │ │
|
||
│ ▼ ▼ │
|
||
│ ┌────────────────────┐ ┌──────────────────────┐ │
|
||
│ │ NestJS 应用 │ │ 静态文件目录 │ │
|
||
│ │ (PM2 守护) │ │ │ │
|
||
│ │ │ │ /data/doodle/ │ │
|
||
│ │ ┌──────────┐ │ │ ├── previews/ │ │
|
||
│ │ │ Auth │ │ │ ├── coloring/ │ │
|
||
│ │ │ Module │ │ │ ├── origami/ │ │
|
||
│ │ ├──────────┤ │ │ ├── fonts/ │ │
|
||
│ │ │ Worksheet│ │ │ └── share/ │ │
|
||
│ │ │ Module │ │ └──────────────────────┘ │
|
||
│ │ ├──────────┤ │ │
|
||
│ │ │ User │ │ │
|
||
│ │ │ Module │ │ │
|
||
│ │ ├──────────┤ │ │
|
||
│ │ │ Favorite │ │ │
|
||
│ │ │ Module │ │ │
|
||
│ │ ├──────────┤ │ │
|
||
│ │ │ Stats │ │ │
|
||
│ │ │ Module │ │ │
|
||
│ │ ├──────────┤ │ │
|
||
│ │ │ Upload │ │ │
|
||
│ │ │ Module │ │ │
|
||
│ │ └──────────┘ │ │
|
||
│ │ │ │ │
|
||
│ │ ▼ │ │
|
||
│ │ ┌──────────┐ │ │
|
||
│ │ │ MySQL │ │ │
|
||
│ │ │ (Prisma) │ │ │
|
||
│ │ └──────────┘ │ │
|
||
│ └────────────────────┘ │
|
||
│ │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 三、项目结构
|
||
|
||
```
|
||
doodle-server/
|
||
├── src/
|
||
│ ├── main.ts ← 应用入口
|
||
│ ├── app.module.ts ← 根模块
|
||
│ │
|
||
│ ├── common/ ← 通用模块
|
||
│ │ ├── guards/
|
||
│ │ │ └── wx-auth.guard.ts ← 微信登录鉴权守卫
|
||
│ │ ├── interceptors/
|
||
│ │ │ └── response.interceptor.ts ← 统一响应格式
|
||
│ │ ├── filters/
|
||
│ │ │ └── http-exception.filter.ts← 统一异常处理
|
||
│ │ ├── decorators/
|
||
│ │ │ └── current-user.decorator.ts ← 获取当前用户
|
||
│ │ └── dto/
|
||
│ │ └── pagination.dto.ts ← 分页参数
|
||
│ │
|
||
│ ├── auth/ ← 鉴权模块
|
||
│ │ ├── auth.module.ts
|
||
│ │ ├── auth.controller.ts ← POST /auth/wx-login
|
||
│ │ └── auth.service.ts ← 微信 code2session + JWT 签发
|
||
│ │
|
||
│ ├── worksheet/ ← 题型配置模块
|
||
│ │ ├── worksheet.module.ts
|
||
│ │ ├── worksheet.controller.ts ← GET /worksheets, GET /worksheets/:id
|
||
│ │ ├── worksheet.service.ts
|
||
│ │ └── dto/
|
||
│ │ ├── query-worksheet.dto.ts ← 查询筛选参数
|
||
│ │ └── worksheet-response.dto.ts
|
||
│ │
|
||
│ ├── category/ ← 分类模块
|
||
│ │ ├── category.module.ts
|
||
│ │ ├── category.controller.ts ← GET /categories
|
||
│ │ └── category.service.ts
|
||
│ │
|
||
│ ├── user/ ← 用户模块
|
||
│ │ ├── user.module.ts
|
||
│ │ ├── user.controller.ts ← GET /user/profile, PATCH /user/profile
|
||
│ │ └── user.service.ts
|
||
│ │
|
||
│ ├── favorite/ ← 收藏模块
|
||
│ │ ├── favorite.module.ts
|
||
│ │ ├── favorite.controller.ts ← GET/POST/DELETE /favorites
|
||
│ │ └── favorite.service.ts
|
||
│ │
|
||
│ ├── history/ ← 下载历史模块
|
||
│ │ ├── history.module.ts
|
||
│ │ ├── history.controller.ts ← GET/POST /history
|
||
│ │ └── history.service.ts
|
||
│ │
|
||
│ ├── stats/ ← 统计模块
|
||
│ │ ├── stats.module.ts
|
||
│ │ ├── stats.controller.ts ← POST /stats/download, GET /stats/popular
|
||
│ │ └── stats.service.ts
|
||
│ │
|
||
│ ├── feedback/ ← 反馈模块
|
||
│ │ ├── feedback.module.ts
|
||
│ │ ├── feedback.controller.ts ← POST /feedback
|
||
│ │ └── feedback.service.ts
|
||
│ │
|
||
│ └── upload/ ← 素材上传模块(管理后台用)
|
||
│ ├── upload.module.ts
|
||
│ ├── upload.controller.ts ← POST /upload/asset
|
||
│ └── upload.service.ts
|
||
│
|
||
├── prisma/
|
||
│ ├── schema.prisma ← 数据库 Schema
|
||
│ └── seed.ts ← 初始数据填充脚本
|
||
│
|
||
├── nginx/
|
||
│ └── doodle-api.conf ← Nginx 配置模板
|
||
│
|
||
├── .env.example ← 环境变量模板
|
||
├── docker-compose.yml ← 可选容器化部署
|
||
├── ecosystem.config.js ← PM2 配置
|
||
├── package.json
|
||
└── tsconfig.json
|
||
```
|
||
|
||
---
|
||
|
||
## 四、数据库设计(Prisma Schema)
|
||
|
||
```prisma
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "mysql"
|
||
url = env("DATABASE_URL")
|
||
}
|
||
|
||
// ─── 题型配置表 ───
|
||
|
||
model Worksheet {
|
||
id String @id @default(cuid())
|
||
title String @db.VarChar(100)
|
||
desc String @db.VarChar(500)
|
||
category String @db.VarChar(20) // math | chinese | english | puzzle | craft
|
||
subcategory String @db.VarChar(50)
|
||
ageMin Int @db.SmallInt // 适龄最小值
|
||
ageMax Int @db.SmallInt // 适龄最大值
|
||
difficulty Int @db.SmallInt // 1-4
|
||
previewImage String @db.VarChar(500)
|
||
tags Json // string[]
|
||
isNew Boolean @default(false)
|
||
isHot Boolean @default(false)
|
||
sortOrder Int @default(0)
|
||
downloadCount Int @default(0)
|
||
status String @default("active") @db.VarChar(10) // active | draft | hidden
|
||
|
||
// 模板引擎字段
|
||
template String @db.VarChar(30) // TemplateType
|
||
generator String @db.VarChar(30) // GeneratorType
|
||
generatorConfig Json // 生成器参数
|
||
layoutConfig Json // 排版参数
|
||
userConfigurable Json? // 用户可调整参数定义
|
||
legacyPage String? @db.VarChar(200) // 旧页面路径(迁移过渡)
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
favorites Favorite[]
|
||
downloadLogs DownloadLog[]
|
||
|
||
@@index([category, status, sortOrder])
|
||
@@index([status, sortOrder])
|
||
@@map("worksheets")
|
||
}
|
||
|
||
// ─── 分类表 ───
|
||
|
||
model Category {
|
||
id String @id @default(cuid())
|
||
name String @db.VarChar(50)
|
||
icon String @db.VarChar(200)
|
||
color String @db.VarChar(10)
|
||
sortOrder Int @default(0)
|
||
parentId String? @db.VarChar(30)
|
||
|
||
@@index([parentId, sortOrder])
|
||
@@map("categories")
|
||
}
|
||
|
||
// ─── 用户表 ───
|
||
|
||
model User {
|
||
id String @id @default(cuid())
|
||
openid String @unique @db.VarChar(100)
|
||
unionid String? @unique @db.VarChar(100)
|
||
nickName String? @db.VarChar(50)
|
||
avatarUrl String? @db.VarChar(500)
|
||
totalDownloads Int @default(0)
|
||
createdAt DateTime @default(now())
|
||
lastActiveAt DateTime @default(now())
|
||
|
||
favorites Favorite[]
|
||
downloadLogs DownloadLog[]
|
||
feedbacks Feedback[]
|
||
|
||
@@map("users")
|
||
}
|
||
|
||
// ─── 收藏表 ───
|
||
|
||
model Favorite {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
worksheetId String
|
||
createdAt DateTime @default(now())
|
||
|
||
user User @relation(fields: [userId], references: [id])
|
||
worksheet Worksheet @relation(fields: [worksheetId], references: [id])
|
||
|
||
@@unique([userId, worksheetId])
|
||
@@index([userId, createdAt])
|
||
@@map("favorites")
|
||
}
|
||
|
||
// ─── 下载日志表 ───
|
||
|
||
model DownloadLog {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
worksheetId String
|
||
params Json? // 生成参数快照
|
||
createdAt DateTime @default(now())
|
||
|
||
user User @relation(fields: [userId], references: [id])
|
||
worksheet Worksheet @relation(fields: [worksheetId], references: [id])
|
||
|
||
@@index([userId, createdAt])
|
||
@@index([worksheetId, createdAt])
|
||
@@map("download_logs")
|
||
}
|
||
|
||
// ─── 反馈表 ───
|
||
|
||
model Feedback {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
content String @db.Text
|
||
contact String? @db.VarChar(100)
|
||
createdAt DateTime @default(now())
|
||
|
||
user User @relation(fields: [userId], references: [id])
|
||
|
||
@@index([userId])
|
||
@@map("feedback")
|
||
}
|
||
|
||
// ─── 学习路线表 ───
|
||
|
||
model LearningPlan {
|
||
id String @id @default(cuid())
|
||
ageMin Int @db.SmallInt
|
||
ageMax Int @db.SmallInt
|
||
ageLabel String @db.VarChar(20)
|
||
milestones Json // string[],能力目标描述
|
||
weeks Json // WeekPlan[],4 周学习路线
|
||
|
||
@@unique([ageMin, ageMax])
|
||
@@map("learning_plans")
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、API 接口设计
|
||
|
||
### 5.1 鉴权
|
||
|
||
微信小程序通过 `wx.login()` 获取临时 `code`,发送到后端换取 `openid`,后端签发 JWT。
|
||
|
||
```
|
||
POST /api/auth/wx-login
|
||
Body: { code: string }
|
||
Response: { token: string, user: UserInfo }
|
||
|
||
流程:
|
||
小程序 wx.login() → code
|
||
→ POST /api/auth/wx-login { code }
|
||
→ 后端调用微信 code2Session API 获取 openid
|
||
→ 查找或创建用户记录
|
||
→ 签发 JWT (payload: { userId, openid })
|
||
→ 返回 { token, user }
|
||
小程序将 token 存入 wx.Storage
|
||
后续请求 Header: Authorization: Bearer <token>
|
||
```
|
||
|
||
### 5.2 题型配置
|
||
|
||
```
|
||
GET /api/worksheets
|
||
Query: category, subcategory, ageMin, ageMax, difficulty, status, page, pageSize
|
||
Response: { items: Worksheet[], total: number }
|
||
|
||
GET /api/worksheets/:id
|
||
Response: Worksheet
|
||
|
||
GET /api/worksheets/popular
|
||
Query: limit (default 10)
|
||
Response: Worksheet[]
|
||
```
|
||
|
||
### 5.3 分类
|
||
|
||
```
|
||
GET /api/categories
|
||
Response: Category[]
|
||
```
|
||
|
||
### 5.4 用户
|
||
|
||
```
|
||
GET /api/user/profile ← 需登录
|
||
Response: UserInfo
|
||
|
||
PATCH /api/user/profile ← 需登录
|
||
Body: { nickName?, avatarUrl? }
|
||
Response: UserInfo
|
||
```
|
||
|
||
### 5.5 收藏
|
||
|
||
```
|
||
GET /api/favorites ← 需登录
|
||
Query: page, pageSize
|
||
Response: { items: FavoriteWithWorksheet[], total: number }
|
||
|
||
POST /api/favorites ← 需登录
|
||
Body: { worksheetId: string }
|
||
Response: { id: string }
|
||
|
||
DELETE /api/favorites/:worksheetId ← 需登录
|
||
Response: { success: true }
|
||
```
|
||
|
||
### 5.6 下载历史 / 统计
|
||
|
||
```
|
||
GET /api/history ← 需登录
|
||
Query: page, pageSize
|
||
Response: { items: DownloadLogWithWorksheet[], total: number }
|
||
|
||
POST /api/stats/download
|
||
Body: { worksheetId: string, params?: object }
|
||
Response: { success: true }
|
||
```
|
||
|
||
### 5.7 反馈
|
||
|
||
```
|
||
POST /api/feedback ← 需登录
|
||
Body: { content: string, contact?: string }
|
||
Response: { id: string }
|
||
```
|
||
|
||
### 5.8 学习路线
|
||
|
||
```
|
||
GET /api/learning-plans
|
||
Response: LearningPlan[]
|
||
|
||
GET /api/learning-plans/:ageRange ← 如 "5-6"
|
||
Response: LearningPlan
|
||
```
|
||
|
||
### 5.9 统一响应格式
|
||
|
||
```typescript
|
||
// 成功
|
||
{
|
||
code: 0,
|
||
data: { ... },
|
||
message: "success"
|
||
}
|
||
|
||
// 失败
|
||
{
|
||
code: 40001, // 业务错误码
|
||
data: null,
|
||
message: "具体错误信息"
|
||
}
|
||
|
||
// 分页数据
|
||
{
|
||
code: 0,
|
||
data: {
|
||
items: [...],
|
||
total: 128,
|
||
page: 1,
|
||
pageSize: 20
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、小程序端适配层
|
||
|
||
原方案中 `platform/cloud-adapter.ts` 需要从微信云开发调用改为标准 HTTP 请求。
|
||
|
||
```typescript
|
||
// platform/cloud-adapter.ts — 改为 HTTP API 调用
|
||
|
||
const BASE_URL = 'https://api.your-domain.com/api';
|
||
|
||
class HttpCloudAdapter implements ICloudAdapter {
|
||
private token: string | null = null;
|
||
|
||
async login(): Promise<UserInfo> {
|
||
const { code } = await wx.login();
|
||
const res = await this.request('POST', '/auth/wx-login', { code });
|
||
this.token = res.token;
|
||
wx.setStorageSync('token', res.token);
|
||
return res.user;
|
||
}
|
||
|
||
async request<T>(method: string, path: string, data?: any): Promise<T> {
|
||
const token = this.token || wx.getStorageSync('token');
|
||
const res = await new Promise<any>((resolve, reject) => {
|
||
wx.request({
|
||
url: `${BASE_URL}${path}`,
|
||
method: method as any,
|
||
data,
|
||
header: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
success: (res) => resolve(res.data),
|
||
fail: reject,
|
||
});
|
||
});
|
||
|
||
if (res.code !== 0) {
|
||
throw new Error(res.message || '请求失败');
|
||
}
|
||
return res.data;
|
||
}
|
||
|
||
// ─── 业务方法(与云开发版保持同一接口)───
|
||
|
||
async getWorksheetList(
|
||
query: WorksheetQuery,
|
||
): Promise<PaginatedResult<Worksheet>> {
|
||
return this.request('GET', '/worksheets', query);
|
||
}
|
||
|
||
async getWorksheetDetail(id: string): Promise<Worksheet> {
|
||
return this.request('GET', `/worksheets/${id}`);
|
||
}
|
||
|
||
async addFavorite(worksheetId: string): Promise<void> {
|
||
await this.request('POST', '/favorites', { worksheetId });
|
||
}
|
||
|
||
async removeFavorite(worksheetId: string): Promise<void> {
|
||
await this.request('DELETE', `/favorites/${worksheetId}`);
|
||
}
|
||
|
||
async getFavorites(
|
||
page: number,
|
||
pageSize: number,
|
||
): Promise<PaginatedResult<Favorite>> {
|
||
return this.request('GET', '/favorites', { page, pageSize });
|
||
}
|
||
|
||
async reportDownload(worksheetId: string, params?: object): Promise<void> {
|
||
await this.request('POST', '/stats/download', { worksheetId, params });
|
||
}
|
||
|
||
async submitFeedback(content: string, contact?: string): Promise<void> {
|
||
await this.request('POST', '/feedback', { content, contact });
|
||
}
|
||
}
|
||
```
|
||
|
||
数据加载策略与原方案一致(缓存优先 + 后台更新),只是数据源从云数据库变为 HTTP API:
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────┐
|
||
│ 数据加载策略 │
|
||
│ │
|
||
│ 题型配置数据 (worksheets/categories) │
|
||
│ ┌────────────────────────────────────────┐ │
|
||
│ │ 优先级 1: 本地缓存(wx.Storage) │ │
|
||
│ │ 优先级 2: HTTP API 查询 │ │
|
||
│ │ 优先级 3: 前端内置兜底数据 │ ← 保证离线可用 │
|
||
│ └────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 缓存策略: │
|
||
│ • 首次启动:API 拉取 → 写入本地缓存 │
|
||
│ • 后续启动:先用缓存渲染 → 后台静默更新 │
|
||
│ • 缓存有效期:24 小时 │
|
||
│ • 无网络:使用本地缓存或内置兜底 │
|
||
└────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 七、静态资源 / 素材存储
|
||
|
||
不再使用微信云存储,改为服务器本地磁盘 + Nginx 直接提供静态文件服务。
|
||
|
||
```
|
||
服务器文件目录:
|
||
/data/doodle/
|
||
├── assets/
|
||
│ ├── previews/ ← 题型效果预览图
|
||
│ │ ├── math/
|
||
│ │ ├── chinese/
|
||
│ │ ├── english/
|
||
│ │ ├── puzzle/
|
||
│ │ └── craft/
|
||
│ ├── coloring/ ← 涂色卡线稿(SVG/PNG)
|
||
│ │ ├── animals/
|
||
│ │ ├── vehicles/
|
||
│ │ ├── holidays/
|
||
│ │ └── ...
|
||
│ ├── origami/ ← 折纸展开图
|
||
│ ├── stickers/ ← 贴纸素材
|
||
│ ├── maze-templates/ ← 迷宫模板数据(JSON)
|
||
│ └── craft-templates/ ← 手工模板
|
||
├── fonts/ ← 字体文件
|
||
│ ├── SimHei.ttf
|
||
│ └── handwriting.ttf
|
||
└── share/ ← 分享图
|
||
└── default-share.png
|
||
```
|
||
|
||
Nginx 配置中将 `/static/` 路径映射到此目录:
|
||
|
||
```nginx
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name api.your-domain.com;
|
||
|
||
ssl_certificate /etc/letsencrypt/live/api.your-domain.com/fullchain.pem;
|
||
ssl_certificate_key /etc/letsencrypt/live/api.your-domain.com/privkey.pem;
|
||
|
||
# 静态素材,长缓存 + Gzip
|
||
location /static/ {
|
||
alias /data/doodle/;
|
||
expires 30d;
|
||
add_header Cache-Control "public, immutable";
|
||
gzip on;
|
||
gzip_types image/svg+xml application/json;
|
||
}
|
||
|
||
# API 反向代理
|
||
location /api/ {
|
||
proxy_pass http://127.0.0.1:3000;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
# 请求限流(防刷)
|
||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||
location /api/ {
|
||
limit_req zone=api burst=50 nodelay;
|
||
# ... proxy_pass 同上
|
||
}
|
||
}
|
||
```
|
||
|
||
小程序中引用素材的 URL 从 `cloud://doodle-xxx/assets/...` 变为 `https://api.your-domain.com/static/assets/...`。
|
||
|
||
---
|
||
|
||
## 八、微信登录对接
|
||
|
||
自建后端需要自行对接微信小程序登录,但整体流程非常简单:
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ 微信登录流程 │
|
||
│ │
|
||
│ 小程序 后端 │
|
||
│ ────── ──── │
|
||
│ wx.login() → code │ │
|
||
│ │ │ │
|
||
│ └──── POST /auth/wx-login { code } ────→ │
|
||
│ │ │
|
||
│ ├── 调用微信 API ──→ │
|
||
│ │ POST https://api.weixin. │
|
||
│ │ qq.com/sns/jscode2session │
|
||
│ │ { appid, secret, code, │
|
||
│ │ grant_type } │
|
||
│ │ │
|
||
│ │ ←── { openid, session_key, │
|
||
│ │ unionid? } │
|
||
│ │ │
|
||
│ ├── 查找/创建 User 记录 │
|
||
│ ├── 签发 JWT │
|
||
│ │ │
|
||
│ ←── { token, user } ─────┘ │
|
||
│ │
|
||
│ wx.setStorageSync('token', token) │
|
||
│ 后续请求携带 Authorization: Bearer <token> │
|
||
│ │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
NestJS 实现要点:
|
||
|
||
```typescript
|
||
// auth/auth.service.ts 核心逻辑
|
||
@Injectable()
|
||
export class AuthService {
|
||
constructor(
|
||
private readonly prisma: PrismaService,
|
||
private readonly jwtService: JwtService,
|
||
private readonly httpService: HttpService,
|
||
) {}
|
||
|
||
async wxLogin(code: string) {
|
||
// 1. 调用微信 code2Session
|
||
const wxRes = await this.httpService.axiosRef.get(
|
||
'https://api.weixin.qq.com/sns/jscode2session',
|
||
{
|
||
params: {
|
||
appid: process.env.WX_APPID,
|
||
secret: process.env.WX_SECRET,
|
||
js_code: code,
|
||
grant_type: 'authorization_code',
|
||
},
|
||
},
|
||
);
|
||
|
||
const { openid, unionid } = wxRes.data;
|
||
if (!openid) throw new UnauthorizedException('微信登录失败');
|
||
|
||
// 2. 查找或创建用户
|
||
const user = await this.prisma.user.upsert({
|
||
where: { openid },
|
||
update: { lastActiveAt: new Date() },
|
||
create: { openid, unionid },
|
||
});
|
||
|
||
// 3. 签发 JWT
|
||
const token = this.jwtService.sign({
|
||
sub: user.id,
|
||
openid: user.openid,
|
||
});
|
||
|
||
return { token, user };
|
||
}
|
||
}
|
||
```
|
||
|
||
需要在微信公众平台配置**合法域名**:`api.your-domain.com`(在「开发管理 → 开发设置 → 服务器域名 → request 合法域名」中添加)。
|
||
|
||
---
|
||
|
||
## 九、部署方案
|
||
|
||
### 9.1 PM2 部署(推荐,简单直接)
|
||
|
||
```javascript
|
||
// ecosystem.config.js
|
||
module.exports = {
|
||
apps: [
|
||
{
|
||
name: 'doodle-api',
|
||
script: 'dist/main.js',
|
||
instances: 1, // 单实例即可,轻量应用
|
||
exec_mode: 'fork',
|
||
env: {
|
||
NODE_ENV: 'production',
|
||
PORT: 3000,
|
||
},
|
||
error_file: '/var/log/doodle/error.log',
|
||
out_file: '/var/log/doodle/out.log',
|
||
merge_logs: true,
|
||
max_memory_restart: '300M',
|
||
},
|
||
],
|
||
};
|
||
```
|
||
|
||
部署步骤:
|
||
|
||
```bash
|
||
# 1. 克隆代码
|
||
git clone <repo-url> /opt/doodle-server
|
||
cd /opt/doodle-server
|
||
|
||
# 2. 安装依赖
|
||
npm ci --production
|
||
|
||
# 3. 构建
|
||
npm run build
|
||
|
||
# 4. 初始化数据库
|
||
npx prisma migrate deploy
|
||
npx prisma db seed
|
||
|
||
# 5. 启动
|
||
pm2 start ecosystem.config.js
|
||
|
||
# 6. 设置开机自启
|
||
pm2 save
|
||
pm2 startup
|
||
```
|
||
|
||
### 9.2 Docker 部署(可选)
|
||
|
||
```yaml
|
||
# docker-compose.yml
|
||
version: '3.8'
|
||
|
||
services:
|
||
api:
|
||
build: .
|
||
ports:
|
||
- '3000:3000'
|
||
environment:
|
||
- DATABASE_URL=mysql://doodle:password@mysql:3306/doodle
|
||
- JWT_SECRET=${JWT_SECRET}
|
||
- WX_APPID=${WX_APPID}
|
||
- WX_SECRET=${WX_SECRET}
|
||
depends_on:
|
||
- mysql
|
||
volumes:
|
||
- ./data/assets:/data/doodle
|
||
restart: unless-stopped
|
||
|
||
mysql:
|
||
image: mysql:8.0
|
||
environment:
|
||
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
|
||
- MYSQL_DATABASE=doodle
|
||
- MYSQL_USER=doodle
|
||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||
volumes:
|
||
- mysql_data:/var/lib/mysql
|
||
restart: unless-stopped
|
||
|
||
volumes:
|
||
mysql_data:
|
||
```
|
||
|
||
### 9.3 环境变量
|
||
|
||
```env
|
||
# .env.example
|
||
NODE_ENV=production
|
||
PORT=3000
|
||
|
||
# 数据库
|
||
DATABASE_URL="mysql://doodle:your_password@localhost:3306/doodle"
|
||
|
||
# JWT
|
||
JWT_SECRET="your-jwt-secret-key"
|
||
JWT_EXPIRES_IN="7d"
|
||
|
||
# 微信小程序
|
||
WX_APPID="your-wx-appid"
|
||
WX_SECRET="your-wx-secret"
|
||
|
||
# 静态资源
|
||
STATIC_BASE_URL="https://api.your-domain.com/static"
|
||
ASSET_DIR="/data/doodle"
|
||
```
|
||
|
||
---
|
||
|
||
## 十、开发路线
|
||
|
||
```
|
||
Phase 1(与前端 Phase 1-2 同步,第 1-3 周)
|
||
├── NestJS 项目初始化(脚手架 + 基础配置)
|
||
├── Prisma Schema 定义 + 数据库迁移
|
||
├── 微信登录鉴权模块(wx-login + JWT)
|
||
├── 题型配置 CRUD API(worksheets / categories)
|
||
├── 数据初始化脚本(存量题型 JSON 导入)
|
||
├── Nginx 配置 + HTTPS 证书
|
||
└── PM2 部署上线
|
||
|
||
Phase 2(与前端 Phase 2-3 同步,第 4-6 周)
|
||
├── 收藏 / 下载历史 API
|
||
├── 统计模块(下载计数、热门排行)
|
||
├── 素材上传接口(管理后台用)
|
||
├── 学习路线数据 API
|
||
└── 前端 cloud-adapter 适配层完成
|
||
|
||
Phase 3(与前端 Phase 4 同步,第 7-8 周)
|
||
├── 反馈模块
|
||
├── 用户信息完善
|
||
├── API 性能优化(缓存策略)
|
||
└── 日志 / 监控完善
|
||
```
|
||
|
||
---
|
||
|
||
## 十一、成本对比
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ 成本对比 │
|
||
│ │
|
||
│ 微信云开发方案: │
|
||
│ ┌────────────────────────────────────────┐ │
|
||
│ │ 免费额度内:¥0 │ │
|
||
│ │ 超出后(DAU 3000+):¥19.9-99 元/月 │ │
|
||
│ │ 随用量增长持续增加 │ │
|
||
│ └────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 自有云服务器方案(NestJS): │
|
||
│ ┌────────────────────────────────────────┐ │
|
||
│ │ 服务器:¥0(已有,无增量成本) │ │
|
||
│ │ 域名 + SSL:¥0(Let's Encrypt 免费证书)│ │
|
||
│ │ MySQL:¥0(服务器上安装,资源占用很小) │ │
|
||
│ │ 总计新增成本:¥0 │ │
|
||
│ │ │ │
|
||
│ │ 唯一成本是初始开发时间(约 3-5 天搭建) │ │
|
||
│ └────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 结论:自建方案利用现有资源,长期零增量成本 ✅ │
|
||
│ │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 十二、技术风险与应对
|
||
|
||
| 风险 | 影响 | 应对方案 |
|
||
| ---------------- | ---------- | ------------------------------------------------------------------------------------ |
|
||
| 服务器宕机 | 后端不可用 | PM2 自动重启 + 前端兜底数据保证基本可用 |
|
||
| 数据库故障 | 数据丢失 | 定期备份(mysqldump cron)+ 前端本地缓存保证体验不中断 |
|
||
| 微信域名校验 | 请求被拦截 | 提前在公众平台配置合法域名,开发阶段可勾选「不校验」 |
|
||
| 服务器带宽不足 | 素材加载慢 | 图片压缩 + Nginx Gzip + 合理设置 Cache-Control + 远期可接入免费 CDN(如 Cloudflare) |
|
||
| JWT 安全性 | token 泄露 | 设置合理过期时间 + refresh token 机制 + HTTPS 传输 |
|
||
| 并发压力(远期) | 响应变慢 | 引入 Redis 缓存热点数据 + 数据库索引优化 + 按需水平扩展 |
|