From 72465f8c3b887643861b772ea8a4df962ff09e1a Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Thu, 3 Sep 2026 10:48:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E4=BA=BA=E6=89=8D=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/company-candidate-api.md | 15 +++- docs/frontend-integration.md | 6 +- .../application/ApplicationService.java | 82 ++++++++++++++++++- .../application/dto/CandidateDetailView.java | 7 ++ .../application/dto/CandidateItemView.java | 3 + .../application/ApplicationServiceTest.java | 77 +++++++++++++++++ 6 files changed, 183 insertions(+), 7 deletions(-) diff --git a/docs/company-candidate-api.md b/docs/company-candidate-api.md index 9f9a73a..91c0daf 100644 --- a/docs/company-candidate-api.md +++ b/docs/company-candidate-api.md @@ -40,7 +40,10 @@ GET /api/v1/company/candidates?[jobId][&status][&keyword][&sort][&order][&page][ "candidateGraduationYear": 2026, "candidateAvatarUrl": "https://...", "createdAt": "2026-08-15T10:00:00", - "updatedAt": "2026-08-16T14:00:00" + "updatedAt": "2026-08-16T14:00:00", + "matchScore": 82, + "matchReasons": ["技能匹配", "学历符合", "城市匹配", "综合评分高"], + "matchPercentile": 80 }], "total": 156, "page": 0, @@ -101,13 +104,21 @@ GET /api/v1/company/candidates/{applicationId} "candidateSchool": "清华大学", "candidateEducation": "本科", "candidateGraduationYear": 2026, - "candidateSkills": ["Java", "Spring Boot", "MySQL"] + "candidateSkills": ["Java", "Spring Boot", "MySQL"], + "matchScore": 82, + "matchReasons": ["技能匹配", "学历符合", "城市匹配", "综合评分高"], + "matchPercentile": 80 }, "requestId": "abc-123", "timestamp": "2026-08-17T15:00:00Z" } ``` +> 详情同样**一致返回** `matchScore`(Integer,0-100,不保留小数)、`matchReasons` +> (简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`,不包含技能明细)与 `matchPercentile` +> (Integer,岗位匹配百分位)。`matchPercentile` 口径与列表一致(按同岗位候选人池,`round(countLower/(cohortSize-1)*100)`,不足 2 人省略)。 +> 该字段属企业内部筛选信息,学生侧不可见。 + ### 错误码 | 状态码 | 错误码 | 说明 | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 7e6a086..599cdf3 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -167,9 +167,11 @@ Authorization: Bearer ``` **企业候选人列表可按岗位匹配度排序**:`GET /company/candidates?sort=match`。 -- 列表项在所有排序下**一致返回** `matchScore`(Integer,0-100 整数)与 `matchReasons`(简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`)。 +- 列表项在所有排序下**一致返回** `matchScore`(Integer,0-100 整数)、`matchReasons`(简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`)与 `matchPercentile`(Integer,岗位匹配百分位)。 - `matchReasons` 不包含具体技能明细;`sort=match` 时按 `matchScore` 降序,`sort=createdAt/updatedAt` 时按时间排序但同样携带匹配字段。 -- matchScore/matchReasons 属企业内部筛选信息,学生端不可见。 +- **候选人详情 `GET /company/candidates/{applicationId}` 同样一致返回** `matchScore`、`matchReasons` 与 `matchPercentile`,供详情页展示匹配度。 +- `matchPercentile` 表示候选人按 `matchScore` 超过了同岗位百分之多少的候选人(口径 `round(countLower/(cohortSize-1)*100)`;同岗位不足 2 人时省略)。 +- matchScore/matchReasons/matchPercentile 属企业内部筛选信息,学生端不可见。 ### 8.2 岗位八维评价 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index 79efae1..cbc2d98 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -33,6 +33,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -299,6 +300,7 @@ public class ApplicationService { List items = content.stream() .map(row -> buildItem(row, overallScores(pageStudentIds))) .collect(Collectors.toList()); + fillMatchPercentile(items, companyId); return PageResponse.of(items, result.getTotalElements(), page, size); } @@ -322,6 +324,7 @@ public class ApplicationService { int from = Math.min(page * size, all.size()); int to = Math.min(from + size, all.size()); List items = all.subList(from, to); + fillMatchPercentile(items, companyId); return PageResponse.of(items, all.size(), page, size); } @@ -345,11 +348,69 @@ public class ApplicationService { .createdAt(a.getCreatedAt()) .updatedAt(a.getUpdatedAt()); - TalentMatchScorer.MatchResult m = talentMatchScorer.match( - j, sp, overallByStudent.get(a.getStudentUserId())); + TalentMatchScorer.MatchResult m = matchOf(row, overallByStudent); return b.matchScore(m.matchScore()).matchReasons(m.reasons()).build(); } + /** 由候选列表 JOIN 行计算岗位匹配(复用 TalentMatchScorer,供列表项/详/百分位共用) */ + private TalentMatchScorer.MatchResult matchOf(Object[] row, Map overallByStudent) { + Application a = (Application) row[0]; + Job j = (Job) row[1]; + StudentProfile sp = (StudentProfile) row[2]; + return talentMatchScorer.match(j, sp, overallByStudent.get(a.getStudentUserId())); + } + + /** + * 岗位匹配百分位(企业内部信息):按“同岗位候选人池”以 matchScore 排位, + * 返回 appId -> 超过同岗位百分之多少的候选人(0-100 整数)。 + * 口径:round(countLower / (cohortSize - 1) * 100);cohortSize<2 时为 null(省略)。 + * cohort = 本企业该岗位的全部投递(不受列表 status/keyword 过滤影响),保证详情与列表一致。 + */ + private Map matchPercentilesByJob(Long companyId, Collection jobIds) { + if (jobIds.isEmpty()) return Collections.emptyMap(); + // 一次取出本企业全量候选人(复用 match 全量查询),仅保留请求涉及的岗位作为 cohort + List rows = applicationRepository.findCompanyCandidatesForMatch(companyId, null, null, null); + Map> byJob = rows.stream() + .filter(r -> jobIds.contains(((Job) r[1]).getId())) + .collect(Collectors.groupingBy(r -> ((Job) r[1]).getId())); + + // 批量取 cohort 内所有学生的综合分,供 matchScore 计算(无 N+1) + List allStudents = byJob.values().stream() + .flatMap(List::stream) + .map(r -> ((Application) r[0]).getStudentUserId()) + .distinct().collect(Collectors.toList()); + Map overall = overallScores(allStudents); + + Map result = new HashMap<>(); + for (List cohort : byJob.values()) { + List scores = new ArrayList<>(); + Map scoreByApp = new HashMap<>(); + for (Object[] row : cohort) { + TalentMatchScorer.MatchResult m = matchOf(row, overall); + scores.add(m.matchScore()); + scoreByApp.put(((Application) row[0]).getId(), m.matchScore()); + } + if (scores.size() < 2) continue; // 同岗位不足 2 人 → 百分位不可计算 + for (Object[] row : cohort) { + Long appId = ((Application) row[0]).getId(); + int my = scoreByApp.get(appId); + long lower = scores.stream().filter(s -> s < my).count(); + int pct = (int) Math.round(lower * 100.0 / (scores.size() - 1)); + result.put(appId, Math.max(0, Math.min(100, pct))); + } + } + return result; + } + + /** 为列表项补充岗位匹配百分位(企业内部信息,全部排序一致返回) */ + private void fillMatchPercentile(List items, Long companyId) { + if (items.isEmpty()) return; + Set involvedJobs = items.stream() + .map(CandidateItemView::getJobId).collect(Collectors.toSet()); + Map pct = matchPercentilesByJob(companyId, involvedJobs); + items.forEach(it -> it.setMatchPercentile(pct.get(it.getApplicationId()))); + } + /** 批量计算候选人综合评分:取每个学生最新周期的快照,复用候选评分引擎公式,避免 N+1 */ private Map overallScores(List studentUserIds) { if (studentUserIds.isEmpty()) return Collections.emptyMap(); @@ -413,7 +474,14 @@ public class ApplicationService { boolean contactVisible = studentUserId != null && privacySettingsService.canViewContact(studentUserId); - return CandidateDetailView.builder() + // 岗位匹配度(企业内部信息,详情一致返回):复用列表面板同一计算逻辑 + Long applicantStudentId = app.getStudentUserId(); + java.math.BigDecimal overall = overallScores( + applicantStudentId != null ? List.of(applicantStudentId) : Collections.emptyList()) + .get(applicantStudentId); + TalentMatchScorer.MatchResult match = talentMatchScorer.match(job, sp, overall); + + CandidateDetailView detail = CandidateDetailView.builder() .applicationId(app.getId()) .jobId(app.getJobId()) .jobTitle(job.getTitle()) @@ -435,7 +503,15 @@ public class ApplicationService { // 联系方式:仅当学生 contactVisible=true 时返回真实值,否则省略 .candidatePhone(contactVisible && u != null ? u.getPhone() : null) .candidateEmail(contactVisible && u != null ? u.getEmail() : null) + // 岗位匹配度与理由:企业内部信息,详情一致返回 + .matchScore(match.matchScore()) + .matchReasons(match.reasons()) .build(); + + // 岗位匹配百分位(企业内部信息):按同岗位候选人池以 matchScore 计算,与列表口径一致 + Map pct = matchPercentilesByJob(companyId, List.of(job.getId())); + detail.setMatchPercentile(pct.get(app.getId())); + return detail; } /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java index 5ce81e3..8e825fe 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java @@ -52,4 +52,11 @@ public class CandidateDetailView { // 联系方式(仅当学生 contactVisible=true 时由 Service 填充,否则保持 null 被省略) private String candidatePhone; private String candidateEmail; + + // 岗位匹配(企业内部筛选信息,详情一致返回,学生不可见) + private Integer matchScore; + private List matchReasons; + + /** 岗位匹配百分位(0-100 整数):超过同岗位百分之多少的候选人;同岗位<2人时为 null 省略 */ + private Integer matchPercentile; } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java index 18d8125..a4fb69f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java @@ -36,4 +36,7 @@ public class CandidateItemView { /** 岗位匹配理由(简短短语,企业内部信息),全部排序下一致返回 */ private List matchReasons; + + /** 岗位匹配百分位(0-100 整数):超过同岗位百分之多少的候选人;同岗位<2人时为 null 省略 */ + private Integer matchPercentile; } \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index ea23390..6d56944 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java @@ -24,12 +24,14 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import java.util.*; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -292,6 +294,49 @@ class ApplicationServiceTest { assertEquals(1, resp.getItems().size()); } + @Test void listCompanyCandidates_shouldComputeMatchPercentile() { + // 同一岗位 3 名候选人,matchScore 90/60/30 → 百分位 100/50/0(口径 countLower/(n-1)) + Object[] high = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); + Object[] mid = buildRow(201L, JOB_ID, "Java", "SUBMITTED", "李四", "北大", "硕士", null, null); + Object[] low = buildRow(202L, JOB_ID, "Java", "SUBMITTED", "王五", "复旦", "本科", null, null); + StudentProfile spHigh = (StudentProfile) high[2]; + StudentProfile spMid = (StudentProfile) mid[2]; + StudentProfile spLow = (StudentProfile) low[2]; + + when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class))) + .thenReturn(mockCandidatePage(high, mid, low)); + // percentile cohort = 该岗位全量候选人(同岗位 3 人) + when(applicationRepository.findCompanyCandidatesForMatch(anyLong(), any(), any(), any())) + .thenReturn(List.of(high, mid, low)); + when(talentMatchScorer.match(any(), argThat(sp -> sp == spHigh), any())) + .thenReturn(new TalentMatchScorer.MatchResult(90, List.of("x"))); + when(talentMatchScorer.match(any(), argThat(sp -> sp == spMid), any())) + .thenReturn(new TalentMatchScorer.MatchResult(60, List.of("x"))); + when(talentMatchScorer.match(any(), argThat(sp -> sp == spLow), any())) + .thenReturn(new TalentMatchScorer.MatchResult(30, List.of("x"))); + + PageResponse resp = applicationService.listCompanyCandidates( + COMPANY_ID, null, null, null, null, null, 0, 20); + Map pctByApp = resp.getItems().stream() + .collect(Collectors.toMap(CandidateItemView::getApplicationId, CandidateItemView::getMatchPercentile)); + assertEquals(Integer.valueOf(100), pctByApp.get(200L)); // 2/(3-1)*100 + assertEquals(Integer.valueOf(50), pctByApp.get(201L)); // 1/(3-1)*100 + assertEquals(Integer.valueOf(0), pctByApp.get(202L)); // 0/(3-1)*100 + } + + @Test void listCompanyCandidates_cohortLessThan2_shouldOmitPercentile() { + // 同岗位仅 1 名候选人 → 百分位不可计算 → null(配合 NON_NULL 省略) + Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); + when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class))) + .thenReturn(mockCandidatePage(row)); + when(applicationRepository.findCompanyCandidatesForMatch(anyLong(), any(), any(), any())) + .thenReturn(Collections.singletonList(row)); + + PageResponse resp = applicationService.listCompanyCandidates( + COMPANY_ID, null, null, null, null, null, 0, 20); + assertNull(resp.getItems().get(0).getMatchPercentile()); + } + // ── 企业候选人详情 ───────────────────────────────────────── @Test void getCompanyCandidateDetail_shouldReturnDetail() { @@ -322,10 +367,42 @@ class ApplicationServiceTest { assertEquals("清华", detail.getCandidateSchool()); assertEquals("13800000001", detail.getCandidatePhone()); assertEquals("stu@example.com", detail.getCandidateEmail()); + // 详情一致返回岗位匹配度与理由(企业内部分数,与列表面板同源) + assertEquals(Integer.valueOf(88), detail.getMatchScore()); + assertEquals(List.of("x"), detail.getMatchReasons()); // 成功详情查询不产生额外 Repository 查询(无 N+1) verify(applicationRepository, never()).findById(anyLong()); } + @Test void getCompanyCandidateDetail_shouldComputeMatchPercentile() { + // 目标候选人同岗位另有 1 人且 matchScore 更低 → 百分位 round(1/(2-1)*100)=100 + Application app = Application.builder().id(APP_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build(); + com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() + .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); + StudentProfile mine = StudentProfile.builder().userId(STUDENT_ID).realName("张三").build(); + Object[] myRow = new Object[]{app, job, mine, null}; + com.mydreamjob.auth.User otherUser = mock(com.mydreamjob.auth.User.class); + StudentProfile other = StudentProfile.builder().userId(999L).realName("李四").build(); + Object[] otherRow = new Object[]{ + Application.builder().id(999L).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build(), + job, other, otherUser}; + + when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.singletonList(myRow)); + when(privacySettingsService.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(false); + when(privacySettingsService.canViewContact(STUDENT_ID)).thenReturn(false); + // percentile cohort = 该岗位全量候选人(目标 + 同岗位另 1 人) + when(applicationRepository.findCompanyCandidatesForMatch(anyLong(), any(), any(), any())) + .thenReturn(List.of(myRow, otherRow)); + when(talentMatchScorer.match(any(), argThat(sp -> sp == mine), any())) + .thenReturn(new TalentMatchScorer.MatchResult(90, List.of("x"))); + when(talentMatchScorer.match(any(), argThat(sp -> sp == other), any())) + .thenReturn(new TalentMatchScorer.MatchResult(40, List.of("x"))); + + CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); + assertEquals(Integer.valueOf(100), detail.getMatchPercentile()); + } + @Test void getCompanyCandidateDetail_wrongCompany_shouldThrow() { // 投递存在,但岗位 companyId 与当前企业不匹配 → APPLICATION_OWNER_MISMATCH Application app = Application.builder().id(APP_ID).jobId(JOB_ID)