岗位列表接口

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
+3 -3
View File
@@ -120,7 +120,7 @@ UPDATE app_job_data SET clean_status=0 WHERE clean_status=1 AND updated_at < NOW
"minExperience": 3, "minExperience": 3,
"employmentType": 0, "employmentType": 0,
"categoryId": 12, "categoryId": 12,
"industryId": 5, "requiredIndustryId": 5,
"description": "1. 负责核心业务系统开发...", "description": "1. 负责核心业务系统开发...",
"requirement": "1. 本科及以上学历...", "requirement": "1. 本科及以上学历...",
"bonus": "1. 有分布式系统经验优先...", "bonus": "1. 有分布式系统经验优先...",
@@ -142,7 +142,7 @@ UPDATE app_job_data SET clean_status=0 WHERE clean_status=1 AND updated_at < NOW
| minExperience | experience + description | 提取最低年限数值,不要求则为0 | | minExperience | experience + description | 提取最低年限数值,不要求则为0 |
| employmentType | description | 判断 0=全职 1=兼职,默认0 | | employmentType | description | 判断 0=全职 1=兼职,默认0 |
| categoryId | description + job_title | 必选,从分类列表中选最接近的,不允许返回null | | categoryId | description + job_title | 必选,从分类列表中选最接近的,不允许返回null |
| industryId | description(任职要求部分) | 仅当明确提到行业经验要求时设置;列表中无完全匹配则选最相似的;未提到则null | | requiredIndustryId | description(任职要求部分) | 仅当明确提到行业经验要求时设置;列表中无完全匹配则选最相似的;未提到则null |
| description | description + experience + education | 提取"岗位职责"部分,保持原文风格,格式化展示 | | description | description + experience + education | 提取"岗位职责"部分,保持原文风格,格式化展示 |
| requirement | description + experience + education | 提取"任职要求"部分,保持原文风格,格式化展示 | | requirement | description + experience + education | 提取"任职要求"部分,保持原文风格,格式化展示 |
| bonus | description + experience + education | 提取"加分项"部分,无则空 | | bonus | description + experience + education | 提取"加分项"部分,无则空 |
@@ -205,7 +205,7 @@ CREATE TABLE bg_job_skill_tag_relation (
| 分类/行业列表怎么给AI | 直接传 id:name 文本 | ID人工维护为短数字,token消耗可控 | | 分类/行业列表怎么给AI | 直接传 id:name 文本 | ID人工维护为短数字,token消耗可控 |
| 地区匹配方式 | AI输出城市名,Java侧匹配 | 城市名无歧义,不需要传参考列表 | | 地区匹配方式 | AI输出城市名,Java侧匹配 | 城市名无歧义,不需要传参考列表 |
| categoryId 是否可空 | 不可空,必须选一个 | 岗位分类是核心维度 | | categoryId 是否可空 | 不可空,必须选一个 | 岗位分类是核心维度 |
| industryId 何时设置 | 仅描述中明确提到行业经验时 | 行业经验是任职要求,不是所有岗位都有 | | requiredIndustryId 何时设置 | 仅描述中明确提到行业经验要求时 | 行业经验是任职要求,不是所有岗位都有 |
| tags 定位 | 核心职能标签,最多5个 | 区别于福利标签,体现岗位核心能力要求 | | tags 定位 | 核心职能标签,最多5个 | 区别于福利标签,体现岗位核心能力要求 |
| skillTags 数量 | 最多8个 | 控制数量,保持精炼 | | skillTags 数量 | 最多8个 | 控制数量,保持精炼 |
| source_id 取值 | app_job_data.id | 简单直接,用于去重 | | source_id 取值 | app_job_data.id | 简单直接,用于去重 |
+62 -5
View File
@@ -23,7 +23,8 @@ offerpie/back-end
│ │ ├─ LoginController.java # 登录相关接口(发送验证码、短信登录) │ │ ├─ LoginController.java # 登录相关接口(发送验证码、短信登录)
│ │ ├─ RouteMenuController.java # 路由菜单接口(获取用户有效菜单树) │ │ ├─ RouteMenuController.java # 路由菜单接口(获取用户有效菜单树)
│ │ ├─ UserProfileController.java # 用户个人资料接口(主表+5张子表的查询与保存) │ │ ├─ UserProfileController.java # 用户个人资料接口(主表+5张子表的查询与保存)
│ │ ─ JobIntentionController.java # 求职意向接口(查询与保存) │ │ ─ JobIntentionController.java # 求职意向接口(查询与保存)
│ │ └─ JobController.java # 岗位接口(岗位列表查询)
│ ├─ service/ │ ├─ service/
│ │ ├─ LoginService.java # 登录业务逻辑(验证码校验、自动注册、JWT生成、Cookie设置) │ │ ├─ LoginService.java # 登录业务逻辑(验证码校验、自动注册、JWT生成、Cookie设置)
│ │ ├─ UserRegisterService.java # 用户注册服务(注册逻辑、邀请码生成与绑定) │ │ ├─ UserRegisterService.java # 用户注册服务(注册逻辑、邀请码生成与绑定)
@@ -31,15 +32,16 @@ offerpie/back-end
│ │ ├─ RouteMenuService.java # 路由菜单服务(查询、添加库存、获取用户菜单树) │ │ ├─ RouteMenuService.java # 路由菜单服务(查询、添加库存、获取用户菜单树)
│ │ ├─ UserProfileService.java # 用户个人资料服务(主表+5张子表的CRUD) │ │ ├─ UserProfileService.java # 用户个人资料服务(主表+5张子表的CRUD)
│ │ ├─ JobIntentionService.java # 求职意向服务(查询与保存/更新) │ │ ├─ JobIntentionService.java # 求职意向服务(查询与保存/更新)
│ │ ├─ JobService.java # 岗位服务(岗位列表查询、匹配度计算编排)
│ │ └─ WxPayNotifyMessageAbstractImpl.java # 微信支付回调实现 │ │ └─ WxPayNotifyMessageAbstractImpl.java # 微信支付回调实现
│ └─ pojo/ │ └─ pojo/
│ ├─ param/ │ ├─ param/
│ │ ├─ userProfile/ # 个人资料入参(UserProfileParam、各子表Param │ │ ├─ userProfile/ # 个人资料入参(UserProfileParam、各子表Param
│ │ └─ job/ # 岗位相关入参(JobIntentionParam │ │ └─ job/ # 岗位相关入参(JobIntentionParam、JobQueryParam
│ ├─ dto/ │ ├─ dto/
│ │ ├─ SmsLoginDto.java # 短信登录入参(mobileNumber + code + inviteCode │ │ ├─ SmsLoginDto.java # 短信登录入参(mobileNumber + code + inviteCode
│ │ ├─ userProfile/ # 个人资料出参(UserProfileDto、各子表Dto │ │ ├─ userProfile/ # 个人资料出参(UserProfileDto、各子表Dto
│ │ └─ job/ # 岗位相关出参(JobIntentionDto │ │ └─ job/ # 岗位相关出参(JobIntentionDto、JobDto、JobMatchScoreDto
│ └─ vo/ │ └─ vo/
│ ├─ LoginVo.java # 登录返回(userId + nick │ ├─ LoginVo.java # 登录返回(userId + nick
│ └─ RouteMenuVo.java # 路由菜单树形VO(含children子菜单) │ └─ RouteMenuVo.java # 路由菜单树形VO(含children子菜单)
@@ -127,8 +129,10 @@ offerpie/back-end
│ │ ├─ UserProfileCompetition.java # 用户竞赛经历表(bg_user_profile_competition │ │ ├─ UserProfileCompetition.java # 用户竞赛经历表(bg_user_profile_competition
│ │ ├─ UserProfileSkillTagRelation.java # 用户技能标签关联表(bg_user_profile_skill_tag_relation │ │ ├─ UserProfileSkillTagRelation.java # 用户技能标签关联表(bg_user_profile_skill_tag_relation
│ │ └─ AppJobData.java # 爬虫岗位原始数据表(app_job_data │ │ └─ AppJobData.java # 爬虫岗位原始数据表(app_job_data
│ └─ vo/ # ViewObjectOssUrlVo、DescriptionParagraph 等) │ └─ vo/ # ViewObjectOssUrlVo、DescriptionParagraph、JobListItemVo 等)
service/ # 业务 ServiceOssService、SmsService、DictCacheService、JobCleanService、JobCleanTransactionService、CompanyCleanService、CompanyCleanTransactionService、UserSkillTagMatchService 等) resources/mapper/ # MyBatis XML 映射文件
│ └─ JobMapper.xml # 岗位自定义SQLselectJobPage
└─ service/ # 业务 ServiceOssService、SmsService、DictCacheService、JobCleanService、JobCleanTransactionService、CompanyCleanService、CompanyCleanTransactionService、UserSkillTagMatchService、JobMatchService 等)
``` ```
> **设计理念** – 业务实体和 Mapper 位于 `manager`B 端和 C 端共享;C 端特有的注解、切面、权限服务、路由菜单服务位于 `client-api`,避免 B 端误用;`common` 提供统一的技术支撑。 > **设计理念** – 业务实体和 Mapper 位于 `manager`B 端和 C 端共享;C 端特有的注解、切面、权限服务、路由菜单服务位于 `client-api`,避免 B 端误用;`common` 提供统一的技术支撑。
@@ -233,6 +237,59 @@ offerpie/back-end
- 权限体系分两层:前端路由控制菜单可见性,后端切面控制功能点权限与库存扣减。 - 权限体系分两层:前端路由控制菜单可见性,后端切面控制功能点权限与库存扣减。
- 权限和菜单作为商品维度,框架只负责校验和库存管理,不关心权限来源。 - 权限和菜单作为商品维度,框架只负责校验和库存管理,不关心权限来源。
## 4.7️⃣ 岗位列表与匹配度计算设计
### 整体架构
- **触发时机**:用户访问岗位列表接口(支持筛选条件:地区/岗位类型/行业/工作类型)
- **核心流程**:扩展层级筛选 → 查询不感兴趣记录 → 扩展排除列表 → SQL分页查询 → 查询收藏状态 → 批量计算匹配度 → 组装返回
- **匹配度计算**:行业30% + 技能40% + 经验20%,在Java层批量计算(避免SQL性能问题)
### 数据库表(使用)
| 表名 | 用途 |
|------|------|
| `bg_job` | 岗位主表(查询岗位列表) |
| `bg_company` | 公司表(关联查询公司信息) |
| `bg_china_regions_code` | 地区表(关联查询地区名称、扩展子级) |
| `bg_job_category` | 岗位类型表(关联查询类型名称、扩展子级) |
| `bg_industry` | 行业表(扩展子级、判断父级匹配) |
| `bg_user_job_dislike` | 不感兴趣记录(排除岗位/公司/地区/行业) |
| `bg_user_job_favorite` | 收藏记录(查询收藏状态) |
| `bg_user_profile` | 用户简历(匹配度计算) |
| `bg_job_skill_tag_relation` | 岗位技能标签(技能匹配) |
| `bg_user_profile_skill_tag_relation` | 用户技能标签(技能匹配) |
### 匹配度计算规则
#### 行业匹配(30分)
- 岗位无要求(required_industry_id=null)→ 30分
- 用户无简历(profile不存在或experienceIndustryIds为空)→ 0分
- 完全匹配(用户行业包含岗位行业)→ 30分
- 父级匹配(用户行业与岗位行业有相同父级)→ 22分
- 不匹配 → 0分
#### 技能匹配(40分)
- 岗位无要求(无技能标签关联)→ 40分
- 用户无技能(无技能标签关联)→ 0分
- 匹配公式:`(匹配数量 / 岗位要求数量) * 40`
#### 经验匹配(20分)
- 岗位无要求(min_experience=0)→ 20分
- 用户无简历(profile不存在或workYears=null)→ 0分
- 计算公式:`min((workYears - minExp) / minExp * 0.8 + 0.2, 1.0) * 20`
### 排除逻辑
- **排除岗位**:直接排除 `jobId`
- **排除公司**:直接排除 `companyId`
- **排除地区**:排除 `regionCode` + 其所有子级地区
- **排除行业**:排除 `requiredIndustryId` + 其所有子级行业
### 核心服务
- **JobService**client-api):岗位列表查询主流程编排
- **JobMatchService**manager):批量计算匹配度(行业/技能/经验三个维度)
### 接口定义
- **路径**`POST /job/list`
- **入参**`JobQueryParam`(分页 + 地区/岗位类型/行业/工作类型筛选)
- **出参**`PageResult<JobDto>`(岗位列表 + 匹配度 + 收藏状态)
## 4.5️⃣ 用户技能标签匹配设计 ## 4.5️⃣ 用户技能标签匹配设计
### 整体架构 ### 整体架构
- **触发时机**:用户保存个人资料(主表或任意子表)后异步触发 - **触发时机**:用户保存个人资料(主表或任意子表)后异步触发
@@ -0,0 +1,36 @@
package org.jiayunet.controller;
import lombok.AllArgsConstructor;
import org.jiayunet.pojo.PageResult;
import org.jiayunet.pojo.dto.job.JobDto;
import org.jiayunet.pojo.param.job.JobQueryParam;
import org.jiayunet.service.JobService;
import org.jiayunet.tool.UserSecurityTool;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 岗位接口
*
* @author zk
*/
@RestController
@RequestMapping("/job")
@AllArgsConstructor
public class JobController {
private final JobService jobService;
/**
* 岗位列表查询
* <p>支持按地区/岗位类型/行业/工作类型筛选,自动排除不感兴趣的岗位/公司/地区/行业,返回匹配度和收藏状态</p>
*/
@PostMapping("/list")
public PageResult<JobDto> listJobs(@Validated @RequestBody JobQueryParam param) {
Long userId = UserSecurityTool.getUserId();
return jobService.listJobs(param, userId);
}
}
@@ -0,0 +1,56 @@
package org.jiayunet.pojo.dto.job;
import lombok.Data;
import java.util.List;
/**
* 岗位列表出参
*
* @author zk
*/
@Data
public class JobDto {
/** 岗位ID */
private Long id;
/** 岗位名称 */
private String title;
/** 薪资描述 */
private String salary;
/** 公司名称 */
private String companyName;
/** 公司简称 */
private String companyShortName;
/** 公司类型 */
private String companyType;
/** 公司Logo */
private String companyLogoUrl;
/** 地区名称 */
private String regionName;
/** 岗位类型名称 */
private String categoryName;
/** 岗位标签 */
private List<String> tags;
/** 来源链接 */
private String sourceUrl;
/** 是否收藏 */
private Boolean isFavorite;
/** 匹配总分(0-90 */
private Integer matchScore;
/** 匹配度详情 */
private JobMatchScoreDto matchDetail;
}
@@ -0,0 +1,25 @@
package org.jiayunet.pojo.dto.job;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 岗位匹配度详情DTO
*
* @author zk
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobMatchScoreDto {
/** 行业得分(0-100,百分制) */
private Integer industryScore;
/** 技能得分(0-100,百分制) */
private Integer skillScore;
/** 经验得分(0-100,百分制) */
private Integer experienceScore;
}
@@ -0,0 +1,29 @@
package org.jiayunet.pojo.param.job;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.jiayunet.pojo.PageParam;
import java.util.List;
/**
* 岗位查询入参
*
* @author zk
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class JobQueryParam extends PageParam {
/** 地区编码列表 */
private List<String> regionCodes;
/** 岗位类型ID列表 */
private List<Long> categoryIds;
/** 行业ID列表 */
private List<Long> industryIds;
/** 工作类型 0=全职 1=兼职 */
private Integer employmentType;
}
@@ -0,0 +1,153 @@
package org.jiayunet.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.jiayunet.mapper.*;
import org.jiayunet.pojo.PageResult;
import org.jiayunet.pojo.dto.job.JobDto;
import org.jiayunet.pojo.dto.job.JobMatchScoreDto;
import org.jiayunet.pojo.param.job.JobQueryParam;
import org.jiayunet.pojo.po.*;
import org.jiayunet.pojo.vo.JobListItemVo;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 岗位服务
* <p>主要功能:岗位列表查询、匹配度计算</p>
* <p>依赖:JobMatchService(匹配度计算)</p>
* <p>使用表:bg_job(查询岗位)、bg_user_job_dislike(查询不感兴趣记录)、bg_user_job_favorite(查询收藏状态)、bg_china_regions_code(扩展地区子级)、bg_job_category(扩展岗位类型子级)、bg_industry(扩展行业子级)</p>
*
* @author zk
*/
@Slf4j
@Service
public class JobService {
@Autowired
private JobMapper jobMapper;
@Autowired
private UserJobDislikeMapper userJobDislikeMapper;
@Autowired
private UserJobFavoriteMapper userJobFavoriteMapper;
@Autowired
private ChinaRegionsCodeMapper regionMapper;
@Autowired
private JobCategoryMapper categoryMapper;
@Autowired
private IndustryMapper industryMapper;
@Autowired
private org.jiayunet.service.JobMatchService jobMatchService;
/**
* 岗位列表查询
* <p>1. 扩展筛选条件子级 2. 查询不感兴趣记录 3. 扩展不感兴趣的地区和行业子级 4. 执行分页查询 5. 查询收藏状态 6. 批量计算匹配度 7. 组装返回数据</p>
*/
public PageResult<JobDto> listJobs(JobQueryParam param, Long userId) {
// 1. 扩展筛选条件的子级
List<String> expandedRegionCodes = expandRegionCodes(param.getRegionCodes());
List<Long> expandedCategoryIds = expandCategoryIds(param.getCategoryIds());
List<Long> expandedIndustryIds = expandIndustryIds(param.getIndustryIds());
// 2. 查询用户不感兴趣记录
List<UserJobDislike> dislikes = userJobDislikeMapper.selectList(new LambdaQueryWrapper<UserJobDislike>().eq(UserJobDislike::getUserId, userId));
// 3. 提取排除列表
List<Long> excludeJobIds = dislikes.stream().map(UserJobDislike::getJobId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
List<Long> excludeCompanyIds = dislikes.stream().map(UserJobDislike::getCompanyId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
// 4. 提取不感兴趣的地区(直接使用,不扩展子级)
List<String> excludeRegionCodes = dislikes.stream().map(UserJobDislike::getRegionCode).filter(Objects::nonNull).distinct().collect(Collectors.toList());
// 5. 提取不感兴趣的行业(直接使用,不扩展子级)
List<Long> excludeIndustryIds = dislikes.stream().map(UserJobDislike::getIndustryId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
// 6. 执行分页查询
Page<JobListItemVo> page = jobMapper.selectJobPage(param.toPage(), expandedRegionCodes, expandedCategoryIds, expandedIndustryIds, param.getEmploymentType(), excludeJobIds, excludeCompanyIds, excludeRegionCodes, excludeIndustryIds);
// 7. 查询收藏状态
List<Long> jobIds = page.getRecords().stream().map(JobListItemVo::getId).collect(Collectors.toList());
Map<Long, Boolean> favoriteMap = getFavoriteMap(userId, jobIds);
// 8. 批量计算匹配度
Map<Long, Map<String, Integer>> matchScoreMap = jobMatchService.batchCalculateMatchScore(page.getRecords(), userId);
// 9. 组装返回数据
List<JobDto> dtoList = page.getRecords().stream().map(vo -> {
JobDto dto = new JobDto();
BeanUtils.copyProperties(vo, dto);
dto.setIsFavorite(favoriteMap.getOrDefault(vo.getId(), false));
Map<String, Integer> scoreMap = matchScoreMap.get(vo.getId());
if (scoreMap != null) {
JobMatchScoreDto matchScore = new JobMatchScoreDto(scoreMap.get("industryScore"), scoreMap.get("skillScore"), scoreMap.get("experienceScore"));
dto.setMatchScore(scoreMap.get("totalScore"));
dto.setMatchDetail(matchScore);
} else {
dto.setMatchScore(0);
dto.setMatchDetail(new JobMatchScoreDto(0, 0, 0));
}
return dto;
}).collect(Collectors.toList());
return new PageResult<>(page.getCurrent(), page.getSize(), page.getTotal(), dtoList);
}
/**
* 扩展地区编码(包含自身和所有子级)
* <p>一次查询:code本身 + provinceCode匹配 + cityCode匹配</p>
*/
private List<String> expandRegionCodes(List<String> codes) {
if (codes == null || codes.isEmpty()) {
return Collections.emptyList();
}
List<ChinaRegionsCode> regions = regionMapper.selectList(new LambdaQueryWrapper<ChinaRegionsCode>().in(ChinaRegionsCode::getCode, codes).or().in(ChinaRegionsCode::getProvinceCode, codes).or().in(ChinaRegionsCode::getCityCode, codes));
return regions.stream().map(ChinaRegionsCode::getCode).distinct().collect(Collectors.toList());
}
/**
* 扩展岗位类型ID(包含自身和所有子级)
* <p>一次查询:id本身 + rootId匹配 + parentId匹配</p>
*/
private List<Long> expandCategoryIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
List<JobCategory> categories = categoryMapper.selectList(new LambdaQueryWrapper<JobCategory>().in(JobCategory::getId, ids).or().in(JobCategory::getRootId, ids).or().in(JobCategory::getParentId, ids));
return categories.stream().map(JobCategory::getId).distinct().collect(Collectors.toList());
}
/**
* 扩展行业ID(包含自身和所有子级)
* <p>一次查询:id本身 + rootId匹配 + parentId匹配</p>
*/
private List<Long> expandIndustryIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
List<Industry> industries = industryMapper.selectList(new LambdaQueryWrapper<Industry>().in(Industry::getId, ids).or().in(Industry::getRootId, ids).or().in(Industry::getParentId, ids));
return industries.stream().map(Industry::getId).distinct().collect(Collectors.toList());
}
/**
* 查询收藏状态Map
* <p>批量查询用户对岗位的收藏状态</p>
*/
private Map<Long, Boolean> getFavoriteMap(Long userId, List<Long> jobIds) {
if (jobIds == null || jobIds.isEmpty()) {
return Collections.emptyMap();
}
List<UserJobFavorite> favorites = userJobFavoriteMapper.selectList(new LambdaQueryWrapper<UserJobFavorite>().eq(UserJobFavorite::getUserId, userId).in(UserJobFavorite::getJobId, jobIds));
return favorites.stream().collect(Collectors.toMap(UserJobFavorite::getJobId, f -> true));
}
}
@@ -1,7 +1,12 @@
package org.jiayunet.mapper; package org.jiayunet.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.jiayunet.pojo.po.Job; import org.jiayunet.pojo.po.Job;
import org.jiayunet.pojo.vo.JobListItemVo;
import java.util.List;
/** /**
* 岗位Mapper * 岗位Mapper
@@ -10,4 +15,9 @@ import org.jiayunet.pojo.po.Job;
*/ */
@Mapper @Mapper
public interface JobMapper extends CommonMapper<Job> { 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; package org.jiayunet.pojo.po;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data; import lombok.Data;
import java.time.Instant; import java.time.Instant;
import java.util.List;
/** /**
* 岗位表(bg_job * 岗位表(bg_job
@@ -13,7 +16,7 @@ import java.time.Instant;
* @author zk * @author zk
*/ */
@Data @Data
@TableName(value = "bg_job") @TableName(value = "bg_job", autoResultMap = true)
public class Job { public class Job {
@TableId(type = IdType.ASSIGN_ID) @TableId(type = IdType.ASSIGN_ID)
@@ -41,10 +44,12 @@ public class Job {
private String bonus; private String bonus;
/** 岗位标签(JSON数组) */ /** 岗位标签(JSON数组) */
private String tags; @TableField(typeHandler = JacksonTypeHandler.class)
private List<String> tags;
/** 技能标签(JSON数组) */ /** 技能标签(JSON数组) */
private String skillTags; @TableField(typeHandler = JacksonTypeHandler.class)
private List<String> skillTags;
/** 薪资描述,如15-25K·13薪、面议 */ /** 薪资描述,如15-25K·13薪、面议 */
private String salary; private String salary;
@@ -55,8 +60,8 @@ public class Job {
/** 最低工作年限,0=不要求 */ /** 最低工作年限,0=不要求 */
private Integer minExperience; private Integer minExperience;
/** 行业经验,关联bg_industry */ /** 要求的行业经验ID,关联bg_industry */
private Long industryId; private Long requiredIndustryId;
/** 来源链接 */ /** 来源链接 */
private String sourceUrl; 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), "minExperience": 最低工作年限数字(不要求则0),
"employmentType": 0或1(0=全职 1=兼职,默认0), "employmentType": 0或1(0=全职 1=兼职,默认0),
"categoryId": 岗位分类ID(必选,从分类列表中选最接近的), "categoryId": 岗位分类ID(必选,从分类列表中选最接近的),
"industryId": 行业ID(仅当明确提到行业经验要求时设置,列表中无完全匹配则选最相似的,未提到则null), "requiredIndustryId": 行业ID(仅当明确提到行业经验要求时设置,列表中无完全匹配则选最相似的,未提到则null),
"description": "岗位职责,保持原文风格,格式化展示", "description": "岗位职责,保持原文风格,格式化展示",
"requirement": "任职要求,保持原文风格,格式化展示", "requirement": "任职要求,保持原文风格,格式化展示",
"bonus": "加分项,无则null", "bonus": "加分项,无则null",
@@ -275,7 +275,7 @@ public class JobCleanService {
2. 岗位标题不存在时,从描述中归纳生成 2. 岗位标题不存在时,从描述中归纳生成
3. 薪资标准化为 10-20K、20K、面议 等格式,无效或空则null 3. 薪资标准化为 10-20K、20K、面议 等格式,无效或空则null
4. categoryId 必须从分类列表中选一个,不允许为null 4. categoryId 必须从分类列表中选一个,不允许为null
5. industryId 仅当描述中明确提到行业经验要求时设置 5. requiredIndustryId 仅当描述中明确提到行业经验要求时设置
6. tags 是核心职能标签(如数据分析、团队协作),最多5个 6. tags 是核心职能标签(如数据分析、团队协作),最多5个
7. skillTags 是技能关键词(如Java、MySQL),最多8个 7. skillTags 是技能关键词(如Java、MySQL),最多8个
8. companyShortName 去掉地区后缀、招聘后缀、括号内容,保持简洁 8. companyShortName 去掉地区后缀、招聘后缀、括号内容,保持简洁
@@ -61,8 +61,21 @@ public class JobCleanTransactionService {
job.setDescription(root.path("description").asText("")); job.setDescription(root.path("description").asText(""));
job.setRequirement(root.path("requirement").asText("")); job.setRequirement(root.path("requirement").asText(""));
job.setBonus(root.path("bonus").asText(null)); 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); String salary = root.path("salary").asText(null);
job.setSalary("null".equals(salary) ? null : salary); job.setSalary("null".equals(salary) ? null : salary);
@@ -70,8 +83,8 @@ public class JobCleanTransactionService {
job.setEducation(root.path("education").asInt(0)); job.setEducation(root.path("education").asInt(0));
job.setMinExperience(root.path("minExperience").asInt(0)); job.setMinExperience(root.path("minExperience").asInt(0));
Long industryId = root.path("industryId").asLong(0); Long requiredIndustryId = root.path("requiredIndustryId").asLong(0);
job.setIndustryId(industryId == 0 ? null : industryId); job.setRequiredIndustryId(requiredIndustryId == 0 ? null : requiredIndustryId);
job.setSourceUrl(data.getDetailUrl()); job.setSourceUrl(data.getDetailUrl());
job.setSourceId(sourceId); 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>