前阵子有朋友把一份毕设项目压缩包丢给我,文件夹名是 springboot_ssm872曲艺黄梅戏剧团管理系统哈尔。第一次看到这个命名,我的第一反应是:SpringBoot 和 SSM 怎么会同时出现在一个项目名里?后来打开源码发现,实际用的是 SpringBoot 做核心容器、MyBatis 做持久层,页面用 Thymeleaf 服务端渲染,跟常见的 SSM 毕设骨架一脉相承,只是套了一层 SpringBoot 的壳。这可能是大部分 Java 后端学习者都会遇到的项目类型:业务不算复杂,但流程完整,从需求梳理、建表到部署都有话可说。
哈尔的项目本身不复杂,难的是把散落在剧团线下的业务理清楚。写这篇文章,不是教你背代码,而是把我接手这个 SpringBoot 项目后的完整复盘过程整理出来:剧团业务到底涉及哪些模块、数据库表怎么设计、排期冲突和票务扣减的代码怎么写、打包上线会遇到什么坑。如果你也在做戏曲院团、文化场馆这类管理系统,或者只是想找一个结构完整的 SpringBoot 项目练手,可以直接把我的方案拿过去改。
1. 做系统前先搞明白:一个剧团到底要管什么
1.1 从Excel到系统:一个真实的业务场景
很多县级剧团实际运营中还在用纸质登记表加 Excel。团长排节目靠手写日历,演员档期靠人工电话确认,服装道具领用靠白条,票务更是卖多少记多少。哈尔最初给我的需求文档只有一句话:“做一个能给剧团用的管理系统,管演员、管演出、管票”。
听起来简单,但实际坐下来聊业务才发现,问题集中在几个具体场景:
- 同一个演员在同一时间段被安排到两场演出,到临近演出前一周才发现,临时找人顶替非常被动。
- 剧目信息散落在不同 Word 文档里,剧本、时长、主演、伴奏带,每次改都要重新传一遍。
- 票务完全靠手工登记,卖出多少、退掉多少、哪些座位还空着,根本统计不清。
- 戏服和道具没有台账,一场演出用了几件、还了几件、损坏了怎么算,都是糊涂账。
这些场景才是系统要解决的“真需求”。管好一个剧团,不只是存几份演员档案那么简单。
1.2 按角色拆需求,比直接堆功能靠谱
我拿到这种业务型项目,第一步永远不是建表,而是先列角色。剧团里大概有四类人会使用系统,他们关心的事情完全不同:
| 角色 | 关心的事 | 需要的功能 |
|---|---|---|
| 团长/管理员 | 演出安排是否冲突、整体收入、剧目库完整度 | 排期管理、统计报表、基础数据维护 |
| 演员 | 自己最近什么时候有演出、演什么角色 | 查看个人日程、查看剧目信息 |
| 票务人员 | 每场还剩多少票、卖了多少、怎么退票 | 售票、退票、余票统计 |
| 服装道具管理员 | 有哪些服装道具、谁借走了、什么时候还 | 借出登记、归还登记、库存查询 |
从这个表能看出来,系统不是一个“全能后台”,而是分角色的工作台。哈尔最开始想做一个大而全的管理页面,把所有功能堆在一张首页上,后来被我按角色拆成四个功能区,清晰了很多。
1.3 需求优先级:先解决冲突,再解决统计
需求优先级我分了三层:
- P0:演员管理、剧目管理、演出排期、排期冲突检测。没有这些,系统就只是花架子。
- P1:票务管理、服装道具借还。这是剧团日常运转最频繁的环节。
- P2:收入统计、Excel 导入导出、操作日志。这些属于“锦上添花”,但做了之后会让系统显得完整。
哈尔项目里最终功能模块就是按这个优先级落地的,先保证核心业务流程能跑通,再谈报表和体验。这个顺序对于同类毕设项目特别重要,很多同学一上来就做炫酷图表,结果连排期冲突都还没处理,答辩时很容易被问住。
2. 技术选型复盘:SpringBoot和SSM到底怎么组合的
2.1 理清概念:SSM不是不能和SpringBoot共存
很多初学者看到“springboot_ssm872”这个项目名会蒙:SpringBoot 不是已经包含了 Spring 和 SpringMVC 吗,为什么还要提 SSM?
这里要把概念理清。SSM 指 Spring + SpringMVC + MyBatis 三个框架组合,原本是 SSH(Spring + Struts + Hibernate)之后很流行的 Java Web 开发方式。SpringBoot 不是一个替代 SpringMVC 的新框架,而是对 Spring 全家桶的自动配置封装,它内部依然用 SpringMVC 处理 Web 请求,依然可以通过 starter 集成 MyBatis。
所以哈尔这个项目实际的技术结构是:
- SpringBoot 作为项目基础框架,负责自动配置、内嵌 Tomcat、依赖管理。
- SpringMVC 负责 Controller 层路由和参数绑定。
- MyBatis 负责持久层 SQL 映射。
- Thymeleaf 负责服务端页面渲染。
用一句话概括就是:以 SpringBoot 为壳,以 SSM 为核。这种组合在很多毕设项目里非常常见,比传统 SSM 省去了一堆 XML 配置,但核心的分层思想没有变。
2.2 项目里的Controller、Service、Mapper怎么分工
我接手哈尔代码时,发现他把所有业务逻辑写在 Controller 里,这是一个典型问题。后来重构成了标准三层:
- Controller:接收请求、参数校验、返回页面或 JSON。
- Service:处理业务逻辑,比如排期冲突、票务扣减、借还状态变更。
- Mapper:只负责数据库增删改查,SQL 写在 XML 里。
这种分层不是形式主义。比如排期冲突检测,如果写在 Controller 里,当你后面同时开放网页端和接口端时,同一段逻辑要复制两份,改一处漏一处。放在 Service 层,所有调用方共用同一套规则,这才是正确做法。
2.3 核心依赖:pom.xml里到底放了什么
哈尔的原项目用了很老的依赖版本,我帮他把 pom.xml 梳理了一遍。一个标准的 SpringBoot + MyBatis + Thymeleaf 项目,核心依赖大概是这样:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里有个细节:mybatis-spring-boot-starter 并不是 SpringBoot 官方维护的,而是 MyBatis 团队自己出的,所以必须写版本号。很多同学在这里只用spring-boot-starter-parent管版本,结果依赖报错,半天查不出来。
2.4 为什么不做前后端分离
哈尔一开始问我要不要上 Vue + SpringBoot 前后端分离,我劝他别。原因是:
- 这是一个管理后台型项目,页面数量不多,服务端渲染完全够用。
- 前后端分离意味着要处理跨域、Token 刷新、接口文档、前端构建部署,工作量直接翻倍。
- 毕设答辩时,面试官或老师更看重业务逻辑和数据库设计,前端用 Thymeleaf 配合 Bootstrap 反而清爽。
等技术功底扎实了,再把它改造成 Vue 版也不迟。先把业务做完整,比过度设计重要得多。
3. 数据库设计:把剧团业务翻译成八张表
3.1 八张核心表和它们的关系
哈尔最初的建表脚本只有演员表和剧目表,后来根据业务场景补到了八张。这八张表基本覆盖了一个中小型剧团的日常管理:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 系统用户 | username, password, real_name, role |
| actor | 演员档案 | name, stage_name, role_type, specialty |
| repertoire | 剧目库 | title, type, duration_minutes, director |
| performance_schedule | 演出排期 | repertoire_id, start_time, end_time, venue, status |
| schedule_actor | 演出与演员关联 | schedule_id, actor_id, character_name |
| ticket | 票务 | schedule_id, seat_no, price, status, order_no |
| costume_props | 服装道具 | name, type, quantity, status, keeper |
| income_record | 收入记录 | schedule_id, amount, settle_time, remark |
关系上,一个剧目可以有多场演出,一场演出可以同时有多个演员,所以performance_schedule和actor之间通过中间表schedule_actor关联。这种设计在面试和答辩时经常会被问,属于标准的“多对多”建模。
3.2 排期表怎么设计才能查出“谁在这个时段有活”
排期冲突检测是剧团管理系统里最核心的功能,表结构设计直接影响 SQL 写起来顺不顺手。
我的做法是在performance_schedule里直接冗余start_time和end_time两个字段,而不是只存一个演出日期。这样可以精确到小时,方便处理“下午场”和“晚场”的情况。
schedule_actor中间表里记录每个演员在某场演出中的角色名,这样查询“某演员某天是否有演出”就能通过关联查询快速实现:
SELECT sa.actor_id, ps.start_time, ps.end_time, ps.venue FROM schedule_actor sa JOIN performance_schedule ps ON sa.schedule_id = ps.id WHERE sa.actor_id = #{actorId} AND ps.status = 1 AND #{endTime} > ps.start_time AND ps.end_time > #{startTime}这条 SQL 的关键是区间重叠判断:只要新演出的开始时间早于已有演出的结束时间,并且新演出的结束时间晚于已有演出的开始时间,就说明两个时段存在重叠。这是排期冲突检测的基础。
3.3 票务表别只设计成“卖了多少张”
票务表如果只放一个“已售数量”,系统很容易出现超卖问题。正确做法是每张票独立成行,也就是“一票一行”:
CREATE TABLE ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, seat_no VARCHAR(20) NOT NULL, price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待售 1-已售 2-已退票', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', buyer_name VARCHAR(50), sale_time DATETIME, UNIQUE KEY uk_schedule_seat (schedule_id, seat_no) );UNIQUE KEY uk_schedule_seat保证同一场演出同一个座位不会出现两张票,这是数据库层面的底线。version字段则用于乐观锁,后面代码部分会详细讲。
3.4 通用字段和状态字段:被很多人忽略的细节
哈尔原表里没有create_time、update_time、del_flag这些字段,我全部补上了。原因很简单:
- 出问题时要能查记录什么时候创建、什么时候修改。
- 删除演员或剧目不要物理删除,用
del_flag逻辑删除,避免误删后数据找不回。
另外,凡是涉及状态的表,我都用一个status字段管理,比如演出排期有“待确认”“已确认”“已结束”“已取消”。用数字代替字符串,查询效率更高,也方便后续扩展状态机。
4. 后端核心逻辑:从登录鉴权到票务扣减的实现细节
4.1 登录鉴权:用拦截器而不是Spring Security
这个项目没有必要引入 Spring Security,太重了。用 SpringBoot 的拦截器配合 Session 就能实现基础登录鉴权。
自定义一个拦截器:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // 未登录,重定向到登录页 response.sendRedirect("/login"); return false; } return true; } }再注册到配置类里:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/css/**", "/js/**", "/images/**"); } }这里有三个细节值得注意:
- 必须排除静态资源路径,否则页面一张图都加载不出来。
- 登录接口本身也要排除,不然永远进不去。
- 我习惯把
loginUser存到 Session,而不是用一个全局静态变量,避免多用户相互覆盖。
4.2 排期冲突检测:数据库查一次,Java再校验一次
前面讲表结构时已经给出 SQL,这里补上 Service 层的完整逻辑。为什么不能只依赖数据库?因为调用方可能同时提交多条排期,单条 SQL 查不出彼此之间的冲突。
我写了一个通用方法:
public boolean checkActorAvailable(Integer actorId, LocalDateTime startTime, LocalDateTime endTime, Integer excludeScheduleId) { ScheduleActorExample example = new ScheduleActorExample(); example.createCriteria() .andActorIdEqualTo(actorId) .andScheduleIdNotEqualTo(excludeScheduleId); List<ScheduleActor> list = scheduleActorMapper.selectByExample(example); for (ScheduleActor sa : list) { PerformanceSchedule ps = performanceScheduleMapper.selectByPrimaryKey(sa.getScheduleId()); if (ps != null && ps.getStatus() == 1) { if (startTime.isBefore(ps.getEndTime()) && endTime.isAfter(ps.getStartTime())) { return false; } } } return true; }excludeScheduleId参数很重要,编辑已有排期时,要排除自己,否则系统会认为“修改后的演出”和“原来的自己”冲突。
4.3 票务扣减:乐观锁防超卖
票务系统最怕并发,两个人同时买同一张票,可能两个人都买成功。用悲观锁SELECT ... FOR UPDATE也行,但毕设项目用乐观锁更简单,也更容易解释。
核心思路是在更新时带上版本号:
int rows = ticketMapper.sellTicket(ticketId, buyerName, version); if (rows == 1) { // 抢票成功,记录操作日志 } else { // 版本号不匹配或票已经被卖出,说明别人的请求先成功了 throw new RuntimeException("该座位已被购买,请重新选择"); }对应的 Mapper XML:
<update id="sellTicket"> UPDATE ticket SET status = 1, buyer_name = #{buyerName}, sale_time = NOW(), version = version + 1 WHERE id = #{id} AND status = 0 AND version = #{version} </update>注意 WHERE 条件里的status = 0是兜底,即使版本号碰巧一致,已售出的票也不能再卖第二次。这种双条件校验特别容易在面试中被问,实际上就是乐观锁加上业务状态校验。
4.4 服装道具借还:一张记录表就能管好
服装道具模块不需要太复杂,核心是借出时把数量扣减,归还时把数量加回,并记录经手人和时间。
我在costume_props表里加了lent_quantity字段,总数量减去已借数量就是可借数量。每次借出都更新这个字段,并往costume_props_log表插一条记录,方便追溯。这个表虽然没有单独列在核心表里,但实际项目中必须有,否则借出归还的账目对不上。
5. 页面和交互设计:让演员也愿意用系统
5.1 Thymeleaf + Bootstrap,简单但够用
哈尔最初想在页面里堆一堆图表,被我拦下了。这个系统的主要使用人群是剧团的工作人员,他们更在意的是“点几下能不能完成操作”,而不是页面特效。
页面端我用 Thymeleaf 模板加上 Bootstrap 4,没有引入复杂的前端框架。好处是:
- Thymeleaf 可以直接在 HTML 里写服务端数据,不用额外封装接口。
- Bootstrap 自带栅格和组件,日期选择器、下拉框、模态框都有现成方案。
- 对哈希的前端基础要求很低,改起来也快。
5.2 排期日历是整个系统的灵魂页面
所有角色里我最看重排期日历页面。团长不用看密密麻麻的表格,一眼扫过去就知道哪天有空档,哪个演员当天有连场。
实现上不需要真的引入 FullCalendar 插件,用 Bootstrap 的卡片列表加时间分组就足够了。每场演出显示剧目名、剧场、开始时间和结束时间,点击卡片可以查看演员名单。后台再跟上色规则,演员有冲突的排期用红色标注,没有冲突的用绿色标注。
这个页面实际上是把 4.2 的排期冲突检测结果可视化,代码不多,但对用户体验的提升非常明显。
5.3 前端校验和服务端校验两条腿走路
页面上表单一定要做前端校验,比如“剧目名不能为空”“结束时间不能早于开始时间”,可以用 jQuery Validate 或直接写原生 JS。但只做前端校验是远远不够的,因为接口可以被绕过。
我在后端使用了@Validated和@NotNull这类注解做参数校验。比如接收排期创建请求时:
public class ScheduleCreateRequest { @NotNull(message = "剧目ID不能为空") private Integer repertoireId; @NotNull(message = "开始时间不能为空") @JsonFormat(pattern = "yyyy-MM-dd HH:mm") private LocalDateTime startTime; @NotNull(message = "结束时间不能为空") @JsonFormat(pattern = "yyyy-MM-dd HH:mm") private LocalDateTime endTime; }双端校验的好处是:正常用户不会被表单错误提示打扰,恶意请求也不会直接打进 Service 层。
5.4 按角色隐藏按钮,而不是只靠前端控制
权限控制如果只在前端用th:if判断角色,懂一点前端知识的人改一下页面源码就能绕过。我更推荐在 Service 层做二次判断。
举一个例子:只有管理员能删除剧目,票务人员只能卖票,不能删票。这部分逻辑我会写在删除方法里:
if (!"ADMIN".equals(currentUser.getRole())) { throw new RuntimeException("无权删除剧目"); }后端权限校验才是真正的边界,前端隐藏按钮只是让界面更干净。
6. 打包上线和踩坑记录:jar包能跑只是第一步
6.1 从开发环境到服务器:外部配置覆盖一切
哈尔第一次把项目打成 jar 包放到服务器上,直接启动失败,原因是他把数据库地址写死在本地的application.properties里。
正确的做法是准备多套配置:
# application-dev.properties spring.datasource.url=jdbc:mysql://localhost:3306/drama?useUnicode=true&characterEncoding=utf8mb4 spring.datasource.username=root spring.datasource.password=123456# application-prod.properties spring.datasource.url=jdbc:mysql://192.168.1.100:3306/drama?useUnicode=true&characterEncoding=utf8mb4 spring.datasource.username=drama spring.datasource.password=生产密码别写仓库里启动时用参数指定环境:
java -jar drama-system.jar --spring.profiles.active=prod这样本地和服务器配置互不干扰,再也不用每次部署前改代码。
6.2 静态资源404:Thymeleaf的路径坑
部署后访问登录页发现 CSS 全部丢失,控制台报 404。原因是我在模板里写了绝对路径:
<link rel="stylesheet" href="/css/bootstrap.min.css">本地开发没问题,但服务器 jar 包运行时,项目根路径不一定是/,如果部署在 Tomcat 二级目录下就找不到。
处理方法有两种:要么用 Thymeleaf 的th:href="@{/css/bootstrap.min.css}"自动处理上下文路径,要么在application.properties里把server.servlet.context-path固定成一个子路径。建议用前者,少改后端。
6.3 MySQL字符集:黄梅戏演员的生僻字不能乱码
这个坑特别有意思。黄梅戏演员表里有人名字带“翾”“曌”这类生僻字,如果数据库连接串里用characterEncoding=utf8,这些字入库后会变成问号。
解决方法是建库时直接指定 utf8mb4:
CREATE DATABASE drama_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;连接串也要同步改成:
spring.datasource.url=jdbc:mysql://localhost:3306/drama?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/ShanghaiserverTimezone=Asia/Shanghai也是必须的,否则服务器时区和 MySQL 时区不一致,时间字段会差八个小时。
6.4 1核2G服务器也能跑:JVM参数调一调
很多毕设项目租的是最便宜的云服务器,1核2G内存。如果直接java -jar启动,SpringBoot 默认堆内存可能占到四五百兆,再叠加 MySQL,内存很容易爆。
我给哈尔的启动脚本是:
java -Xms256m -Xmx512m -jar drama-system.jar \ --spring.profiles.active=prod \ --server.port=8080-Xms256m是初始堆大小,-Xmx512m是最大堆大小。配合 Linux 的nohup后台运行:
nohup java -Xms256m -Xmx512m -jar drama-system.jar > /logs/drama.log 2>&1 &这套配置跑一个几百人用的内部管理系统完全够用,关键是别在服务器上跑多余的服务。
6.5 数据库备份:mysqldump定时任务
数据备份是很多自认为“上线成功”的项目最容易忽略的环节。我用 Linux 的 crontab 做了每天凌晨的自动备份:
0 2 * * * /usr/bin/mysqldump -uroot -p密码 drama_system > /backups/drama_$(date +\%Y\%m\%d).sql注意 crontab 里%需要转义,写成$(date +\%Y\%m\%d),否则任务不会执行。这个细节我印象很深,因为当时我漏写了转义,备份文件一直生成不了,排查了很久才发现是%被 cron 特殊处理了。
备份文件保留最近三十天,用一条 find 命令定期清理:
find /backups -name "drama_*.sql" -mtime +30 -delete数据这东西,平时觉得没用,真丢一次就知道疼了。
最后再补一个很多人不会注意的细节:给所有下拉框能选的就别让用户手输,日期能选的就别让用户手动填,按钮操作完成一定要有明确的成功或失败提示。这套系统上线后最受欢迎的功能不是那些看起来很厉害的统计图,而是排期日历和借还记录,因为这两件事让剧团老师少接了很多电话。我的体会是,技术选型再新,都不如把业务流程理清楚、把易用性做到位来得重要。