1. 项目核心思路与系统设计
做二手闲置交易系统,我是有备而来的。几年前我自己在闲鱼上买卖过几台相机和镜头,体验过"卖家挂出去没人问、买家私聊半天不敢付款"的尴尬,也见过同学在QQ群里卖旧书被骗子坑了押金。所以当接到这个"基于SpringBoot的闲置物品循环交易保障系统"的项目需求时,我第一反应就是:这不是一个普通的CRUD练习,真正难的是"保障"两个字。用户之间的信任怎么建立?交易资金的流转怎么保证安全?出现纠纷怎么仲裁?这一整套逻辑才是系统的灵魂。
这个项目本质上是一个C2C的二手交易平台,核心用户是高校学生和社区内部人群,买卖双方在同一个校园或社区里,既有线下当面交易的便利,又有线上撮合的需求。和闲鱼、转转这类大平台相比,这个系统的定位更轻量,但轻量不代表简陋,反而因为用户规模可控、交易场景集中,我们可以把"保障机制"做得更细。技术栈上选了SpringBoot作为后端骨架,理由很直接:SpringBoot的自动配置和约定优于配置特性,能让开发重心从"配置环境"转移到"业务逻辑"上;而且国内中小型项目里SpringBoot的生态最成熟,招人容易、排错有社区经验、后续接小程序端或者管理后台都有现成方案。
整个系统从需求上拆,可以分成四个核心板块:
- 用户与信用体系:注册登录、实名认证、信用积分。买家卖家的信用评价是二手交易信任的基础。
- 商品与检索:闲置物品发布、分类浏览、关键词搜索、状态管理(在售、预约中、已售出、下架)。
- 交易保障引擎:订单生成、资金冻结与解冻(担保交易)、取消订单、确认收货、售后与争议仲裁。这是整个系统最有价值的部分。
- 通知与运营:站内消息、订单状态变更通知、定时任务(自动取消超时未支付订单、定期清理失效商品)。
这里有一个容易被新手忽略的设计点:资金流和订单流要分离。订单表只记录交易状态,资金冻结/解冻要通过虚拟钱包流水表去记录,不能直接在订单表里加一个"买家已付款"的布尔字段完事。为什么?因为一旦需要退款、部分退款、平台抽取手续费这些操作,资金流水表就是唯一的账本依据,没有流水表你根本说不清楚这笔钱经历了什么。这个设计是后来做代码讲解时反复强调的重点。
场景上,这个系统适合三类人参考:第一类是正在做毕设的计算机专业学生,这个题目比普通的"图书管理系统"有亮点得多,交易保障机制拿出来讲就很加分;第二类是准备接外包项目的开发者,可以把它当作一个中小型电商项目的模板;第三类是自己想搭一个校园二手平台的社团或创业团队,直接用这套源码改改就能用。
我后面会用一整篇文章的篇幅,把代码结构、核心功能的实现思路、部署文档的完整操作步骤、还有我在实际跑这个项目时踩过的坑全部展开。源码、部署文档、代码讲解这三件套,我会合在一起讲,因为只给源码不给文档等于白给,只讲文档不拆代码你也学不到东西。
2. 代码讲解:从工程骨架到核心业务链路
2.1 工程结构与分层设计解读
一个SpringBoot项目拿到手,先别急着跑起来,第一步是看懂目录结构。这个项目的工程结构是我刻意按主流企业级规范组织的,包名是com.campus.secondhand,核心结构如下:
secondhand-system/ ├── src/main/java/com/campus/secondhand/ │ ├── controller/ # 控制层:HTTP接口 + 参数校验 │ ├── service/ # 业务层:事务边界、业务规则 │ │ └── impl/ # 业务实现类 │ ├── mapper/ # 数据访问层:MyBatis-Plus持久层 │ ├── entity/ # 数据库实体 │ ├── dto/ # 前端交互的数据传输对象 │ ├── vo/ # 视图返回对象(聚合字段) │ ├── config/ # 配置类:WebMvc、拦截器、Redis、定时任务 │ ├── exception/ # 统一异常体系 │ ├── common/ # 通用返回体、分页参数、常量枚举 │ └── util/ # 工具类:JWT、日期、脱敏 ├── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ ├── application.yml # 核心配置 │ ├── application-dev.yml │ └── sql/ # 数据库初始化脚本 └── pom.xml这个结构真正想解决的问题是"职责边界"。我见过太多毕设工程是controller里直接写SQL、service层空空的、所有代码堆在两个类里的。这种代码跑起来没问题,但后续扩展和维护就是灾难。比如项目做到后面要加一个"举报功能",如果代码分层清楚,你只需要新增一个ReportController、ReportService、ReportMapper,原有代码一行不用动;如果是大泥球结构,加功能就可能要改动十几个地方,改完还可能把原来的功能改崩了。
分层设计还有一层更深的意思:事务边界的控制。在Spring里,事务是通过AOP代理实现的,默认情况下@Transactional加到service实现类的方法上才生效。如果把数据库操作直接写在controller里,事务注解可能会失效,到时候数据写到一半崩溃了,你会发现库里留下一堆脏数据。这个项目里所有涉及到多个表的写操作,比如创建订单要同时更新商品状态、生成流水、扣减库存,这些必须在service层的一个事务方法中完成。
2.2 认证与授权模块代码解读
认证这块我用的是JWT(JSON Web Token) + 拦截器的方案,没有引入Spring Security那套全家桶。为什么?因为Spring Security的过滤器链和配置体系对新手来说太劝退了,对这类体量的项目来说有点杀鸡用牛刀。JWT的核心思路是:用户登录成功后,服务端生成一个包含用户ID和过期时间的签名Token,前端存在本地,每次请求放在Header里带上,服务端通过拦截器验签解析出当前用户。
JWT的签名验证过程值得展开讲一下。Token生成时,服务端用HMAC-SHA256算法,以密钥jwt.secret对用户ID + 过期时间做签名。后续请求到达时,拦截器先检查Header里的Authorization是不是以Bearer开头,然后解析Token、比对签名、检查是否过期,全部通过才放行。这里有一个非常关键的设置:用户ID是从Token里解析出来的,绝对不能信任前端传过来的userId参数。我在代码讲解里特意标红了这一点,因为很多二手交易系统的漏洞就出在这——用户下单时把请求里的userId改成别人的ID,就把别人的商品买走了或者把别人的余额扣了。正确做法是只从前端拿商品ID,用户信息一律从request.getAttribute("currentUserId")里取,这个值是拦截器解析Token后放进去的。
密码存储用的不是MD5,而是BCrypt。BCrypt和MD5的区别在于BCrypt是加盐的慢哈希,同一个密码每次生成的密文都不一样,而且计算速度天然很慢,这让彩虹表攻击基本失效。在密码加密这块,我建议你在项目里直接引入spring-security-crypto这个依赖,只使用里面的BCryptPasswordEncoder,不需要引入整个Spring Security。
2.3 商品发布与检索功能拆解
商品模块看起来是个标准的CRUD,但这套系统里它有两个"不标准"的点:图片处理和检索方案。
图片处理方面,项目里的方案是前端把图片上传后,后端先把图片存储到本地的/uploads目录(也可以配成阿里云OSS),然后通过一个PictureController提供静态资源访问接口。这里要特别注意的点是跨域问题,如果你把上传接口和静态资源访问放在同一个后端服务里,前端Vue页面在访问图片时用的是http://ip:8080/uploads/xxx.jpg这种完整路径,那么浏览器的跨域策略会拦截。解决方式有两种:一是后端配置CORS允许跨域访问这些资源路径,二是通过Nginx把/uploads路径反向代理到后端服务。我实际测试下来的结论是第二种更稳妥,因为Nginx处理静态资源的性能比Tomcat好得多,而且配置好之后前后端分离部署也不会再有跨域的坑。
检索方案上,我选择了MySQL的全文索引配合"标题 + 分类 + 标签"的组合条件查询。搜索逻辑不是简单地用LIKE '%keyword%',因为没有谁会想搜一本书的时候把数据库里几千条记录全部扫一遍。使用全文索引MATCH(title) AGAINST('keyword' IN NATURAL LANGUAGE MODE)之后,检索速度快了不少,还能按相关度排序。但是这里也有个坑:MySQL的全文索引对中文分词的支持很弱,默认按空格分词,中文句子没有空格,全文索引基本等于没用。我在项目中实际的替代方案是:对搜索关键词做简单的N-gram切分——按两个字符一组切词,再全部拼成Like条件。比如用户搜"机械键盘",程序切出的词是"机械"和"键盘",查询条件就是title LIKE '%机械%' OR title LIKE '%键盘%'或者两个条件都有时用AND。这个土办法对中文搜索完全够用,而且逻辑简单、没有任何外部依赖。
商品状态流转是另一个容易写错的点。我在实体里用枚举GoodsStatus表示商品状态:ON_SALE(在售)、RESERVED(被预约)、SOLD(已售出)、OFF_SHELF(下架)。状态机的转换规则通过service方法控制,不是前端想改就能改的。比如买家下单付款后,商品状态自动从ON_SALE变为RESERVED,此时其他买家再想下单,系统直接抛"商品已被预约"的异常。之前有个朋友参考这套代码做自己的项目,他省略了这个状态,结果出现了一个商品被三个人同时拍下的情况,最后只能手动在数据库里改数据,非常狼狈。
2.4 交易保障核心:担保支付与争议仲裁
这是整个系统最有含金量的模块,也是代码讲解里最长的部分。整个担保交易的流程我梳理成一张状态机:
买家下单 -> 资金冻结(买家账户余额锁定) -> 卖家发货(商品变为已售出) -> 买家确认收货 -> 资金解冻并转入卖家账户 -> 交易完成这里面的每一步都有对应的数据库操作。拿"买家确认收货"这一步举例,它涉及到同时更新三张表:订单表状态改成COMPLETED,钱包流水表新增一条入账记录给卖家加钱,买家卖家的信用积分各自加上交易成功分。这三个操作必须在同一个事务里,任何一个失败都要全部回滚。我在代码里通过@Transactional(rollbackFor = Exception.class)来控制,在代码讲解时专门强调了一点:rollbackFor一定要设置,因为Spring默认只在抛出RuntimeException时才回滚,如果抛的是IOException这类受检异常,事务不会自动回滚。我自己就犯过这个错误,买家付款后流水记录插入失败,但订单状态还是显示已付款,结果对账的时候怎么都对不上。
争议仲裁机制是这个项目比普通电商项目多出来的亮点模块。交易中如果买家说收到的商品和描述不符,可以发起售后,订单状态进入DISPUTED(争议中),此时双方都可以上传证据文字和图片,管理员或者平台方在后台进行仲裁,仲裁结果分两种:全额退款给买家或者把钱打给卖家。这个模块在设计上值得展开的是"权力分离":在争议处理期间,买家和卖家都不能操作这个订单,所有操作权限归仲裁角色。这能避免"买家一边申请退款一边把钱确认给卖家"这种矛盾的并发操作。仲裁结果记录在arbitration_records表里,里面包含仲裁原因、证据描述、处理结果、操作人ID,这些是审计要用的原始数据,千万不能删。
担保交易模式还有一个必须处理的场景:超时未确认收货。买家一直不点确认收货,钱一直冻着,卖家的体验会非常差。我的方案是开启一个定时任务,自动确认收货。具体规则是:订单在"已发货"状态超过7天,系统自动执行确认收货逻辑。这个定时任务是用SpringBoot自带的@Scheduled注解实现的,每10分钟扫描一次订单表,把超时的订单批量处理。使用的时候要小心:默认情况下@Scheduled只有一个线程在跑,如果你的任务是长任务,下次触发时间到了前一个还没跑完,会造成任务堆积。这个系统的定时任务逻辑很简单,单线程完全够用,但如果你要加复杂的定时任务,建议上@Async异步执行或者引入XXL-JOB这类分布式调度框架。
2.5 消息通知与定时任务
消息通知模块我最初是用站内信去做的,后来加了一个"交易状态变更之后通过邮件通知用户"的增强。站内信实现很简单,通知表里记录user_id、content、is_read、create_time,用户登录后拉取最近未读消息即可。但这里有一个隐藏的坑:如果用户在多个设备登录,消息的已读状态是全局的——一台设备上已读了,另一台设备的红点就没了。有的项目要按设备维度去处理已读,但对于这个系统,全局已读就够用了。
定时任务这块除了上面说的自动确认收货,还有一个"定期下架超180天未成交的商品"。数据库里商品越积越多,很多商品发布之后就再也没人问过,挂着也是占位,不如自动下架。这个逻辑也是一个@Scheduled(cron = "0 0 3 * * ?"),每天晚上三点跑一次,把update_time超过半年的在售商品批量改成下架状态。
这里我要额外提醒一个和SpringBoot版本有关的问题。有些新项目用的SpringBoot 2.6+,@Scheduled的默认行为有一些变化,比如spring.main.allow-bean-definition-overriding从默认false变成了true等。如果你的项目是跟着一些旧教程写的,把版本升上来之后定时任务突然不跑了,十有八九是配置或Bean扫描的坑。我们在部署文档里会专门列一张版本兼容对照表。
3. 部署文档:从零到云服务器上线
3.1 环境准备与基础设施选型
这个项目跑起来需要的环境其实不多,我给一个经过验证的最低配置清单:
| 依赖组件 | 版本要求 | 用途说明 |
|---|---|---|
| JDK | 1.8 及以上 | 运行SpringBoot应用 |
| Maven | 3.6+ | 构建打包 |
| MySQL | 5.7 / 8.0 | 主数据库 |
| Redis | 5.0+ | 缓存与登录Token黑名单 |
| Node.js | 14+ | 仅前端Vue构建需要 |
| Nginx | 1.20+ | 反向代理和静态资源托管 |
开发机是Windows/Mac都行,测试环境的Linux服务器我用的是CentOS 7.9。这里我建议你在学习阶段不要一上来就上容器,先把传统部署跑通了再考虑Docker。因为Docker虽然方便,但里面任何一个环节出错(端口映射、数据卷挂载、容器间网络),排查的复杂度对新手不友好。等你在传统方式上把项目跑熟了,再去看仓库里附带的docker-compose.yml,那时候理解起来会快很多。
版本选择方面有一个重要提醒:JDK版本不要太新。JDK 17在某些SpringBoot 2.x版本上是兼容的,但如果你跟我一样用的是JDK 8,就完全不要升级。很多同学在部署时图新鲜装了JDK 21,然后各种依赖报错,最后拉低效率的还是自己。SpringBoot 2.7.x + JDK 8 是我最终验证过的黄金组合。
3.2 数据库初始化与配置文件解析
数据库初始化脚本放在resources/sql/目录下,文件名是secondhand.sql,里面包含建库建表和基础数据脚本。执行方式我推荐用命令行,直接在服务器上执行:
mysql -u root -p < secondhand.sql如果你用Navicat或DataGrip,直接选择运行SQL文件也可以。执行完以后,库里会有12张表,分别是用户表、商品表、订单表、钱包表、钱包流水表、商品图片表、收藏表、消息通知表、争议仲裁表、评价表、信用积分变更记录表、系统配置表。
配置文件这一关是整个部署流程里最容易出错的地方。我把核心的application.yml拆开解释一下:
server: port: 8080 servlet: context-path: / spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/secondhand?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto jwt: secret: your-custom-secret-key-please-change expire-hours: 24配置里的三个细节值得特别强调:
serverTimezone=Asia/Shanghai,这个不写会有时差问题。很多人查数据库时发现时间比正常时间早8个小时,就是因为没配时区。MySQL 5.7之后默认时区是美国时区,必须手动指定。map-underscore-to-camel-case: true,这个配置让数据库的create_time字段自动映射到Java实体的createTime属性。不配的话你只能写一大串@TableField("create_time")注解,纯属浪费时间。- JWT密钥一定要替换成自己的长随机字符串。用默认密钥上线的话,别人只要知道你的密钥就能伪造管理员Token。
除了application.yml,这个项目还有application-dev.yml和application-prod.yml。我习惯把Redis地址、日志级别、上传文件路径这些按环境拆开。启动时通过--spring.profiles.active=prod指定用哪套配置,这样做的好处是:本地开发用dev配置连localhost的Redis,线上用prod配置连云服务的Redis,不用每次上线前改代码。
3.3 打包与部署的两种主流方式
SpringBoot项目的打包方式很简单,使用Maven的package命令即可:
mvn clean package -DskipTests构建成功后,在target/目录下会生成一个secondhand-system-0.0.1-SNAPSHOT.jar,这个jar包就是完整的可运行产物。SpringBoot内置了Tomcat,所以这其实是一个可以直接运行的java进程。启动命令:
java -jar secondhand-system-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod如果想让它在后台长期运行,用nohup配合日志重定向:
nohup java -jar secondhand-system-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod > app.log 2>&1 &这个命令要拆开解释一下:nohup是让进程忽略终端挂断信号,就算你关掉SSH窗口它也不会退出;> app.log是把标准输出写进日志文件;2>&1是把错误输出也合并到同一个日志文件;结尾的&是让命令在后台执行。这四个部分少一个都不行,有同学只用了java -jar然后直接关SSH,再连上发现进程已经没了,就是这个原因。
另一种我看着现在很流行的方式是宝塔面板部署。宝塔上操作SpringBoot项目其实有两种:一种是直接添加Java项目,另一种是通过Docker容器运行。用宝塔的"Java项目管理器"部署时,它会自动帮你设置进程守护,进程崩了会自动拉起,比手动写nohup省心一点。用Docker的话,仓库里也配好了Dockerfile和docker-compose.yml。核心的Dockerfile是这个样子的:
FROM openjdk:8-jre WORKDIR /app COPY target/secondhand-system-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]整个构建过程是把jar包打进一个只有JRE运行环境的精简镜像里。打镜像的命令是:
docker build -t secondhand-system:latest . docker run -d --name secondhand-system -p 8080:8080 --restart=always secondhand-system:latest有一个需要特别提醒的点:用Docker方式部署时,MySQL和Redis不要也丢在Docker容器里,除非你用docker-compose把三个容器一起编排了。否则容器之间的通信会变得很麻烦,你需要在容器的数据库URL里改用宿主机IP(不是localhost)。
3.4 部署后的验证清单
很多同学把项目跑起来之后,就以为部署成功了,其实有一些隐藏的问题要逐一验证。我在部署文档里专门加了一个验证清单:
- 接口连通性:访问
http://服务器IP:8080/api/goods/list,能返回商品列表JSON就说明Java进程正常。 - 静态资源:上传一张商品图片,然后通过浏览器直接访问图片URL,确认能显示。
- 数据库连接:在后台创建一个用户,刷新数据库确认数据进去了,这一步能发现字符集问题(中文乱码就是一个典型)。
- Redis连接:登录接口调通后,检查Redis里有没有生成Token记录,没有就说明Redis配置有问题。
- 定时任务:改一下系统时间或者等一个周期,确认自动上架任务执行了(这里可以暂时将cron表达式改短来验证)。
我把这套验证清单放在部署文档的最后,目的是让别人接手项目时不用猜,一条一条照着试完就知道部署有没有成功。之前有个开发者来请教我,说他的项目能打开登录页,但登录之后所有接口都报401,我让他看一眼Redis有没有Token,一看就是Redis没配对。很多问题都是这样,一开始看着像代码问题,最后发现全是部署问题。
4. 常见问题排查与避坑实录
4.1 SpringBoot版本太高引发的连锁反应
在写这个项目的源码讲解时,我刻意把依赖版本都锁定在了SpringBoot 2.7.x。原因很简单,SpringBoot 3.x把javax命名空间改成了jakarta,很多老代码的import javax.servlet.*全部报错;同时3.x最低要求JDK 17,如果你还在用JDK 8,根本跑不起来。所以遇到"项目能编译但启动报ClassNotFound"的问题,第一反应先检查版本组合。
另外有一个非常隐蔽的坑:SpringBoot 2.6版本之后,spring.mvc.pathmatch.matching-strategy默认值从AntPathMatcher改成了PathPatternParser。这个改动看起来很小,但如果你写了类似/api/goods/**这种自定义拦截器路径,在旧版本上运行没问题,升级到2.6+之后拦截器可能就失效了。解决方案是在配置文件里显式加上:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher这个问题当时困扰了我一个下午,最后是在本地把两个版本各跑一遍,用mvn dependency:tree一行一行对比依赖才定位到的。吃一堑长一智,后来我写项目时第一件事就是统一版本号,用spring-boot-dependencies的BOM来管理依赖版本,避免子依赖之间版本不一致。
4.2 数据访问层的经典坑:数据库字段与实体属性不一致
这个项目用的持久层是MyBatis-Plus,按理说自动映射已经很省事了,但还有几个坑绕不过去:
第一,Boolean和tinyint的映射问题。MySQL里推荐用tinyint(1)来存布尔值,但如果你的字段名里有is_前缀,比如is_deleted,实体类里用Boolean isDeleted来接收,MyBatis-Plus在某些版本里会产生歧义,导致查询结果全是null。解决方案是加@TableField("is_deleted")显式指定字段名,或者在实体属性上避免使用is开头的命名。
第二,逻辑删除要配置全局。这个项目里用户、商品、订单都用了逻辑删除字段,如果不配全局逻辑删除,那每个查询的SQL里都要手写WHERE deleted = 0,漏掉一个就是查询结果不准确。我在配置里加了这两行,效果是所有的selectById、selectList自动带上deleted = 0条件:
mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 04.3 前后端联调最容易栽的跨域问题
跨域问题我用一句话可以讲清楚:浏览器安全策略规定,一个域名下的网页不能直接发起指向另一个域名的Ajax请求。你在前端http://localhost:5173上跑Vue,后端地址是http://localhost:8080,端口不同,属于跨域。调试时接口能通是因为后端配置了跨域过滤器,但这个过滤器在上线后反而可能变成安全隐患,所以我把跨域配置做了环境区分:开发环境允许所有来源,生产环境只允许自己的前端域名。
实现时我是写了一个实现WebMvcConfigurer的配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .maxAge(3600); } }注意allowedOrigins("*")这个写法的局限性:如果你开了allowCredentials(true),那么allowedOrigins就不能是通配符*,必须写具体的域名,否则浏览器会拦截。这也是一个我排查了很久才找到的坑。
4.4 线上部署的其他隐蔽问题
别人的项目部署后会出现数据库中文乱码,这个问题的根源几乎都是数据库连接串没加characterEncoding=utf8。我在部署文档里反复强调这个配置,但还是有朋友没注意到,结果商品标题变成了一堆问号。加在JDBC连接URL上,注意不是配置文件的其他地方。
另一个隐蔽问题是上传文件目录的权限。你在服务器上如果用root用户启动项目,上传目录写文件没问题;但如果用普通用户启动,/uploads目录没有写权限就会报FileNotFoundException。解决方式也很简单,给目录放开权限:
mkdir -p /opt/secondhand/uploads chmod 755 /opt/secondhand/uploads还有个容易被忽略的问题跟Linux文件系统有关。SpringBoot的jar包启动之后,运行目录下的临时文件可能会被系统定时清理(有些发行版自带systemd-tmpfiles-clean),如果你的项目用到了/tmp来缓存上传文件,磁盘空间会莫名其妙地下降。更稳妥的方案是把上传目录固定到非tmp路径,比如/opt/secondhand/uploads。
4.5 排查实战:一次订单状态卡在"已付款"的复盘
最后分享一个真实案例。测试人员在验收时发现一个订单的支付成功回调之后,状态一直没有从"待发货"变成"已发货",数据库里查到的情况是:订单表状态更新了,但钱包流水表里同时出现了两条相同的扣款记录。我当时第一反应是重复请求导致的问题——支付回调接口被触发了两次。
定位的方法很简单,在支付回调的service方法入口加一段日志,打出行参和当前线程号,然后去调用方看日志,果然发现回调通知组件因为没收到成功响应,自动重试了一次。因为这两次请求是在同一时刻发出的,所以都通过了幂等性校验,结果重复扣款。解决方案是给支付回调加上分布式锁:
boolean locked = redisTemplate.opsForValue().setIfAbsent("LOCK:PAY:" + orderNo, "1", Duration.ofSeconds(5)); if (!locked) { // log.info("重复回调,直接返回成功"); return; }加了锁之后,第二次回调进来发现锁不存在(第一次回调已经执行完并删除了锁),但此时业务已经处理完成,直接返回成功即可,不会再重复处理。这个案例在代码讲解里讲得比较细,因为它几乎囊括了分布式系统里最重要的三个思想:幂等性、并发控制、日志追踪。
5. 最后说点实在的
项目做到这里,源码、部署文档、代码讲解基本就闭环了。我在梳理这套东西时最大的感受是:这个项目表面上是SpringBoot的一个综合练习,实际上它真正训练的是"交易系统中保障机制的设计能力"。JWT做认证、MyBatis-Plus做持久化、Redis做并发控制,这些技术栈本身都不难,难的是你遇到"卖家发货后买家跑路"这种业务问题时,能把技术手段恰到好处地用在正确的环节上。
这套源码我建议你拿到手之后,不要先急着在IDE里跑起来。按照部署文档把数据库初始化好,然后用application-dev.yml启动,把核心的一条链路——注册、登录、发布商品、下单、付款、发货、收货——完整走一遍。走完这一遍,你对整个系统的认识会比单独读源码深得多。
最后再分享一个我个人的小习惯:如果是要交毕设或者交付给客户,我会在项目里额外写一个docs/目录,把所有"为什么这么设计"的决策记录放在里面,比如"为什么用JWT而不用Session""为什么订单表和流水表要分开"。这些决策记录在答辩或者验收时特别加分,因为老师或客户最想听的往往不是你的功能做了什么,而是你在做一个功能时,有没有想过它背后的权衡。写文档这件事一开始可能觉得麻烦,但真到了半年后你回来看自己写的代码,那些当初随手记下来的决策就是你最宝贵的领路人。