diff --git a/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobDto.java b/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobDto.java index de7838a..8787491 100644 --- a/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobDto.java +++ b/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobDto.java @@ -54,7 +54,7 @@ public class JobDto { /** 岗位状态(0=有效 1=已下架 2=已过期) */ private Integer status; - /** 匹配总分(0-90) */ + /** 匹配总分(0-100) */ private Integer matchScore; /** 匹配度详情 */ diff --git a/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobMatchScoreDto.java b/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobMatchScoreDto.java index b35c957..92840e9 100644 --- a/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobMatchScoreDto.java +++ b/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobMatchScoreDto.java @@ -14,12 +14,12 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class JobMatchScoreDto { - /** 行业得分(0-100,百分制) */ - private Integer industryScore; + /** 教育得分(0-100,百分制) */ + private Integer educationScore; /** 技能得分(0-100,百分制) */ private Integer skillScore; - /** 经验得分(0-100,百分制) */ + /** 经历得分(0-100,百分制) */ private Integer experienceScore; } diff --git a/client-api/src/main/java/org/jiayunet/service/JobService.java b/client-api/src/main/java/org/jiayunet/service/JobService.java index bb1cbba..f2609b4 100644 --- a/client-api/src/main/java/org/jiayunet/service/JobService.java +++ b/client-api/src/main/java/org/jiayunet/service/JobService.java @@ -126,7 +126,7 @@ public class JobService { dto.setStatus(vo.getStatus()); Map scoreMap = matchScoreMap.get(vo.getId()); if (scoreMap != null) { - JobMatchScoreDto matchScore = new JobMatchScoreDto(scoreMap.get("industryScore"), scoreMap.get("skillScore"), scoreMap.get("experienceScore")); + JobMatchScoreDto matchScore = new JobMatchScoreDto(scoreMap.get("educationScore"), scoreMap.get("skillScore"), scoreMap.get("experienceScore")); dto.setMatchScore(scoreMap.get("totalScore")); dto.setMatchDetail(matchScore); } else { @@ -228,6 +228,8 @@ public class JobService { item.setId(job.getId()); item.setRequiredIndustryId(job.getRequiredIndustryId()); item.setMinExperience(job.getMinExperience()); + item.setRequiredMajorIds(job.getRequiredMajorIds()); + item.setMajorSensitivity(job.getMajorSensitivity()); jobList.add(item); Map> matchScoreMap = jobMatchService.batchCalculateMatchScore(jobList, userId); Map scoreMap = matchScoreMap.get(jobId); @@ -290,7 +292,7 @@ public class JobService { dto.setIsFavorite(count > 0); if (scoreMap != null) { dto.setMatchScore(scoreMap.get("totalScore")); - dto.setMatchDetail(new JobMatchScoreDto(scoreMap.get("industryScore"), scoreMap.get("skillScore"), scoreMap.get("experienceScore"))); + dto.setMatchDetail(new JobMatchScoreDto(scoreMap.get("educationScore"), scoreMap.get("skillScore"), scoreMap.get("experienceScore"))); } else { dto.setMatchScore(0); dto.setMatchDetail(new JobMatchScoreDto(0, 0, 0)); diff --git a/manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java b/manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java index b05bf22..528071b 100644 --- a/manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java +++ b/manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java @@ -63,6 +63,13 @@ public class JobListItemVo { /** 最低工作年限 */ private Integer minExperience; + /** 要求专业ID数组(JSON) */ + @TableField(typeHandler = JacksonTypeHandler.class) + private List requiredMajorIds; + + /** 专业敏感度 0=不限 1=优先 2=强制 */ + private Integer majorSensitivity; + /** 岗位状态(0=有效 1=已下架 2=已过期) */ private Integer status; } diff --git a/manager/src/main/java/org/jiayunet/service/JobMatchService.java b/manager/src/main/java/org/jiayunet/service/JobMatchService.java index 4fda888..a9866c7 100644 --- a/manager/src/main/java/org/jiayunet/service/JobMatchService.java +++ b/manager/src/main/java/org/jiayunet/service/JobMatchService.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.jiayunet.mapper.*; import org.jiayunet.pojo.po.*; import org.jiayunet.pojo.vo.JobListItemVo; +import org.jiayunet.pojo.vo.UserHonorsVo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -13,8 +14,10 @@ import java.util.stream.Collectors; /** * 岗位匹配度计算服务 + *

主要功能:根据用户简历和岗位信息,计算教育/经历/技能三维度匹配分

*

依赖:无

- *

使用表:bg_user_profile(查询用户简历)、bg_job_skill_tag_relation(查询岗位技能)、bg_user_profile_skill_tag_relation(查询用户技能)、bg_industry(查询行业父级)

+ *

使用表:bg_user_profile(查询用户简历维度数据)、bg_job_skill_tag_relation(查询岗位技能)、 + * bg_user_profile_skill_tag_relation(查询用户技能)、bg_major_category(专业树形匹配)

* * @author zk */ @@ -32,11 +35,11 @@ public class JobMatchService { private UserProfileSkillTagRelationMapper userProfileSkillTagRelationMapper; @Autowired - private IndustryMapper industryMapper; + private MajorCategoryMapper majorCategoryMapper; /** * 批量计算岗位匹配度 - *

1. 查询用户简历和技能 2. 批量查询岗位技能 3. 批量查询行业信息 4. 逐个计算匹配度(百分制)5. 加权计算总分

+ *

1. 查询用户简历 2. 查询用户技能 3. 批量查询岗位技能 4. 批量查询专业信息 5. 逐个计算三维度分数 6. 加权计算总分

*/ public Map> batchCalculateMatchScore(List jobs, Long userId) { if (jobs == null || jobs.isEmpty()) { @@ -58,124 +61,241 @@ public class JobMatchService { List jobSkillRelations = jobSkillTagRelationMapper.selectList(new LambdaQueryWrapper().in(JobSkillTagRelation::getJobId, jobIds)); Map> jobSkillMap = jobSkillRelations.stream().collect(Collectors.groupingBy(JobSkillTagRelation::getJobId, Collectors.mapping(JobSkillTagRelation::getSkillTagId, Collectors.toList()))); - // 4. 批量查询行业信息(用于父级匹配) - Set allIndustryIds = new HashSet<>(); - if (profile != null && profile.getExperienceIndustryIds() != null) { - allIndustryIds.addAll(profile.getExperienceIndustryIds()); - } - jobs.stream().map(JobListItemVo::getRequiredIndustryId).filter(Objects::nonNull).forEach(allIndustryIds::add); - Map industryMap = Collections.emptyMap(); - if (!allIndustryIds.isEmpty()) { - List industries = industryMapper.selectBatchIds(allIndustryIds); - industryMap = industries.stream().collect(Collectors.toMap(Industry::getId, i -> i)); - } + // 4. 批量查询专业信息(用于树形匹配) + Map majorMap = loadMajorMap(profile, jobs); // 5. 逐个计算匹配度 Map> result = new HashMap<>(); for (JobListItemVo job : jobs) { - int industryScore = calculateIndustryScore(job.getRequiredIndustryId(), profile, industryMap); - int skillScore = calculateSkillScore(job.getId(), jobSkillMap.get(job.getId()), userSkillTagSet); - int experienceScore = calculateExperienceScore(job.getMinExperience(), profile); + int educationScore = calculateEducationScore(profile, job, majorMap); + int experienceScore = calculateExperienceScore(profile); + int skillScore = calculateSkillScore(jobSkillMap.get(job.getId()), userSkillTagSet); - // 加权计算总分:行业30% + 技能50% + 经验20% - int totalScore = (int) Math.round(industryScore * 0.3 + skillScore * 0.5 + experienceScore * 0.2); + // 加权计算总分:教育30% + 经历40% + 技能30% + int totalScore = (int) Math.round(educationScore * 0.3 + experienceScore * 0.4 + skillScore * 0.3); - result.put(job.getId(), createScoreMap(industryScore, skillScore, experienceScore, totalScore)); + Map map = new HashMap<>(); + map.put("educationScore", educationScore); + map.put("experienceScore", experienceScore); + map.put("skillScore", skillScore); + map.put("totalScore", totalScore); + result.put(job.getId(), map); } return result; } /** - * 计算行业匹配得分(百分制) - *

岗位无要求→100分,用户无简历→0分,完全匹配→100分,父级匹配→75分,不匹配→0分

+ * 批量加载专业信息Map */ - private int calculateIndustryScore(Long jobIndustryId, UserProfile profile, Map industryMap) { - // 岗位无要求 - if (jobIndustryId == null) { - return 100; + private Map loadMajorMap(UserProfile profile, List jobs) { + Set allMajorIds = new HashSet<>(); + if (profile != null && profile.getMajorIds() != null) { + allMajorIds.addAll(profile.getMajorIds()); } + for (JobListItemVo job : jobs) { + if (job.getRequiredMajorIds() != null) { + allMajorIds.addAll(job.getRequiredMajorIds()); + } + } + if (allMajorIds.isEmpty()) { + return Collections.emptyMap(); + } + List majors = majorCategoryMapper.selectBatchIds(allMajorIds); + return majors.stream().collect(Collectors.toMap(MajorCategory::getId, m -> m)); + } - // 用户无简历或无行业经验 - if (profile == null || profile.getExperienceIndustryIds() == null || profile.getExperienceIndustryIds().isEmpty()) { + /** + * 计算教育维度得分(百分制) + *

根据 majorSensitivity 决定学校分和专业分的权重

+ */ + private int calculateEducationScore(UserProfile profile, JobListItemVo job, Map majorMap) { + if (profile == null) { return 0; } - List userIndustryIds = profile.getExperienceIndustryIds(); + // 学校等级分 + int schoolScore = mapSchoolRank(profile.getSchoolRank()); - // 完全匹配 - if (userIndustryIds.contains(jobIndustryId)) { - return 100; + // 专业敏感度 + Integer sensitivity = job.getMajorSensitivity(); + if (sensitivity == null) sensitivity = 0; + + // 不限专业 → 教育分 = 学校分 + if (sensitivity == 0 || job.getRequiredMajorIds() == null || job.getRequiredMajorIds().isEmpty()) { + return schoolScore; } - // 父级匹配 - Industry jobIndustry = industryMap.get(jobIndustryId); - if (jobIndustry != null && jobIndustry.getParentId() != null && jobIndustry.getParentId() != 0) { - for (Long userIndustryId : userIndustryIds) { - Industry userIndustry = industryMap.get(userIndustryId); - if (userIndustry != null && userIndustry.getParentId() != null && userIndustry.getParentId().equals(jobIndustry.getParentId())) { - return 75; - } + // 计算专业相关度 + int majorScore = calculateMajorScore(profile.getMajorIds(), job.getRequiredMajorIds(), majorMap); + + int educationScore; + if (sensitivity == 2) { + // 强制要求:学校40% + 专业60% + educationScore = (int) Math.round(schoolScore * 0.4 + majorScore * 0.6); + // 专业分<40 → 强制降权 + if (majorScore < 40) { + educationScore = (int) Math.round(educationScore * 0.5); + } + } else { + // 优先要求:学校60% + 专业40% + educationScore = (int) Math.round(schoolScore * 0.6 + majorScore * 0.4); + } + + return educationScore; + } + + /** + * 计算专业相关度(百分制) + *

遍历用户专业 × 岗位要求专业,取最高匹配分

+ */ + private int calculateMajorScore(List userMajorIds, List jobMajorIds, Map majorMap) { + if (userMajorIds == null || userMajorIds.isEmpty()) { + return 0; + } + + int maxScore = 0; + for (Long userMajorId : userMajorIds) { + MajorCategory userMajor = majorMap.get(userMajorId); + if (userMajor == null) continue; + + for (Long jobMajorId : jobMajorIds) { + MajorCategory jobMajor = majorMap.get(jobMajorId); + if (jobMajor == null) continue; + + int score = matchMajorPair(userMajor, jobMajor); + maxScore = Math.max(maxScore, score); + if (maxScore == 100) return 100; // 已满分,提前退出 } } + return maxScore; + } + /** + * 匹配一对专业的相关度 + *

完全匹配→100,同二级→70,同一级→30,不相关→0

+ */ + private int matchMajorPair(MajorCategory userMajor, MajorCategory jobMajor) { + // 完全匹配 + if (userMajor.getId().equals(jobMajor.getId())) { + return 100; + } + // 同二级专业类(parentId 相同) + if (userMajor.getParentId() != null && userMajor.getParentId().equals(jobMajor.getParentId())) { + return 70; + } + // 同一级学科门类(rootId 相同) + if (userMajor.getRootId() != null && userMajor.getRootId().equals(jobMajor.getRootId())) { + return 30; + } return 0; } + /** 学校等级映射 */ + private int mapSchoolRank(Integer rank) { + if (rank == null) return 0; + return switch (rank) { + case 1 -> 100; + case 2 -> 80; + case 3 -> 60; + case 4 -> 40; + default -> 0; + }; + } + + /** + * 计算经历维度得分(百分制) + *

Sexp = 公司背书×30% + 时长×10% + 经历深度×60%

+ *

经历深度 = 职责深度×40% + 量化产出×30% + 荣誉×30%

+ */ + private int calculateExperienceScore(UserProfile profile) { + if (profile == null) { + return 0; + } + + int companyScore = mapCompanyPrestige(profile.getCompanyPrestige()); + int durationScore = mapExperienceDuration(profile.getExperienceDuration()); + + // 经历深度 + int roleScore = mapRoleDepth(profile.getRoleDepth()); + int outputScore = mapOutputQuality(profile.getOutputQuality()); + int honorsScore = calculateHonorsScore(profile.getHonors()); + int depthScore = (int) Math.round(roleScore * 0.4 + outputScore * 0.3 + honorsScore * 0.3); + + return (int) Math.round(companyScore * 0.3 + durationScore * 0.1 + depthScore * 0.6); + } + + /** 公司背书映射 */ + private int mapCompanyPrestige(Integer prestige) { + if (prestige == null) return 0; + return switch (prestige) { + case 1 -> 100; + case 2 -> 60; + case 3 -> 30; + case 4 -> 0; + default -> 0; + }; + } + + /** 经历时长映射 */ + private int mapExperienceDuration(Integer duration) { + if (duration == null) return 0; + return switch (duration) { + case 1 -> 100; + case 2 -> 60; + case 3 -> 30; + default -> 0; + }; + } + + /** 职责深度映射 */ + private int mapRoleDepth(Integer depth) { + if (depth == null) return 0; + return switch (depth) { + case 1 -> 100; + case 2 -> 80; + case 3 -> 40; + default -> 0; + }; + } + + /** 量化产出映射 */ + private int mapOutputQuality(Integer quality) { + if (quality == null) return 0; + return switch (quality) { + case 1 -> 100; + case 2 -> 70; + case 3 -> 40; + default -> 0; + }; + } + + /** + * 计算荣誉得分(封顶累加制,总分不超过100) + *

national每项20、provincial每项10、school每项5、paper每项15

+ */ + private int calculateHonorsScore(UserHonorsVo honors) { + if (honors == null) return 0; + int score = 0; + if (honors.getNational() != null) score += honors.getNational().size() * 20; + if (honors.getProvincial() != null) score += honors.getProvincial().size() * 10; + if (honors.getSchool() != null) score += honors.getSchool().size() * 5; + if (honors.getPaper() != null) score += honors.getPaper().size() * 15; + return Math.min(score, 100); + } + /** * 计算技能匹配得分(百分制) *

岗位无要求→100分,用户无技能→0分,匹配公式:(匹配数量 / 岗位要求数量) * 100

*/ - private int calculateSkillScore(Long jobId, List jobSkillTagIds, Set userSkillTagSet) { - // 岗位无要求 + private int calculateSkillScore(List jobSkillTagIds, Set userSkillTagSet) { if (jobSkillTagIds == null || jobSkillTagIds.isEmpty()) { return 100; } - - // 用户无技能 if (userSkillTagSet == null || userSkillTagSet.isEmpty()) { return 0; } - - // 计算匹配数量(Set.contains 是 O(1),性能优于 List.contains 的 O(n)) long matchedCount = jobSkillTagIds.stream().filter(userSkillTagSet::contains).count(); - double ratio = (double) matchedCount / jobSkillTagIds.size(); - - return (int) Math.round(ratio * 100); - } - - /** - * 计算经验匹配得分(百分制) - *

岗位无要求→100分,用户无简历→0分,计算公式:min(max((workYears - minExp) / minExp * 0.8 + 0.2, 0), 1.0) * 100

- */ - private int calculateExperienceScore(Integer minExperience, UserProfile profile) { - // 岗位无要求 - if (minExperience == null || minExperience == 0) { - return 100; - } - - // 用户无简历或无工作年限 - if (profile == null || profile.getWorkYears() == null) { - return 0; - } - - int workYears = profile.getWorkYears(); - double ratio = (double) (workYears - minExperience) / minExperience * 0.8 + 0.2; - // 确保分数在 0-1 之间 - ratio = Math.max(0, Math.min(ratio, 1.0)); - - return (int) Math.round(ratio * 100); - } - - /** - * 创建得分Map - */ - private Map createScoreMap(int industryScore, int skillScore, int experienceScore, int totalScore) { - Map map = new HashMap<>(); - map.put("industryScore", industryScore); - map.put("skillScore", skillScore); - map.put("experienceScore", experienceScore); - map.put("totalScore", totalScore); - return map; + return (int) Math.round((double) matchedCount / jobSkillTagIds.size() * 100); } } diff --git a/manager/src/main/resources/mapper/JobMapper.xml b/manager/src/main/resources/mapper/JobMapper.xml index e6a86f7..b3fecd6 100644 --- a/manager/src/main/resources/mapper/JobMapper.xml +++ b/manager/src/main/resources/mapper/JobMapper.xml @@ -10,6 +10,8 @@ + + @@ -31,6 +33,8 @@ j.source_url, j.required_industry_id, j.min_experience, + j.required_major_ids, + j.major_sensitivity, j.status, c.id AS company_id, c.name AS company_name,