简介:这是一份基于SpringBoot的爬虫高考志愿智能推荐系统完整源码,适用于Java方向毕业设计、课程设计或爬虫与推荐系统项目实战。系统整合爬虫采集、MySQL存储、数据处理和智能推荐算法,能依据成绩与兴趣生成志愿建议,并包含前后端完整工程;项目已经过测试,可正常运行。资源共包含669个文件,以Java源码、Vue前端、JavaScript脚本、SVG图标、图片、SQL脚本及批处理命令为主,压缩包约26.46MB,目录结构清晰,便于二次开发;前端页面、图标素材与后端逻辑分离,修改定位较为方便。目前已有75人学习下载,适合需要快速搭建同类系统、理解SpringBoot与爬虫及推荐算法整合流程的开发者,也可用于教学演示和功能原型验证。压缩包内置安装、运行等批处理脚本,并提供数据库SQL与说明文件,可帮助读者从环境配置到功能落地快速上手,亦可作为毕业设计答辩的参考实现。
1. 这个毕业设计题到底在做什么:数据采集、推荐算法和SpringBoot是怎么拧成一套系统的
先说一个反直觉的结论:基于springboot的爬虫高考志愿智能推荐系统里,真正难住大部分人的不是推荐算法,而是数据。算法部分翻来覆去就是位次换算、冲稳保分档、加权打分这几件事,SpringBoot接口也是常规三板斧。但“数据从哪来、怎么保证数据干净、怎么让数据能支撑起推荐结论”,才是答辩老师最喜欢追问、也最能拉开档次的地方。这个题目适合正在做Java毕业设计、想用一套完整系统展示“采集-清洗-建模-接口-界面”全链路能力的同学。下面不依赖任何现成源码包,按一套可复现的方案把每一步讲透,包括表怎么建、爬虫怎么写、推荐阈值怎么调、坑在哪。
2. 从零搭出高考数据层:建表设计、爬虫选型与采集代码
2.1 数据模型先行:五张表把高考志愿数据装稳
爬虫代码写起来很快,但表结构设计错了,后面改起来想哭。我一般会先建五张核心表,把数据模型固定下来再动手采集。
t_school_info:院校基础信息。院校代码、名称、所在省份、城市、办学层次(985/211/双一流/普通本科)、办学性质(公办/民办)。t_major_plan:招生计划。院校代码、专业名称、年份、省份、计划人数、选科要求(这是新高考的关键字段)。t_score_line:录取分数线。这是推荐系统的核心数据源,院校代码、专业名称、年份、省份、科目组(物理/历史/综合)、批次、最低分、最低位次。t_rank_table:一分一段表。省份、年份、科目组、分数、该分数对应人数、累计人数(位次)。t_recommend_log:推荐日志。记录每次推荐请求的入参、推荐结果和用户反馈,后续做算法评估和论文数据都靠它。
建表时有一个必须注意的点:唯一索引在爬虫写入阶段就定好,不然后面增量更新会重复。比如t_score_line表用(省份、年份、院校代码、专业名称、科目组)做唯一键,定时任务重跑时靠它做幂等写入。SQL大致是这样:
CREATE TABLE t_score_line ( id BIGINT AUTO_INCREMENT PRIMARY KEY, province VARCHAR(16) NOT NULL COMMENT '省份', year INT NOT NULL COMMENT '录取年份', school_code VARCHAR(32) NOT NULL COMMENT '院校代码', school_name VARCHAR(64) NOT NULL COMMENT '院校名称', major_name VARCHAR(64) NOT NULL COMMENT '专业名称', subject_type VARCHAR(8) NOT NULL COMMENT '科目组:物理/历史/综合', batch_name VARCHAR(16) COMMENT '本科批/专科批', min_score INT COMMENT '最低录取分', min_rank INT COMMENT '最低录取位次', UNIQUE KEY uk_line (province, year, school_code, major_name, subject_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='各省录取分数线';这个地方建议把school_name冗余进来,而不是只存school_code。原因是推荐结果给前端展示时,如果每一次都要去t_school_info表关联查询,列表页性能会明显变差;而且爬虫处理中经常出现院校代码一致但名称略有差异的情况,冗余一个字段能加速你排查脏数据。等数据量大了再考虑拆宽表,毕业设计阶段这个冗余是完全值得的。
2.2 SpringBoot里的爬虫怎么写:HttpClient取页面、Jsoup解析
网络爬虫原理说起来就三步:发请求拿HTML、从HTML里解析目标字段、把结构化数据落库。高考录取数据的来源通常是各省教育考试院公布的“投档情况统计表”和院校官网的“历年录取分数”页面,页面结构是经典的HTML表格。工具选型上,我建议用Apache HttpClient + Jsoup,而不是上Selenium或WebMagic全家桶。
选型理由很直接:这些页面不需要执行JavaScript,表格就在静态HTML里,用Selenium等于开着一辆卡车去送快递。HttpClient负责请求和连接管理,Jsoup负责解析HTML,两者组合轻量且调试方便。核心采集代码的结构如下:
public List<ScoreLineVO> fetchScoreLine(String url, String province, int year) { List<ScoreLineVO> result = new ArrayList<>(); try (CloseableHttpClient client = HttpClients.custom() .setUserAgent(randomUA()) .setConnectionTimeToLive(10, TimeUnit.SECONDS) .build()) { HttpGet get = new HttpGet(url); // 把请求伪装成浏览器,能减少一部分拦截 get.setHeader("Accept", "text/html,application/xhtml+xml"); get.setHeader("Accept-Language", "zh-CN,zh;q=0.9"); get.setHeader("Referer", "https://www.haeea.cn/"); try (CloseableHttpResponse resp = client.execute(get)) { if (resp.getStatusLine().getStatusCode() != 200) { return result; // 非200直接返回空,由上层重试 } String html = EntityUtils.toString(resp.getEntity(), "GB2312"); Document doc = Jsoup.parse(html, url); Elements rows = doc.select("table.tbody tr"); for (Element row : rows) { Elements tds = row.select("td"); if (tds.size() < 6) continue; // 跳过格式不完整的行 ScoreLineVO vo = new ScoreLineVO(); vo.setSchoolCode(tds.get(0).text().trim()); vo.setSchoolName(tds.get(1).text().trim()); vo.setMajorName(tds.get(2).text().trim()); vo.setMinScore(Integer.parseInt(tds.get(4).text().trim())); vo.setMinRank(Integer.parseInt(tds.get(5).text().trim())); vo.setProvince(province); vo.setYear(year); result.add(vo); } } } catch (Exception e) { log.error("采集失败: {}, error: {}", url, e.getMessage()); } return result; }这里有两个容易踩的细节。第一是EntityUtils.toString的字符集不能写死,很多老牌考试院页面是GB2312或GBK编码,Jsoup.parse(html, url)里虽然会自动推断meta charset,但如果HttpClient拿到的字节流已经被错误解码,后面的推断就无从谈起。第二是解析时不要硬依赖table选择器,有的页面是div布局的列表,更稳的做法是先定位到包含录取数据的模块,再逐行解析。
2.3 反爬与增量采集:请求间隔、UA轮换和去重
高校官网和考试院站点普遍没有高端风控,但完全不设防也不可能。常见做法是三板斧:随机UA、固定区间延时、失败重试。UA轮换不是玄学,它解决的是服务端按UA特征识别非浏览器请求的问题;延时解决的是请求频率过高被临时封IP的问题。
@Component public class CrawlerGuard { private static final String[] UA_POOL = { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", "Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36" }; private final Random random = new Random(); public void beforeRequest() { // 每次请求前随机等1~3秒,避免被按频率封禁 try { Thread.sleep(1000 + random.nextInt(2000)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public String randomUA() { return UA_POOL[random.nextInt(UA_POOL.length)]; } }很多教程让直接上分布式爬虫,但毕业设计阶段我强烈不建议。分布式爬虫要解决任务分发、去重、节点协调、结果汇总,这本身又是一个毕业设计的体量。高考数据是低频变化数据,一年就更新一次,单机限速采集几百页几十万行数据,一小时左右就能跑完,完全在需求边界之内。把“分布式爬虫”写进论文里作为扩展方向提一句即可,脚踏实地把单机采集做扎实,比堆概念更能过答辩。
数据去重方面,除了数据库唯一索引兜底,采集时还可以用一个HashSet缓存已处理过的学校代码+专业名组合,避免同一页面被重复解析。如果未来真要横向扩展,把HashSet替换成Redis的SETNX命令就行,这个演进路径就是论文里“分布式扩展”的素材。
3. 高考志愿推荐算法:位次法、冲稳保梯度与打分模型
3.1 位次法:分数在不同年份之间的“翻译官”
高考分数每年通胀或紧缩是常态,今年580分能上211,去年580分可能只能上普通一本。所以推荐系统绝不能直接拿2024年分数去对2023年录取线,必须通过“位次”做桥接。位次法的逻辑是:你的分数在本省今年考生中的排名,比你绝对考了多少分更有参考价值。具体分三步:
- 用
t_rank_table查考生今年分数对应的累计人数,得到位次。 - 用这个位次去反查目标院校所在省份去年的位次-分数映射,得到“等效分”。
- 用等效分和该校去年录取最低分做比较,判断冲稳保。
代码实现很直接,但有一个需要注意的取舍:招生计划变化。如果某校某专业去年计划招50人,今年只招20人,同样位次的录取概率会明显下降。我一般会加一个“计划波动修正系数”,这个后面讲打分模型时会再提。
public Integer rankByScore(String province, int year, int score, String subjectType) { // 返回该分数对应的累计人数(位次),查不到时向上取最近的分数 return rankTableMapper.findRankByScore(province, year, score, subjectType); } public Integer scoreByRank(String province, int targetYear, int rank, String subjectType) { // 用考生今年的位次,反查目标年份的等效分 return rankTableMapper.findScoreByRankCeil(province, targetYear, rank, subjectType); }这里参数subjectType不能省略,新高考省份物理组和历史组是两套独立的一分一段表,混用一套数据算出来的位次偏差极大。findScoreByRankCeil的“Ceil”意思是取该位次对应的最低分数天花板,宁可把等效分算保守一点,也不要虚高,否则会把考生推进“冲”的名单里,实际录取概率很低。
3.2 冲稳保梯度:推荐阈值参数怎么定
拿到等效分后,和候选院校往年的最低录取分做差值。这个差值的分档阈值,直接决定推荐结果长什么样。常见的参数区间如下:
差值d = 等效分 - 院校往年录取最低分
| 梯度 | 差值范围 | 含义 | 推荐排序依据 |
|---|---|---|---|
| 冲 | -10 ≤ d < 0 | 等效分略低于往年线,搏一个录取机会 | 院校层次从高到低 |
| 稳 | 0 ≤ d < 5 | 等效分略高于往年线,录取概率较大 | 院校层次 + 专业热度 |
| 保 | 5 ≤ d < 15 | 等效分明显高于往年线,基本稳进 | 院校层次从高到低 |
这个阈值是经验值,不是公式推导出来的。不同省份、不同批次可以微调。比如院校录取线本来就低、考生位次靠后时,±5分的波动范围会非常大,可以把“稳”的上限放宽到8分。我一般会把阈值做成配置项放在application.yml里,方便答辩现场调参数演示效果差异。
// 核心打分逻辑 public void fillRecommendLevel(List<SchoolScore> candidates, int equivalentScore) { for (SchoolScore s : candidates) { int diff = equivalentScore - s.getMinScore(); if (diff >= -10 && diff < 0) { s.setLevel("冲"); } else if (diff >= 0 && diff < 5) { s.setLevel("稳"); } else if (diff >= 5 && diff < 15) { s.setLevel("保"); } else { s.setLevel("不推荐"); } // 加一个修正项:招生计划缩减时,梯度整体下调一档 if (s.getPlanChangeRate() < 0.8 && !"冲".equals(s.getLevel())) { s.setLevel("冲"); s.setRecommendScore(s.getRecommendScore() - 15); } } }修正项的思路值得展开说一下。planChangeRate表示今年招生计划和去年同期的比值,低于0.8说明大幅缩招,同样的位次录取难度增加,这时候原评价为“保”的院校应该降级为“稳”,原“稳”降成“冲”。如果没有招生计划数据源,可以直接跳过这个修正,不会影响主体框架,但答辩时能讲出这一层,说明你真的考虑过录取概率的动态变化。
3.3 为什么推荐算法不选协同过滤:数据稀疏与可解释性
查过论文库的同学会发现,很多志愿推荐系统用了协同过滤或者矩阵分解。但工程落地时,我仍然坚持用规则推荐,原因有两个。
第一是数据稀疏。协同过滤需要大量用户-项目评分矩阵,而高考志愿填报场景里,一个考生只填几十个志愿,全省考生加起来的有效交互数据也不过百万级,分摊到几千所院校上,矩阵稀疏度低到没法训练。更关键的是,志愿填报没有“喜欢/不喜欢”的显式评分,只有“录没录取”的结果,录取结果受分数位次影响极大,用户偏好信号被严重干扰。
第二是可解释性。考生和家长需要知道“为什么推荐这个学校”,规则推荐可以直接给出“该校去年最低位次18000,你的位次16500,等效分高7分,属于稳妥档”。而协同过滤只能告诉你“和你相似的考生也选了这所”,这种输出在高考场景中缺少说服力。冲刺建议、保底判断这些决策都带有强因果关系,规则推荐的透明性就是它最大的工程优势。
当然规则推荐的短板也要承认:它不擅长捕捉“用户特别想去某城市”“对某专业有强烈偏好”这类个性化需求。解决办法是在打分公式里加显式的用户偏好权重,而不是引入一个黑匣子模型。
4. SpringBoot接口与工程化:推荐结果怎么给前端、数据怎么定时刷新
4.1 后端分层:目录结构和推荐接口的返回结构
SpringBoot项目分层看起来是老生常谈,但很多毕业设计代码的问题不是分层不对,而是职责散了一地。我习惯的分层方式是:controller只做参数校验和结果包装,service只做业务编排,mapper只做SQL。推荐相关的核心代码放在service层,这样答辩时你能清晰地说出每一层在解决什么问题。
@RestController @RequestMapping("/api/recommend") public class RecommendController { private final RecommendService recommendService; @PostMapping("/run") public ApiResult<List<RecommendVO>> recommend(@Valid @RequestBody RecommendRequest req) { // @Valid会先拦截非法参数,分数超过750、位次为负数都会直接返回400 return ApiResult.ok(recommendService.recommend(req)); } }RecommendRequest的核心入参应该包含:省份、考生位次或分数、科目组、目标年份、用户偏好标签(城市偏好、专业方向、是否接受中外合作)。这里要把“分数”和“位次”设计成二选一,前端传了分数就先内部转位次,传了位次就省一步转换。我遇到过不少前端同学同时传两个值还传矛盾的情况,接口设计上可以用@AssertTrue做一个自定义校验,两个字段同时有值且换算不一致时直接报错。
返回结构上,RecommendVO至少要有这几个字段:院校名称、院校层次、专业名称、梯度(冲/稳/保)、推荐度得分、推荐理由。推荐理由这个字段极其重要,后面讲答辩技巧时还会提到,它是你区别于普通“查分系统”的亮点。
4.2 Controller层防爬与参数校验:接口不裸奔
这个项目本身是爬虫项目,但你的Controller暴露出去后同样会被别人爬。常见的做法是给推荐接口加两道防线:第一道是参数校验,第二道是限流。参数校验不光是格式问题,更是防注入的基础。比如subjectType只允许“物理/历史/综合”三个值、年份必须是2000之后的整数,这些用@Validated注解就能约束,不用自己写一堆if。
// 滑动窗口限流:每个IP每分钟最多调用30次推荐接口 @Component public class RecommendRateLimiter { private final LoadingCache<String, AtomicInteger> counters = CacheBuilder.newBuilder() .expireAfterWrite(1, TimeUnit.MINUTES) .build(); public boolean tryAcquire(String clientIp) { AtomicInteger counter = counters.getUnchecked(clientIp); return counter.incrementAndGet() <= 30; } }这里的限流方案选用Google Guava的LoadingCache做本地滑动窗口,简单而且能讲清楚原理。为什么不用Redis?因为毕业设计项目没有多实例部署需求,本地缓存足够;但你可以在论文里写一句“多实例部署时可平滑迁移到Redis Lua脚本”。接口防爬还有更狠的做法是加滑块验证码,但那会影响前端演示体验,笔试阶段不建议上。
4.3 定时刷新与前端整合:Vue打包放进SpringBoot
高考数据不是实时变化的,录取数据每年出分后集中更新一次。所以爬虫任务用@Scheduled跑定时即可,不用做成实时触发。我一般在Application类上加了@EnableScheduling,然后在采集服务上写一个每周一凌晨执行的定时方法,配合前面那张表的唯一索引做增量更新。
@Scheduled(cron = "0 0 2 * * MON") public void weeklyDataRefresh() { List<String> provinces = provinceService.loadAll(); for (String province : provinces) { try { crawlerService.crawlProvince(province, targetYear = currentYear); } catch (Exception e) { // 单个省份失败不影响其他省份,记录日志后继续 log.error("省份 {} 采集失败", province, e); } } }这里一个容易被忽略的坑是@Scheduled方法是同步串行的,如果某个省份的采集页面特别慢,会阻塞后面所有省份的更新。解决方式有两种,一是用@Async标记并配置线程池,二是把任务拆小。毕业设计阶段用@Async更轻量,但注意线程池配置里要设置RejectedExecutionHandler,否则定时任务在采集高峰期会被拒绝执行。
前端整合方面,Vue项目打包后扔进src/main/resources/static就能跑通,这确实是SpringBoot部署前端最省事的方式。但有一个细节必须处理:Vue开启history路由模式后,刷新页面会出现404,因为SpringBoot默认找不到前端路由对应的后端路径。解决办法是写一个WebMvcConfigurer将非接口路径全部转发到index.html:
@Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("forward:/index.html"); }或者约定好前端全部使用hash路由,省掉转发配置。如果不想把前后端揉在一个包里,也可以把SpringBoot当纯后端跑在8080端口,Vue用nginx或vite devServer代理转发/api路径到8080。但我个人建议毕业设计用前者,部署简单,答辩时一台电脑就能演示全流程。
5. 爬虫与推荐系统的避坑指南:版本迁移、乱码、新高考与数据陷阱
5.1 现象:JDK17 + SpringBoot 3.x 让 javax 包集体失踪
很多同学新建项目时直接选了最新版的SpringBoot 3.x,然后从网上找的SSM或老项目代码里全是javax.servlet、javax.validation。编译时发现这些包全都import不了,翻车翻得很彻底。原因是SpringBoot 3.0整体迁移到了Jakarta EE 9,所有javax.*包名变成了jakarta.*。
解决方式有两个。一是把项目降级到SpringBoot 2.7.x,继续用javax,这是最省力的方案,也是网上大部分老教程的通用版本;二是改用SpringBoot 3.x,但代码里所有javax.validation改成jakarta.validation,javax.annotation改成jakarta.annotation,同时确保JDK版本是17或更高。顺带一提,如果你用了MyBatis相关的starter,也要注意适配版本,SpringBoot 3.x必须配mybatis-spring-boot-starter 3.0+,这个版本对应关系很容易被忽略。
5.2 现象:爬回来的HTML是一堆乱码和问号
这个问题的典型表现是:控制台打印HTML内容时中文全是???,或者本来是“北京大学”变成了“鍖椾含澶у”。大多数情况是编码识别错误。前面提过考试院老页面以GB2312/GBK为主,而HttpClient默认按ISO-8859-1解码,两层错位叠加,数据直接没法看。
解决方式是:在拿到HttpEntity后,先通过Content-Type头判断字符集;头里没有就回退到页面meta声明;再不行就按GB2312尝试并验证解析后是否包含乱码特征。一个实用的小技巧是用Jsoup直接解析字节流,它会自动读取页面meta里的charset:
Document doc = Jsoup.parse(resp.getEntity().getContent(), "GB2312", url);注意这个构造方法接受的第二个参数是初始charset,如果页面meta里声明了其他字符集,Jsoup会以meta为准自动修正。这个方法比“先转字符串再解析”稳妥得多。乱码问题本质上不是技术难度大,而是“你愿不愿意多花十分钟看页面源码里的meta标签”。
5.3 现象:新高考选科让位次法失效
新高考3+1+2模式下,同一个专业的录取线按“首选科目”分成了物理组和历史组两条线。如果没把subject_type纳入采集和换算逻辑,直接用全省总位次去匹配,结果绝对偏得离谱。物理组考生人数约等于历史组的三倍,同样分数在两个组内的位次完全不同。
解决方式分两步。采集阶段:解析页面时把科目组作为一个独立字段,写进t_score_line,不能把物理组和历史组的行混吃进同一张表。算法阶段:位次换算、等效分计算、候选集筛选都必须带上subjectType条件。同时推荐系统前端入口要让用户选择选科组合,这个入参subjectType的默认值不能是“全部”,必须有明确的选择。这条如果没做,答辩老师拿一个物化生考生和一个史政地考生测同一条接口,你当场就得承认算法是失效的。
5.4 现象:最低分不是真实录取分
另一个很阴间的问题是,很多院校在某省公布的最低录取分,被中外合作办学专业、护理学专业这些“低分专业”拉低了。如果你拿这个最低分作为推荐依据,会出现“某211大学的稳档推荐,实际报计算机专业根本进不去”。毕业生被坑的典型案例:分数够了院校最低线,但不够热门专业线,最后被调剂到冷门专业,这其实是推荐系统的失职。
解决方式有两个层次。最低限度是:推荐结果里明确标注“该校最低分专业为XX,非热门专业分数请参考专业线”,至少让用户知情;进阶做法是:尽可能采集专业级的录取分数而不只是院校投档线,用专业线和考生等效分做比较。代码层面,我做了一个matchScore字段,优先取专业线,专业线缺失时才退回到院校线,并在推荐理由中注明数据粒度。
5.5 现象:定时任务重复采集,推荐数据越跑越脏
定时任务第一次跑完数据正常,第二次执行后数据量翻倍,推荐结果开始出现同校同专业两条记录。原因是写入SQL用了普通的INSERT,没有做幂等处理。解决方式在表结构设计时已经埋好了伏笔:用唯一索引 +ON DUPLICATE KEY UPDATE。MyBatis的Mapper写法大致如下:
<insert id="insertOrUpdate"> INSERT INTO t_score_line (province, year, school_code, major_name, subject_type, min_score, min_rank) VALUES (#{province}, #{year}, #{schoolCode}, #{majorName}, #{subjectType}, #{minScore}, #{minRank}) ON DUPLICATE KEY UPDATE min_score = VALUES(min_score), min_rank = VALUES(min_rank) </insert>这样重复执行定时任务时,已存在的数据只会更新分数和位次,不会新增记录。另外建议每次采集任务开启时用start_time记录本次任务ID,日志里能看出哪一次跑的数据落库了,排查问题时能省大量时间。
6. 从“能跑”到“能答辩”:回测验证、离线演示与进阶方向
6.1 用上一年数据做回测:命中率是唯一指标
推荐系统做得好不好,不能靠感觉,需要量化指标。最实操的方法是“历史回测”:取2024年真实考生的分数和实际录取院校,让系统基于2023年及以前的数据生成推荐列表,看实际录取院校落在推荐列表哪个梯度。命中率定义我建议用“实际录取院校出现在稳或保档”的占比,因为冲档本来就是博概率,没进也算正常。
public double backtest(int year) { List<StudentRecord> students = admissionMapper.selectStudents(year); int hit = 0; for (StudentRecord stu : students) { List<RecommendVO> recommends = recommendService.recommend(stu.toRequest()); if (recommends.stream().anyMatch(r -> "稳".equals(r.getLevel()) && r.getSchoolName().equals(stu.getAdmittedSchool()))) { hit++; } if (recommends.stream().anyMatch(r -> "保".equals(r.getLevel()) && r.getSchoolName().equals(stu.getAdmittedSchool()))) { hit++; } } return (double) hit / students.size(); }回测样本建议抽300到500条覆盖不同分数段的记录,命中率能到70%以上,这个项目就站得住脚。答辩时甩出这个数字,比任何架构图都有说服力。注意回测时要避免“数据泄漏”:2024年的回测只能使用2023年及以前的数据,如果把2024年的录取数据也放进候选集,命中率虚高但毫无实际意义。
6.2 给演示留一条后路:离线JSON与历史数据
现场答辩最怕的事就是网络断了、接口超时、或者院校官网结构变了导致定时任务还没跑完。经验做法是:在src/main/resources/static/mock-data.json里预置一份完整的推荐结果,前端开发时用假数据调界面,答辩时如果真实接口出问题,可以一键切换到离线模式。不是说让你欺骗答辩老师,而是作为工程上的降级方案,你可以坦然地说这是“前端联调时预留的Mock数据,正式上线会走实时接口”。这句话反而会加分。
另外数据库中一定保留一份近三年的完整历史数据,不要删。答辩老师随机问一个学校,你当场查出来数据,比背十页讲究话都管用。
6.3 答辩加分项:推荐结果的解释性输出
把推荐理由从一句话扩成三段式的“数据依据”,是这个项目最容易做出彩的地方。同样是“推荐北京XX大学”,普通输出是“该大学录取概率较大”,你的输出是:“该校软件工程专业2023年在河北物理组最低录取位次12800,您的位次为11000,等效分高2023年专业线约6分,近两年位次趋于稳定,结合您的专业偏好,推荐度88分,判定为稳档。”这三句话里包含位次、趋势、偏好三个维度,它就是你和别人拉开差距的地方。
最后说一个我自己的经验教训:代码写到一半时,不要急着去美化前端界面,先保证爬虫数据能稳定落库、推荐接口能跑通50条真实数据样本。我当年第一次做这类项目时,前端调得花里胡哨,回头发现数据表里全是乱码,一夜推倒重来。先把数据库里的“北京大学”查出来,再谈“推荐算法优化”,这个顺序能让你少走至少一周的弯路。上面讲到的回测方法、幂等写入、离线降级、解释性输出,都是我做这类题目时固定下来的习惯,希望帮到你。
本文还有配套的精品资源,点击获取