forked from baskbuddy/zhangshun
11 changed files with 342 additions and 28 deletions
@ -0,0 +1,17 @@ |
|||||
|
# R01-C 纯计算与重复规则收束实施指令 |
||||
|
|
||||
|
## 目标 |
||||
|
|
||||
|
在不改变评分、排名和推荐结果的前提下,收束候选人综合状态规则,并将岗位偏好评分拆成可独立验证的纯计算器。 |
||||
|
|
||||
|
## 行为边界 |
||||
|
|
||||
|
- 评分引擎继续按固定 8 维判断完整性,且 OK/ESTIMATED 必须有非空分数才可计算; |
||||
|
- 竞争排名继续按当前快照集合大小判断完整性,并保持历史“按状态可计算”的口径; |
||||
|
- 推荐偏好权重保持城市30、用工类型20、最低薪资20、行业15、岗位族15; |
||||
|
- 薪资边界继续使用 `salaryMax >= expectedSalaryMin`; |
||||
|
- 百分比继续保留两位小数并使用 HALF_UP; |
||||
|
- 不改数据库、接口、DTO、排序、分页、权限、审计或开关语义; |
||||
|
- 不进入 R01-D/E,不暂存、不提交、不推送。 |
||||
|
|
||||
|
必须先用特征测试锁定两套 overview 输入适配差异和偏好评分边界,再替换生产调用点。 |
||||
@ -0,0 +1,24 @@ |
|||||
|
# R01-C 纯计算与重复规则收束实施报告 |
||||
|
|
||||
|
> 状态:R01-C 已实现并通过自动化与干净构建,待独立代码复验。 |
||||
|
|
||||
|
## 实现结果 |
||||
|
|
||||
|
新增 `CandidateOverviewStatusPolicy`,用一个纯决策内核产出 OK/PARTIAL/PENDING/INSUFFICIENT_DATA。两个入口显式保留历史适配差异:评分引擎固定 8 维并要求 score 非空;竞争排名按已有快照数量和状态判断。原两个业务类中的 `overviewStatus` 实现均已删除。 |
||||
|
|
||||
|
新增 `JobPreferenceScoreCalculator`,把原先压缩在单行方法中的 30/20/20/15/15 权重计算提取为无 Spring、无数据库依赖的纯函数。`JobRecommendationService` 仅负责解析已有 JSON 字段并组装输入;权重、匹配条件、空信号返回 null、薪资相等边界及 HALF_UP 两位小数全部保持。 |
||||
|
|
||||
|
## 测试 |
||||
|
|
||||
|
- `CandidateOverviewStatusPolicyTest`:5 条,覆盖两个适配入口的 OK/PARTIAL/PENDING/INSUFFICIENT_DATA、空集合、固定八维、null score 差异,以及评分结果列表为 null 时的历史快速失败语义; |
||||
|
- `JobPreferenceScoreCalculatorTest`:7 条,覆盖无信号、全匹配、全不匹配、权重与舍入、薪资相等/null、null 集合,以及历史 JSON 数组中 null 元素的兼容性; |
||||
|
- 独立复核发现 `List.copyOf` 会拒绝历史 JSON 数组中的 null 元素,可能把原先可计算的数据变成 500;已改为允许 null 的防御性只读拷贝,并先补回归测试锁定旧语义; |
||||
|
- 定向关联集合:33/33; |
||||
|
- 第一次定向测试因夹具让城市和行业同时匹配却期望 66.67 而失败,修正为行业不匹配后同一断言通过;未改生产公式或放宽断言。 |
||||
|
|
||||
|
## 全量结果 |
||||
|
|
||||
|
- `mvn clean verify`:2094/2094,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件跳过项,R01-C 定向与关联测试 Skipped=0; |
||||
|
- OpenAPI:176 paths / 214 operations,All checks passed; |
||||
|
- V1–V43 未修改,无新增接口、ErrorCode、依赖、表或索引; |
||||
|
- 暂存区为空,未提交、未推送,历史未跟踪产物保留。 |
||||
@ -0,0 +1,51 @@ |
|||||
|
package com.mydreamjob.candidatescore; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Objects; |
||||
|
import java.util.function.Predicate; |
||||
|
|
||||
|
/** Single pure policy for candidate overview status aggregation. */ |
||||
|
public final class CandidateOverviewStatusPolicy { |
||||
|
|
||||
|
private CandidateOverviewStatusPolicy() { |
||||
|
} |
||||
|
|
||||
|
public static String fromComputedResults( |
||||
|
List<CandidateScoringCalculator.DimensionResult> results, int expectedDimensions) { |
||||
|
Objects.requireNonNull(results, "results"); |
||||
|
return evaluate(results, expectedDimensions, |
||||
|
result -> isComputable(result.status, result.score != null), |
||||
|
result -> "PENDING".equals(result.status), |
||||
|
"PENDING"); |
||||
|
} |
||||
|
|
||||
|
public static String fromSnapshots(List<CandidateDimensionScore> snapshots) { |
||||
|
if (snapshots == null || snapshots.isEmpty()) { |
||||
|
return "INSUFFICIENT_DATA"; |
||||
|
} |
||||
|
return evaluate(snapshots, snapshots.size(), |
||||
|
snapshot -> isComputable(snapshot.getStatus(), true), |
||||
|
snapshot -> "PENDING".equals(snapshot.getStatus()), |
||||
|
"INSUFFICIENT_DATA"); |
||||
|
} |
||||
|
|
||||
|
private static <T> String evaluate(List<T> values, int completeCount, |
||||
|
Predicate<T> computable, Predicate<T> pending, |
||||
|
String emptyStatus) { |
||||
|
if (values == null || values.isEmpty()) { |
||||
|
return emptyStatus; |
||||
|
} |
||||
|
long computableCount = values.stream().filter(computable).count(); |
||||
|
if (computableCount == completeCount) { |
||||
|
return "OK"; |
||||
|
} |
||||
|
if (computableCount > 0) { |
||||
|
return "PARTIAL"; |
||||
|
} |
||||
|
return values.stream().allMatch(pending) ? "PENDING" : "INSUFFICIENT_DATA"; |
||||
|
} |
||||
|
|
||||
|
private static boolean isComputable(String status, boolean valueAvailable) { |
||||
|
return valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
package com.mydreamjob.recommendation; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Collections; |
||||
|
import java.util.List; |
||||
|
|
||||
|
/** Pure calculator for the existing 30/20/20/15/15 job-preference score. */ |
||||
|
public final class JobPreferenceScoreCalculator { |
||||
|
|
||||
|
private static final int CITY_WEIGHT = 30; |
||||
|
private static final int EMPLOYMENT_TYPE_WEIGHT = 20; |
||||
|
private static final int SALARY_WEIGHT = 20; |
||||
|
private static final int INDUSTRY_WEIGHT = 15; |
||||
|
private static final int JOB_FAMILY_WEIGHT = 15; |
||||
|
|
||||
|
private JobPreferenceScoreCalculator() { |
||||
|
} |
||||
|
|
||||
|
public static BigDecimal calculate(Criteria criteria, Target target) { |
||||
|
int matched = 0; |
||||
|
int available = 0; |
||||
|
|
||||
|
if (!criteria.cities().isEmpty()) { |
||||
|
available += CITY_WEIGHT; |
||||
|
if (criteria.cities().contains(target.city())) matched += CITY_WEIGHT; |
||||
|
} |
||||
|
if (!criteria.employmentTypes().isEmpty()) { |
||||
|
available += EMPLOYMENT_TYPE_WEIGHT; |
||||
|
if (criteria.employmentTypes().contains(target.employmentType())) matched += EMPLOYMENT_TYPE_WEIGHT; |
||||
|
} |
||||
|
if (criteria.expectedSalaryMin() != null) { |
||||
|
available += SALARY_WEIGHT; |
||||
|
if (target.salaryMax() != null && target.salaryMax() >= criteria.expectedSalaryMin()) { |
||||
|
matched += SALARY_WEIGHT; |
||||
|
} |
||||
|
} |
||||
|
if (!criteria.industries().isEmpty()) { |
||||
|
available += INDUSTRY_WEIGHT; |
||||
|
if (criteria.industries().contains(target.industryCode())) matched += INDUSTRY_WEIGHT; |
||||
|
} |
||||
|
if (!criteria.jobFamilies().isEmpty()) { |
||||
|
available += JOB_FAMILY_WEIGHT; |
||||
|
if (criteria.jobFamilies().contains(target.jobFamilyCode())) matched += JOB_FAMILY_WEIGHT; |
||||
|
} |
||||
|
return available == 0 ? null : BigDecimal.valueOf(matched * 100L) |
||||
|
.divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
public record Criteria(List<String> cities, List<String> employmentTypes, Integer expectedSalaryMin, |
||||
|
List<String> industries, List<String> jobFamilies) { |
||||
|
public Criteria { |
||||
|
cities = copyPreservingNulls(cities); |
||||
|
employmentTypes = copyPreservingNulls(employmentTypes); |
||||
|
industries = copyPreservingNulls(industries); |
||||
|
jobFamilies = copyPreservingNulls(jobFamilies); |
||||
|
} |
||||
|
|
||||
|
private static List<String> copyPreservingNulls(List<String> values) { |
||||
|
return values == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(values)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public record Target(String city, String employmentType, Integer salaryMax, |
||||
|
String industryCode, String jobFamilyCode) { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,78 @@ |
|||||
|
package com.mydreamjob.candidatescore; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertThrows; |
||||
|
|
||||
|
class CandidateOverviewStatusPolicyTest { |
||||
|
|
||||
|
@Test |
||||
|
void nullComputedResultsPreserveHistoricalFailFastBehavior() { |
||||
|
assertThrows(NullPointerException.class, |
||||
|
() -> CandidateOverviewStatusPolicy.fromComputedResults(null, 8)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void computedResultsPreserveFixedEightDimensionContract() { |
||||
|
assertEquals("PENDING", CandidateOverviewStatusPolicy.fromComputedResults(List.of(), 8)); |
||||
|
assertEquals("OK", CandidateOverviewStatusPolicy.fromComputedResults(results(8, "OK", "80"), 8)); |
||||
|
|
||||
|
List<CandidateScoringCalculator.DimensionResult> partial = new ArrayList<>(results(1, "OK", "80")); |
||||
|
partial.addAll(results(7, "PENDING", null)); |
||||
|
assertEquals("PARTIAL", CandidateOverviewStatusPolicy.fromComputedResults(partial, 8)); |
||||
|
assertEquals("PENDING", CandidateOverviewStatusPolicy.fromComputedResults(results(8, "PENDING", null), 8)); |
||||
|
assertEquals("INSUFFICIENT_DATA", |
||||
|
CandidateOverviewStatusPolicy.fromComputedResults(results(8, "INSUFFICIENT_DATA", null), 8)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void computedResultRequiresScoreEvenWhenStatusLooksComputable() { |
||||
|
assertEquals("INSUFFICIENT_DATA", |
||||
|
CandidateOverviewStatusPolicy.fromComputedResults(results(8, "OK", null), 8)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void snapshotsPreserveExistingSnapshotCountAndStatusContract() { |
||||
|
assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(null)); |
||||
|
assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(List.of())); |
||||
|
assertEquals("OK", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("OK", "ESTIMATED"))); |
||||
|
assertEquals("PARTIAL", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("OK", "PENDING"))); |
||||
|
assertEquals("PENDING", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("PENDING", "PENDING"))); |
||||
|
assertEquals("INSUFFICIENT_DATA", |
||||
|
CandidateOverviewStatusPolicy.fromSnapshots(snapshots("INSUFFICIENT_DATA", "PENDING"))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void snapshotsKeepHistoricalStatusOnlyComputabilityEvenWithNullScore() { |
||||
|
CandidateDimensionScore snapshot = snapshot("OK"); |
||||
|
snapshot.setScore(null); |
||||
|
assertEquals("OK", CandidateOverviewStatusPolicy.fromSnapshots(List.of(snapshot))); |
||||
|
} |
||||
|
|
||||
|
private List<CandidateScoringCalculator.DimensionResult> results(int count, String status, String score) { |
||||
|
List<CandidateScoringCalculator.DimensionResult> values = new ArrayList<>(); |
||||
|
for (int i = 0; i < count; i++) { |
||||
|
CandidateScoringCalculator.DimensionResult result = |
||||
|
new CandidateScoringCalculator.DimensionResult("D" + i); |
||||
|
result.status = status; |
||||
|
result.score = score == null ? null : new BigDecimal(score); |
||||
|
values.add(result); |
||||
|
} |
||||
|
return values; |
||||
|
} |
||||
|
|
||||
|
private List<CandidateDimensionScore> snapshots(String... statuses) { |
||||
|
List<CandidateDimensionScore> values = new ArrayList<>(); |
||||
|
for (String status : statuses) values.add(snapshot(status)); |
||||
|
return values; |
||||
|
} |
||||
|
|
||||
|
private CandidateDimensionScore snapshot(String status) { |
||||
|
return CandidateDimensionScore.builder().status(status).score(new BigDecimal("80")).build(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
package com.mydreamjob.recommendation; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.Arrays; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertNull; |
||||
|
|
||||
|
class JobPreferenceScoreCalculatorTest { |
||||
|
|
||||
|
@Test |
||||
|
void noAvailablePreferenceReturnsNull() { |
||||
|
assertNull(score(criteria(List.of(), List.of(), null, List.of(), List.of()), target())); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void allFiveSignalsMatchReturnsOneHundred() { |
||||
|
assertEquals(new BigDecimal("100.00"), score(fullCriteria(), target())); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void noSignalMatchesReturnsZeroWithTwoDecimals() { |
||||
|
JobPreferenceScoreCalculator.Target other = new JobPreferenceScoreCalculator.Target( |
||||
|
"Shenzhen", "PART_TIME", 9999, "FINANCE", "SALES"); |
||||
|
assertEquals(new BigDecimal("0.00"), score(fullCriteria(), other)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void weightsAndHalfUpRoundingRemainThirtyTwentyTwentyFifteenFifteen() { |
||||
|
JobPreferenceScoreCalculator.Criteria criteria = criteria( |
||||
|
List.of("Shanghai"), List.of(), null, List.of("FINANCE"), List.of()); |
||||
|
assertEquals(new BigDecimal("66.67"), score(criteria, target())); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void salaryBoundaryIsInclusiveAndNullMaximumDoesNotMatch() { |
||||
|
JobPreferenceScoreCalculator.Criteria salaryOnly = |
||||
|
criteria(List.of(), List.of(), 20000, List.of(), List.of()); |
||||
|
assertEquals(new BigDecimal("100.00"), score(salaryOnly, |
||||
|
new JobPreferenceScoreCalculator.Target(null, null, 20000, null, null))); |
||||
|
assertEquals(new BigDecimal("0.00"), score(salaryOnly, |
||||
|
new JobPreferenceScoreCalculator.Target(null, null, null, null, null))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void nullCollectionsAreTreatedAsUnavailable() { |
||||
|
assertNull(score(new JobPreferenceScoreCalculator.Criteria(null, null, null, null, null), target())); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void nullElementsInsideConfiguredListsRemainTolerated() { |
||||
|
JobPreferenceScoreCalculator.Criteria criteria = criteria( |
||||
|
Arrays.asList(null, "Shanghai"), List.of(), null, List.of(), List.of()); |
||||
|
assertEquals(new BigDecimal("100.00"), score(criteria, target())); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal score(JobPreferenceScoreCalculator.Criteria criteria, |
||||
|
JobPreferenceScoreCalculator.Target target) { |
||||
|
return JobPreferenceScoreCalculator.calculate(criteria, target); |
||||
|
} |
||||
|
|
||||
|
private JobPreferenceScoreCalculator.Criteria fullCriteria() { |
||||
|
return criteria(List.of("Shanghai"), List.of("FULL_TIME"), 20000, |
||||
|
List.of("TECH"), List.of("ENGINEERING")); |
||||
|
} |
||||
|
|
||||
|
private JobPreferenceScoreCalculator.Criteria criteria( |
||||
|
List<String> cities, List<String> types, Integer salary, |
||||
|
List<String> industries, List<String> families) { |
||||
|
return new JobPreferenceScoreCalculator.Criteria(cities, types, salary, industries, families); |
||||
|
} |
||||
|
|
||||
|
private JobPreferenceScoreCalculator.Target target() { |
||||
|
return new JobPreferenceScoreCalculator.Target( |
||||
|
"Shanghai", "FULL_TIME", 20000, "TECH", "ENGINEERING"); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue