做校园类项目的人不少但能把“校园生活助手”做到真正能上线、能过答辩、能作为后面求职作品展示的往往不是功能堆得最多的那个而是把后端接口、前端交互、部署运维这条链路都理清楚的那个。express“校园通”校园生活助手APP这个题目看起来只是“一个校园APP”实际做完你会发现它把用户认证、内容发布、文件上传、消息通知、状态机流转这些常见开发场景全串了一遍非常适合用 Node.js 生态快速落地也是计算机毕业设计里性价比很高的一类全栈项目。下面我把这套项目的设计与实现完整复盘一遍对应我整理的毕设源码 84057从需求拆解到部署上线逐一拆开讲里面穿插了不少实际踩坑后的处理方案准备做类似项目的同学可以直接参考。1. 需求拆解校园APP先解决“被割裂”的问题1.1 高频场景与用户痛点做这个项目之前我先把校园场景里学生最常用的信息流顺手列了一遍课表查询、二手闲置、失物招领、校内通知、社团活动、自习室查询。表面看是六个功能点背后其实是同一类诉求校园里的信息太分散了。课表在教务系统里二手信息散落在各种群丢了东西只能靠发朋友圈临时加课和停课通知又没有统一入口。所以校园通的核心目标不是做“大而全”而是把这些高频、强时效、带明确状态变化的信息集中到一个 APP 里。在需求优先级上我分了三档P0课表查询、二手集市、失物招领、校园通知。这四个模块直接决定产品有没有存续价值。P1社团活动、教室查询、个人课表提醒。属于提升粘性和使用频率的模块。P2校园卡消费记录、班车实时位置。成本高、数据源依赖强第一版直接砍掉。这一点很重要。很多毕业设计容易在“功能范围”上失控最后做出来的东西用户根本不会用。我见过不少同学把精力花在“校园达人排行”这类花哨功能上结果核心的课表接口还没做稳。校园类产品永远先解决信息流动问题再考虑趣味化。1.2 用户角色与典型使用场景设计阶段我把角色收敛成三类普通学生、管理员、游客。学生注册登录后可以查看课表、发布二手商品、发起失物招领、收藏校园通知。管理员负责对内容审核、发布公告、管理二手分类和失物状态。游客可以浏览公开内容但无法发布和参与交易。围绕这三个角色我画了几条典型使用路径早晨上课前学生打开 APP 首页看到“今日课表”卡片点击进入周视图确认教室和时间。午休时刷二手集市看中一台二手台灯点击商品详情通过站内留言联系卖家协商后线下交易。下午在食堂捡到一张校园卡打开失物招领发布上传图片并填写丢失地点。管理员在后台收到新发布审核通过后内容才会进入公共列表。这几条路径覆盖了信息发布、信息消费、状态变更三类操作后端在接口设计时必须分别支持读大量数据、写带校验的数据、以及状态受控的数据。1.3 为什么选前后端分离而不是整站项目最终采用前后端分离架构前端是 uni-app 工程可打包为 Android/iOS APP 以及 H5 版本后端是 Express 提供纯 RESTful API。原因很直接毕业设计需要区分“前端能力”和“后端能力”整站渲染不好体现系统分层。移动端 APP 本身需要通过网络接口取数天然适合前后端分离。Express 只负责接口和业务逻辑前端只负责渲染和数据交互双方可以并行开发、分开测试。部署时可以独立扩展比如接口服务和静态资源分别走不同服务。选型确定后接下来的核心问题就是Express 怎么把用户管理、课表查询、商品状态流转这些逻辑组织得既清晰又不过度设计。2. 技术选型与工程结构设计2.1 Express 在这个项目里的准确定位Express 是 Node.js 环境里最经典的 Web 框架它做的事情非常克制路由分发、中间件处理、请求响应。它没有强制你用什么数据库也没规定工程分层所以用得好不好完全取决于开发者自己的约束能力。在这个项目里Express 的定位是“业务接口层”负责三件事接收移动端请求做参数校验和身份校验。调用业务逻辑层完成数据读写和状态流转。返回统一结构的 JSON 响应包括错误码、提示信息和数据体。不把业务直接堆在路由里是 Express 项目后期能不能维护的关键。我见过很多毕设代码是 app.post 里直接写 SQL小程序端对接时改一个字段就要翻半天路由文件。所以目录分层从一开始就要定死。2.2 技术栈组合搭配我用了如下组合层技术选型说明前端uni-app Vue 3 Pinia一套代码编译到 Android、iOS、H5后端Node.js Express 4提供 RESTful API数据库MySQL 8存储用户、商品、课表、公告等结构化数据缓存Redis存储验证码、Token 黑名单、热点课表缓存文件存储本地磁盘 Nginx 静态映射存放上传的图片部署PM2 Nginx接口进程守护与反向代理这套组合最有吸引力的一点是前后端都用 JavaScript/TypeScript 家族接口联调时数据结构不用来回转换而且 uni-app 的跨端能力让“APP”这个要求很容易满足。如果换成 Android 原生 Spring Boot开发周期会明显拉长对毕设项目并不友好。数据库选择 MySQL 而不是 MongoDB是因为校园通里的课表、二手商品、通知都有强关系属性比如学生选课记录、商品分类、失物认领订单用关系模型表达更自然。Redis 在项目里不是主力存储只是用来降低高频查询压力的辅助层。2.3 后端目录结构与分层原则我最终的目录结构如下server/ ├── app.js # Express 实例注册中间件和路由 ├── bin/ │ └── www # 启动脚本 ├── config/ │ ├── db.js # MySQL 连接池 │ ├── redis.js # Redis 客户端 │ └── env.js # 环境变量统一出口 ├── routes/ │ ├── auth.js │ ├── user.js │ ├── schedule.js │ ├── product.js │ ├── lost.js │ └── notice.js ├── controllers/ │ ├── authController.js │ ├── scheduleController.js │ └── ... ├── services/ │ ├── scheduleService.js │ ├── productService.js │ └── ... ├── models/ │ ├── userModel.js │ └── ... ├── middlewares/ │ ├── auth.js # JWT 校验 │ ├── upload.js # 文件上传 │ └── errorHandler.js ├── utils/ │ ├── response.js # 统一响应格式 │ ├── token.js │ └── validator.js └── public/ └── uploads/ # 上传图片目录层级关系是routes只解析参数→ controllers做参数校验和业务编排→ services实现具体业务规则→ models操作数据库。单向依赖不允许反向调用。这样做的好处是如果想把某个函数从 Express 切到另一个框架只需要替换 controllers 层业务规则仍然保留。3. 核心模块的实现细节与关键代码3.1 基于 JWT 的登录鉴权用户认证是整个系统的第一道门。我没有用 Session 方案因为没有必要维护服务端会话状态APP 端每次请求都携带 Token 即可。密码加密使用 bcryptjs注册时对明文密码做哈希处理登录时用 bcrypt.compare 校验。有人问为什么不用 MD5因为 MD5 可被彩虹表直接碰撞bcrypt 自带盐且慢哈希更适合口令存储。生成 Token 的代码如下const jwt require(jsonwebtoken); const config require(../config/env); function generateToken(user) { return jwt.sign( { uid: user.id, role: user.role, }, config.jwtSecret, { expiresIn: 7d } ); }在中间件里校验请求头const { UNAUTHORIZED, FORBIDDEN } require(../utils/response); function authRequired(req, res, next) { const header req.headers.authorization || ; const token header.startsWith(Bearer ) ? header.slice(7) : null; if (!token) return res.status(401).json(UNAUTHORIZED(未登录); try { const payload jwt.verify(token, config.jwtSecret); req.user payload; next(); } catch (err) { return res.status(401).json(UNAUTHORIZED(登录已过期请重新登录)); } } module.exports authRequired;需要补充的一个细节是Token 过期时间设定为 7 天对校园这种低频重入场景比较合适。如果用户改了密码我会把 tokenVersion 字段加一使旧 Token 全部失效。3.2 课表数据的建模与查询课表模块是校园通里数据结构最典型的。它不是一张简单的课程表因为需要考虑单双周、调课、临时停课等变化。我最初的设计只有 course_name、teacher、room、start_time、end_time结果遇到单双周直接就崩了。后来改造为字段类型说明idint主键user_idint所属学生course_namevarchar课程名称teacher_namevarchar教师roomvarchar上课地点day_of_weektinyint1-7周几time_slottinyint第几大节start_weekint开始周end_weekint结束周week_typetinyint0-每周1-单周2-双周notevarchar备注用于调课信息查询某个教学周课表时需要把 week_type 判断写在 SQL 里const sql SELECT * FROM schedules WHERE user_id ? AND start_week ? AND end_week ? AND ( week_type 0 OR (week_type 1 AND MOD(?, 2) 1) OR (week_type 2 AND MOD(?, 2) 0) ) ORDER BY day_of_week, time_slot ;这里的 MOD(?, 2) 就是判断当前周是单周还是双周。实际使用中学生课表数据大多从教务系统导入格式并不统一所以我做了一个 JSON 字段 source_data保存原始文本便于后续解析和纠错。3.3 二手交易里的状态机设计二手集市不能只做成“发帖展示”不然就和论坛没区别了。为了体现业务深度我给二手商品设计了状态流转发布后状态为 ON_SALE在售。买家联系并确认后由卖家将状态改为 RESERVED已预订。线下完成交易后改为 SOLD已卖出。卖家也可以主动下架状态变为 OFF_SHELF。状态机允许的转换关系如下当前状态允许转换到ON_SALERESERVED, OFF_SHELFRESERVEDON_SALE, SOLDSOLD无OFF_SHELFON_SALE实现状态更新的核心不是“写一个 UPDATE”而是先校验状态是否合法。我在 service 层里维护了一个转换表const stateMachine { ON_SALE: [RESERVED, OFF_SHELF], RESERVED: [ON_SALE, SOLD], SOLD: [], OFF_SHELF: [ON_SALE], }; async function updateProductStatus(productId, fromStatus, toStatus, userId) { const product await productModel.findById(productId); if (!product) throw new BusinessError(商品不存在); if (product.user_id ! userId) throw new BusinessError(无权限操作); if (product.status ! fromStatus) throw new BusinessError(商品状态已变化请刷新); if (!stateMachine[fromStatus].includes(toStatus)) { throw new BusinessError(非法状态流转); } await productModel.updateStatus(productId, toStatus); }传入 fromStatus 是为了防止并发情况下两个客户端同时操作。前端在做按钮渲染时也要参考 stateMachine比如只有 ON_SALE 状态才显示“标记为已预订”。3.4 失物招领与图片上传的坑失物招领模块看起来简单实际上最烦人的是图片上传。我用 multer 处理 multipart/form-data存储在服务器 public/uploads 目录下。需要注意的点包括const multer require(multer); const path require(path); const fs require(fs); const uploadDir path.join(__dirname, ../public/uploads); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const storage multer.diskStorage({ destination(req, file, cb) { cb(null, uploadDir); }, filename(req, file, cb) { const ext path.extname(file.originalname).toLowerCase(); const uniqueName ${Date.now()}-${Math.round(Math.random() * 10000)}${ext}; cb(null, uniqueName); }, }); const upload multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter(req, file, cb) { const allow [.jpg, .jpeg, .png, .gif, .webp]; const ext path.extname(file.originalname).toLowerCase(); if (!allow.includes(ext)) { return cb(new Error(仅支持图片格式)); } cb(null, true); }, });这里最容易踩的坑是“上传目录不存在”导致 multer 报错所以要在初始化时用 fs.existsSync 检查并创建。另一个坑是文件名不能直接用用户传来的原名否则容易造成路径穿越和重名覆盖我用时间戳加随机数重命名。图片上传后Express 还需要把 public 目录映射为静态资源app.use(/uploads, express.static(path.join(__dirname, public/uploads)));这样上传后返回的 URL 就是http://api.example.com/uploads/xxx.jpg前端通过拼接图片域名来展示。3.5 校园通知模块的缓存策略校园通知是典型的“读多写少”场景每天能发布的公告有限但学生会在首页反复刷新。因此我没有让每次请求都查数据库而是把首页公告列表缓存到 Redis。写入逻辑是管理员发布或编辑公告后删除对应的缓存 key查询时先从 Redis 取取不到再回源数据库并设置 10 分钟过期。async function getNoticeList(page, size) { const cacheKey notice:list:${page}:${size}; const cached await redisClient.get(cacheKey); if (cached) return JSON.parse(cached); const list await noticeModel.findPage(page, size); await redisClient.setEx(cacheKey, 600, JSON.stringify(list)); return list; }这里需要注意 Redis 不能缓存最新公告详情超过合理时间否则刚发的公告会延迟展示。我用的是“主动失效 短过期”双重策略既能减轻数据库压力又不会出现明显延迟。4. 从开发到部署的完整实操记录4.1 初始化 Express 服务与基础中间件新建 Node 工程后第一步是把 app.js 搭起来。我在这个文件里只做三件事加载中间件、挂载路由、统一错误处理。const express require(express); const cors require(cors); const bodyParser require(body-parser); const path require(path); const routes require(./routes); const errorHandler require(./middlewares/errorHandler); const app express(); app.use(cors()); app.use(bodyParser.json({ limit: 2mb })); app.use(bodyParser.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, public))); app.use(/api, routes); // 404 app.use((req, res) { res.status(404).json({ code: 404, message: 接口不存在 }); }); // 统一错误处理 app.use(errorHandler); module.exports app;这里使用 bodyParser 而不是直接 app.use(express.json()) 是历史习惯其实两者等价。重点是设置了请求体限制为 2mb避免有人通过超大 JSON 报文拖垮服务。启动脚本const app require(../app); const config require(../config/env); const port config.port || 3000; app.listen(port, () { console.log(API server running at http://localhost:${port}); });此时用node bin/www启动再配合 nodemon 开发热重载就够了。4.2 数据库建表与初始化脚本我写了独立的 schema.sql不通过 ORM 自动建表因为毕设答辩时能讲清楚每张表的存在意义会更加分。用户表CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL, password_hash varchar(100) NOT NULL, nickname varchar(32) DEFAULT NULL, avatar_url varchar(255) DEFAULT NULL, role tinyint NOT NULL DEFAULT 1 COMMENT 1用户 2管理员, token_version int NOT NULL DEFAULT 0, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;课程表CREATE TABLE schedules ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL, course_name varchar(50) NOT NULL, teacher_name varchar(32) DEFAULT NULL, room varchar(64) DEFAULT NULL, day_of_week tinyint NOT NULL, time_slot tinyint NOT NULL, start_week int NOT NULL, end_week int NOT NULL, week_type tinyint NOT NULL DEFAULT 0, note varchar(255) DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_week (user_id, start_week, end_week) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表完成后我写了 seed.js 插入一个演示账号、测试课表数据和几个二手商品示例这样前端开发时不需要手动造数据。数据库连接采用 mysql2 的 Promise 连接池const mysql require(mysql2/promise); const pool mysql.createPool({ host: config.dbHost, user: config.dbUser, password: config.dbPassword, database: config.dbName, waitForConnections: true, connectionLimit: 10, queueLimit: 0, charset: utf8mb4, }); module.exports pool;连接池的 connectionLimit 一定要根据服务器内存配置不要贪多。我这台机器只有 2G 内存开 10 个连接基本够用开会更高反而会拖慢系统。4.3 前端 APP 端对接 API 的封装方式uni-app 工程里我建议不要在每个页面直接写 uni.request而是封装一个 request 工具。核心逻辑是统一拼接 baseURL自动携带 token统一错误提示遇到 401 时跳转登录页。// utils/request.js const BASE_URL import.meta.env.VITE_API_BASE || http://localhost:3000/api; export default function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: uni.getStorageSync(token) ? Bearer uni.getStorageSync(token) : , }, success: (res) { if (res.statusCode 200 res.data.code 0) { resolve(res.data.data); } else if (res.statusCode 401) { uni.removeStorageSync(token); uni.navigateTo({ url: /pages/login/login }); reject(res.data); } else { uni.showToast({ title: res.data.message || 请求失败, icon: none }); reject(res.data); } }, fail: (err) { uni.showToast({ title: 网络异常, icon: none }); reject(err); }, }); }); }需要注意的是如果 baseURL 是http://localhost:3000/api在微信开发者工具里运行时会遇到本地调试的跨域问题。更好做法是把环境变量分成 development 和 production 两套开发环境走本地 IP生产环境走服务器域名。状态管理用 Pinia用户信息比如昵称、头像、角色都放 storetoken 仍然放在 uni 的 Storage 里。每次启动 APP 时读取本地 token调一次/api/user/info确认登录是否有效。4.4 PM2 加 Nginx 部署与日志排查部署阶段我把前后端拆成两个服务。前端 uni-app 经过 H5 构建后生成静态文件Nginx 直接托管后端 Node 服务跑在 3000 端口Nginx 把/api/路径反向代理过去。PM2 进程管理配置pm2 start bin/www --name campus-api --max-memory-restart 300M这句命令很关键。加了--max-memory-restart 300M后如果 Node 进程内存超过 300M 会自动重启避免内存泄漏把整个服务拖垮。Nginx 反向代理配置server { listen 80; server_name your-domain.com; location / { root /var/www/campus-app/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:3000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; proxy_set_header Connection ; } location /uploads/ { alias /var/www/campus-server/public/uploads/; expires 30d; add_header Cache-Control public, immutable; } }注意/api/反向代理时proxy_pass 后面如果带/api/会把完整路径传过去如果不带会去掉前缀。我在 Express 路由里统一挂的是/api所以这里要保持一致否则会出现 404。部署后的日志查看命令是pm2 logs campus-api遇到请求报错时日志里会有完整堆栈和 SQL 错误这是排查线上问题最快的方式。5. 常见问题速查与优化建议5.1 跨域和预检请求处理前端 H5 部署在your-domain.com接口请求your-domain.com/api时是同源不需要跨域。但开发阶段前端跑在 5173 端口后端跑在 3000就会出现跨域。解决办法是在 Express 里统一使用 cors 中间件。如果只想允许特定来源可以用const cors require(cors); app.use(cors({ origin: [http://localhost:5173, https://your-domain.com], methods: [GET, POST, PUT, DELETE, OPTIONS], allowedHeaders: [Content-Type, Authorization], }));遇到复杂请求时浏览器会先发 OPTIONS 预检。Express 的 cors 中间件默认会处理 OPTIONS如果自己写中间件就很容易漏掉。最直接的排查方式是用 curl 模拟curl -X OPTIONS http://localhost:3000/api/user/info -H Origin: http://localhost:5173 -H Access-Control-Request-Method: GET -v如果返回头里没有Access-Control-Allow-Origin就说明配置没生效。5.2 数据库连接池与连接释放新手最容易犯的问题是每次请求都mysql.createConnection然后忘记end()导致 MySQL 连接数暴涨报Too many connections。正确做法是全局只创建一次连接池。使用 mysql2/promise 的 pool.query 会自动从连接池获取连接并释放不需要手动管理。但如果用了pool.getConnection()开启事务必须在 finally 里connection.release()否则连接会被占用const connection await pool.getConnection(); try { await connection.beginTransaction(); await connection.execute(UPDATE products SET status ? WHERE id ?, [SOLD, id]); await connection.commit(); } catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }5.3 时间时区与金额精度MySQL 的 datetime 默认用的是数据库时区如果服务器设置的是 UTC而前端展示需要北京时间查询结果会差 8 小时。我建议数据库统一存储 UTC 或使用 DATETIME 存“无时区”的本地时间。后端返回给前端时统一为时间戳或YYYY-MM-DD HH:mm:ss字符串。前端只做展示不在多个环节各自转换。金额字段千万不要用 float 或 double。二手商品价格虽然金额不大但累加统计时会出现 0.1 0.2 不等于 0.3 的经典误差。我用的是 DECIMAL(10, 2)后端计算时用字符串或整数分处理避免浮点运算。5.4 文件上传路径与安全限制前面提到过 multer 的目录自动创建还有一个坑是 Windows 和 Linux 路径分隔符不一致。写代码时统一用 path.join不要自己拼/ folder / filename否则跨平台部署会出问题。另外上传文件的合法性不仅要看扩展名还要检查实际内容。简单场景下可以只做扩展名白名单加大小限制但如果做正式项目最好用图片解析库校验文件头防止上传伪装成图片的可执行文件。这套项目里我保留了扩展名白名单同时把上传文件保存到 public/uploads 而非项目根目录不给予执行权限。还有一个经常被忽略的细节上线后图片访问一定要加缓存头否则每次刷新 APP 商品图片都会重新请求流量消耗巨大。上面 Nginx 配置里的 expires 30d 就是干这件事。5.5 性能优化从哪些点下手毕设项目的并发量不会很高但优化思路必须在文档里体现清楚。第一层是数据库索引。所有表的外键和常用查询字段都建索引比如 schedules 表的 user_id、products 表的 user_id 和 status、notices 表的 publish_time。没有索引的全表扫描在数据量一千条时看不出问题但用 explain 一看就能发现 typeALL。第二层是 Redis 缓存热点数据。校园通知、首页轮播图、课表信息都可以设置短缓存。前提是明确缓存失效策略不要在可接受延迟的区间内用长过期。第三层是接口返回字段裁剪。列表接口只返回 id、标题、缩略图、价格等核心字段详情接口才返回大字段和描述文本。不要每次查询都 SELECT *尤其是有 text 类型字段的 table。第四层是减少无意义的请求次数。前端可以做防抖和分页列表用上拉加载代替一次请求。接口响应时间在校园 Wi-Fi 环境下经常超过 200ms如果前端渲染又是同步串行请求用户体感就会非常差。我在项目复盘里把每一次优化都对应到了具体接口/api/notice/list做 Redis 缓存/api/product/list做分页索引/api/schedule做用户维度缓存。这样在答辩时讲到性能优化不会空泛。最后再分享一点个人心得。做校园类项目最大的收获不是学会 Express 语法而是明白“功能闭环”到底怎么建立。很多代码在单独写教程时都能跑通但一旦把它们串成一个 APP就会出现跨域、token 过期、文件路径、状态并发这些实际问题。express“校园通”这套项目之所以值得认真做就是因为它逼着你把前端页面、后端接口、数据库状态、部署进程全部打通。你只要把其中一个闭环完整跑通比如发布二手商品、后台审核、前端状态变更、Redis 缓存失效就已经超过大部分只做“假页面”的毕设了。后续如果想继续扩展可以加入消息推送、即时聊天、地图定位但前提是先把现有的分层和状态机维持好不然每加一个功能都是在给原来的代码填坑。