岗位列表接口

This commit is contained in:
zk
2026-03-20 18:32:03 +08:00
parent 13520a6e39
commit 28e551285d
14 changed files with 734 additions and 19 deletions
@@ -1,7 +1,12 @@
package org.jiayunet.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.jiayunet.pojo.po.Job;
import org.jiayunet.pojo.vo.JobListItemVo;
import java.util.List;
/**
* 岗位Mapper
@@ -10,4 +15,9 @@ import org.jiayunet.pojo.po.Job;
*/
@Mapper
public interface JobMapper extends CommonMapper<Job> {
/**
* 分页查询岗位列表
*/
Page<JobListItemVo> selectJobPage(Page<JobListItemVo> page, @Param("regionCodes") List<String> regionCodes, @Param("categoryIds") List<Long> categoryIds, @Param("industryIds") List<Long> industryIds, @Param("employmentType") Integer employmentType, @Param("excludeJobIds") List<Long> excludeJobIds, @Param("excludeCompanyIds") List<Long> excludeCompanyIds, @Param("excludeRegionCodes") List<String> excludeRegionCodes, @Param("excludeIndustryIds") List<Long> excludeIndustryIds);
}
@@ -1,11 +1,14 @@
package org.jiayunet.pojo.po;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.time.Instant;
import java.util.List;
/**
* 岗位表(bg_job
@@ -13,7 +16,7 @@ import java.time.Instant;
* @author zk
*/
@Data
@TableName(value = "bg_job")
@TableName(value = "bg_job", autoResultMap = true)
public class Job {
@TableId(type = IdType.ASSIGN_ID)
@@ -41,10 +44,12 @@ public class Job {
private String bonus;
/** 岗位标签(JSON数组) */
private String tags;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> tags;
/** 技能标签(JSON数组) */
private String skillTags;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> skillTags;
/** 薪资描述,如15-25K·13薪、面议 */
private String salary;
@@ -55,8 +60,8 @@ public class Job {
/** 最低工作年限,0=不要求 */
private Integer minExperience;
/** 行业经验,关联bg_industry */
private Long industryId;
/** 要求的行业经验ID,关联bg_industry */
private Long requiredIndustryId;
/** 来源链接 */
private String sourceUrl;
@@ -0,0 +1,65 @@
package org.jiayunet.pojo.vo;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.util.List;
/**
* 岗位列表项VO(SQL查询结果)
*
* @author zk
*/
@Data
public class JobListItemVo {
/** 岗位ID */
private Long id;
/** 岗位名称 */
private String title;
/** 薪资描述 */
private String salary;
/** 公司ID */
private Long companyId;
/** 公司名称 */
private String companyName;
/** 公司简称 */
private String companyShortName;
/** 公司类型 */
private String companyType;
/** 公司Logo */
private String companyLogoUrl;
/** 地区编码 */
private String regionCode;
/** 地区名称 */
private String regionName;
/** 岗位类型ID */
private Long categoryId;
/** 岗位类型名称 */
private String categoryName;
/** 岗位标签 */
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> tags;
/** 来源链接 */
private String sourceUrl;
/** 要求的行业经验ID */
private Long requiredIndustryId;
/** 最低工作年限 */
private Integer minExperience;
}
@@ -260,7 +260,7 @@ public class JobCleanService {
"minExperience": 最低工作年限数字(不要求则0),
"employmentType": 0或1(0=全职 1=兼职,默认0),
"categoryId": 岗位分类ID(必选,从分类列表中选最接近的),
"industryId": 行业ID(仅当明确提到行业经验要求时设置,列表中无完全匹配则选最相似的,未提到则null),
"requiredIndustryId": 行业ID(仅当明确提到行业经验要求时设置,列表中无完全匹配则选最相似的,未提到则null),
"description": "岗位职责,保持原文风格,格式化展示",
"requirement": "任职要求,保持原文风格,格式化展示",
"bonus": "加分项,无则null",
@@ -275,7 +275,7 @@ public class JobCleanService {
2. 岗位标题不存在时,从描述中归纳生成
3. 薪资标准化为 10-20K、20K、面议 等格式,无效或空则null
4. categoryId 必须从分类列表中选一个,不允许为null
5. industryId 仅当描述中明确提到行业经验要求时设置
5. requiredIndustryId 仅当描述中明确提到行业经验要求时设置
6. tags 是核心职能标签(如数据分析、团队协作),最多5个
7. skillTags 是技能关键词(如Java、MySQL),最多8个
8. companyShortName 去掉地区后缀、招聘后缀、括号内容,保持简洁
@@ -61,8 +61,21 @@ public class JobCleanTransactionService {
job.setDescription(root.path("description").asText(""));
job.setRequirement(root.path("requirement").asText(""));
job.setBonus(root.path("bonus").asText(null));
job.setTags(root.path("tags").toString());
job.setSkillTags(root.path("skillTags").toString());
// 解析 JSON 数组为 List<String>
JsonNode tagsNode = root.path("tags");
if (tagsNode.isArray()) {
List<String> tags = new java.util.ArrayList<>();
tagsNode.forEach(node -> tags.add(node.asText()));
job.setTags(tags);
}
JsonNode skillTagsNode = root.path("skillTags");
if (skillTagsNode.isArray()) {
List<String> skillTags = new java.util.ArrayList<>();
skillTagsNode.forEach(node -> skillTags.add(node.asText()));
job.setSkillTags(skillTags);
}
String salary = root.path("salary").asText(null);
job.setSalary("null".equals(salary) ? null : salary);
@@ -70,8 +83,8 @@ public class JobCleanTransactionService {
job.setEducation(root.path("education").asInt(0));
job.setMinExperience(root.path("minExperience").asInt(0));
Long industryId = root.path("industryId").asLong(0);
job.setIndustryId(industryId == 0 ? null : industryId);
Long requiredIndustryId = root.path("requiredIndustryId").asLong(0);
job.setRequiredIndustryId(requiredIndustryId == 0 ? null : requiredIndustryId);
job.setSourceUrl(data.getDetailUrl());
job.setSourceId(sourceId);
@@ -0,0 +1,181 @@
package org.jiayunet.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jiayunet.mapper.*;
import org.jiayunet.pojo.po.*;
import org.jiayunet.pojo.vo.JobListItemVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 岗位匹配度计算服务
* <p>依赖:无</p>
* <p>使用表:bg_user_profile(查询用户简历)、bg_job_skill_tag_relation(查询岗位技能)、bg_user_profile_skill_tag_relation(查询用户技能)、bg_industry(查询行业父级)</p>
*
* @author zk
*/
@Slf4j
@Service
public class JobMatchService {
@Autowired
private UserProfileMapper userProfileMapper;
@Autowired
private JobSkillTagRelationMapper jobSkillTagRelationMapper;
@Autowired
private UserProfileSkillTagRelationMapper userProfileSkillTagRelationMapper;
@Autowired
private IndustryMapper industryMapper;
/**
* 批量计算岗位匹配度
* <p>1. 查询用户简历和技能 2. 批量查询岗位技能 3. 批量查询行业信息 4. 逐个计算匹配度(百分制)5. 加权计算总分</p>
*/
public Map<Long, Map<String, Integer>> batchCalculateMatchScore(List<JobListItemVo> jobs, Long userId) {
if (jobs == null || jobs.isEmpty()) {
return Collections.emptyMap();
}
// 1. 查询用户简历
UserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getUserId, userId));
// 2. 查询用户技能标签(转换为 Set 提升性能)
Set<Long> userSkillTagSet = Collections.emptySet();
if (profile != null) {
List<UserProfileSkillTagRelation> userSkillRelations = userProfileSkillTagRelationMapper.selectList(new LambdaQueryWrapper<UserProfileSkillTagRelation>().eq(UserProfileSkillTagRelation::getUserId, userId));
userSkillTagSet = userSkillRelations.stream().map(UserProfileSkillTagRelation::getSkillTagId).collect(Collectors.toSet());
}
// 3. 批量查询岗位技能标签
List<Long> jobIds = jobs.stream().map(JobListItemVo::getId).collect(Collectors.toList());
List<JobSkillTagRelation> jobSkillRelations = jobSkillTagRelationMapper.selectList(new LambdaQueryWrapper<JobSkillTagRelation>().in(JobSkillTagRelation::getJobId, jobIds));
Map<Long, List<Long>> jobSkillMap = jobSkillRelations.stream().collect(Collectors.groupingBy(JobSkillTagRelation::getJobId, Collectors.mapping(JobSkillTagRelation::getSkillTagId, Collectors.toList())));
// 4. 批量查询行业信息(用于父级匹配)
Set<Long> 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<Long, Industry> industryMap = Collections.emptyMap();
if (!allIndustryIds.isEmpty()) {
List<Industry> industries = industryMapper.selectBatchIds(allIndustryIds);
industryMap = industries.stream().collect(Collectors.toMap(Industry::getId, i -> i));
}
// 5. 逐个计算匹配度
Map<Long, Map<String, Integer>> 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);
// 加权计算总分:行业30% + 技能50% + 经验20%
int totalScore = (int) Math.round(industryScore * 0.3 + skillScore * 0.5 + experienceScore * 0.2);
result.put(job.getId(), createScoreMap(industryScore, skillScore, experienceScore, totalScore));
}
return result;
}
/**
* 计算行业匹配得分(百分制)
* <p>岗位无要求→100分,用户无简历→0分,完全匹配→100分,父级匹配→75分,不匹配→0分</p>
*/
private int calculateIndustryScore(Long jobIndustryId, UserProfile profile, Map<Long, Industry> industryMap) {
// 岗位无要求
if (jobIndustryId == null) {
return 100;
}
// 用户无简历或无行业经验
if (profile == null || profile.getExperienceIndustryIds() == null || profile.getExperienceIndustryIds().isEmpty()) {
return 0;
}
List<Long> userIndustryIds = profile.getExperienceIndustryIds();
// 完全匹配
if (userIndustryIds.contains(jobIndustryId)) {
return 100;
}
// 父级匹配
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;
}
}
}
return 0;
}
/**
* 计算技能匹配得分(百分制)
* <p>岗位无要求→100分,用户无技能→0分,匹配公式:(匹配数量 / 岗位要求数量) * 100</p>
*/
private int calculateSkillScore(Long jobId, List<Long> jobSkillTagIds, Set<Long> 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);
}
/**
* 计算经验匹配得分(百分制)
* <p>岗位无要求→100分,用户无简历→0分,计算公式:min(max((workYears - minExp) / minExp * 0.8 + 0.2, 0), 1.0) * 100</p>
*/
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<String, Integer> createScoreMap(int industryScore, int skillScore, int experienceScore, int totalScore) {
Map<String, Integer> map = new HashMap<>();
map.put("industryScore", industryScore);
map.put("skillScore", skillScore);
map.put("experienceScore", experienceScore);
map.put("totalScore", totalScore);
return map;
}
}
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jiayunet.mapper.JobMapper">
<select id="selectJobPage" resultType="org.jiayunet.pojo.vo.JobListItemVo">
SELECT
j.id,
j.title,
j.salary,
j.tags,
j.source_url,
j.required_industry_id,
j.min_experience,
c.id AS company_id,
c.name AS company_name,
c.short_name AS company_short_name,
c.company_type,
c.logo_url AS company_logo_url,
c.region_code,
r.name AS region_name,
cat.id AS category_id,
cat.name AS category_name
FROM bg_job j
INNER JOIN bg_company c ON j.company_id = c.id
INNER JOIN bg_china_regions_code r ON c.region_code = r.code
INNER JOIN bg_job_category cat ON j.category_id = cat.id
WHERE j.status = 0
AND c.status = 1
<!-- 排除不感兴趣的岗位 -->
<if test="excludeJobIds != null and excludeJobIds.size() > 0">
AND j.id NOT IN
<foreach collection="excludeJobIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
<!-- 排除不感兴趣的公司 -->
<if test="excludeCompanyIds != null and excludeCompanyIds.size() > 0">
AND c.id NOT IN
<foreach collection="excludeCompanyIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
<!-- 排除不感兴趣的地区(含子级) -->
<if test="excludeRegionCodes != null and excludeRegionCodes.size() > 0">
AND c.region_code NOT IN
<foreach collection="excludeRegionCodes" item="code" open="(" separator="," close=")">
#{code}
</foreach>
</if>
<!-- 排除不感兴趣的行业(含子级) -->
<if test="excludeIndustryIds != null and excludeIndustryIds.size() > 0">
AND (j.required_industry_id IS NULL OR j.required_industry_id NOT IN
<foreach collection="excludeIndustryIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>)
</if>
<!-- 筛选条件:地区(含子级) -->
<if test="regionCodes != null and regionCodes.size() > 0">
AND c.region_code IN
<foreach collection="regionCodes" item="code" open="(" separator="," close=")">
#{code}
</foreach>
</if>
<!-- 筛选条件:岗位类型(含子级) -->
<if test="categoryIds != null and categoryIds.size() > 0">
AND j.category_id IN
<foreach collection="categoryIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
<!-- 筛选条件:行业(含子级) -->
<if test="industryIds != null and industryIds.size() > 0">
AND j.required_industry_id IN
<foreach collection="industryIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
<!-- 筛选条件:工作类型 -->
<if test="employmentType != null">
AND j.employment_type = #{employmentType}
</if>
ORDER BY j.create_time DESC
</select>
</mapper>