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.
Industry scenario
Section titled “Industry scenario”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.
Business pain points
Section titled “Business pain points”- 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.
Solution architecture
Section titled “Solution architecture”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 |
Rule method breakdown
Section titled “Rule method breakdown”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 |
Code implementation (complete & runnable)
Section titled “Code implementation (complete & runnable)”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.
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; }}Run & verified results
Section titled “Run & verified results”Run at the project root:
mvn test -Dtest=ResumeScreeningServiceTestTest coverage
Section titled “Test coverage”| 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 |
Verified output
Section titled “Verified output”Ideal candidate (Master’s 985, 8 years, full skill match, zero job changes, certification + CET6):
硬门槛: 全部满足 综合分: 90 结论: INTERVIEW 推荐: STRONGFrequent job-hopper veto (5 job changes in the last 5 years; rejected even with all hard gates passed):
结论: REJECT 推荐: NONE说明: 近 5 年跳槽 5 次或空窗 1 个月,命中稳定性否决项Technical highlights
Section titled “Technical highlights”- The two-stage skeleton is guaranteed jointly by rule order and the Service:
screeningResultcheckshardRejectfirst, then the gates via the positive comparisonhardCheckPass == 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:
skillPassandscoreSkillscompare percentages withskillMatchCount * 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:
salaryPassconstrains bothexpectedSalary <= salaryMaxandexpectedSalary >= salaryMin * 0.8, blocking both over-the-cap and malicious low-balling in a single rule. - Education ranking reused:
eduRankmaps 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.