Browse Source

feat: enterprise talent recommendation with matchScore sorting

- add sort=match to company candidate list with in-memory scoring

- matchScore normalized over known signals, integer clamped to 0-100

- matchReasons as short phrases, returned consistently for all sorts

- config-driven signal weights under app.talent-match.weight
backup/master-local-20261012
pwklppg 2 months ago
parent
commit
21b853b759
  1. 9
      docs/company-candidate-api.md
  2. 5
      docs/frontend-integration.md
  3. 9
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  4. 19
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  5. 132
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  6. 9
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java
  7. 191
      mydreamjob-server/src/main/java/com/mydreamjob/recommend/TalentMatchScorer.java
  8. 8
      mydreamjob-server/src/main/resources/application.yml
  9. 8
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java
  10. 26
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java
  11. 130
      mydreamjob-server/src/test/java/com/mydreamjob/recommend/TalentMatchScorerTest.java

9
docs/company-candidate-api.md

@ -13,8 +13,8 @@ GET /api/v1/company/candidates?[jobId][&status][&keyword][&sort][&order][&page][
| jobId | Long | 否 | 全部 | 按岗位筛选 |
| status | String | 否 | 全部 | SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN |
| keyword | String | 否 | — | 学生姓名/学校/学历关键词搜索 |
| sort | String | 否 | createdAt | createdAt/updatedAt |
| order | String | 否 | desc | asc/desc |
| sort | String | 否 | createdAt | createdAt/updatedAt/match(match=按岗位匹配度降序) |
| order | String | 否 | desc | asc/desc(match 模式下忽略,固定降序) |
| page | int | 否 | 0 | 页码 (0-based) |
| size | int | 否 | 20 | 每页条数 (1-100) |
@ -52,6 +52,11 @@ GET /api/v1/company/candidates?[jobId][&status][&keyword][&sort][&order][&page][
}
```
> 所有排序下列表项**一致返回** `matchScore`(Integer,0-100,不保留小数)与 `matchReasons`
> (简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`,不包含技能明细)。
> `sort=match` 时按 `matchScore` 从高到低排序;`sort=createdAt/updatedAt` 保持按时间排序,但同样携带匹配字段。
> matchScore/matchReasons 属企业内部筛选信息,学生侧不可见。
### 错误码
| 状态码 | 错误码 | 说明 |

5
docs/frontend-integration.md

