Skip to content

Case 14: Resume screening

This case corresponds to Scenario 14 in the JQuick-Java repository’s USE-CASES.md, targeting two-stage intelligent resume pre-screening in the HR SaaS domain. The first stage uses five hard gates — education, relevant years of experience, skill coverage (80%), salary band, and work location — plus two veto items (frequent job hopping and over-long career gaps) to determine the candidate set. The second stage scores only candidates who pass the hard screening across five dimensions — education and school, experience, skills, stability, and certifications/English — routing them to interview recommendation (STRONG / NORMAL), talent reserve (RESERVE), or rejection (REJECT). All gate and scoring criteria are driven by JQuick-Java XML rule proxies.

Recruiting systems receive large volumes of resumes daily from referral, headhunter, and online channels (REFERRAL / HEADHUNTER / ONLINE). When posting a position, HR defines the required education, required years of experience, the required skill list, the salary band (lower/upper bound), and the job city. The resume side provides the candidate’s education (PHD / MASTER / BACHELOR / COLLEGE), school tier (985 / 211 / ORDINARY), years of relevant experience, skill match count, expected salary, current city and willingness to relocate, plus the number of job changes in the last 5 years, career-gap months, professional certifications, and English level (CET6 / CET4 / NONE).

The first stage of screening is hard gates plus stability vetoes: education is first converted to ranks (PhD 4 / Master’s 3 / Bachelor’s 2 / College 1), and the candidate passes when their rank is no lower than the position’s requirement; relevant years pass when no less than the required years; skill coverage passes at 80% (compared via cross-multiplication to avoid integer division precision issues); expected salary passes when it does not exceed the band upper bound and is no lower than 80% of the lower bound (malicious low-balling is also blocked); same city or willingness to relocate passes. All five must be satisfied to pass the hard gates; ≥4 job changes in the last 5 years or a gap of ≥24 months directly hits the stability veto.

The second stage is five-dimension scoring with a full score of 100: the education/school score gives 20 for exceeding the requirement by 2+ ranks, 15 for 1 rank higher, 10 for exactly meeting it, plus another +5 for 985 and +3 for 211; the experience score gives 25/20/12 for exceeding the required years by ≥5/≥2/≥0 years; the skill score gives 20/14/8 for coverage of 100%/80%/60%; the stability score gives 15/10/5 for 0/1/2 job changes, plus another 5 for a gap ≤3 months; certification +5, CET6 +5, CET4 +3.

The conclusion routing is strictly two-stage: hitting a veto item or failing any hard gate means REJECT, and the score does not change the rejection; after passing the hard screening, ≥75 points goes to interview, ≥55 to talent reserve, and the rest are rejected. Recommendation levels are further subdivided: ≥85 STRONG, ≥75 NORMAL, ≥55 RESERVE, and rejected candidates are forcibly set to NONE on the Service side. The decision’s reasons list item by item the unmet gates, the hit veto items, and interview/reserve recommendation explanations.

  • Mixing hard gates with scoring causes misjudgment: without separating the two stages, you can get “high score but education below requirement still goes to interview” or “5 job changes passes just because the composite score is high” — recruiting red lines get diluted by scores.
  • Gate criteria scattered across recruiting-channel codebases: thresholds like 80% skill coverage, 80% of the salary lower bound, and the 24-month gap are hard-coded in different services; adjusting standards for a job family requires changes in multiple places and re-releasing the HR SaaS.
  • Integer division in skill coverage has precision risks: directly dividing match count by required count loses the fraction — 4/5 becomes 0 in integer division — making boundary coverage judgments unreliable.
  • The five scoring dimensions are hard to tune and explain independently: if school, experience, skills, stability, and bonus items are mixed into one large method, HR cannot tune a single dimension’s weight or explain the candidate’s score composition to the hiring department.
  • Salary must guard against both exceeding the cap and low-balling: only checking that the expected salary does not exceed the upper bound lets malicious low-ballers through; the lower bound’s 80% must also be constrained, and such compound gates are hard to express with plain configuration tables.
  • SaaS multi-tenant environments coexist with legacy deployments: different customers have very different JDK versions and infrastructure; some still run JDK 8 / IT-innovation environments and cannot introduce heavy rule platforms, so a lightweight, embeddable rule solution with auditable decisions is needed.

This case adopts the six-piece structure of “input model + decision result model + rule interface + XML rule script + Service orchestration + JUnit tests”. Hard gates, veto items, five-dimension scoring, and routing thresholds are all externalized in XML; the Service handles gate-by-gate determination, score aggregation, and rejection-reason assembly:

Layer Responsibility Files
Domain model Resume elements, screening decision (hard-check result / total score / conclusion / recommendation level / reasons) Resume.java, ScreeningDecision.java
Rule set & XML 5 hard gates + veto item + 5-dimension scoring + conclusion routing (14 methods) ResumeScreeningRules.java, resume-screening.xml
Orchestration service Gate-by-gate determination with veto reasons, score aggregation, recommendation level effective only for passers ResumeScreeningService.java
Tests 6 candidate profiles (ideal, education below requirement, salary out of band, reserve, veto, relocation) ResumeScreeningServiceTest.java

The rule interface has 14 methods in total, with gate, veto, scoring, and routing methods each doing their own job:

