做游戏销售平台这个选题的时候,我其实没有纠结太久。市面上电商类的毕设和开源项目一抓一大把,但真正把“数字商品”这种非实体交付逻辑做明白的反而少。游戏销售平台表面上是电商,内核却是库存、卡密、订单状态机这几条线的博弈,用SpringBoot+Vue3+MyBatis这套组合去落地,恰好能把每个环节都看得清清楚楚。
这篇文章就基于我实际开发这个系统源码的全过程来复盘,从架构选型到数据库设计,从并发扣库存到前后端联调,最后聊几个上线才会遇到的坑。如果你正准备做类似项目,或者想搞懂前后端分离的电商系统到底怎么串起来,这篇应该能帮你省不少时间。
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校验。另外一个经验是用户表不要放太多业务字段,比如“累计消费金额”这种东西应该靠订单表实时统计,除非访问量真的到了需要冗余加速的程度。
商品表是平台的门面,设计的字段要有足够的展示张力:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar(100) | 游戏名称 |
| category | varchar(50) | 分类:端游/手游/点卡 |
| price | decimal(10,2) | 售价 |
| cover | varchar(255) | 封面图URL |
| description | text | 图文详情 |
| status | tinyint | 上架/下架 |
| sales_count | int | 销量统计 |
这里有个细节,封面图URL我建议不要存完整路径,存相对路径就好,后续如果换OSS或者换服务器域名,只用全局替换一次前缀即可,改数据库的事尽量别干。
商品和卡密之间通过product_id关联:
商品表(1) → 卡密表(N)订单表是这个系统的核心流转载体,设计时除了常规字段,一定要把业务扩展性考虑进去。我的订单表是这么设计的:order_no用时间戳加随机数生成唯一订单号,金额字段用decimal避免浮点数精度问题,支付状态用tinyint存枚举值而不是字符串——0待支付、1已支付、2已取消、3已退款,这样查询和索引都更快。
2.2 卡密库存表的设计细节
卡密库存表是游戏销售平台最有特色的部分。一张卡密表里存着productId对应的所有激活码,每个卡密都有唯一编号(可以是随机字符串或者按照某种加密规则生成的CDKey)和一个状态字段。当用户下单支付成功后,系统需要从这张表里“取”一个未被使用的卡密分配给用户。
表结构大致是这样:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| product_id | bigint | 关联商品ID |
| card_no | varchar(64) | 卡号/激活码 |
| secret | varchar(64) | 密码/密钥 |
| status | tinyint | 0待售、1已售、2锁定 |
| order_id | bigint | 售出时关联订单ID |
这里要特别留一个心眼:批量导入卡密时,一定要在product_id和card_no上建唯一索引,防止重复数据混入。我在写过批量导入工具的时候,就曾因为文件里有几行重复的卡密数据导致库存虚标,排查了大半天,后来加上唯一索引重复数据直接报错,反而省心了。
还要考虑库存的展示策略。前端商品列表要显示“还剩多少件”,总不能每次请求都去count一下卡密表,大数据量下这查询很吃力。我选择的方案是:在商品表里直接冗余一个stock字段,每次导入卡密时累加,售出卡密时递减,再配合定时任务做一次校准,保证显示库存和真实可用卡密数量的一致。
2.3 MyBatis动态SQL与复杂查询落地
MyBatis在这个项目里承担了所有数据访问逻辑,实际用下来我对动态SQL的感受是:条件分支别写在Java代码里拼接字符串,一定要用 标签在XML里做,这样SQL的上下文完整可见,维护起来一目了然。
以商品列表的分页条件查询为例:
<select id="selectProductPage" resultType="com.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 test="keyword != null and keyword != ''"> AND p.name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="category != null and category != ''"> AND p.category = #{category} </if> <if test="minPrice != null"> AND p.price >= #{minPrice} </if> <if test="maxPrice != null"> AND p.price <= #{maxPrice} </if> </where> ORDER BY p.id DESC LIMIT #{offset}, #{pageSize} </select>where标签会自动去掉第一个多余的AND,这个机制让我少写很多判断逻辑。另外排序字段如果允许前端传入,一定不要直接拼接列名,要用白名单映射,不然就是注入口子。
订单列表的查询相对复杂一些,要关联用户表和商品表。我使用了MyBatis的resultMap来做关联映射,而不是简单用JOIN把所有字段平铺。用户信息只需要用户名和头像,商品信息只需要名称和封面图,定义好关联映射后返回的VO结构非常干净。
动态SQL还有个常见需求是批量插入。比如导入一万条卡密数据,如果循环调用单条insert,性能会很差。我采用的是foreach批量插入:
<insert id="batchInsertCard"> INSERT INTO card_key (product_id, card_no, secret, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.productId}, #{item.cardNo}, #{item.secret}, 0) </foreach> </insert>MySQL默认最大允许的SQL包大小是4MB,每批500条实测下来是最稳的,既不会超出限制又能保证插入速度可观。
3. 后端核心模块的落地实现
3.1 认证方案与登录态设计
游戏销售平台的用户角色有两种:普通买家和管理员。最开始我考虑过用Session方案,后端存登录态、前端带Cookie,但前后端分离之后Cookie处理跨域问题很麻烦,CORS配置要放开withCredentials,Nginx还要处理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 UPDATE:
@Select("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 vite@latest 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.active=prod配置文件里数据库连接要注意MySQL 8.0和5.7的驱动差异,我用的5.7版本的驱动类是com.mysql.jdbc.Driver,但生产建议加上时区参数:
jdbc:mysql://localhost:3306/game_sales?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai5.2 高频报错排查与避坑清单
开发这个项目的过程中,有几个问题反反复复出现,我整理成一个速查表,后面开发遇到类似问题可以直接对号入座。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端请求跨域报错 | CORS配置没有放行OPTIONS请求 | 确认后端CORS配置允许OPTIONS方法 |
| 登录后刷新页面就掉线 | token存在内存中或未持久化存储 | 存入localStorage并在axios拦截器读取 |
| 数据库中文乱码 | 连接串缺characterEncoding参数或者表字符集不对 | 连接串加characterEncoding=utf8,表建表时指定utf8mb4 |
| 批量插入卡密极慢 | 单条循环insert | 用foreach改为批量插入 |
| 前端打包后刷新404 | Nginx缺少try_files配置 | 配置try_files $uri $uri/ /index.html |
| 接口偶尔返回重复卡密 | 并发分配卡密没有锁 | 使用SELECT ... FOR UPDATE锁定行 |
| 时间字段少了8小时 | JVM和MySQL时区不一致 | 连接串显式加serverTimezone=Asia/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起步迈向真实业务开发,拿这个项目练手,比逛一百篇教程都管用。