
前后端分离这个词在现在的Web开发里几乎成了默认选项。我最近完整做了一套游戏销售平台的系统后端用SpringBoot搭RESTful接口前端用Vue做单页应用数据访问层走MyBatis底层存储交给MySQL整个项目从前端页面到后台管理、从数据库建模到服务器部署全部跑通。这篇文章就把这套系统的设计思路、数据库建模、后端接口实现、前端页面搭建以及部署流程全部拆开讲一遍包括我实操过程中踩过的坑和对应的解决办法。这个项目适合几类人一是想系统理解前后端分离项目如何整合的开发者二是准备毕业设计或者简历项目、需要一个完整可演示系统的人三是想学习SpringBootVue这套主流技术栈如何配合使用的同学。游戏销售平台本身是电商类项目的典型变体比普通商城多了虚拟商品、库存时效性这些特性做起来更有意思也更能体现设计功底。1. 项目定位与技术选型为什么是这套组合1.1 游戏销售平台到底在做什么先用一句话说清楚这个系统的业务模型。游戏销售平台通俗讲就是“卖游戏相关商品”的网站。这里的商品既可以是完整的游戏激活码、充值卡密也可以是游戏币、道具、代练服务这类虚拟商品。和淘宝、京东那种实物电商相比最大的区别是没有物流模块核心流程变成了用户浏览商品 → 加入购物车 → 下单 → 支付 → 系统自动发货返回卡密或者直接发到账号这反而让订单、库存、支付这三块逻辑变得更加聚焦。从功能上看整个系统分为用户端和管理端。用户端包含注册登录、商品列表、商品搜索、商品详情、购物车、订单列表、个人中心管理端包含商品上下架、库存管理、订单管理、用户管理和基础的数据统计。这些功能基本覆盖了一个小型电商系统的全部骨架这也是为什么很多教程拿电商类项目当练习样例——麻雀虽小五脏俱全。实际做下来我认为这个项目最大的难点不在某个单一接口而在订单流程的状态控制。一个订单从创建到最终完成要经历待支付、已支付、已发货、已完成、已取消这几个状态每一个状态迁移都由不同操作触发任何一个环节漏了校验都可能出现脏数据。这也是我后面在数据库设计和后端实现里重点处理的部分。1.2 技术栈选型的逻辑技术栈选择这个组合不是因为它最新或者最热而是因为它非常稳资料多适合做完整项目。先看后端。SpringBoot提供了自动配置和起步依赖一个maven项目加几个依赖就能跑起来内嵌Tomcat让本地开发和服务器部署都足够简单。它的生态太成熟了任何你遇到的问题几乎都能搜到答案这对做完整项目来说很重要——项目卡住的时间成本是最高的。再看看前端。Vue对中小型单页应用极其友好组件化开发让页面复用变得容易响应式数据绑定让页面状态管理直观。和React相比Vue的上手曲线更平缓模板语法也更接近传统HTML的思维习惯适合快速把电商页面搭出来。MyBatis在这个组合里承担数据访问层。有的同学现在创建项目默认就选MyBatis-Plus但我觉得对于学习和吃透原理来说原生的MyBatis更能让你理解SQL映射、参数绑定、ResultMap这些核心机制。Plus确实方便但很多情况下把SQL写死在XML里反而更直观、更容易调优尤其像库存扣减、订单列表分页这种对SQL有明确要求的场景MyBatis的优势非常明显。MySQL则不用多说订单、库存、用户资金这类需要强事务保障的数据用关系型数据库是最稳妥的选择。游戏销售平台的并发量在初期并不会太高MySQL配合合理的事务隔离级别完全够用。1.3 前后端分离模式的优势既然标题明确写了“前后端分离”我也想把这点讲透。分离的核心是把“界面”和“数据”彻底解耦。前端通过Ajax请求后端接口拿JSON数据渲染页面后端只负责提供数据和执行业务规则不关心页面长什么样。这样带来的直接好处有三个。第一前后端可以并行开发后端定义好接口文档前端拿Mock数据先做页面两边互不阻塞。第二后端接口可以被多个端复用同样一套APIWeb端能用小程序端、移动端也能用不用单独再写一套。第三部署上可以独立扩展前端页面是纯静态资源放Nginx或者任何静态服务器都行后端是一个Jar包单独做负载均衡或集群也方便。当然分离也不是没有代价。最明显的就是跨域问题前端跑在页面服务器后端跑在接口服务器浏览器默认会拦截跨域请求需要在开发环境配置代理、在生产环境配置Nginx反向代理。这个我在第五部分会专门讲。整体来看对于游戏销售平台这种需要长期迭代、未来可能扩展多端的项目前后端分离带来的收益远超那点部署成本。2. 系统设计从业务到数据库建模2.1 核心模块划分动手写代码之前一定要先把模块划分清楚。我这次是按业务域拆的后端包结构大致如下controller层对外暴露RESTful接口service层写业务逻辑mapper层负责数据库操作。实体类单独放在entity包里DTO和VO按需放在dto、vo包里。Controller不直接操作Mapper这是分层架构的基本纪律——业务规则必须经过Service否则后期迭代一旦逻辑复杂起来代码会迅速失控。模块划分上我拆成六个用户模块、商品模块、购物车模块、订单模块、支付模块、后台管理模块。每个模块有自己的Controller和Service模块之间通过主键或者业务ID关联避免相互直接引用对方内部实现。2.2 数据表设计数据库设计是整个项目的基石这块做不好后面全是坑。我最终的库脚本包含以下核心表表名用途关键字段user用户信息id, username, password, nickname, phone, create_timegame_product游戏商品id, title, cover, price, stock, category, status, description, sales_countcart_item购物车条目id, user_id, product_id, quantity, checkedorder_info订单主表id, order_no, user_id, total_amount, status, create_time, pay_timeorder_item订单明细id, order_id, product_id, product_title, price, quantitypayment_log支付流水id, order_id, pay_amount, pay_channel, status, create_timeadmin_user后台管理员id, username, password, role以游戏商品表为例字段设计有几个细节。status控制上下架状态取值为0或1商品被下架后前端列表不再查询它但已产生的订单不受影响。stock是库存数量下单扣减时要在SQL层做校验避免超卖。sales_count是累计销量用于排序和展示每笔订单支付成功后在事务里做累加。price字段我建议用DECIMAL(10,2)不要用double浮点数的精度问题在金额计算上非常致命。订单主表和明细表拆开是标准的电商表结构做法好处是同一订单可以包含多个商品明细里冗余了一份商品标题和价格快照即使后续商品改名或者清理订单依然能还原当时的成交信息。2.3 状态机与关键字段设计订单状态是这个系统里最重要的状态流转。我给order_info表的status字段定义了五档0待支付、1已支付、2已发货、3已完成、4已取消。用户下单后生成待支付订单支付成功变为已支付系统自动发货比如返回激活码变为已发货用户确认收货或者超时自动确认变为已完成下单后一定时间内未支付用户可以主动取消系统也可以定时关单把订单置为已取消。这里要特别强调的是库存扣减时机。我在设计中采用“下单锁定、支付确认”的思路用户点击下单时在事务中校验库存并扣减如果订单超时未支付被取消再把库存加回来。这样能保证库存不会因为用户只是把商品放购物车而减少也不会因为用户下单后长时间不支付导致库存长时间被锁。表设计还有一个容易忽略的公共字段create_time、update_time。这两个字段建议所有表都带上后端通过MyBatis的插入和更新语句统一维护排查问题时时间戳能帮你还原数据变更的先后顺序。is_deleted逻辑删除字段也建议预留尤其订单和商品这类核心表物理删除一旦误操作很难恢复。3. 后端实现SpringBoot MyBatis MySQL 的核心环节3.1 工程初始化与基础配置后端工程我直接在Spring Initializr生成的骨架基础上改的。依赖选了Spring Web、MyBatis、MySQL Driver、Lombok另外自己加了jjwt-api、jjwt-impl、jjwt-jackson用于JWT登录认证。application.yml里几个配置值得单独说一下spring: datasource: url: jdbc:mysql://localhost:3306/game_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.gameshop.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case这个配置非常重要。数据库字段是下划线风格比如create_timeJava实体类里是驼峰风格createTime打开这个开关后MyBatis自动映射省去大量手写resultMap的重复劳动。mapper-locations指定XML文件位置接口和XML文件建议同名同包放好避免排查时找不到对应关系。启动类上别忘了加MapperScan把Mapper接口所在的包扫描进来。这一步漏掉最常见的报错就是Could not autowire或者启动时报找不到Mapper Bean。3.2 用户认证JWT登录流程用户认证我选的是JWT无状态、前后端分离项目里非常合适的方案。注册时密码用BCrypt加密存储BCryptPasswordEncoder是Spring Security的一部分我只引了spring-security-crypto这一个包而不是把整个Spring Security引进来这样既能用到安全的加密组件又不会引入完整的过滤器链增加复杂度。登录流程是这样的前端提交用户名密码后端校验通过后用jjwt生成一个带过期时间的Token返回给前端String token Jwts.builder() .setSubject(user.getId().toString()) .claim(username, user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() 3600_000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact();前端在请求拦截器里把Token放到Authorization请求头后端写一个JwtInterceptor在进入Controller前解析Token解析成功就放行并将用户ID写入请求上下文。需要登录才能访问的接口通过拦截器路径配置统一控制这样就不用每个接口都手动写认证代码了。拦截器配置里有一个坑静态路径和登录、注册接口一定要在excludePathPatterns里排除否则前端连登录都提交不进去排查半天发现是Token校验环节直接拦了未登录请求。3.3 商品列表与购物车接口商品列表接口我用了最简单的分页方式手写LIMIT配合参数传递。一个Controller接收pageNum、pageSize、keyword、categoryService层计算偏移量Mapper的SQL用where标签动态拼接条件select idselectPage resultTypeGameProduct select * from game_product where status 1 if testkeyword ! null and keyword ! and title like concat(%, #{keyword}, %) /if if testcategory ! null and category ! and category #{category} /if /where order by sales_count desc limit #{offset}, #{limit} /selectwhere标签会自动去掉多余的and这个细节比手动拼接where 11干净得多。#{keyword}是预编译占位符会自动做参数转义防止SQL注入千万不要用${}直接拼接字符串这是MyBatis使用中最基础也是最重要的一条红线。购物车接口相对简单增删改查即可。需要注意的一点是购物车条目和商品之间存在外键关系但我在代码里没有建数据库外键约束而是在Service层校验商品ID有效性。实际项目中外键约束在电商高并发场景下往往是性能瓶颈通过应用层维护引用完整性是更常见的做法。3.4 下单的事务与库存扣减下单接口是整套系统里最核心、也最容易出问题的地方。它能拆成四步校验商品状态、扣减库存、生成订单主表、生成订单明细。这四步必须在一个事务里完成任何一步失败都要回滚否则会出现库存扣了但订单没生成或者订单生成了但明细缺失的脏数据。扣库存的SQL采用了条件更新来防止超卖update game_product set stock stock - 1 where id #{productId} and stock 1这条SQL执行后返回受影响的行数如果返回0说明库存不足直接抛异常触发回滚。用stock 1作为条件是数据库层面保证扣减安全的关键做法比先查库存再判断要可靠得多。Java侧例子如下Transactional public Long createOrder(OrderCreateDTO dto) { // 1. 扣减库存条件更新 int rows gameProductMapper.decreaseStock(dto.getProductId()); if (rows 0) { throw new BizException(库存不足); } // 2. 生成订单主记录 OrderInfo order new OrderInfo(); // ... 设置属性生成订单号 orderInfoMapper.insert(order); // 3. 生成订单明细 OrderItem item buildOrderItem(order.getId(), dto); orderItemMapper.insert(item); return order.getId(); }Transactional默认只对RuntimeException回滚如果业务层抛的是受检异常事务不会自动回滚。因此我这里用了自定义的BizException继承RuntimeException这样能保证库存异常时整个事务干净回滚。另外订单号生成建议用时间戳加随机数或者雪花算法不要直接用数据库自增ID当订单号暴露给用户避免泄露业务规模信息。3.5 支付模拟与自动发货真正的支付系统对接的是微信、支付宝这类第三方接口流程包含预下单、回调验签、异步通知对个人项目来说复杂度太高。我做了一个模拟支付的接口用户在前端点“立即支付”后端把订单状态从0更新为1同时生成一条支付流水写入payment_log再执行业务逻辑如果是卡密类商品返回对应的卡密信息否则把订单状态置为2已发货。模拟支付也有一个需要注意的细节不能用简单的update order_info set status 1 where id ?了事要加上where status 0的条件防止用户重复点击支付按钮导致订单状态被覆盖。这个场景和上面扣库存的思路一脉相承都是在SQL层面做状态条件校验而不是先查出来再判断。4. 前端实现Vue 搭建界面与交互4.1 项目初始化与环境配置前端用Vue 3 Vite这套组合。Vite启动速度比Webpack快太多开发体验好。创建好项目后安装依赖vue-router、pinia状态管理、axiosHTTP请求、element-plusUI组件库。我在vite.config.js里配置了路径别名resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }proxy配置我在第五部分详细讲这里先记住一个结论开发阶段所有/api开头的请求都交给Vite代理转发到后端8080端口这样前后端联调时几乎不需要处理跨域。4.2 路由与页面结构路由划分对应后端模块。用户端有首页/、商品列表/products、商品详情/product/:id、购物车/cart、订单列表/orders、登录注册/login管理端有/admin/products、/admin/orders、/admin/users。路由懒加载配置成() import()形式按需加载页面组件首屏加载更快。页面里我要重点提的是路由守卫。没有登录的用户不应该能访问购物车和订单页面管理端页面更是只能由管理员访问。Vue Router提供了beforeEach全局前置守卫在跳转前检查pinia里的用户状态和Tokenrouter.beforeEach((to) { const auth useAuthStore() if (to.meta.requiresAuth !auth.token) { return { path: /login, query: { redirect: to.fullPath } } } if (to.meta.requiresAdmin auth.user?.role ! admin) { return { path: /, message: 没有权限 } } })这个写法比在每个组件里手动判断要优雅得多而且把权限和业务逻辑集中在了一起。4.3 axios 封装与请求拦截器axios的封装是Vue项目里的标配操作。我建了一个utils/request.js创建axios实例设置基础URL为/api然后在请求拦截器里从pinia中读取Token写入请求头service.interceptors.request.use(config { const auth useAuthStore() if (auth.token) { config.headers.Authorization Bearer ${auth.token} } return config })响应拦截器统一处理错误码。后端约定返回结构{ code: 0, msg: success, data: ... }code非0说明业务失败直接弹提示HTTP状态码401说明Token过期清空登录状态跳转到登录页。这样一个拦截器就解决了所有请求的Token注入、错误提示、登录失效跳转不用在每一个接口调用里重复写这些逻辑。4.4 核心页面实现要点首页和商品列表页的逻辑相对直接调用/product/page接口拿数据渲染搜索框绑关键词后重新请求即可。购物车页面我用了Pinia管理选中的商品和数量结算按钮打开确认弹窗展示商品合计金额。这里踩过一个小坑后端返回的金额是BigDecimal前端显示时如果直接做浮点运算会出现0.1 0.2 0.30000000000000004这种精度问题。我的解决办法是展示时用toFixed(2)计算合计时把金额统一转成分整数运算最后再除以100显示彻底避开浮点精度坑。4.5 后台管理页面管理端页面用Element Plus的表格组件就能快速搞定。商品管理页包括商品列表表格、新增/编辑弹窗、上下架按钮、库存修改。订单管理页展示订单列表按订单状态筛选支付成功后的订单可以执行“发货”操作。后台页面的表单校验也要认真做。比如新增商品时价格必须大于0、库存必须非负、标题不能为空这类校验用Element Plus自带的rules配置在Form上即可。管理端的接口在请求拦截器里已经带了Token后端再通过拦截器校验管理员权限双重防护下权限控制才可靠。5. 前后端联调与部署从本地到服务器5.1 开发环境的联调方式开发时前端跑在5173端口后端跑在8080端口直接请求必然跨域。我在Vite里配置了代理让前端请求/api/xxx时由Vite开发服务器转发到后端。浏览器访问的是http://localhost:5173/api/xxx代理服务器帮我们请求http://localhost:8080/api/xxx然后再把响应返回给浏览器。浏览器全程只跟同源地址打交道跨域问题自然不存在。这种方式也是我强烈推荐的开发期联调方式比在SpringBoot里写CrossOrigin注解更接近生产环境的工作方式。CrossOrigin适合开发期临时用但生产环境的跨域由Nginx处理代码里加跨域注解反而容易埋下安全隐患。5.2 打包构建与服务器部署项目要上线前端先执行npm run build产出dist目录里面是纯静态文件。后端执行mvn clean package -DskipTests产出可运行的jar包。部署时我习惯用Nginx承担两个职责一是托管前端静态文件二是反向代理后端接口。Nginx配置关键部分是location规则server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /opt/game_shop/dist; try_files $uri $uri/ /index.html; } }try_files这一行是处理Vue的history路由模式的精髓。刷新/product/123时如果Nginx在静态目录里找不到这个路径对应的物理文件就会回退到index.html由Vue Router接管路由解析。不加这一行前端刷新任何非首页的路由都会报404这是前后端分离部署中最常见的问题。后端进程我用systemd管理写一个service文件指定Jar包路径和Java启动参数Restartalways保证进程崩溃后自动拉起。5.3 数据库初始化的注意事项服务器上的MySQL初始化要注意两件事。第一字符集一定要用utf8mb4它比utf8多支持表情符号而且这是MySQL 8.0下的默认字符集兼容性也好。建库时明确指定CREATE DATABASE game_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;第二首次启动后端之前先确认数据库账号权限。很多同学部署时报错Access denied for user排查半天发现是MySQL 8.0默认的认证插件和普通客户端不兼容或者账号权限没给全。导入SQL时先source建表脚本再检查后端连接参数里的serverTimezone时区错误会出现日期差8小时的问题。5.4 部署检查清单按这个顺序部署能省掉一大半问题先确认MySQL启动并能连通导入数据库脚本其次启动后端Jar包用curl直接访问后端健康检查接口确认后端正常然后Nginx托管前端静态文件访问首页最后验证登录、商品、下单这条完整链路。从底层到顶层逐层验证出了问题能立刻定位在哪一层。6. 常见问题与排查技巧实录6.1 高频报错与解决方案我把自己实操中遇到的典型问题整理成了一张速查表每一条都是真实踩过的报错/现象常见原因解决方案Application run failed端口被占用或数据库连不上换端口或检查MySQL连接参数/serverTimezoneCould not autowire Mapper启动类缺MapperScan补充MapperScan或Mapper接口加Mapper注解Invalid bound statementXML文件位置/namespace/方法ID不匹配核对mapper-locations路径和XML的namespace前端请求200但数据不渲染返回数据结构与前端预期不一致统一后端响应结构为code/msg/data刷新页面404Vue history模式没配Nginx回退加try_files $uri $uri/ /index.html金额显示0.30000000000004JS浮点运算精度问题金额统一转分整数运算显示转回元日期比实际少8小时serverTimezone没设置连接参数加serverTimezoneAsia/ShanghaiMySQL连接报Public Key Retrieval驱动版本和mysql8兼容问题连接参数加allowPublicKeyRetrievaltrue6.2 初始化项目的启动顺序问题很多同学一上来就启动SpringBoot结果日志刷了一大堆报错其实最基础的问题出在环境顺序上。必须先确保MySQL服务是启动状态数据库和表已经建好再启动后端。MySQL没起来就启动后端报错信息虽然明显但新手容易在排查日志时消耗大量时间。前端启动之前先检查Node版本Vite 5对Node版本有要求太老的Node启动会直接报语法错误。还要确认依赖已经安装完整直接npm run dev最常见的问题就是缺包控制台会提示某个模块找不到npm install重新装一次基本都能解决。6.3 我的几点避坑经验再分享几个常规教程里不会写的经验。第一事务方法千万不要自己调用自己。我在写下单接口时曾把Transactional放在私有方法上结果事务完全没生效。Spring的事务是通过AOP代理实现的自调用不会经过代理对象注解直接失效。实际开发中事务方法要放在公开方法上并且通过外部调用进入才能触发代理逻辑。第二分页查询不要用MyBatis直接返回List后Java内存来分页。项目数据量小的时候看不出问题一旦数据规模上来内存全量加载会让接口越来越慢。手写LIMIT或者引入PageHelper把分页压力放到数据库这是正确姿势。第三下单接口的幂等性。用户连续点击两次“提交订单”可能生成两笔相同订单。我在前端加了按钮防重复提交请求中禁用按钮后端再加一层校验同一个用户对同一商品短时间内不能重复创建未支付订单。前后端双层防护才能把重复下单的概率压到最低。第四密码等敏感信息不要明文存储在数据库里。这个项目至少要用BCrypt加密存储密码配置文件里的数据库密码虽然在小项目里往往不设防但建议写成环境变量引用养成习惯。第五前后端字段命名要保持一致。后端返回createTime前端就要用createTime接收不要前后端一个用驼峰一个用下划线类似问题极易在列表渲染和数据绑定环节制造大量无意义调试。这套系统我从数据库建表开始一路做到部署上线最有感触的一点是前后端分离项目真正花费时间的不是写代码而是把数据流跑通、把部署落地。我个人的体会是一定要先把数据库字段和实体类对应关系想清楚再动接口逻辑否则后面改字段名、改返回结构会让人非常痛苦。最后再分享一个小技巧本地开发时前端代理路径、后端接口的context-path、生产环境Nginx的location尽量保持一致都用/api开头这样开发和生产环境的基本路径永远是一致的能省掉一大半跨域和路径拼接的排查时间。这个项目后续如果想扩展可以加入真实支付回调、Redis做Session和缓存、RabbitMQ做订单超时关闭每一步的扩展成本都不高。