
前阵子帮人完整搭过一套游戏交易系统后端SpringBoot前端Vue从商品发布、下单支付到后台管理的闭环都跑通了。过程中最大的体会是游戏交易系统看起来是电商项目的变体实际上有大量和普通电商不一样的地方——虚拟物品交付、动态属性、防超卖、订单状态机、对象存储、敏感词过滤每个点都值得单独拎出来说。这篇文章我把当时的设计思路、数据库表结构和踩过的坑一起整理出来适合正在做毕业设计、个人项目或者准备接类似外包的人参考。我不打算把SpringBoot和Vue的基础再讲一遍重点放在“你要动手实现一个交易系统哪些地方必须想清楚”。1. 从需求到模块划分游戏交易系统到底在交易什么很多第一次做这种项目的人第一反应就是照着“XX商城”抄商品列表、购物车、下单、结算然后改个皮肤就叫游戏交易系统。但游戏交易的根本逻辑和实物电商不太一样如果业务模块一开始没拆对后面写代码会越写越别扭。1.1 游戏商品和普通电商的差异游戏交易系统里流通的通常是游戏币、装备、账号代练、道具账号、CDKey这类虚拟商品。它和普通实物商品比有几个明显差异没有物流链路。实物电商要处理配送地址、快递单号、签收游戏交易交付的地点是“游戏内”卖家要提供的是角色名、服务器、交易方式、截图凭证。属性高度不固定。一件衣服有固定尺码和颜色但一个游戏角色可能同时有等级、职业、服务器、装备评分、区服等多个属性。不同游戏的属性完全不同甚至同一个游戏的商品不同卖家的描述字段也不一样。库存模型特殊。有些商品是标品比如“金币100万”可以反复上架有库存概念有些商品是单件孤品比如“某个极品账号”库存只能是0或1上架即售出。交易信任成本高。虚拟商品发货后买家可以反悔卖家也可能收了钱不发货所以平台必须提供中间担保、发货凭证、超时自动确认这些机制。想清楚这几点再设计模块你就不会把系统做成“阉割版电商”而是会主动考虑商品交付信息和订单状态机。1.2 我最终拆出的十个核心模块我当时的做法是把系统拆成十个业务模块前后端都按这个边界来组织代码模块职责范围用户中心注册、登录、个人信息、实名信息、卖家/买家身份商品中心商品发布、编辑、上下架、库存、分类、动态属性搜索与筛选按游戏、区服、价格、关键词搜索商品订单中心下单、支付、发货、确认收货、取消、超时处理支付模块本地模拟支付、回调、交易流水消息通知站内信、WebSocket推送、短信/邮件扩展位IM/留言系统买家和卖家在订单详情里沟通留作交易凭证评价系统订单完成后互相评价影响卖家信用风控与举报敏感词过滤、违禁商品识别、举报处理后台管理用户管理、商品审核、订单干预、数据统计这个划分不是拍脑袋而是围绕“一笔交易从发起到完成”这条线倒推出来的。比如IM/留言系统很多人会忽略但游戏交易的“交付方式确认”“发货前沟通”都靠它没有沟通功能的交易平台体验会差很多。风控模块也属于刚需商品描述里出现联系方式、站外导流词必须能自动拦截。把模块拆清楚之后SpringBoot后端就是一套标准的Controller-Service-Mapper结构Vue前端则按页面所属模块拆成视图目录后面扩展功能会非常顺畅。2. 数据库与表设计订单、库存和账号安全这三块最费心思数据库设计决定这个系统能跑多远。商品表、用户表谁都会建但我发现真正考验水平的是三块商品动态属性怎么存、订单状态怎么流转、库存怎么防超卖。2.1 商品表的“可自定义属性”怎么落库游戏商品的字段不是固定的比如卖“英雄联盟皮肤”需要填“大区、是否支持包区”卖“原神账号”需要填“冒险等级、五星角色数量、是否绑定邮箱”。如果你在商品表里为每个字段建一列游戏种类一多表结构就要天天改。我的做法是固定字段放商品表可变字段放JSON。MySQL 5.7以上直接支持JSON类型配合MyBatis的TypeHandler可以在Java代码里把JSON串映射成Map对象前端也能直接通过extraAttrs.xxx动态渲染表单。商品表核心字段大概是下面这个样子CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seller_id BIGINT NOT NULL COMMENT 卖家ID, title VARCHAR(128) NOT NULL COMMENT 商品标题, category_id BIGINT NOT NULL COMMENT 分类ID游戏币/账号/代练/道具, game_id BIGINT NOT NULL COMMENT 所属游戏ID, server_name VARCHAR(64) COMMENT 区服名称, cover_url VARCHAR(255) COMMENT 封面图URL, original_price DECIMAL(10,2) NOT NULL COMMENT 原价, price DECIMAL(10,2) NOT NULL COMMENT 实际售价, stock INT NOT NULL DEFAULT 0 COMMENT 库存0表示下架, extra_attrs JSON COMMENT 扩展属性例如{等级:60,职业:剑士}, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1在售 2下架 3审核失败, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_uid_status (seller_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;很多教程喜欢用“商品属性表”存扩展字段一张主表加一张纵向属性表查询时动态拼接。但到了一对多查询你会很痛苦要么一条商品垂直拆成好几行要么动态SQL写起来绕圈。对于中小型项目和毕设JSON字段是性价比最高的方案查询整条商品时一次取出属性丢失风险也低。如果之后要做“按属性筛选”比如“找所有60级以上的账号”可以在MySQL里用JSON_EXTRACT函数建虚拟列也可以把商品数据同步到Elasticsearch那种方案属于后期优化不是第一版必须做的。2.2 订单状态机和超时关单订单表我建议用一个status整数表示状态而不是一堆布尔字段。我当时定义的状态是状态值含义触发方式10待支付买家下单成功20已支付待发货支付回调成功30已发货待确认卖家上传发货凭证40交易完成买家确认收货或超时自动确认50已取消超时未支付 / 买家主动取消60退款中买家发起售后70已退款卖家同意或系统自动退款订单表还需要一个全局唯一的order_no业务上所有地方都显示它不暴露数据库自增主键。这样可以防止别人通过订单号直接遍历你的订单数据。超时关单是交易系统的灵魂。不可能每下一单都起一个定时器线程最稳妥又不复杂的做法是Spring的Scheduled定时扫描待支付订单用一条条件更新SQL把超时订单改成取消状态。Scheduled(fixedDelay 60000) public void closeTimeoutOrders() { String sql UPDATE orders SET status 50, close_type 1 WHERE status 10 AND create_time NOW() - INTERVAL 15 MINUTE; orderMapper.executeTimeoutClose(sql); }这里必须用status 10作为更新条件。为什么因为订单可能在你扫描的同一秒被用户支付了如果不带状态条件就会把刚支付的订单也强制取消造成“付了钱但订单被关闭”的严重事故。带上状态条件后即使更新1000条也只有真正还在待支付状态的订单会被改掉执行后返回的行数如果大于0说明确实关闭了超时单。2.3 防超卖乐观锁与唯一索引的组合用法游戏交易里的“库存”比较特殊。很多商品是单件的库存只有1上架后一旦被下单别人就买不到。即便库存大于1比如“金币100万”有50份也要防止两个用户同时抢到最后一份。常见的错误写法是先查询库存再在代码里判断库存是否大于0最后更新库存减一。这在高并发下一定出问题两个线程同时查询都看到库存还有1然后都执行更新库存就变成-1了商品就超卖了。正确做法是用条件更新把库存判断和扣减合并到一个SQL里UPDATE product SET stock stock - 1 WHERE id #{productId} AND stock 0这条SQL返回的受影响行数如果是1说明扣减成功如果是0说明库存已经被抢完了此时后端应该抛出“商品库存不足”提示。注意即使库存最终变0商品状态也应该自动改为“已售罄”或下架否则商品还会挂在列表里。但光扣库存还不够用户连续点击了两次“立即购买”后端可能收到两个订单请求这时候需要防重复下单。我的方案是给订单表的业务订单号加唯一索引CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, ... UNIQUE KEY uk_order_no (order_no) );处理流程是生成同一个order_no或者组合唯一键如buyer_id product_id create_date先执行Insert如果报了DuplicateKeyException说明是重复点击直接查询已有订单返回即可而不是让第二次请求再生成一个新单。这样一来防超卖和防重复提交就同时解决了。3. SpringBoot后端实现接口、鉴权、对象存储与敏感内容过滤后端部分我用的技术栈是SpringBoot 2.7 MyBatis-Plus Spring Security JWT MinIO。这里不讲每个注解是什么重点讲几个实际动手时最容易翻车的环节。3.1 接口分层和统一返回体游戏交易系统前后端交互比较多如果每个接口返回格式都不一样前端处理起来就是灾难。我当时定义了一个ResultT通用返回体Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message SUCCESS; r.data data; return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }配合RestControllerAdvice做全局异常处理业务代码里可以放心地抛出各种自定义异常由统一处理器包装成Result返回。这样做的好处是前端拦截器只需要判断code是否为200不管是表单校验失败、库存不足、没有权限都能拿到结构一致的错误信息。另外要提一下SpringBoot自动装配的原理。为什么一个SpringBoot应用引入spring-boot-starter-web后不用配置Tomcat就能直接跑起来因为spring.factories或AutoConfiguration.imports里声明了自动配置类SpringBoot启动时会根据依赖进行条件装配。这个机制决定了我们写业务代码时尽量遵循“约定大于配置”不要一股脑地往核心类上贴注解项目大了会很难维护。3.2 登录鉴权为什么用JWT而不是Session游戏交易系统虽然通常把Vue打包进SpringBoot后是“同源部署”理论上可以用Session但我还是选了JWT。核心原因是以后如果出小程序、App或者做多端管理后台JWT天然是无状态的不用做Session共享而且JWT里可以直接带上用户ID、角色信息网关层能直接解析方便做权限校验。Spring Boot 2.7 Spring Security 5.7之后有一个很典型的坑WebSecurityConfigurerAdapter已经被废弃了。很多网上教程还在用继承这个类来配置权限新项目里一写就报错。正确姿势是直接使用SecurityFilterChainBean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/**, /api/product/**).permitAll() .anyRequest().authenticated() ) .exceptionHandling() .authenticationEntryPoint(customAuthEntryPoint) .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }JWT生成的时候我会把userId和userRole放进去然后设置过期时间。密码存储一定要用BCryptPasswordEncoder加盐哈希不要用MD5。我见过不少项目把密码直接明文存数据库这种系统一旦数据库泄露所有账号就全完了。3.3 用MinIO保存游戏截图和供单图片商品图片、聊天截图、发货凭证都是文件不能塞进数据库也不能堆在本地磁盘。本地磁盘最大的问题是迁移困难、不好扩容而且SpringBoot应用重启时临时文件容易被清掉。我当时引入了MinIO这是一个开源的对象存储服务安装一个单机版非常轻量接口兼容S3协议后面就算要换公有云OSS代码改动也很小。SpringBoot里接入MinIO的简化代码如下Configuration public class MinioConfig { Value(${minio.endpoint}) private String endpoint; Value(${minio.access-key}) private String accessKey; Value(${minio.secret-key}) private String secretKey; Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }上传文件时服务端从MultipartFile读流生成带日期路径的对象名例如20250115/xxxx.jpg然后调用putObject上传。这里有个非常容易踩的坑MinIO默认创建的bucket是私有的如果直接返回http://ip:9000/bucket/xxx.jpg给前端浏览器会因为“Access Denied”而加载不出图片。两种解决办法在MinIO控制台把专放商品图片的bucket设置为public read适合图片本身就是公开数据的情况。保留bucket私有每次上传后生成一个有效期内的presigned URL返回给前端适合聊天截图等私密文件。我当时用的是第二种订单相关的交付凭证只允许订单双方和平台管理员看到这样更安全。3.4 商品描述里的敏感词过滤游戏交易平台非常容易被导流到站外交易所以商品标题、描述、IM消息都必须做敏感词过滤。敏感词检测的主流方案有两种一种是引入HanLP这类分词工具把文本分词后再去词库匹配另一种是基于DFA确定有穷自动机的敏感词树匹配。对于标题和描述这种短文本DFA就够用了。实现思路是先初始化一个敏感词字典树然后逐字匹配文本命中后替换成*。关键点是过滤服务要做成独立的Service在商品发布、编辑、IM发送时都复用。顺带说一句敏感词库不能一劳永逸。游戏交易里会出现各种谐音、拆字、同义替换你需要定期收集被用户举报的词条追加到词库中。后台管理模块里我专门做了一个“敏感词管理”页面管理员可以直接在线维护词库避免每次都改代码发布。4. Vue前端的关键落地动态路由、状态管理和m3u8/图片展示前端部分我用的是Vue 3 Vite Pinia Element Plus。Vue 3的组合式API在写交易系统这种中后台项目时代码组织比Vue 2的Options API更清晰。接下来只挑几个落地时容易出问题的地方。4.1 项目初始化与依赖选择很多朋友在“Vue安装及环境配置”这一步就被绊住了。现在官方推荐用npm create vuelatest创建项目注意Node.js版本至少16以上不然Vite跑不起来。创建完项目后安装几个核心依赖npm install element-plus npm install axios npm install pinia npm install vue-router4 npm install hls.js这里要特别提醒一下不要一股脑安装一堆UI库和工具库交易系统的前端核心是表单、列表、弹窗、状态标签Element Plus足够覆盖。如果需要图片裁剪上传、富文本编辑器再按需引入这样打包体积才不会爆炸。4.2 动态路由不同角色拥有不同菜单游戏交易系统里有普通用户、卖家和管理员三种角色。普通用户看到的是“首页、商品中心、我的订单、聊天”卖家多一个“商品发布、发货管理”管理员则看到“用户管理、商品审核、订单管理”。如果把这些路由全部写死在静态路由里前端要判断一堆v-if很难维护。我的做法是用Vue Router的动态添加。后端登录成功后返回当前用户的角色和菜单权限前端把路由表映射成组件后通过router.addRoute一次性注册。示例逻辑大致如下const dynamicRoutes backendMenuList.map(menu ({ path: menu.path, name: menu.name, component: () import(../views/${menu.component}.vue), meta: { title: menu.title, roles: menu.roles } })); dynamicRoutes.forEach(route router.addRoute(route));这里有一个90%的人都会踩的坑页面刷新后动态路由会丢失。因为Pinia装的是内存数据刷新浏览器后后端路由菜单还没重新拉取动态路由已经先执行了结果刷新一个详情页就变成404。我的解决方案是路由权限用router.beforeEach做拦截每次跳转前检查当前用户信息是否存在于Pinia如果不存在则调用fetchUserInfo重新拉取用户信息和后端菜单再添加动态路由如果存在就直接放行。简单说就是“刷新后先补数据再放行”而不是依赖页面加载时的一次性初始化。4.3 商品图片与视频展示懒加载和m3u8播放游戏交易系统的商品详情页经常要展示多张游戏截图有时候还有游戏实录视频。图片方面建议全部使用懒加载。Element Plus的el-image自带lazy属性只要保证后端返回的是直接可访问的图片URL即可。如果视频源是m3u8直播流或切片视频Vue播放m3u8免安装的最省事方案是使用hls.js。它通过JavaScript把HLS流转成浏览器可以直接播放的MP4片段不需要用户安装任何播放器插件。我的视频组件核心逻辑是这样的import Hls from hls.js; function playHls(videoElement, src) { if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(src); hls.attachMedia(videoElement); } else if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { // 原生支持HLS的情况比如Safari videoElement.src src; } }这里需要注意跨域问题。如果Vue开发服务器是localhost:5173MinIO视频地址是独立域名需要在SpringBoot的CORS配置里允许该地址生产环境如果用了Nginx反向代理也要同步配置跨域头否则hls.js的请求会被浏览器拦截。5. 资金与订单闭环支付对接的思路和本地模拟方案真做支付的时候微信支付和支付宝都要求企业商户资质个人开发者在本地根本没法直接测试完整回调链路。很多毕设项目做到“下单”就停了其实最关键的支付回调、发货、确认收货这一圈都没走通。我当时的办法是把支付设计成策略接口先接一个本地模拟支付保证全流程能通。5.1 本地支付模拟器的设计先定义统一支付接口public interface PaymentService { /** * 发起支付返回支付参数真实场景返回二维码内容/跳转链接 */ PayResult createPayment(PayRequest request); /** * 处理支付结果回调 */ PayCallbackResult handleCallback(String payload); }真实微信支付实现类是WxPayServiceImpl本地模拟实现类是MockPayServiceImpl。模拟支付页面长得很像收银台只不过点“确认支付”后不会真的扣钱而是直接由后端生成一条支付成功流水然后调用handleCallback。这样做的好处是订单模块、发货模块、消息通知模块完全不用关心底层是哪种支付渠道。后面如果答辩或者上线需要换成真实支付只需要新增一个实现类改一下ConditionalOnProperty配置即可。5.2 回调通知与订单状态更新支付回调是交易系统里最容易出bug的地方。真实的支付回调可以被第三方支付平台重复推送多次所以后端处理必须满足幂等。我的回调处理逻辑是这样设计的校验签名或来源IP防止伪造回调。根据回调里的order_no查询订单。如果订单状态已经是20已支付说明回调之前已经处理过直接返回“成功”不再重复更新。如果订单状态是10待支付才执行“更新订单为已支付 写入交易流水 通知卖家发货”。通知卖家通过WebSocket推送“新订单待发货”消息。这里最关键的是把第3步的状态判断放到数据库更新条件里UPDATE orders SET status 20, pay_time NOW() WHERE order_no #{orderNo} AND status 10同样只有受影响行数为1时才说明本次回调真正完成了状态流转其他情况都视为重复通知。这段逻辑保证哪怕回调并发来了三次订单支付状态也只被更新一次。5.3 买家确认收货和超时自动确认游戏交易不能省掉“确认收货”环节。卖家发货时需要在订单里填写交付凭证比如游戏角色名、服务器、交易方式说明也可以上传截图。买家收到后点击“确认收货”订单进入完成状态卖家才能收到货款这里如果接真实支付就涉及平台分账或担保账户毕设可以先做成标记金额可提现。如果买家一直不点确认订单也不能永远挂在已发货状态。我加了一个定时任务从发货时间算起超过3天自动确认收货。SQL写法依然是“条件更新宁可少更新也不能误更”UPDATE orders SET status 40 WHERE status 30 AND deliver_time NOW() - INTERVAL 3 DAY如果用户在这期间申请了售后状态会先变成60退款中订单就不是30了这条更新也不会影响它。状态机的好处在这里体现得最明显——每个操作都严格限定在“当前合法状态”内只要更新条件带上旧状态基本上不会出现状态错乱。6. 项目打包部署Vue打进SpringBoot单包以及容易被忽略的配置游戏交易系统的部署方式有很多种我最终选择了把Vue打包后放进SpringBoot的静态资源目录用Maven构建成一个可执行的jar。对中小型项目和毕设来说这种“单包部署”方式是最省心的一台服务器、一个jar、一条java -jar命令就搞定。6.1 为什么我不推荐前后端彻底分离部署前两年特别流行“前后端完全分离部署”前端dist丢到Nginx后端Java独立跑在8080然后通过Nginx反代。这套方案当然更适合大型团队但它有代价服务器上要维护Nginx配置前端发布和后端发布要分开管理跨域配置也要小心处理。如果你只是为了交付一个能运行、能演示的系统完全没有必要引入这些复杂度。把Vue打进SpringBoot之后浏览器访问的是同一个端口SpringBoot同时提供接口和静态页面天然没有跨域问题。Spring Security的登录拦截也能同时覆盖前端页面和API。只有将来前端团队庞大、需要独立迭代时再拆开也不迟。SpringBoot的自动装配让这种整合非常顺滑只要把前端构建产物放在src/main/resources/static下应用启动后直接就能访问。6.2 Maven集成前端的具体配置第一版我是手动执行npm run build然后把dist目录里的文件复制到static目录。后来觉得太麻烦就在pom.xml里接了frontend-maven-plugin让Maven在打包时自动安装Node、执行npm install、构建前端plugin groupIdcom.github.eirslett/groupId artifactIdfrontend-maven-plugin/artifactId version1.15.0/version configuration workingDirectoryfrontend/workingDirectory installDirectorytarget/installDirectory /configuration executions execution idinstall node and npm/id phasegenerate-resources/phase goals goalinstall-node-and-npm/goal /goals configuration nodeVersionv18.20.2/nodeVersion /configuration /execution execution idnpm install/id phasegenerate-resources/phase goals goalnpm/goal /goals /execution execution idnpm build/id phasegenerate-resources/phase goals goalnpm/goal /goals configuration argumentsrun build/arguments /configuration /execution /executions /plugin这样执行mvn clean package时Maven会先构建前端再把dist里的资源生成到SpringBoot的classpath静态目录下最终打出的jar天然包含页面。一个非常有用的经验是如果在构建过程中出现Node下载失败优先检查服务器是否安装了git和curl因为frontend-maven-plugin需要从nodejs官网下载压缩包网络环境受限时经常会卡在这一步。6.3 线上环境配置端口、数据库、日志与图片目录最后是线上运行配置。我把SpringBoot的配置文件按环境拆成了application-dev.yml和application-prod.yml生产环境核心配置大致如下server: port: 8080 servlet: context-path: / spring: datasource: url: jdbc:mysql://localhost:3306/game_trade?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: prod_user password: xxxxx servlet: multipart: max-file-size: 20MB max-request-size: 50MB minio: endpoint: http://127.0.0.1:9000 access-key: your-access-key secret-key: your-secret-key有几个配置项是要特别提醒的multipart.max-file-size不设置的话SpringBoot默认只允许上传1MB文件商品截图稍微大点就会报错。如果Vue用了History路由模式单包部署后刷新任意二级页面会404。有两种解决方式改成Hash路由或者在SpringBoot里加一个控制器把非API路径转发到index.html。为了好看的URL我选了后者但如果你图省事直接上Hash模式更稳。MySQL连接串必须带serverTimezoneAsia/Shanghai否则日期字段会报错或者相差8小时。这类问题排查起来很痛苦第一次配置时就写对能省很多时间。日志路径单独配置到/logs目录不要默认打到控制台不然jar在后台运行一段时间后日志文件会越来越大且不好清理。部署完成后我的验证清单是静态首页能打开商品图片能正常加载登录后能发布商品、下单、模拟支付、发货、确认收货后台管理员能审核商品。只要这条链路走通系统就算真正“闭环”了。我在实际开发中体会最深的一点是交易系统的骨架并不复杂难的永远是异常分支。用户重复点击下单、支付回调重复推送、超时关单和确认收货并发执行这些极端情况看起来不起眼却最能区分一个系统是demo还是能用的产品。做这类项目的时候建议你先把状态机画清楚再动手写代码只要状态流转没有歧义后端的坑就会少一大半。