手头这套就业推荐系统,前后迭代了三个版本,技术栈始终固定在 Java + SpringBoot + SSM 这一套上。我陆续接过几个就业服务类的项目,有的是学校就业平台,有的是地方人才服务系统,名字各不相同,但底层逻辑都绕不开一件事:把求职者、职位、企业三方数据放到一起,用推荐算法让它们自动匹配。SpringBoot 提供快速启动和自动装配的能力,SSM 则把三层架构的边界理得清清楚楚。整套源码和配套的设计文档、调试文档我都整理过,这篇就顺着我实际的开发顺序,把系统设计、推荐算法落地、部署调试和常见坑完整讲一遍,适合准备做就业推荐系统、求职招聘平台这类项目的同学参考,也适合拿它作为毕业设计或项目练手的人照着搭。
这类项目名字很多,职业推荐系统、招聘推荐系统、就业匹配系统、求职推荐系统、人才推荐系统,说白了都是同一套东西。我见过不少人一上来就纠结“推荐算法要用多高级”,结果把系统做成论文式玩具,真正能跑到生产环境的核心反而是数据设计和规则逻辑。这篇文章我会把项目拆得很开,从用户场景、表结构、推荐公式到 SpringBoot 与 SSM 的协作方式都交代清楚,你哪怕没做过推荐类系统,也能照着把一整条链路串起来。
1. 就业推荐系统整体设计:它到底在解决什么问题
1.1 核心用户与业务场景拆解
做系统之前先别急着写代码,把角色和场景捋清楚,后面的表结构、接口、推荐逻辑才会自然长出来。就业推荐系统的核心用户有三类:求职者、企业招聘方、平台管理员。在校园场景里还有一个特殊的就业指导老师角色,本质是管理员的变体,负责跟踪学生的求职状态和推荐转化情况。
求职者的诉求很简单:我不想在几百个职位里翻页找,我想让系统根据我的技能和意向岗位,把合适的职位直接推到我面前。企业招聘方的诉求反过来:我发布了测试开发岗,系统能不能从简历库里捞出会 Java、懂 SpringBoot、有项目经验的人给我。管理员的诉求是审核职位、维护标签字典、看平台整体数据。这三类角色的诉求加在一起,就决定了系统必须同时具备“搜索”和“推荐”两条链路,搜索是用户主动发起的显式行为,推荐是系统根据用户画像做主动推送,两者互补才能覆盖完整场景。
这里有个经常被忽略的点:就业推荐系统的用户画像数据天生稀疏。用户注册上来可能只用 30 秒填一个意向岗位,简历内容一片空白,这个时候硬上协同过滤算法,结果就是推荐冷启动失败,页面一片空白或者推得乱七八糟。所以系统设计时第一优先级不是算法多炫,而是把“标签字典”和“规则推荐”作为底座,算法降级策略也要提前想好。
1.2 为什么“匹配”比“搜索”更能体现系统价值
很多新手会把就业推荐系统做成一个普通的职位 CRUD 加模糊查询,能搜出职位就交差。但真到落地的时候你会发现,搜索只能覆盖用户明确知道要什么的情况。比如一个转行来学 Java 的求职者,他可能只知道自己想找“后端开发”工作,但不知道具体应该搜什么关键词、什么技能栈匹配度更高。这个时候推荐的价值就出来了:系统从他填写的意向岗位出发,自动关联出 Java、SpringBoot、MySQL 这些技能标签,再根据这些标签去职位池里计算匹配度,把他可能感兴趣但自己搜不到的内容送到面前。
从业务指标上说,“搜索转化率”和“推荐转化率”是两个完全不同的优化目标。搜索转化率靠的是排序准确性和结果页质量,推荐转化率靠的是画像丰富度和推荐理由的可见性。我在做这套系统时专门加了一个推荐理由模块,每条推荐结果下面都展示“为什么推荐给你”:技能匹配 80%、同城、薪资符合期望等。这个细节对用户信任感的提升非常明显,推荐转化率比不带理由的版本高了不少。
1.3 功能模块规划与页面结构
系统的功能模块按角色划分,大致可以整理成下面这个清单:
| 角色 | 核心功能 | 关键页面/操作 |
|---|---|---|
| 求职者 | 简历维护、职位浏览、推荐列表、投递管理、消息通知 | 简历编辑页、推荐结果页、投递记录页 |
| 企业招聘方 | 企业注册、职位发布/上下架、简历查收、推荐候选人查看 | 职位管理页、收到的简历列表、候选人推荐页 |
| 平台管理员 | 用户管理、职位审核、标签字典维护、数据统计 | 用户列表、审核工作台、报表页 |
前端整体采用 Vue + Element UI,和后端通过 RESTful API 对接。求职端的核心链路是:完善简历 → 查看推荐职位 → 投递简历 → 查看投递反馈;企业端的核心链路是:发布职位 → 查看推荐候选人 → 筛选简历 → 发送面试邀请。两条链路在数据库层面共用职位表和用户表,但通过不同的接口和页面呈现。
可能有人会问,为什么企业端也要推荐候选人?因为对企业来说,主动投递的简历是“被动等待”,而推荐候选人可以主动激活那些有技术但不一定投了这家公司的用户。这个双向推荐机制是就业推荐系统区别于普通招聘网站的核心亮点,在答辩或者项目演示时非常加分。
2. 技术选型拆解:SpringBoot与SSM的取舍与融合
2.1 SpringBoot和SSM是什么关系,为什么两者可以同时出现
很多人看到“Java + SpringBoot + SSM”这个组合会有点懵,觉得 SpringBoot 和 SSM 不是二选一的关系吗?其实不是。SSM 是 Spring + SpringMVC + MyBatis 的组合,SpringBoot 是 Spring 生态下的快速开发框架,底层用的还是 Spring 的 IoC 容器和 SpringMVC 的请求处理机制。我在这套系统里把 SpringBoot 作为工程外壳,负责自动装配、内置容器、starter 依赖管理,而 SpringMVC 的注解式控制器、MyBatis 的持久层映射,这些 SSM 时代的核心技术全部保留。
这个组合对学习者和实战项目都很划算:一方面,SpringBoot 让你不用处理繁琐的 XML 配置,项目搭起来快,开发效率高;另一方面,SSM 那套分层思想在代码里清晰可见,Controller、Service、Mapper 三层边界分明。遇到复杂查询时,MyBatis 的动态 SQL 比 JPA 的自动装配更可控,推荐系统的报表统计类 SQL 也能手写优化。而且现在很多公司的老项目还是 SSM 结构,新项目才用 SpringBoot,你如果两套都熟,面试聊起来会从容很多。
2.2 SSM常用注解盘点与实际应用位置
既然标题挂了 SSM,我顺手把项目里真正用到的注解清单列一下,都是面试高频内容,也是你读这套源码时经常看到的。
- @Controller / @RestController:表现层注解。类上标注后,Spring 容器会把它注册为处理器。@RestController 是 @Controller + @ResponseBody 的组合,接口统一返回 JSON,项目里所有前后端分离接口都用它。
- @Service:业务层注解,标注在服务实现类上。推荐服务 RecommendService、简历服务 ResumeService 都标了这个注解,Spring 扫描到后自动创建单例 Bean。
- @Repository / @Mapper:持久层注解。MyBatis 的 Mapper 接口上标 @Mapper,或者在启动类上标 @MapperScan,把 Mapper 接口批量注册进去。
- @Autowired / @Resource:依赖注入。@Autowired 按类型注入,@Resource 按名称注入。我习惯在构造器注入,写测试和排查空指针都会舒服一些。
- @RequestMapping / @GetMapping / @PostMapping:路由映射。RESTful 风格接口一般用 @GetMapping("/job/{id}") 加 @PathVariable 接收路径参数。
- @RequestBody / @ResponseBody:JSON 序列化与反序列化。接收前端传来的 JSON 对象,返回时自动序列化为 JSON。
- @Transactional:事务控制。投递简历、生成推荐日志、更新职位名额这种多表写操作必须加事务。
- @Configuration / @Bean:Java 配置类。项目里配置拦截器、跨域处理、WebMvcConfigurer 时会用到。
- @SpringBootApplication:这是 SpringBoot 的复合注解,包含 @Configuration、@EnableAutoConfiguration、@ComponentScan 三个核心注解,项目启动入口。
这些注解在项目里都有明确落点,读代码的时候可以先找这些标记,再从标记往外扩,比从头到尾硬啃快得多。
2.3 数据层设计:MyBatis的Mapper与XML分工
这套系统数据库用的是 MySQL 8.0,持久层选了 MyBatis。MyBatis 的用法核心是“接口 + XML”或“接口 + 注解”,我在这套项目里统一采用 Mapper 接口 + XML 映射文件的方式,因为推荐系统免不了写动态条件查询和统计 SQL,XML 里写 、 、 比注解拼字符串好用太多。
举个例子,职位多条件搜索接口需要支持关键字、城市、学历、薪资范围组合查询,XML 映射文件里这样写:
<select id="searchJobs" resultType="com.example.recommend.entity.RecJob"> SELECT * FROM rec_job <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR industry LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="city != null and city != ''"> AND city = #{city} </if> <if test="maxSalary != null"> AND salary_max <= #{maxSalary} </if> <if test="education != null and education != ''"> AND education_require = #{education} </if> </where> ORDER BY publish_time DESC </select>这里要注意一个经典问题:#{} 和 ${} 的区别。#{} 是预编译参数占位符,MyBatis 会把它替换成 ?,由 JDBC 的 PreparedStatement 处理,能防 SQL 注入;${} 是字符串拼接,直接把值拼进 SQL,有注入风险。用户传入的 keyword、city 这类值一律用 #{},只有表名、排序字段这种需要动态拼 SQL 结构的地方用 ${},而且要提前校验白名单。
数据层还有一个实践心得:分页不要用 LIMIT 手写偏移量,数据量大了之后深分页性能会断崖式下降。项目里我接了 PageHelper 插件,配合startPage 方法自动生成分页 SQL,代码干净,性能也够用。这个细节不多,但面试和代码审查里经常被问到。
3. 推荐引擎落地:从简历与职位中提取标签并计算匹配度
3.1 冷启动问题:新用户和新职位没有行为数据怎么办
推荐系统最大的坑就是冷启动。新用户没投递没浏览,新职位没收藏没点击,协同过滤算法在这个阶段直接失效。我在这套系统里的解法分三层:
第一层是用户注册信息兜底。注册时强制选择意向岗位类型、期望城市、期望薪资范围,这些字段直接拼成初始推荐条件。第二层是简历完整度驱动。用户完善技术栈标签、项目经历、自我评价时,系统会实时更新推荐结果,并提示“简历完善度达到 80% 可解锁精准推荐”。第三层是规则推荐兜底。如果画像数据实在不足,就按发布时间、点击热度、同城条件做简单排序,保证推荐列表不为空。
这套策略的好处是逻辑简单、不依赖大数据积累,而且每一层都有明确的业务含义。对毕业设计或者中小型平台来说,这比盲目堆一个协同过滤模型更实用。我在设计文档(就是常说的 LW 文档)里单独开了一节讲“推荐降级策略”,答辩老师对这个点普遍感兴趣,因为它体现的不只是算法能力,还有工程思维。
3.2 简历解析与中文分词:HanLP在SpringBoot中的接入
推荐准确性的基础是标签提取。用户在简历里填的技能标签是多选框,提取起来很简单,但职位描述和自我评价是自由文本,需要做分词和关键词提取。项目里我用了 HanLP 中文分词库,和 SpringBoot 整合非常简单,加一个 Maven 依赖就能直接用:
<dependency> <groupId>com.hankcs</groupId> <artifactId>hanlp</artifactId> <version>portable-1.8.4</version> </dependency>import com.hankcs.hanlp.HanLP; import com.hankcs.hanlp.seg.common.Term; public List<String> extractTags(String text) { List<Term> terms = HanLP.segment(text); return terms.stream() .filter(t -> t.nature.toString().startsWith("n")) // 只保留名词 .map(t -> t.word) .filter(w -> !stopWords.contains(w)) .limit(20) .collect(Collectors.toList()); }分词结果会和系统内置的标签字典做交集,能匹配上的词才作为有效标签落库。这样做的原因是职位描述里可能写“熟练掌握 Java、SpringBoot、MySQL”,分词后能提取出 Java、SpringBoot、MySQL;而用户自我评价里可能写“熟悉 Redis 缓存方案”,也能提取出 Redis。双方在标签维度对齐之后,相似度计算就顺理成章了。
这里有个细节:HanLP 的 portable 版本开箱即用,但词库是通用领域的,对 IT 岗位和“产品经理”“运营”这类词覆盖一般。实际项目中我在标签字典里手工补充了一百多个 IT 技术词条和岗位词条,分词后先走自定义词典,再走通用词典,准确率提升很明显。自定义词典配置可以用 HanLP.Config.CustomDictionaryPath,或者直接在代码里调用 CustomDictionary.add("Java后端")。
3.3 基于标签向量的余弦相似度计算
标签提取完成后,我把用户和职位都表示成标签集合,再用余弦相似度计算匹配得分。原理其实一句话:把两端文本映射到多维空间的向量,向量的每个维度是标签权重,然后计算这两个向量的夹角余弦值。如果两个集合完全一样,向量重合,相似度为 1;如果完全没有交集,向量正交,相似度为 0。
在 Java 里的实现非常直接:
public double calSimilarity(Map<String, Double> userTags, Map<String, Double> jobTags) { double dot = 0.0; double normUser = 0.0; double normJob = 0.0; for (Map.Entry<String, Double> entry : userTags.entrySet()) { if (jobTags.containsKey(entry.getKey())) { dot += entry.getValue() * jobTags.get(entry.getKey()); } normUser += entry.getValue() * entry.getValue(); } for (Double weight : jobTags.values()) { normJob += weight * weight; } if (normUser == 0.0 || normJob == 0.0) { return 0.0; } return dot / (Math.sqrt(normUser) * Math.sqrt(normJob)); }权重怎么定?我用的方案是:用户侧,用户主动选择的技能标签权重为 1.0,从项目经历和自我评价中自动提取的标签权重为 0.6;职位侧,职位要求里明确列出的技术标签权重为 1.0,职位描述分词提取的标签权重为 0.5。这个权重分配适应了“用户主动填的比自动提取的可信”这一经验,实现简单,效果也比较稳。
3.4 硬性条件过滤与综合排序策略
纯相似度计算适合做候选集,不适合直接做最终结果,因为用户可能和职位标签相似度很高,但城市不匹配、薪资对不上、学历被卡。所以在推荐流程里,我分为两步:先过滤,再排序。
过滤条件包括:期望城市与职位城市一致(或同省)、职位薪资范围与期望薪资有交集、学历要求不低于用户学历、岗位类型匹配。这些条件在 SQL 查询阶段直接完成,不进入 Java 内存计算,避免全表扫描。
排序阶段用加权评分公式:
score = 0.55 * 标签相似度 + 0.25 * 职位热度分 + 0.20 * 简历完善度职位热度分是把职位浏览量做百分位归一化,浏览量最高的职位热度分为 1,最低为 0。简历完善度是用户简历字段填写比例,比如教育经历、技能标签、项目经历各占固定权重。这个公式把“匹配度”放在第一位,同时兼顾了职位本身的受欢迎程度和用户资料的完整度,不会出现推了很匹配但没人看过的冷门职位,也不会出现资料残缺的用户拿到一堆奇怪推荐。
最终推荐接口每次返回 20 条候选,按 score 倒序取前 10 条展示,同时把 score 换算成百分制匹配度展示在页面上。用户对“匹配度 85%”这种可视化表达接受度很高,这也是我从实际数据里观察到的一个有用细节。
4. 核心功能模块实现:用户体系、职位模块与投递链路
4.1 登录鉴权与行级权限:企业管理员只能看自己的数据
用户体系用的是 SpringBoot + JWT 的无状态鉴权方案。用户登录成功后,后端签发一个 JWT,里面携带用户 ID、角色编码、企业 ID 等关键信息,前端把 token 存到 localStorage,请求时放在 Authorization 请求头里。后端写了一个拦截器做 token 校验和用户身份解析。
这里我必须提一下行级权限,这个是很多实战项目容易漏掉的地方。普通后台管理系统“用户管理”是全局的,但就业推荐系统里,企业招聘方登录后只能看自己企业发布的职位和收到的简历。如果后端接口对企业 ID 不做限制,任何企业登录后都能查全部简历,这就是严重的数据越权漏洞,也就是常说的水平越权。
我处理的思路很简单:数据表里增加 enterprise_id 字段,所有涉及企业数据的查询 SQL 强制带企业 ID 条件:
SELECT * FROM rec_job WHERE enterprise_id = #{enterpriseId}并且推荐候选人接口在数据层就按当前登录企业 ID 过滤,候选列表里只出现投递过本企业职位或者标签匹配本企业职位的用户。代码审查时这块我会特意标注“行级权限控制”,在面试里主动讲出来,能让面试官觉得你不只是会写 CRUD。
4.2 职位发布与多条件检索
职位发布功能在企业端,核心字段包括职位名称、所属行业、职位类型(全职/实习/兼职)、城市、学历要求、薪资区间、职位描述、技能标签、招聘人数、发布状态。职位发布后默认状态为待审核,管理员审核通过后才会出现在求职端搜索和推荐结果里,这个审核流程能避免垃圾职位污染推荐池。
职位检索接口在求职端,前面已经展示了 MyBatis 动态 SQL 的写法。实际数据量上来后,我还给查询频繁的字段加了索引,rec_job 表的 city、education_require、salary_max 字段,以及 rec_resume 的 user_id 字段都建了普通索引。这里有个经验:不要为了“可能用得上”给每个字段都建索引,索引太多会影响写入性能,你在业务 SQL 里真正作为 WHERE 条件和 JOIN 条件的字段才值得建索引。
职位列表页还有一个细节是排序方式。常规列表按发布时间倒序,搜索列表按相关度排序。如果两者都用同一种排序,用户体验就不对。这套项目里搜索列表先用数据库做条件过滤,再在内存里按综合评分排序,因为候选集通常不超过几百条,内存排序完全扛得住,而且能复用推荐算法的评分逻辑。
4.3 简历模块与推荐日志表结构设计
简历模块是推荐系统的数据燃料,所以表结构设计比普通项目更讲究。rec_resume 表核心字段包括:user_id、真实姓名、出生年份、最高学历、工作年限、期望职位类型、期望城市、期望薪资、技能标签(以逗号分隔或关联标签表)、教育经历(JSON 字段)、项目经历(JSON 字段)、自我评价(文本)。技能标签我单独抽了一张 rec_tag 字典表,用户简历关联 rec_user_tag,职位关联 rec_job_tag,这样标签数据可复用、可统计,标签白名单也好维护。
推荐日志表 rec_recommend_log 是整个系统最值得单独讲的一张表。它记录每一次推荐结果生成:用户 ID、职位 ID、标签相似度、综合评分、推荐来源(规则推荐/相似度推荐/热度兜底)、是否被用户点击、是否投递。这张表看起来是纯日志,但后期做推荐效果评估全靠它,可以统计推荐位的点击率、投递转化率,还能分析哪些推荐来源的效果更好。做推荐系统的同学,一定别把日志表落下。
投递模块相对简单:用户点击投递,系统先查重(同一用户不能重复投递同一职位),然后写 rec_apply 表,状态为待处理;企业端收到新投递后可以标记为已查看、已邀请面试、已通过/已拒绝;用户端通过站内消息接收状态变化通知。
4.4 投递链路上的事务控制:保证数据一致性
投递简历这个操作看似简单,实际上牵涉多个表:写简历投递记录、更新推荐日志表的投递状态、给企业生成站内消息通知,如果职位有招聘人数限制,还要原子扣减剩余名额。这些操作要么全部成功,要么全部回滚,否则就会出现“简历投出去了但消息没通知到企业”这种不一致状态。
我用的方案是在业务方法上标注 @Transactional,让 Spring 的事务管理统一提交和回滚:
@Transactional(rollbackFor = Exception.class) public void applyJob(Long userId, Long jobId) { ApplyRecord record = buildApplyRecord(userId, jobId); applyRecordMapper.insert(record); recommendLogMapper.markApplied(userId, jobId); messageService.sendNoticeToEnterprise(enterpriseId, userId, jobId); jobMapper.decreaseHeadcount(jobId); }这里有一个新手容易踩的坑:同一个类内部方法调用,@Transactional 注解会失效。原因是 Spring 事务基于 AOP 代理,同类方法之间的调用走的是 this 引用,不会经过代理对象,所以事务不生效。我在项目里专门把事务入口和内部逻辑拆到不同类,或者用 TransactionTemplate 手动管理事务,保证了事务边界没有被绕过。
关于数据一致性,还有一个面试容易问到的点:高并发下多个用户同时投递同一个最后名额怎么办。我当时的方案是给 rec_job 表的剩余名额字段加版本号或用 UPDATE ... SET headcount = headcount - 1 WHERE headcount > 0 做乐观控制,保证不会超卖。这种问题和秒杀系统的库存扣减是同一个套路,弄懂一个就能举一反三。
5. 实操过程:环境准备、数据库初始化与系统启动
5.1 环境版本选择与踩坑提醒
我先说一版最稳的组合,也是我实际开发时用的:JDK 8、Maven 3.6.3、MySQL 8.0、SpringBoot 2.7.x、Vue 2 + Element UI。为什么不用 SpringBoot 3.x?因为 3.x 要求 JDK 17,而且很多老教程和老包还在用 javax 命名空间,SpringBoot 3.x 已经全面切到 jakarta,一不留神就会报类找不到的错。如果你非要用新版本,建议单独开一个分支玩,项目主体保持在 2.7.x 最省心。
工具方面,IDEA 就足够了,社区版也能跑,只是需要手动装 Lombok 插件。数据库工具我用 Navicat 和 DataGrip 轮流用,反正都行。下面是版本组合的简单对比:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | 1.8 最稳,SpringBoot 2.x 完全兼容 |
| Maven | 3.6.3+ | 3.8+ 需要留意镜像配置 |
| MySQL | 8.0 | 字符集 utf8mb4,连接串加 serverTimezone |
| SpringBoot | 2.7.x | 避免 3.x 的 jakarta 迁移坑 |
| Vue | 2.x | 和 Element UI 搭配成熟 |
5.2 Java环境变量配置与Maven构建命令
JDK 安装本身不复杂,麻烦的是环境变量配置。Windows 下安装 JDK 后,需要配置 JAVA_HOME 指向 JDK 安装根目录,然后在 Path 变量里加上 %JAVA_HOME%\bin。配置完在命令行里敲 java -version 能输出版本信息就说明成功。这个步骤看着基础,但我见过不止一个同学卡在这里,报“命令提示符中 javac 不是内部或外部命令”,十有八九是 Path 没配或者配完没重新打开命令行。
Maven 配置就更需要注意国内网络问题。默认中央仓库下载依赖非常慢,建议修改 settings.xml,把镜像指向阿里云仓库。Maven 项目构建的核心命令就是:
mvn clean package -DskipTests这个命令会编译代码、跑测试(跳过)、打 jar 包。打包完成后,在项目根目录 target 下会生成一个可执行 jar,直接 java -jar 也能跑,但开发时我通常直接在 IDEA 里启动 Application 入口类,热调试方便得多。
5.3 SpringBoot核心配置:数据源、端口、MyBatis
项目的核心配置都在 src/main/resources/application.yml 里。下面是我实际使用的配置模板:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/recommend_job?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 servlet: multipart: max-file-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.recommend.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里两个配置决定开发体验:map-underscore-to-camel-case 开启后,数据库里 create_time 这种下划线字段会自动映射到 Java 的 createTime 属性,不用手写一堆别名;log-impl 设为 StdOutImpl,控制台会打印完整 SQL 和参数,排查推荐数据不对的时候非常有用。
端口配置也是高频需求,有人问怎么在 IDEA 里给 SpringBoot 配置启动端口。最简单的方式就是改 yml 里的 server.port,也可以在启动配置的 Environment variables 里填 SERVER_PORT=8080,效果一样。如果同时启动多个服务还不想改代码,后一种方式更灵活。
5.4 数据库初始化脚本与标签字典
数据库一共 10 张核心表:sys_user、rec_enterprise、rec_job、rec_resume、rec_tag、rec_user_tag、rec_job_tag、rec_apply、rec_recommend_log、sys_message。初始化脚本里除了建表语句,最有价值的是预置标签字典和演示数据。
标签字典至少要覆盖主流岗位方向和技术栈:Java、SpringBoot、SSM、MySQL、Redis、Vue、前端开发、测试开发、产品经理、运营、数据分析等。每个标签挂一个分类,比如“后端开发”“前端开发”“测试”“产品”,这样职位发布时可以通过分类快速选择标签,避免每次手输导致同一个意思出现“Java”“java”“JAVA”三种写法,标签体系脏了,推荐算法再好的代码也算不出好结果。
演示数据也很关键。我预置了 5 个求职者账号、3 个企业账号、20 个职位、50 条用户标签关系、30 条职位标签关系。这些数据能保证系统启动后立刻看到完整效果,不用因为没数据而对着空白页面发呆。调试文档里我会标注每个演示账号的密码和预期看到的推荐结果,讲解视频里也是一步步照着演示。
5.5 启动验证、接口自测与前端打包集成
后端启动成功后,先验证登录接口,再依次验证职位搜索接口、推荐接口、投递接口。推荐接口的验证方式是:用一个技能标签是 Java、SpringBoot、MySQL 的账号登录,请求 /api/recommend/jobs 接口,看返回列表里是否优先出现带 Java 标签的职位。
前端 Vue 项目开发时通过代理转发接口请求,生产环境则把打包后的 dist 目录直接放进 SpringBoot 的静态资源目录。具体做法是把 Vue 打包产物复制到 src/main/resources/static 下,SpringBoot 会直接托管这些静态文件,访问 http://localhost:8080 就能打开系统首页。需要注意,如果 Vue 用了 history 路由模式,刷新二级页面会出现 404,解决方式是改用 hash 模式,或者在 SpringBoot 里加一个转发配置,把非接口路径全部转发到 index.html。这里我建议直接用 hash 模式,改动最小,效果最稳。
另外,SpringBoot 默认启动时会打印那个大写的 Spring Boot Banner,很多人在意这个细节,想换个好看的或者去掉。做法是在 resources 目录放一个 banner.txt,里面写上你的 ASCII 艺术字,启动时就会自动替换,网上有 banner 生成器在线生成,很方便。
6. 常见问题排查与避坑实录
6.1 SpringBoot版本过高引起的javax/jakarta问题
这是我强烈建议你避开的坑。SpringBoot 3.x 发布后,如果你从网上复制一个老教程的代码,使用 javax.servlet、javax.annotation 这类包,编译会直接失败,因为 3.x 已经全面切换到 jakarta.servlet 和 jakarta.annotation。很多老项目升级时把依赖一改就抛 NoClassDefFoundError,就是因为这个原因。
我在这套项目开发过程中刻意锁定了 SpringBoot 2.7.x,既能用老教程里的大部分代码,又能兼容当前主流面试题提到的自动装配机制。如果你确实要学习新版本,建议单独开个小 Demo 项目研究,别拿就业推荐系统这种大项目当小白鼠,否则光依赖迁移就能耗掉你大半天。
6.2 自动装配失效与Mapper扫描问题
SpringBoot 的核心卖点之一是自动装配,但真正遇到“明明写了 Mapper 接口却注入失败”的人不在少数。报错信息通常是 NoSuchBeanDefinitionException 或者 Invalid bound statement (not found)。排查思路就三步:
第一,检查启动类有没有 @MapperScan 注解,有的话确认扫描包路径和 Mapper 接口所在包一致。第二,检查 XML 文件的 namespace 是不是写的接口全限定名,如果 namespace 写错,则运行时会报绑定异常。第三,确认 Mapper 接口没有和 Service 层混在一个包里导致扫描不到。这三步检查完,九成问题能解决。
我项目里启动类的写法是:
@SpringBootApplication @MapperScan("com.example.recommend.mapper") public class RecommendApplication { public static void main(String[] args) { SpringApplication.run(RecommendApplication.class, args); } }如果你习惯在接口上加 @Mapper,那 @MapperScan 可以不加,但项目里接口多了之后还是用扫描更整洁。
6.3 前端页面404与静态资源无法访问
前端打包放进 SpringBoot 后,最常见的两个表现:首页能打开,但刷新路由页面直接 404;或者干脆整个页面样式都没有,接口数据正常但页面白屏。前者基本就是 Vue router 用了 history 模式,换成 hash 模式即可解决;后者要确认 dist 目录里的文件是否真的放在了 classpath:/static/ 下,放错位置 SpringBoot 按路径找不到。
还有一个隐蔽问题:如果接口和静态资源路径撞了,比如你有 /job/{id} 这样的接口,而 static 下恰好也有一个 job 目录,SpringBoot 的静态资源映射会优先接管。解决方式是尽量别让接口路径和静态目录同名,前端接口统一加 /api 前缀,这个习惯在前后端分离项目里几乎是标准做法。
6.4 事务不生效和数据不一致的定位思路
“数据不一致”在投递模块出现时,第一反应不是去看算法,而是查事务边界。最典型的原因就是我前面说的同类内部方法调用导致事务失效,排查方法是在方法入口打印当前调用栈,或者观察日志里事务日志(TransactionInterceptor)有没有出现。如果整个方法操作多张表但完全没有事务日志,十有八九是代理链断了。
另一个常见原因是事务方法里 try-catch 把异常吃掉了。比如投递方法内部 insert 失败,业务代码 catch 后记了日志但没 rethrow,Spring 拿到不到异常就不会回滚,数据就停留在半完成状态。正确姿势是 catch 之后抛一个自定义业务异常,或者干脆不 catch,让全局异常处理器统一处理并返回友好提示。
6.5 推荐结果为空或者推荐不准
推荐接口返回空列表的原因基本集中在这几点:职位池为空、过滤条件过严、标签没有对齐。我调试时遇到过用户期望城市是北京,但测试职位都是上海的,过滤后自然为空;也遇到过用户标签是“Java后端”,职位标签库里存的是“Java”,字符串对不上导致相似度恒为零。
解决办法有三个方向:一是过滤条件拆开放松,比如先按城市过滤,没有结果再扩大同省;二是标签匹配支持同义词映射,把“Java后端”“Java开发”“Java工程师”归并到同一个标准标签下;三是兜底推荐,只要推荐池不足 10 条,就补热度最高的职位,保证界面不空。推荐不准的调优则要靠 rec_recommend_log 表的数据反馈,看用户点击了哪些推荐,没点哪些,反过来调权重,这一步是做推荐系统最有意思的部分。
7. 项目扩展方向:从能用到好用还能怎么升级
这套就业推荐系统目前是基于标签相似度和规则推荐实现的,优点是逻辑透明、部署简单、数据依赖低,但也意味着它没有“越用越聪明”的自学习能力。如果后续想往更高级的方向扩展,我建议按下面几条路线走。
第一,算法层面可以从内容推荐升级到协同过滤。基于用户的历史投递行为建立“相似用户”关系,把和你画像类似的用户看过的职位也推荐给你。这条路线的实现成本不算高,推荐效果会有明显提升,但要特别注意冷启动问题,新用户行为数据少时仍然要回到标签推荐兜底。
第二,架构层面可以做异步化。现在推荐计算是同步的,用户请求推荐时实时算,一旦用户量上来,数据库并发压力会很大。可以引入消息队列,比如 RabbitMQ 或 ActiveMQ,把“用户完善简历”后的推荐重算放到异步消费;更复杂的还可以接 Flink 做实时特征计算,实时更新热度和点击特征,这在大型招聘平台里是标配思路。
第三,运营层面可以加反馈闭环。给推荐结果增加“不感兴趣”按钮,把用户负反馈记录下来,下次推荐时压低相关标签的权重。这个功能虽然简单,但能把推荐系统的用户体验提升一个档次,而且是在现有标签体系上做增量,不需要推倒重来。
我在实际项目里最深的体会是:推荐系统的价值不完全取决于算法先进性,而取决于数据质量、规则设计的合理性和降级策略的完备性。你可能用很基础的余弦相似度,但只要标签体系干净、数据闭环完整,效果就足够超过很多堆模型的毛坯系统。反过来,数据乱成一团,再好的模型也只是在垃圾数据上提炼垃圾结论。
最后分享一个小技巧,也是我每次调试推荐系统都用的方法:先给同一个用户构造两组对比数据,一组是全简历画像,一组是只有基本信息,分别请求推荐接口,观察两组推荐结果的差异。如果差异不大,说明标签提取链路可能没生效;如果差异明显但正确,说明推荐逻辑基本靠谱。这招虽然土,但定位问题非常快,建议你也试试。