做过课程设计和毕业设计的同学,看到“SSM412”这种编号应该会心一笑——这就是当年我在蚌埠学院做项目时的归档编号。这个编号底下存的是一个基于SSM加Vue的电影院在线售票系统,用今天的话说,就是一套前后端分离的Web全栈小项目。前后端的技术栈选择很经典:后端用Spring、SpringMVC、MyBatis,前端用Vue全家桶,数据库用MySQL。这类项目最大的价值不在于技术多新,而在于它把企业级系统里常见的那套东西,用最精简的方式串了起来,非常适合用来做课设、毕设,或者作为练手的第一个完整全栈项目。
我实际做完之后的体会是,这个系统麻雀虽小五脏俱全。它覆盖了用户端从浏览电影、选择场次、在线选座、下单支付的全流程,也覆盖了管理端从电影排片、影厅管理、订单管理到基础数据维护的一整套闭环。对想通过一个真实项目把SSM后端和Vue前端真正打通的人来说,这个选题非常合适。下面我会把整个项目的设计思路、数据库建模、后端接口、前端实现和常见坑点全部拆开讲一遍,全程基于我实际做过、踩过的真实经历。
1. 先拆需求:电影院售票系统到底要解决什么问题
1.1 核心业务流程梳理
做这类系统最忌讳一上来就写代码。我的习惯是先把业务流程画一遍,站在用户的角度走一遍完整的购票链路:用户打开系统首页,看到正在热映的电影列表,点进某部电影后能看到它的详情介绍、预告片和两三天内的排片场次,选一个合适的场次后进入影厅座位图,挑好座位提交订单,支付成功后生成电子票。这条主链路跑通,项目已经完成百分之八十的工作量。
管理端的业务流程相对简单但更加繁琐:排片员要维护电影信息、影厅信息、每日场次和票价,还要处理用户下单后释放座位、订单超时这些状态变化。用户端和管理端看似是两个界面,但背后的业务数据必须完全一致。这也是我最后选择前后端分离架构的直接原因——管理端做成独立页面,通过权限字段控制入口,用户端界面保持简洁干净,两边通过同一套接口访问数据库。
1.2 角色与功能边界划分
这个系统我分成了三个角色:普通用户、售票员、系统管理员。普通用户能做的操作是注册登录、浏览电影、查看影厅座位、下单支付、查看历史订单;售票员负责电影信息导入和排片管理,说白了一个电影上映之后,在哪个厅放、每天放几场、票价定多少,都由这个角色控制;系统管理员则掌握着最大权限,可以管理用户账号、查看全部订单、统计票房数据。
很多同学在课设里会忽略角色划分,把所有操作堆在一个页面上,答辩时老师一问“谁的权限是什么”就答不上来。我这里特意把权限做成了一张user_role表,用普通数字区分角色等级,前端拿到角色后决定显示哪些菜单和按钮,后端则在每次请求里校验当前登录用户的角色,双重保障。
1.3 技术选型:为什么选了SSM而不是SpringBoot
团队这两年其实已经全面转向SpringBoot了,我做这个项目时也犹豫过要不要直接用Boot。但后来想通了:SSM虽然配置繁琐,但它能逼你把Spring容器、SpringMVC请求流程、MyBatis映射关系逐个手写一遍,把底层机制搞明白。SpringBoot给你做好了一切,反而少了这个过程。我用SSM做完整个后端以后,再去接触Boot的自动配置就很容易理解——很多注解只是帮你省掉了原先那些XML配置。
Vue作为前端框架是另一个维度的选择。影院售票界面对交互性要求高,尤其是座位选择页,需要动态渲染二维座位图并响应点击事件,如果用传统的jQuery模板拼接会非常痛苦。Vue的响应式数据绑定和组件化能很自然地管理座位状态、订单对象、用户信息这些公共数据。我在开发时把座位区域、电影卡片、订单流程都抽成了独立组件,后期修改某个模块完全不影响其他部分。
2. 数据库设计是整个系统的地基
2.1 七张核心表的建模思路
数据库设计直接决定后面开发的顺不顺。这个项目我用到了七张核心表:users用户表、films电影表、halls影厅表、sessions场次表、sessions_seats座位状态表、orders订单表、order_items订单明细表。表与表之间的关系很清晰:一个电影对应多个场次,一个影厅对应多个场次,一个场次对应多个座位状态记录,一个订单属于一个用户且包含多个订单明细。
以users表为例,我保留了最基本的id、username、password、phone、role、create_time六个字段,密码使用MD5加密后再存库。films表则包含title、cover、duration、director、actors、description、status等字段。这里一个取值区间要特别注意:status字段我设计为0代表未上映、1代表正在上映、2代表已下映,首页只查询status为1的数据,排片员在后台操作上下映。
2.2 座位与订单一对多的状态联动
sessions_seats是这套设计的核心表,字段包括id、session_id、row_no、col_no、seat_no、status。status字段是整个系统的灵魂——我用0代表座位可售,1代表已被锁定正在支付中,2代表已售出,3代表影厅过道或维修座位。为什么不用单独一张表记录已卖出的座位,而是将座位状态直接内嵌在场次座位表中?因为这样最有利于并发控制。
具体逻辑是这样的:用户选中座位后,后端执行一条UPDATE语句,将status从0改为1,同时带上session_id和seat_no的条件。MyBatis返回的影响行数如果为1,说明抢座成功;如果为0,说明这个座位已经被别人锁定,直接提示座位不可选。这种基于数据库行锁的方式,比纯前端控制靠谱得多,也方便实现订单超时释放。
orders表记录的是订单主信息,包含order_no、user_id、session_id、total_amount、status、create_time、pay_time。status同样区分多个状态:0未支付、1已支付、2已完成、3已取消。订单明细表order_items则记录每一张电影票的具体信息,包括场次、座位、单价和数量。两张表用order_no关联,查询用户历史订单时先查主表再查子表。
2.3 设计阶段踩过的坑
这里必须说几个我实际踩过的坑。第一,外键约束不要加太多。刚开始我为了“严谨”在每个表之间都加外键,结果测试时删除一条电影数据会被订单表挡住,每次都要先删除关联数据,特别麻烦。后来为了演示方便,我去掉了大部分物理外键,改由MyBatis在代码层维护关联关系。课设项目里这样做完全没问题。第二,所有时间字段我统一用datetime而不是timestamp。timestamp有2038年的限制,而且受时区影响,datetime在直接写SQL时更省心。第三,订单号我用了时间戳加随机数的组合:order_no长度20位,前14位是当前时间,后6位是随机数字,避免了并发下单时订单号重复。
3. 后端SSM框架搭建与接口设计
3.1 Maven项目结构与SSM配置要点
后端项目我按标准Maven结构搭建:src/main/java存放Java源码,按照controller、service、mapper、entity四层分包,src/main/resources存放配置文件,其中spring-mvc.xml、spring-mybatis.xml、jdbc.properties、log4j.properties各司其职。SSM配置有几个关键点:spring-mybatis.xml里要配置数据源、SqlSessionFactory和MapperScannerConfigurer,让MyBatis自动扫描mapper接口;spring-mvc.xml里必须配置注解驱动、Controller扫描和静态资源放行,否则前端页面会全部被DispatcherServlet拦下来。
配置静态资源放行这个坑特别经典。我第一次部署时把config里的/*.html和.js、.css都拦截了,后端启动正常但访问页面就是404。后来在spring-mvc.xml加了下面这段才解决。
<mvc:resources location="/static/" mapping="/static/**" /> <mvc:annotation-driven />还有一个容易忽略的点是事务管理器。售票系统里下单并扣减座位状态必须放在同一个事务里,否则可能订单创建成功了座位却没锁住。我在spring-mybatis.xml里配置了DataSourceTransactionManager,并在OrderServiceImpl类的lockSeatsAndCreateOrder方法上标注@Transactional,让整个方法成为原子操作,任何一个环节失败都会整体回滚。
3.2 核心接口清单与返回格式约定
后端接口我统一使用RESTful风格,返回JSON格式数据。这里只列一下最核心的接口,方便大家参考:
| 功能描述 | 请求方式 | 接口路径 |
|---|---|---|
| 获取正在热映的电影列表 | GET | /api/films/releasing |
| 获取电影详情 | GET | /api/films/{id} |
| 获取某场次的排片信息 | GET | /api/sessions/movie/{filmId} |
| 获取某场次的座位图 | GET | /api/sessions/{id}/seats |
| 锁定座位并创建订单 | POST | /api/orders/lock |
| 支付订单 | POST | /api/orders/{orderNo}/pay |
| 获取用户历史订单 | GET | /api/orders/user/{userId} |
返回格式我统一封装为一个Result类,包含code、msg、data三个字段。code为200表示成功,其他值对应业务错误,比如50001代表座位已被锁定。这种约定让前端axios拦截器能够统一处理所有异常情况,而不需要在每个请求回调里单独判断。
3.3 选座防并发和订单超时释放
锁定座位的方法值得展开细讲。用户提交锁定请求后,后端首先遍历用户提交的座位编号集合,对每个座位执行更新操作,SQL大致如下:
UPDATE sessions_seats SET status = 1 WHERE session_id = #{sessionId} AND seat_no = #{seatNo} AND status = 0如果更新影响行数等于座位数量,说明全部锁定成功,继续创建订单;如果某个座位更新影响行数为0,说明有人先手一步,此时立即抛出业务异常,并释放本次已经锁定的其他座位,避免产生僵尸数据。
订单超时释放是我最初遗漏的功能。用户锁座后如果一直不支付,座位永远处于锁定状态,对真实业务来说不可接受。我这里用了一个最简单的方案:在OrderMapper里编写查询超过十五分钟仍未支付的订单,再由一个调度线程定期执行释放操作,把订单状态改为已取消,同时把对应座位状态改回可售。没有引入Redis和消息队列,因为这个量级的课设项目用数据库定时轮询完全足够,而且代码量少好理解。
3.4 日期和金额处理的细节问题
后端与前端交互时,日期序列化和金额计算最容易出问题。Jackson默认会把java.util.Date序列化为时间戳数字,前端拿到没法直接展示,我在spring-mvc.xml里配置了日期格式化器,统一输出成yyyy-MM-dd HH:mm:ss格式。金额字段我直接用BigDecimal类型接收和计算,避免使用double带来精度误差;数据库中金额字段设计为DECIMAL(10,2),既满足精度要求又方便报表统计。
4. 前端Vue侧实现细节
4.1 环境准备和项目初始化
前端部分我是用Vue CLI创建的项目。这里有版本选择的问题:我使用的是Vue 2.6加Vue CLI 4版本,配合Element UI组件库。之所以不用Vue 3,是因为当时Element Plus还不够稳定,而且网上的SSM配合Vue的参考资料大部分都是Vue 2的写法。实际运行下来Vue 2的生态最成熟,适合课设周期比较短的项目。
Node版本需要注意,当时Vue CLI对Node版本有要求,我在开发机上遇到过npm run serve报错的情况,后来把Node从16降到14就正常了。初始化命令很简单:
vue create cinema-frontend选择预设时我勾选了Router和Vuex,CSS预处理器选择了SCSS,其他保持默认。项目创建完成后先整理目录结构,主要分成views、components、router、store、api五个目录。
4.2 目录结构与组件划分
views目录按页面拆分:HomeView.vue是电影列表首页,FilmDetail.vue是电影详情和场次选择,SeatSelect.vue是选座页面,OrderConfirm.vue是订单确认页,PayResult.vue是支付结果页,LoginView.vue和RegisterView.vue负责登录注册,管理端的FilmManage.vue、SessionManage.vue、OrderManage.vue三个页面放在sub目录里。components目录则放置可复用的公共组件,比如SeatMap.vue座位图组件、FilmCard.vue电影卡片组件、HeaderBar.vue顶部导航栏。
组件划分有个原则:页面级组件只负责数据获取和交互事件,展示细节交给子组件。比如SeatMap组件只接收一个座位数组作为props,再通过emit向外抛出选中座位的事件,父组件收到事件后更新订单数据。这样设计的好处是选座逻辑和座位渲染完全可以独立测试,不用反复启动整个项目。
4.3 Axios封装、跨域代理和路由模式
axios我是封装在api/request.js里的,设置baseURL为/api,并在拦截器中统一处理token添加、错误提示和登录跳转。开发环境用Vue CLI的代理解决跨域问题,vue.config.js中的配置如下:
devServer: { proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true } } }这里必须说明的是,前端的/api路径前缀在后端Controller的@RequestMapping里并没有对应,我让后端统一以/api开头定义接口路径,前端代理时只需把/api转发到后端服务地址即可,不需要去掉前缀。生产环境则是通过Nginx将同一个域名下的/api请求反向代理到后端端口,彻底规避跨域。
路由这块我遇到过一个坑。刚开始用了HTML5 History模式,路由路径很干净,但部署到Tomcat后一刷新页面就404,因为后端没有对应的路径返回index.html。后来改成hash模式就不存在这个问题了。如果要坚持使用history模式,需要在Nginx中配置try_files $uri $uri/ /index.html;。对课设项目来说,使用hash模式最省心,URL里多一个#号完全不影响演示。
4.4 Vuex管理订单状态
订单相关的状态我全部放在了Vuex中管理。用户从选座页点击选座按钮后,将选中的座位信息存入Vuex的orderState模块,然后跳转到订单确认页;确认页读取Vuex中的座位和金额数据,用户点击支付后调用支付接口;支付成功后清空Vuex中对应的state。为什么不直接用路由参数传递?因为选座页和订单确认页之间不仅要传座位编号数组,还要传场次信息、影厅信息、单价和总金额,对象数据用路由参数传递既繁琐又不安全,页面刷新就会丢。Vuex中的数据虽然刷新也会丢失,但订单确认页本身不应该被刷新,刷新了重新选座就行。
5. 选座组件:整个前端的重难点
5.1 座位图渲染逻辑
SeatMap组件是整个前端最核心的部分。影厅座位数据从后端接口获取后是这样一个数组结构:每条记录包含row_no、col_no、seat_no和status字段。组件里我首先把一维数组转换成二维嵌套数组,以row_no作为外层index,以col_no作为内层index,然后通过两层嵌套渲染出座位图。
渲染时CSSTable布局:最外层是一个相对定位的div,内部用CSS Grid或Table布局,屏幕上每一排是一个tr,每个座位是一个td单元格。银幕位置我放在座位图上方,用一条弧形渐变模拟幕布效果,视觉上非常直观。
每个座位格子的样式由status属性决定:0号状态渲染为绿色可点击,1号状态渲染为灰色不可点击,2号状态渲染为红色不可点击,3号状态渲染为白色不占座。我同时在座位格子上绑定了点击事件,只有status为0的座位才能触发selectSeat方法。
5.2 选座数量控制和合计金额计算
实际电影院通常限制一次最多选四张票,我在组件内部维护了一个selectedSeats数组,每次点击先判断数量是否已达上限,超过则弹出提示并阻止选中。系统通过this.selectedSeats.length * Number(this.session.price)实时计算合计金额,在座位图下方的信息栏中同步显示。
这里有一个体验细节:每次点击座位后需要触发一次重新渲染。Vue的响应式系统会自动检测数组变化,但如果直接使用this.selectedSeats[index] = value这种方式修改数组,Vue可能无法检测到变化。我统一使用this.selectedSeats.push(seatNo)或this.selectedSeats.splice(index, 1)来增删座位,确保视图能正确刷新。
5.3 提交订单前的座位二次校验
用户完成选座点击提交后,前端虽然已经拿到了座位状态,但仍需将座位编号发送到后端做二次校验。因为用户在页面上停留的时间可能很长,在这个时间内座位可能已被其他人锁定。后端二次校验通过后才创建订单,否则回传错误信息告诉用户哪些座位已不可选。
我在前端提交按钮上做了防重复提交处理:调用提交方法后立即将按钮置为禁用状态,同时显示加载动画;接口响应成功或失败后再恢复按钮。这个细节看起来不起眼,却是演示现场最容易丢分的地方——如果出现连续点击导致出现两笔订单,评委老师印象立刻打折扣。
5.4 电影预告片播放的扩展点
如果要在电影详情页加上预告片功能,前端可以直接用video标签播放MP4格式的视频文件。课设阶段把视频文件放在前端public目录下,引用相对路径即可。网上流传的m3u8在线流媒体播放方案需要额外的播放器依赖和跨域配置,这类格式适合后续进阶研究,课设演示加上反而增加不确定性。我当时是直接存了几个MP4文件在本地,演示效果足够好,又不需要担心网络和格式问题。
6. 常见问题与排查技巧实录
6.1 后端启动失败和404的排查顺序
后端启动报错大多集中在Tomcat端口被占用和Bean创建失败两类。端口占用用命令行解决:在Windows下执行netstat -ano查找占用8080端口的进程PID,再用taskkill /PID /F强制结束。Bean创建失败则要结合日志从下往上找Caused by信息,常见原因是Mapper接口扫描路径配置错了,导致SqlSessionFactory创建失败。
接口访问404时我建议按三层排查:先看Controller类有没有加上@Controller注解,再看RequestMapping路径和前端请求路径是否一致,最后检查Mapper层返回的JSON字段名是否和小程序端使用的字段名完全对应。排查到字段名不同是最隐蔽的,表现为浏览器请求返回200但页面全是undefined。
6.2 前端白屏和跨域报错的常见原因
前端白屏最常见原因是Node服务没启动、启动报错、或者浏览器访问的端口和devServer配置不一致。我在开发期习惯让后端跑8081端口,前端通过代理访问;如果后端不在8081启动,前端所有请求都会报ERR_CONNECTION_REFUSED。页面加载出来但接口报错,则优先检查vue.config.js中的proxy配置是否生效,修改代理配置后必须重启npm run serve,热更新不会重新加载webpack配置。
6.3 MyBatis映射和JSON序列化的坑
查询结果字段为null是初学者最容易困惑的问题。原因几乎都是数据库字段名是下划线风格user_id,而Java实体类属性是userId驼峰风格。我在mybatis-config.xml中配置了mapUnderscoreToCamelCase属性为true,MyBatis会自动完成下划线与驼峰之间的映射,这个配置务必加上,否则每个查询都要手动写resultMap。
日期序列化的问题在订单接口中特别明显。后端返回的createTime如果被Jackson转成时间戳数字,前端显示出来就是一串1388888888888。我在spring-mvc.xml中配置了jackson的dateFormat为yyyy-MM-dd HH:mm:ss,同时设置timezone为GMT+8,这样前端拿到的就是格式化后的字符串,直接用插值语法展示即可。
6.4 提交源码和依赖安装的几个细节
给答辩老师或同学分享前端源码时,记得删掉node_modules文件夹再把整个项目压缩包发出去,接收方本地执行npm install还原依赖。如果对方项目启动后报failed to load tsconfig这类错误,基本都是原始项目使用了不同于本机的Vue版本或构建工具版本,比较省事的办法是从头用vue create新建一个同样版本的项目模板,将源码中的src目录和关键配置文件复制过去。
6.5 常见问题速查表
| 故障现象 | 可能原因 | 解决办法 |
|---|---|---|
| 后端启动立刻退出 | 端口被占用 | netstat -ano查找PID并终止 |
| 前端页面白屏无报错 | Node版本过低或ES语法不支持 | 切换Node版本,查看编译日志 |
| 前端请求后端超时 | 代理配置未重启 | 修改vue.config.js后重跑devServer |
| 查询结果字段为null | 下划线与驼峰不对应 | 开启mapUnderscoreToCamelCase |
| 提交订单重复 | 按钮未防抖 | 提交期间禁用按钮 |
| 刷新页面404 | history模式未做回退 | 改用hash模式 |
| vue项目发给他人报配置错误 | mismatched Vue版本 | 新建同版本模板再拷入源码 |
7. 打包部署与答辩演示的经验
7.1 前后端分离部署的具体操作
项目要完整跑起来并演示,我采用的方案是用Maven将后端打成WAR包部署到Tomcat,前端npm run build打出dist目录,交给Nginx托管并配置反向代理。
Nginx配置的核心部分是需要把/api前缀的请求转发到Tomcat:
server { listen 80; server_name localhost; root /usr/share/nginx/html/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这种部署形式能完美模拟真实的前后端分离发布场景,答辩时也更容易说明系统具备实际部署能力。如果不想装Nginx也可以直接把dist目录重命名为ROOT放进Tomcat的webapps下,但API接口路径需要改成绝对路径或者额外处理代理,稍微麻烦一点。
7.2 演示前一定要提前准备的三件事
第一件事,数据库初始化。我在项目启动时会把后端SQL脚本里加一些预置数据,包含六部电影、三个影厅、两天内的场次,以及一部分已售出的座位状态。这样演示时首页不会空荡荡,选座时也能直观看到两种不同颜色的座位,证明系统状态管理正常。第二件事,测试购票全流程。从头到尾跑通一遍选座下单支付操作,确认支付成功后订单列表状态变为已支付,座位变为已售。第三件事,清理开发期的调试代码。尤其是console.log和System.out.println,留着会显得很不专业。
7.3 给系统增加三个实用的小功能
如果项目做完后想再加分,我建议从三个方向入手。一是票房统计报表,后端用SQL按电影维度聚合订单金额和数量,前端用表格和柱状图展示;二是用户邮箱或手机号验证码注册,避免直接用明文密码注册;三是排片冲突检测,在新增场次时自动校验同影厅时间段是否有重叠,有重叠就提示排片员换其他时间。这三个功能都不复杂,但能明显提升系统完整度和答辩展示性。
最后说一点我个人的经验。做这类全栈课设项目,最大的成就感来自打通前后端的那一刻——当你从数据库的一条记录,一路看到它变成页面上一个可点击的座位格子时,整个系统的轮廓才真正清晰起来。如果你也正卡在SSM整合或者Vue跨域这类问题上,不用急,照着上面的流程一步步排查,先把架构跑通再倒回去优化细节。项目做完了记得打个压缩包备份好,答辩前再完整走一遍购票流程,你一定能成为同学里面最从容的那一个。