前后端分离的校园闲置物品交易系统,Spring Boot + Vue + MyBatis + MySQL这套组合,最近在技术社区里讨论热度一直很高。说实话,这类系统确实是前后端分离入门到进阶之间一个非常好的练习载体:业务模型清晰、功能边界明确、涉及的技术点覆盖广,又不会像电商中台那种大型系统一样让人无从下手。我自己前后带过几批新人做过类似项目,也帮别人排查过不少这类系统的问题,这次就把整套从设计到部署的完整思路和实操过程整理出来。
这套系统的核心价值在于:业务完整度高、技术栈主流、可扩展性强。对在校学生来说,它可以直接作为课程设计或毕业设计的原型;对刚入行的开发来说,它是一个能写进简历、面试时能清楚讲明白业务逻辑的实战项目;对想转前后端分离开发模式的人来说,它是一套可以照着敲、跑起来看效果的完整范例。无论你是哪种情况,这篇文章都会尽量把关键环节讲透。
1. 整体设计与技术选型思路
1.1 为什么选前后端分离架构
前后端分离已经是目前Web开发的主流形态了。前几年做这类系统,很多人还用JSP + Servlet + Bootstrap那一套,前端页面直接嵌在后端工程里,开发的时候耦合度高,改个按钮样式都要重新部署后端。前后端分离的核心思路是:前端只负责页面渲染和用户交互,通过HTTP接口请求数据;后端只负责业务逻辑和数据处理,通过JSON格式返回结果。两者通过接口文档约定契约,各自独立开发、独立部署。
这套模式带来的直接好处有三个。
第一,开发和调试效率大幅提升。前端跑在Node环境里,有热更新,改代码立即生效;后端跑在Spring Boot内置的Tomcat里,用DevTools也能做到改完自动重启。两边可以同时开工,不用互相等待。
第二,部署灵活。前端构建出来的是一堆静态文件,扔到Nginx里就能跑,也可以直接打进Spring Boot的resources目录;后端可以独立部署在多台机器上,用Nginx做反向代理,后面要扩展到集群也容易。
第三,团队协作更顺畅。这个系统虽然一个人也能做完,但如果你用前后端分离的结构去组织代码,天然就有一种分工的边界感:前端管components、views、router,后端管controller、service、mapper。后面加人进来接手也容易。
当然,前后端分离不是没有代价。跨域问题、Token鉴权、接口联调沟通成本,这些都是需要额外处理的。我在后面会逐个讲解。
1.2 技术栈选型的取舍逻辑
这套系统的技术栈是Spring Boot + Vue + MyBatis + MySQL,每一环都有它的理由,不是拍脑袋选的。
Spring Boot选它,是因为它把Spring生态里大量繁琐的配置自动化了。以前用Spring MVC写一个项目,要配web.xml、spring-mvc.xml、数据源、事务管理器一堆东西;Spring Boot用starter机制加自动配置,一个启动类就能把应用跑起来,这对中小型项目和初学者来说极其友好。而且Spring Boot 2.x到现在3.x已经非常成熟,社区资料丰富,遇到问题基本都能搜到答案。
Vue选它,是因为它的学习曲线相对平缓,模板语法直观,而且配合Element UI这类组件库,开发后台管理界面和中台页面非常快。React当然也能做,但Vue在国内的生态和资料更接地气,对中文开发者更友好。
MyBatis选它,是因为它对SQL的控制力很强。像校园闲置物品交易系统这类业务,查询条件往往动态变化:按分类筛选、按价格区间筛选、按关键词模糊搜索,这些用MyBatis的动态SQL写起来非常灵活。相比之下,如果业务查询没那么复杂,用JPA也能省不少事,但JPA在复杂查询和SQL调优方面不够直观。这里我的建议是:以MyBatis为主,复杂查询写XML,简单CRUD用注解或者通用Mapper,两者结合效率最高。
MySQL作为存储层不用多说,它是目前中小型项目最稳妥的选择,事务支持完善、性能足够、运维成本低。为了演示方便,后面建表脚本和数据初始化脚本我都会给出来,你本地装一个MySQL 5.7或8.0都能直接跑。
2. 核心功能模块与数据库设计
2.1 功能模块划分
做这类系统,第一步不是急着写代码,而是把功能模块理清楚。我习惯先把角色和用例画出来,再反推数据库表结构。校园闲置物品交易系统,核心角色有四个:游客、买家、卖家、管理员。游客和登录用户看到的内容不一样,买家和卖家在业务上实际是同一类用户在不同场景下的身份切换。
功能上,我从实际使用角度拆成四个模块。
用户模块负责注册、登录、个人信息管理。注册时默认是普通用户,管理员账号由数据库初始化脚本写入。登录用JWT签发Token,前端把Token存在localStorage里,每次请求带上,后端用拦截器统一校验。
商品模块是系统的核心,包括商品发布、商品列表展示、商品详情、商品编辑、商品下架、商品搜索与筛选。商品状态我设计为枚举:1表示在售、2表示已售出、3表示下架、0表示审核中。发布商品需要填写标题、描述、分类、价格、成色、图片等字段。
交易模块负责下单、订单状态流转、买卖双方确认、订单删除。这里不涉及真实支付,只做模拟交易流程:买家下单后订单状态为待确认,卖家确认后变为待收货,买家确认收货后变为已完成,整个过程可取消。
管理模块给管理员使用,负责用户管理、商品审核、分类管理、数据统计。用户发布商品后默认进入审核中状态,管理员审核通过后才会在列表中展示,这能有效过滤垃圾信息。
2.2 数据库表结构设计
数据库设计是这类系统最容易踩坑的地方。我见过不少人上来就建表,后面改来改去,代码和表结构拧在一起,非常痛苦。这里我把核心表结构方案直接列出来,并且会解释每个字段的用意。
用户表是基础,字段包含:id、username、password、nickname、avatar、phone、email、role、status、create_time。password存储的是BCrypt加密后的密文,不要存明文。role字段用int类型,1为普通用户,2为管理员,这样扩展角色也方便。
商品分类表:id、name、sort、create_time。这个表内容简单,但容易被人忽略。分类不要硬编码在前端,因为分类是后续运营时要调整的,存数据库里后端和前端都能动态读取。
商品表字段比较多:id、user_id、category_id、title、description、price、original_price、condition_level(成色)、images、status、view_count、create_time、update_time。这里几个设计细节值得说一下。
price字段用DECIMAL(10, 2),不要用FLOAT或DOUBLE,因为浮点数在比较和计算时会有精度问题。images字段我建议用逗号分隔的多个图片路径字符串,比如/images/1.jpg,/images/2.jpg,这样读取时用split方法拆一下就能拿到图片列表。如果图片数量可能很多,再单独建一张商品图片表也行,但对这个系统来说,字符串方案已经完全够用,而且查询时省了一次关联查询。
订单表:id、order_no、product_id、buyer_id、seller_id、price、status、create_time、pay_time、confirm_time。order_no用时间戳加随机数生成,保证唯一性,不要用自增id直接当订单号,这样容易暴露业务数据量而且可能在并发时出问题。
收藏表:id、user_id、product_id、create_time。这个表加一个联合唯一索引uk_user_product(user_id, product_id),防止用户重复收藏同一条商品。
留言表:id、product_id、user_id、content、create_time。用于商品详情页的评论互动,这个功能看起来不起眼,但面试时能体现你考虑问题的完整性。
把表关系理清楚之后,可以用一个简单的例子串一遍流程:用户A发布一台九成新的自行车,状态为审核中;管理员在后台看到审核请求,审核通过后状态变为在售;用户B在商品列表搜到自行车,点进详情后下单;A收到订单消息后确认交易,B确认收货后订单完成,商品状态变为已售出。这条链路涉及用户表、商品表、订单表的联动,数据库表之间的外键关系虽然不强制在数据库层面建立,但逻辑上一定要清晰。
2.3 接口设计规范
在写后端代码之前,接口要先定好。我采用RESTful风格设计API,统一返回格式为:
{ "code": 200, "message": "success", "data": {} }code为200表示成功,非200表示业务异常或系统异常。这样前端axios拦截器里只需要判断code和HTTP状态码,处理逻辑统一。接口按模块划分:
- 认证接口:
POST /api/auth/register、POST /api/auth/login - 用户接口:
GET /api/user/info、PUT /api/user/info - 商品接口:
GET /api/product/list、GET /api/product/detail/{id}、POST /api/product、PUT /api/product、DELETE /api/product/{id} - 订单接口:
POST /api/order、GET /api/order/list、PUT /api/order/{id}/status - 管理接口:
GET /api/admin/product/list、PUT /api/admin/product/audit、GET /api/admin/user/list
接口路径的命名有几个讲究。第一,统一加/api前缀,后面接Nginx反代时方便区分静态资源和动态请求。第二,用名词复数表示资源,URL里不要出现动词,比如用/api/product不是/api/getProduct。第三,列表接口统一支持pageNum、pageSize、keyword、categoryId等查询参数,后面做分页查询时能保持一致的代码模式。
3. 后端Spring Boot核心实现
3.1 项目初始化和基础配置
创建Spring Boot工程,最简单的方式是用Spring Initializr,在IDEA里直接新建项目时选好依赖就行。这里建议手动加依赖,版本可控,遇到问题也好排查。pom.xml核心依赖是这样的:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>Spring Boot 2.7.x是目前稳定性最好的版本线,JDK 8和17都能跑。如果你非要上Spring Boot 3.x,要注意javax包名改成jakarta,部分配置写法也有变化,第一次做项目没必要给自己增加这个复杂度。
application.yml里重点配置三块:数据源、MyBatis、文件上传。这里给一个参考配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campus_trade?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password servlet: multipart: max-file-size: 10MB max-request-size: 50MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.campustrade.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置强烈建议开启,这样数据库里的create_time能自动映射到Java实体类的createTime字段,省去一大堆resultMap手动映射。数据源URL里必须加上serverTimezone=Asia/Shanghai,否则MySQL 8.0驱动会报时区错误。useSSL=false是避免本地开发时证书校验的麻烦,生产环境再根据实际情况打开。
3.2 JWT认证与登录拦截器
用户认证是这类系统的安全基石,我选择JWT方案。核心流程是:用户登录成功后,后端生成一个Token返回给前端;前端把Token存起来,每次请求在HTTP头里带上Authorization: Bearer <token>;后端写一个拦截器,拦截需要认证的接口,从Token中解析出用户ID,存入ThreadLocal供后续业务使用。
JWT本身包含三部分:Header、Payload、Signature。Payload里我存放userId和role,还有过期时间。用java-jwt库来生成和解析Token,代码比较简洁。
public class JwtUtil { private static final String SECRET = "your-secret-key"; public static String generateToken(Integer userId, Integer role) { Algorithm algorithm = Algorithm.HMAC256(SECRET); Date expireDate = new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000); return JWT.create() .withClaim("userId", userId) .withClaim("role", role) .withExpiresAt(expireDate) .sign(algorithm); } public static DecodedJWT verifyToken(String token) { Algorithm algorithm = Algorithm.HMAC256(SECRET); return JWT.require(algorithm).build().verify(token); } }写拦截器的时候有个容易忽略的坑:拦截器里解析Token失败时,不能只返回一个错误JSON,还需要把HTTP状态码设置为401,否则前端axios拦截器判断逻辑会很别扭。另外,放行路径要白名单化,像登录、注册、商品列表、商品详情这些接口不需要认证,直接放行。
登录接口的实现逻辑是:前端传用户名和密码过来,后端用BCryptPasswordEncoder检查密文是否匹配,匹配成功后生成Token返回。BCrypt比MD5安全得多,MD5加盐虽然也能用,但BCrypt是Spring Security的标准做法,拿来即用。
3.3 商品发布与图片上传
商品发布是买家和卖家交互的起点。前端通过表单收集信息,用multipart/form-data格式同时上传图片文件和商品数据。后端的Controller这样接收:
@PostMapping("/api/product") public Result createProduct(@RequestParam("file") MultipartFile file, @RequestParam("title") String title, @RequestParam("price") BigDecimal price) { String imageUrl = fileService.upload(file); productService.createProduct(userId, title, price, imageUrl); return Result.success(); }图片上传的存储方案,这里我推荐本地磁盘存储。在服务器上建一个/data/images目录,文件按日期分目录存放,比如/data/images/2025/06/01/xxx.jpg。文件名用UUID加时间戳生成,防止重名。不要直接用用户上传的原始文件名,一是可能有路径穿越风险,二是重名概率高。上传代码的核心逻辑:
public String upload(MultipartFile file) { if (file.isEmpty()) { throw new BusinessException("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); if (!allowedExt.contains(ext.toLowerCase())) { throw new BusinessException("不支持的文件类型"); } String fileName = UUID.randomUUID().toString().replace("-", "") + ext; String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); File dir = new File(uploadPath + "/" + datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, fileName)); return "/images/" + datePath + "/" + fileName; }然后通过一个WebMvcConfigurer把本地的/data/images目录映射到/images/**这个虚拟路径上,前端就能直接通过http://localhost:8080/images/2025/06/01/xxx.jpg访问到图片了。
实际生产环境,更常见的做法是把图片传到阿里云OSS或者腾讯云COS,走CDN加速,减轻应用服务器的压力。但本地磁盘方案对学习和中小规模部署完全够用,后面需要再升级也容易,只是多一个适配层的问题。
3.4 MyBatis动态SQL实现商品筛选
商品列表页的筛选条件是这套系统里最具代表性的查询场景。用户可能按照分类、价格区间、关键词、商品状态等多个条件组合筛选。这种需求如果用JPA写,条件拼接会比较痛苦,但MyBatis的动态SQL就是为这个场景设计的。
在ProductMapper.xml里,我这样写查询语句:
<select id="selectProductList" resultType="Product"> SELECT * FROM product <where> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> AND status = 1 </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select><where>标签会自动去掉第一个多余的AND,这个细节很关键。注意>和<是XML里的转义写法,直接写>和<会导致XML解析报错。排序用create_time DESC,把最新的商品放在前面,这是商品列表展示的合理策略。
分页这里我没有引入PageHelper插件,而是手动算offset。这样做的理由有两个:一是这个系统数据量不大,手动分页处理起来简单直观;二是减少外部依赖,避免PageHelper版本和MyBatis版本不兼容的问题。当然,如果你习惯PageHelper,引入也是可以的,它确实能让分页代码简洁很多。
3.5 订单状态机设计
订单模块是整个系统里最容易逻辑混乱的部分,核心问题就是状态管理。订单状态我定义为:
- 0:待确认(买家下单,等待卖家确认)
- 1:待收货(卖家确认,等待买家收货)
- 2:已完成(买家确认收货)
- 3:已取消(交易终止)
不同角色能执行的操作不一样:买家可以下单和取消订单(仅限待确认状态),卖家可以确认订单和取消订单(同样仅限待确认状态),买家可以确认收货。这些限制条件必须写进service层,不能只靠前端按钮隐藏来控制。
我在实现时用了一个Map来约束合法的状态流转:
private static final Map<Integer, List<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(0, Arrays.asList(1, 3)); // 待确认 -> 待收货 / 已取消 TRANSITIONS.put(1, Arrays.asList(2)); // 待收货 -> 已完成 }状态变更的方法统一走一个updateOrderStatus方法,先校验当前状态是否在合法流转路径上,不在就直接抛异常。这样能有效防止并发或恶意请求跳过中间状态直接操作订单。
订单状态变更还有一个容易忽略的点:买家确认收货后,要同步把对应的商品状态改成已售出。这个逻辑我放在同一个事务里,用@Transactional注解保证原子性。如果分两步操作不加事务,中途出错就会出现"订单已完成但商品还在卖"的数据不一致问题。
4. 前端Vue核心实现
4.1 工程结构和路由设计
前端我用Vue 3 + Vite + Element Plus组合。相比Vue 2 + Vue CLI,这套组合的构建速度和开发体验都更好。工程目录结构建议这样组织:
src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── store/ # 状态管理(Pinia) ├── views/ # 页面视图 │ ├── home/ # 首页 │ ├── product/ # 商品相关页面 │ ├── order/ # 订单相关页面 │ ├── user/ # 个人中心 │ └── admin/ # 后台管理 └── utils/ # 工具函数路由配置里我用到了路由懒加载,这个对首屏加载速度优化很有帮助。首页和商品列表这些核心页面可以静态引入,用户中心、后台管理等低频页面用动态import。
路由守卫是前端鉴权的关键。系统里有些页面必须登录后才能访问,比如发布商品、订单列表、个人中心。在router.beforeEach里做校验:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })这里有个细节:前端路由守卫只是体验层面的限制,真正防住非法请求的是后端的JWT拦截器。前端跳转登录页只是引导用户,后端返回401才是真正的安全边界。两者缺一不可。
4.2 axios封装与拦截器
axios需要在实例级别统一处理请求头、响应拦截和错误处理。封装的核心代码:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' const request = axios.create({ baseURL: '/api', // 配合Vite proxy 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 !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message) return Promise.reject(error) } )baseURL设置为/api、配合Vite的proxy代理转发到后端8080端口,这样做的好处是在开发环境和生产环境之间切换时,不用修改前端代码。生产的Nginx配置里做一个/api开头的请求反向代理到后端服务就可以了。
4.3 商品列表与详情页实现
商品列表页是用户进入系统的第一个主要界面,设计目标是:信息展示清晰、筛选操作方便。我用Element Plus的el-card组件做商品卡片,每个卡片展示商品主图、标题、价格、成色。筛选区放在页面顶部,包含分类下拉框、价格区间输入和搜索框。
搜索和筛选的逻辑是:监听筛选条件变化,重新请求/api/product/list接口,通过categoryId、minPrice、maxPrice、keyword参数传递给后端。为防用户连续点击导致的接口请求频繁,我在前端做了简单的防抖处理,输入搜索关键词时,延迟300毫秒再发请求,实际使用体验会舒服很多。
商品详情页有一个重要细节:图片预览。el-image组件自带preview-src-list属性,可以实现点击图片放大预览的交互。详情页核心信息区展示价格、成色、卖家昵称、发布时间,下方是收藏按钮和下单按钮。收藏按钮要与登录状态绑定,未登录点击时提示跳转登录页。
详情页的数据加载要考虑Loading状态。我的习惯是:页面加载时先展示骨架屏,数据返回后渲染内容。这样在高延迟网络环境下,用户体验不会太差。
4.4 发布商品表单的关键校验
发布商品页的核心是表单校验。Element Plus的el-form配合rules配置,能覆盖大部分校验场景。价格字段用type: 'number'配合min和max限制范围,图片上传用el-upload组件限制文件类型和大小。
图片上传这里要特别注意:el-upload默认走的是ajax上传,上传成功后得到图片URL,再把URL作为商品表单的一个字段提交。而不是把File对象放进JSON里提交,JSON序列化File对象会得到空对象,这个坑我见过不少次。
const handleUploadSuccess = (response) => { form.imageUrls.push(response.data) }发布成功后,跳转到商品详情页,用户能看到自己刚发布的商品。但此时商品状态是审核中,只有管理员在后台审核通过,商品才会在列表页展示。这个流程在逻辑上模拟了真实交易平台的审核机制。
5. 前后端联调与部署
5.1 开发环境跨域配置
开发阶段最常遇到的问题就是跨域。浏览器同源策略会拦截localhost:5173(前端Vite)向localhost:8080(后端Spring Boot)发起的请求。解决方案有两个层面,我建议两层同时配。
前端Vite的proxy配置,在vite.config.js里:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })后端CORS配置,在Spring Boot里写一个配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }两层都配上,开发时用前端proxy,部署后靠Nginx转发生效。allowCredentials(true)配合allowedOriginPatterns("*")要注意,如果用了@CrossOrigin注解,这两个配置不能同时用*,不然会有冲突。
5.2 前后端分离部署方案
生产环境的部署方案,我推荐前端编译成静态文件交给Nginx托管,后端打jar包用systemd守护运行。前端构建:
npm run build构建产物在dist目录,把dist目录下的文件上传到服务器的/usr/share/nginx/html,或者自己指定的任意目录。Nginx的配置关键点在于location匹配规则:
server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html这行必须写,因为Vue Router的history模式在做页面刷新时,如果Nginx找不到对应的真实文件,会返回404。加上这行配置后,所有前端路由都会回退到index.html,由前端路由接管。
后端打包命令是mvn clean package -DskipTests,生成的jar文件用java -jar启动。生产环境建议用systemd管理进程,设置开机自启和崩溃自动重启。进程的启动参数里要留出合理的JVM堆内存,比如-Xms256m -Xmx512m,这个系统并发量不大,堆内存512MB基本就够用。
数据库在生产环境的初始化,直接把建表脚本导入MySQL即可。密码这类敏感信息不要硬编码在application.yml里,用环境变量注入的方式配置。Spring Boot里读取环境变量很方便:
spring: datasource: username: ${DB_USERNAME:root} password: ${DB_PASSWORD:root}服务器上的环境变量写在systemd服务文件里,或者用export命令设置。这样即使代码仓库泄露,数据库密码也不会暴露。
5.3 常见错误与排查记录
我整理了一份这套系统搭建和运行过程中最常遇到的问题清单,每一条都是实际踩坑记录。
| 问题现象 | 可能原因 | 排查与解决方式 |
|---|---|---|
| Spring Boot启动报数据库连接失败 | 数据库服务未启动、密码错误、URL参数缺失 | 检查MySQL连接:mysql -uroot -p;确认驱动版本与数据库版本匹配;确认URL带serverTimezone参数 |
| Vue页面请求接口404 | 后端接口路径与前端不一致、Nginx未正确代理 | 先用Postman直接请求后端接口确认可用;再看浏览器Network里的实际请求URL和代理配置是否吻合 |
| 前端提示"跨域" | 开发环境代理未生效、后端CORS未配置 | 检查Vite proxy是否监听正确的端口;确认后端CORS配置类被扫描到 |
| 图片上传后访问404 | 虚拟路径映射未配置、图片目录不存在 | 确认WebMvcConfigurer的addResourceHandlers路径配置;确认磁盘上目录确实存在 |
| Token过期后接口报401 | 前端未正确处理401 | 检查响应拦截器里是否有401的统一处理逻辑,跳转登录页并清除本地缓存 |
| 商品列表接口响应慢 | SQL未走索引、查询了不必要的关联表 | 用EXPLAIN分析SQL执行计划;在where条件上建立合适的索引;检查是否懒加载了不需要的字段 |
| 订单状态显示异常 | 状态流转逻辑没做合法校验 | 检查service层的状态机校验是否完善;确认前端下拉框选项与后端状态值一一对应 |
MySQL索引这块我再多说一句:商品表的category_id、status、create_time这三个字段是查询高频字段,建议建立联合索引:
ALTER TABLE product ADD INDEX idx_category_status_time (category_id, status, create_time);这个索引覆盖了列表页最常用的筛选条件组合。不过要注意,索引不是越多越好,每个索引都会拖慢写入速度,所以只给高频查询加索引就够了。
5.4 并发与安全需要关注的细节
这个系统虽然校园场景并发量不大,但作为项目实践,提前考虑几个安全细节,面试时也能加分。第一个是SQL注入防护。MyBatis的#{}是预编译方式,能有效防止SQL注入,但${}是字符串拼接,存在注入风险。建议在项目里全局搜索一下${}的使用位置,除了表名、排序字段等必须动态拼接的场景外,一律改用#{}。
第二个是密码安全。用户密码存的是BCrypt加密后的密文。BCrypt的机制是自动加盐,即使两个用户密码相同,加密后的结果也不同,能有效抵抗彩虹表攻击。登录验证用matches方法:
public boolean checkPassword(String rawPassword, String encodedPassword) { return passwordEncoder.matches(rawPassword, encodedPassword); }第三个是文件上传安全。除了限制文件类型和大小,还要注意校验文件内容。比如用户传一个改名成.jpg的恶意文件,文件头可能根本不是图片。简单的做法是用Java的ImageIO读取图片,读取失败就拒绝上传:
try { BufferedImage image = ImageIO.read(file.getInputStream()); if (image == null) { throw new BusinessException("文件内容不是有效图片"); } } catch (IOException e) { throw new BusinessException("文件内容不是有效图片"); }第四个是接口的幂等性问题,主要是下单接口。如果用户快速点击两次下单按钮,可能创建两个重复订单。解决方案是前端按钮loading状态防重复点击,后端再做一个简单的幂等校验:用户和商品组合不能重复创建待确认的订单。
// 在事务里校验 Order existing = orderMapper.selectByBuyerAndProduct(buyerId, productId, 0); if (existing != null) { throw new BusinessException("您已下单,请勿重复操作"); }5.5 如何把这个项目讲成面试亮点
项目做完之后,展示环节同样重要。很多人在简历里写"开发了校园闲置物品交易系统",但面试时讲不清楚技术亮点,因为只是照着教程敲了一遍。这里我分享几个让项目更有说服力的切入点。
第一,能清楚讲出技术选型的理由。比如为什么用MyBatis而不是JPA,为什么用JWT而不是Session,每个决策都要有逻辑支撑。面试官问"你为什么用JWT"如果你回答"大家都在用",这场对话基本就结束了。正确的回答思路是:这个系统前后端分离,Session方案需要处理跨域Cookie问题,JWT无状态、Token放在请求头里,适配前后端分离更方便。再补充一点JWT的缺点,比如无法主动失效,配合黑名单或Redis可以解决,能体现出你的思考深度。
第二,能画出项目架构图和数据表关系图。面试时手绘简单的架构图或者ER图,是非常加分的能力。核心表之间的关系要能说得清清楚楚:用户表与商品表是一对多,用户表与订单表是多对多(一个用户既可以是买家也可以是卖家),商品表与收藏表是一对多。
第三,能针对某个功能做更深入的探讨。比如"这个系统如果要支持搜索功能,你会怎么做",你可以回答:当前是MySQL LIKE查询,数据量大时可以用MySQL全文索引,或者引入Elasticsearch做全文检索。再比如"如果要接入真实支付,你怎么设计",你可以谈谈支付回调、订单状态同步、对账机制。这些问题没有标准答案,但能展示你对系统演进方向的理解。
我把这个项目相关的一些扩展方向列在下面,你在简历上写"项目展望"时可以参考:
| 扩展方向 | 需要解决的技术问题 | 涉及的新技术点 |
|---|---|---|
| 真实支付 | 支付回调、订单超时关闭、对账 | 微信支付/支付宝SDK、延迟队列 |
| 消息通知 | 买家下单通知卖家、卖家确认通知买家 | WebSocket、SSE、消息队列 |
| 搜索优化 | 商品搜索的准确性和性能 | Elasticsearch、IK分词、索引设计 |
| 图片存储 | 海量图片的存储和访问性能 | 对象存储OSS/COS、CDN、水印处理 |
| 高可用部署 | 单点故障、水平扩展 | Nginx负载均衡、Redis缓存、主从复制 |
我个人在做这类系统的过程中,最深的一个体会是:业务逻辑清晰比技术栈华丽更重要。很多人在做项目时喜欢堆新技术,Redis、MQ、微服务全套往上加,结果系统复杂度和工作量都上去了,但核心业务逻辑反而没做好。校园闲置物品交易系统这个题目看着简单,但真正把用户认证、商品流转、订单状态机这些基础业务做扎实,做到每个环节都有理有据、每个异常都有兜底方案,这本身就是一种很扎实的工程能力。
最后再分享一个小技巧:调试接口时不要光看浏览器报错,多抓抓Network面板里的实际请求和响应。很多前端页面显示不正常的问题,其实后端接口返回的数据已经是对的了,只是前端解析或渲染的环节出了问题。养成先看Network再改代码的习惯,能省掉大量无效的调试时间。
如果大概率你是照着这篇文章一步步走下来的,这套系统跑起来之后,建议你花点时间把订单状态流转的边界情况再测一遍,比如买家下单后卖家两天不处理、买家取消订单、卖家在待收货状态不能直接改价格——这些看起来不起眼的细节,恰恰是面试官最喜欢深挖的地方。