MongoDB: 综合项目:电商评论系统(6 模块)
最后更新:2026-08-26
综合项目是检验学习成果的最佳方式——本课程通过 6 模块实现完整电商评论系统,串联 Phase 1-5 全部知识点。
1. 项目概览
项目:ShopHub 评论系统 架构:Node.js + Express + mongoose + MongoDB + JWT 功能:用户认证、商品管理、嵌套评论、聚合分析、权限控制、Atlas 部署
系统架构设计思路:电商评论系统采用经典三层架构——路由层(URL 映射 + 中间件)、业务层(Controller 处理请求逻辑)、数据层(mongoose Model 定义数据结构与验证)。三层分离使得每一层可以独立测试和修改:改 API 路径不动业务逻辑,改查询方式不动路由定义。
架构选型决策过程:
| 决策点 | 选项A | 选项B | 选择 | 原因 |
|---|---|---|---|---|
| Web 框架 | Express | Koa / Fastify | Express | 生态最成熟,教程资源多 |
| ODM | mongoose | 原生驱动 | mongoose | Schema 验证 + 中间件 + populate |
| 认证方案 | JWT | Session | JWT | 无状态,易扩展,适合 API |
| 评论结构 | 嵌套文档 | 引用模式 | 引用模式(parentId) | 灵活深度,避免嵌套限制 |
| 部署平台 | Atlas + Render | 自建 | Atlas + Render | 托管省运维,适合中小项目 |
数据建模决策:评论系统选择引用模式(parentId 指向父评论)而非嵌套文档——嵌套文档有 BSON 16MB 限制,且深层回复难以查询和分页。引用模式虽然需要额外查询组装树形结构,但支持无限层级和灵活排序。
graph TB
Client[浏览器/移动 App] -->|HTTP| Express[Express 服务器]
subgraph "Express 路由层"
Express --> AuthMW[auth 路由<br/>注册/登录]
Express --> ProductMW[products 路由<br/>CRUD]
Express --> ReviewMW[reviews 路由<br/>评论/点赞]
end
subgraph "Controller 层"
AuthMW --> AuthCtrl[authController]
ProductMW --> ProdCtrl[productController]
ReviewMW --> RevCtrl[reviewController]
end
subgraph "Model 层"
AuthCtrl --> UserModel[User Model]
ProdCtrl --> ProdModel[Product Model]
RevCtrl --> RevModel[Review Model]
end
subgraph "MongoDB 集合"
UserModel --> Users[(users)]
ProdModel --> Products[(products)]
RevModel --> Reviews[(reviews)]
end
style Express fill:#d4edda
style Reviews fill:#cce5ff
数据模型关系:
erDiagram
USER ||--o{ REVIEW : "writes"
PRODUCT ||--o{ REVIEW : "has"
REVIEW ||--o{ REVIEW : "parent reply"
USER {
ObjectId _id PK
string email UK
string username UK
string passwordHash
string role
boolean isActive
}
PRODUCT {
ObjectId _id PK
string sku UK
string title
number price
string category
number rating
number reviewCount
}
REVIEW {
ObjectId _id PK
ObjectId productId FK
ObjectId userId FK
string content
number rating
ObjectId parentId FK
number likeCount
boolean isApproved
}
2. 模块 1:项目初始化
架构选型决策过程详解:电商评论系统的技术选型不是"哪个流行选哪个",而是基于约束条件的最优解——1. 选 Express 而非 NestJS:项目规模中小型,NestJS 的装饰器和 DI 增加复杂度但不增加收益;2. 选 mongoose 而非原生驱动:Schema 验证和中间件机制对评论系统至关重要(密码哈希、软删除过滤);3. 选 JWT 而非 Session:API 服务无状态需求,Session 需 Redis 共享存储增加运维成本;4. 选引用模式而非嵌套文档:评论数量不可预测,嵌套文档有 16MB 限制风险。
模块交互原理:六个模块的依赖关系形成清晰的层次——模块 1(初始化)提供基础设施,模块 2(模型)定义数据契约,模块 3(CRUD)实现业务逻辑,模块 4(聚合)添加分析能力,模块 5(安全)加固防护,模块 6(部署)完成上线。每个模块只依赖前序模块,不反向依赖,这使得开发可以迭代推进——先完成模块 1-3 获得可用 API,再逐步添加聚合、安全和部署。
项目初始化的设计思路:项目初始化不仅是"npm init",更是确定项目结构、依赖选择、配置管理策略的过程。良好的项目结构应该反映 MVC 分层:models/ 放数据定义、controllers/ 放业务逻辑、routes/ 放路由映射、middlewares/ 放横切关注点(认证、错误处理)。
目录结构设计原则:
| 目录 | 职责 | 依赖方向 | 测试策略 |
|---|---|---|---|
| models/ | 数据定义 + 验证 | 无外部依赖 | 单元测试 |
| controllers/ | 请求处理 + 协调 | 依赖 models | 集成测试 |
| routes/ | URL → Controller 映射 | 依赖 controllers + middlewares | 路由测试 |
| middlewares/ | 认证/验证/错误处理 | 依赖 config | 单元测试 |
| validators/ | joi Schema 定义 | 无外部依赖 | 单元测试 |
| utils/ | 工具函数 | 无外部依赖 | 单元测试 |
依赖选择理由:
| 依赖 | 用途 | 为什么选它 |
|---|---|---|
| express | Web 框架 | 最成熟,中间件生态丰富 |
| mongoose | ODM | Schema 验证 + populate + 中间件 |
| jsonwebtoken | JWT 认证 | 无状态认证,适合 API |
| bcrypt | 密码哈希 | 行业标准,抗彩虹表 |
| joi | 输入验证 | Schema 风格,可复用 |
| dotenv | 环境变量 | 12-factor app 规范 |
| cors | 跨域 | API 必备 |
(1) 项目结构
shophub-reviews/
├── package.json
├── .env
├── .env.example
├── src/
│ ├── app.js # Express 应用
│ ├── config/
│ │ └── db.js # mongoose 连接
│ ├── models/ # 数据模型
│ │ ├── User.js
│ │ ├── Product.js
│ │ └── Review.js
│ ├── controllers/ # 业务逻辑
│ │ ├── authController.js
│ │ ├── productController.js
│ │ └── reviewController.js
│ ├── routes/ # 路由
│ │ ├── auth.js
│ │ ├── products.js
│ │ └── reviews.js
│ ├── middlewares/ # 中间件
│ │ ├── auth.js # JWT 认证
│ │ ├── errorHandler.js
│ │ └── validate.js
│ ├── validators/ # 请求体验证
│ │ └── schemas.js
│ └── utils/ # 工具
│ ├── logger.js
│ └── jwt.js
└── README.md
(2) package.json
{
"name": "shophub-reviews",
"version": "1.0.0",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest --watch"
},
"dependencies": {
"express": "^4.19.2",
"mongoose": "^7.6.0",
"jsonwebtoken": "^9.0.0",
"bcrypt": "^5.1.0",
"joi": "^17.13.0",
"dotenv": "^16.4.0",
"cors": "^2.8.5"
},
"devDependencies": {
"nodemon": "^3.1.0",
"jest": "^29.7.0"
}
}
(3) .env 文件
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/shophub
JWT_SECRET=your-super-secret-key-change-in-prod
JWT_EXPIRES_IN=7d
3. 模块 2:用户与商品模型
数据模型设计原则:Schema 设计不是"把表单字段搬到 mongoose",而是要考虑:查询模式(最常用的查询是什么?)、数据关系(哪些实体需要关联?)、性能需求(是否需要索引?字段是否需要 select:false?)、安全需求(密码是否需要隐藏?是否需要软删除?)。
Schema 设计决策:
| 设计决策 | 选择 | 原因 |
|---|---|---|
| 密码存储 | passwordHash + select:false | 默认查询不返回,防止泄露 |
| 密码哈希 | pre-save 中间件 + bcrypt | 自动哈希,业务代码无感知 |
| 角色设计 | enum + RBAC | 三角色:customer/admin/moderator |
| 商品评分 | 冗余字段 rating + reviewCount | 避免每次聚合计算 |
| 评论结构 | parentId 引用 | 支持无限嵌套层级 |
| 软删除 | isDeleted + pre-find 过滤 | 数据可恢复,合规需求 |
| 时间戳 | timestamps: true | createdAt/updatedAt 自动管理 |
模型关系与引用方向:Review 引用 User 和 Product(多对一),Review 引用 Review 自身(自引用实现嵌套)。引用方向是"多的一方引用一的一方"——不在 Product 中嵌入 Review 数组(会无限增长),而是 Review 中存 productId。
graph LR
User1[Alice] -->|userId| R1[Review: "Great!"]
User2[Bob] -->|userId| R2[Review: "Good"]
User3[Charlie] -->|userId| R3[Reply: "Thanks!"]
Prod1[Product: Phone] -->|productId| R1
Prod1 -->|productId| R2
R1 -->|parentId| R3
style User1 fill:#cce5ff
style Prod1 fill:#d4edda
style R1 fill:#fff3cd
(1) User 模型(含密码哈希中间件)
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const UserSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Invalid email format']
},
username: {
type: String,
required: true,
unique: true,
minlength: 3,
maxlength: 30,
match: [/^[a-zA-Z0-9_]+$/, 'Alphanumeric + underscore only']
},
passwordHash: {
type: String,
required: true,
minlength: 60, // bcrypt hash 长度
select: false
},
role: {
type: String,
enum: ['customer', 'admin', 'moderator'],
default: 'customer'
},
isActive: { type: Boolean, default: true }
}, { timestamps: true });
// 密码哈希中间件
UserSchema.pre('save', async function(next) {
if (!this.isModified('passwordHash')) return next();
this.passwordHash = await bcrypt.hash(this.passwordHash, 10);
next();
});
// 验证密码
UserSchema.methods.comparePassword = function(candidate) {
return bcrypt.compare(candidate, this.passwordHash);
};
module.exports = mongoose.model('User', UserSchema);
(2) Product 模型
// models/Product.js
const ProductSchema = new mongoose.Schema({
sku: { type: String, required: true, unique: true, index: true },
title: { type: String, required: true, maxlength: 200 },
description: { type: String, maxlength: 5000 },
price: { type: mongoose.Schema.Types.Decimal128, required: true, min: 0 },
category: { type: String, enum: ['Electronics', 'Books', 'Clothing', 'Home'], index: true },
stock: { type: Number, default: 0, min: 0 },
rating: { type: Number, default: 0, min: 0, max: 5 },
reviewCount: { type: Number, default: 0 },
isActive: { type: Boolean, default: true, index: true }
}, { timestamps: true });
ProductSchema.index({ category: 1, price: -1 });
ProductSchema.index({ title: 'text', description: 'text' });
module.exports = mongoose.model('Product', ProductSchema);
(3) Review 模型(含嵌套回复)
// models/Review.js
const ReviewSchema = new mongoose.Schema({
productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product', required: true, index: true },
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
content: { type: String, required: true, maxlength: 1000 },
rating: { type: Number, required: true, min: 1, max: 5 },
parentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Review', default: null, index: true },
likes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
likeCount: { type: Number, default: 0 },
isApproved: { type: Boolean, default: true },
isDeleted: { type: Boolean, default: false }
}, { timestamps: true });
ReviewSchema.index({ productId: 1, createdAt: -1 });
ReviewSchema.pre(/^find/, function(next) {
this.where({ isDeleted: { $ne: true } });
next();
});
module.exports = mongoose.model('Review', ReviewSchema);
4. 模块 3:评论 CRUD API(嵌套评论)
嵌套评论设计原理补充:引用模式(parentId)的查询策略需要权衡"查询次数"和"数据完整性"——两次查询法(顶层评论 + 回复)只需 2 次数据库 I/O,但只支持两级嵌套;递归查询法($graphLookup)支持无限层级但性能差。本系统选择两次查询法 + 内存组装,因为:1. 大多数用户只看两层评论(顶层 + 回复);2. 更深层回复可通过"查看更多回复"按需加载;3. 两次查询的性能远优于递归查询。
评论安全策略设计:评论系统面临三类安全威胁——1. 内容安全:垃圾评论、敏感词、广告链接(防护:频率限制 + 敏感词过滤 + 人工审核);2. 权限安全:非作者编辑/删除他人评论(防护:authenticate + userId 匹配);3. 注入安全:$where 注入、$regex DoS(防护:参数化查询 + 正则转义)。安全措施应在中间件层统一实现,而非每个 Controller 重复编写。
**嵌套评论查询策略**:
```mermaid
sequenceDiagram
participant Client
participant API as /products/:id/reviews
participant DB as MongoDB
Client->>API: GET /products/123/reviews
API->>DB: 查询顶层评论 (parentId=null)
DB-->>API: 返回 [R1, R2, R3]
API->>DB: 查询所有回复 (parentId in [R1,R2,R3]._id)
DB-->>API: 返回 [R1.1, R1.2, R2.1]
API->>API: 组装树形结构<br/>R1.replies=[R1.1, R1.2]<br/>R2.replies=[R2.1]
API-->>Client: 返回树形数据
评论 CRUD API 设计:
| 操作 | 端点 | 方法 | 认证 | 业务规则 |
|---|---|---|---|---|
| 查看评论 | /products/:id/reviews | GET | 否 | 分页 + 嵌套回复 |
| 发表评论 | /products/:id/reviews | POST | 是 | 验证商品存在 + 评分1-5 |
| 发表回复 | /products/:id/reviews | POST | 是 | 验证父评论存在 |
| 编辑评论 | /reviews/:id | PUT | 是(作者) | 仅作者可编辑 |
| 删除评论 | /reviews/:id | DELETE | 是(作者/admin) | 软删除 isDeleted |
| 点赞/取消 | /reviews/:id/like | POST | 是 | $addToSet/$pull 切换 |
点赞设计:使用 $addToSet(幂等添加)+ $pull(移除)而非 push(可能重复)。同时维护 likeCount 冗余字段避免每次 count(likes)。
(1) 创建评论
// controllers/reviewController.js
exports.createReview = async (req, res) => {
const { productId } = req.params;
const { content, rating, parentId } = req.body;
// 验证商品存在
const product = await Product.findById(productId);
if (!product) return res.status(404).json({ error: 'Product not found' });
// 如果是回复,验证父评论存在
if (parentId) {
const parent = await Review.findById(parentId);
if (!parent) return res.status(404).json({ error: 'Parent review not found' });
}
const review = await Review.create({
productId,
userId: req.user._id,
content,
rating,
parentId: parentId || null
});
// 更新商品评分统计
await updateProductRating(productId);
await review.populate('userId', 'username avatar');
res.status(201).json(review);
};
(2) 嵌套查询
exports.listReviews = async (req, res) => {
const { productId } = req.params;
const { sort = 'createdAt', order = 'desc', limit = 20, page = 1 } = req.query;
// 查询顶层评论
const reviews = await Review.find({
productId,
parentId: null
})
.populate('userId', 'username avatar')
.sort({ [sort]: order === 'desc' ? -1 : 1 })
.limit(limit * 1)
.skip((page - 1) * limit)
.lean();
// 查询所有回复
const reviewIds = reviews.map(r => r._id);
const replies = await Review.find({
parentId: { $in: reviewIds }
})
.populate('userId', 'username avatar')
.sort({ createdAt: 1 })
.lean();
// 组装树形结构
const tree = reviews.map(parent => ({
...parent,
replies: replies.filter(r => r.parentId.toString() === parent._id.toString())
}));
res.json({ data: tree, page, limit });
};
(3) 点赞功能
exports.likeReview = async (req, res) => {
const { reviewId } = req.params;
const userId = req.user._id;
const review = await Review.findById(reviewId);
if (!review) return res.status(404).json({ error: 'Review not found' });
const alreadyLiked = review.likes.some(id => id.toString() === userId.toString());
if (alreadyLiked) {
await Review.updateOne(
{ _id: reviewId },
{ $pull: { likes: userId }, $inc: { likeCount: -1 } }
);
return res.json({ liked: false });
} else {
await Review.updateOne(
{ _id: reviewId },
{ $addToSet: { likes: userId }, $inc: { likeCount: 1 } }
);
return res.json({ liked: true });
}
};
5. 模块 4:聚合分析
聚合管道设计模式详解:评论系统的聚合需求分为三类——1. 单维度统计(评分分布、评论总数):单管道 $match + $group/$bucket 即可;2. 多维度并行统计(商品详情页的总览+分布+近期评论):必须用 $facet 一次查询返回;3. 跨集合关联统计(热门商品榜含商品信息):$group + $lookup + $unwind 组合。设计原则:先 $match 过滤减少数据量,再 $group 聚合,最后 $sort/$limit 排序限制。
$facet 子管道设计策略:$facet 的每个子管道应尽量精简——1. 总数子管道只需 $count,开销最小;2. 平均值子管道用 $group({ _id: null }),单条输出;3. 分布子管道用 $group({ _id: '$rating' }),输出条数等于值域大小;4. 列表子管道需要 $sort + $limit + $lookup,开销最大应放最后。子管道顺序不影响执行(并行),但影响代码可读性——建议按开销从小到大排列。
**聚合管道设计模式**:
| 统计需求 | 聚合阶段 | 输出 |
|:---------|:---------|:-----|
| 评分分布 | $match → $bucket | [{_id: 5, count: 120}, ...] |
| 商品统计 | $match → $facet | {total, avgRating, distribution, recent} |
| 热门商品 | $match → $group → $sort → $lookup → $limit | [{productId, avgRating, reviewCount}] |
**$facet 的价值**:$facet 允许在同一输入上并行执行多个聚合管道——一次查询返回 total、avgRating、ratingDistribution、recentReviews 四个维度。如果没有 $facet,需要 4 次独立查询。
```mermaid
graph TB
Input[评论数据流] --> Facet["$facet<br/>多管道并行"]
Facet --> P1["管道1: $count<br/>总数"]
Facet --> P2["管道2: $group<br/>平均评分"]
Facet --> P3["管道3: $group + $sort<br/>评分分布"]
Facet --> P4["管道4: $sort + $limit + $lookup<br/>近期评论(含用户信息)"]
P1 --> Output[合并输出<br/>{total, avg, distribution, recent}]
P2 --> Output
P3 --> Output
P4 --> Output
style Facet fill:#d4edda
style Output fill:#cce5ff
$bucket 评分分布原理:$bucket 按 boundaries 将评分分到桶中——boundaries=[1,2,3,4,5,6] 表示 5 个桶:[1,2)、[2,3)、[3,4)、[4,5)、[5,6)。每个桶统计数量和平均值。
(1) 评分分布($bucket)
exports.getRatingDistribution = async (req, res) => {
const { productId } = req.params;
const distribution = await Review.aggregate([
{ $match: { productId: new mongoose.Types.ObjectId(productId), parentId: null } },
{
$bucket: {
groupBy: '$rating',
boundaries: [1, 2, 3, 4, 5, 6],
default: 'Other',
output: {
count: { $sum: 1 },
avgHelpful: { $avg: '$likeCount' }
}
}
}
]);
res.json({ data: distribution });
};
(2) 商品统计($facet)
exports.getProductStats = async (req, res) => {
const { productId } = req.params;
const stats = await Review.aggregate([
{ $match: { productId: new mongoose.Types.ObjectId(productId), parentId: null } },
{
$facet: {
total: [{ $count: 'count' }],
avgRating: [{ $group: { _id: null, avg: { $avg: '$rating' } } }],
ratingDistribution: [
{ $group: { _id: '$rating', count: { $sum: 1 } } },
{ $sort: { _id: 1 } }
],
recentReviews: [
{ $sort: { createdAt: -1 } },
{ $limit: 5 },
{
$lookup: {
from: 'users',
localField: 'userId',
foreignField: '_id',
as: 'userInfo'
}
},
{ $unwind: '$userInfo' },
{
$project: {
content: 1,
rating: 1,
createdAt: 1,
username: '$userInfo.username',
avatar: '$userInfo.avatar'
}
}
]
}
}
]);
res.json(stats[0]);
};
(3) 热门商品榜
exports.getTopProducts = async (req, res) => {
const { limit = 10 } = req.query;
const topProducts = await Review.aggregate([
{ $match: { parentId: null, isApproved: true } },
{
$group: {
_id: '$productId',
avgRating: { $avg: '$rating' },
reviewCount: { $sum: 1 },
totalLikes: { $sum: '$likeCount' }
}
},
{ $sort: { avgRating: -1, reviewCount: -1 } },
{ $limit: limit * 1 },
{
$lookup: {
from: 'products',
localField: '_id',
foreignField: '_id',
as: 'product'
}
},
{ $unwind: '$product' },
{
$project: {
sku: '$product.sku',
title: '$product.title',
thumbnail: '$product.thumbnail',
avgRating: 1,
reviewCount: 1
}
}
]);
res.json({ data: topProducts });
};
6. 模块 5:权限与安全
纵深防御原则详解:API 安全不是单点防护而是多层防线——网络层(HTTPS + CORS)防止窃听和跨域滥用;应用层(JWT 认证 + RBAC 授权 + 输入验证)拦截未授权和恶意请求;数据层(参数化查询 + 最小权限数据库用户)防止注入和越权。每层独立防御的意义:任一层被突破不影响其他层的保护——即使 CORS 配置错误,JWT 认证仍能拦截未登录用户;即使 JWT 泄露,RBAC 仍能限制低权限用户的操作范围。
JWT Token 安全最佳实践:1. 密钥强度:用 openssl rand -hex 32 生成 256 位随机密钥;2. 过期时间:7 天(平衡安全与体验,敏感操作可缩短);3. Token 刷新:access token 短过期 + refresh token 长过期(双 Token 机制);4. Token 存储:前端用 httpOnly cookie(防 XSS)而非 localStorage;5. Token 撤销:维护黑名单(Redis SET)或使用短过期时间减少撤销需求。
**JWT 认证 vs Session 认证**:
| 维度 | JWT | Session |
|:-----|:----|:--------|
| 存储位置 | 客户端(Token) | 服务端(内存/Redis) |
| 扩展性 | 无状态,天然支持分布式 | 需共享 Session 存储 |
| 安全性 | Token 泄露无法即时撤销 | 可即时销毁 Session |
| 性能 | 无服务端查询开销 | 每次查 Session 存储 |
| 过期管理 | exp 声明,无法主动续期 | 可滑动续期 |
| 适用场景 | API / 微服务 | 传统 Web 应用 |
**RBAC 权限模型**:Role-Based Access Control 基于角色分配权限——用户拥有角色,角色拥有权限。本系统三个角色:
| 角色 | 权限 | 操作范围 |
|:-----|:-----|:---------|
| customer | 评论、点赞、编辑自己的评论 | /reviews(自己的) |
| moderator | 审核/删除任意评论 | /reviews(所有) + 审核后台 |
| admin | 管理商品、用户、所有评论 | /products + /users + /reviews(所有) |
**注入防护要点**:MongoDB 注入风险主要来自 $where(执行任意 JS)和无锚定 $regex(DoS 攻击)。防护原则:① 永远用参数化查询而非字符串拼接;② mongoose 自动转义查询值;③ $regex 需转义特殊字符。
```mermaid
sequenceDiagram
participant Client
participant Auth as authenticate 中间件
participant Authorize as authorize 中间件
participant Controller
participant DB
Client->>Auth: 请求 + Bearer Token
Auth->>Auth: jwt.verify(token, secret)
alt Token 无效
Auth-->>Client: 401 Unauthorized
else Token 有效
Auth->>Authorize: req.user = {id, role}
Authorize->>Authorize: roles.includes(req.user.role)?
alt 无权限
Authorize-->>Client: 403 Forbidden
else 有权限
Authorize->>Controller: 执行业务逻辑
Controller->>DB: 参数化查询
DB-->>Client: 200 OK
end
end
(1) JWT 中间件
// middlewares/auth.js
const jwt = require('jsonwebtoken');
exports.authenticate = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'No token' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
};
exports.authorize = (...roles) => (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
(2) 认证 API
// controllers/authController.js
const User = require('../models/User');
const jwt = require('jsonwebtoken');
exports.register = async (req, res) => {
const { email, username, password } = req.body;
const existing = await User.findOne({ $or: [{ email }, { username }] });
if (existing) return res.status(409).json({ error: 'Email or username already exists' });
const user = await User.create({
email,
username,
passwordHash: password // pre-save 中间件会哈希
});
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.status(201).json({ user: { id: user._id, email: user.email, username: user.username }, token });
};
exports.login = async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+passwordHash');
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const valid = await user.comparePassword(password);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign(
{ id: user._id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({ user: { id: user._id, email: user.email, username: user.username, role: user.role }, token });
};
(3) 注入防护
// ❌ 危险:$where 执行任意 JS
db.reviews.find({ $where: 'this.userId == "' + userId + '"' });
// ✅ 安全:使用参数化查询
db.reviews.find({ userId: new ObjectId(userId) });
// ✅ mongoose 自动转义
const reviews = await Review.find({ userId: userId });
// ❌ 危险:$regex DoS
db.reviews.find({ content: { $regex: req.query.q } });
// ✅ 安全:转义正则特殊字符
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
db.reviews.find({ content: { $regex: escapeRegex(req.query.q), $options: 'i' } });
7. 模块 6:部署上线
部署架构选型决策:部署方案的选择基于项目规模和团队能力——1. Atlas + Render(本项目选择):零运维启动,适合中小项目和 MVP 阶段,月成本 < $50;2. 自建 MongoDB + Docker:完全可控但需运维能力,适合有 DevOps 团队的中大型项目;3. Kubernetes + Atlas:弹性伸缩 + 托管数据库,适合流量波动大的生产系统。本项目选 Atlas + Render 的核心理由:把有限精力投入业务开发而非运维。
健康检查与自动恢复:生产部署的健康检查不仅是 /healthz 返回 200——它需要检测三层健康:1. 应用层(Express 是否响应);2. 数据库层(MongoDB 是否可达);3. 依赖层(Redis/ES 等是否正常)。健康检查端点被负载均衡器定期调用,连续失败 N 次后自动摘除实例并重启。数据库断连时应返回 503(Service Unavailable)而非 200,避免流量打到不健康实例。
**部署架构**:
```mermaid
graph LR
Client[用户浏览器] -->|HTTPS| CDN[CDN / 静态资源]
Client -->|HTTPS| LB[Load Balancer]
LB -->|HTTP| App1[Render Instance 1<br/>Express App]
LB -->|HTTP| App2[Render Instance 2<br/>Express App]
App1 -->|TLS + SRV| Atlas[MongoDB Atlas<br/>3 节点副本集]
App2 -->|TLS + SRV| Atlas
Atlas -->| PITR | Backup[自动备份<br/>7天保留]
style Atlas fill:#d4edda
style App1 fill:#cce5ff
环境配置策略:
| 环境变量 | 开发值 | 生产值 | 管理方式 |
|---|---|---|---|
| NODE_ENV | development | production | .env / 平台设置 |
| MONGODB_URI | mongodb://localhost:27017 | mongodb+srv://... | 平台密钥管理 |
| JWT_SECRET | test-secret | 256bit 随机串 | openssl rand -hex 32 |
| CORS_ORIGIN | * | https://shophub.example.com | .env / 平台设置 |
| PORT | 3000 | 平台分配 | 默认即可 |
健康检查设计:/healthz 端点不仅检查 Express 是否在运行,还要检查 MongoDB 是否可达——如果数据库断连,应用应返回 503(Service Unavailable)而非 200,让负载均衡器自动摘除不健康实例。
(1) MongoDB Atlas 配置
# 1. 创建 Atlas 集群(参考课程 #01)
# 2. 配置 IP 白名单:0.0.0.0/0(开发)或应用服务器 IP
# 3. 创建数据库用户:app_user / <password>
# 4. 获取连接字符串:
mongodb+srv://app_user:<password>@cluster0.mongodb.net/shophub?retryWrites=true&w=majority
(2) 环境变量(生产)
# .env.production
NODE_ENV=production
PORT=3000
MONGODB_URI=mongodb+srv://app_user:StrongPass@cluster0.mongodb.net/shophub?retryWrites=true&w=majority
JWT_SECRET=<generated-strong-secret-256-bit>
JWT_EXPIRES_IN=7d
CORS_ORIGIN=https://shophub.example.com
(3) 健康检查
app.get('/healthz', async (req, res) => {
try {
const db = mongoose.connection.db;
await db.admin().command({ ping: 1 });
res.json({
status: 'ok',
uptime: process.uptime(),
mongo: 'connected',
timestamp: new Date().toISOString()
});
} catch (err) {
res.status(503).json({ status: 'error', error: err.message });
}
});
(4) Render / Railway 部署
# === Render 部署 ===
# 1. 连接 GitHub 仓库
# 2. 设置环境变量(MONGODB_URI, JWT_SECRET 等)
# 3. 设置构建命令:npm install
# 4. 设置启动命令:npm start
# 5. 自动 HTTPS + 部署
# === Railway 部署 ===
railway login
railway init
railway add mongodb # 一键添加 MongoDB
railway up
(5) 性能优化 checklist
// === src/app.js 优化版 ===
const mongoose = require('mongoose');
mongoose.connect(process.env.MONGODB_URI, {
maxPoolSize: 50,
minPoolSize: 5,
serverSelectionTimeoutMS: 5000
});
app.use(express.json({ limit: '1mb' }));
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true
}));
8. 完整项目演示
项目启动与测试流程:完整项目演示遵循"环境准备→启动服务→测试API→验证功能→部署上线"的流程。先启动 MongoDB 副本集(事务和 Change Streams 需要副本集),再启动 Express 应用,最后用 curl 测试核心 API 端点。
API 测试清单:
| # | 功能 | 端点 | 预期状态码 |
|---|---|---|---|
| 1 | 注册 | POST /api/auth/register | 201 |
| 2 | 登录 | POST /api/auth/login | 200 |
| 3 | 创建商品 | POST /api/products | 201 (admin) |
| 4 | 商品列表 | GET /api/products | 200 |
| 5 | 发表评论 | POST /api/products/:id/reviews | 201 |
| 6 | 评论列表 | GET /api/products/:id/reviews | 200 |
| 7 | 点赞 | POST /api/reviews/:id/like | 200 |
| 8 | 商品统计 | GET /api/products/:id/stats | 200 |
| 9 | 健康检查 | GET /healthz | 200 |
# === 启动项目 ===
npm install
npm start
# === API 调用示例 ===
# 1. 注册用户
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","username":"alice","password":"Pass123!"}'
# 2. 登录
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"Pass123!"}'
# 3. 创建评论
curl -X POST http://localhost:3000/api/products/<productId>/reviews \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"content":"Great product!","rating":5}'
# 4. 查看商品统计
curl http://localhost:3000/api/products/<productId>/stats
# 5. 健康检查
curl http://localhost:3000/healthz
▶ 示例 1:嵌套评论 + 点赞功能演示
// === 场景:Alice 评论商品,Bob 回复 Alice,Charlie 点赞 ===
// 1. Alice 发表 5 星评论
const aliceReview = await Review.create({
productId: '647f1f77bcf86cd799439011',
userId: aliceId,
content: 'Excellent smartphone! The camera quality is outstanding.',
rating: 5,
parentId: null // 顶层评论
});
// 返回: { _id: 'review001', content: '...', rating: 5, likeCount: 0 }
// 2. Bob 回复 Alice
const bobReply = await Review.create({
productId: '647f1f77bcf86cd799439011',
userId: bobId,
content: 'I agree, the camera is amazing!',
rating: 5,
parentId: aliceReview._id // 引用父评论
});
// 返回: { _id: 'review002', parentId: 'review001', content: '...' }
// 3. Charlie 点赞 Alice 的评论
await Review.updateOne(
{ _id: aliceReview._id },
{ $addToSet: { likes: charlieId }, $inc: { likeCount: 1 } }
);
// Alice 的评论: { likeCount: 1, likes: [charlieId] }
// 4. 再次点赞 → 取消
await Review.updateOne(
{ _id: aliceReview._id },
{ $pull: { likes: charlieId }, $inc: { likeCount: -1 } }
);
// Alice 的评论: { likeCount: 0, likes: [] }
// 5. 查询嵌套评论树
const reviews = await Review.find({ productId: '647f...', parentId: null })
.populate('userId', 'username avatar')
.lean();
const replies = await Review.find({ parentId: { $in: reviews.map(r => r._id) } })
.populate('userId', 'username avatar')
.lean();
const tree = reviews.map(r => ({
...r,
replies: replies.filter(rep => rep.parentId.toString() === r._id.toString())
}));
console.log(JSON.stringify(tree, null, 2));
// [{
// content: 'Excellent smartphone!...',
// userId: { username: 'alice', avatar: '...' },
// replies: [{ content: 'I agree...', userId: { username: 'bob' } }]
// }]
输出:嵌套评论树形结构,Alice 评论 → Bob 回复,Charlie 点赞/取消点赞。
▶ 示例 2:完整 ShopHub 评论系统运行演示
# === 1. 启动 MongoDB 副本集 ===
docker run -d --name mongo -p 27017:27017 mongo:7.0 --replSet rs0
docker exec mongo mongosh --eval 'rs.initiate()'
# === 2. 启动 Node.js 应用 ===
npm install
npm start
# 输出:
# ✅ MongoDB connected: localhost
# 🚀 Server running on port 3000
# === 3. 测试核心 API ===
# 3.1 用户注册
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","username":"alice","password":"Pass123!"}'
# 返回:
# {
# "success": true,
# "user": { "id": "...", "email": "alice@example.com", "username": "alice" },
# "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# }
# 3.2 用户登录
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"Pass123!"}'
# 3.3 创建商品
curl -X POST http://localhost:3000/api/products \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"sku": "PHONE-001",
"title": "Smartphone X",
"price": 599,
"category": "Electronics",
"stock": 50
}'
# 3.4 商品列表(带分页 + 投影)
curl 'http://localhost:3000/api/products?page=1&limit=20&category=Electronics'
# 返回:
# {
# "success": true,
# "data": [{ "sku": "PHONE-001", "title": "Smartphone X", "price": 599, "thumbnail": "...", "rating": 0 }],
# "meta": { "page": 1, "limit": 20, "total": 1, "pages": 1 }
# }
# 3.5 添加评论
curl -X POST http://localhost:3000/api/products/507f1f77bcf86cd799439021/reviews \
-H "Authorization: Bearer <user_token>" \
-H "Content-Type: application/json" \
-d '{"content":"Great phone!","rating":5}'
# 3.6 点赞评论
curl -X POST http://localhost:3000/api/reviews/507f1f77bcf86cd799439031/like \
-H "Authorization: Bearer <user_token>"
# 3.7 查看商品统计
curl http://localhost:3000/api/products/507f1f77bcf86cd799439021/stats
# 返回:
# {
# "total": [{ "count": 1 }],
# "avgRating": [{ "avg": 5 }],
# "ratingDistribution": [{ "_id": 5, "count": 1 }],
# "recentReviews": [{ "content": "Great phone!", "rating": 5, "username": "alice" }]
# }
# 3.8 健康检查
curl http://localhost:3000/healthz
# 返回:
# {
# "status": "ok",
# "uptime": 1234,
# "mongo": "connected",
# "timestamp": "2026-07-06T10:00:00.000Z"
# }
# === 4. 部署到 Render/Railway ===
git push heroku main
# 自动部署,环境变量 MONGODB_URI 指向 Atlas
# === 5. 性能监控(生产)===
# Datadog APM 自动追踪:
# - mongoose 查询耗时
# - API 响应时间
# - 数据库连接数
# - 慢查询告警
输出:完整电商评论系统从注册、登录、商品管理、评论 CRUD、点赞、统计到健康检查全流程演示,可直接部署到生产环境。
▶ 示例 3:生产部署检查清单 + 监控告警配置
项目开发完成只是第一步——上线前需要逐项检查安全、性能、可靠性,上线后需要监控关键指标并配置告警。本示例提供完整的生产部署检查清单和监控告警配置,确保评论系统稳定运行。
// === 1. 生产部署检查清单(启动时自动验证)===
async function productionReadinessCheck() {
const checks = [];
// 环境变量
const requiredEnvVars = ['MONGODB_URI', 'JWT_SECRET', 'NODE_ENV', 'PORT'];
requiredEnvVars.forEach(v => {
checks.push({
item: `环境变量 ${v}`,
status: process.env[v] ? 'PASS' : 'FAIL',
detail: process.env[v] ? '已设置' : '未设置'
});
});
checks.push({
item: 'NODE_ENV=production',
status: process.env.NODE_ENV === 'production' ? 'PASS' : 'WARN',
detail: `当前值: ${process.env.NODE_ENV || '未设置'}`
});
// 数据库连接
try {
const adminStatus = await mongoose.connection.db.adminCommand({ ping: 1 });
checks.push({ item: '数据库连接', status: 'PASS', detail: '连接正常' });
const serverStatus = await mongoose.connection.db.adminCommand({ serverStatus: 1 });
checks.push({
item: '连接池使用率',
status: serverStatus.connections.current < serverStatus.connections.available * 0.8 ? 'PASS' : 'WARN',
detail: `${serverStatus.connections.current}/${serverStatus.connections.available}`
});
} catch (err) {
checks.push({ item: '数据库连接', status: 'FAIL', detail: err.message });
}
// 索引检查
const collections = ['products', 'reviews', 'users'];
for (const coll of collections) {
const indexes = await mongoose.connection.db.collection(coll).indexes();
const hasTextIndex = indexes.some(i => Object.values(i.key).includes('text'));
checks.push({
item: `${coll} 文本索引`,
status: hasTextIndex ? 'PASS' : 'WARN',
detail: hasTextIndex ? '已创建' : '未创建,全文搜索不可用'
});
}
return checks;
}
// === 2. 健康检查端点(供负载均衡器探测)===
app.get('/health', async (req, res) => {
const start = Date.now();
const health = {
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
checks: {}
};
// MongoDB 连接检查
try {
await mongoose.connection.db.adminCommand({ ping: 1 });
health.checks.mongodb = { status: 'up', responseTime: `${Date.now() - start}ms` };
} catch {
health.checks.mongodb = { status: 'down' };
health.status = 'degraded';
}
// 内存检查
const mem = process.memoryUsage();
health.checks.memory = {
rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
heapUsed: `${Math.round(mem.heapUsed / 1024 / 1024)}MB`,
heapTotal: `${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
status: mem.heapUsed / mem.heapTotal < 0.9 ? 'ok' : 'warn'
};
const statusCode = health.status === 'ok' ? 200 : 503;
res.status(statusCode).json(health);
});
// === 3. Prometheus 指标采集 ===
const client = require('prom-client');
const register = new client.Registry();
const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5]
});
register.registerMetric(httpRequestDuration);
const dbQueryDuration = new client.Histogram({
name: 'mongodb_query_duration_seconds',
help: 'MongoDB query duration',
labelNames: ['collection', 'operation'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1]
});
register.registerMetric(dbQueryDuration);
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
httpRequestDuration
.labels(req.method, req.route?.path || req.path, res.statusCode)
.observe((Date.now() - start) / 1000);
});
next();
});
app.get('/metrics', async (req, res) => {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
// === 4. 告警规则配置(Prometheus 规则文件)===
const alertRules = `
groups:
- name: review_system
rules:
- alert: HighErrorRate
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.01
for: 5m
labels: { severity: critical }
annotations: { summary: "5xx 错误率超过 1%" }
- alert: SlowAPIResponse
expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1
for: 10m
labels: { severity: warning }
annotations: { summary: "API P95 响应时间超过 1 秒" }
- alert: MongoDBConnectionPoolExhausted
expr: mongodb_connections{state="current"} / mongodb_connections{state="available"} > 0.8
for: 5m
labels: { severity: warning }
annotations: { summary: "MongoDB 连接池使用率超过 80%" }
- alert: HighMemoryUsage
expr: process_heap_bytes / process_max_heap_bytes > 0.9
for: 5m
labels: { severity: critical }
annotations: { summary: "Node.js 内存使用率超过 90%" }
`;
输出:生产部署前自动检查环境变量、数据库连接、索引完整性;/health 端点实时报告服务状态;Prometheus 指标采集 HTTP 延迟和数据库查询耗时;告警规则覆盖错误率、响应时间、连接池、内存四个维度。
部署检查清单的优先级:1. P0(必须通过才能上线)——环境变量齐全、数据库连接正常、JWT 密钥安全、HTTPS 已配置;2. P1(上线后一周内修复)——文本索引已创建、连接池参数合理、限流已开启、日志写入文件;3. P2(持续优化)——未使用索引清理、慢查询优化、缓存命中率提升、监控仪表盘完善;4. 核心原则——"先上线再优化"但不跳过 P0 检查,P0 不过不上线。
❓ 常见问题
📖 小节
- 完整电商评论系统架构(Node.js + Express + mongoose)
- 6 模块:初始化 → 模型 → CRUD → 聚合分析 → 权限 → 部署
- 嵌套评论(parent_id 引用)
- 聚合统计($bucket + $facet)
- JWT 认证 + RBAC 角色
- MongoDB Atlas 云部署
📝 作业
- 基础题(⭐):搭建项目骨架(含 package.json / .env / app.js)。
- 基础题(⭐):实现 User + Product + Review 三个 Model。
- 进阶题(⭐⭐):实现评论 CRUD(创建、列表、点赞、软删除)。
- 进阶题(⭐⭐):实现聚合统计(评分分布 + 商品统计 + 热门榜)。
- 挑战题(⭐⭐⭐):完整电商评论系统部署到 Atlas 或 Render,包含 6 个模块全部功能。