从大三下学期开始,周围同学陆陆续续找我参谋毕业设计选题。轮到我自己的时候,我很清楚一个道理:绝对不做“烂大街”的图书管理系统,也不碰那种听起来很酷但根本跑不通的AI项目。最终我选的是基于SpringBoot的赛事报名系统,也可以叫高校学科竞赛管理平台。这个题目听起来平平无奇,但它能覆盖的SpringBoot技术点非常多:认证授权、事务控制、文件上传、Excel导出、定时任务、状态机流转,甚至消息通知,全都塞得进去。论文也有得写,答辩也有得讲。
这篇博客我打算用“复盘”的口吻把整个项目从选题到部署完完整整捋一遍,重点放在SpringBoot落地时那些文档里查不到、老师也不一定会讲的操作细节上。
1. 选题定调:赛事报名系统到底要做什么才不算“增删改查”
先把你手上的需求梳理清楚。号称“赛事报名系统”的项目很多,但绝大多数都做成了三个页面:学生注册登录、管理员发布比赛、学生报名。这确实就是增删改查。为了让它变成一份合格的毕业设计,我给它加入了一个关键视角:竞赛活动的完整生命周期管理。
赛事从筹备到结束,不只是“发布”和“报名”两个动作。真实的高校学科竞赛场景里有这些环节:
- 学院管理员创建比赛,填写比赛名称、级别(校级/省级/国家级)、类别(数学建模、电子设计、程序设计)、报名开始时间、报名截止时间、比赛时间、比赛地点、参赛人数上限、参赛形式(团队/个人)。
- 学生浏览比赛列表,查看详情,之后提交报名。如果是团队赛,还要填写队员信息、指导教师信息。
- 教师或教务处管理员审核报名资格,驳回时要写理由。
- 比赛结束后录入成绩,系统根据奖项规则(一等奖10%、二等奖20%等)生成获奖名单。
- 导出Excel报名表、成绩表,用于教务处留档。
选题的时候还要想清楚一个问题:用户角色有几种。我最终设计了三种角色:学生(学生端)、教师/学院管理员(审核端)、系统管理员(平台端)。没有用复杂的RBAC权限框架,就靠Spring Security的@PreAuthorize和自定义拦截器,完全够用,而且讲解起来也清晰。
毕业后你会发现,这类系统放到真实的学校场景里,性能要求不高,但状态一致性要求很高。比如一个比赛报名人数满了,就不能再让其他人报进来;一个学生不能重复报名同一场比赛;比赛开始后,报名通道要自动关闭。这些业务规则听起来简单,实际写代码时全是容易出Bug的地方。这也是我后来写论文时反复强调的重点:用状态机管理赛事和报名的生命周期,而不是靠一堆if/else散落各处。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:SpringBoot版本和配套组件怎么定
2.1 SpringBoot版本:别盲目追新,2.7.18是毕设的安全区
打开Spring Initializr的时候,默认给的SpringBoot版本可能是3.2、3.3甚至更高。但我想给所有做毕设的同学泼一盆冷水:除非你有特殊需求,否则直接选2.7.x系列,最好是2.7.18。 原因很实在:
第一,SpringBoot 3.x强制要求JDK 17及以上,而大多数高校的实验环境和电脑上装的还是JDK 8。你写JDK 17的代码,放到老师验机的机器上跑不起来,场面会很难看。
第二,很多教程、CSDN博客、学长学姐留下的代码都是基于SpringBoot 2.x的,网上能搜到的报错解决方案也主要集中在2.x版本。你选3.x,遇到一个奇怪的报错,搜到的答案全是2.x的写法,还得做API迁移,浪费时间。
第三,SpringBoot 2.7.18是2.x系列的最终维护版本,安全性不差,稳定性极好,国内大量企业项目还在用这个版本。
对应的配套很明确:
| 组件 | 版本/选择 | 原因 |
|---|---|---|
| JDK | 1.8 或 11 | 兼容性最好,验证环境友好 |
| SpringBoot | 2.7.18 | 2.x最终版,教程多,稳定 |
| MySQL | 8.0 或 5.7 | 学校机房大概率装了,驱动兼容 |
| MyBatis-Plus | 3.5.3.x | 代码生成、分页查询方便,省时间 |
| Redis | 可选,建议不装 | 毕设阶段非必须,全靠MySQL也能跑 |
| 前端框架 | Vue 2 + Element UI | 与SpringBoot 2.x时代匹配,教程多 |
如果你非要上SpringBoot 3.x,那就要做好心理准备:javax.servlet换成了jakarta.servlet,Spring Security的配置方式变了,MyBatis-Plus需要升级到3.5.5以上才能适配。这些没有一项是毕设阶段该花时间去折腾的。
2.2 前后端分离还是服务端渲染
很多同学在选题初期会纠结这个问题。我的建议很简单:如果前端基础一般,直接做前后端分离,但后端不要试图涉及太复杂的鉴权逻辑。
我是用SpringBoot写纯RESTful API,前端用Vue2 + Element UI + Axios。为什么要这样?因为现在的毕设论文里,画架构图的时候,前后端分离的图比服务端渲染的图好看得多,也更能体现“学会了一个完整的Web开发模式”。而且答辩时老师很可能会问“你怎么处理跨域”和“Token存在哪里”这类问题,回答好了是加分项。
但这个方案有一个代价:你需要额外写一层前端的路由守卫、登录状态管理和接口封装。我自己的做法是参考了若依(RuoYi)这种经典开源项目的思路,但没直接复制,因为直接抄开源项目是作弊,答辩也圆不回来。
2.3 核心依赖清单
pom.xml里我最常用的依赖就是这几个,列出来给正在配置环境的同学参考:
xml复制<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-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version>
</dependency>
</dependencies>
这个组合的好处是:上手快、社区资料多、不会在依赖冲突上卡太久。EasyExcel在做报名名单导出时是神器,别看Apache POI功能强,写一大堆样板代码,EasyExcel两行注解就能搞定。
3. 数据库设计:报名系统的核心表结构与状态流转
3.1 六张核心表的设计思路
这个系统的数据库设计是整个项目的地基。我最初的核心表就六个,但每个字段选择都有讲究。
第一张是sys_user用户表,包含id、username、password、real_name、student_no(学号)、college(学院)、phone、role(角色)、status(状态)、create_time。学号字段我做了唯一索引,这在后面做报名资格校验时非常有用。
第二张是competition赛事表。关键字段有id、title(比赛名称)、level(校级/省级/国家级)、category(比赛类别)、type(个人/团队)、max_team_members(团队最大人数)、max_applicants(总报名人数上限)、register_start_time、register_end_time、competition_time、location、description、status(草稿/报名中/已截止/已结束)、creator_id。
第三张是competition_registration报名表,它的字段会很讲究:id、competition_id、user_id、team_name(团队名称)、member_list(团队成员JSON字符串或关联表)、teacher_name(指导教师)、status(待审核/已通过/已驳回/已取消)、audit_comment(审核意见)、create_time。
第四张是audit_log审核日志表,用于记录谁在什么时间把报名状态从A改成了B,以及操作备注。千万别小看这张表,答辩时老师说“你这个系统有没有过程留痕”,就靠它回答。
第五张是score成绩表,包含registration_id、score、prize_level、reviewer_id、remark。它和报名表通过registration_id一对一关联。
第六张是notice公告表,用于发布赛事通知、获奖公示等。
用外键吗?我的答案是:不要用物理外键,只保留逻辑外键字段。 这是企业开发里很常见的做法。物理外键会影响插入和删除性能,而且毕设阶段你的数据量根本到不了需要外键保证一致性的程度。在代码里通过事务和业务条件来控制一致性更灵活。
3.2 赛事状态机:把状态流转画进代码里
比赛状态不能想改就改。我定义了四个状态:DRAFT(草稿)、REGISTERING(报名中)、CLOSED(已截止)、FINISHED(已结束)。状态流转规则只有四条:
- DRAFT -> REGISTERING(管理员手动发布)
- REGISTERING -> CLOSED(报名截止时间到了自动触发,或管理员手动截止)
- REGISTERING -> DRAFT(管理员撤回,仅限还没人报名时)
- CLOSED -> FINISHED(录入全部成绩后手动结束)
在这个设计基础上,报名接口的校验逻辑就非常清晰了:
java复制public void register(RegistrationDTO dto) {
Competition competition = competitionMapper.selectById(dto.getCompetitionId());
// 只有报名中状态才能报名
if (competition.getStatus() != CompetitionStatus.REGISTERING) {
throw new BusinessException("当前不在报名时间内");
}
// 时间校验:用数据库时间对比,避免服务器时间被修改
Date now = new Date();
if (now.before(competition.getRegisterStartTime()) || now.after(competition.getRegisterEndTime())) {
throw new BusinessException("不在报名时间窗口内");
}
// 人数校验:使用数据库计数+唯一索引兜底
Long count = registrationMapper.selectCount(
new LambdaQueryWrapper<CompetitionRegistration>()
.eq(CompetitionRegistration::getCompetitionId, dto.getCompetitionId())
.ne(CompetitionRegistration::getStatus, "CANCELLED")
);
if (count >= competition.getMaxApplicants()) {
throw new BusinessException("报名人数已满");
}
// 防重复报名
Long duplicate = registrationMapper.selectCount(
new LambdaQueryWrapper<CompetitionRegistration>()
.eq(CompetitionRegistration::getCompetitionId, dto.getCompetitionId())
.eq(CompetitionRegistration::getUserId, SecurityUtils.getUserId())
);
if (duplicate > 0) {
throw new BusinessException("您已报名该比赛,请勿重复报名");
}
// 落表
...
}
有人可能会问:先查再插,会不会有并发问题?如果同时有1000个人抢最后一个名额,查询的时候都发现没满,然后一起插入,会不会超员?答案是可能。但毕设阶段你不一定需要用到Redis分布式锁或者数据库悲观锁。一个简单的兜底方案是:在competition_registration表上加一个联合唯一索引 (competition_id, user_id),然后在插入时捕获DuplicateKeyException,捕获后统一提示“您已报名”。这比上锁从易用性角度简单得多,性能还稳定。这个技巧我会在答辩时专门提一句,老师会觉得你想过并发问题。
3.3 报名表里的“冗余字段”是刻意为之
报名表里我存了competition_title这个冗余字段。有人会说这不符合数据库第三范式。为什么还这么做?因为列表页展示“我报名的比赛”时,如果每次都要去关联查询competition表,会多一次JOIN,而且报名记录一旦提交,比赛标题后续被管理员改掉,历史报名记录里的标题依然要保留当时的信息。这就是一种快照设计。毕设文档里写清楚“这里做了空间换时间,保留历史快照”,老师反而会觉得你理解了反范式设计的价值。
4. 后端落地:认证、赛事管理、报名与成绩模块的实现细节
4.1 基于JWT的登录认证,替换掉Session
我为什么要用JWT而不是基于Session的登录?除了迎合前后端分离之外,还有一个很重要的原因:SprngSecurity配合JWT做出来的登录逻辑,比传统的Session方案更能体现你对安全性的理解。
实际实现流程是这样:
- 前端提交
username和password到/api/auth/login。 - 后端调用
AuthenticationManager做认证,成功后使用Jwts.builder()生成Token,有效期设2小时。在Token的claims里塞入userId和role。 - 前端把Token存在
localStorage中,之后每一个请求在拦截器里加上Authorization: Bearer <token>头。 - 后端写一个
JwtAuthenticationFilter,继承OncePerRequestFilter,解析Token,把用户信息放进SecurityContextHolder。
这里最关键的坑是:JWT的密钥不能写死在前端,也不能只写在application.yml里,必须放到环境变量或配置中心。 我当时的做法是在application.yml里用${JWT_SECRET:defaultSecretKey}来配置,本地没有设置环境变量时用默认值,生产/演示环境设置JWT_SECRET环境变量。这个细节写进论文里,老师会注意到你考虑过密钥管理。
SecurityConfig类里的核心配置大致长这样:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/auth/login", "/api/competition/list", "/api/competition/detail/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers("/api/teacher/**").hasRole("TEACHER")
.anyRequest().authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(restAuthenticationEntryPoint());
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
注意SpringBoot 2.7里面WebSecurityConfigurerAdapter还能用,虽然已被标记@Deprecated,但不影响运行和文档讲解。如果用了SpringBoot 3,Security的写法会变成基于SecurityFilterChain的Bean注入,又是另一种风格了,这也是老生常谈“版本太高”带来的麻烦之一。
4.2 核心业务接口:从Controller到Service的完整链路
我现在把报名模块的接口设计列出来,供参考:
| 方法 | 路径 | 角色 | 说明 |
|---|---|---|---|
| POST | /api/auth/login | 匿名 | 登录获取Token |
| GET | /api/competition/list | 任意登录用户 | 分页查询比赛列表(支持状态过滤) |
| GET | /api/competition/detail/ | 任意登录用户 | 查询比赛详情及当前用户报名状态 |
| POST | /api/registration/submit | 学生 | 提交报名 |
| GET | /api/registration/my | 学生 | 查询我的报名记录 |
| POST | /api/registration/cancel/ | 学生 | 取消报名(在截止时间前) |
| GET | /api/admin/registration/list | 教师/管理员 | 按赛事实名分页审核 |
| POST | /api/admin/registration/audit | 教师/管理员 | 审核通过/驳回 |
| POST | /api/admin/score/entry | 教师/管理员 | 录入成绩 |
| GET | /api/admin/export/registrations/ | 教师/管理员 | 导出报名名单Excel |
Controller层的代码尽量薄,只做参数接收、结果封装和异常转换,业务逻辑全部下沉到Service层。
以“提交报名”为例,我在Service层的实现里做了三件容易被忽略的事:
第一,从SecurityContextHolder取当前登录用户,绝不信任前端传来的userId参数。否则别人改一下参数就替别人报名了。
第二,使用@Transactional。虽然这个例子里只插了一张表,但团队赛时还要插入成员明细,多个写操作必须在一个事务里,保证要么全部成功要么全部回滚。
第三,异步发送通知。报名成功后,给学生的站内信或邮件通知用@Async方法发送。这里我特意测试了一个SpringBoot经典坑:同一个类内部调用@Async方法会失效。因为Spring的AOP代理在类内部直接调用时不会经过代理对象。正确做法是把异步发送逻辑放在另一个Service类里,从外部注入调用。这个坑特别适合写进论文“踩坑与解决方案”那一章。
成绩模块的录入逻辑里,还有个比较能体现业务思考的点:成绩录入不是直接更新分数,而是生成一条score记录,同时更新registration的状态为FINISHED。如果比赛是团队赛,成绩是挂在团队报名记录上的。要算学院排名的时候,就根据competition.level给每个奖次赋分,比如国家级一等奖10分、省级一等奖5分。这个积分逻辑虽然不是系统的核心功能,但我做了个小页面展示“学院积分排行”,毕设演示的时候视觉冲击力很强,老师也会觉得这个系统不只是报名工具,还能做数据分析。
4.3 文件上传和Excel导出:毕设演示时的“动态亮点”
一个静态的报名系统很无聊。动态亮点我选了三个:第一,管理员可以上传比赛通知的PDF附件;第二,成绩录入后可以导出获奖名单Excel;第三,用定时任务在比赛截止时间自动锁闭报名。
Excel导出我用EasyExcel分页查询,避免把全表数据一次性加载到内存。代码大概长这样:
java复制@GetMapping("/export/registrations/{competitionId}")
public void exportRegistrations(@PathVariable Long competitionId,
HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode("报名名单_" + competitionId, "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
BaseCompetitionService baseCompetitionService = null;
EasyExcel.write(response.getOutputStream(), RegistrationExportVO.class)
.sheet("报名名单")
.doWrite(() -> {
// 分页查询,每页1000条,流式写出
return registrationService.pageRegistrationsForExport(competitionId, 1, 1000);
});
}
那些“URLEncoder编码文件名”和“流式分页写”的小细节,平时没人讲,但面试官和答辩老师就爱问这些。
下面说定时任务。我用Spring自带的@Scheduled,在每天凌晨0点扫描所有状态为REGISTERING且register_end_time已过的比赛,把状态改成CLOSED。
java复制@Component
public class CompetitionStatusScheduler {
@Scheduled(cron = "0 0 0 * * ?")
public void autoCloseExpiredRegistrations() {
Date now = new Date();
int updated = competitionMapper.update(null,
new LambdaUpdateWrapper<Competition>()
.eq(Competition::getStatus, CompetitionStatus.REGISTERING)
.lt(Competition::getRegisterEndTime, now)
.set(Competition::getStatus, CompetitionStatus.CLOSED));
log.info("自动关闭{}个已过期的报名通道", updated);
}
}
这里有个容易踩坑的点:@Scheduled默认是单线程执行的。如果你写了多个定时任务,它们会排队执行,一个任务阻塞,其他全部堵住。解决办法是配置TaskScheduler线程池,或者直接在启动类加一句@EnableScheduling,然后在配置类里定义ThreadPoolTaskScheduler。我一开始就是没管线程池,后来因为有个任务里做了慢查询,结果其他定时任务全被拖死了。这个经验后来变成了论文里“性能优化”一节的内容。
5. 实战必踩坑:版本配置、事务失效与循环依赖
5.1 为什么Idea里新建的springboot项目一运行就报错?
我遇到最多的问题是两类。第一类是创建项目时选的SpringBoot版本太高,Maven仓库里还没有对应的依赖,又或者JDK版本不够。解决方式是统一降到2.7.18 + JDK1.8的组合。第二类是application.yml文件在IDEA里不提示任何配置项。
这个“不提示”问题很玄学。application.yml不提示配置项通常是因为IDEA没有把这个文件识别成SpringBoot配置文件,或者项目没有正确加载SpringBoot的依赖。你可以试试右键application.yml,选择Add as Spring Boot Configuration File。如果还不行,检查pom.xml里是否引入了spring-boot-starter,以及IDEA的Spring插件是否被禁用。还有一部分情况是IDEA缓存坏了,执行File -> Invalidate Caches and Restart。
再有一个让我卡了一下午的坑是:eclipse里集成mybatis一直下载依赖失败。控制台显示downloading...然后一直不动或者超时。原因是Maven默认中央仓库在国外,国内访问不稳定。改在settings.xml里配置阿里云镜像,问题迎刃而解。
xml复制<mirror>
<id>aliyunmaven</id>
<mirrorOf>*</mirrorOf>
<name>阿里云公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
你说这些是不是高深的技术?不是,但就是它们浪费了大量时间,而且这也是一个真实开发者的日常。
5.2 事务失效:我踩过的那三个坑
SpringBoot里事务是高频考点。我做的报名系统至少有四处用了@Transactional:提交报名、取消报名、录入成绩、批量导入比赛。但我在自测的时候发现过三个典型的失效场景。
第一个是自调用。我写了一个RegistrationService,在submit()方法里调用了同一个类里的sendNotification()方法,想让通知发送和报名提交处于同一事务。后来发现事务根本没有包裹住通知发送逻辑。原因就是之前说的AOP代理问题:this.sendNotification()走的是真实对象,不是Spring生成的代理对象。解决办法是把通知发送抽到独立NotificationService,然后注入进来调用。
第二个是异常被吞掉。@Transactional默认只对RuntimeException回滚。如果你的方法里用try/catch捕获了异常又没重新抛出,事务就不会回滚。所以要么让异常继续向上抛,要么在catch块里手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
第三个是方法不是public。Spring声明式事务是基于CGLIB或JDK动态代理实现的,只有public方法上的@Transactional才会被拦截处理。我在写内部辅助方法时加过@Transactional,完全不生效,改public之后就好了。
这三个坑的共性是:它们看起来不是代码逻辑错误,而是框架原理没有理解到位。把它们每一种都写成一个小节并配上测试验证,论文的“系统测试与问题修复”那章就会非常饱满。
5.3 循环依赖:三个Service互相引用的现场
说实话,如果项目规划得好,根本不会出现循环依赖。但毕设开发通常边写边改,很容易出现CompetitionService引用RegistrationService,而RegistrationService为了查询赛事信息又引用了CompetitionService的情况。
SpringBoot 2.6开始默认禁止循环依赖,如果检测到会直接启动报错。我当时遇到的就是The dependencies of some of the beans in the application context form a cycle。最直接的解决办法是重构代码,把互相调用的公共方法下沉到一个第三方的Service或放在实体服务层内部完成。比如注册时查看比赛的逻辑不一定要去调CompetitionService,直接由RegistrationService注入CompetitionMapper查询就好了,没必要再套一层Service。这是最干净的方案,比开启spring.main.allow-circular-references=true要安全得多。
如果确实无法避免,可以用@Lazy注解延迟注入打破循环。但毕设阶段用这个是下策,答辩老师追问起来“你为什么要反转控制”,回答不好反而扣分。
5.4 MyBatis-Plus的坑:逻辑删除和分页插件
使用MyBatis-Plus时有两个坑值得写进踩坑实录。
第一个是逻辑删除。我给所有业务表都加了deleted字段,通过@TableLogic注解实现逻辑删除。这样做的好处是报名记录不会被物理删除,后面做数据统计时还能引用历史数据。坏处是,如果查询时忘记加逻辑删除条件,会查出已经被“删除”的记录。MyBatis-Plus的自动注入可以解决大部分情况,但多表关联查询时需要注意。
第二个是分页插件。如果不配置PaginationInnerInterceptor,selectPage方法只是“假分页”,它会先把全量数据查出来,再在内存里截取指定页,数据一多性能直接崩掉。这个太经典了,我身边好几个同学都是这样中招的。
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
6. 部署演示与答辩准备:让毕设真正“跑起来”才是王道
6.1 本地运行与部署到Docker Desktop
很多同学的毕设项目在本机跑得风生水起,但一听说老师要求“发一个可运行的版本”就头大。我觉得最稳妥的方案是:先保证本机一键运行,再考虑Docker部署。 本机的运行方式很简单:装好MySQL和JDK8,执行项目里的script/init.sql初始化数据库,然后mvn spring-boot:run启动后端,前端npm install && npm run dev。把这个流程写成README.md,老师照着操作能跑起来,已经超过一半的毕设了。
如果还想更进一步,就把项目打成Jar包然后构建Docker镜像。
Docker部署的核心是写一个合适的Dockerfile。Java项目的Dockerfile其实很简单:
dockerfile复制FROM openjdk:8-jre-alpine
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
COPY target/competition-system.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app.jar", "--spring.profiles.active=prod"]
然后执行:
bash复制mvn clean package -DskipTests
docker build -t competition-system:1.0 .
docker run -d -p 8080:8080 --name competition-system \
-e JWT_SECRET=mySecretKey \
-e DB_HOST=host.docker.internal \
competition-system:1.0
这里最容易被同学问“为什么连不上数据库”的地方在于:Docker容器里的localhost不是宿主机,MySQL跑在宿主机上时,要连host.docker.internal。这算是一个跟SpringBoot本身没关系但绝对会遇到的坑。
当然,这里只是单容器运行数据库。如果要用docker-compose把MySQL和SpringBoot编排在一起,也建议补上,这样对外演示时,一条docker-compose up -d就能起来一整套环境,专业感拉满。
6.2 演示前必须做好的三件事
答辩演示是毕设的临门一脚。基于我自己的演示经历,有三件事要提前准备:
第一,造一份有说服力的演示数据。不要只创建两三条记录,要造出不同状态的比赛:一个正在报名中的、一个已经报满的、一个已结束并发布了成绩的。学生账号里要有待审核、已通过、已驳回的报名记录。数据越丰富,演示时能讲的故事就越多。
第二,提前测试断网环境下的功能。答辩现场网络不一定稳定,如果前端CDN加载不出来,页面可能整个白屏。所以我在演示前把Vue项目打包后放到Nginx里,后端和前端都跑在本地或局域网内,杜绝“因网络原因演示失败”的尴尬。
第三,准备一个“出问题怎么兜底”的预案。如果现场端口被占用,如果MySQL服务没启动,如果浏览器缓存了旧页面,你都要知道怎么一分钟内解决。别小看这些操作,紧张状态下最容易在这些小事上翻车。
6.3 答辩时怎么把“普通项目”讲出亮点
这个项目说白了就是“报名系统”,但如果答辩时只讲“登录、报名、审核”三件事,老师很难给你打高分。我给自己的汇报逻辑定了三条主线:
第一讲业务状态机的设计。整个系统的核心不是CRUD,而是如何用状态机保证比赛从发布到结束的每一步都不乱。每个状态变更的触发条件是什么、涉及哪些校验、失败怎么回滚。这条线能讲清楚,你的系统就区别于“数据库操作页面”了。
第二讲异常场景的处理。比如重复报名、超员报名、并发冲突。讲清楚你是怎么用“唯一索引+业务校验+兜底捕获”三层机制来保证数据正确性的。老师最爱听这种“防坑”的设计。
第三讲可维护性。项目如何分层,Controller/Service/Mapper的职责边界在哪里,为什么要把异步通知抽离出来,为什么用逻辑删除而不是物理删除。用具体的代码位置做例子,比抽象地背概念强十倍。
我在答辩前还模拟过一个问题:“你的项目跟开源项目有什么区别?”这个问题很刁钻。我的回答是:很多开源竞赛系统强调通用性和配置化,而我的项目面向高校学科竞赛场景进行了垂直定制,在报名时间窗校验、多人组队、教师审核等环节上贴合业务实际,并且代码完全是自己独立开发的,提交记录清晰可查。这个回答既诚实又显得你有独立开发能力。
7. 功能延伸的方向:让这个项目继续长出新东西
毕设验收后,这个系统可以往三个方向做延伸,我个人觉得最有价值的是下面这些:
第一个方向是数据分析可视化。目前系统已经有报名数据和成绩数据,完全可以做一个竞赛概览大屏,用ECharts展示全校各学院报名人数、获奖分布、比赛热度趋势。技术层面不需要额外引入重型组件,后端提供统计接口,前端用ECharts画图即可。这个功能一加,系统从此前的“管理工具”直接升级为“决策辅助平台”。
第二个方向是消息通知的多渠道整合。现在站内信是有的,但还可以接入邮件通知、企业微信或钉钉机器人推送。SpringBoot里集成邮件发送很简单,关键是要处理异步和重试,保证通知不丢失。
第三个方向是赛事报名流程的通用配置化。目前每个比赛的报名表单是固定的,无非是姓名、学号、学院、指导老师。如果做得再灵活一点,可以让管理员动态配置报名表单字段,这个功能会非常实用,也是很多开源系统做不到的。当然,实现难度不小,适合那些想冲优秀毕设的同学挑战。
第四个方向是报名人员名单的PDF盖章导出。很多学校打印报名表时需要带红章。Java里生成PDF并盖红章,用itext或POI都能做,但这个功能属于合规和视觉层面的打磨,适合有时间余裕的同学玩一玩。
我个人认为,毕设最大的价值不是代码量有多大,而是通过一个完整项目把SpringBoot相关的核心机制真正跑通一遍。赛事报名系统这个题目,无论从业务完整度、难度适中性、还是可扩展性来看,都是性价比极高的选择。如果你正卡在某个报错上,或者在纠结要不要换个更“高级”的选题,我的建议很简单:先把技术栈锁定,再把状态流转画清楚,然后一头扎进去写就完了,剩下的坑踩一个填一个,填完你就毕业了。
