跳转到内容

案例 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

规则接口共 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 推荐等级字符串

将以下六个文件按源码原包名放入 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 即可运行。

package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 候选人简历要素(初筛所需的硬性条件与画像)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public 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;
}
package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 简历初筛决策结果
*/
@Data
public 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);
}
}
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);
}
<?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>
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()
+ ",纳入人才储备池");
}
}
}
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;
}
}

在工程根目录执行:

Terminal window
mvn test -Dtest=ResumeScreeningServiceTest
测试方法 构造的业务形态 关键断言/期望决策
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 上的招聘系统。