接触过不少刚学完 Java 基础的同学,第一个想动手做的项目往往就是“XX管理系统”。说实话,管理系统确实是练手的好选择:业务逻辑清晰、 CRUD 为主、前后端都能练到。但真正能把一个管理系统从 0 到 1 完整做出来,并且把细节打磨到位,并没有想象中那么轻松。
这段时间我正好在做一个基于 SpringBoot + Vue 的影城管理系统,技术栈就是标题里那套:Java + MySQL + MyBatis。这个项目不算大,但涵盖了影院日常运营的核心场景:影片管理、排片管理、选座购票、订单管理、会员营销等等。整个做下来,踩了不少坑,也积累了不少经验。今天这篇文章,我就把整个项目的设计与实现过程做个完整的复盘,从需求拆解到数据库设计,从后端接口到前端页面,从部署到排错,把关键环节都说清楚,希望能给正在做类似项目的同学一条清晰的路。
1. 项目整体拆解与核心模块划分
动手写代码之前,我先做了一件事:把影城管理系统当成一个真实的业务场景来梳理。影院运营方(管理员)需要什么功能?观众(普通用户)需要什么功能?这两个角色的诉求完全不同,必须先理清楚再动手。
1.1 用户角色与权限边界
这个系统我划分了三种角色,各有各的权限范围:
| 角色 | 核心诉求 | 主要操作 |
|---|---|---|
| 普通观众 | 快速浏览影片、选座购票、查询订单 | 影片浏览、场次查看、在线选座、下单支付、订单查询 |
| 影院运营管理员 | 日常运营管控、数据维护 | 影片信息管理、影厅管理、排片管理、订单审核、统计报表 |
| 系统超级管理员 | 系统级配置与人员管理 | 员工账号管理、角色权限分配、系统参数配置 |
其实很多管理系统在权限这块做得很随意,甚至直接做成单角色(只有管理员)。我觉得既然是系统设计,尽量把角色边界做清晰,哪怕代码量多一点,对于理清业务模型和后续扩展都更有价值。比如后面要做“门店管理员”和“总部运营”的区分,角色模型清晰了,扩展起来就顺手很多。
1.2 功能模块清单
整个系统我按业务域拆成了 7 个核心模块:
- 影片管理:影片信息维护(片名、简介、海报、时长、类型、上映状态),支持上下架操作。
- 影厅管理:影厅座位布局的维护,包括影厅号、座位行列数、座位类型(普通座/情侣座/无障碍座)。
- 排片管理:把影片、影厅、时间三者绑定,生成具体的场次,这是后期所有购票业务的基础。
- 在线购票:用户基于某场次选座,锁定座位并生成订单,模拟支付后出票。
- 订单管理:订单状态流转(待支付、已支付、已出票、已取消、已退票),超时取消机制。
- 会员营销:会员等级、积分累计、优惠券发放与核销,用于提升用户黏性。
- 统计报表:票房统计、场次上座率、影片热度排行,用图表展示。
这个清单看着不复杂,但每一条展开都有业务细节。比如“选座”这件事,座位状态就分可售、已售、锁定、维护四种,并发场景下怎么保证两个用户不会买到同一个座位,这就是一个真正值得思考的业务问题。
1.3 技术选型与理由
技术栈选的是 SpringBoot + Vue + MySQL + MyBatis,这套组合是当下 Java 后端开发非常主流的一套方案,适合这个项目的几个原因:
- SpringBoot 简化了项目搭建和配置,内置 Tomcat,一键启动,省去大量 XML 配置。
- MyBatis 轻量灵活,SQL 自己掌控,特别适合业务场景清晰、查询条件复杂的管理系统。虽然 JPA 开发更快,但 SQL 可控性更强,排查问题也直观。
- MySQL 是关系型数据库的首选,生态成熟,事务支持好,影城这种订单+金额的场景必须依赖事务保证数据一致性。
- Vue + Element UI(我实际用的是 Vue2 + Element UI 的组合)做后台管理界面非常高效,组件丰富,表格、表单、弹窗这些高频组件开箱即用。
这个选型不求新,但求稳。对大多数初学者或者做毕设性质项目的同学来说,这套组合的参考资料最多,遇到问题也最容易搜到解决方案。
2. 数据库设计:像搭积木一样把表关系理顺
数据库设计是整个项目的基石,设计不好后期写 SQL 就是灾难。影城管理系统涉及的实体不少,但实体关系其实很清晰,核心就一条业务链:影片 → 排片场次 → 座位 → 订单。
2.1 核心表结构
我实际建的表有 12 张,这里挑最关键的说。
影片表(film)
id、film_name(片名)、film_type(类型)、film_duration(时长,分钟)、 poster_url(海报地址)、director(导演)、actors(主演)、 description(简介)、publish_date(上映日期)、status(1上映中 0已下架)影片表的字段其实可以更丰富,比如想看人数、评分、预告片地址,但核心字段就是以上这些。海报地址我用的是相对路径,文件实际存服务器目录,数据库只存路径字符串,避免把图片二进制直接塞进数据库,这是个大坑(数据库膨胀极快,读写性能下降)。
影厅表(hall)
id、hall_name(影厅名)、row_count(行数)、column_count(列数)、 seat_type(座位布局类型)、status(启用/停用)影厅表本身字段不多,但座位的生成逻辑在代码里。新建一个影厅后,我会在服务端根据行列数自动生成对应的座位记录,写入座位表。
座位表(seat)
id、hall_id(所属影厅)、row_no(行号)、col_no(列号)、 seat_type(普通座/情侣座/VIP座)、is_active(是否可用)座位表与影厅是多对一的关系。为了让用户能直观地看到“我选的座位在哪个位置”,座位的行号用数字(1、2、3...),列号用字母(A、B、C...),比如“5排C座”就很直观。
场次表(schedule)
id、film_id(影片id)、hall_id(影厅id)、start_time(开始时间)、 end_time(结束时间)、price(票价)、stock(余票数量)、status(场次状态)场次表是整个购票业务的枢纽。注意 end_time 不用用户输入,我会根据影片时长 + 开场时间 + 默认间隔(比如 20 分钟清场时间)自动计算。stock 是冗余字段,值等于该影厅可用座位数减去已售和锁定座位数,查询列表时直接展示,不用每次都 count 子查询,性能更好。
订单表(orders)
id、order_no(订单编号)、user_id(下单用户)、schedule_id(场次id)、 seat_ids(选座id集合,JSON格式)、total_price(订单总金额)、 status(0待支付 1已支付 2已出票 3已取消 4已退票)、create_time、pay_time订单表的 seat_ids 用 JSON 格式存,虽然违反了严格的数据库范式,但在这个场景下可读性和扩展性都更好,查询时直接拿整个字符串解析即可。如果将来要做复杂的选座数据分析,再抽独立表也来得及。
2.2 为什么订单表不按“一票一订单”设计
这是我在设计阶段认真纠结过的问题。一个订单对应多张票(比如一次选两个座位),是每条票单独生成一个订单,还是一个订单里包含多张票?
我最终选择了“一单多票”的方案,理由有二:
- 用户视角:“我付了一次钱,应该是一笔订单”。一个订单对应多条票记录虽然符合“标准设计”,但用户查订单时要看到两个订单,逻辑上就不够直观。
- 支付视角:一次支付动作对应支付平台的一笔流水,订单和支付流水做成 1:1 关系,财务对账更简单。
基于这个决定,订单表里用一个 seat_ids 字段而不是独立的订单明细表。简单场景下这样处理完全够用,少一层关联查询。
2.3 索引设计
表结构定好后,我给高频查询字段加了索引:
- orders 表:user_id + status 组合索引(用户查订单列表)、order_no 唯一索引(商户号查询);
- schedule 表:film_id + start_time 组合索引(按影片和时间筛选场次)、hall_id 索引(查影厅排期);
- film 表:status 索引(只查上映中的影片)。
索引不是越多越好,每多一个索引,写入时就要多维护一棵 B+ 树。管理系统写入量不算大,但也要克制,只加真正在查询和排序中用到的字段。
2.4 一个值得注意的小细节:时间字段类型
排片管理中涉及大量时间比较,前端传的是 JSON 字符串(比如"2025-06-20 14:30:00"),后端接收后转 LocalDateTime 存进 MySQL 的 datetime 类型。
这里我踩过一个坑:MyBatis 的 TypeHandler 对 LocalDateTime 的处理在不同版本有差异。如果你用的 JDBC 驱动版本比较老,存进去的时间会变成 Date 类型,取出来再传给前端时格式化就乱了。我的解决方案:统一在代码里配置 JSON 序列化的 DateTimeFormatter 为 "yyyy-MM-dd HH:mm:ss",同时数据库统一用 datetime(不是 timestamp),前后端时间显示就稳定了。
3. 后端实现:业务逻辑落地的关键环节
后端我按 SpringBoot 的标准分层来做:Controller → Service → Mapper。Controller 只做参数接收和结果返回,Service 处理业务逻辑,Mapper 写 SQL。分层清晰了,出问题找起来快很多。
3.1 统一返回结果与全局异常处理
我封装了一个返回结果类,所有接口的返回结构统一为:
{ "code": 200, "message": "成功", "data": { ... } }code 为 200 表示成功,非 200 表示业务失败,比如 400 参数错误、401 未登录、403 无权限、500 系统异常。
同时配置了全局异常处理器。比如业务中抛出“座位已被锁定”这样的自定义异常,统一在这里拦截并转成 JSON 返回,前端拿到 code 和 message 直接弹提示,不用每个接口写 try-catch。代码整洁度提升非常明显。
3.2 核心业务逻辑:选座购票的事务与并发处理
购票是整个系统最核心、也最容易出 bug 的地方。用户选了两个座位,点击支付,此时后端做的事情是:校验座位状态 → 锁定座位 → 生成订单 → 扣减库存。
这里面最大的问题是并发:两个用户同时选中了同一个座位,怎么办?
我用的方案是“乐观锁 + 状态校验”两步走。座位表里加了一个 version 字段(乐观锁标记)。更新座位状态时,在 SQL 里带上版本校验:
UPDATE seat_lock SET status = 2, user_id = #{userId}, version = version + 1 WHERE id = #{seatId} AND hall_id = #{hallId} AND schedule_id = #{scheduleId} AND status = 1 AND version = #{oldVersion}这条 SQL 如果影响行数为 0,说明座位已经被别人抢了或者状态不对,直接抛异常提示“座位已被购买,请重新选择”。这个方案的优点是实现简单、性能好,不需要引入 Redis 分布式锁这种重武器。对于影城管理系统的并发规模(通常单场次几百个座位,同时抢票的人不会太多)完全够用。
另一个关键点是整个购票流程要放在一个事务里。我用的方案是在 Service 方法上加@Transactional注解,任何一步异常,订单、座位锁定、库存扣减全部回滚,避免出现“订单没生成但座位被锁了”这种数据不一致。
3.3 超时未支付自动取消
用户选座后如果一直不支付,座位就得一直锁着吗?那肯定不行。我实现了一个简单的“定时取消”逻辑:创建一个定时任务,每隔 1 分钟扫描一次订单表,把“创建时间超过 15 分钟且状态仍为待支付”的订单揪出来,将其状态改为已取消,同时把座位状态释放回可售、库存加回。
UPDATE orders SET status = 3, cancel_time = NOW() WHERE user_id = ... AND status = 0 AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE)后续发布一个“回滚座位”的批量任务,处理已取消订单的座位释放。
定时任务用 Spring 自带的@Scheduled就行,单机够用。如果用 SpringBoot 的集群部署,就要考虑分布式锁避免多个节点重复处理同一批订单,不过那是进阶话题了,本项目用不上。
3.4 订单编号生成规则
订单编号我用的规则是:时间戳 + 随机数。具体格式是yyyyMMddHHmmss + 4位随机数字,再加用户 id 的后四位做收尾。例如:202506201530126518。
这样生成的订单号,能在一定程度避免重复。如果还要更严格,可以在数据库里加唯一索引:“用户id + 时间范围内的订单号唯一”,即使并发下偶发重复,插入时也会被数据库挡下来。
3.5 排片与场次冲突校验
排片管理有一个隐藏门槛:同一个影厅在同一时间不能安排两场电影。如果只是做一个单表的增删改查,业务很快就成型了,但排片一旦冲突,后面用户在现场就会看到两个场次混在一起。
我给排片接口加了一个冲突校验逻辑:新增场次时,检查同影厅、同时间段是否已存在场次。
// 伪代码:冲突校验 List<Schedule> schedules = scheduleMapper.selectByHallIdAndTimeRange( hallId, startTime, endTime); if (schedules.size() > 0) { throw new BusinessException("该影厅在所选时间段已有排片"); }这里的“时间段”指开始时间和结束时间的交叉区间,SQL 逻辑是:
SELECT COUNT(*) FROM schedule WHERE hall_id = #{hallId} AND status != 3 AND ((#{newStart} BETWEEN start_time AND end_time) OR (#{newEnd} BETWEEN start_time AND end_time) OR (start_time BETWEEN #{newStart} AND #{newEnd}))这个条件看着复杂,其实就是判断两个时间区间是否有重叠。实际开发中你只需要把区间重叠判断的四种情况列出来,边界情况多测几组,就不会漏。
3.6 登录认证:JWT
系统需要区分用户角色,我用的认证方式是 JWT。用户登录成功后,后端签发一个 token,前端存到本地存储里,后续请求在请求头带上Authorization: Bearer <token>。
JWT 的核心优点是服务端无状态,不需要在 Session 中保存登录信息。缺点是 token 一旦签发,在有效期内无法主动失效。管理系统的权限变更场景不多,可以接受。
我在 JWT 的 payload 里放了 userId、role 字段。后端写了一个拦截器,解析 token,把用户信息塞到 ThreadLocal 里,Service 层拿当前登录用户就比较方便:
public class UserContext { private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>(); public static void set(LoginUser user) { HOLDER.set(user); } public static LoginUser get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }拦截器里校验 token,校验通过就放行,校验失败返回 401。这里有个细节要留意:每次请求结束必须调用UserContext.clear(),否则线程池复用时,上一个用户的信息会被下一个请求读到,产生串号问题。这个坑我排查了很久才找到原因。
4. 数据库表设计:一次性把表结构和关系理清楚
这一节我单独拿出来讲,因为数据库设计是很多初学者容易忽视但实际极其重要的一环。一个差劲的表结构会导致后面写 SQL 时这里 join 那里嵌套,维护成本直接翻倍。
4.1 总体关系梳理
整个系统的实体关系和业务链可以这样串起来:
- 用户(普通用户/管理员)都在统一的一张用户表中,通过角色字段区分。
- 影片表和影厅表是两个基础资料表,本身无关联。
- 排片表把影片和影厅关联起来:一个影片可以有多场排片,一个影厅可以承接多场排片(不同时间)。
- 场次表与座位表通过 schedule_id 关联,某场次的座位状态单独存储。
- 订单表与用户表、场次表关联。
这样设计的好处是:数据模型分清了“资料数据”(影片、影厅)和“业务数据”(场次、订单),两者分开,互不干扰。
4.2 关键表的详细字段说明
我把几张核心表的建表 SQL 贴出来,大家可以直接参考这份结构做开发。
-- 用户表 CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL COMMENT '登录账号', `password` varchar(255) NOT NULL COMMENT '加密密码', `nickname` varchar(64) DEFAULT NULL COMMENT '昵称', `phone` varchar(20) DEFAULT NULL COMMENT '手机号', `role` tinyint NOT NULL DEFAULT '1' COMMENT '1-观众 2-管理员 3-超级管理员', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-正常 0-禁用', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 影片表 CREATE TABLE `film` ( `id` int NOT NULL AUTO_INCREMENT, `film_name` varchar(128) NOT NULL, `film_type` varchar(64) DEFAULT NULL, `duration` int DEFAULT NULL COMMENT '时长(分钟)', `poster_url` varchar(255) DEFAULT NULL, `director` varchar(64) DEFAULT NULL, `actors` varchar(255) DEFAULT NULL, `description` text, `publish_date` date DEFAULT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-上映中 0-已下架', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 场次表 CREATE TABLE `schedule` ( `id` int NOT NULL AUTO_INCREMENT, `film_id` int NOT NULL, `hall_id` int NOT NULL, `start_time` datetime NOT NULL, `end_time` datetime NOT NULL, `price` decimal(8,2) DEFAULT NULL, `stock` int NOT NULL DEFAULT '0', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-正常 0-停售', PRIMARY KEY (`id`), KEY `idx_film_start` (`film_id`, `start_time`), KEY `idx_hall_time` (`hall_id`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;-- 座位表(场次座位) CREATE TABLE `seat_occupancy` ( `id` int NOT NULL AUTO_INCREMENT, `schedule_id` int NOT NULL, `row_no` varchar(8) NOT NULL, `col_no` varchar(8) NOT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-可售 2-锁定 3-已售 4-维护', `user_id` int DEFAULT NULL COMMENT '锁座/购票用户', `order_id` int DEFAULT NULL COMMENT '关联订单', `version` int NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_schedule` (`schedule_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里解释一下为什么座位表里有个version字段:这就是乐观锁标记,专门用于解决并发抢座问题。每次更新座位状态时,SQL 会带上前一次查询到的 version 值,如果执行期间有其他事务改过这行数据,version 就变了,当前更新就会失败(影响行数为 0),从而实现“谁先改谁成功”。
4.3 建表时的几个通用建议
- 字符集统一用 utf8mb4:如果用户提交的文本里有 emoji 符号(比如影评里有 😂),用 utf8 会把数据截断或报错,utf8mb4 是它的完整超集。
- 金额字段用 decimal,不要用 double。电影票的金额虽然只有两位小数,但涉及到支付和财务,必须精确。float/double 在计算机里是浮点表示的,计算会产生精度误差,这在财务场景是不可接受的。
- 冗余字段要克制。比如场次表的 stock 是典型的冗余字段,设计时要明确它的维护方式(每次售/退都同步更新),想清楚之后才加。
5. 前端实现:Vue + Element UI 构建后台界面
前端这块我采用的是经典的后台管理结构:左侧菜单栏、顶部导航栏、右侧内容区。没有过度设计,用 Vue 生态里最常用的框架组合,效率很高。
5.1 前端目录结构
src/ ├── api/ // 接口封装 │ ├── film.js │ ├── schedule.js │ ├── order.js │ └── user.js ├── assets/ // 静态资源 ├── components/ // 通用组件 ├── router/ // 路由配置 ├── store/ // 状态管理 ├── views/ │ ├── film/ // 影片管理页面 │ ├── hall/ // 影厅管理页面 │ ├── schedule/ // 排片管理页面 │ ├── order/ // 订单管理页面 │ ├── user/ // 会员管理页面 │ └── dashboard/ // 统计报表页面 └── utils/ // 工具类(request封装、时间格式化等)把每个模块的页面放在对应目录下,维护起来非常直观。新增一个页面时,不需要全项目乱找代码位置,直接在对应目录里加文件就行。
5.2 请求封装与拦截器
我封装了一个request.js,基于 axios 统一处理请求:
- 请求拦截:从本地存储取出 token,添加到请求头;
- 响应拦截:解析统一返回值。如果 code 不是 200,用 Element UI 的 Message 组件弹出错误提示;如果 code 是 401,跳转登录页;
- 统一处理 token 过期。
这样前端所有接口调用处不需要重复写错误处理,代码干净很多。
// request.js 核心逻辑(简化版) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message({ message: res.message, type: 'error' }) if (res.code === 401) { router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { Message({ message: '网络请求异常', type: 'error' }) return Promise.reject(error) } )5.3 核心页面实现要点
选座页面
选座页面是这个系统中最有交互感的一部分。我用 CSS Grid 实现座位布局,普通座位是方块,选中时高亮变色,已售和锁定状态置灰且不可点击。数据是从后端接口一次拉取该场次的所有座位状态,前端根据状态字段判断渲染样式。
交互逻辑上,为了防止用户反复点击后端导致重复请求,我做了前端限制:最多选中 5 个座位,点击支付时一次性把座位 id 数组传给后端,后端统一处理。
排片管理页面
排片页面用的是 Element UI 的日期时间选择器 + 影片下拉框 + 影厅下拉框。提交排片时,把影片时长、开始时间传给后端,后端计算结束时间。我还在前端做了一个二次确认提示:“当前场次结束时间为 22:15,确认排片?”避免用户手动输入错误。
统计报表页面
统计报表我一开始用的是后端查询 + 前端 ECharts 渲染。后来发现,如果数据量不大,直接把后端聚合好的 JSON 返回前端,图表直接绑定,能省去前端再处理数据的步骤,逻辑简单可靠。
比如“近七天票房走势”后端接口返回:
[ { "date": "06-14", "boxOffice": 5200.00 }, { "date": "06-15", "boxOffice": 6300.00 } ]前端 ECharts 直接把数组塞进 series.data,一个简单的柱状图就完成了。
5.4 前端打包与部署
前端开发完成后,执行npm run build会生成 dist 目录,里面是纯静态文件。我可以把 dist 目录直接扔进后端项目的 static 目录,让 SpringBoot 同时提供静态资源服务和接口服务,这样部署只需要跑一个 Java 进程,非常省事。
这个方案的优点是简单,无需单独给前端配 Nginx,适合个人项目或学习环境。但如果你做的项目之后要上生产、要分离部署、要做负载均衡,那还是建议前后端分离部署,前端静态文件放在 Nginx 里,后端接口单独启动一个服务,通过 Nginx 反向代理。
6. 完整开发流程复盘:从 0 到 1 的操作顺序
很多同学拿到项目后第一反应就是“直接开始写代码”,然后被各种问题卡住。实际上,合理开发顺序能极大提高效率。我把自己的实操顺序整理在这里。
6.1 第一步:搭建项目骨架和数据模型
先创建 SpringBoot 项目,引入依赖(spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok、jjwt 等),配置好application.yml里的数据源。
然后按前面的表结构,把建表 SQL 在本地 MySQL 执行一次。遇到字段要微调(比如加了某个枚举值)就在这一步解决。表结构是一切的根基,后面代码都是围绕它来的。
6.2 第二步:编写实体类和基础 CRUD
按表结构生成对应的实体类。这一环节我用 Lombok 的@Data注解减少 getter/setter 的样板代码。
然后先实现最简单的“影片管理”模块,跑通一个完整的“后端查数据 → 返回 JSON → 前端表格展示”链路。这是项目管理中的“纵向切片”做法:先打通一条线,再横向铺开其他模块,而不是先把所有后端写完再一次性写前端。
6.3 第三步:按业务域顺序实现
我实现的顺序是:
- 用户模块(登录、注册、权限校验);
- 影厅模块(含座位生成);
- 影片模块(增删改查、上下架);
- 排片模块(含时间冲突校验);
- 选座购票模块(核心业务,事务+并发控制);
- 订单模块(状态流转、超时取消);
- 统计报表(SQL 聚合、ECharts 展示)。
这个顺序遵循一个原则:先做基础资料管理,再做依赖这些基础资料的业务功能。排片依赖影片和影厅,购票依赖排片和座位,逻辑依赖关系清楚了,依次实现自然顺畅。
6.4 第四步:联调和数据填充
前后端都写得差不多之后,开始联调。我发现很多问题是在这一步才暴露的,比如:
- 时间格式:后端返回的时间字符串是 "2025-06-20T14:30:00",前端显示有 T,还得再做格式化;
- 跨域问题:前端和后端不在同一端口,SpringBoot 没配 CORS,请求直接被挡。
最终我在后端的 WebMvcConfigurer 里统一配置了跨域:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .maxAge(3600); } }如果部署时前后端在同端口(前端扔进后端 static 目录),这个配置就不需要。但开发环境一个 8080 一个 5173,没跨域配置寸步难行。
为了看效果,我还手动往数据库插了一些模拟数据:6 部影片、4 个影厅(包含 8x8、6x10 两种规格)、以及未来三天的场次。这一步很重要,没有数据,前端页面和统计图表都是空的,联调无从谈起。
6.5 第五步:测试与优化
最后阶段我做了三类测试:
- 功能测试:每个接口用测试工具跑一遍,确认参数边界(比如负数、空值、超长字符串)不崩溃;
- 并发测试:用测试脚本模拟 20 个并发用户抢同一批座位,确认不会出现超卖;
- 兼容性测试:前端在不同浏览器(Chrome、Edge)上打开,确认样式不塌。
测试过程的产出不止是修 bug,还会发现很多设计不合理的地方,我调整了几处接口参数格式,加了一些必要的参数校验注解(@NotNull、@Min 等),更安全也更友好。
7. 常见问题与排查技巧实录
这部分是整篇里我觉得最有价值的内容。开发过程中遇到的坑,很多是教科书上不会写但实际必踩的,列出来供大家参考。
7.1 问题一:前端请求后端 404/无法访问
现象:前端页面正常启动,但每次调用接口都提示“Network Error”或者后端日志里收到 404。
原因:这类问题 80% 是路径问题。可能后端@RequestMapping路径写错了,或者前端api文件里 baseURL 没配对。
排查思路:
- 先用浏览器直接访问后端接口文档地址,看响应是否正确;
- 再核对后端
application.yml里的 context-path(如果设置了/api前缀,前端 baseURL 要对应加上); - 最后看浏览器控制台的完整报错信息,对比实际请求的 URL 和后端路由是否匹配。
7.2 问题二:LocalDateTime 序列化格式不对
现象:前端表格里时间显示为2025-06-20T14:30:00,带 T 字母,丑且不符合业务预期。
原因:默认的 Jackson 序列化器对 LocalDateTime 的处理方式,会输出 ISO 格式,不是我们习惯的yyyy-MM-dd HH:mm:ss。
解决方案:在application.yml中配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8或者用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")加在实体类的 LocalDateTime 字段上。
7.3 问题三:MyBatis 中大于小于号报错
现象:SQL 里写了start_time < #{param},报 XML 解析错误。
原因:MyBatis 的 XML Mapper 文件里,<会被当作 XML 标签开始符解析,直接报错。
解决方案:
- 用转义字符:
<代替<,>代替>; - 或者把比较条件写在 CDATA 区:
<![CDATA[ AND create_time < DATE_SUB(NOW(), INTERVAL 15 MINUTE) ]]>这是 MyBatis 用得越多越会碰到的坑,记牢。
7.4 问题四:超时取消任务重复执行
现象:订单明明已经取消了,日志里发现取消逻辑跑了两遍,报了一次“订单状态更新失败”。
原因:上一次定时任务还没跑完,下一次又触发了,同一个订单被两个任务实例同时处理。
解决方案:最简单的方案是在更新 SQL 里加上状态条件,只有“状态=待支付”的订单才能被修改为已取消,这样谁先更新成功,另一个自然影响行数为 0,不会破坏数据。
UPDATE orders SET status = 3 WHERE id = #{id} AND status = 0这种“乐观状态校验”的方式,在大多数业务场景下比引入分布式锁更务实。
7.5 问题五:前端跨域但是后端配置了 CORS 还是报错
现象:后端已经加了 CORS 配置,前端发起请求仍然提示跨域。
原因:如果通过 Spring Security 拦截了请求,跨域配置必须配合 Security 的过滤器链才能生效。本项目没有用 Spring Security,所以只需要在 WebMvcConfigurer 配置即可。但如果后续谁把 Spring Security 加进来,记住要在 SecurityConfig 中也允许对应跨域配置。
7.6 问题六:用户表同一密码不同用户查询超时
这个其实是我做性能优化时遇到的。用户量少,索引不明显,但数据量一旦到几十万,没加索引的查询就是全表扫描,慢是一定的。后来我给 user 表加了唯一索引(username),查询瞬间从 500ms 降到 1ms。
我的建议是:开发阶段没数据,看不出问题;但表结构一建立,就必须把索引想清楚,否则后面补索引时已经积累了一堆脏数据,迁移成本更高。
8. 一点个人经验体会
这个影城管理系统开发完,我最大的体会是:一个“完整”的管理系统,难点从来不在 CRUD 本身,而在于那些业务规则和边界条件。选座的并发控制、排片的时间冲突校验、超时订单的释放,这些才是项目真正有价值的地方,也是值得在面试里拿出来讲的东西。
另外我还想强调一下源码的整洁性。很多初学者把项目做完,代码堆在一起,命名混乱,注释缺失。这个项目我自己回头去看,凡是偷懒的地方(比如一个 Mapper 里放了几十个 SQL、Controller 里写业务逻辑),后期维护时都想骂自己。代码不是写完就结束了,它是给人看的。命名清晰、分层合理、逻辑内聚,这个习惯越早养成越好。
如果你正准备做类似的系统,我的建议是:先花两天时间把需求和数据表设计想透彻,再动手写代码。开发中遇到问题不要怕,用日志和断点去定位,而不是瞎猜。做完一个项目,你会发现在这个过程中踩过的坑,比看十本书都有用。