一套带完整业务闭环的游戏交易系统,我是怎么把源码跑通并改出生产感的
前阵子拿到一套标注为 SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 的游戏交易系统源码,还附带文档。第一反应是这种"源码+含文档"的项目我见多了,多半是教学级CRUD,跑通容易,真要拿去做点正经事就各种露怯。但这次我完整过了一遍,从用户注册登录、商品上架审核、下单支付、订单发货,到后台管理、余额提现,整个交易闭环出乎意料地完整。
这篇文章我不打算写成"项目介绍PPT",而是从一个Java Web开发者的角度,把这套系统的技术选型逻辑、数据库和状态机设计、前后端关键实现、从零跑通的冷启动过程,以及我实际踩过的坑全部摊开讲。尤其最后那个订单资金一致性问题的排查过程,我建议所有做交易类系统的朋友都看一下——这比任何"高并发架构设计"文章都实用,因为这才是真实环境里会发生的事。
1. 这套技术组合的定位:为什么是SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0
1.1 游戏交易系统需要的不仅仅是CRUD脚手架
很多人以为游戏交易系统就是个"商品展示+下单"的普通站点,但实际上,它比一般的管理系统复杂得多。
核心在于它同时踩了三个敏感区域:虚拟商品的展示与库存管理、真实资金的交易流转、买卖双方的信任与售后。这三个区域叠加在一起,对系统的要求不是"能用",而是"不能出错"。余额不能凭空多或少、订单状态不能乱、同一个商品不能被卖两次、支付回调不能重复入账——这些约束直接决定了技术选型的方向。
Java Web在这个场景下依然是相当稳妥的选择,不是因为它的性能最极致,而是因为它的生态足够厚:事务管理有Spring,持久层有MyBatis-Plus,社区里能搜到海量的真实踩坑案例,招人成本也低。做交易类系统,确定性比炫技重要得多。
1.2 SpringBoot2与MyBatis-Plus的搭配逻辑
这套源码用的是SpringBoot 2.7.x配MyBatis-Plus 3.5.x,这个组合在当前阶段非常成熟,也很有代表性。
如果项目启动不报错,一个比较稳妥的版本搭配是:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | SpringBoot 2.7推荐JDK 8/11 |
| SpringBoot | 2.7.x | 用3.x则需要JDK17+,且javax要换jakarta |
| mybatis-plus-boot-starter | 3.5.3.1 | 3.5.4之后分页插件写法有调整 |
| MySQL Connector/J | 8.0.x | 必须8.0+,才能适配MySQL8的服务端认证 |
| node版本 | 16.x/18.x | Vite 3/4对node版本有要求 |
提示:如果你打算把项目升级到SpringBoot 3,最麻烦的不是SpringBoot本身,而是MyBatis-Plus的依赖需要切换到
mybatis-plus-spring-boot3-starter,同时所有javax.*开头的包都要改成jakarta.*。这个迁移成本看起来不大,但一个项目里几十个文件逐个改,相当折腾。所以不是特别有必要的话,SpringBoot2 + JDK8/11的组合完全够用。
1.3 Vue3相比Vue2在实际项目里的收益
这套源码前端用Vue3,大多数人关心的是"Vue3到底比Vue2好在哪"。
从这次实际开发体验来看,最直接的感受是Composition API带来的代码组织方式改变。Vue2的Options API把data、methods、computed、watch分开,一个功能相关的逻辑片段会散落到好几个区块;而Composition API让"同一功能的变量和函数放在一起",代码可读性提升非常明显。比如商品列表页,我可以在一个setup里把所有和"商品查询"相关的逻辑集中在一起,而不是在data里放变量、在methods里放方法、在computed里放派生状态。
另一个实际收益是Vue3基于Proxy的响应式系统。Vue2的Object.defineProperty无法监听对象属性的新增和删除,经常需要用$set解决;Vue3从根上解决了这个问题,直接给对象加属性也能被响应式追踪,这在处理后台动态表单、后端返回的动态字段时省了不少事。
1.4 MySQL8.0为交易数据带来的关键特性
MySQL8.0在这套系统里不是可有可无的版本号,有几个特性对交易类业务很实用。
第一个是utf8mb4字符集。游戏交易的商品标题和留言里经常有emoji和特殊符号,MySQL5.7的utf8mb4虽然也支持,但8.0对字符集的处理更完善,默认就是utf8mb4,避免了"明明设置了utf8mb4还是报字符集错误"的尴尬。
第二个是窗口函数。比如要实现"每个卖家最近N笔订单"这种需求,MySQL8.0可以直接用ROW_NUMBER() OVER (PARTITION BY seller_id ORDER BY create_time DESC),在5.7里你得写复杂的关联子查询。
第三个要考虑的是MySQL8.0的默认认证插件caching_sha2_password。JDBC驱动必须是8.0+,否则连接会报认证错误。另外如果数据库是Docker起的,本地连接时如果时区不对,会报Server returns invalid timezone,这个在后面的冷启动章节详细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 游戏交易的领域模型设计:从商品表到订单状态机
2.1 核心表的职责划分
这套系统的数据库设计,最值得学习的不是单个表,而是"交易链路各环节都能找到对应的表和字段"。我整理了几个核心表的职责:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| user | 用户账号信息 | username, password, balance, status |
| goods | 商品主体信息 | seller_id, title, price, stock, status |
| goods_image | 商品多图 | goods_id, url, sort |
| orders | 交易订单主表 | order_no, goods_id, buyer_id, seller_id, price, status |
| wallet_log | 钱包流水表 | user_id, change_amount, balance_after, direction, order_no |
| message | 买卖双方沟通消息 | from_user_id, to_user_id, content, is_read |
| admin_user | 后台管理员账号 | username, password, role |
这里我想重点说一下wallet_log钱包流水表,这是很多学生项目里没有、但真实交易系统一定不能缺的表。它记录每一笔余额变化:增长多少、减少多少、变化之后余额是多少、关联的订单号是什么。有了这张表,用户如果反馈"钱不对",你可以直接按用户ID拉出所有流水,精确到每一分钱。
注意:很多人在设计钱包流水表时只记录
change_amount(变化金额),不记录balance_after(变化后余额)。遇到对账场景就非常痛苦,因为你得把所有流水重算一遍才知道最终余额。这套系统把balance_after直接存下来,对账时一个SQL就能核对,这个设计思路建议抄下来。
2.2 订单状态机的流转设计
交易系统最怕的就是订单状态随便改、怎么改的说不清。这套源码用了一个明确的订单状态机,状态流转逻辑清晰:
| 当前状态 | 触发动作 | 下一状态 | 说明 |
|---|---|---|---|
| 0 待支付 | 用户支付成功 | 1 待发货 | 支付回调触发 |
| 1 待发货 | 卖家点击发货 | 2 待收货 | 卖家填写发货信息 |
| 2 待收货 | 买家确认收货 | 3 已完成 | 确认后资金清算 |
| 0 待支付 | 超时未支付 | 4 已取消 | 定时任务自动取消 |
| 1 待发货 | 买家申请退款 | 5 退款中 | 进入退款流程 |
| 2 待收货 | 买家申请退款 | 5 退款中 | 需要卖家同意 |
| 5 退款中 | 退款完成 | 6 已退款 | 资金退回买家 |
每次状态变更都建议记录到一张order_status_log表里,谁在什么时间把订单从什么状态改成了什么状态、改了之后变为什么状态。这不仅是数据审计的需要,也是排查问题时最直接的线索。
2.3 金额与库存的一致性设计
交易系统里最不能妥协的两个点是金额和库存。
金额方面,代码里所有涉及钱的字段都用BigDecimal,包括商品价格、订单金额、钱包余额、流水金额。不要用double或者float做金额计算,浮点数的二进制表示问题会导致0.1+0.2等于0.30000000000000004这种诡异结果。交易系统里出现这种误差,是事故级别的。
库存方面,下单时的扣减库存用一条原子SQL:
sql复制UPDATE goods SET stock = stock - 1
WHERE id = ? AND stock > 0;
stock > 0这个条件保证不会扣成负数,stock = stock - 1是原子操作,不会因为并发请求导致超卖。凡是看到"先查库存,再判断,再更新"的写法,在并发场景下都有超卖风险。
订单号用雪花ID或"时间戳+随机数"生成,不要用自增主键当订单号。订单号会暴露订单量,而且自增ID在分库分表场景下会造成全局冲突。
3. 后端实现拆解:从认证鉴权到交易下单的核心链路
3.1 基于JWT的登录认证与接口权限控制
这套系统的后端认证用的是JWT方案。用户登录成功后,后端签发一个带过期时间的token返回给前端,前端把它存在本地,之后每次请求都在HTTP头里带上。
后端用一个HandlerInterceptor拦截器统一处理token校验逻辑:
java复制public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token)) {
throw new BusinessException(401, "未登录");
}
// 解析token,校验合法性,把userId放倒request属性中
LoginUser loginUser = JwtUtil.parseToken(token);
request.setAttribute("userId", loginUser.getUserId());
return true;
}
}
拦截器比AOP更适合做登录态校验:它天然能拿到HTTP请求和响应对象,并且能在进入Controller之前拦截。拦截器统一注册到Spring MVC中,排除掉登录接口、注册接口、商品列表查询等放行路径。
权限层面,这套系统区分了普通用户端和管理后台端。普通用户接口和管理员接口放在不同的Controller前缀下,管理员接口除了JWT校验之外,还会校验role字段,保证普通用户的token不能调用管理员接口。
3.2 商品上架与审核流程
游戏交易系统里面,商品不能直接上架,需要管理员审核。这个流程虽然简单,但很值得学习。
卖家提交商品后,商品状态是"待审核"。管理员在后台看到待审核列表,检查商品标题、描述、图片、价格是否合规,选择通过或驳回。通过后商品状态变为"上架",用户可以在前台看到;驳回后跳到"已驳回",卖家可以修改后重新提交。
这套流程的核心价值在于:商品上架是写入操作,审核是保险,状态机是约束。如果没有审核环节,恶意卖家可以刷屏垃圾商品;如果没有状态机,商品状态任意变更,买家可能会下单买到一个已经被下架的商品。
3.3 下单到支付的完整业务闭环
这块是整套源码的核心,我把下单到支付的完整流程拆开看:
- 买家提交订单,后端创建订单记录,初始状态为"待支付"。
- 创建订单的同时,用原子SQL扣减商品库存。
- 前端跳转到支付页面,这里源码默认是模拟支付,实际项目中对接微信/支付宝的统一下单接口。
- 支付成功后,支付平台回调后端接口,后端在回调里做两件事:把订单状态从"待支付"改为"待发货";给卖家钱包增加订单金额。
- 给买家发送一条站内信:"您的订单已支付成功,等待卖家发货。"
- 卖家在"我要卖"的订单列表里看到待发货订单,点击发货。
- 买家收到货后确认收货,订单状态变为"已完成"。
从代码实现来看,下单和支付回调是最容易出现问题的两个环节。下单时的事务边界、库存扣减和订单创建必须在一个事务里,任何一个失败都必须全部回滚。而支付回调则是天然的高并发入口,必须在回调方法里保证幂等性——同一个支付通知可能会被支付平台重发多次,第一次处理成功之后,后续重复通知不应该再重复入账。
这里先埋个伏笔,这套源码在支付回调的顺序处理上有一个隐藏的坑,我在第7章里详细复盘。
3.4 MyBatis-Plus在CRUD之外的进阶用法
MyBatis-Plus在这套项目里不只是一个自动生成SQL的CRUD框架,几个高阶特性用得恰到好处。
逻辑删除:用户在系统里删除商品、删除订单,不是真的执行DELETE,而是执行UPDATE ... SET deleted = 1。这样数据保留了,后台还能查到历史数据。实体类上标注@TableLogic配合全局配置即可。
自动填充:create_time和update_time字段用MetaObjectHandler统一填充,不用在每一个插入更新方法里手动set当前时间。
乐观锁:用@Version注解配合OptimisticLockerInnerInterceptor实现,更新订单状态时带上版本号条件,防止两个请求同时更新同一个订单导致状态互相覆盖。
分页插件:PaginationInnerInterceptor是MyBatis-Plus最实用的插件,不用自己写LIMIT ?计算,直接用LambdaQueryWrapper组合条件。
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
提示:如果你的项目是多模块工程,注意MyBatis-Plus的扫描路径。比如模块包名是
com.game.common和com.game.system,@MapperScan必须覆盖到所有包含Mapper的包,否则会报"Invalid bound statement (not found)"。
4. Vue3前端从零落地的关键细节
4.1 Vite脚手架与项目结构
这套源码前端是用Vite创建的Vue3项目。创建命令很简单:
bash复制npm create vite@latest game-trade-web -- --template vue
cd game-trade-web
npm install
npm run dev
项目结构直接影响后续开发的效率,这套源码的目录划分值得参考:
code复制src/
api/ # 按业务模块拆分的接口请求方法
assets/ # 静态资源
components/ # 通用组件
router/ # 路由配置
store/ # Pinia状态管理
utils/ # 请求封装、工具函数
views/ # 页面组件
admin/ # 管理后台页面
user/ # 用户中心页面
goods/ # 商品相关页面
order/ # 订单相关页面
4.2 登录态管理与路由守卫
前端在用户登录后把token存入Pinia和localStorage,防止页面刷新后登录态丢失。axios实例的请求拦截器统一加token,响应拦截器统一处理401:
javascript复制// utils/request.js
import axios from 'axios'
import { useUserStore } from '@/store/user'
import { ElMessage } from 'element-plus'
import router from '@/router'
const request = axios.create({
baseURL: '/api',
timeout: 15000
})
request.interceptors.request.use(config => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = userStore.token
}
return config
})
request.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
router.push('/login')
}
ElMessage.error(error.response?.data?.message || '请求失败')
return Promise.reject(error)
}
)
路由守卫配合登录态,保护需要登录才能访问的页面:
javascript复制router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.token) {
next('/login')
} else {
next()
}
})
4.3 商品列表页的数据流与交互细节
商品列表页是前台用户最常访问的页面,查询条件多:关键字、游戏分类、价格区间、排序方式。Vue3里用ref和reactive组合管理搜索条件和分页参数:
javascript复制const loading = ref(false)
const goodsList = ref([])
const total = ref(0)
const queryParams = reactive({
pageNum: 1,
pageSize: 12,
keyword: '',
categoryId: null,
priceMin: null,
priceMax: null,
sortBy: 'default'
})
const loadGoods = async () => {
loading.value = true
try {
const res = await getGoodsList(queryParams)
goodsList.value = res.records
total.value = res.total
} finally {
loading.value = false
}
}
onMounted(() => {
loadGoods()
})
搜索功能要有防抖,不然用户每输入一个字符就触发一次请求,后端接口扛不住,前端也卡。可以用watch配合setTimeout做300毫秒防抖,当用户停止输入300毫秒后再加载数据。
4.4 后台管理页面的表格、弹窗与表单校验
管理后台的典型交互是:表格展示数据、顶部搜索栏、点击操作按钮弹出对话框、表单校验后提交。
Element Plus在这个场景下非常顺手。表格用el-table加el-table-column,操作列放编辑、审核、删除按钮。弹窗用el-dialog,表单用el-form加rules校验规则。
code复制<el-form :model="goodsForm" :rules="goodsRules" ref="goodsFormRef">
<el-form-item label="商品标题" prop="title">
<el-input v-model="goodsForm.title" />
</el-form-item>
<el-form-item label="价格" prop="price">
<el-input-number v-model="goodsForm.price" :precision="2" :min="0" />
</el-form-item>
</el-form>
还有一个容易忽视的细节:表格的加载状态、空数据状态、分页变化后的滚动位置。这套源码里后台表格都加了v-loading和空数据提示,虽然是小细节,但确实提升了使用体验。
5. 环境准备与冷启动:从MySQL8.0安装到前后端联调
5.1 MySQL8.0在Windows/Linux/Docker下的安装要点
这套源码要求MySQL8.0。如果你本地已经装了5.7,直接用Docker起一个8.0实例是最省事的方式:
bash复制docker run -d \
--name mysql8 \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=root123456 \
mysql:8.0
yaml复制# 指定字符集和时区,避免中文乱码和时区问题
docker run -d \
--name mysql8 \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=root123456 \
-e TZ=Asia/Shanghai \
mysql:8.0 \
--character-set-server=utf8mb4 \
--collation-server=utf8mb4_unicode_ci
Windows下用安装包安装即可,主要注意安装到最后一步时,Root账号的认证方式。MySQL8.0默认是caching_sha2_password,如果你用Navicat老版本连接可能会报错,要么在安装时选Use Legacy Authentication,要么把驱动和客户端升级到兼容版本。
提示:连接MySQL8.0时如果报
Public Key Retrieval is not allowed,在JDBC连接串里加allowPublicKeyRetrieval=true&useSSL=false,本地开发连接基本上都要加这两个参数。
5.2 后端配置与数据库初始化
后端项目的application.yml配置如下:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/game_trade?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root123456
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
数据库初始化直接用源码自带的init.sql导入即可。导入完成后,用SELECT * FROM user;验证一下数据是否正常。如果启动后端时报错Unknown database 'game_trade',先去MySQL创建数据库再导入:
bash复制mysql -u root -p
CREATE DATABASE game_trade DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
exit
mysql -u root -p game_trade < init.sql
5.3 前端环境与代理配置
前端装完依赖之后,npm run dev启动,Vite默认端口是5173。后端接口在8080端口,存在跨域问题。正规做法是在vite.config.js里配置代理:
javascript复制import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
这样前端请求/api/goods/list会代理到http://localhost:8080/api/goods/list,前端代码里不需要写完整的后端地址,之后部署到生产环境时只需要改代理配置就行。
5.4 最容易忽视的联调细节
前后端联调的时候,最容易被卡住的是以下几个点:
- 请求路径的上下文前缀不一致。后端Controller映射可能是
/api/goods/list,前端也要请求/api/goods/list,中间任何一个环节多写少写一层路径就会404。 - 参数名大小写不一致。前端传
pageNum,后端接收的是pageNum,但MyBatis-Plus分页插件默认要求current和size,页面参数需要映射。 - 时间格式不一致。前端显示的时间是标准ISO格式,后端返回的是时间戳,需要统一格式。
- 图片上传的静态资源映射。本地开发商品图片上传到服务器磁盘的一个目录,前端要能通过URL访问,需要在SpringBoot里配置静态资源映射:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceHandler("file:" + System.getProperty("user.dir") + "/upload/");
}
}
6. 上线前要处理的五个隐患:并发、安全、事务、分页、日志
6.1 超卖与重复下单的并发控制
游戏交易系统里,一个热门商品可能同时被几十个人下单。如果不做并发控制,就会出现库存只有1件但创建了10个订单的情况。
这套源码在库存扣减上用原子SQL实现了最基础的保护,但在真实高并发场景下,还要配合乐观锁和唯一索引。给订单表加一个(goods_id, buyer_id, status)的联合唯一索引,可以防止同一个买家对同一件商品创建多个待支付订单。订单号本身也应该唯一,防止重复提交。
6.2 SQL注入与XSS防护
MyBatis-Plus默认的selectWrapper、LambdaQueryWrapper都是参数预编译,不用太担心SQL注入。但如果你在XML里写了${}拼接字符串,比如:
xml复制<select id="selectBySort" resultType="Goods">
SELECT * FROM goods ORDER BY ${sortField} ${sortOrder}
</select>
如果sortField和sortOrder是从前端传过来的,攻击者可以传入id; DROP TABLE goods--之类的字符串。排序字段这种固定枚举场景,强烈建议在后端校验白名单。
XSS防护方面,用户在前台留言、商品描述、昵称这些地方可能输入<script>标签。后端在接收输入时要统一转义,或者用过滤器处理。SpringBoot可以用@ControllerAdvice全局处理,对请求体中的字符串字段进行HTML转义。
6.3 事务边界与分布式事务隐患
下单流程涉及多个操作:扣库存、创建订单、生成支付记录。这些操作必须在同一个事务里。
java复制@Transactional(rollbackFor = Exception.class)
public Order createOrder(Long goodsId, Long userId) {
Goods goods = goodsMapper.selectById(goodsId);
int rows = goodsMapper.deductStock(goodsId); // 原子扣减
if (rows == 0) {
throw new BusinessException("库存不足");
}
Order order = new Order();
// 构建订单信息
orderMapper.insert(order);
return order;
}
@Transactional默认只对RuntimeException回滚,如果业务里throw new Exception(),事务不会回滚。所以必须指定rollbackFor = Exception.class。
注意:事务方法内部不要调用远程接口或外部服务,因为远程调用期间数据库连接一直被占用,事务时间太长会导致连接池耗尽。如果确实需要调用外部服务,要把远程调用拆出事务,用"本地事务+消息补偿"的方式处理。
6.4 分页查询性能与慢SQL
MyBatis-Plus的分页插件自动生成COUNT查询,在数据量小的时候没问题,但订单表数据量到几十万之后,全表COUNT会变慢。解决办法是给WHERE条件涉及的字段加复合索引,比如订单表经常按buyer_id + status查询:
sql复制ALTER TABLE orders ADD INDEX idx_buyer_status (buyer_id, status);
ALTER TABLE orders ADD INDEX idx_create_time (create_time);
商品列表页的搜索,如果数据量大,建议引入Elasticsearch做全文检索,MySQL只负责交易数据。这套源码本身没有这个复杂度,但对于游戏交易这种"图片多、关键字杂、筛选条件多"的搜索场景,这是后面必然要做的演进方向。
6.5 日志规范与操作审计
交易系统必须有完善的日志体系,尤其是支付回调、余额变更、订单状态变更这三类操作。代码中至少要在这些关键节点打印业务日志:
- 支付回调:收到的原始通知参数、处理结果、耗时
- 余额变更:变更前余额、变更金额、变更后余额、关联订单号
- 订单状态变更:操作人、原状态、新状态、触发来源
日志里要包含order_no和user_id,这样排查问题时可以用一个订单号把所有相关日志串联起来。Binlog的清理和保留策略也要提前定,一般生产环境保留7到15天,可以用PURGE BINARY LOGS BEFORE NOW() - INTERVAL 7 DAY手动清理,但更推荐直接配置过期时间。
7. 一次订单资金一致性问题的完整排查链路
7.1 问题现象
这套源码在本地跑了几天后,出现了一个诡异的问题:一个买家下了单、付了钱,但在账号里查订单,状态还是"已取消"。买家的钱包余额却增加了对应金额。
用户反馈:我明明支付成功了,为什么订单显示已取消?
7.2 第一轮排查:订单表数据与日志
第一步查订单表,发现该订单的status是4(已取消),pay_time字段为空。接着查支付回调日志,日志里明确显示回调已接收到,并且执行了。但执行到了哪一步,日志已经看不出来了,因为方法里只有外围的日志,没有内部关键步骤的日志。
继续查钱包流水表,发现wallet_log里有一条"支付成功入账"的记录,金额和订单金额一致。这意味着支付回调方法确实执行到了加钱这一步。
到这里,问题已经很清晰了:钱包加了钱,但订单状态没有改成"已支付"。这说明逻辑中途出错了,但错误没有被抛出来,事务还是提交了。
7.3 根因定位:事务内顺序与更新行数校验
翻看支付回调的实现代码,发现这个方法有两个致命问题:
第一,先加钱,再更新订单状态。正确逻辑应该是先更新订单状态,再给卖家加钱。顺序反了会导致"订单更新失败但钱包入账成功"的不一致状态。
第二,更新订单状态时带了status = 0条件,但在同一个时间窗口,系统的定时任务把超时未支付的订单自动取消,把状态改成了4。于是支付回调里的更新语句变成了:
sql复制UPDATE orders SET status = 1, pay_time = NOW() WHERE id = ? AND status = 0;
由于订单已经被定时任务改成了status = 4,这个UPDATE的影响行数是0。但代码里没有检查update的返回值,没有在影响行数为0时抛出异常回滚事务。于是钱包加钱的SQL正常提交,订单状态却没有更新成功。
这就是典型的**"事务内部分操作成功但部分操作静默失败"**的问题。这里的关键点是:数据库事务保证原子性,但如果代码逻辑里没有校验每一步操作的结果,事务依然会"成功提交"一个业务上不完整的状态。
7.4 修复方案:调整顺序、校验行数、做幂等
修复方案分三层:
第一层,调整代码顺序。支付回调里先更新订单状态并严格校验影响行数,影响行数为0或者状态不是待支付时,直接抛出异常回滚,不允许继续加钱:
java复制@Transactional(rollbackFor = Exception.class)
public void handlePayCallback(OrderPayNotify notify) {
// 1. 更新订单状态,严格要求影响行数为1
int rows = orderMapper.updateStatus(notify.getOrderNo(), 0, 1);
if (rows != 1) {
throw new BusinessException("订单状态更新失败,已取消或已支付");
}
// 2. 更新卖家钱包余额
walletService.addBalance(notify.getSellerId(), notify.getAmount());
// 3. 记录钱包流水
walletLogService.record(notify.getSellerId(), notify.getAmount());
}
第二层,给钱包流水表加唯一索引。order_no + user_id唯一索引可以防重复入账,即使回调消息被支付平台重发多次,第二次执行时会因为流水重复插入而抛异常,整个事务回滚。
第三层,支付回调处理逻辑要幂等。先查订单,如果订单已经是"待发货",说明回调已处理过,直接返回成功,不再重复处理。
7.5 这套排查思路的复用价值
这个坑在真实交易系统里非常典型,而且往往不会在测试环境暴露——单机测试时不会触发超时任务和支付回调恰好在同一秒竞争的情况。
我总结出三条可复用的经验:
-
任何更新操作都必须校验影响行数。
UPDATE返回的int不是摆设,只要不等于期望值,就应该视为业务失败并回滚事务。尤其是带有状态条件的更新,影响行数为0意味着状态不符合预期。 -
事务方法内的操作顺序决定了故障时的数据形态。先改状态再动钱,保证任何异常发生时,已经变的业务状态是"可回滚"的。加钱这种和外部资金相关的操作,应该放在状态变更成功之后。
-
支付回调这类入口,幂等是刚需。支付平台的通知机制是"无法确认就重发",重试可能持续48小时。如果回调处理不幂等,每重试一次就多入账一笔,后果不堪设想。
8. 这套源码后续还能怎么改、怎么扩展
我个人把这套系统从拿到到跑通,再到把上面的坑修完,整体花了一周左右。最大的体会是:一份源码的价值不在代码本身,而在它是否逼你把"交易系统"这四个字的每一个细节都落到实处。
如果要在它的基础上做二次开发,我最推荐先做这几件事:
第一,把模拟支付替换成真实支付平台对接。微信支付和支付宝的官方SDK都支持沙箱环境,接入难度不大,但涉及到证书、回调验签、退款等细节,做完之后你对支付系统的理解会上一个台阶。
第二,引入Redis。目前的架构里,商品详情、分类列表、热门推荐这些数据在MySQL里反复查询,压力全在数据库上。用一个简单的Redis缓存+缓存失效策略,性能就能提升一个量级。后面再扩展分布式锁、限流,都有了基础。
第三,给订单模块加一个定时任务的分布式调度。目前超时取消订单用的是简单的@Scheduled,单机没什么问题,但部署多个实例时会重复执行。换用Quartz或者xxl-job,加一个分布式锁,就能适应多实例部署。
第四,把管理后台的图表统计做起来。游戏交易平台的运营很依赖数据:每日交易额、商品类目分布、卖家活跃度、退款率。SQL是现成的,套一个图表组件就能出报表。
我把这些经验写出来的时候,回头看,这套系统的核心价值不是"代码能跑",而是它提供了一条完整的、可以沿着往下深耕的主线:从C端用户下单到B端后台管理,从数据库建模到状态机流转,从接口开发到前后端联调,最后还要面对真实环境里的脏数据和并发竞争。这份经历对初中级开发者来说,比去看十篇"高并发系统设计"都要值钱。
