SpringBoot校园二手书交易平台:从架构设计到JWT与并发实践 1. 项目概述与核心痛点做校园项目这些年我见过太多“为了毕设而毕设”的系统界面花里胡哨后端一塌糊涂演示的时候跑得欢真拿去做生产环境就翻车。今天要拆解的这个“基于Java的SpringBoot框架校园二手书交易平台”属于学生项目里最常见、也最容易被做烂的一类。但换个角度说它恰恰是理解企业级开发流程的最佳载体——业务逻辑不复杂却又完整覆盖了用户认证、商品管理、交易流程、权限控制这些核心模块。这个平台解决什么问题说白了就是校园里二手书交易的“信息孤岛”。高校里每学期期末都有大量教材闲置大一买的新书大二就用不上了而低年级同学还在原价购买。传统方式要么靠 QQ 群吼、朋友圈发广告要么在宿舍楼下贴纸条信息分散、难检索、没信用背书。而一个独立的校内交易平台可以让买家按书名、按专业、按价格快速筛选卖家一键挂书交易状态全程可见管理员还能对违规信息做审核。这不仅仅是把“线下跳蚤市场”搬上线更重要的是把“交易可信度”做了一个结构化建模。适合谁来看这篇拆解我建议这样对号入座如果你是正在做毕业设计的学生本文可以直接当系统设计说明书用如果你是想练手 SpringBoot 的开发者本文的模块划分和数据表设计能让你少走弯路如果你是刚接触“前后端分离”概念的初学者我会尽量把每一步背后的为什么讲透而不是只堆一堆代码。我还是那句老话项目不怕小就怕没有完整的思考链路。哪怕只是一个校园二手书交易平台只要把需求分析、数据建模、接口设计、权限控制这些环节走通你就算真正入了后端开发的门。2. 整体设计与技术选型拆解2.1 为什么选用 SpringBoot 而不是 SSH 或 SSM很多老教程还在教 SSHStrutsSpringHibernate或者 SSMSpringSpringMVCMyBatis但从 2024 年往回看SpringBoot 已经不是“新框架”了而是行业事实标准。它最大的价值不是省了那几行 XML 配置而是把“约定优于配置”这个思想落实到了工程级别。举个最直观的例子在 SSM 时代搭建一个能跑起来的 Web 工程你需要配置 web.xml、spring-mvc.xml、spring-dao.xml、mybatis-config.xml还要处理各种 jar 包版本冲突。一顿操作猛如虎结果 Tomcat 一启动就报 ClassNotFoundException。而 SpringBoot 只需要一个启动类加一个 pom.xml内嵌 Tomcat 自动装配秒级启动。针对校园二手书交易平台这种中体量项目SpringBoot 还有一个隐性优势它的 starter 机制让集成第三方组件极其方便。我们需要 JPA 做持久化直接引 spring-boot-starter-data-jpa需要安全认证引入 spring-boot-starter-security需要参数校验引入 spring-boot-starter-validation。所有依赖版本都由父 POM 统一管理基本不会出现版本冲突。当然有人会说 SSM 更能体现“底层功底”。但实际开发中企业要的是快速交付、稳定运行而不是看你手写多少 XML。SpringBoot 并不是把 Spring 封装得不可见恰恰相反它让你更专注于业务逻辑本身。如果你真想学习原理SpringBoot 的自动装配源码就是最好的教材。2.2 前后端分离还是服务端渲染这是做这个项目时需要做出的第一个关键决策。两种方案各有优缺点我直接给你结论除非你的项目要求是“必须用 Thymeleaf 模板引擎”否则优先选前后端分离。原因有三层。第一校园二手书交易平台有明确的“多端适配”需求——学生可能用手机浏览器访问也可能在电脑上操作前后端分离后同一套后端 API 可以同时服务 Web 端和移动端甚至以后可以接小程序。第二前后端分离让团队分工更清晰一人负责 Vue/React 页面一人负责 SpringBoot 接口只需要约定好 JSON 结构。第三从面试角度看能说清楚“跨域处理”“JWT 鉴权”“Restful 风格”这些词比单纯说“我用 Thymeleaf 渲染了页面”要有说服力得多。我在这套系统里选的是 Vue 3 Element Plus 作为前端框架后端就是一个纯 API 项目。这并不意味着服务端渲染一无是处——如果项目里有 SEO 需求SSR 会更好。但校园二手书交易平台的核心流量来自校内学生靠的是口碑传播而不是百度搜索所以前端渲染完全没问题。2.3 数据库选型MySQL 5.7 还是 8.0别犹豫直接用 MySQL 8.0。需要注意的是8.0 默认字符集是 utf8mb4这比 5.7 的 utf8 更适合存储生僻字和表情符号。二手书的书名里偶尔会带个特殊符号比如“C Primer Plus第 6 版”用 utf8 会报错utf8mb4 则毫无压力。还有一个非常容易被踩的坑MySQL 8.0 的驱动类名从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.DriverURL 里还需要带serverTimezoneAsia/Shanghai否则做时间查询时会差 8 个小时。网上很多老教程还在用旧写法你照着抄就等着被坑。2.4 核心功能模块划分一个标准的校园二手书交易平台最少要包含以下模块用户模块学生注册、登录、个人信息维护、密码修改图书模块图书发布、图书编辑、图书下架、图书搜索筛选交易模块下单购买、订单状态流转、交易取消评价模块买卖双方互评、评价列表展示管理员模块用户管理、图书审核、订单监管、数据统计每个模块之间要有清晰的依赖关系。比如“订单模块”不能直接操作“图书表”的库存字段而是要通过 Service 层的接口调用这样才能保证数据一致性。有些同学喜欢把所有业务逻辑都堆在 Controller 里一个方法写 200 行这种代码自己调试都费劲更别说让别人维护了。我画了一个简单的模块依赖关系不要笑这是用文字描述的用户模块 - 图书模块 - 交易模块 - 评价模块 ↑ ↑ └──── 管理员模块 ────┘管理员模块像是一个“监管者”可以越过普通用户的权限边界直接审核图书内容、处理违规订单。这种设计不是多余的——如果没有管理员审核平台很容易被发广告的人刷屏图书信息的可信度也会直线下降。3. 核心细节解析与实操要点3.1 用户认证JWT 还是 Session校园二手书交易平台属于“轻度敏感”系统不涉及支付密码、身份证号等顶级隐私但用户登录态的保持方式依然值得仔细斟酌。我见过很多学生项目直接用 HttpSession逻辑简单服务端存状态配合拦截器也能实现登录校验。但问题在于如果以后要把后端拆成微服务或者部署到多台服务器Session 同步就成了噩梦。而使用 JWTJSON Web Token后服务端不保存登录状态token 里直接携带用户 ID 和过期时间每次请求只需验签天然适合分布式场景。JWT 的使用要点如下// 生成 token String token Jwts.builder() .setSubject(userId.toString()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact();这里有几个细节必须注意。第一secretKey 不能写在代码里应该放到 application.yml 的配置项中并且不同环境使用不同值。第二token 有效期我一般设为 7 天对校园内部工具来说足够长但如果涉及更敏感的操作可以缩短到 2 小时并加入刷新机制。第三JWT 一旦签发就无法撤销所以前端在退出登录时除了删除本地 token还应该调一个后端接口把该 token 加入黑名单简单起见用 Redis 存黑名单即可。登录接口的完整逻辑大概是这样用户提交学号和密码 - Controller 接收参数 - Service 调 UserRepository 查库 - 用 BCrypt 校验密码哈希 - 校验通过后生成 JWT - 返回给前端。密码存储绝对不能是明文也不能用 MD5——MD5 已经被彩虹表打穿了。Spring Security 自带的 BCryptPasswordEncoder 就是一个好选择它是加盐哈希每次加密结果不同但校验时能正确匹配。3.2 图书发布功能从表单到数据库的完整链路图书发布是整个平台使用频率最高的功能之一也是很多初学者写代码时最容易出乱子的地方。我们先明确这个功能需要哪些字段字段名类型必填说明book_namevarchar(100)是书名authorvarchar(50)是作者publishervarchar(50)否出版社isbnvarchar(20)否国际标准书号original_pricedecimal(10,2)否原价sell_pricedecimal(10,2)是售价condition_descvarchar(500)是成色描述image_urlvarchar(200)否封面图路径major_categoryvarchar(20)是专业分类course_namevarchar(50)否关联课程名前端上传图片时我推荐两种方案如果服务器在国外或带宽小直接传 base64 字符串会撑爆内存更稳妥的是用 MultipartFile 上传到本地磁盘或对象存储然后把返回的 URL 传给后端。后端接收图片上传时要做三件事校验文件类型只允许 jpg/png/webp、限制大小单张不超过 5MB、重命名文件。重命名不能直接用用户上传的原始文件名因为可能出现路径穿越攻击。推荐用 UUID 拼接原始后缀名比如a3f2b91e-6c7d-4e5a-9f0c-2d4e8f1a3b7c.jpg。图书发布完成后状态字段status默认为 0待审核管理员审核通过后变为 1在售买家下单后变为 2已预约交易完成后变为 3已售出卖家主动下架则变为 4已下架。你可能觉得状态机太啰嗦但如果没有这个字段你会遇到一个经典问题买家下单时另一人恰好把书买了数据库就出现了“超卖”。通过状态字段配合乐观锁更新可以避免这种并发问题。3.3 搜索与筛选不要让 SQL 变成大杂烩二手书平台必须有搜索功能而搜索的实现方案大有讲究。最朴素的方案是一条 SQLSELECT * FROM book WHERE book_name LIKE %关键词%这种方式对于小型项目够用但是有两个问题一是无法实现分词匹配搜“Java程序设计”找不到“Java 程序设计”中间多空格二是%关键词%写法会导致全表扫描数据量到几万条时性能就开始下降。更好的方案是引入全文索引或搜索引擎。MySQL 自带 FULLTEXT 索引但对于中文分词支持不太好。我建议在这个项目中引入一个轻量级方案如果数据量在十万以下可以使用 Elasticsearch 的简化版——直接建一张book_keyword表在发布图书时把书名、作者、出版社、专业分类拼接成一个字符串用 IK 分词器分词后存入表里。查询时先查这张表拿到 book_id 列表再回表查完整信息。这种方案不引入额外中间件但能明显提升中文搜索效果。当然如果追求极致简单也可以直接用 MySQL 的LOCATE函数替代LIKE性能稍好一些但并不会有质的飞跃。说到底校园二手书平台的核心场景是“按专业找到教材”所以搜索字段的权重排序很重要书名匹配 作者匹配 课程名匹配。在 SQL 里可以用ORDER BY FIELD或CASE WHEN实现权重排序。筛选功能就简单多了——按专业分类筛、按价格区间筛、按成色筛。这些都可以用一个动态 SQL 拼接器完成但要注意防止 SQL 注入。不要用字符串直接拼接参数用 MyBatis 的where标签配合test判断可以优雅解决。3.4 交易流程设计状态机与防并发策略这是整个项目技术含量最高的部分。二手书交易不同于电商平台的“购物车—下单—支付—发货”它的流程更简单但并发问题一样不少。我设计的交易状态机如下待付款 - 已付款等待卖家确认 - 已成交线下交付完成 ↓ ↑ 已取消 超时未确认自动取消买家下单时系统应该做两件事第一件事扣减图书的库存或直接把 status 改为已预约第二件事创建一个订单记录状态为待付款。注意这里不能用“先查询再更新”的朴素逻辑否则两个买家同时下单就会“超卖”。正确做法是用一条 UPDATE 语句做原子操作UPDATE book SET status 2 WHERE id ? AND status 1如果执行结果影响行数为 1说明抢购成功如果为 0说明这本书已经被别人抢走了。这种乐观锁写法虽然简单但非常可靠比用悲观锁 synchronized 高效得多。订单表设计的核心字段order_id主键用雪花算法或数据库自增book_id关联图书buyer_id买家用户 IDseller_id卖家用户 IDorder_price成交价下单时快照防止卖家之后改价status订单状态0待付款/1已付款/2已完成/3已取消created_time / pay_time / finish_time关键时间点要注意的是order_price必须冗余存储不能实时去查图书表的价格。否则卖家在买家下单后把价格改了订单金额就变了对不上。这就是典型的“快照模式”在电商系统中非常常见。交易取消逻辑也不难如果买家在“待付款”状态取消订单则把图书 status 改回 1在售如果超过 30 分钟未付款应该有个定时任务扫描过期订单并自动关闭。定时任务用 SpringBoot 自带的Scheduled注解就能实现不用引入 Quartz。3.5 管理员审核与数据统计管理员模块如果只做增删改查那这个项目的“深度”就少了 30%。我建议至少加入以下三块内容图书审核列表分页展示所有待审核图书管理员可以一键通过或拒绝用户管理除了禁用/启用用户外还要能查看每个用户的交易记录和评价记录数据看板统计每日新增用户数、新增图书数、成交量用 ECharts 展示曲线图管理员接口必须做权限校验不能在 Controller 里简单判断“角色等于 admin”就完事。正确做法是引入 Spring Security 的PreAuthorize(hasRole(ADMIN))注解配合 JWT 中携带的角色信息。在配置拦截器时需要对/api/admin/**路径统一校验。这里有一个经验之谈管理员操作的接口尽量加操作日志比如谁在什么时间审核了哪本书审核结果是什么。虽然我只是做一个校园项目但带着这种审计思维做出来的系统代码质量和面试表现会完全不在一个档次。4. 实操过程与核心环节实现4.1 环境准备与项目初始化我用的开发环境如下你可以按自己的习惯微调JDK 1.8 或 11推荐 11SpringBoot 2.7 以后很多特性需要 11 Maven 3.8 MySQL 8.0 Redis 5.0用于缓存和 token 黑名单非必须但推荐 Node.js 16前端创建一个 SpringBoot 项目最快捷的方式是访问 Spring Initializr勾选以下依赖Spring Web Spring Data JPA或者 MyBatis MySQL Driver Validation Lombok Spring Security可选如果用 JWT 需要手动配置一些组件项目结构推荐采用“按业务分包”而不是“按技术分包”。很多教材习惯建controller、service、dao三个大包所有 Controller 堆在一起。但业务多了以后这种分包方式非常难维护。我推荐这种方式com.example.booktrade ├── common // 通用工具类、统一返回结果、异常处理 ├── config // 配置类安全配置、跨域配置、拦截器 ├── security // JWT 过滤器、认证入口 ├── user // 用户模块controller/service/repo/entity ├── book // 图书模块 ├── order // 订单模块 └── admin // 管理模块按业务分包的好处是你需要改用户功能时整个包的结构一目了然不需要在五个不同的包之间来回跳。4.2 创建基础实体类与统一返回格式实体类直接对应数据库表。以用户表为例Entity Table(name user) Data public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(unique true, nullable false, length 20) private String studentNo; Column(nullable false) private String passwordHash; Column(nullable false, length 20) private String nickname; Column(length 20) private String phone; Column(length 100) private String avatarUrl; Column(nullable false) private Integer role; // 0-学生 1-管理员 Column(nullable false) private Integer status; // 0-正常 1-禁用 private LocalDateTime createTime; private LocalDateTime updateTime; }注意几个细节。第一studentNo学号字段必须有唯一约束防止一个学号注册多个账号。第二密码字段存的是passwordHash而不是明文password。第三时间字段推荐用LocalDateTime别用java.util.Date因为 LocalDateTime 在 JSON 序列化时可以配合JsonFormat输出yyyy-MM-dd HH:mm:ss格式。统一返回格式是整个系统所有接口的规范我很推荐用以下结构public class ResultT { private Integer code; // 200 成功400 业务错误401 未登录403 无权限 private String message; private T data; }每个 Controller 方法都返回Result前端收到响应后先判断code再决定是否渲染。这样做的价值是让前后端联调时的沟通成本降到最低。如果你让每个接口随意返回自己的结构前端每种情况都要单独判断调试时想死的心都有。4.3 实现图书发布的完整代码图书发布接口的 Controller 代码如下PostMapping(/api/book/publish) public ResultString publish(RequestBody Valid BookPublishRequest request, RequestAttribute(userId) Long userId) { bookService.publish(request, userId); return Result.success(发布成功等待管理员审核); }Valid注解会触发参数校验我们需要定义请求 DTO 的约束Data public class BookPublishRequest { NotBlank(message 书名不能为空) Size(max 100) private String bookName; NotBlank(message 作者不能为空) private String author; NotNull(message 售价不能为空) DecimalMin(value 0.1, message 价格必须大于0) private BigDecimal sellPrice; NotBlank(message 成色描述不能为空) Size(max 500) private String conditionDesc; // ... 其他字段 }Service 层的实现核心是设置初始状态public void publish(BookPublishRequest request, Long userId) { Book book new Book(); BeanUtils.copyProperties(request, book); book.setSellerId(userId); book.setStatus(BookStatus.PENDING); book.setCreateTime(LocalDateTime.now()); bookRepository.save(book); }这里不要直接在 Controller 里 new Book 并手动 set 一堆字段用 BeanUtils 做属性复制省时省力但要注意属性名必须一致。图片上传的逻辑在前面已经提到我再补充一个关键点上传后的文件访问路径要与静态资源映射对应。在 SpringBoot 中你要配置一个虚拟路径映射到磁盘目录Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir /); }否则你在浏览器里直接访问http://localhost:8080/upload/xxx.jpg会 404。4.4 实现搜索接口搜索接口要考虑分页。前端每次请求传pageNum和pageSize后端返回总条数和当前页数据。我常用的封装是 Spring Data 的PageRequestpublic PageResultBookVO search(String keyword, Integer category, BigDecimal minPrice, BigDecimal maxPrice, int pageNum, int pageSize) { Pageable pageable PageRequest.of(pageNum - 1, pageSize); PageBook page bookRepository.searchBooks(keyword, category, minPrice, maxPrice, pageable); // 转换为 VO 返回 }对应的 Repository 方法可以使用 JPA 的 Specifications 或 MyBatis 的 XML。我用 MyBatis 更习惯因为复杂 SQL 比较灵活。下面是 MyBatis 的动态查询示例select idsearchBooks resultTypecom.example.booktrade.entity.Book SELECT * FROM book where book.status 1 if testkeyword ! null and keyword ! AND (book_name LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %) OR course_name LIKE CONCAT(%, #{keyword}, %)) /if if testcategory ! null AND major_category #{category} /if if testminPrice ! null AND sell_price gt; #{minPrice} /if if testmaxPrice ! null AND sell_price lt; #{maxPrice} /if /where ORDER BY create_time DESC /select这里我把status 1放在where外面保证所有查询都只搜索在售商品避免把“待审核”“已下架”的书搜出来。这个细节很多同学会忽略实际一测就发现搜索结果里有下架书用户体验非常差。分页查询的另一个注意点是排序稳定。如果搜索结果中多个记录的 create_time 相同不同页可能会重复或遗漏数据。我习惯在ORDER BY后面加一个唯一字段id作为二级排序ORDER BY create_time DESC, id DESC4.5 核心交易接口实现先看下订单创建接口PostMapping(/api/order/create) public ResultOrderVO createOrder(RequestBody CreateOrderRequest request) { // 1. 校验图书是否存在且状态为在售 // 2. 原子更新图书状态为“已预约” // 3. 创建订单状态为待付款 // 4. 返回订单信息 }我专门把第 2 步用 SQL 实现Modifying Query(UPDATE Book b SET b.status 2 WHERE b.id :bookId AND b.status 1) int lockBook(Param(bookId) Long bookId);如果返回的 int 为 0说明该书已被他人锁定直接抛出业务异常。这里还要注意事务边界原子更新和创建订单需要在一个事务里如果创建订单失败要把图书状态回滚为在售。用Transactional注解标注在 Service 方法上即可。付款确认接口可以做一个简单的模拟。校园二手书交易平台一般不接真实支付渠道我通常设计为“在线下交易时买家点击确认已付款”。为了简化流程也可以让买家直接点击“确认购买”后订单状态变为“已付款”然后等待卖家在“我的卖出”里点击“确认交付”。这样可以避开支付网关的对接逻辑也更符合校园场景。4.6 部署与上线注意事项本地开发跑通还不是结束真正能称为“项目”的标准是能部署到服务器让别人访问。部署方案我推荐用 Docker Compose 管理 MySQL、Redis、后端 Jar 包和 Nginx。version: 3 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: 你的密码 MYSQL_DATABASE: booktrade volumes: - ./mysql-data:/var/lib/mysql backend: build: ./backend ports: - 8080:8080 depends_on: - mysql nginx: image: nginx:latest ports: - 80:80 volumes: - ./frontend-dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf前端构建后的静态文件放在 nginx 里后端接口通过/api路径访问Nginx 做反向代理转发到 8080 端口。这样部署完成后学生只需要通过校园网访问服务器 IP 就能使用平台。别担心不熟悉 Docker这个 Compose 文件已经是最小化配置照着跑基本能成功。关于上线前必须检查的几个点我用一张表总结检查项参考标准数据库连接配置不要用 root 账号创建专用账号并限定权限服务器时区统一设置为 Asia/Shanghai文件上传目录权限运行时用户必须有读写权限日志输出配置 logback 输出到文件方便排查问题JWT 密钥生产环境使用随机生成的长字符串4.7 前端关键页面与接口对接虽然本文重点讲后端但前端对接的痛点值得提一嘴。用 Vue 3 开发时我建议把 axios 实例封装在一个文件里统一配置 baseURL 和请求拦截器// request.js import axios from axios import router from ./router const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器携带 token 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) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(网络异常) return Promise.reject(error) } )这个拦截器一写前端每个页面请求就直接调用request.get(/book/list)即可完全不用在每个页面里重复处理错误。注意后端接口路径与 axios baseURL 的配合如果 Nginx 把/api转发到后端前端 baseURL 直接写/api后端 Controller 的 RequestMapping 不要再带/api前缀避免重复。再强调一遍跨域问题在开发环境会遇到因为前端跑在 5173 端口后端跑在 8080 端口。可以通过后端配置 CORS 解决Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }但一旦用 Nginx 部署前端和后端就同源了不需要 CORS。如果你在服务器上还遇到跨域问题多半是 Nginx 的 proxy_pass 配置少了proxy_set_header Host。5. 常见问题与排查技巧实录5.1 问题一启动时提示端口被占用SpringBoot 默认端口 8080如果本机装了其他服务占用该端口启动会直接报Web server failed to start. Port 8080 was already in use。最简单的排查方式lsof -i :8080 # Mac/Linux netstat -ano | findstr :8080 # Windows找到占用端口的进程号后要么杀掉它要么在 application.yml 里改端口server: port: 8081但改端口之后前端 axios 的 baseURL 也要同步修改否则接口全部 404。5.2 问题二登录后访问接口返回 403这个问题的根源几乎都是 JWT 过滤器没有放行白名单路径。我的建议是把放行规则明确写出来放行 POST /api/auth/login POST /api/auth/register GET /api/book/list GET /api/book/detail/{id} GET /api/category/list 需要认证 POST /api/book/publish POST /api/order/create GET /api/order/list GET /api/user/info 需要管理员 GET /api/admin/**在 Spring Security 配置类里用antMatchers精确放行。如果你用了permitAll()放行了/api/**那所有接口都能匿名访问等于安全机制形同虚设。5.3 问题三中文乱码分三种情况。第一数据库表字段乱码查看数据库编码是否为utf8mb4。第二HTTP 响应乱码在 SpringBoot 中设置server: servlet: encoding: charset: UTF-8 force: true第三前端页面乱码检查 HTML 文件是否声明meta charsetUTF-8。5.4 问题四图片上传后无法访问图片上传成功了但浏览器访问返回 404。90% 的原因是静态资源映射没生效。前文已经给了addResourceHandlers配置还要注意uploadDir路径的末尾要有斜杠。如果你是 Windows 部署路径分隔符要转换String uploadDir Paths.get(upload).toAbsolutePath().toString();这里容易出问题相对路径在不同工作目录下解析不同。我推荐在配置文件中写死绝对路径部署时用环境变量覆盖。5.5 问题五接口返回的日期格式是时间戳JPA 默认序列化 LocalDateTime 可能会输出一大串数组或时间戳前端没法直接用。我的解决办法是在实体类的日期字段上统一加注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime createTime;也可以用全局配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai5.6 问题六并发下单导致库存超卖这个在前面已经讲了核心解法——原子 UPDATE。如果你发现UPDATE book SET status2 WHERE id? AND status1返回 0 但仍然创建了订单那一定是你的事务代码里把更新放在了查询之后而且没有检查更新结果。记住一个原则先更新再判断影响行数。如果不为 1 就抛出异常事务回滚一切当作没发生。5.7 避坑清单每条都是实战总结不要把数据库密码写在代码里用Value注入或环境变量。不要用SELECT *做业务查询只查需要的字段。不要在一个 Controller 方法里同时做太多事一个方法只处理一个请求。不要忘记在delete操作前确认外键关联比如删除用户前要检查他有没有未完成的订单。不要在循环里逐个调用数据库查询应该批量查询后内存匹配。不要忽略异常处理全局用RestControllerAdvice捕获业务异常并统一返回。发布上线前一定要把spring.jpa.show-sql关闭否则 SQL 日志会刷爆磁盘。如果用 Lombok注意Data注解在实体类上没问题但在某些子类继承场景会引发 equals/hashCode 问题需要改成Getter Setter。以上都是我在实际开发中踩过或见别人踩过的坑每一句都对应一个让人抓狂的调试夜晚。把这些记住你至少能少走三个月弯路。5.8 性能优化经验在需要时才做校园二手书平台的并发量远没有电商大促那么恐怖所以不要一开始就上各种缓存。过度设计比不做设计更可怕。我合理的性能优化路径是第一数据库层给外键字段加索引查询常用字段建联合索引。比如(seller_id, status)这个组合索引可以快速查出某卖家在售的书。第二查询层图书列表加上 Redis 缓存过期时间 5 分钟。缓存更新策略采用“更新数据库后删除缓存”简单可靠。但注意缓存穿透问题用一个空值缓存兜底。第三静态资源层图片传输本身就是耗时大户。建议前端压缩图片后再上传而不是后端统一压。比如让前端把图片宽度控制在 800 像素以内质量压缩到 80%单张图片体积就能降到 200KB 左右加载速度快一倍。在我实测的数据里五千条图书数据、一百个并发请求的情况下不做任何缓存接口平均响应时间大约 120ms加了 Redis 缓存后降到了 40ms。但这只是一次“演示级”优化如果你把精力放在更复杂的业务逻辑上收益反而更高。6. 项目后续扩展方向做完一个校园二手书交易平台只是起点它还可以往很多方向延伸而且每一条路都能带来新的技术挑战。第一个扩展方向是“多学校隔离”。不同学校的二手书不应该混在一起展示这需要引入学校维度的数据隔离。一种做法是在所有核心表中加school_id字段查询时强制带上传入的学校标识另一种更优雅的方案是独立出一个school服务用统一认证对接各校的教务系统。当然这是微服务架构的范畴了对单体 SpringBoot 项目来说加一个school_id字段就够了。第二个扩展方向是“信用体系”。目前交易双方的评价只有文字和星级不足以形成约束。可以引入信用分机制用户完成一笔订单加 2 分被差评扣 10 分信用分低于 60 限制交易。这个功能实现起来不复杂但能极大提升平台信息的可信度。信用分计算的异步任务可以用 RabbitMQ 或 Spring 的Async处理。第三个方向是“消息推送”。当买家下单后卖家需要及时收到通知。校园场景下最合适的是微信服务号模板消息或者短信但需要申请比较麻烦。如果只是演示可以在系统内建设一个站内信模块在页面右上角显示未读角标。最后我想聊聊这个项目的“可复用性”。把图书换成“闲置数码”“考研资料”“生活用品”这套平台的骨架完全不需要大改。换一个皮就是一个校园闲置交易平台。因此不要把这个项目当成一个单纯的毕设而是当成一个可以积累的“基础版本”。以后你想做任何 C2C 交易类项目都能从这个平台的架构中直接复用核心逻辑。我个人在实际操作中的体会是做完这个项目你对 SpringBoot 的信心会提升一个档次因为你能清晰地说出——用户从注册登录到发布商品再到完成交易整个生命周期中的数据流是如何流动的。这种“全链路”的感觉才是做项目最宝贵的收获。代码写得不完美没关系架构演进本来就是逐步完善的。但如果你能从零完整地实现一个系统并且愿意把踩过的坑记录下来那你已经比大部分只抄代码的人强得多。最后分享一个小技巧写项目文档时不要只写“实现了什么”多写“为什么这么做”。面试官最看不上的就是那种把代码一贴、功能一列就完事的项目说明书。你把这个博主文章里的思考过程比如为什么选 JWT 不选 Session、为什么用乐观锁不用悲观锁写进文档这个项目的深度立刻就不一样了。