
说实话影院购票系统我做过好几版从学校里的课设到后来给客户做的商业项目都碰过。这个基于SpringBootVueMyBatisMySQL的完整实现前端负责展示和用户交互后端提供接口和业务逻辑数据库管着票务状态一套走下来基本把全栈开发的主要环节都覆盖了。我见过太多人拿着一堆教程学完还是不会做项目问题就出在缺乏一个像这样能串起所有知识点的完整案例。这篇博文就是想把整个系统的设计思路、表结构、关键代码和踩过的坑一次性讲透适合正在做毕设的学生也适合准备转行Java开发、需要项目经验的朋友。1. 为什么我建议拿影院购票系统当全栈练手项目1.1 业务复杂度卡得恰到好处很多练手项目要么太简单比如单纯做CRUD的管理系统做完除了会写几个接口什么都没沉淀下来要么太难比如微服务电商系统光搭建基础设施就劝退了一堆人。影院购票系统正好卡在中间偏上一点的位置既有面向用户的C端功能也有面向运营的B端后台还牵扯到并发锁座、订单超时、库存回滚这些真实业务场景。拿选座来说用户在页面上点一个座位前端要立刻反馈这个座位被选中点击提交之后后端要保证这个座位没有被别人抢走支付超时之后座位还要能自动释放。一个不起眼的选座功能背后藏着并发控制、事务管理、定时任务三块硬骨头。这样的复杂度刚好让学习者能做出东西又能实实在在地碰一遍生产环境里的典型问题。1.2 技术栈覆盖得精准且主流SpringBoot是现在Java后端开发的事实标准Vue是前端框架里上手曲线最平缓、社区最活跃的一个MyBatis在国内企业里的占有率极高MySQL更是开源数据库里的常青树。这套组合你随便打开一个招聘网站的Java后端岗位十有八九要求里都写着。用这套技术栈做项目不是为了炫技而是为了让项目经验和岗位需求直接对上。比较有意思的是搜索引擎里经常能看到springboot整合flinkspringboot整合activemq基于springboot的校园考勤系统这些词你会发现SpringBoot的生态已经渗透到了各种系统开发里。但不管外面怎么玩出花来一个普通的业务系统最核心的还是对数据的管理和对业务流程的把控影院购票系统恰恰把这些基本功练得最扎实。1.3 这套项目适合谁来搞第一类是大三大四的学生毕设和课设选这个方向很合适业务理解起来不费劲系统复杂度足以撑起一篇论文第二类是自学Java准备找工作的人简历上放一个能说清楚并发和事务的完整项目比放三个超市管理系统好用得多第三类是刚入职接手旧项目的人借这套系统快速熟悉SpringBootMyBatis的开发模式和Vue前后端分离的联调流程。提示如果你刚学完SpringBoot基础语法建议先跑通一个简单的CRUD项目再来啃这篇内容否则后面讲事务和锁的时候可能会有点费劲。2. 先把业务切清楚再写代码模块划分与核心流程2.1 用户端和管理端的功能清单做项目最忌讳上来就建表写代码我习惯拿着功能清单跟人聊需求。影院购票系统的功能可以切成两大块。用户端面向普通观众核心功能包括注册登录手机号或用户名注册登录后获取Token后续请求带着Token访问影片浏览首页展示正在热映的电影可以按类型、上映时间筛选影片详情查看电影的导演、演员、剧情简介、片长、评分场次选择一部电影通常有多家影厅、多个时间点的场次用户需要选择具体场次座位选择在座位图上点选想坐的位置已售的座位要置灰选完提交后锁定订单管理与支付生成订单模拟支付成功后出票也可以取消待支付的订单管理端面向影院运营人员核心功能包括影片管理上片、下片、修改排片信息影厅管理维护影厅信息比如屏幕尺寸、影厅类型、座位排布场次编排为电影安排具体播放时间、影厅和票价一部电影可以安排多个场次订单管理查看所有订单、处理退票基础统计查看每个场次的售票情况2.2 购票主流程的状态流转流程可以概括成一句话选电影、选场次、锁定座位、提交订单、支付出票。但落到数据库层面关键是两个状态的联动。第一个是座位的状态。我在设计里给每个场次下的每个座位定义了三种状态可用、锁定、已售。用户选座时座位从可用变成锁定这时候别人就看不到了支付成功之后从锁定变成已售永久占用如果用户放弃支付到了超时时间座位从锁定回滚成可用。第二个是订单的状态。订单状态有四个待支付、已支付、已取消、已退票。用户在某个场次选了好几个座位提交之后生成一笔订单这笔订单可能包含多个座位所以订单和座位是一对多的关系。这两个状态的联动关系非常关键。如果用户下单但一直不付款座位就一直被锁死别的观众想买也买不了所以必须有一个兜底机制。我的方案是SpringBoot的定时任务每隔一段时间扫描一批超时的待支付订单自动把它们改成已取消同时把订单关联的所有座位状态回滚成可用。这样用户即便中途放弃座位也能在几分钟之后重新流入市场。3. 数据库设计是命根子座位、场次、订单的表结构拆解3.1 核心表结构设计我直接给出这套系统里我认为最核心的几张表照着建就能跑通基本业务。用户表CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码BCrypt加密, nickname varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, role tinyint(4) NOT NULL DEFAULT 0 COMMENT 0用户 1管理员, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;影片表CREATE TABLE movie ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL COMMENT 电影名称, cover varchar(255) DEFAULT NULL COMMENT 海报URL, duration int(11) DEFAULT NULL COMMENT 片长分钟, director varchar(50) DEFAULT NULL, actors varchar(255) DEFAULT NULL, description text COMMENT 剧情简介, category varchar(50) DEFAULT NULL COMMENT 类型动作/喜剧/科幻, release_date date DEFAULT NULL COMMENT 上映日期, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1上映 0下架, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;影厅表CREATE TABLE hall ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 影厅名称如1号厅, row_num int(11) NOT NULL COMMENT 座位排数, col_num int(11) NOT NULL COMMENT 每排座位数, seat_count int(11) NOT NULL COMMENT 总座位数, hall_type varchar(50) DEFAULT NULL COMMENT IMAX/杜比/普通, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;场次表CREATE TABLE session ( id bigint(20) NOT NULL AUTO_INCREMENT, movie_id bigint(20) NOT NULL COMMENT 电影ID, hall_id bigint(20) NOT NULL COMMENT 影厅ID, start_time datetime NOT NULL COMMENT 开演时间, end_time datetime NOT NULL COMMENT 结束时间由片长推算, price decimal(10,2) NOT NULL COMMENT 本场票价, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1可售 0停售, create_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;场次座位表CREATE TABLE session_seat ( id bigint(20) NOT NULL AUTO_INCREMENT, session_id bigint(20) NOT NULL COMMENT 场次ID, seat_row int(11) NOT NULL COMMENT 排号, seat_col int(11) NOT NULL COMMENT 列号, seat_status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0可用 1锁定 2已售, order_id bigint(20) DEFAULT NULL COMMENT 锁定/购买该座位的订单ID, lock_time datetime DEFAULT NULL COMMENT 锁定时间用于判断超时, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表和订单明细表CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id bigint(20) NOT NULL, session_id bigint(20) NOT NULL, total_price decimal(10,2) NOT NULL COMMENT 总金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退票, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_detail ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, session_seat_id bigint(20) NOT NULL COMMENT 场次座位ID, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;3.2 为什么要用session_seat这张中间表我在好几个项目群里看到有人把座位状态直接写到seat表里也就是在影厅座位表上直接加一个status字段。这个做法看起来省事但仔细一推敲就会出问题。同一个座位在早上10点的场次里可能是可卖的到了下午2点的场次里可能已经卖掉了。如果你只在一张seat表上维护状态那么这个座位到底是可卖还是已售就变得跟场次强相关而seat表本身根本不知道你要查哪个场次。正确的做法是先有一张基础的seat表记录影厅的物理座位布局第几排第几列然后再有一张session_seat表把每一个场次和该场次下所有座位做一次笛卡尔积式的预分配。也就是说给1号影厅安排一场《流浪地球》我就把1号影厅的50个座位全部复制到这个场次的session_seat表里每个座位单独一条记录初始状态全是可用。这样做的好处非常明显不同场次之间的座位状态完全隔离互不干扰。一个座位在A场次已售在B场次照样是可用状态因为它们是两条不同的session_seat记录。这个设计是整个购票系统最核心的地方也是面试官最爱深挖的点。3.3 订单与座位的关联方式订单表里没有直接存座位的id数组而是用order_detail做了一张关联表。为什么因为一个订单可能包含多个座位如果直接在订单表里加多个座位字段字段数量就不固定后续要统计场次卖了多少票、做数据报表都会非常痛苦。用明细表把订单和场次座位的多对多关系拆开每个座位一条明细查询某个场次的售票情况时直接count order_detail和session_seat的关联记录就行。3.4 一个场次的座位怎么初始化新建一个场次的时候除了往session表里插一条记录还需要把这个场次对应的影厅座位全部初始化到session_seat表里。这个动作在ServiceImpl里的逻辑是这样的public void createSession(Session session) { // 1. 保存场次基本信息 sessionMapper.insert(session); // 2. 查出影厅的总排数和每排座位数 Hall hall hallMapper.selectById(session.getHallId()); // 3. 循环生成该场次下的所有座位 for (int row 1; row hall.getRowNum(); row) { for (int col 1; col hall.getColNum(); col) { SessionSeat ss new SessionSeat(); ss.setSessionId(session.getId()); ss.setSeatRow(row); ss.setSeatCol(col); ss.setSeatStatus(0); sessionSeatMapper.insert(ss); } } }实测下来一个50座的影厅插入50条记录对MySQL来说毫无压力。如果有人拿数据量太大、浪费存储空间来说事你大可以跟他算一笔账一天排10个场次一个场次50个座位一天也才500条记录一年18万条左右MySQL完全可以轻松扛住。以现在服务器动不动几TB的存储这点数据量真的不用操心。4. 后端硬骨头SpringBoot MyBatis 在购票场景的落地细节4.1 并发锁座我在这个项目里用的方案这是整个项目里最容易被面试官揪住不放的点也是我在开发过程中花时间最多的地方。想象一下《哪吒之魔童闹海》热门场次开售的场景两百个座位瞬间涌入几千个请求如果没有并发控制两个用户同时点同一个座位两个人都看到了可售两个人都下单成功了这就出了大事故。我在这个项目里采用的方案是MySQL的悲观锁核心就是那句SELECT ... FOR UPDATE。当一个事务执行这条语句时会对命中记录加锁其他事务再想对这行记录加锁或者更新时必须等第一个事务提交或回滚之后才能继续。锁座的Service层代码大致长这样Transactional(rollbackFor Exception.class) public Long lockSeat(Long sessionSeatId, Long userId) { // 1. 强制锁定这一行其他事务在这里会阻塞等待 SessionSeat seat sessionSeatMapper.selectByIdForUpdate(sessionSeatId); if (seat.getSeatStatus() ! 0) { throw new BusinessException(座位已被购买或锁定); } // 2. 更新座位状态为锁定记录锁定时间和订单ID SessionSeat update new SessionSeat(); update.setId(sessionSeatId); update.setSeatStatus(1); update.setLockTime(new Date()); sessionSeatMapper.updateById(update); return sessionSeatId; }对应的MyBatis Mapper里这么写select idselectByIdForUpdate resultTypecom.example.entity.SessionSeat select * from session_seat where id #{id} for update /select这里有几个细节值得展开说。第一事务必须开启。FOR UPDATE的锁要在事务提交之后才释放如果方法上没有加Transactional锁会在SQL执行完就释放根本起不到保护作用。我见过好几个学员把锁写出来了但忘了加事务注解并发测试一跑就翻车。第二查询条件必须落到索引。FOR UPDATE的行锁是锁索引记录如果where条件没有走索引MySQL会退化成锁表整个场次的座位都锁住并发性能直接崩掉。这里我用的是主键id查询天然满足条件。第三乐观锁与悲观锁的选择。乐观锁的做法是在表里加一个version字段更新的时候比对版本号通过乐观锁控制并发。我为什么没用原因很简单乐观锁适合冲突少的场景但热门场次的座位属于典型的高竞争资源乐观锁会导致大量请求失败重试客户体验非常差。而悲观锁在这个场景下是最直观、最可靠的。4.2 事务边界怎么划最合理锁座和创建订单必须在一个事务里不然锁刚释放订单还没建好座位就又被别人锁走了。我的做法是把锁定座位生成订单生成订单明细放在一个服务方法里整体提交或整体回滚。伪代码逻辑如下Transactional public OrderDO createOrder(ListLong sessionSeatIds, Long userId) { OrderDO order new OrderDO(); ListSessionSeat seats new ArrayList(); for (Long seatId : sessionSeatIds) { // 逐个锁座 SessionSeat ss lockSeatInCurrentTx(seatId); seats.add(ss); } // 计算总价并把订单插入 for (SessionSeat ss : seats) { // 根据ss关联的场次查询票价 // 累加总价 } orderMapper.insert(order); // 插入明细 for (SessionSeat ss : seats) { orderDetailMapper.insert(order.getId(), ss.getId()); } return order; }这里有一个容易被忽略的问题如果一个订单选了多个座位那么在遍历锁座的过程中前面几个座位已经锁成功了最后一个座位如果已经被别人抢走整个事务就会回滚前面锁住的座位也会自动释放。这就保证了要么全部锁成功要么一个都不锁不会出现用户选了三连座只锁到两个的尴尬情况。4.3 排障实录MyBatis里那些让我抓狂的条件失效问题玩过MyBatis的人都知道动态SQL里的if判断是业务开发中最常用的功能。但有个坑非常隐蔽我一度排查了一整个下午。需求是这样的管理端后台需要根据场次状态和电影ID查询场次列表。我写的SQL是这样的select idselectByCondition resultTypecom.example.vo.SessionVO select * from session where if teststatus ! null and status ! and status #{status} /if if testmovieId ! null and movie_id #{movieId} /if /where /select看起来没毛病结果一执行传status0的时候条件判断居然不生效直接把所有状态的场次都查出来了。问题出在哪呢MyBatis的if判断用的OGNL表达式当status是Integer类型并且值为0的时候status ! 这个判断会出问题。OGNL在进行Integer和String比较时遇到数字0会出现判断为false的情况导致整个and条件被MyBatis忽略。正确的写法是去掉对字符串空串的判断if teststatus ! null and status #{status} /if这类问题非常典型几乎是MyBatis面试题里必考的点。以后你写动态SQL只要字段类型是Integer、Long这类包装类型if判断里不要加上! 的字符串判断只判断! null就足够了。这个坑我在后面做其他项目的时候又踩过一次所以必须拿出来多说两句。4.4 MyBatis缓存在购票系统里的位置说到MyBatis就绕不开缓存这也是搜索引擎里关于mybatis缓存mybatis二级缓存实现关键词热度居高不下的原因。MyBatis的一级缓存是SqlSession级别的默认开启同一个SqlSession内执行相同的两条查询SQL第二次会直接命中缓存不查数据库。二级缓存是namespace级别的需要配置才能开启。我在这个购票系统里对session_seat表相关的查询全部没开二级缓存。原因很简单座位状态是实时数据如果MyBatis把查询结果缓存了一个事务里锁定了座位另一个事务查询时命中的却是缓存里的旧数据那锁座逻辑就全乱了。像座位、订单这类状态变化极为频繁的核心数据用MyBatis缓存反而是负担必须绕过。提示MyBatis二级缓存适合缓存配置类数据比如影片的放映类型、影厅的类型字典。前提是你自己要能接受最高长达几秒钟的数据不一致。任何缓存方案都有取舍没有银弹。4.5 MySQL排序和分页做场次列表时踩的坑MySQL排序这个点看着简单用起来也有讲究。我在场次列表页做了按价格排序的功能前端传入sort字段后端动态组织ORDER BY。这里有两个细节第一个是排序字段的注入问题。MyBatis里#{}是预编译参数会用占位符代替而ORDER BY后面的字段名如果用#{}传参生成的SQL会是ORDER BY price排序结果完全不对。动态列名必须用${}直接拼接但这就带来了SQL注入风险。所以前端传的排序字段永远不要直接拼到SQL里而是在后端定义一个白名单映射MapString, String sortMap new HashMap(); sortMap.put(price, price); sortMap.put(time, start_time); sortMap.put(id, id); // 前端传sortprice时从map里取值取不到就用默认值 String orderColumn sortMap.getOrDefault(sort, id);第二个是价格字段的类型。我在表设计里用的是decimal(10,2)而不是double。原因很简单浮点数在MySQL里存储有精度误差如果你用double存价格排序时10.00和10.01可能莫名其妙地排错位。凡涉及钱的字段一律用DECIMAL这是做电商、票务系统的基本常识。4.6 SpringBoot定时任务处理超时订单订单超时释放用SpringBoot自带的任务调度就能实现不需要引入Quartz。在启动类上加EnableScheduling再写一个定时任务类Component public class OrderTimeoutTask { Autowired private OrderMapper orderMapper; Autowired private SessionSeatMapper sessionSeatMapper; // 每30秒执行一次 Scheduled(fixedDelay 30000) Transactional(rollbackFor Exception.class) public void releaseTimeoutOrders() { // 1. 查找超过15分钟仍未支付的订单 ListOrderDO timeoutOrders orderMapper.selectTimeoutOrders(15); for (OrderDO order : timeoutOrders) { // 2. 把订单状态改为已取消 orderMapper.updateStatus(order.getId(), 2); // 3. 把订单关联的所有座位回滚成可用 ListLong seatIds orderDetailMapper.selectSeatIdsByOrderId(order.getId()); for (Long seatId : seatIds) { SessionSeat update new SessionSeat(); update.setId(seatId); update.setSeatStatus(0); update.setOrderId(null); update.setLockTime(null); sessionSeatMapper.updateById(update); } } } }这里有个细节就是订单关联的座位回滚必须在同一个事务里完成如果嫌在事务里循环更新太多也可以用一条UPDATE session_seat SET ... WHERE order_id #{orderId}直接批量回滚性能更好。开发的时候可以胆子大一点用批量SQL代替循环。5. 前端Vue页面结构、路由权限和选座交互5.1 页面分解与路由设计先看前端项目的总体结构src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 │ ├── MovieCard.vue │ ├── SeatMap.vue │ └── OrderCard.vue ├── router/ # 路由配置 ├── store/ # 状态管理Vuex/Pinia ├── views/ │ ├── home/ # 首页、影片详情、场次选择 │ ├── user/ # 登录注册、个人中心、订单列表 │ ├── admin/ # 管理后台页面 │ └── order/ # 选座、确认订单、支付结果 └── layout/ # 布局组件路由分成两个主要的嵌套路由组一组是用户端Layout一组是管理端Layout。管理端配置了路由守卫没有登录或者不是管理员身份就重定向回首页。const router new VueRouter({ routes: [ { path: /, component: UserLayout, children: [ { path: , component: HomePage }, { path: movie/:id, component: MovieDetail }, { path: session/:sessionId, component: SeatSelect } ] }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, role: admin }, children: [ { path: movie, component: AdminMovie }, { path: session, component: AdminSession }, { path: order, component: AdminOrder } ] } ] })搜索引擎里天天有人搜vue动态路由其实就是一种按需挂载路由的思路。我这个项目里管理端用的是静态配置加路由守卫的方式已经够用。如果后续要做更细的权限比如某些管理员只能管理排片不能看订单那时候才需要动态路由用户登录后后端返回他拥有权限的路由表前端用router.addRoutes()按需注册。5.2 插槽让组件复用舒服了很多Vue插槽是很多人学之前很懵、学之后真香的一个功能。拿我写的电影卡片组件举例正常情况下卡片展示海报、标题、评分就够了但在首页和运营管理页同样一张卡片需要展示完全不同的内容。首页要放选座购票按钮管理页要放编辑下架按钮。在MovieCard.vue里留出插槽template div classmovie-card img :srcmovie.cover alt/ h3{{ movie.title }}/h3 p{{ movie.score }}/p slot nameaction/slot /div /template使用的时候就很灵活MovieCard v-form in movies :keym.id :moviem template v-slot:action router-link :to/session/list/${m.id}选择场次/router-link /template /MovieCard管理端里这么用MovieCard v-form in movieList :keym.id :moviem template v-slot:action el-button typetext clickeditMovie(m)编辑/el-button el-button typetext clickoffShelf(m)下架/el-button /template /MovieCard同一套组件两个场景复用不用复制粘贴两份代码这就是插槽的核心价值。作用域插槽更强大一点可以让父组件拿到子组件内部的数据来做定制比如座位图组件里把某个座位是否可用的信息抛给父组件决定颜色。5.3 选座页面的核心交互选座是前端交互最重的一个页面直接决定了用户对系统第一印象的好坏。我的实现思路是这样的先用后端接口查出当前场次的所有座位状态数据是一个二维数组前端按排数、列数渲染成座位网格。每个座位是一个div根据状态设置不同样式可用是浅色已售是灰色自己选中是高亮色。点击可用的座位把它加入已选列表再点一次取消选中。选完座位之后点击提交按钮前端把座位id列表传给后端后端走完锁座和创建订单的流程返回订单ID和总价前端跳转到支付页面。这里要注意一旦锁座成功前端就需要开启一个倒计时提示用户在15分钟内完成支付。我做过几版选座组件一个比较容易被忽略的细节是选座的页面一定要在座位锁定成功后立刻展示已锁定状态。有些实现偷懒等用户提交之后才去刷新座位状态结果用户自己选的座位不显示已锁界面体验就很分裂。正确的做法是请求成功后前端本地把对应的座位状态改成已锁定。5.4 axios封装与跨域处理前端请求必须封装一套统一的axios实例把baseURL、请求头、超时时间、错误提示都集中管理。不需要每次请求都重新写完整路径。拦截器里做两件重要的事一是从localStorage里取Token加进请求头二是收到401响应时统一跳转登录页。const service axios.create({ baseURL: process.env.VUE_APP_BASE_URL, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { router.push(/login) } Message.error(error.response?.data?.message || 请求失败) return Promise.reject(error) } )开发环境下前后端联调跨域的问题几乎人人都会遇到。两个解决方案我推荐用Vue CLI里的proxy// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样前端请求/api/session/list时开发服务器会代理到后端的8090端口浏览器看起来就是同源请求没有跨域问题。生产环境部署时我通常会把前端打包产物放到后端项目的src/main/resources/static目录下或者用Nginx把/和/api分别指向前端静态资源目录和后端地址这两个方案都比在后端代码里配置CORS跨域注解更稳。6. 环境搭建与排错实录MySQL、Maven、IDEA那些坑6.1 MySQL的安装和版本选择搜索引擎里关于mysql安装教程mysql 5.7.44 安装过程详细rpm安装mysql的搜索量一直居高不下可见数据库环境搭建劝退了大量初学者。Windows环境下大概从MySQL官网下载installer或者zip包。我个人的建议是如果只是做项目而不是做企业生产环境直接装MySQL 5.7的zip包解压版就行。解压之后有个容易忽略的步骤以管理员身份打开命令行执行mysqld --initialize-insecure初始化数据目录否则后续启动服务会一直报找不到data目录。然后执行mysqld --install注册成Windows服务再用net start mysql启动。连接数据库之前还要检查两样东西字符集和时区。字符集建议在建库的时候指定CREATE DATABASE cinema_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;时区问题更隐蔽。JDBC连接串里如果不加serverTimezoneAsia/ShanghaiMySQL 8.0版本会直接报错说服务器时区无法识别就算MySQL 5.7能连上日期时间字段也会出现时差8小时的情况。我的项目里常用的SpringBoot数据源配置长这样spring: datasource: url: jdbc:mysql://localhost:3306/cinema_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver6.2 SpringBoot版本为什么不能乱选搜springboot版本太高的人多半是已经踩坑了。SpringBoot 2.x和3.x之间最大的差别之一是javax命名空间全面切换成了jakarta。2.x时代你写import javax.servlet.*3.x必须写import jakarta.servlet.*。如果你从网上复制了一段基于SpringBoot 2.x的代码然后自己新建的是SpringBoot 3.x项目一开机就会报各种类找不到的错误。我的建议是做这个购票系统SpringBoot用2.7.x这一代的最后一个版本配JDK 8或JDK 11都行网上能找到的参考资料最多照着踩坑也最容易排。如果你非要用3.x就得接受JDK 17起步而且要小心那些老版本的MyBatis starter是否兼容。6.3 IDEA里的启动配置和Maven依赖问题IDEA里跑SpringBoot项目经常有人纠结怎么配置启动端口。其实SpringBoot的默认端口就是8080如果你在application.yml里写了server.port: 8090启动时看到日志里的Tomcat started on port(s): 8090就是成功了。没生效的话检查一下是不是改了配置文件没有重新编译IDEA有时会缓存旧的target目录。Maven依赖下载慢是老生常谈搜索引擎里搜springboot gradle项目搭建的人也不少。我的习惯是在settings.xml里替换成阿里云镜像源mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror前端环境也有对应的坑Vue项目初始化时经常卡在npm install这一步。同样使用国内镜像设置registry为淘宝镜像源npm config set registry https://registry.npmmirror.com设置之后再装依赖速度基本可以从等半小时降低到一两分钟。6.4 联调时最常遇到的三个报错前后端联调时最常遇到的问题我列在下面这张表里都是实际排查过的报错现象根本原因解决方式前端请求后端返回404后端接口路径跟前端请求路径不一致在后端Controller里打印日志或直接访问Swagger地址确认真实路径使用统一/api前缀方便代理请求发送成功了但拿不到数据后端返回的JSON字段名和前端使用的驼峰命名不一致在application.yml里配置map-underscore-to-camel-case: true让数据库的create_time自动映射为createTime前端报CORS跨域错误未配置代理或后端未放开跨域开发环境优先使用Vue的proxy代理生产环境用Nginx反向代理这里重点说说MyBatis的驼峰映射配置它是一个典型的配了能救命、忘配想骂人的设置。数据库字段用下划线命名是SQL规范Java实体类用驼峰命名是Java规范两者之间靠这条配置自动互相翻译mybatis: configuration: map-underscore-to-camel-case: true不加这条配置你会发现create_time查询出来永远是null因为MyBatis默认找的是实体类里的createTime字段而数据库列名是create_time两者对不上。6.5 开发时远程访问MySQL失败的问题这个问题也很典型自己电脑上MySQL跑得好好的放到服务器上或者局域网里另一台机器就连不上了。原因通常是MySQL默认只绑定了127.0.0.1地址不监听对外的IP。改配置bind-address为0.0.0.0再创建一个允许任意主机访问的用户CREATE USER dev% IDENTIFIED BY password; GRANT ALL PRIVILEGES ON cinema_db.* TO dev%; FLUSH PRIVILEGES;另外Windows防火墙和云服务器的安全组规则也都需要放行3306端口否则从外部连就是超时跟MySQL本身没关系。7. 复盘做完这个项目你真正带走的是什么7.1 从业务需求到数据库建模的迁移能力这个项目做完之后我最大的感触是日常业务系统的开发难点大多不在代码而在建模。能画出清晰的表结构能说清楚每张表之间的关联关系能解释为什么拆出session_seat这张中间表比会写几十个CRUD接口更能体现一个后端开发的水准。以后再遇到演唱会购票、景区门票预约、体育比赛选座你会发现它们的业务结构跟影院购票几乎同构都是先有资源座位/票再根据场次/日期做一次复制生成可售票池然后通过锁和事务保证不超卖最后用订单状态把整个流程串起来。你会很自然地复用这套模型根本不需要从零开始设计。7.2 并发控制和安全意识的建立在没有做这类项目之前很多人对事务、行锁的理解只停留在面试题里。真正自己写完锁座逻辑、压测过并发、看过死锁日志之后对这些概念的理解会完全不一样。你会意识到数据库的一个行锁就是业务正确性的最后一道防线。做这类系统程序员根本不能指望靠前端把按钮置灰来防止重复下单因为恶意请求根本不需要走页面。同样的密码存储一定要用BCrypt加密而不是明文涉及钱的字段要用DECIMAL排序字段要做白名单过滤。这些安全意识不是靠看书看出来的是真的做一个带支付逻辑的系统的时候才会刻进脑子里。7.3 可以继续扩展的方向这套系统还有不少可以往深处挖的地方用Redis缓存热门电影的场次查询结果把热点数据从MySQL里解放出来用Redis分布式锁替换数据库悲观锁应对多实例部署的场景对接微信支付或支付宝沙箱环境把模拟支付改成真实支付给管理端加一个简单的数据报表统计每个场次的上座率。另外给有精力的人一个建议可以尝试把这个项目部署到云服务器上前端打包扔到Nginx后端用Maven打包成jar包数据库用云数据库整个过程走完一遍你对于一个网站是怎么上线这件事会形成一个完整的闭环认知找工作面试时聊起来也更有底气。反正我自己的经验是把一个项目从头到尾做完、部署上线、踩过坑修过bug学到的东西远比刷一百道面试题实在。这套影院购票系统的价值不在于代码本身有多高级而在于它把全栈开发中最常用的技术点完整地串了一遍能真正动手做完它的人收获的东西不会少。