案例 14:招聘简历智能初筛
本案例对应 JQuick-Java 仓库 USE-CASES.md 中的场景 14,面向 HR SaaS 领域的招聘简历两段式智能初筛。第一段用学历、相关年限、技能覆盖率(80%)、薪资带宽、工作地五个硬门槛,叠加高频跳槽与超长空窗两个否决项,确定候选集合;第二段仅对通过硬筛的候选人按学历院校、经验、技能、稳定性、资质英语五个维度评分,路由到推荐面试(STRONG / NORMAL)、人才储备(RESERVE)或淘汰(REJECT),全部门槛与评分口径由 JQuick-Java 的 XML 规则代理驱动。
招聘系统每天从内推、猎头、在线渠道(REFERRAL / HEADHUNTER / ONLINE)收进大量简历。HR 发布岗位时定义岗位要求学历、要求年限、必备技能清单与薪资带宽(下限/上限)、工作城市;简历侧提供候选人学历(PHD / MASTER / BACHELOR / COLLEGE)、院校层次(985 / 211 / ORDINARY)、相关工作年限、技能匹配数、期望薪资、所在城市与是否接受异地调动,以及近 5 年跳槽次数、职业空窗月数、资格证、英语水平(CET6 / CET4 / NONE)。
初筛第一段是硬性门槛与稳定性否决:学历先档位化(博士 4 / 硕士 3 / 本科 2 / 大专 1),候选人档位不低于岗位要求即通过;相关年限不少于要求年限通过;技能覆盖率达到 80% 通过(用交叉相乘比较,规避整数除法精度问题);期望薪资不超过带宽上限且不低于下限的八成通过(恶意低报同样拦截);同城或接受异地调动通过。五项全部满足才算硬门槛通过;而近 5 年跳槽 ≥4 次或空窗 ≥24 个月直接命中稳定性否决项。
第二段是五维评分,满分 100:学历院校分按超档 20 / 高 1 档 15 / 达标 10,另加 985 +5、211 +3;经验分按超出年限 ≥5/≥2/≥0 年给 25/20/12;技能分按覆盖率 100%/80%/60% 给 20/14/8;稳定性分按跳槽 0/1/2 次给 15/10/5,空窗 ≤3 个月再加 5;资质证 +5、CET6 +5、CET4 +3。
结论路由严格两段式:命中否决项或任一硬门槛不满足即 REJECT,评分不改变淘汰结论;硬筛通过后 ≥75 分进入面试、≥55 分进入人才储备、其余淘汰。推荐等级进一步细分:≥85 STRONG、≥75 NORMAL、≥55 RESERVE,淘汰候选人在 Service 侧被强制置为 NONE。决策 reasons 逐条列出未满足的门槛、命中的否决项以及面试/储备推荐说明。
- 硬门槛与评分混在一起导致误判:若不区分两段,可能出现「评分高但学历不达标仍进面试」或「跳槽 5 次只因综合分高而通过」,招聘红线被分数稀释。
- 门槛口径散落各招聘渠道代码中:80% 技能覆盖率、薪资下限八成、空窗 24 月等阈值硬编码在不同服务里,岗位族调整标准时需要多处改动并重新发版 HR SaaS。
- 技能覆盖率用整数除法有精度隐患:匹配数/要求数直接整除会丢失小数,4/5 在整数除法下得 0,临界覆盖判定不可靠。
- 五维评分难以独立调参与解释:院校、经验、技能、稳定性、加分项若揉成一个大方法,HR 无法单独调整某一维度权重,也无法向用人部门解释候选人分数构成。
- 薪资不仅要防超顶还要防低报:只校验期望薪资不超上限会放过恶意低报候选人,必须同时约束下限八成,这类复合门槛用普通配置表难以表达。
- SaaS 多租户环境与老旧部署并存:不同客户的 JDK 版本与基础设施差异大,部分客户仍在 JDK 8 / 信创环境,无法引入重型规则平台,需要轻量可嵌入且决策结果可审计的规则方案。
本案例采用「入参模型 + 决策结果模型 + 规则接口 + XML 规则脚本 + Service 编排 + JUnit 测试」六件套结构,硬门槛、否决项、五维评分与路由阈值全部外置在 XML 中,Service 负责门槛逐项判定、评分汇总与淘汰原因组装:
| 层次 | 职责 | 对应文件 |
|---|---|---|
| 领域模型 | 简历要素、初筛决策(硬筛结果/总分/结论/推荐等级/说明) | Resume.java、ScreeningDecision.java |
| 规则集与 XML | 5 项硬门槛 + 否决项 + 5 维评分 + 结论路由(14 个方法) | ResumeScreeningRules.java、resume-screening.xml |
| 编排服务 | 门槛逐项判定与否决原因、评分汇总、推荐等级仅对通过者生效 | ResumeScreeningService.java |
| 测试 | 6 组候选人形态(理想、学历不足、薪资超带、储备、否决、调动) | ResumeScreeningServiceTest.java |
规则方法拆分
Section titled “规则方法拆分”规则接口共 14 个方法,门槛类、否决类、评分类、路由类各司其职:
| 规则方法 | 口径 | 分值/输出 |
|---|---|---|
eduRank |
学历档位化:博士 4 / 硕士 3 / 本科 2 / 大专及其他 1 | 1 ~ 4 |
educationPass |
候选人学历档位 ≥ 岗位要求档位即通过 | boolean |
experiencePass |
相关工作年限 ≥ 岗位要求年限即通过 | boolean |
skillPass |
要求技能数 ≤0 直接通过;否则匹配数 ×100 ≥ 要求数 ×80(覆盖率 80%)通过 | boolean |
salaryPass |
期望薪资 ≤ 带宽上限 且 ≥ 下限 ×0.8 才通过 | boolean |
locationPass |
所在城市与工作城市相同,或接受异地调动即通过 | boolean |
hardReject |
近 5 年跳槽 ≥4 次 或 空窗 ≥24 个月返回 true | boolean |
scoreEducation |
超档 20 / 高 1 档 15 / 恰好达标 10;985 再 +5、211 再 +3 | 0 ~ 25 |
scoreExperience |
超出年限 ≥5 给 25 / ≥2 给 20 / ≥0 给 12 / 不足给 0 | 0 ~ 25 |
scoreSkills |
要求数 ≤0 或全覆盖给 20;覆盖 ≥80% 给 14;≥60% 给 8;否则 0 | 0 ~ 20 |
scoreStability |
跳槽 0/1/2 次给 15/10/5;空窗 ≤3 月再 +5 | 0 ~ 20 |
scoreBonus |
持资格证 +5;CET6 +5;CET4 +3 | 0 ~ 10 |
screeningResult |
否决项或硬门槛不通过 REJECT;通过后 ≥75 INTERVIEW、≥55 RESERVE、否则 REJECT | 结论字符串 |
recommendLevel |
≥85 STRONG;≥75 NORMAL;≥55 RESERVE;否则 NONE | 推荐等级字符串 |
代码实现(完整可运行)
Section titled “代码实现(完整可运行)”将以下六个文件按源码原包名放入 JQuick-Java 的测试工程:两个模型放在 com.github.paohaijiao.demo.model,规则接口与测试类放在 com.github.paohaijiao.demo,编排服务放在 com.github.paohaijiao.demo.service,XML 脚本放入 src/test/resources/demo/。保持目录与包名不变后,在工程根目录执行 mvn test -Dtest=ResumeScreeningServiceTest 即可运行。
Resume.java
Section titled “Resume.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 候选人简历要素(初筛所需的硬性条件与画像) */@Data@NoArgsConstructor@AllArgsConstructorpublic class Resume {
/** * 候选人姓名 */ private String candidateName;
/** * 应聘岗位 */ private String positionName;
/** * 候选人学历:PHD / MASTER / BACHELOR / COLLEGE */ private String educationLevel;
/** * 岗位要求学历 */ private String requiredEducation;
/** * 毕业院校层次:985 / 211 / ORDINARY */ private String schoolTier;
/** * 相关工作年限 */ private int yearsExperience;
/** * 岗位要求年限 */ private int requiredYears;
/** * 技能匹配数 */ private int skillMatchCount;
/** * 岗位要求技能数 */ private int requiredSkillCount;
/** * 期望薪资 */ private double expectedSalary;
/** * 薪资带宽下限 */ private double salaryMin;
/** * 薪资带宽上限 */ private double salaryMax;
/** * 所在城市 */ private String currentCity;
/** * 工作城市 */ private String jobCity;
/** * 是否接受异地调动 */ private boolean willingToRelocate;
/** * 近 5 年跳槽次数 */ private int jobHoppingCount;
/** * 职业空窗月数 */ private int gapMonths;
/** * 是否持有岗位相关资格证 */ private boolean certified;
/** * 英语水平:CET6 / CET4 / NONE */ private String englishLevel;
/** * 招聘渠道:REFERRAL / HEADHUNTER / ONLINE */ private String channel;}ScreeningDecision.java
Section titled “ScreeningDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 简历初筛决策结果 */@Datapublic class ScreeningDecision {
/** * 候选人姓名 */ private String candidateName;
/** * 综合评分(0-100) */ private int totalScore;
/** * 硬性条件是否全部满足 */ private boolean hardCheckPass;
/** * 初筛结论:INTERVIEW-推荐面试 / RESERVE-人才储备 / REJECT-不通过 */ private String result;
/** * 推荐等级:STRONG / NORMAL / RESERVE / NONE */ private String recommendLevel;
/** * 命中与不满足条件说明 */ private List<String> reasons = new ArrayList<String>();
public void addReason(String reason) { this.reasons.add(reason); }}ResumeScreeningRules.java
Section titled “ResumeScreeningRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 14:招聘简历智能初筛规则集 * <p> * 规则按「硬性门槛 → 多维评分 → 结论路由」两段式拆分: * <ul> * <li>门槛类:学历、年限、技能覆盖率、薪资带宽、工作地,任一不满足即淘汰</li> * <li>评分类:学历院校、经验、技能、稳定性、资质英语五个维度</li> * </ul> * 规则脚本见 {@code src/test/resources/demo/resume-screening.xml}。 */public interface ResumeScreeningRules {
int eduRank(@Param("education") String education);
boolean educationPass(@Param("actualRank") int actualRank, @Param("requiredRank") int requiredRank);
boolean experiencePass(@Param("yearsExperience") int yearsExperience, @Param("requiredYears") int requiredYears);
boolean skillPass(@Param("skillMatchCount") int skillMatchCount, @Param("requiredSkillCount") int requiredSkillCount);
boolean salaryPass(@Param("expectedSalary") double expectedSalary, @Param("salaryMin") double salaryMin, @Param("salaryMax") double salaryMax);
boolean locationPass(@Param("currentCity") String currentCity, @Param("jobCity") String jobCity, @Param("willingToRelocate") boolean willingToRelocate);
boolean hardReject(@Param("jobHoppingCount") int jobHoppingCount, @Param("gapMonths") int gapMonths);
int scoreEducation(@Param("actualRank") int actualRank, @Param("requiredRank") int requiredRank, @Param("schoolTier") String schoolTier);
int scoreExperience(@Param("yearsExperience") int yearsExperience, @Param("requiredYears") int requiredYears);
int scoreSkills(@Param("skillMatchCount") int skillMatchCount, @Param("requiredSkillCount") int requiredSkillCount);
int scoreStability(@Param("jobHoppingCount") int jobHoppingCount, @Param("gapMonths") int gapMonths);
int scoreBonus(@Param("certified") boolean certified, @Param("englishLevel") String englishLevel);
String screeningResult(@Param("totalScore") int totalScore, @Param("hardCheckPass") boolean hardCheckPass, @Param("hardReject") boolean hardReject);
String recommendLevel(@Param("totalScore") int totalScore);}resume-screening.xml
Section titled “resume-screening.xml”<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN" "classpath:paohaijiao/dtd/Jquick-java.dtd"><javas namespace="com.github.paohaijiao.demo.ResumeScreeningRules">
<!-- 学历档位:博士 4 / 硕士 3 / 本科 2 / 大专 1 --> <java name="eduRank" returnClass="int"> <![CDATA[ int def eduRank(str:education) { if (education == "PHD") { return 4; } else if (education == "MASTER") { return 3; } else if (education == "BACHELOR") { return 2; } else { return 1; } } ]]> </java>
<!-- 学历门槛 --> <java name="educationPass" returnClass="boolean"> <![CDATA[ boolean def educationPass(int:actualRank, int:requiredRank) { if (actualRank >= requiredRank) { return true; } else { return false; } } ]]> </java>
<!-- 年限门槛 --> <java name="experiencePass" returnClass="boolean"> <![CDATA[ boolean def experiencePass(int:yearsExperience, int:requiredYears) { if (yearsExperience >= requiredYears) { return true; } else { return false; } } ]]> </java>
<!-- 技能门槛:覆盖率达到 80% 即通过(交叉相乘避免除法精度问题) --> <java name="skillPass" returnClass="boolean"> <![CDATA[ boolean def skillPass(int:skillMatchCount, int:requiredSkillCount) { if (requiredSkillCount <= 0) { return true; } else if (skillMatchCount * 100 >= requiredSkillCount * 80) { return true; } else { return false; } } ]]> </java>
<!-- 薪资门槛:期望不超带宽上限,且不低于下限八成(恶意低报也不通过) --> <java name="salaryPass" returnClass="boolean"> <![CDATA[ boolean def salaryPass(double:expectedSalary, double:salaryMin, double:salaryMax) { if (expectedSalary <= salaryMax && expectedSalary >= salaryMin * 0.8) { return true; } else { return false; } } ]]> </java>
<!-- 工作地门槛:同城或接受调动 --> <java name="locationPass" returnClass="boolean"> <![CDATA[ boolean def locationPass(str:currentCity, str:jobCity, boolean:willingToRelocate) { if (currentCity == jobCity || willingToRelocate) { return true; } else { return false; } } ]]> </java>
<!-- 硬性否决:高频跳槽或超长空窗 --> <java name="hardReject" returnClass="boolean"> <![CDATA[ boolean def hardReject(int:jobHoppingCount, int:gapMonths) { if (jobHoppingCount >= 4 || gapMonths >= 24) { return true; } else { return false; } } ]]> </java>
<!-- 学历院校评分:超档 20 / 高一档 15 / 达标 10,另加院校层次分 --> <java name="scoreEducation" returnClass="int"> <![CDATA[ int def scoreEducation(int:actualRank, int:requiredRank, str:schoolTier) { int score = 0; if (actualRank - requiredRank >= 2) { score = 20; } else if (actualRank - requiredRank == 1) { score = 15; } else if (actualRank == requiredRank) { score = 10; } if (schoolTier == "985") { score = score + 5; } else if (schoolTier == "211") { score = score + 3; } return score; } ]]> </java>
<!-- 经验评分:超出要求越多分越高 --> <java name="scoreExperience" returnClass="int"> <![CDATA[ int def scoreExperience(int:yearsExperience, int:requiredYears) { int extra = yearsExperience - requiredYears; if (extra >= 5) { return 25; } else if (extra >= 2) { return 20; } else if (extra >= 0) { return 12; } else { return 0; } } ]]> </java>
<!-- 技能覆盖率评分:100% 得 20,80% 得 14,60% 得 8 --> <java name="scoreSkills" returnClass="int"> <![CDATA[ int def scoreSkills(int:skillMatchCount, int:requiredSkillCount) { if (requiredSkillCount <= 0) { return 20; } else if (skillMatchCount >= requiredSkillCount) { return 20; } else if (skillMatchCount * 100 >= requiredSkillCount * 80) { return 14; } else if (skillMatchCount * 100 >= requiredSkillCount * 60) { return 8; } else { return 0; } } ]]> </java>
<!-- 稳定性评分:跳槽 0/1/2 次得 15/10/5,空窗 3 个月内加 5 --> <java name="scoreStability" returnClass="int"> <![CDATA[ int def scoreStability(int:jobHoppingCount, int:gapMonths) { int score = 0; if (jobHoppingCount == 0) { score = 15; } else if (jobHoppingCount == 1) { score = 10; } else if (jobHoppingCount == 2) { score = 5; } if (gapMonths <= 3) { score = score + 5; } return score; } ]]> </java>
<!-- 资质与英语加分:资格证 5 分,CET6 5 分 / CET4 3 分 --> <java name="scoreBonus" returnClass="int"> <![CDATA[ int def scoreBonus(boolean:certified, str:englishLevel) { int score = 0; if (certified) { score = score + 5; } if (englishLevel == "CET6") { score = score + 5; } else if (englishLevel == "CET4") { score = score + 3; } return score; } ]]> </java>
<!-- 初筛结论:硬否决与门槛优先,评分只决定面试与储备 --> <java name="screeningResult" returnClass="java.lang.String"> <![CDATA[ str def screeningResult(int:totalScore, boolean:hardCheckPass, boolean:hardReject) { if (hardReject) { return "REJECT"; } else if (hardCheckPass == false) { return "REJECT"; } else if (totalScore >= 75) { return "INTERVIEW"; } else if (totalScore >= 55) { return "RESERVE"; } else { return "REJECT"; } } ]]> </java>
<!-- 推荐等级 --> <java name="recommendLevel" returnClass="java.lang.String"> <![CDATA[ str def recommendLevel(int:totalScore) { if (totalScore >= 85) { return "STRONG"; } else if (totalScore >= 75) { return "NORMAL"; } else if (totalScore >= 55) { return "RESERVE"; } else { return "NONE"; } } ]]> </java>
</javas>ResumeScreeningService.java
Section titled “ResumeScreeningService.java”package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.ResumeScreeningRules;import com.github.paohaijiao.demo.model.Resume;import com.github.paohaijiao.demo.model.ScreeningDecision;
/** * 场景 14:招聘简历智能初筛服务 * <p> * 职责划分: * <ul> * <li><b>Java 侧</b>:硬条件逐项判定与否决、五维评分汇总、淘汰原因与推荐说明组装</li> * <li><b>规则侧</b>:门槛口径、院校/经验/技能/稳定性评分、结论与推荐阈值,全部外置在 XML 中</li> * </ul> * 采用「硬性门槛 → 多维评分 → 结论路由」两段式:门槛不满足时评分不改变淘汰结论。 */public class ResumeScreeningService {
private final ResumeScreeningRules rules;
public ResumeScreeningService(ResumeScreeningRules rules) { this.rules = rules; }
/** * 执行简历初筛 * * @param resume 简历要素 * @return 初筛决策 */ public ScreeningDecision screen(Resume resume) { ScreeningDecision decision = new ScreeningDecision(); decision.setCandidateName(resume.getCandidateName());
int actualRank = rules.eduRank(resume.getEducationLevel()); int requiredRank = rules.eduRank(resume.getRequiredEducation());
boolean educationPass = rules.educationPass(actualRank, requiredRank); boolean experiencePass = rules.experiencePass(resume.getYearsExperience(), resume.getRequiredYears()); boolean skillPass = rules.skillPass(resume.getSkillMatchCount(), resume.getRequiredSkillCount()); boolean salaryPass = rules.salaryPass(resume.getExpectedSalary(), resume.getSalaryMin(), resume.getSalaryMax()); boolean locationPass = rules.locationPass(resume.getCurrentCity(), resume.getJobCity(), resume.isWillingToRelocate()); boolean hardCheckPass = educationPass && experiencePass && skillPass && salaryPass && locationPass; boolean hardReject = rules.hardReject(resume.getJobHoppingCount(), resume.getGapMonths());
int score = rules.scoreEducation(actualRank, requiredRank, resume.getSchoolTier()) + rules.scoreExperience(resume.getYearsExperience(), resume.getRequiredYears()) + rules.scoreSkills(resume.getSkillMatchCount(), resume.getRequiredSkillCount()) + rules.scoreStability(resume.getJobHoppingCount(), resume.getGapMonths()) + rules.scoreBonus(resume.isCertified(), resume.getEnglishLevel());
decision.setHardCheckPass(hardCheckPass); decision.setTotalScore(score); String result = rules.screeningResult(score, hardCheckPass, hardReject); decision.setResult(result); // 推荐等级仅对进入面试或储备池的候选人生效,淘汰候选人均为 NONE decision.setRecommendLevel("REJECT".equals(result) ? "NONE" : rules.recommendLevel(score));
buildReasons(resume, decision, educationPass, experiencePass, skillPass, salaryPass, locationPass, hardReject); return decision; }
private void buildReasons(Resume resume, ScreeningDecision decision, boolean educationPass, boolean experiencePass, boolean skillPass, boolean salaryPass, boolean locationPass, boolean hardReject) { if (hardReject) { decision.addReason("近 5 年跳槽 " + resume.getJobHoppingCount() + " 次或空窗 " + resume.getGapMonths() + " 个月,命中稳定性否决项"); } if (!educationPass) { decision.addReason("学历 " + resume.getEducationLevel() + " 未达岗位要求 " + resume.getRequiredEducation()); } if (!experiencePass) { decision.addReason("相关工作年限 " + resume.getYearsExperience() + " 年未达要求 " + resume.getRequiredYears() + " 年"); } if (!skillPass) { decision.addReason("技能匹配 " + resume.getSkillMatchCount() + "/" + resume.getRequiredSkillCount() + ",覆盖率不足 80%"); } if (!salaryPass) { decision.addReason("期望薪资 " + resume.getExpectedSalary() + " 超出岗位薪资带宽或显著低于带宽下限"); } if (!locationPass) { decision.addReason("候选人不在工作城市且不接受异地调动"); } if ("INTERVIEW".equals(decision.getResult())) { decision.addReason("硬性条件全部满足,综合评分 " + decision.getTotalScore() + ",推荐等级 " + decision.getRecommendLevel()); } else if ("RESERVE".equals(decision.getResult())) { decision.addReason("硬性条件满足但评分 " + decision.getTotalScore() + ",纳入人才储备池"); } }}ResumeScreeningServiceTest.java
Section titled “ResumeScreeningServiceTest.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.Resume;import com.github.paohaijiao.demo.model.ScreeningDecision;import com.github.paohaijiao.demo.service.ResumeScreeningService;import com.github.paohaijiao.xml.JQuickJava;import org.junit.Assert;import org.junit.Before;import org.junit.Test;
/** * 场景 14 生产级样板:招聘简历智能初筛 * <p> * 覆盖 6 组候选人形态:理想候选人强烈推荐、学历硬门槛不达标、 * 薪资超预算、门槛达标评分储备、高频跳槽否决、异地调动达标。 */public class ResumeScreeningServiceTest {
private ResumeScreeningService service;
@Before public void setUp() { ResumeScreeningRules rules = JQuickJava.create() .createApi(ResumeScreeningRules.class, "demo/resume-screening.xml"); this.service = new ResumeScreeningService(rules); }
/** * 理想候选人:硕士 985、8 年经验、技能全匹配、零跳槽 → 90 分,强烈推荐面试 */ @Test public void shouldRecommendStrongCandidateForInterview() { Resume resume = baseResume("张伟", "MASTER", "MASTER", "985", 8, 3, 5, 5); resume.setExpectedSalary(18000.0); resume.setSalaryMin(15000.0); resume.setSalaryMax(25000.0); resume.setCurrentCity("上海"); resume.setJobCity("上海"); resume.setWillingToRelocate(false); resume.setJobHoppingCount(0); resume.setGapMonths(2); resume.setCertified(true); resume.setEnglishLevel("CET6");
ScreeningDecision decision = service.screen(resume);
Assert.assertTrue(decision.isHardCheckPass()); Assert.assertEquals(90, decision.getTotalScore()); Assert.assertEquals("INTERVIEW", decision.getResult()); Assert.assertEquals("STRONG", decision.getRecommendLevel()); Assert.assertTrue(decision.getReasons().contains("硬性条件全部满足,综合评分 90,推荐等级 STRONG")); }
/** * 本科学历应聘硕士要求岗位 → 学历门槛不通过直接淘汰 */ @Test public void shouldRejectWhenEducationBelowRequirement() { Resume resume = baseResume("李强", "BACHELOR", "MASTER", "211", 5, 3, 4, 5); resume.setExpectedSalary(16000.0); resume.setSalaryMin(15000.0); resume.setSalaryMax(25000.0); resume.setCurrentCity("上海"); resume.setJobCity("上海"); resume.setWillingToRelocate(false); resume.setJobHoppingCount(1); resume.setGapMonths(5); resume.setCertified(true); resume.setEnglishLevel("CET6");
ScreeningDecision decision = service.screen(resume);
Assert.assertFalse(decision.isHardCheckPass()); Assert.assertEquals("REJECT", decision.getResult()); Assert.assertEquals("NONE", decision.getRecommendLevel()); Assert.assertTrue(decision.getReasons().contains("学历 BACHELOR 未达岗位要求 MASTER")); }
/** * 期望薪资超出带宽上限 → 薪资门槛不通过 */ @Test public void shouldRejectWhenSalaryOutOfRange() { Resume resume = baseResume("王芳", "MASTER", "BACHELOR", "211", 6, 3, 5, 5); resume.setExpectedSalary(30000.0); resume.setSalaryMin(20000.0); resume.setSalaryMax(25000.0); resume.setCurrentCity("北京"); resume.setJobCity("北京"); resume.setWillingToRelocate(false); resume.setJobHoppingCount(1); resume.setGapMonths(1); resume.setCertified(true); resume.setEnglishLevel("CET6");
ScreeningDecision decision = service.screen(resume);
Assert.assertFalse(decision.isHardCheckPass()); Assert.assertEquals("REJECT", decision.getResult()); Assert.assertTrue(decision.getReasons().contains( "期望薪资 30000.0 超出岗位薪资带宽或显著低于带宽下限")); }
/** * 大专达标、技能覆盖 80%、评分 57 → 纳入人才储备 */ @Test public void shouldReserveBorderlineCandidate() { Resume resume = baseResume("赵敏", "COLLEGE", "COLLEGE", "ORDINARY", 5, 3, 4, 5); resume.setExpectedSalary(9000.0); resume.setSalaryMin(8000.0); resume.setSalaryMax(12000.0); resume.setCurrentCity("成都"); resume.setJobCity("成都"); resume.setWillingToRelocate(false); resume.setJobHoppingCount(2); resume.setGapMonths(6); resume.setCertified(true); resume.setEnglishLevel("CET4");
ScreeningDecision decision = service.screen(resume);
Assert.assertTrue(decision.isHardCheckPass()); Assert.assertEquals(57, decision.getTotalScore()); Assert.assertEquals("RESERVE", decision.getResult()); Assert.assertEquals("RESERVE", decision.getRecommendLevel()); Assert.assertTrue(decision.getReasons().contains("硬性条件满足但评分 57,纳入人才储备池")); }
/** * 近 5 年跳槽 5 次 → 命中稳定性否决项 */ @Test public void shouldRejectFrequentJobHopper() { Resume resume = baseResume("孙磊", "MASTER", "BACHELOR", "985", 7, 3, 5, 5); resume.setExpectedSalary(20000.0); resume.setSalaryMin(15000.0); resume.setSalaryMax(30000.0); resume.setCurrentCity("深圳"); resume.setJobCity("深圳"); resume.setWillingToRelocate(false); resume.setJobHoppingCount(5); resume.setGapMonths(1); resume.setCertified(true); resume.setEnglishLevel("CET6");
ScreeningDecision decision = service.screen(resume);
Assert.assertTrue(decision.isHardCheckPass()); Assert.assertEquals("REJECT", decision.getResult()); Assert.assertTrue(decision.getReasons().get(0).contains("命中稳定性否决项")); }
/** * 异地候选人愿意调动,评分 78 → 正常推荐面试 */ @Test public void shouldInterviewRelocationWillingCandidate() { Resume resume = baseResume("周涛", "BACHELOR", "BACHELOR", "211", 7, 2, 3, 3); resume.setExpectedSalary(12000.0); resume.setSalaryMin(10000.0); resume.setSalaryMax(15000.0); resume.setCurrentCity("武汉"); resume.setJobCity("上海"); resume.setWillingToRelocate(true); resume.setJobHoppingCount(1); resume.setGapMonths(12); resume.setCertified(true); resume.setEnglishLevel("CET6");
ScreeningDecision decision = service.screen(resume);
Assert.assertTrue(decision.isHardCheckPass()); Assert.assertEquals(78, decision.getTotalScore()); Assert.assertEquals("INTERVIEW", decision.getResult()); Assert.assertEquals("NORMAL", decision.getRecommendLevel()); }
private Resume baseResume(String name, String education, String requiredEducation, String schoolTier, int years, int requiredYears, int matched, int required) { Resume resume = new Resume(); resume.setCandidateName(name); resume.setPositionName("Java 开发工程师"); resume.setEducationLevel(education); resume.setRequiredEducation(requiredEducation); resume.setSchoolTier(schoolTier); resume.setYearsExperience(years); resume.setRequiredYears(requiredYears); resume.setSkillMatchCount(matched); resume.setRequiredSkillCount(required); resume.setChannel("ONLINE"); return resume; }}在工程根目录执行:
mvn test -Dtest=ResumeScreeningServiceTest测试覆盖形态
Section titled “测试覆盖形态”| 测试方法 | 构造的业务形态 | 关键断言/期望决策 |
|---|---|---|
shouldRecommendStrongCandidateForInterview |
硕士 985、8 年、技能 5/5、零跳槽空窗 2 月、证 + CET6 | 硬门槛全过,总分 90,INTERVIEW,STRONG,含综合 90 推荐说明 |
shouldRejectWhenEducationBelowRequirement |
本科应聘硕士岗、211、5 年、技能 4/5 | hardCheckPass=false,REJECT,推荐 NONE,含学历未达 MASTER 说明 |
shouldRejectWhenSalaryOutOfRange |
硕士、期望 30000、带宽 20000-25000 | 薪资门槛不过,REJECT,含期望薪资超带宽说明 |
shouldReserveBorderlineCandidate |
大专对大专、普通院校、技能 4/5(恰 80%)、跳槽 2 次、CET4 | 硬门槛全过,总分 57,RESERVE,推荐等级 RESERVE |
shouldRejectFrequentJobHopper |
硕士 985、7 年、硬门槛全满足,但近 5 年跳槽 5 次 | 即便硬门槛通过仍 REJECT,第一条原因命中稳定性否决项 |
shouldInterviewRelocationWillingCandidate |
本科 211、7 年对 2 年、3/3 技能、武汉投上海愿调动 | 工作地门槛通过,总分 78,INTERVIEW,NORMAL |
理想候选人(硕士 985、8 年、技能全匹配、零跳槽、证 + CET6):
硬门槛: 全部满足 综合分: 90 结论: INTERVIEW 推荐: STRONG高频跳槽否决(近 5 年 5 次跳槽,硬门槛全满足也淘汰):
结论: REJECT 推荐: NONE说明: 近 5 年跳槽 5 次或空窗 1 个月,命中稳定性否决项- 两段式骨架由规则顺序与 Service 共同保证:
screeningResult先判hardReject、再用hardCheckPass == false正向比较判门槛、最后才看分数,硬门槛不满足时五维评分照常计算但不改变淘汰结论,招聘红线不会被高分稀释。 - 覆盖率判定交叉相乘,规避整数除法:
skillPass与scoreSkills用skillMatchCount * 100 >= requiredSkillCount * 80/60比较百分比,4/5 恰为 80% 边界也能精确判定,且要求技能数为 0 时有明确兜底分支。 - 薪资双边界复合门槛:
salaryPass同时约束expectedSalary <= salaryMax与expectedSalary >= salaryMin * 0.8,超顶与恶意低报在同一条规则中拦截。 - 学历档位化复用:
eduRank把学历字符串映射为 4/3/2/1 档,门槛判定(educationPass)与学历评分(scoreEducation)复用同一档位结果,门槛与加分口径不会出现不一致。 - 推荐等级在 Java 侧强制兜底:Service 对 REJECT 候选人三元短路直接写 NONE,不调用
recommendLevel,即使脚本阈值将来调整也不会给淘汰候选人发出 STRONG/NORMAL 推荐;逐项失败原因由 Java 侧组装,可直接展示给 HR 与用人部门。 - 阈值全外置 + Java 8 无重型依赖:80% 覆盖率、带宽八成、75/55 路由线、85/75 推荐线都在
resume-screening.xml中,ASM 代理实例创建一次即用,多租户 SaaS 可按租户加载不同 XML,亦可嵌入仍在 JDK 8、信创 JDK 上的招聘系统。