From 21b853b7598ae6ba4a34c9fe350f46911c6b03af Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 2 Sep 2026 16:06:32 +0800 Subject: [PATCH] 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 --- docs/company-candidate-api.md | 9 +- docs/frontend-integration.md | 5 + .../application/ApplicationController.java | 9 +- .../application/ApplicationRepository.java | 19 ++ .../application/ApplicationService.java | 132 ++++++++---- .../application/dto/CandidateItemView.java | 9 + .../recommend/TalentMatchScorer.java | 191 ++++++++++++++++++ .../src/main/resources/application.yml | 8 + .../ApplicationControllerTest.java | 8 +- .../application/ApplicationServiceTest.java | 26 ++- .../recommend/TalentMatchScorerTest.java | 130 ++++++++++++ 11 files changed, 495 insertions(+), 51 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/recommend/TalentMatchScorer.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/recommend/TalentMatchScorerTest.java diff --git a/docs/company-candidate-api.md b/docs/company-candidate-api.md index 939320c..9f9a73a 100644 --- a/docs/company-candidate-api.md +++ b/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 属企业内部筛选信息,学生侧不可见。 + ### 错误码 | 状态码 | 错误码 | 说明 | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 632e707..7e6a086 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -166,6 +166,11 @@ Authorization: Bearer { "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,可稀疏提交): diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java index f719074..ee2a98e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java +++ b/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 ALLOWED_STATUSES = Set.of( "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); - private static final Set ALLOWED_SORTS = Set.of("createdAt", "updatedAt"); + private static final Set ALLOWED_SORTS = Set.of("createdAt", "updatedAt", "match"); private static final Set ALLOWED_ORDERS = Set.of("asc", "desc"); // ── 学生投递 ────────────────────────────────────────────── @@ -151,7 +152,7 @@ public class ApplicationController { @Operation(summary = "企业候选人列表", description = "跨岗位查询本企业所有候选人投递,支持按岗位、状态、关键词筛选,支持分页和排序") @GetMapping("/company/candidates") - public ResponseEntity> listCompanyCandidates( + public ResponseEntity>> 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 data = applicationService.listCompanyCandidates( company.getId(), jobId, status, keyword, sort, order, page, size); return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java index d175cc3..648bffd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java @@ -84,4 +84,23 @@ public interface ApplicationRepository extends JpaRepository @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 findCompanyCandidatesForMatch(@Param("companyId") Long companyId, + @Param("jobId") Long jobId, + @Param("status") String status, + @Param("keyword") String keyword); } 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 3552278..79efae1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/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 ALLOWED_STATUSES = Set.of( "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); - private static final Set ALLOWED_SORTS = Set.of("createdAt", "updatedAt"); + private static final Set ALLOWED_SORTS = Set.of("createdAt", "updatedAt", "match"); private static final Set 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 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 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 content = result.getContent(); + // 批量取本页候选人的综合评分,供 matchScore 计算使用(首项已过滤,无 N+1) + List pageStudentIds = content.stream() + .map(row -> ((Application) row[0]).getStudentUserId()) + .distinct().collect(Collectors.toList()); + List 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 listCompanyCandidatesByMatch(Long companyId, Long jobId, String status, + String keyword, int page, int size) { + List rows = applicationRepository.findCompanyCandidatesForMatch(companyId, jobId, status, keyword); + List studentUserIds = rows.stream() + .map(row -> ((Application) row[0]).getStudentUserId()) + .distinct().collect(Collectors.toList()); + Map overallByStudent = overallScores(studentUserIds); + + List 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 items = all.subList(from, to); + return PageResponse.of(items, all.size(), page, size); + } + + /** 由候选人列表项原始 JOIN 行构建视图;始终补充 matchScore/matchReasons(对任意排序一致返回) */ + private CandidateItemView buildItem(Object[] row, Map 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 overallScores(List studentUserIds) { + if (studentUserIds.isEmpty()) return Collections.emptyMap(); + String asOf = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")); + Map> byStudent = dimensionScoreRepository + .findUpToPeriod(studentUserIds, asOf).stream() + .collect(Collectors.groupingBy(CandidateDimensionScore::getStudentUserId)); + + Map result = new HashMap<>(); + for (Map.Entry> e : byStudent.entrySet()) { + String latest = e.getValue().stream() + .map(CandidateDimensionScore::getPeriod) + .max(Comparator.naturalOrder()).orElse(null); + if (latest == null) continue; + List latestSnapshots = e.getValue().stream() + .filter(s -> latest.equals(s.getPeriod())).collect(Collectors.toList()); + result.put(e.getKey(), scoringCalculator.overallScoreFromSnapshots(latestSnapshots)); + } + return result; } /** 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 dee412d..18d8125 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 @@ -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 matchReasons; } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommend/TalentMatchScorer.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommend/TalentMatchScorer.java new file mode 100644 index 0000000..4801e64 --- /dev/null +++ b/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`)。 + * + *

由 5 个加权信号组成(技能/学历/经验/城市/背景参考),每信号 0-100, + * 汇总后取整为 `matchScore`(0-100,四舍五入)。纯确定性规则,无 AI。 + * 信号权重来自 {@code app.talent-match.weight.*} 配置,总和约定为 1.00。

+ */ +@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)。 + * + *

采用「已知信号加权归一化」:仅对两端数据都具备的信号计分, + * 缺失/无法判断的信号不参与(不因数据缺失而扣分)。 + * 分值 = Σ(已知信号得分 × 权重) / Σ(已知权重),并夹取到 [0,100]。 + * + * @return matchScore(0-100 整数,不会低于 0 或超过 100)与简短理由数组 + */ + public MatchResult match(Job job, StudentProfile profile, BigDecimal overallScore) { + List 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 reasons) { + if (profile == null || isBlank(profile.getSkillsJson())) return null; + if (job == null || isBlank(job.getRequirements())) return null; + List 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 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 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 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 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 parseSkills(String json) { + if (isBlank(json)) return List.of(); + try { + List list = MAPPER.readValue(json, new TypeReference>() {}); + 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 reasons) {} +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 213f600..4888c0f 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/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: diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java index 1469230..af989fe 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java +++ b/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 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 resp = PageResponse.of(List.of(item), 1, 0, 20); when(applicationService.listCompanyCandidates(anyLong(), isNull(), isNull(), isNull(), isNull(), isNull(), anyInt(), anyInt())) .thenReturn(resp); 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 1c8b498..ea23390 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/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 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 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 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 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 resp = applicationService.listCompanyCandidates( COMPANY_ID, null, null, null, "updatedAt", "asc", 0, 20); assertEquals(1, resp.getItems().size()); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/recommend/TalentMatchScorerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/recommend/TalentMatchScorerTest.java new file mode 100644 index 0000000..ca9a9c4 --- /dev/null +++ b/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("综合评分高")); + } +} \ No newline at end of file