一到毕设季,总有学弟学妹跑来问我:有没有一个既不算太复杂、又能把前端后端技术全部串起来的项目?每次我都会把“阳光音乐厅订票系统”从仓库里翻出来当例子讲。这是一个用SpringBoot做后端、Vue做前端、MySQL存数据的完整票务管理平台,覆盖用户注册登录、演出场次展示、在线选座、下单支付、后台票务管理等一整套业务闭环。这篇文章不是给你念需求文档,而是把我从零搭这个项目时的技术选型逻辑、数据库设计取舍、核心模块的实现过程,以及最后怎么部署到Linux服务器上的完整记录,全部摊开写给你看。不管你是拿它做毕设、课设,还是单纯想学SpringBoot+Vue的实战套路,这篇文章应该能帮你少走至少半个月的弯路。
1. 项目整体设计与技术选型思路
1.1 为什么是SpringBoot+Vue这对组合
很多人刚接触课设项目时会纠结:用Servlet+JSP老一套,还是用SSM+模板引擎,又或者干脆前后端不分?我的建议很直接:如果你没有必须在老技术上做文章的硬性要求,SpringBoot+Vue是现阶段性价比最高的组合。
原因有三点。第一,前后端分离是当前企业级开发的标配,答辩时你可以明确说出“前端只负责渲染和交互,后端只提供JSON接口”这句话,面试官会认为你有工程化意识。第二,SpringBoot把人从繁重的XML配置里解放出来,内嵌Tomcat后一个java -jar就能把后端跑起来,开发体验接近“写完即启动”;Vue的组件化开发方式让页面代码组织清晰,一个选座组件可以独立维护,改起来不牵连其他页面。第三,这套组合的资料密度极大,你踩过的坑大概率别人也踩过,搜一下就能找到解决方案,对新手极其友好。
有人会担心Vue是不是应该上3代,我的看法是:如果是自己学习,Vue3+Element Plus当然没问题;如果是时间紧的毕设,Vue2+Element UI反而更稳。因为Vue2经过多年沉淀,社区插件兼容性几乎没有暗坑,Element UI的表单、表格、对话框组件足够覆盖管理后台90%的页面需求。技术选型不是越新越好,而是越顺手越好,这个原则在后面的部署环节还会被反复验证。
1.2 系统功能模块如何拆解
阳光音乐厅订票系统从使用角色上天然分成两端:面向购票者的前台,和面向运营者的后台。前台要解决的核心问题是“让用户能快速找到想看的演出,并且顺畅完成选座下单”;后台要解决的核心问题是“让管理员能对演出场次和订单状态做到可控可查”。
前台模块我拆成了五个部分:用户注册登录、演出列表与详情、场次选择、在线选座、订单中心。注册登录是一切业务的前提,我用JWT生成登录令牌,前端把token存在localStorage里;演出列表页负责展示所有上架状态的演出,详情页展示演员介绍、场次时间和价格区间;选座是系统的灵魂功能,点击某个场次后进入座位图,空闲座位可点击,已售座位不可操作;订单中心则负责订单列表、支付模拟和退票操作。
后台模块拆为四个部分:演出管理、场次管理、订单管理、用户管理。演出管理维护演出名称、海报、简介等信息;场次管理需要和座位表联动——新增场次时自动生成该场次的全部座位记录,这一条是很多课设容易漏掉的设计;订单管理支持按订单号或用户查询,并可以手动处理异常订单;用户管理则是简单的列表和角色查看,不需要开放角色编辑权限,免得给自己挖坑。
1.3 技术版本与开发环境怎么选
版本选择这块我直接给出一套我实测下来最省心的组合,背景是JDK 1.8,后端Spring Boot 2.7.x,ORM用MyBatis-Plus 3.5.x,数据库MySQL 5.7或8.0均可,前端Vue 2.7.x,组件库Element UI 2.15.x,Node环境控制在16.x。这个组合的兼容性是我踩过一轮坑之后确定下来的,比如Spring Boot 3.x强行要求JDK 17,很多同学本机装了JDK 8,直接换版本会引发连锁问题;又比如vue-cli新版本配合Node 18以上时,Node-sass这类依赖极容易安装失败,反而Node 16配合旧版本更顺滑。
| 层级 | 技术选型 | 版本建议 | 用途说明 |
|---|---|---|---|
| 后端基础 | Spring Boot | 2.7.x | 提供依赖注入、Web MVC、内嵌Tomcat |
| ORM | MyBatis-Plus | 3.5.x | 减少简单增删改查代码量,自带分页插件 |
| 鉴权 | JWT + HandlerInterceptor | jjwt 0.9.x | 无状态登录认证,退出或过期后需重新登录 |
| 前端框架 | Vue | 2.7.x | 使用Options API,资料多、学习成本低 |
| UI组件库 | Element UI | 2.15.x | 表格、表单、弹窗开箱即用 |
| 数据库 | MySQL | 5.7 / 8.0 | 存储全部业务数据,InnoDB引擎 |
开发工具方面我用的IDEA社区版加VSCode,IDEA负责Java后端,VSCode负责Vue前端,两个窗口分屏工作。数据库客户端用Navicat或DBeaver,DBeaver免费且开箱即用,对新手没有破解方面的坑。最后强调一下,全局依赖版本在项目一开始就要锁死,固守在pom.xml和package.json里,否则后期“为什么别人能跑我不能跑”的问题大多出在版本不一致上。
2. 数据库设计:订票系统的地基
2.1 核心表结构设计与关系梳理
阳光音乐厅订票系统的核心表,我最终精简为五张:用户表、演出表、场次表、座位表、订单表。在设计时要抓住一个关键业务概念:演出和场次要分开。演出是“《月光奏鸣曲》钢琴独奏音乐会”,它只有一个标题、一套介绍;但这个演出可以在12月档、1月档安排多个场次,每个场次有独立的演出时间、上架状态和票价策略。如果不拆开,你就得在演出表里重复维护标题与介绍,一旦演出信息写错,所有场次都会跟着错。
用户表t_user的字段是id、username、password、real_name、phone、role、create_time。password字段我直接存BCrypt加密后的哈希值,明文密码绝不落库,这一点不仅是安全要求,也是答辩时的加分项。role字段只有两个值:0代表普通用户,1代表管理员,不搞复杂的角色表。
演出表t_concert包含id、title、performer、poster_url、description、start_date、end_date、status、create_time。status同样是整数状态,0下架1上架,只有上架状态的演出才会出现在前台列表里。场次表t_session是核心业务桥梁,包含id、concert_id、session_time、venue、duration、status,其中concert_id外键指向演出表,一个演出对应多个场次。
座位表t_seat的粒度是整个系统里最细的,包含id、session_id、row_no、col_no、seat_type、status。这里有个设计细节:seat_type表示座位类型(VIP座、A区、B区、C区),不同票价策略实际上由场次里的seat_price_map配置,而座位本身的记录是在创建场次时批量生成的。订单表t_orders包含id、order_no、user_id、session_id、seat_id、seat_desc、price、status、create_time、pay_time,它把用户、场次、座位三个维度连成一条线,一个座位在同一场次下只能出现一条非取消状态的订单。
2.2 用状态机管理座位和订单状态
状态机这个词听起来高大上,其实逻辑非常简单:一个字段的值从哪几个状态流转到哪几个状态,是有明确规则的。座位只有三个状态:0空闲、1锁定、2已售。用户在选座页面点击一个空闲座位时,如果没有立即提交订单,这个座位先标记为1锁定,防止别人同时选中;订单创建成功后在限定时间内完成模拟支付,座位才流转为2已售;如果超时未支付,座位要回滚成0空闲。
订单的状态比座位多一个环节:0待支付、1已支付、2已取消、3已使用。下单成功后状态是0,模拟支付接口调用成功之后变成1;用户主动取消订单时状态变成2,同时释放对应座位;管理员或者系统定时任务在演出结束后,可以把已支付的订单批量置为3。理解状态机对写后端代码很有帮助,因为你可以把每个状态变化封装成独立的service方法,而不是在controller里堆if-else,代码的清晰度会显著提高。
2.3 一段真实的设计演进记录
这个项目我其实写了不止一版,第一版为了省事,把座位存成“A区1排1座”这样的字符串直接塞进订单表,还觉得挺聪明。后来做选座页面时发现问题:用户选座时我根本不知道哪些座位空闲,只能把所有订单的座位字符串捞出来在内存里比对比对。数据量小的时候还能跑,一旦场次多了,性能明显变差,而且“哪个座位属于哪个场次”的判断逻辑写起来非常痛苦。
第二次重构才引入独立的座位表。每次创建场次时,按照场馆座位布局循环插入座位记录,例如15排每排20个座位,一场就有300条记录。这样设计带来的好处是立竿见影的:点击选座时一条SQL就能查出场次下的全部座位和状态;下单时通过带条件的UPDATE语句直接锁定座位,数据库层面就能防止超卖。这是在真实开发中“先写能用版本、再重构到合理结构”的一个缩影,比一开始纸上谈兵设计半天要有效得多。
3. 后端接口设计与关键业务实现
3.1 后端项目结构与通用封装
后端我采用的是经典四层结构:controller、service、mapper、entity,再加上一个config包放配置类、一个common包放通用返回结果和异常处理。实体类对应数据库五个表,通过MyBatis-Plus的BaseMapper获得基础的增删改查能力,省掉的代码量相当可观。Mapper层主要写选座时那种自定义SQL;Service层处理业务逻辑;Controller层只做参数接收和结果返回,不做具体业务判断。
通用返回结果我封装了一个Result类,结构是{ code: 200, message: "操作成功", data: {} }。code为200时前端认为成功,非200时前端直接弹出message提示消息。这套约定在前端axios的响应拦截器里统一处理,不需要每个接口各自判断成功与否。另外还配了一个全局异常处理器@RestControllerAdvice,捕获参数校验异常、业务异常、数据库异常,把错误信息整理成统一的Result返回,前端拿到之后弹出错误提示即可。有了这两个基础封装,后面写所有接口的速度都会明显加快。
3.2 登录鉴权与权限控制怎么做
登录鉴权我没有用Spring Security,而是选择轻量方案:JWT加Spring MVC拦截器。原因很简单:这个项目的权限模型只有“用户”和“管理员”两种角色,用Spring Security虽然更“标准”,但学习成本高,配置不当反而会挡住自己。JWT的核心原理是:用户登录成功后,后端用密钥生成一个包含用户id和角色的token字符串返回给前端;前端在后续请求的Header里带上token;后端写一个拦截器,在请求进入Controller之前解析token,校验通过就放行。
代码结构上,我实现了一个AuthInterceptor实现HandlerInterceptor接口,在preHandle方法里读取Header中的Authorization字段,调用JwtUtil解析用户信息,存入ThreadLocal。然后注册到WebMvcConfigurer中,并且设置好放行名单:登录注册接口、演出列表和详情接口直接放行;订单相关接口必须登录;管理后台相关接口不仅要求登录,还要求用户角色是管理员。权限判断这一块,我采用一个简单做法:在需要管理员权限的接口上加自定义注解@RequireAdmin,拦截器里检测到该注解后校验角色字段,实现起来干净利落。
3.3 选座下单的并发控制是全场最难点
毕设答辩时老师最爱问的问题,多半围绕这一块展开:两个人同时点击同一个座位,你系统怎么保证只有一个能抢到?我的方案是用数据库乐观锁思想。具体来说,座位表中status字段是0空闲、1锁定、2已售,用户点击选座时,后端执行一条Update语句:
UPDATE t_seat SET status = 1 WHERE id = #{seatId} AND status = 0这条语句的关键在于,WHERE条件带上了status=0。执行后,MyBatis-Plus会返回受影响行数,如果返回值是1,说明这条Update成功把一个空闲座位改成了锁定状态,这个座位被你抢到了;如果返回值是0,说明座位已经被别人锁定或售出,本次选座失败。这种“原子性条件更新”在并发场景下能有效防止超卖,因为数据库的行锁机制保证同一时刻只有一条Update能真正修改这条记录,不需要引入Redis,也不需要手动加synchronized。对课设来说,这是一个性价比极高的方案,既体现了对并发的理解,又不会增加架构复杂度。
下单时需要在一个事务里完成三件事:锁定座位、创建订单、扣减余票信息。我用@Transactional注解把这个动作包在同一个事务方法内,任何一个步骤抛异常都会回滚前面已经执行的写操作,避免出现座位锁了但订单没生成的数据脏状态。注意:如果用了MyBatis-Plus,默认事务管理器是不需要额外配置的,只要你的数据源配置正确,@Transactional就能正常工作。
3.4 订单超时与票退回的实现细节
订单生成后如果用户一直不支付,座位不能一直被锁定下去,否则其他用户永远买不到这个票。我用三种方案可选,最终选了最简单可靠的定时任务方案。在Spring Boot中开启@EnableScheduling,然后写一个ScheduledTask,每隔一分钟扫描一次订单表,找出“创建时间早于当前时间三分钟,且状态为待支付”的订单,把它们的状态批量改为已取消,同时将对应的座位状态回滚为0空闲。
@Scheduled(fixedDelay = 60000) public void handleTimeoutOrders() { List<Order> expiredOrders = orderMapper.findExpiredPendingOrders(3分钟前的时间点); expiredOrders.forEach(order -> { // 1. 把订单状态修改为已取消 // 2. 把对应座位状态改回空闲 }); }这套方案虽然叫“轮询”,但它胜在朴素、无额外中间件,并且完全满足课设的业务场景。如果项目想更进一步,可以提“延迟队列”,但你需要为此引入Redis或MQ,复杂度会明显上升。我的建议是:如果回答“能不能用Redis实现”这类追问,你可以说“Redis的过期键通知机制配合监听可以做延迟释放,但会有极端情况丢通知的问题,需要补偿标记处理”,这句话能体现你对技术边界有思考。
4. 前端Vue工程化与核心页面实现
4.1 前端项目初始化和工程结构
前端我用vue-cli脚手架初始化项目。创建一个Vue2项目后,首先要安装Element UI和相关依赖,再把目录调成自己习惯的结构:src下分为api、router、views、components、utils五个目录。api目录统一存放所有接口请求函数,每个函数对应一个后端接口;router目录管理页面路由;views目录放页面级组件,比如Login.vue、ConcertList.vue、SeatSelect.vue;components目录放被复用的子组件,比如座位图组件;utils目录放axios实例和token存储等工具函数。
这套目录结构的核心价值在于“约定优于配置”,不用每次写代码前纠结文件放哪里。api目录里的请求函数命名我习惯和后端Controller接口路径保持一致,比如fetchConcertList对应GET /api/concert/list,createOrder对应POST /api/order/create,这样前后端联调时找接口特别快,几乎不用翻文档。页面组件和路由文件的关系也一目了然:新增一个页面,无非是在views里新建文件,再到router里注册一条记录。
4.2 核心页面拆解:选座页是技术含量最高的地方
前台几个页面的技术含量差别很大。注册登录页和演出列表页基本是Element UI的常规用法,重点在于和blob图片、接口返回的POST请求配合。技术含量最高的是选座页面,因为它需要把数据库里的一维座位记录渲染成直观的二维座位图。
我的实现思路是这样的:从后端一次性获取一个场次下的全部座位列表,每条记录包含rowNo、colNo、status字段;前端用computed属性把一维数组分组为二维数组seats[rowNo][colNo];渲染时外层循环遍历排数,内层循环遍历列数。每个座位的状态由status字段决定:空闲状态渲染为可点击的绿色按钮,点击后进入选中状态并变成橙色,再次点击取消;锁定状态渲染为灰色不可点击;已售状态渲染为红色且带一个小图标。用户点完座位确认后,调用下单接口把sessionId和seatId传给后端。
为了提升用户体验,我在座位图上方加了一个图例说明,绿色代表可选、橙色代表选中、灰色代表锁定、红色代表已售。这个细节看起来简单,但对于票务系统来说,没有图例的用户引导等于劝退用户。座位区域用flex布局包在可滚动容器内,适应不同大小的屏幕。管理后台里我还复用了这个选座页面,但切换为只读模式,方便管理员查看各个场次的售票情况。
4.3 axios封装与路由守卫的细节
axios如果不做封装,前端代码会陷入重复写URL、重复处理token的泥潭。我的做法是在utils/request.js中创建一个axios实例,设置baseURL为/api,并加上两个拦截器。请求拦截器统一注入token:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config })响应拦截器负责统一处理业务码和HTTP异常,如果后端返回401状态,说明token过期,直接清掉本地token并跳转登录页。这样一来,每个业务页面里的接口调用代码变得非常精简,拿选座接口举例,调用时只需要写createOrder(data).then(res => { ... }),不用关心token怎么加,也不用关心错误弹窗怎么处理。
路由守卫是另一个容易被忽略的细节。我用Vue Router的全局前置守卫beforeEach来实现两个功能:一是未登录用户跳转到需要登录的页面时,自动重定向到登录页;二是已经登录的用户访问登录页时,直接跳到首页。实现方式是在路由配置里给需要鉴权的页面加上meta: { requiresAuth: true },然后在前置守卫中判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })这个守卫看起来简单,但它是一个项目的“门禁系统”。如果没有它,前端所有“需要登录才能下单”的约束都只是一纸空文,任意用户手动改一下localStorage就能绕过后台页面。虽然真正安全的后端接口本身有拦截器校验,但前端路由守卫仍然是提升体验和防御纵深的重要一环。
5. 前后端联调、打包与部署上线
5.1 开发环境的跨域问题与反向代理配置
前后端分离开发时,最困扰新手的问题就是跨域。前端页面运行在localhost:8081,后端接口跑在localhost:8080,浏览器同源策略默认拦截这种跨端口请求。我采用的最佳实践是:后端不开启全局CORS跨域配置,而是在前端的vue.config.js里配置devServer代理。Vue开发服务器接收到前端请求后,将带/api前缀的请求转发到后端地址,前端看起来就像在请求自己的域名,浏览器不触发跨域。
具体配置很简单:
devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这个方案的好处是开发环境和生产环境可以采用同一套接口路径,只在代理层做转发。如果后端接口还没有写完,我甚至可以在proxy里加一个mock字段,或者手工给对应的URL写死返回数据,前端开发完全不依赖后端进度。注意一点:源后端的接口路径里不需要再写/api前缀,由代理层统一加,这样才能保证生产环境Nginx转发时路径规则一致。
5.2 生产构建与Linux服务器部署
当代码开发完毕后,部署上线有两个打包动作。前端在项目根目录执行npm run build,生成的静态文件存放在dist目录里。后端在项目根目录执行mvn clean package -DskipTests,生成一个可执行的jar包。我建议在打包前仔细检查后端配置文件,确认数据库连接地址生产环境是否使用独立库、日志文件路径是否可写、文件上传目录是否存在,这些细节在本地跑的时候不容易察觉,部署到服务器后一旦缺失就是一场排查大会。
服务器我选择一台轻量Linux云主机,系统CentOS 7或Ubuntu 20.04均可。部署流程大致分为五步:安装JDK1.8并配置环境变量;安装MySQL并导入数据库脚本;安装Nginx;上传jar包和dist目录;启动后端服务。启动后端我用nohup java -jar music-hall.jar > nohup.log 2>&1 &,这样关闭SSH终端后服务不会退出,日志会滚动写入nohup.log文件,后续排查问题有据可查。
5.3 Nginx配置静态资源与反向代理
Nginx的核心职责有两个:托管前端静态文件,并把/api开头的请求反向代理到后端jar包进程。配置片段如下:
server { listen 80; server_name your_domain_or_ip; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; 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 $uri $uri/ /index.html这行非常关键。因为前端路由使用了history模式,用户在浏览器里直接访问/user/orders这样的地址时,Nginx在磁盘上找不到对应文件,需要把请求回退到index.html,由前端路由接管并渲染对应页面。如果不加这一行,刷新页面或直接访问深层链接就会出现404。部署完成后,通过浏览器输入服务器IP访问首页,再完整走一遍注册、登录、浏览演出、选座、下单、后台管理的流程,确认无异常后,这套系统才算真正上线。
6. 常见问题排查与避坑实录
6.1 数据库连接与时区类问题
这类问题出现频率最高。我整理了一个速查表,几乎覆盖了我帮别人排查时遇到的所有情况。
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 启动报错Access denied for user | 数据库账号密码错误或权限不足 | 检查yml配置,给用户授权GRANT ALL ON db.* TO 'root'@'%' |
| 报错SSL connection error | MySQL 8默认开启SSL加密 | JDBC连接串加useSSL=false关闭,并指定serverTimezone=Asia/Shanghai |
| 中文插入数据库变成问号 | 数据库或表字符集不是utf8mb4 | 建库语句显式指定DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci |
| 插入当前时间差8小时 | JDBC时区未处理,服务器与本地时区不一致 | 连接串加serverTimezone=Asia/Shanghai,Linux执行timedatectl set-timezone Asia/Shanghai |
另外一个我特别提醒的点是MySQL驱动版本。如果你用的Spring Boot 2.7.x,依赖管理的驱动会自动是8.0.x,驱动类名要写com.mysql.cj.jdbc.Driver;如果你从老项目复制了一段基于5.x的配置,驱动类名写的是com.mysql.jdbc.Driver,启动时会报找不到类的异常。这类问题定位很容易,但新手往往会在网上越搜越乱,建议第一时间检查和后端版本关联的配置。
6.2 前端开发调试过程的高频坑
前端的问题主要集中在三个方面。第一个是Element UI的按需引入和全量引入的区别,我在项目里用了全量引入方式,虽然包体积大一些,但省心,避免某个组件忘记注册导致页面空白。第二个是axios拦截器导致的偶发登录失效:如果token在localStorage里的key值和后端要求的不一致,或者前端存储了过期token,请求会被拦截器放行但后端校验失败。我的解决办法是统一封装token的存取方法,不散落在各个组件里。
第三个是Vue打包后页面白屏的问题。这个坑往往出在publicPath上,vue-cli默认publicPath是/,如果你的前端文件没有部署在服务器根路径下,而是放在了某个子目录,那么静态资源的路径就会全部失效。我的方案是统一部署在根路径,并且在Nginx里配置好根目录指向dist目录,基本避免这个问题的出现。如果确实要部署在子路径,就要显式设置publicPath为对应的子路径前缀。
6.3 部署上线后的运维小技巧
服务上线后,有些小技巧能让你省下大量排查时间。第一个是养成看日志的习惯。后端看nohup.log,前端看浏览器Console和Network,大部分问题都会在控制台里留下线索。启动失败时用java -jar加--debug参数能输出更详细的日志。第二个是一定要设置好防火墙的安全组规则,只开放80端口和SSH端口,8080端口不要直接暴露给公网,否则接口可能被扫描到并被恶意调用。第三个是定期备份数据库,即使这是个课设项目,也可以用mysqldump做一个简单的定时备份脚本,这个习惯养成了对以后工作也是加分项。
最后一个建议给正在熬夜改Bug的你:这个项目里最有价值的东西不是那几千行代码,而是你踩过坑之后建立起来的排查思路。比如“先看报错信息、再查配置、最后才怀疑代码逻辑”的路径,很多人头两次做项目时是反着来的。把这套思考方式记录下来,比源码本身珍贵得多。我当年第一次部署这个项目时,光是解决一个MySQL时区问题就折腾了两天,现在回头看,那两天换来的是对数据源配置、JDBC驱动、Linux时间体系三个知识点的透彻理解。所以别怕踩坑,坑踩得越深,你学到的东西才越牢。