
做游戏销售平台这个选题的时候我其实没有纠结太久。市面上电商类的毕设和开源项目一抓一大把但真正把“数字商品”这种非实体交付逻辑做明白的反而少。游戏销售平台表面上是电商内核却是库存、卡密、订单状态机这几条线的博弈用SpringBootVue3MyBatis这套组合去落地恰好能把每个环节都看得清清楚楚。这篇文章就基于我实际开发这个系统源码的全过程来复盘从架构选型到数据库设计从并发扣库存到前后端联调最后聊几个上线才会遇到的坑。如果你正准备做类似项目或者想搞懂前后端分离的电商系统到底怎么串起来这篇应该能帮你省不少时间。1. 项目整体架构与设计思路1.1 为什么选前后端分离这个系统从一开始就没有考虑服务端渲染模板。游戏销售平台的页面交互密度不低商品列表要筛选、购物车要即时更新、用户中心要切换各种Tab如果靠后端模板拼页面每次操作都要整页刷新体验很割裂。前后端分离之后前端只管渲染数据、交互逻辑后端只负责提供标准化的RESTful接口两边可以独立开发、独立部署联调时只要盯住接口文档就行。实际开发时这种优势体现得很直接。我这边可以先定义好接口返回结构统一用Result对象包装code、message、data三件套前端同事或者说我自己切到前端角色的时候可以拿Mock数据先跑页面完全不用等后端代码编译。等到接口联调阶段只要后端接口路径和字段名称不变前端连代码都不用动。从部署角度讲前后端分离也更干净。后端打成jar包跑在服务器上前端构建成纯静态文件丢给Nginx托管反向代理一下/api路径转发到后端端口。这样后端的负载压力被Nginx挡了一层静态资源加载速度也有明显提升部署回滚也灵活——前端错了只替换静态文件后端错了只换jar包。1.2 技术栈选型的权衡选SpringBoot没什么好犹豫的Java生态做企业级应用它就是最省心的那个。自动配置把大量显式配置压缩掉内嵌Tomcat让部署变成了java -jar一条命令配合Spring全家桶的成熟生态做个电商系统是绰绰有余。很多简历上写着“熟悉SpringBoot”的人其实没体会过它解决配置地狱的成就感这个项目做完你会对自动配置原理有实在的感知。前端选Vue3而不是Vue2核心原因是组合式API让代码组织方式有了质的变化。游戏商品列表页涉及搜索条件、分页、排序、筛选等多个响应式状态Vue2的Options API会把这些逻辑拆散在data、methods、watch里改一个联动逻辑要上下翻。Vue3的setup函数把相关逻辑聚在一起写业务的时候思路不用断开而且Composition API天然方便做逻辑复用比如把“获取商品列表”这个逻辑抽成useProductList函数多个组件直接调用。持久层我用的是MyBatis而不是MyBatis-Plus倒不是Plus不好而是在这个项目里我不想失去对SQL的完全掌控。游戏销售平台的查询关联度比较高比如订单列表要根据商品类型过滤、按时间范围统计、还要连用户表拉取昵称这类动态SQL在MyBatis里写mapper.xml非常直观每个条件都可以精确控制。Plus虽然提供了通用CRUD但遇到这种复杂查询还是得手写SQL反而平添一层抽象。MySQL这边用的是5.7版本稳定压倒一切。轻量级电商系统对数据库的特性要求并不多InnoDB事务配合行级锁已经能覆盖主要的并发场景线上数据量不到百万级也用不上分库分表这些重型方案老老实实把索引设计和事务隔离级别做好就足够了。1.3 目录结构与模块划分项目的包结构我一开始就按业务模块划分而不是按技术层划分这一点对后期维护影响很大。后端大体上是这样组织的com.gamesales ├── common // 统一返回、异常处理、工具类 ├── config // 跨域、拦截器、WebMvc配置 ├── controller // 接口入口 ├── service // 业务逻辑层 ├── mapper // MyBatis数据访问接口 ├── entity // 数据库实体 ├── dto // 数据传输对象 └── vo // 视图对象实体、DTO、VO分开这件事看着繁琐但真的能救命。比如订单实体里存的是userId和payStatus但前端需要看到用户名和支付状态的中文描述如果直接用实体响应就会把不需要的字段也暴露出去而且层次不清晰。用VO做一层转换接口返回的内容就完全在掌控之中。前端结构同样按业务模块拆src ├── api // 接口请求封装 ├── assets ├── components // 公共组件 ├── router ├── store // Pinia状态管理 ├── views │ ├── Home // 首页 │ ├── Product // 商品详情 │ ├── Order // 订单流程 │ └── User // 个人中心 └── utils这样的拆分让新人拿到代码后可以快速定位改订单逻辑去Order目录改商品展示去Product目录而不会出现“找一个功能翻遍全项目”的情况。2. 数据库设计与核心业务模型2.1 游戏销售平台的核心表结构游戏销售平台的数据库设计关键要抓住“虚拟商品交易”这个本质。和卖实体服装不同游戏商品卖的是激活码、序列号、点卡这类数字资产每一件商品背后都关联着一批可用的卡密库存。这意味着商品表和库存表之间要建立起清晰的一对多关系。用户表这块没啥特殊设计的把必要字段设置好就行。需要注意密码存储不能明文我用BCrypt加密注册时加密入库登录时matches校验。另外一个经验是用户表不要放太多业务字段比如“累计消费金额”这种东西应该靠订单表实时统计除非访问量真的到了需要冗余加速的程度。商品表是平台的门面设计的字段要有足够的展示张力字段名类型说明idbigint主键namevarchar(100)游戏名称categoryvarchar(50)分类端游/手游/点卡pricedecimal(10,2)售价covervarchar(255)封面图URLdescriptiontext图文详情statustinyint上架/下架sales_countint销量统计这里有个细节封面图URL我建议不要存完整路径存相对路径就好后续如果换OSS或者换服务器域名只用全局替换一次前缀即可改数据库的事尽量别干。商品和卡密之间通过product_id关联商品表(1) → 卡密表(N)订单表是这个系统的核心流转载体设计时除了常规字段一定要把业务扩展性考虑进去。我的订单表是这么设计的order_no用时间戳加随机数生成唯一订单号金额字段用decimal避免浮点数精度问题支付状态用tinyint存枚举值而不是字符串——0待支付、1已支付、2已取消、3已退款这样查询和索引都更快。2.2 卡密库存表的设计细节卡密库存表是游戏销售平台最有特色的部分。一张卡密表里存着productId对应的所有激活码每个卡密都有唯一编号可以是随机字符串或者按照某种加密规则生成的CDKey和一个状态字段。当用户下单支付成功后系统需要从这张表里“取”一个未被使用的卡密分配给用户。表结构大致是这样字段名类型说明idbigint主键product_idbigint关联商品IDcard_novarchar(64)卡号/激活码secretvarchar(64)密码/密钥statustinyint0待售、1已售、2锁定order_idbigint售出时关联订单ID这里要特别留一个心眼批量导入卡密时一定要在product_id和card_no上建唯一索引防止重复数据混入。我在写过批量导入工具的时候就曾因为文件里有几行重复的卡密数据导致库存虚标排查了大半天后来加上唯一索引重复数据直接报错反而省心了。还要考虑库存的展示策略。前端商品列表要显示“还剩多少件”总不能每次请求都去count一下卡密表大数据量下这查询很吃力。我选择的方案是在商品表里直接冗余一个stock字段每次导入卡密时累加售出卡密时递减再配合定时任务做一次校准保证显示库存和真实可用卡密数量的一致。2.3 MyBatis动态SQL与复杂查询落地MyBatis在这个项目里承担了所有数据访问逻辑实际用下来我对动态SQL的感受是条件分支别写在Java代码里拼接字符串一定要用 标签在XML里做这样SQL的上下文完整可见维护起来一目了然。以商品列表的分页条件查询为例select idselectProductPage resultTypecom.gamesales.vo.ProductVO SELECT p.id, p.name, p.category, p.price, p.cover, p.sales_count, p.stock FROM game_product p where if testkeyword ! null and keyword ! AND p.name LIKE CONCAT(%, #{keyword}, %) /if if testcategory ! null and category ! AND p.category #{category} /if if testminPrice ! null AND p.price gt; #{minPrice} /if if testmaxPrice ! null AND p.price lt; #{maxPrice} /if /where ORDER BY p.id DESC LIMIT #{offset}, #{pageSize} /selectwhere标签会自动去掉第一个多余的AND这个机制让我少写很多判断逻辑。另外排序字段如果允许前端传入一定不要直接拼接列名要用白名单映射不然就是注入口子。订单列表的查询相对复杂一些要关联用户表和商品表。我使用了MyBatis的resultMap来做关联映射而不是简单用JOIN把所有字段平铺。用户信息只需要用户名和头像商品信息只需要名称和封面图定义好关联映射后返回的VO结构非常干净。动态SQL还有个常见需求是批量插入。比如导入一万条卡密数据如果循环调用单条insert性能会很差。我采用的是foreach批量插入insert idbatchInsertCard INSERT INTO card_key (product_id, card_no, secret, status) VALUES foreach collectionlist itemitem separator, (#{item.productId}, #{item.cardNo}, #{item.secret}, 0) /foreach /insertMySQL默认最大允许的SQL包大小是4MB每批500条实测下来是最稳的既不会超出限制又能保证插入速度可观。3. 后端核心模块的落地实现3.1 认证方案与登录态设计游戏销售平台的用户角色有两种普通买家和管理员。最开始我考虑过用Session方案后端存登录态、前端带Cookie但前后端分离之后Cookie处理跨域问题很麻烦CORS配置要放开withCredentialsNginx还要处理Cookie域的问题想想就头疼。后来果断切到JWT方案。JWT的核心思路是无状态后端不存登录信息把用户身份和过期时间加密放进token里前端每次请求在Authorization头里带过来后端用拦截器解析校验。流程是登录成功后用用户ID和角色信息生成token过期时间设置成7天返回给前端。前端把token存在localStorage里后续请求由axios拦截器统一添加Authorization头。后端拦截器校验token如果解析失败或者过期直接返回401前端收到401就跳转登录页。生成token的代码大致是String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();这里有个很关键的实践点如果只是校验登录状态JWT直接解析就够了但如果要刷新用户信息、封禁用户等特殊操作无状态token是无法实时生效的只能等它过期。所以在设计时将管理员用户单独维护了一个“可踢下线”的黑名单缓存运营操作需要管理权限的时候就查一下缓存避免被注销的账号还能继续操作。关于用户密码我用了BCrypt加密而不是MD5。MD5天生适合做校验和不适合做密码存储因为彩虹表攻击和暴力碰撞的成本太低了。BCrypt每次加密自动加盐相同密码加密出来的结果都不一样暴力破解成本指数级上升。Spring Security的BCryptPasswordEncoder可以直接拿来用不必引入整个Security框架增加复杂性。3.2 订单流程与并发扣库存的实现订单创建阶段我遇到的最核心问题是并发控制。两个用户同时买同一款游戏只剩一件库存如果都在支付前卡密分配阶段把这一件卡密取走了就产生了超卖。经典方案是悲观锁和乐观锁这里我用了悲观锁的思路在查询可用卡密时加上FOR UPDATESelect(SELECT id, card_no, secret FROM card_key WHERE product_id #{productId} AND status 0 LIMIT 1 FOR UPDATE) CardKey selectAvailableCard(Param(productId) Long productId);FOR UPDATE会把命中的行锁住事务提交前其他事务无法读取这一行这样同一件商品的卡密分配就变成了串行操作。同时还要把商品表里对应的库存字段做条件更新防止显示库存变负数UPDATE game_product SET stock stock - 1 WHERE id #{productId} AND stock 0这里用affected rows来判断是否扣减成功如果影响行数为0说明库存已经没了直接抛出库存不足异常。这种方案在游戏销售平台这种并发量级下是完全够用的。要知道库存超卖的根源在于“检查库存”和“扣减库存”两个操作没有在同一个原子性上下文里而悲观锁配合事务恰好把这两个动作焊死了。如果有一天并发量真要涨到上千TPS那时候再考虑把库存操作移到Redis配合Lua脚本处理架构上也顺理成章。订单创建的完整流程我串起来是这样的创建订单记录状态为待支付调用卡密查询接口锁定一张可用卡密将卡密状态置为锁定状态关联本订单号扣减商品显示库存提交事务用户支付成功后再更新卡密状态为已售、订单状态为已支付。这里把“锁定”和“售出”分成两步是为了处理支付超时15分钟内未支付后台定时任务把锁定的卡密释放回库存池。3.3 支付逻辑与回调幂等这个项目我接入了模拟支付流程。真实项目里对接微信或支付宝有一个绕不开的话题——回调接口的幂等性。支付平台异步回调同一个通知可能发送多次如果不做幂等处理同一笔订单可能被重复标记为已支付卡密也可能被重复发放。我的处理方案很简单一是订单表里pay_status字段加一个“支付成功”的唯一逻辑判断更新之前先查一次当前状态二是数据库层面设立一张pay_notify表记录每次回调的通知ID唯一索引一加重复回调直接忽略。关键代码如下public void handlePayNotify(String orderNo, String notifyId) { // 先判断通知是否处理过 if (payNotifyMapper.findByNotifyId(notifyId) ! null) { return; } // 事务内处理 Order order orderMapper.selectByOrderNo(orderNo); if (order.getPayStatus() 0) { // 发放卡密 cardKeyMapper.updateStatusByOrderId(order.getId(), 1); // 更新订单状态 orderMapper.updatePayStatus(order.getId(), 1); } // 记录通知ID payNotifyMapper.insert(notifyId, orderNo); }支付回调处理要加事务这里面的每一步都必须成功不然就会出现订单已支付但卡密没发放的严重事故。我在本地测试的时候就专门模拟过“卡密更新失败但订单更新成功”的情况如果不在同一个事务里用户会投诉拿着支付凭证却收不到货。4. 前端Vue3工程化与联调实践4.1 基于Vite的项目搭建与核心目录规划前端这一侧我选用Vite作为构建工具它的开发服务器冷启动速度比Webpack快了一个量级刷新时的热更新几乎无感知。对于我这种要频繁修改页面调试接口的场景省下的等待时间积少成多。创建项目没什么花头npm create vitelatest game-sales-web -- --template vue装依赖、加router、pinia、axios、element-plus这些常规操作就不细说了。我想重点说的是目录规划的细节前面也提过但真正开发中还有一个容易被忽略的模块——api目录的整理。每个页面对应的接口我单独用一个js文件管理比如product.js里放商品相关的请求order.js里放订单相关的请求import request from /utils/request export function getProductPage(params) { return request({ url: /api/product/page, method: get, params }) } export function createOrder(data) { return request({ url: /api/order/create, method: post, data }) }所有请求都走request实例这个实例在utils/request.js里创建统一配置了baseURL、超时时间和请求/响应拦截器。这样写业务代码的时候一行调用就能发请求维护语义也很清楚。很多新手喜欢在组件里直接字符串拼URL项目变大之后后端改一个路径前端要全文搜索替换这是自找苦吃。4.2 组合式API的业务逻辑封装Vue3的组合式API是这版框架的最大红利。游戏商品列表页的逻辑复杂度很适合用composition来组织。我建了一个useProductList的组合函数export function useProductList() { const loading ref(false) const products ref([]) const total ref(0) const queryParams reactive({ page: 1, pageSize: 12, keyword: , category: }) async function fetchList() { loading.value true try { const res await getProductPage(queryParams) products.value res.data.list total.value res.data.total } finally { loading.value false } } return { loading, products, total, queryParams, fetchList } }在组件里调用时只需要引入这个函数相关状态和操作就直接可用。更妙的是筛选条件变化后只要重新调用fetchList所有关联视图自动更新。这类逻辑如果拆到Vue2的分散写法里data、watch、methods、computed各放一段维护时要在文件里来回跳。前端路由用了Vue Router 4配置时给常规页面都做了一级懒加载{ path: /product/:id, name: ProductDetail, component: () import(/views/Product/ProductDetail.vue), meta: { requiresAuth: true } }购车到结算这一步比较特殊需要登录才能访问。路由守卫里对requiresAuth做了统一拦截没登录就跳转登录页登录后跳回原目标页。这里有个小坑跳转回跳如果用replace会导致浏览器历史记录异常一定要用router.push配上query参数传递redirect路径。4.3 跨域配置与请求拦截的实战经验前后端分离开发时遇到最大的拦路虎就是跨域。前后端在不同的端口上跑Vite默认5173后端8080浏览器会直接拒绝跨域请求。后端CORS配置是最快解决方案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不能写成allowedOrigins()因为allowCredentials(true)和不能同时使用否则浏览器会直接报错。这是个非常经典的报错场景一眼看到The value of the Access-Control-Allow-Origin header in the response must not be the wildcard *....就知道是这里配错了。请求拦截器里我还统一处理了两件事token注入和错误提示。请求发出前从localStorage取token塞进header响应回来时如果是401就清除本地登录态并跳转登录页其他业务错误用Element Plus的ElMessage提示用户。request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(error.response?.data?.message || 网络异常) } return Promise.reject(error) } )这样业务代码里基本不需要再写重复的错误提示异常统一走拦截器页面代码保持清爽。5. 打包部署与线上问题排查5.1 前端构建与后端部署的关键配置开发做完后部署是临门一脚这一脚踢不好前面全白干。前端构建命令很简单npm run build产物会输出到dist目录。这里有个关键点Vite构建时默认base是/如果部署在域名根路径下没问题但如果要部署到子路径比如域名/gamesales就必须要先配置base。在vite.config.js里export default defineConfig({ base: /, plugins: [vue()] })我这边部署用了Nginx托管前端静态文件同时充当反向代理。配置大概是server { listen 80; server_name gamesales.example.com; location / { root /var/www/gamesales/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files配置到index.html是前端路由History模式必须的否则刷新一个非根路径直接404。这也是前端部署最常见的坑忘了配try_files用户一刷新页面就白屏报错。后端部署就是把jar包扔服务器上mvn clean package -DskipTests java -jar game-sales-server.jar --spring.profiles.activeprod配置文件里数据库连接要注意MySQL 8.0和5.7的驱动差异我用的5.7版本的驱动类是com.mysql.jdbc.Driver但生产建议加上时区参数jdbc:mysql://localhost:3306/game_sales?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai5.2 高频报错排查与避坑清单开发这个项目的过程中有几个问题反反复复出现我整理成一个速查表后面开发遇到类似问题可以直接对号入座。现象可能原因解决方案前端请求跨域报错CORS配置没有放行OPTIONS请求确认后端CORS配置允许OPTIONS方法登录后刷新页面就掉线token存在内存中或未持久化存储存入localStorage并在axios拦截器读取数据库中文乱码连接串缺characterEncoding参数或者表字符集不对连接串加characterEncodingutf8表建表时指定utf8mb4批量插入卡密极慢单条循环insert用foreach改为批量插入前端打包后刷新404Nginx缺少try_files配置配置try_files $uri $uri/ /index.html接口偶尔返回重复卡密并发分配卡密没有锁使用SELECT ... FOR UPDATE锁定行时间字段少了8小时JVM和MySQL时区不一致连接串显式加serverTimezoneAsia/Shanghai还有一个经验值得单独拎出来说。MyBatis打印SQL日志是排查问题的利器效果立竿见影。在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl日志开启后每条SQL的完整语句和参数值都打印出来了排查查询条件多一截少一截的问题效率极高。日常开发我全程开着这个日志上线前再替换成logback的慢SQL日志生产环境不建议把SQL全量打印会拖累性能。部署后还有一个高频问题用户反馈图片加载不出来。排查了一圈发现是前后端分离部署的时候上传的图片存到了后端本地磁盘而前端页面跑在各个域下图片路径自然访问不到。这个问题的长线解法是接入对象存储服务但项目内部做了一个临时兼容——配置一个静态资源映射把上传目录通过WebMvcConfigurer暴露出来Nginx再对/upload路径做映射到后端地址。Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); }这类看似不起眼的小配置恰恰是线上系统稳定运行的关键所在。回看这个项目从架构选型到每一行代码落地最核心的体会是做一个前后端分离的系统技术栈不是最难的难的是把数据流、状态流、异常流在各个环节都理顺。游戏销售平台麻雀虽小但库存并发、订单状态流转、支付回调幂等这些电商核心难题都覆盖到了做完之后再去写更复杂的企业级系统底子就扎实了。如果你正准备从CRUD起步迈向真实业务开发拿这个项目练手比逛一百篇教程都管用。