1. 这是一个什么项目,为什么值得做
每年考研的人数都在往上走,从年初的备考规划到冲刺阶段的真题模拟,考研er要面对的信息量是相当恐怖的:目标院校的招生简章、报录比、专业课大纲、公共课复习资料、时间节点……这些信息分散在各个网站、公众号、论坛里,找起来费时费力,而且很难判断哪份资料靠谱。我做这个毕业设计的时候,思路其实很简单——为什么不把这些东西整合到一个平台上,让考生在一个系统里完成查资讯、找资料、定计划、记进度这一整条备考链路?
“考研小助手”就是这样一个基于SpringBoot的研究生备考服务平台。项目本身定位很明确:面向考研学生提供一个轻量级的、一站式的备考辅助系统,覆盖考研资讯展示、备考资源管理、学习计划制定、复习进度打卡这几个核心场景。从功能体量上看,它不大不小,属于典型的“业务逻辑清晰、技术栈完整、可扩展性强”的项目,选择它作为计算机毕业设计题目,好处是:工作量可控,不会因为功能太杂导致烂尾;技术覆盖全面,能把SpringBoot、MyBatis-Plus、Redis、定时任务、文件上传、JWT认证这些面试常问的点全部串起来;后续想升一档,还能轻松加上协同过滤推荐算法、WebSocket在线讨论室这些亮点功能。
这篇文章我打算从项目需求拆解、技术选型、数据库设计、核心模块实现、避坑经验这几个维度完整复盘一遍,所有代码和思路都是实际跑通过、验证过的。无论你是正在选毕业设计题目的大四学生,还是想练手SpringBoot全栈开发的初学者,这个项目都值得从头到尾跟一遍。
2. 需求拆解与功能全景设计
2.1 考研场景的痛点分析
在做需求分析之前,我花了不少时间跟正在备考的学弟学妹聊天,还翻了几十个考研论坛的帖子。总结下来,考研备考过程中最让人头大的痛点集中在三个地方:
一是信息聚合难。研招网、学校研究生院官网、学院通知栏,各有各的信息入口,目标院校一多,每天刷一遍都得花一两个小时,还容易漏掉关键通知。很多考生直到报名快结束了才发现自己错过了某所学校的招生宣讲会。
二是资料管理乱。每个考研er的网盘里都堆着几十G的资料,政治、英语、数学、专业课混在一起,版本新旧不一。到了复习后期想找一套2024年的真题解析,翻了半天只找到一堆散零件。
三是计划执行差。备考是长跑,大多数考生一开始会认真做计划,但坚持不了一周就放弃了。没人提醒、没有打卡反馈、没有进度可视化,是计划坚持不下去的三个主要原因。
2.2 功能模块的划分逻辑
基于上面的痛点,我把系统划分成五个功能模块,按照用户身份分为前台学生端和后台管理端。前台是考生能直接用到的功能,后台是运营人员维护内容的工具:
用户模块:注册、登录(支持邮箱验证码)、个人信息维护、密码加密存储。这里用JWT做无状态认证,不依赖Session,方便后续前后端分离部署。
资讯公告模块:后台按分类发布考研资讯(院校信息、招生简章、复习指导、政策解读),前台支持条件筛选、关键词搜索、点击量统计。首页用轮播图展示置顶资讯,让重要通知第一时间触达用户。
备考资源模块:后台上传资源文件(PDF、图片、压缩包均可),设置资源分类和标签,前台支持按科目(政治/英语/数学/专业课)筛选。资源支持在线预览和下载,后台能看到下载量排行。
学习计划模块:用户创建专属备考计划,按倒计时天数自动拆分阶段目标,支持每日打卡、进度统计、考前冲刺提醒。这是整个系统里最有“小助手”感觉的功能。
后台管理模块:管理员登录后台,维护用户列表、发布资讯、审核资源、查看数据统计看板。统计部分用定时任务每晚生成报表,减少实时查询压力。
2.3 为什么选用SpringBoot作为主框架
做这个项目之前我也对比过其他方案:SSM(SpringMVC + Spring + MyBatis)太老旧,写配置的时间比写业务代码还多;SpringCloud那一套对毕业设计来说明显超重。SpringBoot的优势在于自动化配置机制——通过spring-boot-starter一系列依赖,把Web、数据访问、缓存、定时任务等常用组件的配置全部内置,开发者只需要关心业务代码。
尤其适合毕设场景的是SpringBoot的起步依赖管理。比如引入spring-boot-starter-web后,Maven会自动带上Tomcat、Jackson、Spring MVC相关依赖,版本已经经过官方校准,不需要自己处理版本冲突。这对经验不丰富、容易在依赖地狱里浪费大量时间的学生来说,价值非常大。
另外SpringBoot天然支持前后端分离开发模式,这是现在互联网项目的主流形态。项目里我用Vue 3写的前端,通过HTTP接口与后端交互,部署时把前端打包产物直接放进SpringBoot的src/main/resources/static目录,一个Jar包就能跑起整个系统,对毕业设计答辩演示来说非常方便。
3. 技术选型与开发环境准备
3.1 技术栈清单与选型理由
| 层次 | 选型方案 | 选择理由 |
|---|---|---|
| 后端框架 | SpringBoot 2.7.x | 稳定、教程多、兼容JDK8,生态资料最丰富 |
| ORM框架 | MyBatis-Plus 3.5.x | 代码量少,内置分页插件、条件构造器,开发效率高 |
| 数据库 | MySQL 8.0 | 免费、主流,支持JSON类型,文档丰富 |
| 缓存 | Redis 5.x | 适合做验证码缓存、资讯热点数据缓存、点击量计数 |
| 安全方案 | JWT + BCrypt | 无状态认证,适合前后端分离;BCrypt加盐hash,防彩虹表攻击 |
| 文件存储 | 本地磁盘 + Nginx映射 | 毕设规模用云OSS没必要,本地存储够用且简单 |
| 定时任务 | Spring Task | 框架自带,注解化配置,满足日报表生成、倒计时提醒等需求 |
| 前端 | Vue 3 + Element Plus | 组件齐全,UI美观,招聘市场认可度高 |
这里特别说一句SpringBoot版本的问题。现在Spring Boot 3.x已经发布很久了,但如果你去做毕设或者对Spring生态不是很熟,我依然建议选择SpringBoot 2.7.x。原因很简单:3.x要求JDK17及以上,很多学校实验室电脑装的是JDK8;3.x里javax.*包名换成了jakarta.*,网上大量旧博客代码直接复制会报错;MyBatis-Plus、一些老牌第三方库的高版本适配文档还不完善。项目图稳不图新,2.7.x是当前阶段最稳妥的选择。
3.2 Maven项目结构与核心依赖配置
项目采用标准的Maven多模块思路——虽然物理上是单模块,但包结构按功能分包,保证代码可维护性。先看pom.xml的核心依赖:
<dependencies> <!-- Web基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- JWT --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <!-- Lombok,减少样板代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>包结构沿用常见的分层层级:controller层接收请求、参数校验;service层处理业务逻辑;mapper层对接数据库;entity层定义实体类;config放配置类;common放通用工具类和统一返回结果。这种结构在毕设答辩时非常好讲解——老师问你某一层是干什么的,你可以直接指着代码说清楚职责划分。
3.3 开发环境的搭建记录
我本地的环境是Windows 11 + IntelliJ IDEA 2023.2 + JDK 8 + Maven 3.9。几个值得注意的配置点:
- IDEA里Maven的配置要从
File → Settings → Build Tools → Maven设置自定义的settings.xml,把仓库地址指向阿里云镜像,不然依赖下载慢到你怀疑人生。 - MySQL 8.0的驱动类名是
com.mysql.cj.jdbc.Driver,URL需要加serverTimezone=Asia/Shanghai参数,否则连接时会报时区错误。 - Redis在Windows下没有官方版本,可以下载
redis-windows社区版,或者直接在项目里用Docker跑一个Redis容器,都行。
application.yml里的关键配置片段:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/kaoyan_app?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 redis: host: localhost port: 6379 database: 0 mybatis-plus: mapper-locations: classpath:mapper/*.xml global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意logic-delete-field这个配置,MyBatis-Plus支持逻辑删除,删除数据实际上是执行UPDATE语句把deleted字段置为1,这对保留用户评论、资源记录等业务数据非常重要。
4. 数据库设计:五张核心表撑起整个业务
4.1 表结构全景
数据库是整个系统的地基。这一版数据库总共设计了7张表,核心的是这5张:
user:用户表article:考研资讯表resource:备考资源表study_plan:学习计划表plan_record:每日打卡记录表
另外还有user_favorite(收藏表)和resource_category(资源分类表)。建表语句我精简一下,关键的表结构如下:
CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `target_school` varchar(100) DEFAULT NULL COMMENT '目标院校', `target_major` varchar(100) DEFAULT NULL COMMENT '目标专业', `role` tinyint DEFAULT '1' COMMENT '1-学生 2-管理员', `status` tinyint DEFAULT '1' COMMENT '1-正常 0-禁用', `deleted` tinyint DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表'; CREATE TABLE `article` ( `id` int NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '标题', `category` varchar(50) DEFAULT NULL COMMENT '资讯分类:院校信息/招生简章/复习指导/政策解读', `cover` varchar(255) DEFAULT NULL COMMENT '封面图', `content` longtext COMMENT '内容HTML', `source` varchar(100) DEFAULT NULL COMMENT '来源', `is_top` tinyint DEFAULT '0' COMMENT '是否置顶:1-置顶', `view_count` int DEFAULT '0' COMMENT '浏览数', `status` tinyint DEFAULT '1' COMMENT '1-已发布 0-草稿', `deleted` tinyint DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category` (`category`), KEY `idx_is_top` (`is_top`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考研资讯表';4.2 核心设计思路
有几个设计点值得展开讲讲。
首先是数据冗余的取舍。资讯表里的view_count字段就是典型的空间换时间。如果每次浏览都去做一次COUNT计数,在数据量大时性能会很差。这里我在Redis里维护浏览数,每隔一段时间批量同步到数据库,Windows下Redis的持久化配置默认开启了RDB快照,不会丢太多数据。
其次是推荐系统的雏形。resource表我加了一个tags字段,用逗号分隔存多个标签(比如“英语一,真题,2019年”)。在做“猜你想找”的推荐功能时,直接查当前用户收藏的资源标签,然后按标签相关性拉取其他资源。这种基于标签的推荐虽然简单,但对毕设来说已经足够形成亮点。
第三是学习计划的粒度设计。study_plan表里有一个total_days字段和plan_json字段,plan_json用JSON存储阶段划分信息(比如“基础阶段30天、强化阶段45天、冲刺阶段20天”),打卡记录按天落在plan_record表。这样设计的优点是:修改计划不产生历史打卡记录的变更,统计“连续打卡天数”只需要查plan_record表即可。
4.3 索引设计经验
关于索引,我的原则是:先满足业务查询,再用慢日志后补。article表的category和is_top字段在前台列表页会频繁作为查询条件,加了索引效果明显。plan_record表的user_id和plan_date联合索引,支撑每天加载打卡状态时只扫一行记录。对于小表(user_favorite)不建太多索引,因为全表扫描本身很快。
注意:
longtext类型字段不能加默认值,也尽量不要对它建索引。资讯正文内容都是通过富文本编辑器提交的HTML,有两三万字很常见。查询列表时用SELECT只查需要的字段列表,不要把content字段带出来,这是大字段拖垮查询性能的经典事故。
5. 核心功能模块实现过程
5.1 用户认证模块:JWT从零到一
认证模块是系统的基础,几乎所有接口都要先过这一关。我采用的是JWT + 拦截器方案,流程是这样的:
- 用户在登录接口提交用户名和密码。
- 服务端用BCrypt校验密码,注意BCrypt每次加密结果都不同,所以比较时调用
matches方法,而不是把密文解密对比。 - 校验通过后,用JWT工具类生成token,payload里放
userId和role,设置过期时间(我设的是24小时),返回给前端。 - 前端把token存到
localStorage,在HTTP请求拦截器里统一加到Authorization头。 - 后端拦截器验证token,从
Claims里取出用户ID,放入ThreadLocal,后续业务代码直接用。
JWT工具类的核心代码:
@Component public class JwtUtils { // 密钥,实际应该放到配置文件中 private static final String SECRET = "kaoyan-secret-key-2024-example"; private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; public String generateToken(Integer userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS256) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }拦截器里要做异常捕获,token过期、签名错误、token为空分别返回不同的错误码。这里有个很容易踩的坑:签名密钥长度必须大于32字节,否则HS256算法会报WeakKeyException(jjwt 0.11版本起增加了密钥强度校验)。
5.2 资讯与资源管理:列表查询与缓存加速
资讯模块是典型的CRUD,但有几个细节值得打磨。列表接口我做了分类筛选、分页、置顶排序和关键词搜索:
public IPage<ArticleVO> pageArticle(ArticleQuery query) { Page<Article> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>(); // 分类筛选 if (StringUtils.hasText(query.getCategory())) { wrapper.eq(Article::getCategory, query.getCategory()); } // 关键词搜索,标题和内容 if (StringUtils.hasText(query.getKeyword())) { wrapper.and(w -> w.like(Article::getTitle, query.getKeyword()) .or() .like(Article::getContent, query.getKeyword())); } // 置顶排前,再按时间倒序 wrapper.orderByDesc(Article::getIsTop) .orderByDesc(Article::getCreateTime); // 不查询content大字段,文章详情单独查 wrapper.select(Article.class, f -> !f.getProperty().equals("content")); return articleMapper.selectPage(page, wrapper); }这段代码里最值得强调的是大字段分离查询。列表接口用wrapper.select排除content字段,详情接口才完整查询。这个优化在数据有几百条资讯时收益不明显,但养成这个习惯,以后处理千万级数据的C端接口时就知道好处了。
热点资讯的缓存我用Redis做了一层。详情接口先查Redis,没有才查数据库,再回填缓存:
public ArticleVO getArticleDetail(Integer id) { String cacheKey = "article:detail:" + id; String json = redisTemplate.opsForValue().get(cacheKey); if (json != null) { return JSONUtil.toBean(json, ArticleVO.class); } Article article = articleMapper.selectById(id); if (article == null) { throw new BizException(ResultCode.NOT_FOUND); } String jsonValue = JSONUtil.toJsonStr(article); redisTemplate.opsForValue().set(cacheKey, jsonValue, 30, TimeUnit.MINUTES); return BeanUtil.copyProperties(article, ArticleVO.class); }这里要注意缓存穿透的问题。如果查询的ID根本不存在,缓存会一直为空,请求会全部打到数据库。我踩过这个坑之后加了“空值缓存”——即使查不到数据也往Redis里写入一个占位符,过期时间缩短到5分钟,同时接口层做了基础参数校验,非法ID直接拦截。
5.3 学习计划模块:倒计时自动生成与打卡
学习计划是“考研小助手”这个产品名字的由来,也是我做的时候最想做出彩的部分。功能设计是这样的:
用户输入目标院校、专业、考试日期,系统自动计算出距离考研还有多少天,然后按“基础→强化→冲刺”三个阶段把总天数平均分配,自动生成学习计划。用户可以在计划内添加具体任务,比如“过完高数第一轮”“背完英语高频词”。
生成计划的算法很简单:
/** * 根据考试日期生成阶段计划 * 阶段默认比例:基础45%,强化35%,冲刺20% */ public StudyPlan generatePlan(Integer userId, LocalDate examDate) { LocalDate today = LocalDate.now(); long totalDays = ChronoUnit.DAYS.between(today, examDate); if (totalDays <= 0) { throw new BizException("考试日期必须晚于今天"); } StudyPlan plan = new StudyPlan(); plan.setUserId(userId); plan.setExamDate(examDate); plan.setTotalDays((int) totalDays); plan.setStatus(1); // 三个阶段的起止日期 int baseDays = (int) (totalDays * 0.45); int strengthenDays = (int) (totalDays * 0.35); int sprintDays = (int) (totalDays - baseDays - strengthenDays); // 构造阶段JSON Map<String, Object> stages = new HashMap<>(); stages.put("base", buildStage("基础阶段", today, today.plusDays(baseDays))); stages.put("strengthen", buildStage("强化阶段", today.plusDays(baseDays + 1), today.plusDays(baseDays + strengthenDays))); stages.put("sprint", buildStage("冲刺阶段", today.plusDays(baseDays + strengthenDays + 1), examDate)); plan.setPlanJson(JSONUtil.toJsonStr(stages)); studyPlanMapper.insert(plan); return plan; }打卡功能用一张plan_record表记录,每天只能打一次卡,前端展示月度打卡日历。判断今天是否已打卡,用user_id + plan_date查一次记录即可,每次打卡后更新计划表的continuous_days连续打卡天数。这块逻辑虽然简单,但涉及并发时要注意:同一用户同时发两个打卡请求可能造成重复记录。我的解决方案是在plan_record表加了UNIQUE KEY uk_user_date (user_id, plan_date),数据库层面兜底,应用层用Redis分布式锁也可以,但对这个场景来说唯一索引最简单也最有效。
5.4 定时任务的落地实践
Spring Task在项目里做了三件事:每晚凌晨统计昨日资讯浏览排行、每天上午9点给北有学习计划的用户推送备考提醒邮件、每周一更新一次资源排行榜。核心就是一个@Scheduled注解:
@Component @Slf4j public class ScheduledTasks { @Autowired private ArticleService articleService; @Autowired private MailService mailService; // 每天凌晨1点执行资讯浏览统计 @Scheduled(cron = "0 0 1 * * ?") public void statArticleViews() { log.info("开始统计资讯浏览数据..."); // 从Redis取浏览数据写入article表的view_count字段 articleService.syncViewCountFromRedis(); log.info("资讯浏览统计完成"); } // 每天9点给备考中的用户发送提醒 @Scheduled(cron = "0 0 9 * * ?") public void sendRemindEmails() { List<StudyPlan> plans = studyPlanService.listActivePlans(); for (StudyPlan plan : plans) { String email = userService.getEmailById(plan.getUserId()); mailService.sendSimpleMail(email, "考研小助手备考提醒", "距离考试还有 " + plan.getRemainDays() + " 天,记得完成今天的打卡哦!"); } } @Scheduled(cron = "0 0 10 ? * MON") public void weeklyResourceRank() { // 生成一周资源下载榜,存入Redis缓存 } }cron表达式不熟的人容易写错,这里记住几个速记键:秒 分 时 日 月 周,*代表任意值,?只用在“日”和“周”两个位置表示不指定。比如0 0 1 * * ?就是“每天凌晨1点整执行”,0 0 10 ? * MON是“每周一上午10点执行”。
一个坑:Spring Task默认是单线程串行执行的,如果你有多个定时任务且每个任务耗时较长,后面的任务会被阻塞。解决方法是配置一个线程池:
@Configuration @EnableScheduling public class ScheduleConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10)); } }5.5 文件上传与静态资源映射
资源模块需要上传PDF、图片等文件。SpringBoot默认的单文件上传大小限制是1MB,做资源的同学上传一套真题PDF往往几MB甚至几十MB,所以必须手动调大限制:
spring: servlet: multipart: max-file-size: 100MB max-request-size: 100MB文件上传接口注意几个细节:
- 文件保存路径不要放项目内部,建议存到绝对路径,比如
D:/kaoyan-files/,否则打包成Jar后通过new File()写相对路径会失败。 - 文件名要用
UUID + 原始后缀重新拼接,避免中文文件名和重名文件覆盖的问题。 - 保存后需要外部访问,两种方案:一是把文件路径作为Nginx映射的静态资源,二是SpringBoot里自定义静态资源映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:D:/kaoyan-files/"); } }这样访问http://localhost:8080/files/xxx.pdf就能直接打开文件。
提示:毕设答辩演示时,如果老师想看文件上传效果,最好提前准备一个不超过5MB的小PDF,不然上传大文件等待时间长,演示卡壳就很尴尬了。
6. 前后端联调与项目打包部署
6.1 Vue项目如何与SpringBoot优雅共存
项目前端使用Vue 3 + Element Plus + Vite构建,开发环境中前后端通过代理解决跨域问题。在vite.config.js中配置:
export default defineConfig({ server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })后端接口统一以/api前缀暴露,前端所有请求都走/api开头,开发时代理转发到后端的8080端口,Vite服务器对浏览器的响应没有跨域问题。
生产部署时,很多人不知道Vue构建产物怎么跟SpringBoot合体。最简单的方式是:前端执行npm run build,把生成的dist目录里所有文件拷贝到后端的src/main/resources/static文件夹下。这样SpringBoot启动后,访问http://localhost:8080/就直接进入前端页面,后端接口仍然走/api前缀。一个Jar包,前后端全搞定。
6.2 打Jar包的配置细节
后端要打可执行Jar包,需要在pom.xml里包含前端构建插件,或者干脆手动构建。我用的是手动方案:
- 前端构建:
npm run build - 把dist内容拷贝到后端static目录
- 后端执行
mvn clean package -DskipTests - 运行
java -jar target/kaoyan-app-1.0.0.jar
运行后有两个细节容易踩坑:
- 如果resources目录下有之前的打包残留,
mvn clean会清掉,但要确保前端产物在clean之后再复制,否则会打包进旧的index.html。 - 数据库和Redis的连接地址要用生产环境的真实地址,如果部署在云服务器上,建议把敏感配置放到
application-prod.yml,通过--spring.profiles.active=prod参数激活。
6.3 用宝塔面板部署到云服务器
最近帮同学部署了好几个SpringBoot项目,发现宝塔面板是相对省心的方案。基本步骤是:
- 服务器装好宝塔面板,先安装Nginx、MySQL 8.0、Redis,这里面MySQL和Redis直接用面板里的“软件商店”一键安装最快。
- 创建数据库和账号,导入本地导出SQL文件。
- 上传Jar包到服务器目录,用面板里的“Java项目管理器”部署,填好端口号。
- Nginx配置反向代理,把80端口的
/api请求转发到Java应用的8080端口。 - 配置SSL证书,用面板申请免费的Let's Encrypt证书。
反向代理配置的核心:
server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } client_max_body_size 100m; }client_max_body_size 100m这行一定不能少,否则前端上传大于1MB的文件时,Nginx直接返回413错误,而后端日志里看不到任何异常。
7. 常见问题与避坑指南
7.1 SpringBoot版本引起的“编译通过运行报错”
有同学私信环境是SpringBoot 3.2 + JDK 17,直接拿我这个项目的代码跑,结果启动报错:java.lang.NoClassDefFoundError: javax/servlet/Filter。原因就是3.x里Servlet API的包名从javax.servlet迁移到了jakarta.servlet,旧项目里的javax相关依赖全部失效。
这个问题其实很好避免:要么跟着项目用2.7.x + JDK8,要么把代码里所有javax.servlet替换成jakarta.servlet。但我还是建议,如果你不熟悉这两者的区别,就直接用2.7.x,把精力放在业务上。
7.2 Redis连接失败导致登录接口502
启动后访问登录接口报错,原因往往是Redis服务没启动。很多同学本地装好了Redis但忘记启动,然后登录时验码用到了Redis模板,应用直接抛异常。建议在项目启动脚本里加一段Redis健康检查,启动前先确认redis-cli ping返回PONG。如果不想装Redis,也可以用Spring的CacheManager做本地缓存替代,不过会失去Redis的持久化能力。
7.3 定时任务“不执行”的原因排查
有段时间我发现邮件提醒没有发出去,查日志发现任务根本没触发。排查思路按这个顺序来:
先用@PostConstruct在应用启动时打印一句话,确认@EnableScheduling生效;再检查cron表达式,常见错误是秒位忘记写(0 0 9 * * ?和0 9 * * ?的区别很大);最后看是不是项目里有两个同名@Component被扫描了,导致任务在另一个实例上执行,日志打到了别处。
我这边的最终原因是服务器时区问题。云服务器默认时区是UTC,而cron表达式按系统时区计算,导致每天9点的任务实际是北京时间下午5点执行。解决办法是启动时指定时区:
java -jar kaoyan-app.jar --spring.jackson.time-zone=GMT+8或者更稳妥地在Linux服务器执行timedatectl set-timezone Asia/Shanghai。
7.4 前端打包后SpringBoot页面404
Vue项目npm run build生成的静态文件如果放到resources/static下,运行时依旧404,大概率是路径问题。Vite的base配置默认是/,打包后的资源引用路径是绝对路径/assets/xxx.js,如果你的项目部署在http://ip:8080根路径下没问题,但如果做了子路径映射(比如http://ip:8080/kaoyan/),就必须把vite.config.js里的base改成/kaoyan/,否则找不到静态资源。
7.5 易忽略的安全问题自查清单
毕设答辩时老师经常问安全问题,提前做好这些能加不少印象分:
- 密码用BCrypt加密,不要用MD5,MD5已经能被彩虹表秒破;
- 前端提交的数据后端要二次校验,不能信任任何输入;
- JWT的密钥不要硬编码在代码里,放到配置文件并加入
.gitignore; - 文件上传接口要校验文件扩展名,防止上传可执行的JSP木马文件,只允许PDF、图片、压缩包等白名单格式;
- 管理员的密码不要用admin/123456这种,容易被弱口令爆破进来。
8. 写在最后的实操心得
整个项目从零写完,大概用了三周多的时间。第一周做需求分析和数据库设计,整天泡在ER图里反复调表结构;第二周把用户、资讯、资源三个模块跑通,每天都能看到接口正常返回数据;第三周集中做学习计划、定时任务、文件上传,最后两天联调打包。
我最大的体会是:毕设项目能不能顺利推进,数据库设计阶段就已经定生死。中间因为资源表设计不合理,我重写了一次前端列表页。当初为了图省事,把资源分类设计成了多对多关系,结果前端筛选时要去算关联关系,代码绕来绕去,不如直接在资源表里冗余一个category_id字段来得直观。经典的“适当的冗余换来的是简单和高效”,在这个项目里体会得很深。
另外一个建议是:代码要尽量写得规范,注释不是写给老师看的,是写给两周后的自己看的。每完成一个模块,写一份简短的功能说明,记录接口的入参出参、逻辑要点和踩坑记录,答辩时整理成PPT的素材也顺手。
如果你打算用这个项目作为毕业设计,我建议在此基础上加一个“亮点功能”,比如基于协同过滤的院校推荐、基于WebSocket的考研战友聊天室、或者用ECharts展示历年分数线趋势的的可视化图表。这些功能工作量不大,但能在答辩时快速吸引评委注意力。
最后再分享一个小技巧:开发期间前端调试,可以准备一份带测试数据的SQL脚本,每次测试环境挂了就重新导入一张快照,比手动造数据快得多。这套项目后续如果我还要扩展,大概率会先做移动端适配,把打卡和资讯浏览的体验搬到小程序上,让备考学生真正能够随时随地使用。
无论如何,动手写永远比看别人的代码强。去把项目跑起来,然后在跑起来的基础上改点东西,这趟毕业设计就算没白做。