每年六月毕业季,宿舍楼下总是一片狼藉:带不走的台灯、书架、考研资料,直接扔进垃圾桶的大有人在。明明很多东西还有七八成新,挂到群里卖却往往要面对两种结局——要么消息瞬间被刷屏淹没,要么被砍价砍到怀疑人生。这就是我决定动手做一套springboot校园二手交易平台系统的直接原因。
这个系统从表面看就是一个典型的管理业务项目,但真正把它当作一个完整产品去做的时候,你会发现里面要处理的东西远比想象中多:用户身份认证怎么做、商品状态怎么流转、图片上传怎么处理、并发下单怎么保证不超卖,甚至还有校园场景特有的信任体系设计。这些内容拼在一起,才是这套系统的完整技术含量。
这篇文章我会把整个设计和开发过程拆开来讲,包括技术选型的取舍理由、数据库表结构设计、核心业务流程的实现细节,以及那些踩过一遍才会长记性的坑。适合正在准备毕业设计、想拿校园项目练手,或者正在做前后端分离项目但总觉得缺了点深度的同学参考。
1. 为什么校园场景需要一套独立的二手交易系统
1.1 校园二手交易的真实痛点
校园里二手交易的体量其实非常大。一个三千人的学院,每年毕业季大概会有上千件物品流转需求:教材资料、小家电、自行车、吉他、显示器,品类五花八门。但现实是,这些需求一直靠QQ群、微信群、贴吧来解决,体验非常糟糕。
QQ群最大的问题是信息扁平化。群里每秒钟刷出十几条消息,一条"出九成新台式机,1200自提"的信息发出后,十几秒就被淹没,真正的买家根本看不到。而贴吧和论坛式的交易帖子存在找东西困难的问题——想买二手自行车的人得翻几十页帖子才能找到一个匹配的。更麻烦的是,完全没有身份约束,校外人员进群发广告、加好友骗定金的情况屡见不鲜。
线下跳蚤市场倒是可以面对面交易,但一年就办一两次,时机通常集中在毕业季,而实际上二手需求全年都有。大二学生上学期想买书架、下学期想买加湿器,这些都是分散的、碎片化的需求,必须有一个随时可用的平台来承接。
我自己蹲了几个二手群观察了一周,发现成交信息里大量涉及:物品描述不完整、没有实物图、沟通成本高、缺乏交易记录。这些问题的本质不是信息不够,而是没有一个结构化的信息模型来承载物品的多维度属性——图片、价格、成色、位置、联系方式、交易状态。
1.2 平台要解决的核心问题清单
做这个系统之前,我先把问题拆成了以下几个核心目标:
- 信息结构化:每件商品有明确的标题、描述、价格、图片、成色、分类,而不是一句"出一个东西,见图"。
- 可检索:支持按关键词、分类、价格区间组合搜索,让买家快速找到想要的商品。
- 身份可信:只有通过学生认证的用户才能发布商品和下单,解决校外人员混入的问题。
- 交易可追踪:从商品发布、下单、付款(或约定线下交易)到完成的整个生命周期,都有状态记录。
- 管理可介入:管理员能处理举报、下架违规商品、封禁恶意用户,而不是让平台秩序失控。
这五个目标几乎是所有校园交易类项目的共同底座,不管你是做的毕业设计还是想拿去参加比赛,把这五点想清楚,项目的骨架就立住了。
1.3 这个系统的技术含量在哪里
有人说校园二手交易平台不就是个CRUD吗?商品增删改查、用户增删改查、订单增删改查,框架半天就能搭出来。但做深了会发现,真正的技术含量藏在那些看不太见的地方:
- 订单状态机:商品从在售到待付款到交易完成,状态流转的合法性校验怎么做?
- 并发控制:两个人同时点"立即购买"同一件商品,怎么保证不会都下单成功?
- 权限体系:普通用户、学生认证用户、管理员三个角色,接口权限怎么隔离?
- 文件处理:用户上传的图片怎么存储、怎么设置访问权限、怎么防止恶意文件上传?
- 数据一致性:下单后库存扣减和订单创建不是同时完成的,中间出错了怎么回滚?
这些问题的答案,只有真正动手写代码的时候才会浮出来。本文后面的章节就是逐个解决这些问题的完整记录。
2. 技术选型的拆解与取舍逻辑
2.1 为什么最终坚持用Spring Boot
最初我曾经犹豫过要不要用SSH或SSM那套传统组合。但仔细算了一笔账,决定还是用Spring Boot。
原因很简单:Spring Boot的自动配置机制把过去SSM项目里最大头的那部分工程量——beans.xml配置、springmvc-config.xml配置、mybatis配置、事务配置——几乎全部自动化了。我只需要引入对应的starter,写好application.yml,框架就能把数据源、SqlSessionFactory、事务管理器这些核心组件自动装配好。
这里上面还有一个关键选择是版本。Spring Boot 3.x发布后,很多教程都在推新版本,但我在构建这个项目时选了2.7.x。原因有三:
- JDK兼容性:3.x强制要求JDK 17,而2.7.x可以跑在JDK 8上。校园项目部署环境通常比较复杂(可能是学校机房的旧电脑,也可能是便宜的云服务器),JDK 8的兼容面广得多。
- 第三方组件兼容:很多教程和开源项目还在用适配2.x的版本,比如MyBatis-Plus旧版、某些安全框架的集成,在3.x会有兼容风险。
- 学习资料密度:2.x的教程、踩坑文章、论坛讨论都是最丰富的,遇到问题能更快搜索到解决方案。
注意:如果你是新做一个项目且目标环境明确是JDK 17+,用3.x没问题。但做毕设或长期维护的项目,我会优先推荐2.7.x——稳定压倒一切,这个判断在我后续开发过程中被反复验证是对的。
2.2 后端组件搭配:MyBatis-Plus、Redis、JWT一个都不能少?
分层技术栈最终定为:
- Spring Boot 2.7.x:底座
- MyBatis-Plus 3.5.x:ORM,内置CRUD方法和分页插件,省掉大量手写SQL
- Redis:验证码存储、首页缓存、分布式锁(处理并发下单)
- JWT:前后端分离场景下的无状态登录方案
- Lombok:简化实体类代码
- Hutool:工具库,处理验证码生成、JWT工具、加密工具等
有个容易犯的错误是觉得Redis是炫技用的、没这个必要。我一开始也觉得一个小项目搞Redis有点重,但实际做下来发现Redis至少解决了两个真实问题:
- 短信/邮箱验证码的过期机制:虽然校园平台通常不用短信验证码,但注册发送邮箱验证码的场景很常见。验证码本来就是要过期自动销毁的数据,Redis自带过期时间,比存数据库然后定时清理优雅得多。
- 热点商品的缓存:首页爆款商品或者某个热门分类的商品,如果每次请求都查数据库,数据库压力会很明显。把热点数据放到Redis里,读请求走缓存,只有缓存过期才回源数据库。
JWT选型的理由也很直接:既然前端决定用Vue做前后端分离,那Session机制就不合适了。JWT把用户身份信息加密后放在Token里,后端不需要存储会话状态,天然适配无状态API设计。
2.3 前端方案:Vue前后端分离还是服务端渲染
前端我最终选了Vue 3 + Vite + Element Plus,前后端分离架构。
实际上很多校园项目的毕设版本用的是Thymeleaf模板引擎做服务端渲染,好处是一个Spring Boot应用直接打包部署,不需要单独部署前端,省了跨域和联调的麻烦。但它的缺陷是整个界面交互都受限于后端渲染逻辑,想做一个弹窗确认、动态筛选、实时状态更新,代码会绕很多。
前后端分离之后的开发模式舒服很多:前端项目只需通过Axios调用后端接口,接口返回JSON数据,前端负责渲染交互。后端的注意力可以完全集中在业务逻辑、参数校验、权限控制上,而不是纠结页面上的按钮点击事件。
代价是需要额外处理三个问题:跨域配置、Token传递、接口联调。跨域问题我会在后面的章节详细说,这里先卖个关子。
2.4 图片存哪里:本地存储的利与弊
用户上传的商品图片,处理方案有三种:本地磁盘存储、云存储OSS、MinIO自建对象存储。
我第一版直接用了本地磁盘存储。理由很务实:项目要跑起来给演示,本地存储不需要额外配置,也不依赖外网,在任何一台机器上启动项目就能看到完整效果。工作原理是:
- 前端把图片文件通过multipart/form-data方式POST到后端。
- 后端接收到MultipartFile后,把文件写入服务器磁盘的指定目录,比如
/upload/2024/06/01/uuid.jpg。 - 文件路径保存到数据库商品表。
- 后端配置静态资源映射,把
/upload/**映射到磁盘目录,前端通过http://ip:8080/upload/xxx.jpg直接访问图片。
这套方案的坑在于:重命名不要用文件名直接存,否则不同的用户可以互相覆盖同名文件。我用UUID.randomUUID()加上原始文件的后缀名生成新文件名,再按日期分目录存储,能避免大量小文件堆在一个目录里导致目录索引性能下降。
如果你在上线部署时追求高可用,可以考虑换成MinIO,接口设计和OSS类似,但本地部署不需要开通云服务。这里先不展开,后面踩坑章节再细说。
3. 业务模块规划与数据模型设计
3.1 功能模块全景图
系统按照角色可以拆成三个端:
- 普通用户端(未登录):浏览首页、查看商品列表、搜索商品、查看商品详情。
- 用户端(已登录):发布商品、编辑/下架商品、下单购买、查看自己的订单、收藏商品、留言咨询、举报商品、个人资料管理。
- 管理员端:用户管理(认证审核、封禁/解封)、商品管理(下架违规商品)、举报管理(处理举报、查看处理记录)、数据统计(商品总量、成交量、活跃用户数)。
模块划分的原则是"每个角色只看到和自己相关的东西",商品列表必须有条件过滤——已登录用户不能看到自己发布的商品并点击"购买",管理员可以看到所有商品并一键下架,普通用户只能看到状态为"在售"的商品。
3.2 核心表结构详解
下面这几张表是整个系统的核心,建议在动手写代码之前就设计好,后面返工的代价远大于提前多花一天想清楚。
用户表 user
CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号,登录账号', password VARCHAR(64) NOT NULL COMMENT 'BCrypt加密后的密码', nickname VARCHAR(30) COMMENT '昵称', avatar VARCHAR(255) COMMENT '头像图片路径', phone VARCHAR(11) COMMENT '手机号', college VARCHAR(50) COMMENT '学院', major VARCHAR(50) COMMENT '专业', auth_status TINYINT DEFAULT 0 COMMENT '认证状态 0未认证 1待审核 2已认证 3审核驳回', credit_score INT DEFAULT 100 COMMENT '信用分,初始100', role TINYINT DEFAULT 0 COMMENT '角色 0普通用户 1管理员', status TINYINT DEFAULT 1 COMMENT '状态 1正常 0封禁', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT='用户表';一个值得注意的设计细节:学号直接作为登录账号。校园场景下学号是唯一且公开的,天然适合做用户名,而且后续做学生认证的时候,学号和学生证、学校邮箱是绑定关系,不需要额外建一张映射表。
商品表 goods
CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '发布者ID', title VARCHAR(100) NOT NULL COMMENT '标题', description TEXT COMMENT '详细描述', category VARCHAR(20) NOT NULL COMMENT '分类:教材/数码/生活/运动/其他', original_price DECIMAL(10,2) COMMENT '原价', price DECIMAL(10,2) NOT NULL COMMENT '出售价', quality TINYINT COMMENT '成色:1全新 2九成新 3七成新 4五成新及以下', image_urls VARCHAR(1000) COMMENT '图片路径,多张用逗号分隔', contact VARCHAR(50) COMMENT '联系方式', status TINYINT DEFAULT 0 COMMENT '状态 0在售 1已售出 2已下架 3违规下架', view_count INT DEFAULT 0 COMMENT '浏览量', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT='二手商品表';image_urls用逗号分隔这种设计很常见,虽然违反了第一范式,但考虑到商品图片最多三五张,查一次商品信息就能拿到全部图片路径,省掉一次子查询,性能上是划算的。大多数人不会把图集设计成独立表,除非你的业务场景是每件商品可能有几十张详细展示图(比如房源的户型图)。
订单表 orders
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单编号', goods_id BIGINT NOT NULL COMMENT '商品ID', buyer_id BIGINT NOT NULL COMMENT '买家ID', seller_id BIGINT NOT NULL COMMENT '卖家ID', price DECIMAL(10,2) NOT NULL COMMENT '成交价', status TINYINT DEFAULT 0 COMMENT '状态 0待付款 1已付款待收货 2交易完成 3已取消 4退款中', remark VARCHAR(255) COMMENT '买家留言', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME COMMENT '付款时间', finish_time DATETIME COMMENT '完成时间', cancel_time DATETIME COMMENT '取消时间' ) COMMENT='订单表';order_no订单编号我用的是时间戳加随机数生成,形如20240601103012001,这样每个订单都有可读的业务编号,方便用户对账和管理员排查。用雪花ID也可以,但对校园项目说时间戳加随机数完全够用。
3.3 订单状态机的数据库表达
订单状态不是一个简单的字段,它隐含了一组业务规则:只有待付款的订单可以取消;只有待付款的订单可以支付;只有已付款待收货的订单可以确认收货变成交易完成;已经完成的订单不能再取消。
如果不在代码里做状态校验,就会出现用户疯狂点击"取消"按钮,把已完成的订单又取消掉,数据库里一堆脏数据。
我的做法是在Order实体里加一个状态枚举:
public enum OrderStatus { WAIT_PAYMENT(0, "待付款"), PAID(1, "已付款待收货"), FINISHED(2, "交易完成"), CANCELED(3, "已取消"); private final int value; private final String desc; // 构造方法、getter省略 }然后在订单状态变更的Service方法里,统一通过一个方法判断当前状态能否流转到目标状态。这个方法本质上就是一张状态流转表:
private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put(OrderStatus.WAIT_PAYMENT, new HashSet<>(List.of(OrderStatus.PAID, OrderStatus.CANCELED))); ALLOWED_TRANSITIONS.put(OrderStatus.PAID, new HashSet<>(List.of(OrderStatus.FINISHED, OrderStatus.CANCELED))); }这种方式能把非法状态流转在业务层直接拦截掉,比单纯改字段值安全很多。
3.4 设计上的几个"反直觉"决定
有几个设计我说出来可能有人要反对,但实际开发下来是省心的。
不建外键。用户表、商品表、订单表之间虽然有明确的关系,但我故意没有在数据库层面加FOREIGN KEY约束。这是因为校园平台的数据量虽然不大,但外键的存在会让删除操作变成牵一发动全身——删一个用户要检查所有关联商品、所有订单;删一个商品要先处理所有锁定的订单。我在业务代码里通过Service层显式进行关联校验,反而更好控制错误信息和处理顺序。
逻辑删除代替物理删除。商品被用户"删除"时,我只是把status字段改成某个标记值,而不是直接DELETE。这样首页还能统计历史数据,管理员还能在后台看到被删内容。配合一个deleted字段也是常见做法。
不做购物车功能。很多人一听到二手交易系统就觉得应该有购物车,但仔细想想——二手商品每一件都是唯一的,不存在同一商品买多件的场景,购物车在这里完全没有意义。过度设计是校园项目最普遍的通病,少做没用的需求,把下单流程做直,反而是成熟的设计判断。
4. 核心业务流程的实现细节
4.1 商品发布:从表单到上架的完整链路
商品发布流程的代码逻辑不算复杂,但有几个点必须处理到位。我把流程拆成下面几条:
- 用户提交表单数据,前端把商品字段(标题、描述、分类、原价、售价、成色、联系方式)拼成JSON,连同图片文件一起提交。
- 后端先做登录校验和权限校验——未登录不能发布;已认证的用户才能发布。
- 参数校验:标题长度限制、价格必须大于0且不能高于原价的离谱程度(比如原价100卖500这种数据要直接拒绝)。
- 图片处理:接收MultipartFile数组,逐个保存,返回图片路径数组。
- 组装Goods实体,初始状态设为"在售",写入数据库。
核心的Service方法大致是:
@Transactional(rollbackFor = Exception.class) public Long publish(GoodsPublishDTO dto, List<MultipartFile> images, Long userId) { // 1. 校验用户认证状态 User user = userMapper.selectById(userId); if (user == null || user.getAuthStatus() != 2) { throw new BusinessException("请先完成学生认证后再发布商品"); } // 2. 校验参数 if (dto.getPrice().compareTo(BigDecimal.ZERO) <= 0) { throw new BusinessException("出售价格必须大于0"); } // 3. 处理图片 List<String> imageUrls = images.stream() .map(image -> fileService.saveImage(image)) .collect(Collectors.toList()); // 4. 组装实体 Goods goods = new Goods(); goods.setUserId(userId); goods.setTitle(dto.getTitle()); treats.setDescription(dto.getDescription()); goods.setPrice(dto.getPrice()); goods.setImageUrls(String.join(",", imageUrls)); goods.setStatus(GoodsStatus.ON_SALE.getValue()); // 5. 入库 goodsMapper.insert(goods); return goods.getId(); }@Transactional(rollbackFor = Exception.class)是必须写的,如果图片保存后发现前面某一步抛错,数据库不能留半截数据。
4.2 图片上传:最容易翻车的环节
Spring Boot默认的multipart上传限制很小,如果你不主动配置,超过1MB的图片会被直接拒绝。用户的手机照片动辄2-3MB,所以一定要在application.yml里显式调大:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 30MB图片保存逻辑里有几个容易被忽略的细节:
- 文件名重命名:不要用用户上传的原始文件名,用UUID重新生成。否则可能遇到两个用户上传同名图片互相覆盖,更危险的是文件名里带路径转义字符可能构成安全风险。
- 扩展名白名单校验:只允许
.jpg、.jpeg、.png、.gif、.webp,其他一律拒绝。不要只用Content-Type判断,这个可以伪造,最稳妥的是解析一下文件头或者用扩展名+Content-Type双重校验。 - 静态资源映射:保存好文件之后还得让前端能访问到。在Spring Boot中配置:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath = System.getProperty("user.dir") + "/upload/"; registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }这样浏览器就能通过http://localhost:8080/upload/xxx.jpg直接访问图片了。
4.3 下单与库存扣减:并发安全怎么做
二手商品每件只有一个库存,两个人同时点购买同一件商品,应该只有一个人成功。刚开始我用的是典型的select-then-update逻辑:
- 查商品状态是否在售
- 如果在售,创建订单
- 更新商品状态为已售出
这个逻辑在低并发场景没问题,但遇到并发请求时会出现两个人都查到了"在售",两个人都创建了订单,然后商品被卖了两次。原因就是检查和更新不是原子操作。
解法是乐观锁。在goods表增加一个version字段,更新时用带上version来校验:
UPDATE goods SET status = 1, version = version + 1 WHERE id = #{goodsId} AND status = 0 AND version = #{version}如果UPDATE影响的行数为0,说明商品状态已经被其他请求改掉了,本次下单直接失败。结合事务,下单逻辑变成:
@Transactional(rollbackFor = Exception.class) public Long createOrder(Long goodsId, Long buyerId) { // 1. 查商品 Goods goods = goodsMapper.selectById(goodsId); // 2. 前置校验 if (goods == null || goods.getStatus() != GoodsStatus.ON_SALE.getValue()) { throw new BusinessException("商品已下架或已售出"); } if (goods.getUserId().equals(buyerId)) { throw new BusinessException("不能购买自己发布的商品"); } // 3. 乐观锁更新商品状态 int rows = goodsMapper.updateStatusWithVersion( goodsId, goods.getVersion(), GoodsStatus.ON_SALE.getValue(), GoodsStatus.SOLD.getValue()); if (rows == 0) { throw new BusinessException("手慢了,商品已被别人买走"); } // 4. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); // ... 设置订单字段 orderMapper.insert(order); return order.getId(); }这里有一个更极端的情况是数据库事务隔离级别默认是REPEATABLE_READ(MySQL InnoDB默认),两个并发事务同时读同一行在售商品,理论上一方更新后另一方再提交时会冲突,但因为我们是先UPDATE后INSERT,UPDATE的锁会串行化这个过程,实测下来是安全的。
如果你还担心,可以再加一层Redis分布式锁,以goods:lock:商品ID作为key,下单前先尝试加锁。但考虑到校园平台的并发量——峰值大概每秒几十次下单——乐观锁已经绰绰有余,分布式锁属于锦上添花。
4.4 订单状态流转:如何防止非法跳转
状态机校验放在Service层统一出口。所有涉及订单状态变更的操作都必须通过OrderStateMachine:
public void ensureCanChange(OrderStatus current, OrderStatus target) { Set<OrderStatus> allowed = ALLOWED_TRANSITIONS.get(current); if (allowed == null || !allowed.contains(target)) { throw new BusinessException("订单状态非法流转:" + current.getDesc() + " -> " + target.getDesc()); } }然后在每个Service业务方法里第一步调用这个校验。这样就让状态变更的唯一入口变得可控,无论是用户页面的按钮、还是管理员的强制操作,都必须经过规则校验。
5. 校园场景特有的信任机制设计
5.1 学生身份认证:把骗子挡在门外
线上交易最怕遇到不守信的人,校园二手市场尤其明显。因为地点范围小,一旦出现骗子,影响会迅速波及整个学校。
我设计了三层认证方式,供不同情况下使用:
- 学校邮箱验证:部分学校给每个学生分配了专属校园邮箱,后缀一般是
stu.xxx.edu.cn。用户填入学号和对应邮箱,系统发验证码,输入验证码即通过认证。这种方式防伪能力最强。 - 学生证上传审核:用户上传学生证照片,管理员在后台人工审核。这种方式审核成本高,但兼容了没有校园邮箱的学校。
- 学号+姓名匹配:如果对接了学校的信息系统接口,可以直接用学号+姓名查询验证。但在毕设场景下工作量太大,我把它列为可选方案。
实践中我把1和2都做了:优先邮箱验证,验证不了就上传学生证等管理员审核。认证通过前,发布商品功能直接不可用,从入口处就挡住了大量潜在风险。
5.2 信用分与举报处理体系
信用分是校园平台的关键运营机制。初始分为100分,规则如下:
- 交易成功且双方无投诉:双方信用分各自加1分,上限110分。
- 卖家被举报且查实(描述不符、拒不发货):扣10分。
- 买家被举报且查实(恶意砍价、放鸽子):扣5分。
- 信用分低于60分:禁止发布商品、限制下单。
- 信用分低于30分:封禁账号。
这套规则对标的是主流电商平台的信用体系,但做了简化,强调"高频低额"的约束:从违规到封禁是一个累积过程,给用户改正空间,也在流程上保留证据。
举报处理流程:买家/卖家 → 提交举报单(选择商品、填写理由、上传截图) → 管理员后台审核 → 审核通过则下架商品并扣信用分,审核驳回则关闭举报单 → 被举报方有申诉机会。
5.3 交易安全提示与规则约束
校园二手交易通常不走线上支付,很多是当面交易,平台不需要介入资金流。但为了减少纠纷,我在系统中做了几个强制设计:
- 商品详情页显著位置展示"建议校内当面交易,仔细验货后再付款"的提示。
- 下单时弹出交易须知,必须勾选"已阅读并同意"才能下单。
- 订单完成后,买家和卖家互相评价,"描述相符"、"沟通态度"、"交易速度"三个维度打分。
这些设计看似轻量,但真的能降低平台方的责任风险,同时让用户意识到交易平台只是撮合角色,保护自己的权益还是要靠主动行为。
6. 开发中的踩坑实录与优化手记
6.1 Spring Boot版本选择引发的连锁反应
最初我确实冲动过,想直接用Spring Boot 3.x加JDK 17,毕竟新版本性能更好,而且代表未来的方向。但做了几天就开始后悔了。
问题出在生态兼容上。我用到的MyBatis-Plus版本如果太旧,直接不支持3.x的Spring Boot;如果换新版,和JDK 17 + Spring Boot 3.x配合又需要额外配置。中间遇到的一个例子是MyBatis-Plus的分页插件在Spring Boot 3下需要新依赖mybatis-plus-spring-boot3-starter,而网上的老教程全都是在讲mybatis-plus-boot-starter,照着旧教程写了半天才发现不对。
最后我把项目整体回退到Spring Boot 2.7.x + JDK 8 + MyBatis-Plus 3.5.3,一天之内所有问题迎刃而解。后来想一想,求稳定是项目开发的第一原则,技术栈的新旧并没有想象中那么重要。
6.2 文件上传限制和跨域那点事
这两个坑几乎每个做前后端分离的人都会踩一遍,我简单说下自己的解决过程。
跨域问题排查了整整一个下午。现象是前端Vue启动在8081端口,后端Spring Boot跑在8080端口,浏览器里前端页面发起POST请求,NetWork面板里显示请求发出去了,但响应状态是CORS error。后来在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); } }让我注意的是allowedOriginPatterns("*")配合allowCredentials(true)必须用allowedOriginPatterns而不是allowedOrigins,否则某些浏览器会拦截带Cookie的请求。校园项目开发环境下没有Cookie操作需求的话,直接allowCredentials(false)更简单。
文件上传限制前面已经说了,不重复。排到一起说是因为它们经常一并出现——跨域导致图片上传失败,调整完CORS又发现文件大小超限,两个问题像是约好了一起来。
6.3 时间格式化与全局异常处理的细节
前端展示订单创建时间时,发现格式一直是2024-06-01T10:30:00,而不是期望的2024-06-01 10:30:00。原因是Java 8的LocalDateTime序列化成JSON时默认走了ISO8601格式。
解决办法有两个:
- 在字段上加
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - 在
application.yml里配置全局格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8我建议用全局配置,因为项目里的时间字段不止一个,全局配置能一次解决所有的问题。
再说Long类型精度丢失的坑。我把数据库自增主键设计为BIGINT,Java里用Long接。如果主键值大于JavaScript的Number.MAX_SAFE_INTEGER(9007199254740991),前端拿到的ID会变成失真的数字。比如后端返回的订单号1752345678901234567,前端可能变成1752345678901234500,导致详情查询失败。解决办法是在返回给前端的VO里把ID转为String,或者@JsonSerialize(using = ToStringSerializer.class)。
全局异常处理也是一个被很多人低估的模块。我在项目里实现了@RestControllerAdvice统一捕获业务异常、参数校验异常、系统异常,返回统一的{code, message, data}结构。这样做的好处是前端只需要统一处理一种错误响应结构,而不是每个接口各返回各的格式,联调效率翻倍。
6.4 部署上线后真实压出来的问题
系统部署到服务器之后,做了一轮最基础的并发测试,果然暴露了问题。
一是首页商品列表在没有缓存的情况下,每次刷新都查数据库,QPS到了50时数据库连接池占用就开始飙升。后来加了Redis缓存,把首页热门商品和分类列表缓存5分钟,压力立刻降下来了。需要注意缓存穿透和缓存雪崩——我做了空值缓存和随机过期时间,防止热点key集中失效导致数据库被冲垮。
二是数据库连接池用的是HikariCP(Spring Boot默认),默认最大连接数是10,在并发测试里出现过connection is not available, request timed out的错误。后来把最大连接数调到了50,并在连接池空闲时间、超时时间上做了调优:
spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000三是商品列表的大字段(description)和图片字段(image_urls)在列表页根本用不上,但默认SELECT *会全部查出来,导致响应体巨大。后来在Mapper里写了一个精简版VO查询,只select列表展示需要的字段,响应体直接从几KB降到几百字节。
一些最后想说的话
这个系统从最初的一时兴起,到最后完整落地、部署上线,前前后后用了大概三周。回头看,最大的收获不在于学会了多少框架API,而在于把"一个想法"变成一个"可用的产品"的过程中,逼着自己做了大量教科书里不会讲的实操决策。比如版本选型时选择稳定而不是追新,存储设计在规范化和性能之间的平衡,信任机制从业务规则到代码落地的层层思考。
如果你也要做类似的springboot主题项目,我的建议是:不要把注意力全放在"写代码"上,先花两天把业务边界和表结构设计想清楚,然后动手前想清楚每一个状态流转和并发场景,这样后面代码写起来就是水到渠成,而不是边写边改、边改边崩溃。
最后分享一个实操小技巧:给别人演示系统的时候,先把测试数据准备得丰富一点——几十个用户的评论、上百件带真实图片的商品、几组不同状态的订单,这些数据能让你的演示效果拉满。毕竟,一个空荡荡的平台和一个热闹的二手市场,站在评审老师的角度看,完全是两个系统。