L19 · 后端搭建:Express + MongoDB
🎯 本节目标:搭建 Node.js 后端服务,连接 MongoDB 数据库
📦 本节产出:可运行的 Express 服务 + 数据库连接 + 商品/用户/订单数据模型
🔗 前置钩子:Phase 2 的完整前端架构(L18 产出)
🔗 后续钩子:L20 将基于数据模型实现 RESTful APINOTE
Phase 2 → Phase 3 过渡说明
Phase 2 围绕任务管理系统构建了完整的前端工程基础——路由、状态管理、组件通信、测试、部署。 Phase 3 将沿用这些工程基础,迁移到一个全新的全栈电商项目。业务场景变了,但架构思路延续:
- Vue Router → 电商页面路由(商品/购物车/订单)
- Pinia → 购物车/认证等复杂状态管理
- Axios + 拦截器 → 与后端 API 通信
- Vitest → 后端 API 测试 + 前端组件测试
之所以切换业务场景,是因为全栈电商涵盖认证、支付、实时通信、SSR 等需要服务端配合的流程。
1. 项目结构
Phase 3 采用 monorepo 结构,前后端放在同一仓库。新建 vue-shop 目录,把 L18 的前端工程放到 client/,保留源码、配置和锁文件,不复制 .git、node_modules、dist。在新仓库根目录执行 git init,前端进入 client/ 后用原锁文件执行 npm ci。前后端各自安装依赖;本课不额外引入 workspace 工具。
vue-shop/
├── client/ # Vue 3 前端(Phase 1-2 的代码移入)
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
├── server/ # Express 后端(新增)
│ ├── src/
│ │ ├── config/
│ │ │ └── db.ts # 数据库连接
│ │ ├── models/ # Mongoose 数据模型
│ │ │ ├── User.ts
│ │ │ ├── Product.ts
│ │ │ └── Order.ts
│ │ ├── routes/ # 路由定义
│ │ ├── controllers/ # 请求处理逻辑
│ │ ├── middlewares/ # 中间件(认证、错误处理)
│ │ ├── utils/ # 工具函数
│ │ ├── app.ts # 组装 Express 应用
│ │ └── index.ts # 连接数据库、启动 HTTP 服务
│ ├── package.json
│ └── tsconfig.json
└── package.json # 可选:根目录快捷脚本2. 初始化后端项目
后端固定使用 Node.js 22.12+、Express 5、Mongoose 8、TypeScript 5;前端继续使用前面课程的 Vue 3.5 / Vite 6。Express 与 @types/express 保持同一主版本,安装后提交锁文件,后续用 npm ci 重装。下面的命令在 vue-shop/ 执行。
mkdir server && cd server
npm init -y
npm install express@5 cors@2 dotenv@16 mongoose@8
npm install -D typescript@5 tsx@4 @types/express@5 @types/cors@2 @types/node@222.1 TypeScript 配置
将下面内容保存为 server/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "Node",
"types": ["node"],
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}2.2 Express 入口
// server/src/app.ts
import express from 'express'
import cors from 'cors'
const app = express()
// 中间件
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true,
}))
app.use(express.json({ limit: '1mb' })) // 解析 JSON 请求体
app.use(express.urlencoded({ extended: true }))
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
export default appapp.ts 只组装应用,便于测试时直接导入;真正的启动入口单独保存:
// server/src/index.ts
import 'dotenv/config'
import app from './app'
import { connectDB } from './config/db'
async function start() {
await connectDB()
const port = Number(process.env.PORT || 3000)
app.listen(port, '127.0.0.1', () => {
console.log(`✅ Server running on http://localhost:${port}`)
})
}
start().catch(error => {
console.error('启动失败:', error)
process.exit(1)
})3. 连接 MongoDB
先启动数据库。本课程用 MongoDB 8 的本地单节点副本集,供 L25 的多文档事务使用;它适合本机练习,不提供生产环境的高可用性。已安装并启动 Docker 后,执行一次:
docker run -d --name vue-shop-mongo -p 127.0.0.1:27017:27017 \
-v vue-shop-mongo-data:/data/db mongo:8.0 --replSet rs0 --bind_ip_all
# 等 MongoDB 就绪;此命令返回 ok: 1 后再初始化副本集
docker exec vue-shop-mongo mongosh --quiet --eval 'db.adminCommand({ ping: 1 })'
docker exec vue-shop-mongo mongosh --quiet --eval 'rs.initiate({_id: "rs0", members: [{_id: 0, host: "127.0.0.1:27017"}]})'再次开发时使用 docker start vue-shop-mongo,不要重复创建容器或初始化副本集。这里 Node 进程运行在宿主机;若把后端也容器化,需要另配可相互访问的副本集地址。MongoDB 测试副本集说明
// server/src/config/db.ts
import mongoose from 'mongoose'
export async function connectDB() {
const uri = process.env.MONGODB_URI
if (!uri) throw new Error('缺少 MONGODB_URI')
await mongoose.connect(uri, { serverSelectionTimeoutMS: 5000 })
console.log('✅ MongoDB connected:', mongoose.connection.name)
mongoose.connection.on('error', (err) => {
console.error('MongoDB error:', err)
})
}4. 数据模型(Mongoose Schema)
4.1 用户模型
// server/src/models/User.ts
import mongoose, { Schema, Types } from 'mongoose'
export interface IUser {
_id: Types.ObjectId
name: string
email: string
password: string // bcrypt 哈希,不是可解密的密文
role: 'user' | 'admin'
avatar?: string
createdAt: Date
updatedAt: Date
}
const userSchema = new Schema<IUser>({
name: { type: String, required: true, trim: true },
email: {
type: String, required: true, unique: true,
lowercase: true, trim: true,
match: [/^\S+@\S+\.\S+$/, '邮箱格式不正确'],
},
password: { type: String, required: true, select: false },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
avatar: String,
}, { timestamps: true })
export default mongoose.model<IUser>('User', userSchema)select: false 只控制查询的默认投影,不负责密码哈希,也不保证新建文档序列化时自动隐藏密码。L22 会在注册时验证原始密码并哈希,返回用户信息时显式挑选字段。unique 创建唯一索引,重复邮箱错误也要在接口层处理。
4.2 商品模型
// server/src/models/Product.ts
import mongoose, { Schema, Types } from 'mongoose'
export interface IProduct {
_id: Types.ObjectId
name: string
description: string
price: number
category: string
images: string[]
stock: number
rating: number
reviewCount: number
isActive: boolean
createdAt: Date
updatedAt: Date
}
const productSchema = new Schema<IProduct>({
name: { type: String, required: true, trim: true },
description: { type: String, required: true },
price: { type: Number, required: true, min: 0 },
category: { type: String, required: true, index: true },
images: [{ type: String }],
stock: { type: Number, required: true, min: 0, default: 0, validate: Number.isSafeInteger },
rating: { type: Number, default: 0, min: 0, max: 5 },
reviewCount: { type: Number, default: 0, min: 0, validate: Number.isSafeInteger },
isActive: { type: Boolean, default: true },
}, { timestamps: true })
// 索引:按分类和价格查询
productSchema.index({ category: 1, price: 1 })
// 文本索引:支持搜索
productSchema.index({ name: 'text', description: 'text' })
export default mongoose.model<IProduct>('Product', productSchema)4.3 订单模型
// server/src/models/Order.ts
import mongoose, { Schema, Types } from 'mongoose'
export const orderStatuses = [
'pending', 'paid', 'shipped', 'delivered', 'reviewed',
'cancelled', 'refunding', 'refunded',
] as const
export type OrderStatus = typeof orderStatuses[number]
export interface IOrderItem {
product: Types.ObjectId
name: string
price: number
quantity: number
image: string
}
export interface IOrder {
_id: Types.ObjectId
user: Types.ObjectId
items: IOrderItem[]
totalAmount: number
status: OrderStatus
shippingAddress: {
name: string
phone: string
address: string
city: string
}
paymentMethod: string
paidAt?: Date
deliveredAt?: Date
createdAt: Date
updatedAt: Date
}
const orderSchema = new Schema<IOrder>({
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
items: [{
product: { type: Schema.Types.ObjectId, ref: 'Product', required: true },
name: { type: String, required: true },
price: { type: Number, required: true, min: 0 },
quantity: { type: Number, required: true, min: 1, validate: Number.isSafeInteger },
image: { type: String, default: '' },
}],
totalAmount: { type: Number, required: true, min: 0 },
status: {
type: String,
enum: orderStatuses,
default: 'pending',
},
shippingAddress: {
name: { type: String, required: true },
phone: { type: String, required: true },
address: { type: String, required: true },
city: { type: String, required: true },
},
paymentMethod: { type: String, default: 'wechat' },
paidAt: Date,
deliveredAt: Date,
}, { timestamps: true, optimisticConcurrency: true })
export default mongoose.model<IOrder>('Order', orderSchema)5. ER 图
ORDER_ITEM 是订单内嵌数组,不是单独的集合。图中一张订单至少有一个明细的业务约束由 L25 创建接口检查;当前 Schema 本身仍允许空数组。商品引用不会自动建立数据库外键约束;后续接口负责检查商品和用户是否存在。金额以元展示,服务端计算时按分取整,避免直接累加浮点小数。订单状态完整枚举先在这里定义,L25 再实现允许的状态转换。
6. 环境变量
# server/.env
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/vue-shop?replicaSet=rs0
CLIENT_URL=http://localhost:5173把 server/.env 加入 .gitignore,可提交不含私密值的 .env.example。认证密钥在 L22 加入。
7. 开发脚本
合并到 server/package.json,保留安装命令生成的 dependencies/devDependencies:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}npm run build
npm run dev
# ✅ MongoDB connected: vue-shop
# ✅ Server running on http://localhost:3000
# 另开终端检查
curl http://localhost:3000/api/healthtsx 负责运行与文件变更重启,不做类型检查;npm run build 执行 TypeScript 检查。根目录若要保留快捷命令,可以新建:
{
"name": "vue-shop",
"private": true,
"scripts": {
"dev:client": "npm --prefix client run dev",
"dev:server": "npm --prefix server run dev",
"build:server": "npm --prefix server run build"
}
}移动目录后,L18 的 CI 与部署配置也要把前端工作目录设为 client/,避免在仓库根目录查找 Vite 配置。
8. CORS:前后端分离的跨域
CORS 控制浏览器是否允许页面读取跨源响应,不是服务端鉴权。满足简单请求条件时没有 OPTIONS 预检;JSON 请求或带 Authorization 的请求通常会预检。这里配置的固定 origin 会写入响应头,由浏览器与请求来源比较;来源不匹配不代表服务器一定没有收到或执行请求。cors 官方说明
9. 本节总结
检查清单
- [ ] 能搭建 Express + TypeScript 项目
- [ ] 能连接 MongoDB 并处理连接错误
- [ ] 能用 Mongoose 定义 Schema 和 Model
- [ ] 理解 monorepo 前后端项目结构
- [ ] 理解 CORS 跨域配置
- [ ] 能用
tsx watch重启开发服务,并用tsc检查类型
git add .
git commit -m "L19: Express + MongoDB 后端搭建"