@ -166,6 +166,11 @@ Authorization: Bearer <accessToken>
{ "jobId": 123 }
```
**企业候选人列表可按岗位匹配度排序**:`GET /company/candidates?sort=match`。
- 列表项在所有排序下**一致返回** `matchScore`(Integer,0-100 整数)与 `matchReasons`(简短短语数组,如 `["技能匹配","学历符合","城市匹配","综合评分高"]`)。
- `matchReasons` 不包含具体技能明细;`sort=match` 时按 `matchScore` 降序,`sort=createdAt/updatedAt` 时按时间排序但同样携带匹配字段。
- matchScore/matchReasons 属企业内部筛选信息,学生端不可见。
### 8.2 岗位八维评价
`POST /jobs/{jobId}/reviews` 可附加可选 `dimensionScores`(八个固定维度,值 1-5,可稀疏提交):

9
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java

@ -1,10 +1,11 @@
package com.mydreamjob.application;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.application.dto.CandidateItemView;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.company.CompanyService;
@ -37,7 +38,7 @@ public class ApplicationController {
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt", "match");
private static final Set<String> ALLOWED_ORDERS = Set.of("asc", "desc");
// ── 学生投递 ──────────────────────────────────────────────
@ -151,7 +152,7 @@ public class ApplicationController {
@Operation(summary = "企业候选人列表", description = "跨岗位查询本企业所有候选人投递,支持按岗位、状态、关键词筛选,支持分页和排序")
@GetMapping("/company/candidates")
public ResponseEntity<ApiResponse<CandidateListResponse>> listCompanyCandidates(
public ResponseEntity<ApiResponse<PageResponse<CandidateItemView>>> listCompanyCandidates(
@RequestParam(required = false) Long jobId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword,
@ -184,7 +185,7 @@ public class ApplicationController {
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
CandidateListResponse data = applicationService.listCompanyCandidates(
PageResponse<CandidateItemView> data = applicationService.listCompanyCandidates(
company.getId(), jobId, status, keyword, sort, order, page, size);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}

19
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java

@ -84,4 +84,23 @@ public interface ApplicationRepository extends JpaRepository<Application, Long>
@Param("status") String status,
@Param("keyword") String keyword,
Pageable pageable);
/**
* 企业候选人 match 排序用:取出本企业全部候选人的完整内存对象(不分页),
* 复用与 {@link #findCompanyCandidates} 相同的 JOIN/过滤口径;排序与分页由 Service 在内存中完成。
*/
@Query("SELECT a, j, sp, u FROM Application a " +
"JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
"LEFT JOIN User u ON a.studentUserId = u.id " +
"WHERE j.companyId = :companyId " +
"AND (:jobId IS NULL OR a.jobId = :jobId) " +
"AND (:status IS NULL OR a.status = :status) " +
"AND (:keyword IS NULL OR sp.realName LIKE CONCAT('%', :keyword, '%') " +
" OR sp.school LIKE CONCAT('%', :keyword, '%') " +
" OR sp.education LIKE CONCAT('%', :keyword, '%'))")
List<Object[]> findCompanyCandidatesForMatch(@Param("companyId") Long companyId,
@Param("jobId") Long jobId,
@Param("status") String status,
@Param("keyword") String keyword);
}

132
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -2,16 +2,20 @@ package com.mydreamjob.application;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CandidateItemView;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.recommend.TalentMatchScorer;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.notification.NotificationService;
@ -25,9 +29,12 @@ import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -46,11 +53,14 @@ public class ApplicationService {
private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService;
private final PrivacySettingsService privacySettingsService;
private final TalentMatchScorer talentMatchScorer;
private final CandidateScoringCalculator scoringCalculator;
private final CandidateDimensionScoreRepository dimensionScoreRepository;
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt", "match");
private static final Set<String> ALLOWED_ORDERS = Set.of("asc", "desc");
// ── 学生端 ────────────────────────────────────────────────
@ -250,9 +260,9 @@ public class ApplicationService {
* 排序字段通过白名单映射,禁止拼接原始请求参数
*/
@Transactional(readOnly = true)
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order,
int page, int size) {
public PageResponse<CandidateItemView> listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order,
int page, int size) {
// 参数校验
if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
@ -265,6 +275,11 @@ public class ApplicationService {
String escapedKeyword = escapeLike(keyword);
// sort=match:内存实时算分 + 排序 + 分页(确定性规则,无法在 SQL 内计算)
if ("match".equals(sort)) {
return listCompanyCandidatesByMatch(companyId, jobId, status, escapedKeyword, page, size);
}
// 排序:白名单字段(createdAt/updatedAt)+ 主键稳定次序,方向取决于校验后的 order
String sortField = (sort == null) ? "createdAt" : sort;
Sort.Direction direction = (order == null || "desc".equalsIgnoreCase(order))
@ -276,35 +291,84 @@ public class ApplicationService {
companyId, jobId, status, escapedKeyword,
PageRequest.of(page, size, sortSpec));
List<CandidateItemView> items = result.getContent().stream()
.map(row -> {
Application a = (Application) row[0];
com.mydreamjob.job.Job j = (com.mydreamjob.job.Job) row[1];
StudentProfile sp = (StudentProfile) row[2];
com.mydreamjob.auth.User u = (com.mydreamjob.auth.User) row[3];
return CandidateItemView.builder()
.applicationId(a.getId())
.jobId(a.getJobId())
.jobTitle(j.getTitle())
.status(a.getStatus())
.candidateName(sp != null ? sp.getRealName() : null)
.candidateSchool(sp != null ? sp.getSchool() : null)
.candidateEducation(sp != null ? sp.getEducation() : null)
.candidateGraduationYear(sp != null ? sp.getGraduationYear() : null)
.candidateAvatarUrl(u != null ? u.getAvatarUrl() : null)
.createdAt(a.getCreatedAt())
.updatedAt(a.getUpdatedAt())
.build();
})
List<Object[]> content = result.getContent();
// 批量取本页候选人的综合评分,供 matchScore 计算使用(首项已过滤,无 N+1)
List<Long> pageStudentIds = content.stream()
.map(row -> ((Application) row[0]).getStudentUserId())
.distinct().collect(Collectors.toList());
List<CandidateItemView> items = content.stream()
.map(row -> buildItem(row, overallScores(pageStudentIds)))
.collect(Collectors.toList());
return CandidateListResponse.builder()
.items(items)
.total(result.getTotalElements())
.page(page)
.size(size)
.totalPages(result.getTotalPages())
.build();
return PageResponse.of(items, result.getTotalElements(), page, size);
}
/** match 模式:取出本企业全量候选人 → 批量取综合分 → 内存算分排序 → 内存分页 */
private PageResponse<CandidateItemView> listCompanyCandidatesByMatch(Long companyId, Long jobId, String status,
String keyword, int page, int size) {
List<Object[]> rows = applicationRepository.findCompanyCandidatesForMatch(companyId, jobId, status, keyword);
List<Long> studentUserIds = rows.stream()
.map(row -> ((Application) row[0]).getStudentUserId())
.distinct().collect(Collectors.toList());
Map<Long, java.math.BigDecimal> overallByStudent = overallScores(studentUserIds);
List<CandidateItemView> all = rows.stream()
.map(row -> buildItem(row, overallByStudent))
.sorted(Comparator
.comparing(CandidateItemView::getMatchScore, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(CandidateItemView::getApplicationId, Comparator.reverseOrder()))
.collect(Collectors.toList());
int from = Math.min(page * size, all.size());
int to = Math.min(from + size, all.size());
List<CandidateItemView> items = all.subList(from, to);
return PageResponse.of(items, all.size(), page, size);
}
/** 由候选人列表项原始 JOIN 行构建视图;始终补充 matchScore/matchReasons(对任意排序一致返回) */
private CandidateItemView buildItem(Object[] row, Map<Long, java.math.BigDecimal> overallByStudent) {
Application a = (Application) row[0];
com.mydreamjob.job.Job j = (com.mydreamjob.job.Job) row[1];
StudentProfile sp = (StudentProfile) row[2];
com.mydreamjob.auth.User u = (com.mydreamjob.auth.User) row[3];
CandidateItemView.CandidateItemViewBuilder b = CandidateItemView.builder()
.applicationId(a.getId())
.jobId(a.getJobId())
.jobTitle(j.getTitle())
.status(a.getStatus())
.candidateName(sp != null ? sp.getRealName() : null)
.candidateSchool(sp != null ? sp.getSchool() : null)
.candidateEducation(sp != null ? sp.getEducation() : null)
.candidateGraduationYear(sp != null ? sp.getGraduationYear() : null)
.candidateAvatarUrl(u != null ? u.getAvatarUrl() : null)
.createdAt(a.getCreatedAt())
.updatedAt(a.getUpdatedAt());
TalentMatchScorer.MatchResult m = talentMatchScorer.match(
j, sp, overallByStudent.get(a.getStudentUserId()));
return b.matchScore(m.matchScore()).matchReasons(m.reasons()).build();
}
/** 批量计算候选人综合评分:取每个学生最新周期的快照,复用候选评分引擎公式,避免 N+1 */
private Map<Long, java.math.BigDecimal> overallScores(List<Long> studentUserIds) {
if (studentUserIds.isEmpty()) return Collections.emptyMap();
String asOf = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
Map<Long, List<CandidateDimensionScore>> byStudent = dimensionScoreRepository
.findUpToPeriod(studentUserIds, asOf).stream()
.collect(Collectors.groupingBy(CandidateDimensionScore::getStudentUserId));
Map<Long, java.math.BigDecimal> result = new HashMap<>();
for (Map.Entry<Long, List<CandidateDimensionScore>> e : byStudent.entrySet()) {
String latest = e.getValue().stream()
.map(CandidateDimensionScore::getPeriod)
.max(Comparator.naturalOrder()).orElse(null);
if (latest == null) continue;
List<CandidateDimensionScore> latestSnapshots = e.getValue().stream()
.filter(s -> latest.equals(s.getPeriod())).collect(Collectors.toList());
result.put(e.getKey(), scoringCalculator.overallScoreFromSnapshots(latestSnapshots));
}
return result;
}
/**

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

@ -1,11 +1,13 @@
package com.mydreamjob.application.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 企业候选人列表项视图 — 不返回 studentUserId、phone、coverLetter、内部字段
@ -14,6 +16,7 @@ import java.time.LocalDateTime;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CandidateItemView {
private Long applicationId;
@ -27,4 +30,10 @@ public class CandidateItemView {
private String candidateAvatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 岗位匹配度(0-100 整数),全部排序下一致返回 */
private Integer matchScore;
/** 岗位匹配理由(简短短语,企业内部信息),全部排序下一致返回 */
private List<String> matchReasons;
}

191
mydreamjob-server/src/main/java/com/mydreamjob/recommend/TalentMatchScorer.java

@ -0,0 +1,191 @@
package com.mydreamjob.recommend;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.job.Job;
import com.mydreamjob.profile.StudentProfile;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 岗位匹配度确定性计算器(企业候选人看板 `sort=match`)。
*
* <p>由 5 个加权信号组成(技能/学历/经验/城市/背景参考),每信号 0-100,
* 汇总后取整为 `matchScore`(0-100,四舍五入)。纯确定性规则,无 AI。
* 信号权重来自 {@code app.talent-match.weight.*} 配置,总和约定为 1.00。</p>
*/
@Slf4j
@Component
public class TalentMatchScorer {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Pattern EXPERIENCE_REQ = Pattern.compile("(\\d+)\\s*[-~至]\\s*(\\d+)\\s*年");
private static final Pattern EXPERIENCE_MIN = Pattern.compile("([1-9]\\d*)\\s*年");
// 学历档次(数值越大越高)
private static final int LVL_PHD = 4;
private static final int LVL_MASTER = 3;
private static final int LVL_BACHELOR = 2;
private static final int LVL_ASSOCIATE = 1;
@Value("${app.talent-match.weight.skill:0.40}")
private double wSkill;
@Value("${app.talent-match.weight.edu:0.25}")
private double wEdu;
@Value("${app.talent-match.weight.exp:0.20}")
private double wExp;
@Value("${app.talent-match.weight.loc:0.10}")
private double wLoc;
@Value("${app.talent-match.weight.score:0.05}")
private double wScore;
/**
* 计算指定岗位与候选人档案的匹配度。overallScore 为候选综合评分(可为 null)。
*
* <p>采用「已知信号加权归一化」:仅对两端数据都具备的信号计分,
* 缺失/无法判断的信号不参与(不因数据缺失而扣分)。
* 分值 = Σ(已知信号得分 × 权重) / Σ(已知权重),并夹取到 [0,100]。
*
* @return matchScore(0-100 整数,不会低于 0 或超过 100)与简短理由数组
*/
public MatchResult match(Job job, StudentProfile profile, BigDecimal overallScore) {
List<String> reasons = new ArrayList<>();
double weighted = 0.0;
double knownWeight = 0.0;
Double skill = skillScore(job, profile, reasons);
if (skill != null) { weighted += skill * wSkill; knownWeight += wSkill; }
Double edu = educationScore(job, profile, reasons);
if (edu != null) { weighted += edu * wEdu; knownWeight += wEdu; }
Double exp = experienceScore(job, profile, reasons);
if (exp != null) { weighted += exp * wExp; knownWeight += wExp; }
Double loc = cityScore(job, profile, reasons);
if (loc != null) { weighted += loc * wLoc; knownWeight += wLoc; }
Double bg = backgroundScore(overallScore, reasons);
if (bg != null) { weighted += bg * wScore; knownWeight += wScore; }
if (knownWeight == 0.0) {
return new MatchResult(0, List.copyOf(reasons));
}
int matchScore = (int) Math.round(weighted / knownWeight);
matchScore = Math.max(0, Math.min(100, matchScore));
return new MatchResult(matchScore, List.copyOf(reasons));
}
/** 技能匹配:候选人技能命中岗位要求关键词的占比 → 0-100;数据不足返回 null */
private Double skillScore(Job job, StudentProfile profile, List<String> reasons) {
if (profile == null || isBlank(profile.getSkillsJson())) return null;
if (job == null || isBlank(job.getRequirements())) return null;
List<String> skills = parseSkills(profile.getSkillsJson());
if (skills.isEmpty()) return null;
// 忽略大小写与空格,兼容「Spring Boot」与「SpringBoot」同义的写法
String reqNorm = job.getRequirements().toLowerCase().replace(" ", "");
long hit = skills.stream()
.filter(s -> {
String sn = s.toLowerCase().replace(" ", "");
return !sn.isBlank() && reqNorm.contains(sn);
})
.count();
double score = hit * 100.0 / skills.size();
if (score > 0) reasons.add("技能匹配");
return score;
}
/** 学历匹配:达到/高于岗位最低学历=100,低于按档位折算;要求未写明学历时返回 null */
private Double educationScore(Job job, StudentProfile profile, List<String> reasons) {
if (profile == null || isBlank(profile.getEducation())) return null;
int reqLvl = job == null ? 0 : educationLevel(job.getRequirements());
if (reqLvl == 0) return null; // 岗位未声明学历要求 → 该信号无法判断
int candLvl = educationLevel(profile.getEducation());
if (candLvl >= reqLvl) {
reasons.add("学历符合");
return 100.0;
}
reasons.add("学历略低");
return candLvl * 100.0 / reqLvl;
}
/** 经验匹配:毕业年数对照岗位要求经验区间 → 0-100;岗位未声明年限时返回 null */
private Double experienceScore(Job job, StudentProfile profile, List<String> reasons) {
if (profile == null || profile.getGraduationYear() == null || profile.getGraduationYear() <= 0) return null;
String req = job == null ? "" : nullToEmpty(job.getRequirements());
Integer required = parseExperience(req);
if (required == null) return null; // 岗位未声明经验要求 → 该信号无法判断
int years = LocalDate.now().getYear() - profile.getGraduationYear();
if (years >= required) {
reasons.add("经验匹配");
return 100.0;
}
reasons.add("经验不足");
return Math.max(0, years * 100.0 / required);
}
/** 城市匹配:岗位城市与候选人所在地一致=100,否则=50;任一缺失返回 null */
private Double cityScore(Job job, StudentProfile profile, List<String> reasons) {
if (job == null || isBlank(job.getCity()) || profile == null || isBlank(profile.getLocation())) return null;
reasons.add(job.getCity().equals(profile.getLocation()) ? "城市匹配" : "城市待定");
return job.getCity().equals(profile.getLocation()) ? 100.0 : 50.0;
}
/** 背景参考:候选综合评分映射档位;无评分时返回 null */
private Double backgroundScore(BigDecimal overallScore, List<String> reasons) {
if (overallScore == null) return null;
double v = overallScore.doubleValue();
if (v >= 85) { reasons.add("综合评分高"); return 100.0; }
if (v >= 60) { reasons.add("综合评分中"); return 70.0; }
reasons.add("综合评分低");
return 40.0;
}
private int educationLevel(String text) {
if (isBlank(text)) return 0;
if (text.contains("博士")) return LVL_PHD;
if (text.contains("硕士") || text.contains("研究生")) return LVL_MASTER;
if (text.contains("本科")) return LVL_BACHELOR;
if (text.contains("大专") || text.contains("专科")) return LVL_ASSOCIATE;
return 0;
}
/** 解析经验要求:优先取区间下限,其次取"X年"最小值,解析不到返回 null */
private Integer parseExperience(String text) {
Matcher range = EXPERIENCE_REQ.matcher(text);
if (range.find()) return Integer.valueOf(range.group(1));
Matcher min = EXPERIENCE_MIN.matcher(text);
int lowest = Integer.MAX_VALUE;
while (min.find()) {
lowest = Math.min(lowest, Integer.parseInt(min.group(1)));
}
return lowest == Integer.MAX_VALUE ? null : lowest;
}
private List<String> parseSkills(String json) {
if (isBlank(json)) return List.of();
try {
List<String> list = MAPPER.readValue(json, new TypeReference<List<String>>() {});
return list == null ? List.of() : list.stream().filter(s -> s != null && !s.isBlank()).toList();
} catch (Exception e) {
log.warn("failed to parse skillsJson: {}", e.getMessage());
return List.of();
}
}
private boolean isBlank(String s) {
return s == null || s.isBlank();
}
private String nullToEmpty(String s) {
return s == null ? "" : s;
}
/** 匹配结果载体 */
public record MatchResult(int matchScore, List<String> reasons) {}
}

8
mydreamjob-server/src/main/resources/application.yml

@ -133,6 +133,14 @@ app:
cooling-days: ${DELETION_COOLING_DAYS:30}
phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod}
talent-match:
weight:
skill: ${TALENT_MATCH_WEIGHT_SKILL:0.40}
edu: ${TALENT_MATCH_WEIGHT_EDU:0.25}
exp: ${TALENT_MATCH_WEIGHT_EXP:0.20}
loc: ${TALENT_MATCH_WEIGHT_LOC:0.10}
score: ${TALENT_MATCH_WEIGHT_SCORE:0.05}
management:
endpoints:
web:

8
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java

@ -3,7 +3,7 @@ package com.mydreamjob.application;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CandidateItemView;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.User.Role;
import com.mydreamjob.auth.UserRepository;
@ -101,8 +101,7 @@ class ApplicationControllerTest {
@Test void list_companyNormal_shouldReturn200() throws Exception {
mockCompanyExists();
CandidateListResponse resp = CandidateListResponse.builder()
.items(List.of()).total(0).page(0).size(20).totalPages(0).build();
PageResponse<CandidateItemView> resp = PageResponse.of(List.of(), 0, 0, 20);
when(applicationService.listCompanyCandidates(anyLong(), isNull(), isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(resp);
@ -281,8 +280,7 @@ class ApplicationControllerTest {
.candidateName("张三").candidateSchool("清华").candidateEducation("本科")
.candidateGraduationYear((short)2026).candidateAvatarUrl("https://img/1.png")
.createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build();
CandidateListResponse resp = CandidateListResponse.builder()
.items(List.of(item)).total(1).page(0).size(20).totalPages(1).build();
PageResponse<CandidateItemView> resp = PageResponse.of(List.of(item), 1, 0, 20);
when(applicationService.listCompanyCandidates(anyLong(), isNull(), isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt()))
.thenReturn(resp);

26
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

@ -6,9 +6,13 @@ import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.application.dto.CandidateItemView;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.privacy.PrivacySettingsService;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.recommend.TalentMatchScorer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -24,6 +28,8 @@ import java.util.*;
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.Mockito.*;
@ExtendWith(MockitoExtension.class)
@ -36,6 +42,9 @@ class ApplicationServiceTest {
@Mock private ApplicationStatusHistoryRepository historyRepository;
@Mock private com.mydreamjob.notification.NotificationService notificationService;
@Mock private PrivacySettingsService privacySettingsService;
@Mock private TalentMatchScorer talentMatchScorer;
@Mock private CandidateScoringCalculator scoringCalculator;
@Mock private CandidateDimensionScoreRepository dimensionScoreRepository;
@InjectMocks private ApplicationService applicationService;
private static final Long STUDENT_ID = 100L;
@ -49,6 +58,11 @@ class ApplicationServiceTest {
void setUp() {
publishedJob = com.mydreamjob.job.Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java").status("PUBLISHED").build();
application = Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build();
// 候选人列表现对所有排序都计算 matchScore:为无关用例提供默认桩,避免 Mockito 返回 null/遗漏
lenient().when(dimensionScoreRepository.findUpToPeriod(anyList(), anyString()))
.thenReturn(Collections.emptyList());
lenient().when(talentMatchScorer.match(any(), any(), any()))
.thenReturn(new TalentMatchScorer.MatchResult(88, List.of("x")));
}
private void mockStudentRole() {
@ -191,7 +205,7 @@ class ApplicationServiceTest {
when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class)))
.thenReturn(mockCandidatePage(row));
CandidateListResponse resp = applicationService.listCompanyCandidates(
PageResponse<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, null, null, null, null, null, 0, 20);
assertEquals(1, resp.getItems().size());
assertEquals("张三", resp.getItems().get(0).getCandidateName());
@ -204,7 +218,7 @@ class ApplicationServiceTest {
when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class)))
.thenReturn(mockCandidatePage(row));
CandidateListResponse resp = applicationService.listCompanyCandidates(
PageResponse<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, JOB_ID, null, null, null, null, 0, 20);
assertEquals(1, resp.getItems().size());
assertEquals(JOB_ID, resp.getItems().get(0).getJobId());
@ -216,7 +230,7 @@ class ApplicationServiceTest {
when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class)))
.thenReturn(mockCandidatePage(row));
CandidateListResponse resp = applicationService.listCompanyCandidates(
PageResponse<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, null, "SUBMITTED", null, null, null, 0, 20);
assertEquals("SUBMITTED", resp.getItems().get(0).getStatus());
}
@ -225,7 +239,7 @@ class ApplicationServiceTest {
when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class)))
.thenReturn(mockCandidatePage());
CandidateListResponse resp = applicationService.listCompanyCandidates(
PageResponse<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, null, null, null, null, null, 0, 20);
assertEquals(0, resp.getItems().size());
assertEquals(0, resp.getTotal());
@ -273,7 +287,7 @@ class ApplicationServiceTest {
when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class)))
.thenReturn(mockCandidatePage(row));
CandidateListResponse resp = applicationService.listCompanyCandidates(
PageResponse<CandidateItemView> resp = applicationService.listCompanyCandidates(
COMPANY_ID, null, null, null, "updatedAt", "asc", 0, 20);
assertEquals(1, resp.getItems().size());
}

130
mydreamjob-server/src/test/java/com/mydreamjob/recommend/TalentMatchScorerTest.java

@ -0,0 +1,130 @@
package com.mydreamjob.recommend;
import com.mydreamjob.job.Job;
import com.mydreamjob.profile.StudentProfile;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class TalentMatchScorerTest {
private TalentMatchScorer newScorer() {
TalentMatchScorer s = new TalentMatchScorer();
// 注入与 application.yml 一致的默认权重(skill 0.40 / edu 0.25 / exp 0.20 / loc 0.10 / score 0.05)
ReflectionTestUtils.setField(s, "wSkill", 0.40);
ReflectionTestUtils.setField(s, "wEdu", 0.25);
ReflectionTestUtils.setField(s, "wExp", 0.20);
ReflectionTestUtils.setField(s, "wLoc", 0.10);
ReflectionTestUtils.setField(s, "wScore", 0.05);
return s;
}
private Job job(String requirements, String city) {
return Job.builder().requirements(requirements).city(city).build();
}
private StudentProfile profile(String education, String location, Short gradYear, String skillsJson) {
return StudentProfile.builder()
.education(education).location(location).graduationYear(gradYear)
.skillsJson(skillsJson).build();
}
@Test void skillMatch_reasonIsSkillMatchWithoutDetail() {
Job j = job("熟悉Java、SpringBoot,三年以上经验,本科", "北京");
StudentProfile p = profile("本科", "北京", (short) 2020,
"[\"Java\",\"SpringBoot\",\"Python\"]");
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult r = s.match(j, p, null);
assertTrue(r.reasons().contains("技能匹配"));
// 理由为简短短语,不含技能明细
assertFalse(r.reasons().stream().anyMatch(x -> x.contains("Java") || x.contains("Spring")));
}
@Test void educationMet_shouldBe100AndEduMatch() {
Job j = job("要求本科及以上", "北京");
StudentProfile p = profile("硕士", "北京", (short) 2020, null);
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult r = s.match(j, p, null);
assertTrue(r.reasons().contains("学历符合"));
// 已知信号=学历(100×0.25)+城市(100×0.10),累计 0.35 → 100/100
assertEquals(100, r.matchScore());
}
@Test void educationBelow_shouldBeLowerAndEduLow() {
Job j = job("要求本科", "北京");
StudentProfile p = profile("大专", "北京", (short) 2020, null);
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult r = s.match(j, p, null);
assertTrue(r.reasons().contains("学历略低"));
// (50×0.25 + 100×0.10)/(0.25+0.10) = 22.5/0.35 ≈ 64
assertEquals(64, r.matchScore());
}
@Test void cityMatch_versusDifferentCity() {
Job j = job("本科", "北京");
StudentProfile same = profile("本科", "北京", (short) 2020, null);
StudentProfile diff = profile("本科", "上海", (short) 2020, null);
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult rSame = s.match(j, same, null);
TalentMatchScorer.MatchResult rDiff = s.match(j, diff, null);
assertTrue(rSame.reasons().contains("城市匹配"));
assertTrue(rDiff.reasons().contains("城市待定"));
assertTrue(rSame.matchScore() > rDiff.matchScore());
// 同城 (100×0.25+100×0.10)/0.35=100;异城 (100×0.25+50×0.10)/0.35≈86
assertEquals(100, rSame.matchScore());
assertEquals(86, rDiff.matchScore());
}
@Test void experienceMet_versusLack() {
Job j = job("3年以上经验", "北京");
StudentProfile met = profile("本科", "北京", (short) 2018, null); // 8 年
StudentProfile lack = profile("本科", "北京", (short) 2025, null); // 1 年
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult rMet = s.match(j, met, null);
TalentMatchScorer.MatchResult rLack = s.match(j, lack, null);
assertTrue(rMet.reasons().contains("经验匹配"));
assertTrue(rLack.reasons().contains("经验不足"));
assertTrue(rMet.matchScore() > rLack.matchScore());
}
@Test void backgroundScoreHighAndLow() {
Job j = job("本科", "北京");
StudentProfile p = profile("本科", "北京", (short) 2020, null);
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult rHigh = s.match(j, p, new BigDecimal("90.00"));
TalentMatchScorer.MatchResult rLow = s.match(j, p, new BigDecimal("40.00"));
assertTrue(rHigh.reasons().contains("综合评分高"));
assertTrue(rLow.reasons().contains("综合评分低"));
assertTrue(rHigh.matchScore() > rLow.matchScore());
}
@Test void missingData_shouldNotNpeAndScoreDegraded() {
Job j = job("本科", "北京");
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult r = s.match(j, null, null);
assertEquals(0, r.matchScore());
assertTrue(r.reasons().isEmpty());
}
@Test void allSignalsHit_intScoreAndShortReasons() {
Job j = job("要求本科,熟悉Java,3年经验", "北京");
StudentProfile p = profile("硕士", "北京", (short) 2018,
"[\"Java\",\"SpringBoot\"]");
TalentMatchScorer s = newScorer();
TalentMatchScorer.MatchResult r = s.match(j, p, new BigDecimal("90.00"));
// 整数 0-100
assertTrue(r.matchScore() >= 0 && r.matchScore() <= 100);
assertEquals(r.matchScore(), (int) Math.round(r.matchScore()));
// 每信号至多一句
assertTrue(r.reasons().size() <= 5);
assertTrue(r.reasons().contains("技能匹配"));
assertTrue(r.reasons().contains("学历符合"));
assertTrue(r.reasons().contains("经验匹配"));
assertTrue(r.reasons().contains("城市匹配"));
assertTrue(r.reasons().contains("综合评分高"));
}
}
Loading…
Cancel
Save