Rule method Criterion Score / output
eduRank Education ranking: PhD 4 / Master’s 3 / Bachelor’s 2 / College and others 1 1 ~ 4
educationPass Passes when candidate’s education rank ≥ the position’s required rank boolean
experiencePass Passes when years of relevant experience ≥ the position’s required years boolean
skillPass Passes directly when required skill count ≤0; otherwise passes when match count ×100 ≥ required count ×80 (80% coverage) boolean
salaryPass Passes only when expected salary ≤ band upper bound and ≥ lower bound ×0.8 boolean
locationPass Passes when the current city equals the job city, or the candidate accepts relocation boolean
hardReject Returns true for ≥4 job changes in the last 5 years or a gap of ≥24 months boolean
scoreEducation Exceeds by 2+ ranks 20 / 1 rank higher 15 / exactly meets 10; 985 adds +5, 211 adds +3 0 ~ 25
scoreExperience Exceeding years ≥5 gives 25 / ≥2 gives 20 / ≥0 gives 12 / below gives 0 0 ~ 25
scoreSkills Required count ≤0 or full coverage gives 20; coverage ≥80% gives 14; ≥60% gives 8; otherwise 0 0 ~ 20
scoreStability 0/1/2 job changes gives 15/10/5; gap ≤3 months adds +5 0 ~ 20
scoreBonus Certification +5; CET6 +5; CET4 +3 0 ~ 10
screeningResult Veto item or failed hard gate → REJECT; after passing, ≥75 INTERVIEW, ≥55 RESERVE, otherwise REJECT Conclusion string
recommendLevel ≥85 STRONG; ≥75 NORMAL; ≥55 RESERVE; otherwise NONE Recommendation level string

Place the following six files into the JQuick-Java test project using the original package names from the source: the two models go into com.github.paohaijiao.demo.model, the rule interface and test class go into com.github.paohaijiao.demo, the orchestration service goes into com.github.paohaijiao.demo.service, and the XML script goes into src/test/resources/demo/. Keeping directories and package names unchanged, run mvn test -Dtest=ResumeScreeningServiceTest at the project root.

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;
}
}

Run at the project root:

Terminal window
mvn test -Dtest=ResumeScreeningServiceTest
Test method Constructed business profile Key assertions / expected decision
shouldRecommendStrongCandidateForInterview Master’s 985, 8 years, skills 5/5, zero job changes with 2-month gap, certification + CET6 All hard gates pass, total 90, INTERVIEW, STRONG, contains the composite-90 recommendation reason
shouldRejectWhenEducationBelowRequirement Bachelor’s applying for a Master’s-required position, 211, 5 years, skills 4/5 hardCheckPass=false, REJECT, recommendation NONE, contains the “education below MASTER requirement” reason
shouldRejectWhenSalaryOutOfRange Master’s, expected 30000, band 20000-25000 Salary gate fails, REJECT, contains the expected-salary-out-of-band reason
shouldReserveBorderlineCandidate College vs College, ordinary school, skills 4/5 (exactly 80%), 2 job changes, CET4 All hard gates pass, total 57, RESERVE, recommendation level RESERVE
shouldRejectFrequentJobHopper Master’s 985, 7 years, all hard gates satisfied, but 5 job changes in the last 5 years REJECT even with hard gates passed; the first reason is the stability veto hit
shouldInterviewRelocationWillingCandidate Bachelor’s 211, 7 years vs 2 required, 3/3 skills, Wuhan applying to Shanghai, willing to relocate Location gate passes, total 78, INTERVIEW, NORMAL

Ideal candidate (Master’s 985, 8 years, full skill match, zero job changes, certification + CET6):

硬门槛: 全部满足 综合分: 90 结论: INTERVIEW 推荐: STRONG

Frequent job-hopper veto (5 job changes in the last 5 years; rejected even with all hard gates passed):

结论: REJECT 推荐: NONE
说明: 近 5 年跳槽 5 次或空窗 1 个月,命中稳定性否决项
  • The two-stage skeleton is guaranteed jointly by rule order and the Service: screeningResult checks hardReject first, then the gates via the positive comparison hardCheckPass == false, and only then looks at the score; when hard gates fail, the five-dimension score is still computed but does not change the rejection, so recruiting red lines are never diluted by high scores.
  • Coverage judged by cross-multiplication, avoiding integer division: skillPass and scoreSkills compare percentages with skillMatchCount * 100 >= requiredSkillCount * 80/60, so the exact 80% boundary of 4/5 is judged precisely, with an explicit fallback branch when the required skill count is 0.
  • Salary dual-boundary compound gate: salaryPass constrains both expectedSalary <= salaryMax and expectedSalary >= salaryMin * 0.8, blocking both over-the-cap and malicious low-balling in a single rule.
  • Education ranking reused: eduRank maps education strings to ranks 4/3/2/1; both the gate check (educationPass) and the education score (scoreEducation) reuse the same ranking result, so the gate and bonus criteria can never diverge.
  • Recommendation level forcibly backstopped on the Java side: the Service short-circuits REJECT candidates to NONE with a ternary, without calling recommendLevel; even if script thresholds change in the future, rejected candidates can never receive a STRONG/NORMAL recommendation. Item-by-item failure reasons are assembled on the Java side and can be shown directly to HR and the hiring department.
  • All thresholds externalized + Java 8 with no heavy dependencies: the 80% coverage, 80%-of-band floor, 75/55 routing lines, and 85/75 recommendation lines all live in resume-screening.xml; the ASM proxy instance is created once and reused. A multi-tenant SaaS can load different XML per tenant, and it also embeds into recruiting systems still running on JDK 8 or IT-innovation JDKs.