
每年做毕业设计校园二手物品交易平台这类题目从来没冷过。我前后带过好几个学弟学妹做类似的项目自己也完整用 node.js 加 vue 从零搭过一个校园二手物品信息发布平台从需求拆分、数据库设计到前后端联调、部署上线整条链路都走了一遍。这篇文章不写那种泛泛的“项目总结”而是把这个平台的设计与开发过程拆开来讲包含表结构怎么定、接口怎么设计、前端页面怎么组织、环境怎么搭、上线会踩哪些坑最后给出能直接照抄的启动步骤和排查思路。如果你正打算拿这个题目当毕业设计或者想练手一个前后端分离的完整项目这篇应该对你有用。1. 校园二手平台的需求画像与技术选型思路1.1 为什么这个题目年年有人做校园二手物品平台的需求其实非常具体每年毕业季教材、自行车、小家电、宿舍神器这些东西扔了可惜留着又带不走新生入学又要重新买。线下交易靠群聊刷屏、贴海报信息很快就沉底了买卖双方匹配效率很低。所以做一个“校园范围内的信息发布与撮合平台”业务逻辑天然清晰供需两端都真实存在。从项目开发角度看这类平台的功能边界也特别适合一个人完成。核心模块无非是用户体系、商品信息管理、收藏留言交互再加上一个简单的管理后台。不涉及支付、物流这些复杂链路但增删改查、文件上传、权限控制、搜索过滤这些基本功全都能练到。面试时讲项目经历这个题目也拿得出手——数据模型、接口设计、登录鉴权都能展开讲不会像“仿某某电商”那样撞车撞到烂大街。1.2 技术栈为什么选 Node.js Vue现在做这个题目的常见组合有 Java Spring Boot Vue、Node.js Vue、PHP Vue 几种。我选择 Node.js 有几点实际考虑。第一环境成本低。装一个 Node.js LTS 版本npm 全局装好脚手架项目就能跑起来。对很多人的机器来说比配 JDK、Maven、Spring 那套要轻快得多。第二前后端语言统一。前端 Vue 写 JavaScript后端 Express 也是 JavaScript调试时不需要在两种语言心智模型之间来回切换。比如前端传了个对象后端直接 console.log 就能看结构类型问题少很多。第三Express 本身非常轻路由中间件的写法直观适合教学和二次扩展。相比 Spring Boot 那一套注解和依赖注入Node 的中间件链更容易向新手解释“请求进来之后经历了什么”。当然这不是说 Spring Boot 不好如果你已经熟练 Java用 Spring Boot 完全没问题。但如果你是这个方向的新手或者想尽快跑通全流程Node.js 的性价比确实更高。前端选 Vue 同理组件化开发思路清晰Element UI 一套组件库就能覆盖后台表格、表单、弹窗这些高频场景不需要自己折腾 UI 细节。1.3 功能模块怎么切分把需求落到开发上我习惯先按角色拆分再按核心流程排序。普通用户侧注册登录用户名、密码、昵称、头像、手机号、学院信息浏览商品首页列表、分类筛选、关键字搜索、商品详情发布商品标题、描述、价格、原价、分类、图片上传、联系方式管理自己发布的商品编辑、下架、删除、查看浏览量收藏与留言收藏感兴趣的商品、在详情页留言咨询管理员侧登录后台分类管理增删改分类商品管理查看全部商品、下架违规内容用户管理查看用户列表、禁用账号开发顺序建议先做用户注册登录再做商品发布和列表然后补收藏留言最后做管理后台。这个顺序能保证每个阶段都有一个可演示的闭环。2. 表结构设计和接口规划先把数据流走通2.1 数据库拆成几张表才合适这类平台我最终设计了六张核心表用户表、商品表、分类表、收藏表、留言表以及一个简单的轮播图表可选非必须。对于毕设或者练手项目这个粒度刚刚好不需要过早引入订单表、交易表这些容易把项目撑爆的东西。用户表字段大致如下字段类型说明idint主键自增usernamevarchar(50)登录名唯一passwordvarchar(255)bcrypt 加密后的密码nicknamevarchar(50)昵称avatarvarchar(255)头像 URLphonevarchar(20)联系电话collegevarchar(100)学院/校区信息roletinyint0 普通用户1 管理员statustinyint0 正常1 禁用create_timedatetime注册时间商品表是核心字段要权衡好展示和查询的需要字段类型说明idint主键user_idint发布者 ID关联用户表titlevarchar(100)商品标题descriptiontext详细描述pricedecimal(10,2)售价original_pricedecimal(10,2)原价可选category_idint分类 IDimagesjson图片 URL 数组JSON 存储statustinyint0 在售1 已售2 下架viewsint浏览量create_timedatetime发布时间update_timedatetime更新时间这里有一个容易被纠结的点商品图片是单独建一张 image 表还是用 JSON 字段直接存数组我实际做下来在校园二手这种轻量场景下单独建表会让查询多一次 join而且处理图片主图、排序、删除这些逻辑都会变复杂。直接用 JSON 字段存一个数组后端解析、前端渲染都简单直观。等图片数量或并发上去了再考虑拆表完全来得及。收藏表和留言表结构比较固定。收藏表要有 user_id 和 goods_id 的联合唯一索引防止一个人重复收藏同一件商品。留言表要留一个 reply_to 字段支持回复层级虽然大多数毕设场景用不到多级回复但留一个字段成本很低后面要扩也方便。索引方面goods 表的 category_id 建普通索引title 建一个前缀模糊索引配合搜索。不要一开始就上全文索引数据量没到那个程度反而会带来索引维护成本。2.2 接口设计RESTful 风格和统一返回结构后端接口我用 RESTful 风格组织统一加 /api/v1 前缀版本号留好后面再接其他端也方便。几个典型模块的接口路径是这样的模块接口方法说明用户/auth/registerPOST注册用户/auth/loginPOST登录返回 token用户/user/profileGET获取当前用户信息用户/user/profilePUT修改个人信息商品/goodsGET商品列表支持分页、分类、关键字参数商品/goods/:idGET商品详情商品/goodsPOST发布商品需登录商品/goods/:idPUT编辑商品仅本人商品/goods/:idDELETE删除商品仅本人或管理员收藏/collectPOST收藏商品收藏/collect/:goodsIdDELETE取消收藏收藏/collect/listGET我的收藏列表留言/messagePOST发布留言留言/message/listGET查询某商品的留言列表上传/uploadPOST图片上传返回图片 URL所有接口统一返回结构{ code: 0, data: {}, message: ok }code 为 0 表示成功非 0 表示业务异常。前端 axios 拦截器里只需要判断 code 是否为 0 就能统一处理错误提示不需要每个页面写 try catch。HTTP 状态码也保留语义401 返回未登录、403 返回无权限、404 返回资源不存在方便排查问题。2.3 登录鉴权为什么用 JWT 而不是 Session前后端分离的项目最核心的差别在于前端页面和后端服务可能部署在不同域名或端口下。Session 方案依赖 Cookie跨域时要处理 CORS 携带凭证、SameSite 策略这类问题调试起来非常烦。JWT 方案无状态服务端不用存会话登录成功后返回一个 token前端保存后续每次请求在请求头带上即可。我在项目里的做法是登录接口验证用户名密码通过后签发一个 7 天过期的 tokenpayload 里只放 user_id 和 role 两个核心字段。后端写一个 auth 中间件解析请求头里的 Authorization: Bearer token验证通过后把 user_id 挂到 req 对象上后续业务接口直接取用。关键代码示意// 登录接口的部分逻辑 const bcrypt require(bcryptjs); const jwt require(jsonwebtoken); router.post(/login, async (req, res) { const { username, password } req.body; const user await db.queryOne(SELECT * FROM user WHERE username ?, [username]); if (!user || !bcrypt.compareSync(password, user.password)) { return res.json({ code: 1, message: 用户名或密码错误 }); } const token jwt.sign( { id: user.id, role: user.role }, SECRET_KEY, { expiresIn: 7d } ); res.json({ code: 0, data: { token, userInfo: { id: user.id, username: user.username, nickname: user.nickname, avatar: user.avatar, role: user.role } } }); });密码一定要用 bcryptjs 做哈希绝对不要明文存数据库。bcrypt 的对比是自带盐的相同密码每次加密结果都不同安全性比普通 md5 高很多。有一点要提醒SECRET_KEY 不要写死在代码里放到项目根目录的 .env 文件中部署时通过环境变量注入这个习惯从第一天就要养好。3. Vue 前端的关键实现细节3.1 工程目录怎么组织才不乱前端我用 Vue CLI 创建的项目结构上不搞花活按职责分目录就好。src 下的核心目录views页面级组件一个路由对应一个文件夹components复用组件比如商品卡片、图片上传组件、空状态组件api接口请求模块按用户、商品、收藏、留言拆文件router路由配置store全局状态用来存用户信息utils工具函数比如 token 的读写api 目录下我建议每个模块单独一个文件比如 goods.js 里只放商品相关的请求函数import request from /utils/request; export function getGoodsList(params) { return request({ url: /goods, method: get, params }); } export function getGoodsDetail(id) { return request({ url: /goods/${id}, method: get }); } export function publishGoods(data) { return request({ url: /goods, method: post, data }); }这样页面组件里只需要 import 对应函数不需要关心 url 怎么拼、参数怎么传后端改接口时只动 api 目录页面层完全不受影响。3.2 路由守卫和页面权限控制的实现路由表里给需要登录的页面加 meta 标记比如{ path: /publish, component: () import(/views/GoodsPublish.vue), meta: { requiresAuth: true, title: 发布商品 } }然后在全局前置守卫里统一判断router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }); } else { next(); } });未登录用户点击“发布商品”或“我的收藏”时自动跳到登录页并带上 redirect 参数记录原始目标地址。登录成功后从 query 里取出 redirect 回跳体验会好很多。管理后台的路由单独加 requiresAdmin 标记守卫里除了检查 token还要检查本地存的 userInfo.role 是否为管理员。前端控制只是门槛后端接口同样要做权限校验管理员接口必须校验 role不能只靠前端隐藏入口。这个我在后面接口设计里也会强调。3.3 商品发布页与图片上传的完整做法商品发布页是整个前端交互最重的页面主要包含表单验证和图片上传两块。图片上传我用 Element UI 的 el-upload 组件action 指向后端的上传接口headers 里动态带上 token。后端用 multer 处理文件存储核心配置const multer require(multer); const path require(path); const storage multer.diskStorage({ destination: (req, file, cb) cb(null, uploads/), filename: (req, file, cb) { const ext path.extname(file.originalname); cb(null, Date.now() - Math.round(Math.random() * 1e9) ext); } }); const upload multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });这里有个必须注意的点文件名一定要重命名不能直接用用户上传的原始文件名。一方面是为了避免中文文件名和特殊字符在 URL 解析时出问题另一方面是防止重名文件互相覆盖。我用“时间戳 随机数 扩展名”的方案实测下来安全性够用。上传接口返回图片的访问 URL前端拿到后存进表单的图片数组提交商品时一起传给后端。后端把图片数组 JSON 序列化后存进 goods 表的 images 字段。商品详情页展示时前端再 JSON.parse 还原成数组用轮播图或缩略图形式展示。3.4 商品列表、搜索和详情页的数据流首页商品列表的逻辑不难但有几个细节容易做糙。第一是搜索防抖。搜索框绑定输入事件后如果每敲一个字符就发一次请求后端压力大前端也会因为响应乱序出现搜索结果错乱。我习惯用 lodash 的 debounce 或者自己写一个 300ms 的定时器用户停止输入后才真正发起请求。watch: { keyword() { clearTimeout(this.timer); this.timer setTimeout(() { this.page 1; this.loadGoods(); }, 300); } }第二是分页和筛选参数的状态管理。列表页要同时维护 page、pageSize、categoryId、keyword 这几个参数每次搜索、切分类都要重置 page 为 1不然会出现搜完结果还在第 5 页的尴尬情况。详情页的逻辑主要是两个展示商品基本信息、加载留言列表。另外每次进入详情页前端调一个接口给商品浏览量加 1。这个操作不用阻塞页面渲染直接在请求详情成功之后异步调一下就行。有一个容易忽略的问题编辑商品时原本的图片数组是响应式数据直接赋值给表单会跟原数据产生关联改一个另一个也变。我实际做的时候踩过一次后来改成深拷贝或者直接从详情接口重新拉数据再填充表单问题就消失了。4. 从零搭环境到跑通前后端联调4.1 环境版本选择上的讲究Node.js 版本直接选官网 LTS 版本不用追最新。很多老教程会教你装 node-sass那东西和 Node 版本强绑定装不上是常事。现在新项目都推荐用 dart-sass完全没有这个问题所以如果看到网上教程让你装 node-sass直接跳过找 sass 和 sass-loader 的现代写法。npm 注册源建议换成国内镜像命令就一行npm config set registry https://registry.npmmirror.com这一步能省掉大量“装包装到一半卡死”的时间。MySQL 用 8.0 常见版本就行安装时注意记住 root 密码。后端代码里连接数据库不建议用 root单独建一个项目专用账号并授权这是个安全习惯也方便后面排查权限问题。4.2 后端项目初始化和跑通的最小步骤后端我用 Express 手动搭建不用脚手架这样每一步都是可控的。新建项目目录后npm init -y npm install express mysql2 jsonwebtoken bcryptjs multer cors dotenv npm install -D nodemonpackage.json 里加一个 dev 脚本scripts: { dev: nodemon app.js, start: node app.js }app.js 是入口文件依次挂载中间件const express require(express); const cors require(cors); const path require(path); require(dotenv).config(); const app express(); app.use(cors()); app.use(express.json()); app.use(/uploads, express.static(path.join(__dirname, uploads))); app.use(/api/v1/auth, require(./routes/auth)); app.use(/api/v1/goods, require(./routes/goods)); app.use(/api/v1/collect, require(./routes/collect)); app.use(/api/v1/message, require(./routes/message)); app.use(/api/v1/admin, require(./routes/admin)); app.listen(3000, () console.log(Server running on 3000));数据库连接用连接池方式不要每个请求都创建一个新连接const mysql require(mysql2); const pool mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, charset: utf8mb4 }); module.exports pool.promise();启动后端后先用一个简单的测试接口确认服务正常再继续开发业务接口。4.3 前端项目初始化和代理配置前端用 Vue CLI 创建npm install -g vue/cli vue create campus-mall选默认配置或手动选择 Router、Vuex 都可以组件库我用的 Element UI。前端代码的核心配置在 vue.config.js 里的 devServer 代理module.exports { devServer: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } };这样前端请求 /api/v1/goods 时开发服务器会自动转发到后端的 3000 端口绕开跨域问题。这个方案比后端开 CORS 更符合生产环境的部署方式后面部署到服务器时Nginx 也是同样的代理思路。4.4 第一次联调的验证路线环境和代码都准备好之后启动顺序有讲究。先把 MySQL 服务打开确认数据库和表已经建好。然后启动后端看到端口监听日志。再启动前端浏览器访问本地地址。第一次跑通时我建议按这条路径完整走一遍注册一个新用户检查数据库里是否多了一条记录密码字段是否是加密后的哈希值登录拿到 token看 localStorage 里是否正常存在发布一个带图片的商品去 uploads 目录看文件是否真实落盘首页看到这条商品点击进入详情页搜索这个商品的关键字确认列表能筛出来收藏这个商品去“我的收藏”页确认在详情页发一条留言刷新页面确认留言不丢这套流程走完主链路基本就通了剩下的是业务细节和管理后台的完善。5. 联调部署中高频报错的排查记录5.1 跨域问题的两种解法前后端分离项目里最常见的报错之一就是浏览器控制台出现 Access-Control-Allow-Origin 相关的提示。这个问题的本质是浏览器同源策略拦截了跨域请求。开发环境我推荐用 Vue CLI 的 proxy 方案上面已经配置过前端代码里不需要写完整后端地址统一走相对路径 /api。生产环境部署时用 Nginx 做反向代理把 /api 开头的请求转发到 Node 服务端口同样能解决。后端配置 CORS 是兜底方案适合临时调试或接口要开放给第三方用的情况但生产环境主要还是靠 Nginx 代理统一入口。排查跨域问题时有个技巧直接看浏览器 Network 面板里请求的响应状态。如果请求发出去了但响应被拦截往往是 CORS 头的问题如果请求根本没到服务器那是代理配置的问题检查前端 proxy 路径和后端路由前缀是否一致。5.2 图片上传 404 和打包后白屏的路径坑图片上传成功后前端显示 404这是高发问题根因通常是后端没有把 uploads 目录映射为静态资源。Express 里要显式声明app.use(/uploads, express.static(path.join(__dirname, uploads)));否则文件确实存到了服务器磁盘上但前端访问不到。另外要注意 uploads 目录必须真实存在multer 不会自动创建多级目录第一次运行前手动建好或者用 fs.mkdirSync 递归创建能少踩一个坑。前端打包后的路径问题也很典型。默认 Vue CLI 打包产物的静态资源路径是绝对路径 /部署到服务器子目录或者其他端口时全是白屏。解决办法是在 vue.config.js 里设置module.exports { publicPath: ./ };这样资源路径变成相对路径部署到哪里都不会因为路径不对导致白屏。还有一个 Vue Router 的坑如果用 history 模式刷新页面时后端服务器不认识 /goods/123 这类路径会返回 404。开发时没问题生产部署必须在 Nginx 里加 try_files 规则location / { try_files $uri $uri/ /index.html; }如果不想配 Nginx直接用 hash 模式也能规避只是 URL 里会多一个 #美观度差点。5.3 数据库连接常见的三个报错场景后端跑起来后数据库相关的报错集中在几个地方。第一个是 ECONNREFUSED。这表示 Node 进程连不上 MySQL优先检查 MySQL 服务有没有启动端口是不是默认的 3306以及连接配置里的 host 是否写对了。第二个是 ER_ACCESS_DENIED_ERROR。用户名密码错误或者该用户没有对应库的权限。排查时先在命令行用同样的账号密码手动连接一次能连上说明后端配置没问题连不上就重新授权或改密码。第三个是连接超时或 Too many connections。毕设这种小体量项目原因通常是代码里每次请求都新建了连接而没有释放。解决办法就是用连接池并且把 waitForConnections 设为 trueconnectionLimit 设一个合理值比如 10。字符集问题也值得提一句。创建数据库和表时统一指定 utf8mb4连接配置里也带上 charset: utf8mb4。不然用户发布的中文描述可能出现乱码而且这个乱码在数据库里已经存错了改配置也不会修复已有数据只能重建数据。5.4 部署到服务器的最小操作清单本地跑通后部署到服务器上其实没有想象中复杂。我常用的最小操作路径是后端部分代码上传到服务器后安装依赖、配置 .env 环境变量、用 PM2 启动npm install --production pm2 start app.js --name campus-mall pm2 save前端部分本地执行 npm run build把 dist 目录上传到服务器Nginx 配置指向 dist 目录同时代理 /api 到后端的 3000 端口server { listen 80; server_name your-domain.com; root /var/www/campus-mall/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:3000; } }配置好后执行 nginx -s reload。这个结构下Nginx 负责静态文件托管和反向代理Node 进程只处理 API 请求职责清晰也方便以后加 HTTPS。实用避坑清单与项目经验总结最后把做这个项目过程中积累的关键经验整理成清单每条都是实操中真实踩过或验证过的适合你在开发时对照检查。密码必须哈希存储用 bcryptjs不要用明文或 md5这是底线问题。图片文件名必须重命名用时间戳加随机数避免中文乱码和覆盖冲突。商品图片用 JSON 字段存数组不要在早期过度设计成子表。商品列表接口一定要做分页前端要处理搜索后重置页码不然翻页和搜索组合起来逻辑会乱。接口统一返回结构前端 axios 拦截器统一处理 code 和错误提示不要每个页面各自为政。所有涉及登录后才能操作的后端接口必须在后端校验 token不能只靠前端隐藏按钮。开发环境的跨域交给 Vue CLI proxy生产环境的跨域交给 Nginx 反向代理后端 CORS 只做兜底。上传目录要预先建好Express 的静态资源映射要显式配置否则图片上传成功但前端 404。打包部署时记得设置 publicPath 为相对路径Vue Router history 模式要配 try_files。启动顺序永远是数据库先行再后端最后前端联调验证时按主线流程走一遍别跳步。这套平台做下来我最深的体会是信息发布类系统的技术难点其实不在增删改查而在图片处理、登录态维持、搜索防抖这几个容易忽略的细节上。如果你也是从零开始做这类项目建议从一开始就把启动流程和踩坑记录写进 README等最后写论文、录演示视频的时候你会感谢当初记下这些的自己。另外演示数据一定要提前造好至少覆盖三四个分类、不同价位段和不同成色的商品页面才会撑得住演示效果才能拉满。