做一个项目之前,我习惯先把技术栈的风险清单拉出来过一遍,越早暴露越好。今天要聊的这个音乐厅订票系统,在技术上其实没有特别新鲜的亮点,它的价值在于"全链路"——从用户注册、场次查询、选座、下单支付、出票到后台管理、数据统计,整条业务线在Spring Boot+Vue+MyBatis+MySQL这套组合下如何落地,中间有多少坑、多少设计取舍。这篇文章我不打算给你讲原理课,而是按我实际开发的顺序,把关键环节的设计思路、核心代码、踩过的坑一次说清楚,适合正在做毕业设计、初级工程师想系统了解企业级项目分层,或者想快速上手一套完整业务系统源码的朋友参考。
1. 项目整体架构设计与技术选型思路
1.1 技术栈组合背后的实际考量
很多人一拿到"企业级"三个字就紧张,觉得肯定要上微服务、中间件堆满。实际上,音乐厅订票这类中型业务系统,单机部署、前后端分离、经典三层架构已经能扛住绝大多数场景。选Spring Boot是因为它把配置收敛得非常好,内嵌Tomcat,一个jar包就能跑起来,配合Actuator做健康检查,部署成本很低。Vue负责前端交互,组件化开发让选座、抢票这类高频交互页面不至于把逻辑全塞在一个文件里。MyBatis则是对SQL有完全掌控力的持久层框架,订票系统里大量动态查询、多表联查,用MyBatis的XML映射比JPA的自动生成SQL更直观、更好调优。
这套组合真正的优势在于"招聘成本低、出活快"。我见过的很多团队,Spring Boot+MyBatis几乎是标配,新人上手周期基本控制在一周以内。MySQL作为存储层,既能满足事务需求(订单、支付、库存扣减都依赖ACID),又不需要引入额外的分布式组件,在数据量没有达到千万级之前,它都是性价比最高的选择。
1.2 数据库设计核心要点:六张核心表撑起整条业务
订票系统的业务链路很清晰:用户浏览演出、选择场次座位、创建订单、模拟支付、生成电子票、入场核销。根据这个链路,我设计了一套六张核心表的骨架:
- user:用户表,字段包含id、username、password(BCrypt加密)、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串起来,麻烦。在这个项目里我用JWT(JSON Web Token)+Spring Security做无状态认证。用户登录成功后,服务端签发一个token,返回给前端,前端每次请求把它放到HTTP Header的Authorization里即可。
核心逻辑不复杂,但有一个细节容易踩坑:token过期时间设置。音乐厅订票用户可能在购票过程中停留较久,我把token有效期设成2小时,刷新token有效期设成7天,用户在购票中途不至于被强制踢下线。代码层面,网关或拦截器里校验token的有效性,同时从token中解析user_id塞到请求上下文。这样业务代码里随时可以拿到当前登录用户,不用每次都查库。实际项目里也不要傻傻地把密码明文存在数据库,我统一用BCrypt加密,同一个人不同次注册产生的密文都不一样,防彩虹表。
2.2 场次与座位管理:一点不简单的数据关系设计
座位管理是音乐厅跟普通商品管理最大的区别。普通商品一个SKU就是一条记录,座位却要维护"排-列-区域-票价"四维信息。我看过不少半成品项目,直接把座位状态存成一个大JSON字符串,查座位的时候全量解析,改状态的时候全量更新,性能很差。
这个项目里我采用的方案是:用座位表独立记录每个座位的状态。这么做的好处是精确锁定座位时只需要UPDATE一条记录,配合条件语句控制并发。比如选座接口执行类似这样的SQL:
UPDATE 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 id="selectOrderList" resultType="com.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 test="status != null and status != ''"> AND o.status = #{status} </if> <if test="performanceName != null and performanceName != ''"> AND p.name LIKE CONCAT('%', #{performanceName}, '%') </if> <if test="startTime != null"> AND o.create_time >= #{startTime} </if> <if test="endTime != null"> AND o.create_time <= #{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,我建议直接学Vue3+Composition API,不用再掉进Vue2的坑里。项目初始化我用的是官方命令行:
npm create vite@latest sunshine-front -- --template vue cd sunshine-front npm install npm install vue-router@4 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以上版本连接时有个经典坑,就是useSSL=false这个参数在连接串里最好显式声明,否则会报SSL连接错误。JDBC连接串我建议这样写:
jdbc:mysql://localhost:3306/sunshine_ticket?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=trueserverTimezone=Asia/Shanghai是必填的,否则插入时间字段时默认用UTC,看到的时间比本地少8小时。allowPublicKeyRetrieval=true在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。多线程环境下大家查到的都是status=0,于是同时进入更新逻辑,后更新的覆盖先更新的,"乐观逻辑"变成了"超卖漏洞"。
修复方式就是前面讲的:把"查询再更新"改成一条条件UPDATE,让数据库行锁去保证原子性。50个并发请求真正执行UPDATE的时候,第一个请求成功后,剩余49个请求的WHERE status = 0条件都不满足,受影响行数为0,回滚。就是这一个小小的SQL范式改变,从根上解决了超卖问题。
另外还有一个细节:订单创建和座位锁定要在同一个事务里,否则用户在支付页面上停留时间过长,座位被定时任务释放,他支付成功后却发现座位没了。建议是:座位锁定的释放时间要和订单的支付超时时间对齐,我设置的是订单30分钟过期,座位锁定8分钟,看起来有错差,实际上座位锁定会在用户下单时重新刷新,不会让正在支付的用户丢失座位。
4.3 高频报错速查表:你大概率也会碰到
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 接口返回404但代码没问题 | 前端请求路径少了一层上下文 | AbstractAnnotationConfigDispatcherServletInitializer配置中setContextPath或Nginx反向代理路径对齐 |
| MySQL连接报Public Key Retrieval is not allowed | 驱动版本与认证模式 | 连接串加allowPublicKeyRetrieval=true |
| 前后端联调跨域报CORS | 端口不同 | 后端加@CrossOrigin或配置CORS Filter,生产环境用Nginx同源代理 |
| 更新数据总是不生效 | 方法上没有加@Transactional | 检查事务注解,注意异常被try/catch吞掉则不能回滚 |
| 查询列表时带上了登陆人的数据 | 缓存了查询条件 | 每次查询用新对象接收参数,别复用全局变量 |
| Vite启动报digital envelope | Node版本与OpenSSL | Node升到18+,或设置NODE_OPTIONS=--openssl-legacy-provider |
这张表我建议你直接存着,实际开发里这些报错出现频率远高于代码本身。
5. 项目扩展建议与经验收尾
如果你拿到这套源码,不只是跑起来就完事,我强烈建议你在上面做几个扩展练习,含金量不低。
第一个扩展是缓存优化。当前版本座位查询还是直接走MySQL,如果同一场次热点比较高,可以引入Redis缓存场次信息,缓存击穿时用互斥锁重建,缓存失效时把请求挡在数据库前面。这比单纯加索引效果好得多。
第二个扩展是支付流程。当前是模拟支付,你可以对接支付宝/微信沙箱环境,把支付回调的异步通知处理写一遍。这里有一个关键点:支付回调一定要做幂等处理,用订单号做唯一约束,重复回调不会重复改单、不会重复发电子票。
第三个扩展是统计报表。管理端可以做每日票房统计、热门场次排行、用户复购分析。SQL层面用GROUP BY和窗口函数能写出很漂亮的报表,Vue端配合ECharts渲染折线图、柱状图,整套系统的逼格立刻上一个档次。
我个人在实际开发这套系统过程中最大的感受是:技术选型稳一点比炫一点重要,Spring Boot+Vue+MyBatis+MySQL这套组合本身没有天花板,天花板在业务理解上——座位怎么锁、状态怎么流转、超时怎么兜底,这些才是项目的灵魂。希望这篇拆解能让你少走几个弯路,把时间和精力花在真正提升系统价值的地方。