
这套源码我前后维护过不少版本也帮人排查过一堆环境问题。之前有不少同学习惯把微信群里刷屏的闲置信息当作交易渠道退群、翻记录、被放鸽子都是家常便饭。后来我基于 SpringBoot Vue MySQL 做了一套校园闲置物品交易系统把商品发布、浏览、搜索、交易留言和个人中心串成完整闭环前后端分离项目结构清楚本地配置好数据库就能跑。这篇文章把系统的核心设计、后端接口思路、前端页面逻辑、部署步骤和常见坑一次讲透适合正在做课程设计、毕业设计或者想入门前后端分离项目的开发者参考。1. 这个系统解决的核心问题以及技术选型背后的考量1.1 校园闲置物品交易的真实场景是什么样的校园里的闲置物品交易和闲鱼这类平台有本质区别。交易双方基本限定在同一所学校甚至同一个校区信任基础更强线下见面也方便。但正因为是熟人圈层大家普遍不愿意挂到公开平台去卖嫌审核麻烦、物流麻烦、沟通成本高。微信群成了默认渠道而微信群的最大问题是没有结构一屏消息五分钟刷过去商品信息、价格、联系方式全部混在聊天记录里想找个能用的台灯你得翻半天聊天记录还可能别人早就出掉了。内部系统会干活是很不一样的。系统里有明确的商品分类和上下架状态用户注册后可以发布闲置浏览时按分类过滤和关键词搜索看中之后通过订单留言和卖家联系最后线下见面交易。整个过程有记录、有状态、有追溯比群聊靠谱得多。1.2 为什么选 Spring Boot Vue MySQL 这套组合先说后端。Spring Boot 最大的优势是约定优于配置项目起来之后不用像传统 SSM 那样做大量 XML 配置内嵌 Tomcat直接就能跑。写 REST API 非常顺手。它又有成熟的生态JWT 鉴权、MyBatis-Plus 持久层、文件上传、拦截器这些都有现成方案可以接。对于校园交易这种业务逻辑中等复杂的系统Spring Boot 的工程量是刚好合适的。前端选 Vue 也有现实原因。Vue 的组件化适合把商品卡片、图片轮播、表单校验这些页面拆开维护配合 Element UI 组件库页面做出来又快又整洁。前后端分离的模式下前端可以单独起一个开发服务器通过代理转发请求到后端部署时再打包成静态文件交给 Nginx。这套模式是目前中小型 Web 项目的主流玩法学一次后面做其他项目也能复用。数据库用 MySQL 不用多说免费、稳定、资料多。实际开发里遇到的坑大多不是 MySQL 本身的问题而是版本驱动、字符集、时区这些边界细节后面我单独讲。提示这套技术栈同时也是面试和课设中出现频率最高的组合之一。把它完整跑通等于把从前端页面到后端接口再到数据库存储的整条链路都过了一遍价值不只是拿到一个项目而是理解一条开发主线。2. 数据库表设计先想明白业务模型再写代码2.1 业务模块的划分和核心流转路径开始动代码前我最关心的是数据模型。这直接决定了后续功能的扩展空间。校园闲置交易系统按业务拆主要有四块用户体系注册、登录、个人信息维护商品体系发布闲置、分类浏览、搜索、上下架交易流程买家发起购买意向、卖家确认、线下交易完成个人中心我发布的商品、我买到的商品、信息修改核心流转路径就是用户注册登录 - 发布商品或浏览商品 - 看中商品后下单留言 - 卖家看到意向并联系 - 线下交易完成。在这条路径上最关键的两张表是用户表和商品表交易单表是连接两者的桥梁。2.2 关键表的字段定义与设计思路下面是我在实际项目中确认过的表结构可以直接照着建。用户表sys_userCREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(100) NOT NULL COMMENT 加密后的密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, school varchar(100) DEFAULT NULL COMMENT 学校/校区, phone varchar(20) DEFAULT NULL COMMENT 联系电话, wechat varchar(50) DEFAULT NULL COMMENT 微信号, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;商品表goodsCREATE TABLE goods ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 发布者ID, category_id bigint(20) DEFAULT NULL COMMENT 分类ID, title varchar(100) NOT NULL COMMENT 商品标题, goods_desc text COMMENT 商品描述, price decimal(10,2) NOT NULL COMMENT 售价, original_price decimal(10,2) DEFAULT NULL COMMENT 原价, cover_image varchar(255) DEFAULT NULL COMMENT 封面图, images text COMMENT 商品图片多个按逗号分隔, status tinyint(4) DEFAULT 0 COMMENT 0上架中 1已下架 2已售出, view_count int(11) DEFAULT 0 COMMENT 浏览量, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_category (status, category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表;交易单表trade_orderCREATE TABLE trade_order ( id bigint(20) NOT NULL AUTO_INCREMENT, goods_id bigint(20) NOT NULL, buyer_id bigint(20) NOT NULL COMMENT 买家ID, seller_id bigint(20) NOT NULL COMMENT 卖家ID, price decimal(10,2) NOT NULL COMMENT 成交价格快照, message varchar(255) DEFAULT NULL COMMENT 买家留言, status tinyint(4) DEFAULT 0 COMMENT 0待确认 1已确认 2已完成 3已取消, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT交易单表;你在写表结构时要注意几个点。第一冗余字段该留就留。trade_order里的price存的是下单那一刻的价格快照而不是每次去查商品表计算。商品可能在下单后被改价或删除如果订单关联的价格变了买卖双方会产生纠纷。做交易系统快照思维很重要。第二图片不要直接存数据库存服务器路径或 OSS 地址。images字段用逗号分隔多个图片地址是为了查询方便商品详情页前端拿到字符串后 split 成数组即可。如果项目做大建议换成 JSON 数组格式或者独立的商品图片表。第三状态字段用整数扩展性更好。比如商品状态 0 上架、1 下架、2 已售出后续如果要加“审核中”“被举报”直接在枚举和代码映射里加值就行不用改表结构。交易单状态也是同理。注意goods_desc之所以不用description这个字段名是因为description在部分 MySQL 版本和框架自动映射里容易碰到关键字冲突或转换问题。取名字虽然丑一点但跑起来省心。3. Spring Boot 后端核心实现从登录鉴权到商品接口3.1 项目分层结构与依赖选型后端项目按传统的四层结构组织Controller、Service、Mapper、Entity。实体类对应表Mapper 用 MyBatis-Plus 提供的基础方法复杂查询用 LambdaQueryWrapper 拼条件既不写繁杂的 XML也能覆盖大部分业务场景。依赖方面除了基础 web、mybatis-plus、mysql-connector还需要引入 JWT 相关库以及处理 JSON 的工具。application.yml里的核心配置如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campus_trade?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 100MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0serverTimezoneAsia/Shanghai是必须要加的。不加这个参数在 Java 8 以上版本连 MySQL 8 时会经常报时区错误或者数据库时间和本地时间对不上。3.2 JWT 登录认证的实现链路校园系统不需要太复杂的权限方案JWT 足够。用户登录成功后后端生成一个 token 返回给前端前端存到 localStorage之后每次请求都在请求头里带上。后端用一个拦截器统一校验。Token 生成逻辑很直接public class JwtUtil { private static final String SECRET your-secret-key; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000L; public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }接着在拦截器里校验Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登录状态已过期请重新登录\}); return false; } } }注册拦截器时要把登录、注册、商品列表、商品详情这些不需要登录就能访问的接口放行。这里最常见的坑是拦截器路径配置过宽导致前端加载首页都拿不到数据。我一般这么配Configuration public class WebConfig implements WebMvcConfigurer { Autowired private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns( /api/user/login, /api/user/register, /api/goods/list, /api/goods/detail/**, /api/goods/category/**, /upload/** ); } }3.3 商品发布、列表分页与关键业务规则商品发布接口拿到当前登录用户的 ID 作为user_id插入商品表。这里有一个容易被忽略的细节前端传来的图片是多个字符串拼成的数组后端要先统一处理成逗号分隔的字符串再存库查出来时再转换。PostMapping(/api/goods) public Result publish(RequestBody Goods goods, HttpServletRequest request) { Long userId (Long) request.getAttribute(userId); if (userId null) { return Result.error(未登录); } goods.setUserId(userId); goods.setStatus(0); goods.setViewCount(0); ListString imageList goods.getImageList(); if (imageList ! null !imageList.isEmpty()) { goods.setImages(String.join(,, imageList)); } goodsService.save(goods); return Result.success(); }商品列表接口是最常用的查询入口要支持分类、关键词、分页还要排除已删除和已下架的商品。用 MyBatis-Plus 的 LambdaQueryWrapper 很方便GetMapping(/api/goods/list) public Result list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 8) Integer size, RequestParam(required false) Long categoryId, RequestParam(required false) String keyword) { LambdaQueryWrapperGoods wrapper new LambdaQueryWrapper(); wrapper.eq(Goods::getStatus, 0) .eq(categoryId ! null, Goods::getCategoryId, categoryId) .like(StrUtil.isNotBlank(keyword), Goods::getTitle, keyword) .orderByDesc(Goods::getCreateTime); PageGoods result goodsService.page(new Page(page, size), wrapper); return Result.success(result); }注意里面eq(condition, column, value)的写法第一个参数是布尔条件。条件为真时才把这个查询条件拼进去这是 MyBatis-Plus 最实用的特性可以省掉一大片 if 判断。3.4 图片上传与本地静态资源映射校园系统大概率没有 OSS 预算图片先存本地。上传接口把 MultipartFile 保存到指定目录文件名用 UUID 重写避免中文名和重名导致的各种问题。PostMapping(/api/upload) public Result upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件为空); } String originalFilename file.getOriginalFilename(); String ext StrUtil.subAfter(originalFilename, ., true); String fileName UUID.randomUUID().toString().replace(-, ) . ext; File dest new File(uploadDir, fileName); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } try { file.transferTo(dest); } catch (IOException e) { log.error(上传失败, e); return Result.error(上传失败); } return Result.success(/upload/ fileName); }很多同学在这里卡住是因为前端上传成功后图片地址类似/upload/xxx.jpg但浏览器直接访问却 404。原因是 Spring Boot 默认不会把磁盘上的目录映射成静态资源路径必须手动配置Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir /); }addResourceLocations的尾声/不能漏漏掉后路径拼接永远不对这是我把头都抓掉过的一个问题。4. Vue 前端页面与接口对接组件拆解和联调细节4.1 前端项目结构与路由设计前端我用的是 Vue2 Vue Router Element UI项目结构保持简洁核心目录就四个views放页面components放公共组件router放路由api放请求封装。页面路由设计如下const routes [ { path: /, component: Home, meta: { title: 首页 } }, { path: /login, component: Login }, { path: /register, component: Register }, { path: /goods/:id, component: GoodsDetail, meta: { title: 商品详情 } }, { path: /publish, component: GoodsPublish, meta: { requiresAuth: true } }, { path: /my, component: PersonalCenter, meta: { requiresAuth: true } } ]requiresAuth这个字段配合路由守卫使用。在router.beforeEach里判断 localStorage 是否有 token没有就跳转到登录页。发布商品和个人中心页必须加这个守卫不然用户没登录也能看到页面调用接口时又被 401 顶回来体验很差。4.2 axios 请求封装与登录态处理axios 封装是一个前端项目的刚需。统一处理 baseURL、请求头、响应码可以避免每个页面都重复写一套逻辑。import axios from axios const request axios.create({ baseURL: process.env.VUE_APP_BASE_API || /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) location.href /login return Promise.reject(new Error(res.msg)) } return res }, error { return Promise.reject(error) } ) export default request开发环境下/api代理到后端用vue.config.js配置module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true }, /upload: { target: http://localhost:8080, changeOrigin: true } } } }这里有个重要的选型原则跨域问题优先用代理解决而不是前端直接改成后端全地址。代理不仅让请求路径更简洁还能避免浏览器跨域限制带来的 Cookie、预检请求等一系列麻烦。如果后端已经做了全局 CORS 配置前端就不要再用跨域地址访问不然会出现重复 CORS 头或预检失败这类问题排查起来特别费时间。4.3 商品列表页和详情页的关键交互商品列表页用卡片式布局长列表配上分页。每家页面点击卡片进入详情页再利用scrollBehavior或组件生命周期控制页面滚动位置避免从详情页返回时列表滚动条错乱。Element UI 的el-card、el-image、el-pagination组合起来足够清爽。详情页的核心是图片轮播、基础信息展示和购买按钮。数据从/api/goods/detail/{id}拿展示之前要把后端返回的images字符串拆成数组同时判断图片路径是否带域名前缀。我一般封装一个工具函数统一拼接function formatImageUrl(url) { if (!url) return if (url.startsWith(http)) return url if (process.env.NODE_ENV development) return url return location.origin url }发布商品的表单注意三个点图片上传组件要用action指向后端的/api/upload同时带上 token价格字段用数字输入框并做范围校验发布成功后跳转到商品详情页而不是首页这样用户能立刻验证商品是否显示正常。5. 把源码跑起来Windows 和 Mac 本地部署完整流程5.1 环境版本怎么选最省心先说版本问题这是新手最容易被绊倒的地方。后端基于 Spring Boot 2.7.x推荐配 JDK 8 或 JDK 11。JDK 17 也有同学尝试过但部分三方依赖和反射机制可能不兼容没必要冒风险。前端如果拿到的是 Vue2 项目Node 版本建议 14~16。Node 版本太高node-sass或者部分旧依赖编译会直接报错。MySQL 建议 5.7 或 8.0 都可以。但如果你用的是 8.0一定要把驱动改成com.mysql.cj.jdbc.DriverJDBC URL 里要带serverTimezoneAsia/Shanghai。MySQL 5.7 使用com.mysql.jdbc.Driver也能跑只是旧驱动在 8.0 上会报认证方式错误。5.2 数据库和配置准备步骤很简单把源码里的 SQL 文件导入本地 MySQL。命令行方式是mysql -u root -p campus_trade.sql导入完成后修改application.yml里的数据库账号密码启动后端mvn spring-boot:run看到 Tomcat started on port 8080 就说明后端起来了。前端启动npm install npm run serve浏览器打开http://localhost:8081即可。如果 npm install 过程很慢建议临时用淘宝镜像npm config set registry https://registry.npmmirror.com5.3 高频报错和排查思路我在帮人部署的过程中总结出了几个重复出现率最高的问题直接列成表格现象原因处理方式后端启动报Access denied for user数据库账号或密码错误核对 application.yml 账号密码启动报时区错误JDBC URL 缺serverTimezone补上serverTimezoneAsia/Shanghai前端列表页请求 404代理没配置/api或/upload检查 vue.config.js proxy 配置图片加载 404静态资源映射没配检查 addResourceHandlers 配置前端请求 401token 过期或未设置重新登录检查拦截器放行路径发布商品保存报错字段长度超限或必填字段为空看后端日志提示的具体字段npm 启动报node-sass错误Node 版本过高切换 Node 14/16或npm rebuild node-sass有个容易绕圈子的场景Windows 上 MySQL 装了却连不上或者用 Docker 拉 MySQL 但容器起不来。我不建议新手部署时优先用 Docker 跑数据库虽然集装箱化很好但目录挂载、时区、容器网络、端口映射这一串问题叠在一起排错成本远大于直接装个本地 MySQL。先把系统跑通后面再研究 Docker 不迟。6. 拿到源码后如何做二次开发以及上线部署的几个关键点6.1 最值得扩展的三个方向源码提供的是一套可运行的最小闭环但离一个完整的校园交易产品还有距离。我个人建议优先扩展三个方向第一站内私信功能。目前交易双方的联系方式是互相查看手机号或微信号这在演示阶段没问题但真实场景里用户更希望先聊两句确认货况。可以基于 WebSocket 做站内实时聊天或者退一步做留言板。后端用 Spring Boot 自带的 WebSocket 支持就能实现前端用原生 WebSocket 或socket.io-client对接难度可控但体验提升非常明显。第二交易单的完整状态机。很多同学把交易单做成了“发起购买意向”的留言单下单后商品状态不联动导致同一件商品被多人“下单”。完善的做法是创建交易单时立即将商品状态改为“交易中”卖家确认后变为“已售出”买家取消或超时未确认再回滚为“上架中”。状态流转中还要校验当前商品的状态防止并发下单。第三信用评价体系。交易完成后买卖双方互评评价累计成信用分信用分高的用户发布的商品在列表页有标识甚至可以按信用分排序。这个功能对真实校园交易非常有用也让系统有了一层社交和治理属性。6.2 上线部署时最容易忽略的三件事本地跑通和真正上线是两码事。第一前端打包后的文件可以直接由后端提供服务。在前端项目根目录执行npm run build生成dist目录把里面所有文件复制到后端的src/main/resources/static目录然后重新打包后端 jar。这样做的好处是省去了单独部署 Nginx 的步骤访问同一个端口即可适合演示和小并发场景。但如果用户量上来还是建议前后端分离部署前端dist交给 Nginx后端 jar 单独跑通过反向代理把/api转发给后端。第二上传的图片目录要考虑持久化。后端 jar 包重启后放在临时目录里的图片可能被系统清理所以要把uploadDir配成固定的磁盘路径例如 Linux 下的/data/campus/upload并在部署时提前建好目录、设置好写权限。第三数据库要记得备份。校园系统虽然并发不高但数据却是实打实的用户资产。建议写一个定时任务每天凌晨用mysqldump导出一次保留最近七天的备份。真出问题的时候一份备份能省掉一整天的重来时间。6.3 我实际维护中的一些体会最后说点代码之外的东西。这套系统我做过实验性质的部署也拿去给同学实际用过一段时间。这类项目最大的价值不是功能有多丰富而是形成了一个清晰的前后端协作范本。新手照着把登录鉴权、CRUD、文件上传、分页查询、跨域处理走一遍后面做任何管理后台、信息管理类系统都能直接平移经验。你会慢慢意识到技术框架本身只是工具真正决定系统质量的是数据模型设计得是否合理、状态流转是否严密、异常分支是否处理干净。能在毕业设计或课程设计中把这些问题都亲手走一遍比单纯背八股文有意义得多。