资讯详情 Node.js校园跑腿全栈实战:Express+uni-app订单状态机与权限
📅 2026/10/18 12:39:16
作为一个写过不少“半成品全栈项目”的开发者我越来越认同一个判断能让你真正成长的项目不是功能有多炫而是它能不能把前端、后端、数据库、权限、状态流转这些散落的知识点串成一条完整的链路。校园跑腿系统就是这类项目的典型代表。它表面上是一个“发布任务 接单 配送 管理”的 CRUD 业务但真正动手做一遍你会发现它涵盖了多角色权限、订单状态机、并发接单、接口校验、管理端统计等大量全栈开发的核心问题。如果只把它当成“又一个管理系统”来看很容易低估它的学习价值反过来如果能把它拆解清楚你会发现它非常适合用来验证 Node.js、Express、uni-app 以及数据库设计的综合能力。本文不打算贴一个完整仓库然后让你自己看。我会从一个“要真正跑通这个项目”的开发者视角把系统架构、业务模块、核心接口、用户端页面、管理端权限、联调验证和常见问题全部拆开讲。如果你正准备拿校园跑腿系统当作 Node.js 全栈练习项目或者正在接手类似的多角色业务系统这篇文章能帮你少走很多弯路。1. 这篇文章真正要解决的问题先说一个很多学习者都遇到过的尴尬场景学会了 JavaScript 基础会写 Express 接口也看过 Vue 或 uni-app 的文档但真要独立做一个“能同时跑起来”的项目时往往卡在几个地方前端页面有人写后端接口也有人会写但前后端联调时一堆问题跨域、字段对不上、接口路径不一致。知道要建数据库表但面对“订单、用户、跑腿员、管理员”这些角色时不知道表结构怎么设计更不知道状态字段怎么流转。学会了 JWT 登录但不知道哪些接口该校验权限管理端接口和用户端接口如何区分。写接口时都是“增删改查”但遇到“一个订单同时被多个跑腿员抢单”这种场景不知道并发控制怎么做。校园跑腿系统刚好把这些问题全部暴露出来。它的业务并不复杂但角色多、状态多、边界情况多非常适合用来补上“只会写代码、不会做系统”这块短板。所以这篇文章的核心目标是以校园跑腿系统中的“订单任务流”为主线讲清楚一个包含管理端与用户端的 Node.js Express uni-app 全栈项目应该怎么设计、怎么实现、怎么联调、怎么排查问题。读完之后你至少应该能回答这几个问题用户端、跑腿员、管理端三者的权限边界在哪里订单状态为什么不能随便用字符串硬编码uni-app 如何与 Express 后端安全地通信管理端和用户端共用一套后端时接口如何做隔离2. 系统总体架构与核心概念校园跑腿系统在架构上属于典型的前后端分离项目只是它的“前端”比较特殊用户端和管理端都基于 uni-app但也可以根据实际需要把管理端做成 Web 管理后台。技术栈分工如下层次技术选型职责服务端Node.js Express提供 RESTful API、鉴权、业务逻辑、数据存储数据库MongoDB / MySQL存储用户、订单、跑腿员、管理数据用户端uni-app学生发布任务、查看订单、支付跑腿费、确认完成管理端uni-app 或 Web 后台用户管理、跑腿员审核、订单管理、数据统计这里有一个容易混淆的概念需要先理清uni-app 并不是一个后端框架它是一套跨端 UI 框架。它最大的价值是一套代码可以编译到微信小程序、App 和 H5。也就是说学生用户可能在小程序里下单管理员可能在浏览器后台处理订单但两者访问的都是同一个 Express 服务。Node.js Express 在这里承担的是“业务网关”和“数据中枢”的角色。Express 本身非常轻量它的核心能力是路由和中间件。对于校园跑腿这种规模的项目Express 比 NestJS 更合适做教学练习因为框架约束更少你能更清楚地看到请求从路由到控制器再到数据库的完整路径。数据库选型上MongoDB 和 MySQL 都可以。MongoDB 的优势是订单文档结构灵活适合快速迭代开发MySQL 的优势是事务支持更成熟适合对订单金额、账户余额有强一致要求的场景。本文示例以 MongoDB Mongoose 为例因为对全栈初学者更友好后面会单独说明如果换成 MySQL 需要注意什么。这一节的小结论是架构上不要追求复杂用户端、服务端、管理端三层各司其职真正的复杂度不在技术栈而在业务状态和权限设计上。3. 角色、业务模块与订单状态机设计整个系统的业务核心是“跑腿任务”围绕任务会牵扯出三类角色学生用户发布者发布跑腿任务填写取件地点、送达地点、跑腿费、备注查看自己发布的任务状态确认订单完成。跑腿员接单者浏览待接单任务点击接单配送过程中更新状态送达后等待用户确认。管理员审核跑腿员资质管理违规订单查看平台订单总量、完成率、待接单数量等统计数据。在动手写接口之前最先要设计清楚的不是页面而是订单状态机。一个跑腿订单从发布到结束最少要经过这几个状态PENDING待接单 - ACCEPTED已接单 - DELIVERING配送中 - DONE已完成 \- CANCELED已取消这看起来很简单但实际开发时容易犯一个错误在代码里到处写死字符串比如order.status 2、order.status 已接单。一旦产品说“已接单”要改成“配送中”你就要全局搜索替换非常痛苦。更合理的做法是定义状态常量文件或者直接在模型层做好枚举约束// 文件路径server/models/orderStatus.js const ORDER_STATUS { PENDING: PENDING, // 待接单 ACCEPTED: ACCEPTED, // 已接单 DELIVERING: DELIVERING, // 配送中 DONE: DONE, // 已完成 CANCELED: CANCELED // 已取消 }; const ALLOWED_TRANSITIONS { [ORDER_STATUS.PENDING]: [ORDER_STATUS.ACCEPTED, ORDER_STATUS.CANCELED], [ORDER_STATUS.ACCEPTED]: [ORDER_STATUS.DELIVERING, ORDER_STATUS.CANCELED], [ORDER_STATUS.DELIVERING]: [ORDER_STATUS.DONE], [ORDER_STATUS.DONE]: [], [ORDER_STATUS.CANCELED]: [] }; module.exports { ORDER_STATUS, ALLOWED_TRANSITIONS };状态机的价值在于它把“订单现在能不能进入下一个状态”这个规则收敛到一个地方。比如已完成的订单不能再次接单配送中的订单不能被取消这些校验不需要散落在各个接口里。业务模块上用户端主要包含登录注册、发布任务、任务列表、订单详情、个人中心管理端主要包含跑腿员审核、订单查询、用户管理、数据统计。这些模块并不需要每个都做得很重但接口边界要清楚。4. 环境准备与项目初始化校园跑腿系统涉及两个工程后端 Express 服务和前端 uni-app 项目。实际开发中建议分成两个目录管理例如server和frontend。后端环境准备如下Node.js 建议使用 14 以上的 LTS 版本具体以你本机安装为准可以通过node -v查看。数据库使用 MongoDB本地安装 MongoDB 或使用云数据库均可。包管理工具使用 npm后面提到的依赖都通过 npm 安装。初始化后端项目的命令mkdir campus-order-server cd campus-order-server npm init -y npm install express mongoose jsonwebtoken cors npm install -D nodemon这里解释一下每个依赖的用途expressHTTP 框架负责路由和中间件。mongooseMongoDB 的对象模型工具用于定义数据结构和操作数据库。jsonwebtoken生成和校验 JWT用于登录态管理。cors解决浏览器跨域问题uni-app 编译到 H5 时尤其需要。nodemon开发环境下监听文件变化自动重启服务。在package.json中配置启动脚本{ name: campus-order-server, version: 1.0.0, scripts: { start: node server.js, dev: nodemon server.js } }前端部分uni-app 的创建方式有两种HBuilderX 直接新建项目或者使用 CLI 方式创建。如果你习惯命令行可以用vue create或官方 CLI 模板创建如果你更习惯可视化操作HBuilderX 会更方便。创建项目时要选择 Vue 3 或 Vue 2 模板本文示例代码兼容 Vue 2 风格实际项目以你创建时的模板为准。这一节需要注意的坑是不要一上来就写业务代码先把两个空项目的启动命令跑通。后端能访问前端能显示默认页面再开始接口开发和页面开发。否则后面所有问题都会纠缠在一起很难定位。5. 后端核心接口实现后端部分是整个系统的重点。我会按“服务入口 - 数据模型 - 业务接口 - 权限控制 - 管理端接口”的顺序逐步实现。5.1 服务入口与数据库连接先创建server.js这是 Express 应用的入口文件// 文件路径server/server.js const express require(express); const mongoose require(mongoose); const cors require(cors); const orderRoutes require(./routes/order); const adminRoutes require(./routes/admin); const userRoutes require(./routes/user); const app express(); // 中间件 app.use(cors()); app.use(express.json()); // 连接数据库 mongoose.connect(mongodb://127.0.0.1:27017/campus_order, { useNewUrlParser: true, useUnifiedTopology: true }).then(() { console.log(MongoDB connected); }).catch((err) { console.error(MongoDB connection error:, err.message); process.exit(1); }); // 业务路由 app.use(/api/user, userRoutes); app.use(/api/order, orderRoutes); app.use(/api/admin, adminRoutes); // 健康检查接口 app.get(/health, (req, res) { res.json({ code: 0, message: ok }); }); // 统一错误处理 app.use((err, req, res, next) { console.error(err); res.status(err.status || 500).json({ code: err.status || 500, message: err.message || 服务器内部错误 }); }); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(Server running on http://localhost:${PORT}); });这个入口文件里有一个容易被忽略的设计统一错误处理中间件。Express 的异步接口如果抛出异常必须交给错误处理中间件否则请求会一直挂起。后面写业务接口时所有async函数都要用try/catch包裹再next(err)这样错误信息才能集中处理。5.2 订单模型设计订单模型是整个系统最核心的数据结构。用 Mongoose 定义如下// 文件路径server/models/Order.js const mongoose require(mongoose); const orderSchema new mongoose.Schema({ orderNo: { type: String, required: true, unique: true }, publisherId: { type: String, required: true }, publisherName: { type: String, default: }, title: { type: String, required: true }, description: { type: String, default: }, reward: { type: Number, default: 0 }, pickup: { type: String, required: true }, destination: { type: String, required: true }, status: { type: String, default: PENDING }, runnerId: { type: String, default: }, runnerName: { type: String, default: }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now } }); module.exports mongoose.model(Order, orderSchema);设计时有几个细节值得说明orderNo为什么要唯一因为面向用户的展示通常用订单号而不是数据库 ObjectId。订单号可以用时间戳加随机数生成保证可读性和唯一性。publisherId和runnerId为什么不直接用数据库的外键关联在 Mongoose 里可以用ref做关联但初学者容易为了“关联”而过度设计。这里存 ID 加冗余的用户名字段查询列表时不用频繁联表对教学项目和中小型业务都够用。状态字段用字符串而不是数字。虽然数字更省空间但字符串可读性更好配合前端枚举展示也更直观。只要状态流转受状态机约束字符串不会变成失控的脏数据。5.3 发布任务与接单接口订单相关路由放在routes/order.js这里实现三个核心能力发布任务、跑腿员接单、更新配送状态。// 文件路径server/routes/order.js const express require(express); const router express.Router(); const Order require(../models/Order); const { ORDER_STATUS } require(../models/orderStatus); const { authToken } require(../middleware/auth); // 发布跑腿任务 router.post(/create, authToken, async (req, res, next) { try { const { title, description, reward, pickup, destination } req.body; if (!title || !pickup || !destination) { return res.status(400).json({ code: 400, message: 标题、取件地点、送达地点不能为空 }); } const orderNo CO Date.now() Math.floor(Math.random() * 1000); const order await Order.create({ orderNo, publisherId: req.user.id, publisherName: req.user.name || , title, description: description || , reward: Number(reward) || 0, pickup, destination, status: ORDER_STATUS.PENDING }); res.json({ code: 0, data: order, message: 发布成功 }); } catch (err) { next(err); } }); // 跑腿员接单 router.post(/accept/:orderId, authToken, async (req, res, next) { try { const order await Order.findById(req.params.orderId); if (!order) { return res.status(404).json({ code: 404, message: 订单不存在 }); } if (order.status ! ORDER_STATUS.PENDING) { return res.status(409).json({ code: 409, message: 该订单已被接单或已取消 }); } // 这里在真实项目中需要校验 req.user 是否是跑腿员角色 order.status ORDER_STATUS.ACCEPTED; order.runnerId req.user.id; order.runnerName req.user.name || ; order.updatedAt new Date(); await order.save(); res.json({ code: 0, data: order, message: 接单成功 }); } catch (err) { next(err); } }); // 跑腿员更新订单状态配送中 / 已完成 router.put(/status/:orderId, authToken, async (req, res, next) { try { const { status } req.body; const allowedStatus [ORDER_STATUS.DELIVERING, ORDER_STATUS.DONE]; if (!allowedStatus.includes(status)) { return res.status(400).json({ code: 400, message: 非法的状态变更 }); } const order await Order.findById(req.params.orderId); if (!order) { return res.status(404).json({ code: 404, message: 订单不存在 }); } // 只有接单的跑腿员才能更新状态 if (order.runnerId ! req.user.id) { return res.status(403).json({ code: 403, message: 无权操作该订单 }); } order.status status; order.updatedAt new Date(); await order.save(); res.json({ code: 0, data: order, message: 状态更新成功 }); } catch (err) { next(err); } }); // 查询跑腿任务列表支持分页和状态筛选 router.get(/list, authToken, async (req, res, next) { try { const { page 1, pageSize 10, status } req.query; const query {}; if (status) { query.status status; } const total await Order.countDocuments(query); const list await Order.find(query) .skip((Number(page) - 1) * Number(pageSize)) .limit(Number(pageSize)) .sort({ createdAt: -1 }); res.json({ code: 0, data: { total, list } }); } catch (err) { next(err); } }); module.exports router;这段代码里有一个非常关键的点接单接口的状态判断。如果没有if (order.status ! PENDING)这行校验就可能出现两个跑腿员同时看到待接单订单、同时请求接单的情况。虽然这里通过“先查再改”的方式能解决一部分问题但在高并发场景下仍然不够安全这一点我会在“最佳实践”里展开。5.4 登录鉴权与角色权限控制用户路由涉及注册和登录这里提供一个最小可用版本// 文件路径server/routes/user.js const express require(express); const router express.Router(); const jwt require(jsonwebtoken); const { SECRET } require(../middleware/auth); // 模拟用户存储实际项目应使用 User 模型 const users []; router.post(/register, async (req, res, next) { try { const { name, password, role } req.body; if (!name || !password) { return res.status(400).json({ code: 400, message: 用户名和密码不能为空 }); } const user { id: String(Date.now()), name, password, role: role || student }; users.push(user); res.json({ code: 0, message: 注册成功 }); } catch (err) { next(err); } }); router.post(/login, async (req, res, next) { try { const { name, password } req.body; const user users.find(u u.name name u.password password); if (!user) { return res.status(401).json({ code: 401, message: 用户名或密码错误 }); } const token jwt.sign({ id: user.id, name: user.name, role: user.role }, SECRET, { expiresIn: 7d }); res.json({ code: 0, data: { token, user } }); } catch (err) { next(err); } }); module.exports router;中间件middleware/auth.js负责解析请求头中的 JWT并把用户信息挂到req.user上// 文件路径server/middleware/auth.js const jwt require(jsonwebtoken); const SECRET process.env.JWT_SECRET || campus-order-demo-secret; function authToken(req, res, next) { const authHeader req.headers[authorization]; if (!authHeader) { return res.status(401).json({ code: 401, message: 请先登录 }); } try { const token authHeader.replace(Bearer , ); const decoded jwt.verify(token, SECRET); req.user decoded; next(); } catch (err) { return res.status(401).json({ code: 401, message: 登录状态已失效 }); } } module.exports { authToken, SECRET };管理端接口则需要额外的管理员角色校验// 文件路径server/middleware/admin.js function authAdmin(req, res, next) { if (req.user req.user.role admin) { next(); } else { return res.status(403).json({ code: 403, message: 需要管理员权限 }); } } module.exports { authAdmin };管理端路由可以放在routes/admin.js实现订单列表查询和平台统计// 文件路径server/routes/admin.js const express require(express); const router express.Router(); const Order require(../models/Order); const { authToken } require(../middleware/auth); const { authAdmin } require(../middleware/admin); // 管理端订单列表 router.get(/orders, authToken, authAdmin, async (req, res, next) { try { const { page 1, pageSize 10, status } req.query; const query {}; if (status) query.status status; const total await Order.countDocuments(query); const list await Order.find(query) .skip((Number(page) - 1) * Number(pageSize)) .limit(Number(pageSize)) .sort({ createdAt: -1 }); res.json({ code: 0, data: { total, list } }); } catch (err) { next(err); } }); // 管理端订单统计 router.get(/stats, authToken, authAdmin, async (req, res, next) { try { const total await Order.countDocuments(); const pending await Order.countDocuments({ status: PENDING }); const delivering await Order.countDocuments({ status: DELIVERING }); const done await Order.countDocuments({ status: DONE }); res.json({ code: 0, data: { total, pending, delivering, done } }); } catch (err) { next(err); } }); module.exports router;权限控制的逻辑并不复杂但它是区分“接口能用”和“系统安全”的分水岭。发布接口、接单接口、管理接口必须使用不同的鉴权策略尤其是管理端接口不能只靠前端隐藏入口后端必须做二次校验。6. uni-app 用户端核心实现后端接口就绪后下一步是让用户端真正“用起来”。uni-app 项目的核心工作有三个封装网络请求、编写页面交互、管理登录态。6.1 请求封装uni-app 自带的uni.request已经够用但直接在每个页面里写uni.request会非常冗余。更推荐先封装一个统一的请求工具统一管理 baseURL、请求头、错误提示和登录态。// 文件路径frontend/utils/request.js const BASE_URL http://localhost:3000/api; export function request({ url, method GET, data {} }) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL url, method, data, header: { Content-Type: application/json, Authorization: uni.getStorageSync(token) ? Bearer uni.getStorageSync(token) : }, success: (res) { if (res.data.code 0) { resolve(res.data.data); } else { uni.showToast({ title: res.data.message || 请求失败, icon: none }); reject(res.data); } }, fail: (err) { uni.showToast({ title: 网络异常请检查服务端, icon: none }); reject(err); } }); }); }请求封装里有几个设计点需要注意baseURL 不要写死在每个请求里集中在这里后续切换测试环境、生产环境只需要改一处。token 从本地存储读取每次请求自动带上页面里不需要手动处理。成功和失败统一拦截页面代码只关心业务数据不需要每个页面都写弹窗提示。如果后续需要处理 401 登录失效可以在这里统一跳转登录页而不是在每个页面里判断。6.2 发布任务页面发布任务页面是用户端最核心的入口。页面表单字段与后端/api/order/create接口的字段要保持一致否则前端传了desc后端读的是description接口就会返回校验错误。template view classcreate-order view classform-item input v-modelform.title placeholder请输入任务标题 / /view view classform-item textarea v-modelform.description placeholder请输入任务描述 / /view view classform-item input v-modelform.pickup placeholder取件地点例如3号教学楼 / /view view classform-item input v-modelform.destination placeholder送达地点例如7号宿舍楼 / /view view classform-item input v-modelform.reward typenumber placeholder跑腿费元 / /view button typeprimary clicksubmitOrder发布任务/button /view /template script import { request } from /utils/request.js; export default { data() { return { form: { title: , description: , pickup: , destination: , reward: } }; }, methods: { submitOrder() { if (!this.form.title || !this.form.pickup || !this.form.destination) { uni.showToast({ title: 请填写必填项, icon: none }); return; } request({ url: /order/create, method: POST, data: this.form }).then(() { uni.showToast({ title: 发布成功, icon: success }); setTimeout(() { uni.navigateBack(); }, 1000); }); } } }; /script style scoped .create-order { padding: 30rpx; } .form-item { margin-bottom: 20rpx; } input { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 16rpx; } textarea { border: 1rpx solid #ddd; border-radius: 8rpx; padding: 16rpx; width: 100%; height: 160rpx; } /style页面的顺序是先校验、再请求、再反馈。在实际项目中发布按钮还应该加一个loading状态防止用户多次点击产生重复订单。6.3 订单列表与状态展示订单列表页同时服务于“我发布的”和“我可以接的”两种场景。列表页需要展示订单号、标题、起终点、赏金和状态。状态展示建议做一个枚举映射而不是在页面里写死中文// 文件路径frontend/utils/orderStatus.js export const ORDER_STATUS_MAP { PENDING: 待接单, ACCEPTED: 已接单, DELIVERING: 配送中, DONE: 已完成, CANCELED: 已取消 };列表页核心逻辑template view classorder-list view v-foritem in orderList :keyitem._id classorder-card view classorder-title{{ item.title }}/view view classorder-info{{ item.pickup }} - {{ item.destination }}/view view classorder-bottom text classreward¥{{ item.reward }}/text text classstatus{{ ORDER_STATUS_MAP[item.status] }}/text /view /view view v-iforderList.length 0 classempty暂无订单/view /view /template script import { request } from /utils/request.js; import { ORDER_STATUS_MAP } from /utils/orderStatus.js; export default { data() { return { orderList: [], ORDER_STATUS_MAP }; }, onLoad() { this.loadOrders(); }, methods: { loadOrders() { request({ url: /order/list?page1pageSize10 }).then((data) { this.orderList data.list; }); } } }; /script这里有一个 uni-app 的常见知识点ORDER_STATUS_MAP挂在data里才能在模板中直接使用。如果你只在script中import了一个常量模板里是拿不到的必须把它赋值给data的属性。7. 前后端联调与效果验证代码写完之后最考验耐心的是联调阶段。我建议按照下面顺序做一次完整的流程验证第一步启动 MongoDB。确保本地 MongoDB 服务正常运行可以通过命令行或可视化工具确认。第二步启动后端服务cd campus-order-server npm run dev看到以下日志说明后端启动成功MongoDB connected Server running on http://localhost:3000第三步启动 uni-app 项目。如果你用的是 HBuilderX直接选择“运行到浏览器”如果用 CLI 创建的项目执行cd frontend npm run dev:h5第四步按业务流程做一次完整冒烟测试注册一个学生账号和一个跑腿员账号。用学生账号登录发布一条“取快递”任务。用跑腿员账号登录在任务列表中看到这条任务并接单。跑腿员将订单状态更新为“配送中”再更新为“已完成”。用学生账号查看订单状态确认已经变为“已完成”。用管理员账号登录查看订单统计接口确认数据正确。如果每一步都能正常完成说明从数据库、后端接口、前端页面到权限控制这条主链路是通的。任何一个环节报错都可以按下一节的排查思路定位。8. 常见问题与排查思路前后端联调阶段几乎每个开发者都会遇到下面这些问题这里整理成一份可以直接对照的排查表问题现象可能原因排查方式解决方案后端启动报 MongoDB 连接失败MongoDB 服务未启动或连接字符串写错检查数据库服务是否启动用 MongoDB Compass 尝试连接启动 MongoDB 服务确认连接地址和端口uni-app 请求报跨域错误H5 端请求的接口地址与页面地址不同源后端未开启 CORS打开浏览器 Network 面板看请求是否被拦截后端使用cors()中间件或在 uni-app 的 manifest 中配置代理接口返回 404请求路径与后端路由不一致或路由挂载顺序错误查看后端路由文件与前端请求的 url 是否完全一致统一接口路径例如后端挂载/api/order前端请求/order/create接口报 401token 缺失或 JWT 密钥不一致检查请求头 Authorization 字段是否存在确认登录后已保存 token并在请求拦截器中统一注入接口报 403用户角色权限不足或不是该订单的接单人查看req.user中的角色和order.runnerId确认测试账号角色正确确认操作的是本人接单的订单接单时提示“订单已被接单”订单状态已经不是 PENDING或并发抢单查看数据库中订单的 status 字段使用状态机校验必要时引入乐观锁或事务uni-app 模板中常量显示 undefined导入的状态枚举没有挂到 data 上检查 script 中是否将常量赋值给 data 属性在 data 中声明ORDER_STATUS_MAP并赋值修改代码后接口行为没变化后端没有重启或前端没有重新编译确认 nodemon 是否正常运行H5 是否热更新重启后端服务或手动刷新浏览器页面这里的核心排查思路是分层定位先确认数据库有没有数据再确认后端接口有没有正确返回最后确认前端有没有正确渲染。不要一上来就怀疑代码写错了先看请求到底有没有发出去、响应到底是什么。9. 最佳实践与工程建议9.1 目录结构要按职责划分后端目录建议按“路由、控制器、模型、中间件”划分而不是把所有代码堆在server.js一个文件里。一个比较合理的结构是server/ ├── server.js # 入口 ├── routes/ # 路由定义 ├── controllers/ # 业务逻辑 ├── models/ # 数据模型 ├── middleware/ # JWT、角色鉴权、错误处理 └── utils/ # 工具函数即使项目规模不大也值得养成这种习惯。后期加功能时你不会因为找不到代码位置而浪费时间。9.2 接口参数校验不能省发布任务接口只做了“字段是否存在”的校验实际项目中还需要校验字段类型、长度范围、金额是否为正数。如果不做参数校验很容易出现reward传成字符串、destination传成超长文本等脏数据。可以引入joi、zod这类校验库也可以先写工具函数手动校验但一定不能省。9.3 接单并发控制要重视校园跑腿的接单场景天然存在并发风险多个跑腿员同时看到同一订单同时点击接单两个请求都通过了status PENDING的判断结果订单被两个人同时接走。解决思路有三个层级依赖状态校验就是前面代码中的写法适合低并发教学场景。乐观锁在更新时带上原来的状态条件例如updateOne({ _id: orderId, status: PENDING }, { $set: { status: ACCEPTED } })如果modifiedCount 0说明已经被人抢走。数据库事务如果使用 MySQL可以在事务中先锁定订单行再更新。代码示例// 采用乐观锁方式接单 const result await Order.updateOne( { _id: orderId, status: ORDER_STATUS.PENDING }, { $set: { status: ORDER_STATUS.ACCEPTED, runnerId: req.user.id, runnerName: req.user.name || , updatedAt: new Date() } } ); if (result.modifiedCount 0) { return res.status(409).json({ code: 409, message: 手慢了订单已被接走 }); }这种写法的好处是把“查询并判断”和“更新”合并成了一个原子操作从根本上避免了并发抢单问题。9.4 JWT 与密码安全上面的用户示例为了演示把密码明文存在了数组里真实项目绝对不能用这种方式。密码必须使用bcrypt等算法哈希后再存储JWT 密钥也必须通过环境变量配置不能写死在代码里。# 文件路径server/.env JWT_SECRETyour-strong-secret DB_URLmongodb://127.0.0.1:27017/campus_order PORT30009.5 日志与错误监控开发阶段可以依赖console.log但项目一旦要部署就需要把请求日志、错误日志统一记录。可以用morgan记录访问日志用winston记录应用日志方便排查线上问题。9.6 管理端权限必须后端强制校验管理端页面可以做好用户体验但不能把安全性寄托在“用户看不到管理入口”。所有管理端接口都必须走authToken authAdmin双重中间件否则任何人只要知道了接口路径就能绕过前端直接获取数据。10. 总结与后续学习方向回到开头的问题为什么校园跑腿系统是 Node.js 全栈开发练习的好素材因为它不是那种“写完就忘”的增删改查。它逼着你思考角色边界、设计订单状态机、处理并发接单、区分管理端权限还要把 uni-app 前端和 Express 后端真正联调起来。做完这一套流程你对全栈开发的理解会比只刷课程视频深刻得多。如果你准备继续深入下面几个方向值得优先探索把内存用户存储替换成真实的 User 模型加上bcrypt密码加密和跑腿员审核流程。增加订单超时取消机制可以用setTimeout或者定时任务扫描超过 N 分钟未接单的 PENDING 订单。从 MongoDB 切换到 MySQL体会关系型数据库在事务和表关联上的差异。引入 WebSocket 或轮询让用户端能实时收到订单状态变化通知。这套代码本身并不复杂但它把全栈开发的骨架完整地立了起来。建议你先照着把主链路跑通再逐步往里面加自己的业务想法。每个阶段都能产生新问题而这些问题才是做项目练习最值钱的部分。