
做一个项目之前我习惯先把技术栈的风险清单拉出来过一遍越早暴露越好。今天要聊的这个音乐厅订票系统在技术上其实没有特别新鲜的亮点它的价值在于全链路——从用户注册、场次查询、选座、下单支付、出票到后台管理、数据统计整条业务线在Spring BootVueMyBatisMySQL这套组合下如何落地中间有多少坑、多少设计取舍。这篇文章我不打算给你讲原理课而是按我实际开发的顺序把关键环节的设计思路、核心代码、踩过的坑一次说清楚适合正在做毕业设计、初级工程师想系统了解企业级项目分层或者想快速上手一套完整业务系统源码的朋友参考。1. 项目整体架构设计与技术选型思路1.1 技术栈组合背后的实际考量很多人一拿到企业级三个字就紧张觉得肯定要上微服务、中间件堆满。实际上音乐厅订票这类中型业务系统单机部署、前后端分离、经典三层架构已经能扛住绝大多数场景。选Spring Boot是因为它把配置收敛得非常好内嵌Tomcat一个jar包就能跑起来配合Actuator做健康检查部署成本很低。Vue负责前端交互组件化开发让选座、抢票这类高频交互页面不至于把逻辑全塞在一个文件里。MyBatis则是对SQL有完全掌控力的持久层框架订票系统里大量动态查询、多表联查用MyBatis的XML映射比JPA的自动生成SQL更直观、更好调优。这套组合真正的优势在于招聘成本低、出活快。我见过的很多团队Spring BootMyBatis几乎是标配新人上手周期基本控制在一周以内。MySQL作为存储层既能满足事务需求订单、支付、库存扣减都依赖ACID又不需要引入额外的分布式组件在数据量没有达到千万级之前它都是性价比最高的选择。1.2 数据库设计核心要点六张核心表撑起整条业务订票系统的业务链路很清晰用户浏览演出、选择场次座位、创建订单、模拟支付、生成电子票、入场核销。根据这个链路我设计了一套六张核心表的骨架user用户表字段包含id、username、passwordBCrypt加密、phone、email、status、create_time。performance演出场次表存演出名称、场馆、演出时间、开场时间、票价梯度可拆表这里用price_level字段存储JSON粗粒度配置。seat座位表关联performance_id记录排号、列号、座位区域、原价、折扣状态、锁定状态。座位数多的场次可以按区域分区。orders订单表关联user_id和performance_id包含订单号、总金额、状态待支付/已支付/已取消/已退款、创建时间、支付时间。order_detail订单明细表记录每个座位级别的价格快照、座位编号方便后续出票。ticket电子票表关联订单和座位包含唯一的票号、核销状态、核销时间、入场时间。这套表的整体逻辑是场次驱动座位、订单驱动票务。座位不和用户直接绑定而是被订单引用这样设计的好处是同一场演出可以支持退票后重新释放座位票务状态流转更干净。设计时注意订单号不要用数据库自增id业务上展示的订单号建议用时间戳随机数片段拼避免泄露真实订单量同时便于分表。1.3 后端工程的目录分层模板我见过太多把所有类都塞在controller和service两个包里的项目短期看着爽加需求的时候想哭。这个项目的目录结构我是按功能层次双维度切的com.sunshine.ticket ├── controller // 接口层只做参数接收和结果组装 ├── service // 业务层事务边界就在这里 ├── mapper // MyBatis接口 ├── entity // 数据库实体 ├── dto // 请求/响应模型避免实体直接暴露给前端 ├── vo // 视图对象比如订单详情VO、场次座位图VO ├── config // 配置类跨域、拦截器、全局异常、Redis等 ├── common // 统一返回结果、枚举、常量、异常体系 └── utils // 工具类JWT、日期、Bean拷贝等controller层尽量保持瘦一个接口只拼装参数和调用service不写业务逻辑。service层承担校验、事务、编排的职责。我在这个项目里专门把事务边界放在service原因很简单一个下单动作要同时更新座位状态、插入订单表、生成票号跨DAO操作必须一个事务要么全成功要么全回滚否则会出现座位锁了、订单却丢了这种让用户血压飙升的问题。2. 后端核心模块实操解析2.1 用户认证为什么我选了JWT而不是Session企业级系统最基础的是认证授权。用Session有个痛点分布式部署时要引入Session共享得配Redis把Session串起来麻烦。在这个项目里我用JWTJSON Web TokenSpring Security做无状态认证。用户登录成功后服务端签发一个token返回给前端前端每次请求把它放到HTTP Header的Authorization里即可。核心逻辑不复杂但有一个细节容易踩坑token过期时间设置。音乐厅订票用户可能在购票过程中停留较久我把token有效期设成2小时刷新token有效期设成7天用户在购票中途不至于被强制踢下线。代码层面网关或拦截器里校验token的有效性同时从token中解析user_id塞到请求上下文。这样业务代码里随时可以拿到当前登录用户不用每次都查库。实际项目里也不要傻傻地把密码明文存在数据库我统一用BCrypt加密同一个人不同次注册产生的密文都不一样防彩虹表。2.2 场次与座位管理一点不简单的数据关系设计座位管理是音乐厅跟普通商品管理最大的区别。普通商品一个SKU就是一条记录座位却要维护排-列-区域-票价四维信息。我看过不少半成品项目直接把座位状态存成一个大JSON字符串查座位的时候全量解析改状态的时候全量更新性能很差。这个项目里我采用的方案是用座位表独立记录每个座位的状态。这么做的好处是精确锁定座位时只需要UPDATE一条记录配合条件语句控制并发。比如选座接口执行类似这样的SQLUPDATE seat SET status 1, lock_time NOW() WHERE id #{seatId} AND performance_id #{performanceId} AND status 0因为UPDATE语句本身是行锁只有同时抢同一个座位的第一个请求能更新成功后一个请求受影响行数为0直接判定座位已被占。这里就不需要引入Redis分布式锁在高并发但座位粒度隔离的场景下行锁条件更新是效率最高的方案。锁座位的时间不宜过长我给它加了一个8分钟的倒计时超时后通过定时任务把status-1超时释放的记录恢复成0。2.3 订单流程与库存扣减把先锁座、后支付的闭环跑通订单模块的核心不是CRUD是状态机和一致性。我把订单状态定义成枚举PENDING待支付、PAID已支付、CANCELLED已取消、REFUNDING退款中、REFUNDED已退款。用户从选座到支付的流转是创建订单时把座位表改成锁定状态订单表插入PENDING记录用户支付成功后把座位状态改成已售订单改成PAID生成电子票记录。若30分钟内未支付系统自动把订单置为CANCELLED同时把座位状态恢复为待售。在service层实现时我在下单方法上加了Transactional(rollbackFor Exception.class)注意这里有第二个容易踩的坑事务只在同一个线程内生效。如果你在事务方法内部用Async注解去发短信或做异步通知那个异步线程是不受当前事务管理的。所以我的做法是先提交事务再发通知。用小技巧规避用TransactionSynchronizationManager.registerSynchronization注册事务提交后的回调确保事务成功后才有后续动作。2.4 MyBatis动态SQL实战能用但别乱用MyBatis最强大的能力是动态SQL但很多人用成了灾难——把一堆if堆在XML里读起来像天书。我的原则是能拆查询就拆查询能用代码控制流转就别硬上动态SQL。比如后台管理系统要支持按演出名称、状态、时间范围筛选订单这种查询条件组合较多的场景动态SQL确实是最合适的select idselectOrderList resultTypecom.sunshine.ticket.vo.OrderVO SELECT o.order_no, o.total_amount, o.status, p.name AS performance_name, p.start_time, u.username FROM orders o LEFT JOIN performance p ON o.performance_id p.id LEFT JOIN user u ON o.user_id u.id where if teststatus ! null and status ! AND o.status #{status} /if if testperformanceName ! null and performanceName ! AND p.name LIKE CONCAT(%, #{performanceName}, %) /if if teststartTime ! null AND o.create_time gt; #{startTime} /if if testendTime ! null AND o.create_time lt; #{endTime} /if /where ORDER BY o.create_time DESC /select注意几个细节一是where标签能自动去掉多余的AND但条件判断里一定要判空否则会查出不该查的数据二是表别名统一多表查的时候能少写很多代码三是分页不要自己写LIMIT字面值配合PageHelper插件页面参数从controller层传pageNum、pageSize底层自动拦截改写SQL简单可靠。3. 前端Vue页面与接口联调3.1 脚手架搭建Vite比webpack轻快太多了音乐厅订票系统的前端我选的是Vue3全家桶用Vite作为构建工具。如果你刚接触Vue我建议直接学Vue3Composition API不用再掉进Vue2的坑里。项目初始化我用的是官方命令行npm create vitelatest sunshine-front -- --template vue cd sunshine-front npm install npm install vue-router4 axios pinia element-plusElement Plus作为后台管理界面的UI库选座页面则完全自定义用CSS Grid布局模拟音乐厅的扇形座位分布。整个前端拆成了两个端用户端小程序风格/Web端和管理端。管理端采用经典的侧边栏顶栏布局路由结构是懒加载模式每个页面组件的资源只在使用时加载首屏快不少。3.2 页面拆分与组件复用选座页是重头戏选座页是整个系统前端最复杂的部分也是用户感知最强的部分。我把它拆成SeatMap.vue和SeatItem.vue两层组件。SeatMap负责根据接口返回的座位布局数据渲染整个座位矩阵SeatItem只负责单个座位的外观状态。座位有四种状态可用、已售、选中、锁定被其他人占座。交互逻辑是点击可用座位 - 状态切换为选中加入待选列表再次点击已选中座位 - 取消选中点击已售或锁定座位 - 弹出提示。选座结束进入确认订单页前端把选中的seatId列表一次性提交给后端后端完成锁定和订单创建。这里前端要做一层兜底短时间内点击同一个座位两次时要防止重复提交。我用一个selectedMap对象记录选中状态提交时冻结按钮并加loading状态等后端返回结果再恢复交互避免用户疯狂点击导致重复下单。3.3 Axios封装与权限拦截统一处理token和错误码单独每个页面写一次axios调用那是重复造轮子我封装了一个request.js模块统一设置BaseURL、请求超时时间、请求头。核心逻辑集中在拦截器里// 请求拦截器 service.interceptors.request.use(config { const token localStorage.getItem(access_token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器 service.interceptors.response.use( response { const res response.data // 约定后端统一返回 { code, message, data } if (res.code ! 200) { ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message || 网络异常请稍后重试) return Promise.reject(error) } )这样所有业务页面里只需要request.get(/order/list)然后拿数据渲染管token、管报错提示、管登录态失效跳转都在一个地方处理完了。管理端的权限控制再叠一层路由守卫根据不同角色管理员、普通用户动态生成可访问的路由表前端在全局前置守卫里比对后执行跳转或拦截。4. 项目部署与常见问题排查4.1 本地环境搭建避坑指南这类型号项目最烦人的是环境问题很多人在环境上耗了两天代码一行没看。先说数据库MySQL 8.0以上版本连接时有个经典坑就是useSSLfalse这个参数在连接串里最好显式声明否则会报SSL连接错误。JDBC连接串我建议这样写jdbc:mysql://localhost:3306/sunshine_ticket?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai是必填的否则插入时间字段时默认用UTC看到的时间比本地少8小时。allowPublicKeyRetrievaltrue在MySQL 8.0以上经常需要加否则客户端第一次连库会报Public Key Retrieval is not allowed。前端环境有个隐蔽问题是Node和Vite版本不匹配。Vite5要求Node18以上如果还在用老Node14启动大概率直接报错。遇到类似Cannot find module vite或者error:0308010C:digital envelope routines::unsupported第一反应就是查Node版本和依赖版本。4.2 座位超卖与并发压测实录说一个我在测试环境真实遇到的并发问题。用JMeter开了50个线程同时选中同一场次的同一个座位最终来了三单。原因我当时定位了半天我的锁座接口流程是先查询座位的状态判断为0再执行UPDATE。多线程环境下大家查到的都是status0于是同时进入更新逻辑后更新的覆盖先更新的乐观逻辑变成了超卖漏洞。修复方式就是前面讲的把查询再更新改成一条条件UPDATE让数据库行锁去保证原子性。50个并发请求真正执行UPDATE的时候第一个请求成功后剩余49个请求的WHERE status 0条件都不满足受影响行数为0回滚。就是这一个小小的SQL范式改变从根上解决了超卖问题。另外还有一个细节订单创建和座位锁定要在同一个事务里否则用户在支付页面上停留时间过长座位被定时任务释放他支付成功后却发现座位没了。建议是座位锁定的释放时间要和订单的支付超时时间对齐我设置的是订单30分钟过期座位锁定8分钟看起来有错差实际上座位锁定会在用户下单时重新刷新不会让正在支付的用户丢失座位。4.3 高频报错速查表你大概率也会碰到现象原因解决方案接口返回404但代码没问题前端请求路径少了一层上下文AbstractAnnotationConfigDispatcherServletInitializer配置中setContextPath或Nginx反向代理路径对齐MySQL连接报Public Key Retrieval is not allowed驱动版本与认证模式连接串加allowPublicKeyRetrievaltrue前后端联调跨域报CORS端口不同后端加CrossOrigin或配置CORS Filter生产环境用Nginx同源代理更新数据总是不生效方法上没有加Transactional检查事务注解注意异常被try/catch吞掉则不能回滚查询列表时带上了登陆人的数据缓存了查询条件每次查询用新对象接收参数别复用全局变量Vite启动报digital envelopeNode版本与OpenSSLNode升到18或设置NODE_OPTIONS--openssl-legacy-provider这张表我建议你直接存着实际开发里这些报错出现频率远高于代码本身。5. 项目扩展建议与经验收尾如果你拿到这套源码不只是跑起来就完事我强烈建议你在上面做几个扩展练习含金量不低。第一个扩展是缓存优化。当前版本座位查询还是直接走MySQL如果同一场次热点比较高可以引入Redis缓存场次信息缓存击穿时用互斥锁重建缓存失效时把请求挡在数据库前面。这比单纯加索引效果好得多。第二个扩展是支付流程。当前是模拟支付你可以对接支付宝/微信沙箱环境把支付回调的异步通知处理写一遍。这里有一个关键点支付回调一定要做幂等处理用订单号做唯一约束重复回调不会重复改单、不会重复发电子票。第三个扩展是统计报表。管理端可以做每日票房统计、热门场次排行、用户复购分析。SQL层面用GROUP BY和窗口函数能写出很漂亮的报表Vue端配合ECharts渲染折线图、柱状图整套系统的逼格立刻上一个档次。我个人在实际开发这套系统过程中最大的感受是技术选型稳一点比炫一点重要Spring BootVueMyBatisMySQL这套组合本身没有天花板天花板在业务理解上——座位怎么锁、状态怎么流转、超时怎么兜底这些才是项目的灵魂。希望这篇拆解能让你少走几个弯路把时间和精力花在真正提升系统价值的地方。