Skip to content

L19 · 后端搭建:Express + MongoDB ​

🎯 本节目标:搭建 Node.js 后端服务,连接 MongoDB 数据库
📦 本节产出:可运行的 Express 服务 + 数据库连接 + 商品/用户/订单数据模型
🔗 前置钩子:Phase 2 的完整前端架构(L18 产出)
🔗 后续钩子:L20 将基于数据模型实现 RESTful API

NOTE

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/ 执行。

bash
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@22

2.1 TypeScript 配置 ​

将下面内容保存为 server/tsconfig.json:

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 入口 ​

typescript
// 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 app

app.ts 只组装应用,便于测试时直接导入;真正的启动入口单独保存:

typescript
// 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 后,执行一次:

bash
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 测试副本集说明

typescript
// 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 用户模型 ​

typescript
// 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 商品模型 ​

typescript
// 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 订单模型 ​

typescript
// 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. 环境变量 ​

bash
# 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:

json
{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}
bash
npm run build
npm run dev
# ✅ MongoDB connected: vue-shop
# ✅ Server running on http://localhost:3000
# 另开终端检查
curl http://localhost:3000/api/health

tsx 负责运行与文件变更重启,不做类型检查;npm run build 执行 TypeScript 检查。根目录若要保留快捷命令,可以新建:

json
{
  "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 检查类型
bash
git add .
git commit -m "L19: Express + MongoDB 后端搭建"

🔗 → 下一节:L20 将基于这些 Model 实现完整的 RESTful CRUD API。 ​