做校园二手交易平台这个方向,网上现成的开源模板一抓一大把,但真正拿到手里就会发现,大多数模板只是把增删改查堆了一遍,商品发布、登录鉴权看着都有,真跑到交易环节就漏洞百出。我带着 Vue2 + Node.js + Element UI 这套组合从头写了一个高校二手物品交易系统,从需求梳理到数据库建模再到前后端联调,整个过程踩了不少坑,这里把完整的设计思路和实现细节整理出来,给正在做类似项目的同学一个参照。
这套系统的核心价值在于:它不是把商品挂出来就完事,而是把二手交易里最容易出问题的环节——订单状态流转、商品状态一致性、用户权限控制——都做了比较严谨的设计。适合已经掌握了 Vue 和 Node.js 基础语法、想做完整全栈项目练手的人,也适合做毕业设计时想拿一套"能跑通、讲得清"的方案去落地的同学。
1. 为什么是这套技术栈:从需求反推选型,而不是先定技术再套需求
1.1 前端选型:Vue2 加 Element UI 的组合依然能打
很多人一上来就问:都什么年代了,怎么不用 Vue3?如果你是自己练手,用 Vue3 当然没问题。但如果目标是在有限时间内交付一个稳定、文档全、踩坑少的项目,Vue2 配合 Element UI 反而是更务实的选择。
Element UI 对表单处理、表格展示、分页组件、弹窗确认这些后台管理场景的封装已经很成熟。二手交易系统里,商品发布页就是一个典型的复杂表单页,需要校验价格、描述、图片、分类等多个字段,Element UI 的表单校验机制配合自定义验证规则,能省掉大量手写校验逻辑的时间。另外,校园项目的评审老师或者协作同学,多数人对这套组件的熟悉程度也更高,后续交接维护的成本低。
Vue3 加 Element Plus 当然也可以,但 Element Plus 早期版本的一些组件行为和 Element UI 存在差异,对新手不太友好。我的建议是:项目周期短、求稳,就选 Vue2 加 Element UI;如果是想顺便学新技术,再考虑 Vue3 的组合。
1.2 服务端选型:Node.js 配 Express,专注业务逻辑本身
服务端我选的是 Node.js + Express。相比 Spring Boot 那套重型的工程化体系,Express 足够轻,启动快,中间件生态完善,能把精力集中在业务逻辑上,而不是消耗在环境配置。
这个决策背后有个很实际的理由:二手交易系统的业务复杂度其实不高,核心就是用户、商品、订单三个领域。用 Express 写路由,一个资源的增删改查就是十几行代码的事,配合 mysql2 连接池查询数据库,整个服务端代码可以控制在两千行以内,维护起来毫无压力。如果用 Java 那套,光工程结构、依赖注入、实体类映射就能劝退一批刚起步的开发者。
1.3 数据库选型:MySQL 为主,Redis 只做必要补充
系统里的商品、用户、订单、收藏这些核心数据,全部存放在 MySQL 中。MySQL 的事务机制是这个项目里最值得依赖的能力,尤其是订单创建时商品状态的流转,必须用事务来保证数据一致性。关于这一点,后面在订单模块会详细展开。
Redis 我只用在了两处:一处是存储登录 token 的黑名单,另一处是缓存商品分类列表。前者是安全需求,后者是性能优化。校园场景下并发量本身不高,Redis 引入的目的更多是让系统结构有扩展空间,不建议在一开始就把缓存用得到处都是,过度设计反而增加复杂度。我见过不少同学把商品详情也塞进 Redis,结果每次上架下架都要处理缓存同步,平白无故踩了很多坑。
2. 功能模块拆解与数据库建模:先想清楚有哪些状态,再动手写 CRUD
2.1 核心功能模块的边界划分
系统按角色拆成三端:买家端、卖家端和管理后台。这里容易被忽略的是,同一个用户在不同上下文里既是买家又是卖家,所以功能模块不能按"用户角色"划分,而应该按"业务域"划分:
- 用户域:注册、登录、个人信息维护、密码修改、发布的商品列表、买到的/卖出的订单列表
- 商品域:发布、编辑、上下架、图片上传、分类浏览、关键词搜索、收藏/取消收藏
- 交易域:买家下单、卖家确认、订单状态流转、订单取消
- 管理域:商品审核下架、用户封禁/解封、数据统计
模块边界划清楚之后,最大的好处是接口设计不会乱。比如商品详情页要在同一屏展示商品信息、卖家信息、收藏状态,我会把它设计成一个聚合接口返回,而不是让前端调三个接口再自己拼装。用聚合接口减少网络往返,这在校园网环境下体验差异还是很明显的。
2.2 核心表结构设计详解
数据库我一共建了六张表,每张表的设计都有对应的业务考量:
users 用户表
字段:id、username、password_hash、nickname、avatar_url、phone、campus、role、status、created_at。
其中 password_hash 必须存加密后的密文,绝不能存明文密码。加密方式我选了 bcrypt,自带加盐机制,比单纯的 md5 靠谱太多。role 字段区分普通用户和管理员,status 用于实现封禁逻辑。
categories 商品分类表
字段:id、name、sort。分类数据是高度稳定的,比如教材书籍、电子产品、生活用品、运动器材、其他。这张表做得很轻,sort 字段控制前端展示顺序。
goods 商品表
字段:id、title、description、price、original_price、category_id、images、status、seller_id、buyer_id、created_at、updated_at。
这里最核心的设计是 status 字段。商品状态我划分了四个:1 在售、2 已下架、3 已售出、0 审核中。buyer_id 在商品被下单时写入,用来记录最终的购买者。images 字段我存的是图片 URL 的 JSON 字符串,用逗号拼接也行,但 JSON 数组更规范。
orders 订单表
字段:id、order_no、goods_id、seller_id、buyer_id、price、status、created_at、updated_at。
order_no 是业务订单号,下单时生成,格式是时间戳加随机数,既保证唯一又可读。status 字段是订单状态机,下面会详细讲。这张表里的 price 字段存的是成交快照,即使商品后来改了价格,订单里的价格也不受影响——这是交易系统的基本准则,我刚做的时候在这个细节上疏忽过,导致改价后旧订单的金额跟着变,排查了半天。
favorites 收藏表
字段:id、user_id、goods_id、created_at。联合唯一索引建在 user_id 和 goods_id 上,防止重复收藏。
notifications 站内信表(后期补的)
字段:id、user_id、type、content、is_read、created_at。用于通知用户商品被下单、订单被取消等事件。
2.3 订单状态机的设计:交易系统的灵魂所在
订单状态是整个系统里最容易出 bug 的地方,我花了很大精力设计这组状态,最终确定为四个:0 待卖家确认、1 待买家确认收货、2 已完成、3 已取消。
下单后订单进入"待卖家确认"状态,对应商品状态变为"已被下单";卖家在订单列表里确认成交后,订单变为"待买家确认收货",此时商品状态是"已售出";买家点击确认收货,订单完成。任何一方在确认前都可以取消,取消后商品回到"在售"状态。
这个流程设计参考了闲鱼的交易模式,但不是照搬。校园二手交易通常是线下面对面交易,不存在物流环节,所以不需要"发货"这个动作,把状态的粒度控制到"双方确认"就够了。状态机的关键在于:订单状态迁移必须走统一的方法,不能散落在各个接口里直接改 status,否则业务一复杂就失控。
3. 登录鉴权与权限控制:JWT 方案在二手交易场景的落地
3.1 为什么选 JWT 而不是传统的 Session
校园项目里用户量不大,理论上 Session 方案完全够用。但 Session 方案需要服务端存储会话,用户登录状态多了以后,排查问题会比较麻烦。JWT 方案的好处是无状态,服务端不保存会话信息,token 里直接携带用户 ID、角色、过期时间,每次请求只需要验签就能确认身份。
这里有一个需要想清楚的取舍:JWT 的无状态是一把双刃剑。优点是扩展性好、适合前后端分离;缺点是 token 一旦签发,在过期之前无法立即失效。为了弥补这个缺陷,我在用户修改密码和封禁用户两个操作里,引入了 token 黑名单机制,把需要失效的 token 存进 Redis,每次校验时先查黑名单。封禁用户时,把这名用户当前有效的 token 全部拉黑,实现的效果是用户被踢下线。
另外,不要把敏感数据塞进 token 的 payload 里。JWT 的 payload 只是做 Base64 编码,不是加密,任何拿到 token 的人都能解码看到内容。我见过有人把用户手机号、身份证号写进 token 里,这是非常危险的做法。token 里只需要放 userId 和 role,需要用户详细信息时再去数据库查。
3.2 签发与校验的完整流程
登录接口的处理逻辑是这样的:校验用户名和密码通过后,用 jsonwebtoken 生成 token,payload 是 userId 和 role,设置过期时间为 24 小时。登录成功响应时,把 token 和用户基本信息(昵称、头像)一起返回给前端。
前端拿到 token 后存在 localStorage 里,后续每个请求通过 axios 的请求拦截器在 header 里携带Authorization: Bearer <token>。后端写一个 auth 中间件,统一解析并校验 token,校验通过后把 userId 挂载到 req 对象上,后续接口直接从 req.userId 获取当前用户身份。
这里要注意一个细节:axios 响应拦截器里要统一处理 401 状态码。token 过期后,后端返回 401,前端拦截到 401 就清除本地登录信息并跳转到登录页。如果不做统一处理,用户会遇到"页面看着正常,一点按钮就白屏"的诡异情况。
3.3 前端路由守卫与权限控制
Vue 前端用 vue-router 的全局前置守卫实现页面级权限控制。我在路由元信息里标记哪些页面需要登录、哪些页面需要管理员权限,守卫里根据 token 和用户角色判断是否放行。
路由守卫只能管到页面入口,接口层面的权限还是要后端控制。比如卖家编辑商品、删除商品这类操作,不能只根据登录状态判断,还要校验操作人是否为商品所属的 seller_id。我当时写了一个 ownership 中间件,查询商品详情后统一判断当前用户是否有操作权限,避免每个接口里都重复写一遍判断逻辑,接口的安全性就靠这一层托底。
4. 商品发布与订单流转:最容易被忽略的数据一致性问题
4.1 商品发布接口与图片上传的实现细节
商品发布这个功能看似简单,但涉及图片上传时踩了很多坑。前端表单里用 Element UI 的 el-upload 组件,我用的是手动上传模式,用户选完图片后先调用图片上传接口,拿到 URL 后存入表单数据,最后随商品信息一起提交。
图片上传接口基于 multer 中间件实现。这里有一个新手很容易踩的坑:直接用 base64 格式上传图片,一张几 MB 的图转成 base64 后会膨胀约三分之一,而且 Express 默认的 JSON 解析器对请求体大小有限制,图片一大接口直接 413。正确做法是表单二进制上传,前端用 FormData 包装文件,后端配置 multer 的存储目录和文件名规则。
文件名的生成我采用的是"时间戳 + 随机串 + 原始扩展名"的格式,坚决不用用户传来的原始文件名,防止路径穿越和重名覆盖。同时校验文件类型白名单,只允许 jpg、png、webp 格式,限制单个文件不超过 5MB。项目里我额外配了图片压缩,前端拿到图片后先 canvas 压缩到合适尺寸,再走上传接口,服务端压力小很多。
4.2 下单流程的事务边界:"唯一资源"的并发保护
这是整个项目里我花最多时间论证的部分。
下单操作的业务逻辑是:校验商品状态是在售、校验当前用户不是卖家本人、创建订单、把商品状态改为已被下单/已售出。四个操作必须是一个原子性的整体,任何一个失败都应该回滚全部。
实现上我用的是 mysql2 的事务机制。开启事务后先查商品状态,用SELECT ... FOR UPDATE对商品行加锁,防止并发场景下两个买家同时抢到同一件商品。如果商品状态不是"在售"就直接回滚并抛出业务异常,否则插入订单记录,更新商品状态,最后提交事务。
这里有个重要的实现细节:商品状态更新应该写成条件更新,UPDATE goods SET status = ? WHERE id = ? AND status = 1,配合影响行数判断。如果更新影响的行数为 0,说明商品状态已经被改过,事务回滚。这种"乐观锁 + 条件更新"的双保险,能有效避免并发下单导致的数据错乱。项目做完后我对这一块做了并发压测,模拟 20 个请求同时抢同一件商品,最终只有一个订单成功创建,其余全部被正确拦截。
4.3 订单取消与状态回滚
订单取消要考虑两种触发方式:用户主动取消和系统超时自动取消。主动取消的场景里,用户点击取消按钮,后端校验订单状态必须在"待卖家确认"或"待买家确认收货"这两个可取消状态内,然后事务性地把订单状态改为已取消,同时把商品状态回滚为"在售",并释放 buy_id。
自动超时取消是我在开发后期补的功能。如果买家下单后卖家长时间不处理,会一直占着商品的可售状态。我用 node-schedule 做了定时任务,每五分钟扫描一次超过 24 小时未确认的订单,批量执行超时取消逻辑。这个定时任务在上线初期确实兜住了一些"下了单又消失"的情况,算是很实用的补充。
5. 开发过程中踩过的坑:完整排查链路分享
5.1 Element UI 表单校验的自定义规则陷阱
商品发布页的价格和图片校验我用了自定义规则,刚开始是怎么写都不生效,表单提交时校验通过,但后端收到的数据是空的。
排查链路是这样的:先在前端打印表单数据,确认 el-form 绑定的 model 字段名和后端接口字段名完全一致。问题不在字段名。后来在自定义校验规则里加 console.log,发现校验函数根本没被调用。这才意识到 Element UI 的自定义 validator 有个特性:内部回调 callback 必须被调用,否则校验会一直处于 pending 状态,整个表单的 validate 永远不会返回结果。我的问题出在一个异步校验分支里忘了调用回调函数,导致表单校验永久等待。
解决方式很直白:在自定义校验函数的每个分支里,无论如何都保证调用一次 callback。另外要注意 trigger 参数的设置,输入框用 blur 和 change 双触发,选择器组件默认的 change 触发在特定场景下不会按预期工作,显式声明 trigger 能规避很多隐蔽问题。
5.2 上传图片后刷新页面"图片丢失"的排查过程
这是一个非常典型的部署环境问题。开发环境一切正常,部署到服务器后,上传图片成功,但刷新页面图片就 404 了。
排查链路:先看浏览器 network 面板,确认图片请求落在静态资源目录。发现上传目录里的文件确实存在,但请求路径不对。原来我用的是相对路径存储(如uploads/xxx.jpg),开发时前端页面直接访问后端服务,路径自然正确。部署后前端静态页和后端 API 分离部署,相对路径解析到了前端服务器的地址,自然找不到图片。
这个坑的处理方式是两处改动:一是后端统一返回绝对路径(基于配置的服务器域名加图片相对路径),二是用 Nginx 把/uploads路径反向代理到后端静态资源服务。这里顺便提醒一句:上传目录一定不要放在前端构建产物里,否则每次重新部署前端,上传的图片就被清空了。我最初就把上传目录放在前端工程目录下,一天内被清了两次,教训惨痛。
5.3 跨域问题与联调阶段遇到的请求"神秘消失"
前后端分离开发时,跨域问题是绕不开的。开发阶段我用 Vite 的 proxy 配置做代理转发,生产环境用 Nginx 做反向代理,这样浏览器视角下 API 请求都是同源的,规避了 CORS 的复杂度。
排查过一个跟跨域相关但更隐蔽的问题:联调时发现某些接口总是不定时地请求不到,表现是浏览器 network 里请求被打成 pending,迟迟没有响应。一开始以为是接口代码问题,看后端日志发现请求根本没到服务端。最终定位是浏览器对同一域名下的并发连接数限制了。这跟代码其实没关系,真正的解决方式是让静态资源和 API 走不同域名或至少不同端口,或者开启 HTTP/2 支持多路复用。这是我踩过的一个偏冷门的坑,写在这里提醒遇到的同行不用在业务代码里白费功夫。
5.4 时间显示时区错乱
订单创建时间的展示一开始慢吞吞出现了 8 小时的偏差。排查发现 MySQL 的 datetime 类型没有时区概念,Node.js 服务默认按 UTC 时间读取,再通过 JSON 序列化返回给前端,前端拿到的是 UTC 时间字符串,而国内时间是 UTC+8,于是全部错位。
这里的正确处理方式是:数据库连接配置里声明时区,比如timezone: 'Z'或指定+08:00;后端统一返回时间戳或 ISO 字符串;前端统一用 dayjs 处理格式化展示。我选了"后端返回时间戳"的方案,因为这样前后端的时间解析完全由前端掌控,不会再出现跨时区偏差。这类问题在本地开发时往往不明显,一旦部署到云服务器或者环境变量有差异,就集中爆发了。
6. 系统扩展思考与实际运行感受
项目写完上线运行了一段时间,整体稳定,核心交易链路没有出过数据错误。要说感受,最深刻的一点是:这类系统的难点从来不在写多少页面、有多少炫酷交互,而在于把业务状态和数据一致性管好。状态机设计得清楚,事务边界划分得明白,这个系统就成功了一大半。
关于扩展,目前最值得做的是引入消息队列去做超时订单处理,而不是用定时任务去轮询,这对于数据量增长后的可靠性更有保障。另一个方向是给商品增加检索能力,当前的关键词搜索基于 SQL LIKE 模糊查询,几百件商品时完全够用,但商品数据过万后查询性能就会明显下降,到那时可以考虑接入全文检索或者专业搜索引擎组件。对校园场景来说,复用内网环境还是优先方案,搜索慢与搜索精确是两个不同的优化维度。
如果你正在做类似的项目,我的建议是先沉下来把数据库表和状态流转设计夯实,不要在页面样式和交互炫技上花费过多时间。数据库是地基,状态机是骨架,这两样东西稳固了,后续的功能迭代都会变成一件很轻松的事情。这个项目做完之后,我对"用工程化的思维做全栈系统"有了更具体的认识,也推荐每个想提升实战能力的开发者完整地走一遍这种交易类系统的开发流程。