Browse Source

企业人才推荐补充

backup/master-local-20261012
pwklppg 2 months ago
parent
commit
72465f8c3b
  1. 15
      docs/company-candidate-api.md
  2. 6
      docs/frontend-integration.md
  3. 82
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  4. 7
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java
  5. 3
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java
  6. 77
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

15
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 人省略)。
> 该字段属企业内部筛选信息,学生侧不可见。
### 错误码
| 状态码 | 错误码 | 说明 |

6
docs/frontend-integration.md

@ -167,9 +167,11 @@ Authorization: Bearer <accessToken>
```
**企业候选人列表可按岗位匹配度排序**:`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 岗位八维评价

82
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<CandidateItemView> 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<CandidateItemView> 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<Long, java.math.BigDecimal> 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<Long, Integer> matchPercentilesByJob(Long companyId, Collection<Long> jobIds) {
if (jobIds.isEmpty()) return Collections.emptyMap();
// 一次取出本企业全量候选人(复用 match 全量查询),仅保留请求涉及的岗位作为 cohort
List<Object[]> rows = applicationRepository.findCompanyCandidatesForMatch(companyId, null, null, null);
Map<Long, List<Object[]>> byJob = rows.stream()
.filter(r -> jobIds.contains(((Job) r[1]).getId()))
.collect(Collectors.groupingBy(r -> ((Job) r[1]).getId()));
// 批量取 cohort 内所有学生的综合分,供 matchScore 计算(无 N+1)
List<Long> allStudents = byJob.values().stream()
.flatMap(List::stream)
.map(r -> ((Application) r[0]).getStudentUserId())
.distinct().collect(Collectors.toList());
Map<Long, java.math.BigDecimal> overall = overallScores(allStudents);
Map<Long, Integer> result = new HashMap<>();
for (List<Object[]> cohort : byJob.values()) {
List<Integer> scores = new ArrayList<>();
Map<Long, Integer> 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<CandidateItemView> items, Long companyId) {
if (items.isEmpty()) return;
Set<Long> involvedJobs = items.stream()
.map(CandidateItemView::getJobId).collect(Collectors.toSet());
Map<Long, Integer> pct = matchPercentilesByJob(companyId, involvedJobs);
items.forEach(it -> it.setMatchPercentile(pct.get(it.getApplicationId())));
}
/** 批量计算候选人综合评分:取每个学生最新周期的快照,复用候选评分引擎公式,避免 N+1 */
private Map<Long, java.math.BigDecimal> overallScores(List<Long> 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<Long, Integer> pct = matchPercentilesByJob(companyId, List.of(job.getId()));
detail.setMatchPercentile(pct.get(app.getId()));
return detail;
}
/**

7
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<String> matchReasons;
/** 岗位匹配百分位(0-100 整数):超过同岗位百分之多少的候选人;同岗位<2人时为 null 省略 */
private Integer matchPercentile;
}

3
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java

@ -36,4 +36,7 @@ public class CandidateItemView {
/** 岗位匹配理由(简短短语,企业内部信息),全部排序下一致返回 */
private List<String> matchReasons;
/** 岗位匹配百分位(0-100 整数):超过同岗位百分之多少的候选人;同岗位<2人时为 null 省略 */
private Integer matchPercentile;
}

77
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<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, null, null, null, null, null, 0, 20);
Map<Long, Integer> 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<CandidateItemView> 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)

Loading…
Cancel
Save