岗位列表接口
This commit is contained in:
@@ -120,7 +120,7 @@ UPDATE app_job_data SET clean_status=0 WHERE clean_status=1 AND updated_at < NOW
|
||||
"minExperience": 3,
|
||||
"employmentType": 0,
|
||||
"categoryId": 12,
|
||||
"industryId": 5,
|
||||
"requiredIndustryId": 5,
|
||||
"description": "1. 负责核心业务系统开发...",
|
||||
"requirement": "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 |
|
||||
| employmentType | description | 判断 0=全职 1=兼职,默认0 |
|
||||
| categoryId | description + job_title | 必选,从分类列表中选最接近的,不允许返回null |
|
||||
| industryId | description(任职要求部分) | 仅当明确提到行业经验要求时设置;列表中无完全匹配则选最相似的;未提到则null |
|
||||
| requiredIndustryId | description(任职要求部分) | 仅当明确提到行业经验要求时设置;列表中无完全匹配则选最相似的;未提到则null |
|
||||
| description | description + experience + education | 提取"岗位职责"部分,保持原文风格,格式化展示 |
|
||||
| requirement | description + experience + education | 提取"任职要求"部分,保持原文风格,格式化展示 |
|
||||
| bonus | description + experience + education | 提取"加分项"部分,无则空 |
|
||||
@@ -205,7 +205,7 @@ CREATE TABLE bg_job_skill_tag_relation (
|
||||
| 分类/行业列表怎么给AI | 直接传 id:name 文本 | ID人工维护为短数字,token消耗可控 |
|
||||
| 地区匹配方式 | AI输出城市名,Java侧匹配 | 城市名无歧义,不需要传参考列表 |
|
||||
| categoryId 是否可空 | 不可空,必须选一个 | 岗位分类是核心维度 |
|
||||
| industryId 何时设置 | 仅描述中明确提到行业经验时 | 行业经验是任职要求,不是所有岗位都有 |
|
||||
| requiredIndustryId 何时设置 | 仅描述中明确提到行业经验要求时 | 行业经验是任职要求,不是所有岗位都有 |
|
||||
| tags 定位 | 核心职能标签,最多5个 | 区别于福利标签,体现岗位核心能力要求 |
|
||||
| skillTags 数量 | 最多8个 | 控制数量,保持精炼 |
|
||||
| source_id 取值 | app_job_data.id | 简单直接,用于去重 |
|
||||
|
||||
@@ -23,7 +23,8 @@ offerpie/back-end
|
||||
│ │ ├─ LoginController.java # 登录相关接口(发送验证码、短信登录)
|
||||
│ │ ├─ RouteMenuController.java # 路由菜单接口(获取用户有效菜单树)
|
||||
│ │ ├─ UserProfileController.java # 用户个人资料接口(主表+5张子表的查询与保存)
|
||||
│ │ └─ JobIntentionController.java # 求职意向接口(查询与保存)
|
||||
│ │ ├─ JobIntentionController.java # 求职意向接口(查询与保存)
|
||||
│ │ └─ JobController.java # 岗位接口(岗位列表查询)
|
||||
│ ├─ service/
|
||||
│ │ ├─ LoginService.java # 登录业务逻辑(验证码校验、自动注册、JWT生成、Cookie设置)
|
||||
│ │ ├─ UserRegisterService.java # 用户注册服务(注册逻辑、邀请码生成与绑定)
|
||||
@@ -31,15 +32,16 @@ offerpie/back-end
|
||||
│ │ ├─ RouteMenuService.java # 路由菜单服务(查询、添加库存、获取用户菜单树)
|
||||
│ │ ├─ UserProfileService.java # 用户个人资料服务(主表+5张子表的CRUD)
|
||||
│ │ ├─ JobIntentionService.java # 求职意向服务(查询与保存/更新)
|
||||
│ │ ├─ JobService.java # 岗位服务(岗位列表查询、匹配度计算编排)
|
||||
│ │ └─ WxPayNotifyMessageAbstractImpl.java # 微信支付回调实现
|
||||
│ └─ pojo/
|
||||
│ ├─ param/
|
||||
│ │ ├─ userProfile/ # 个人资料入参(UserProfileParam、各子表Param)
|
||||
│ │ └─ job/ # 岗位相关入参(JobIntentionParam)
|
||||
│ │ └─ job/ # 岗位相关入参(JobIntentionParam、JobQueryParam)
|
||||
│ ├─ dto/
|
||||
│ │ ├─ SmsLoginDto.java # 短信登录入参(mobileNumber + code + inviteCode)
|
||||
│ │ ├─ userProfile/ # 个人资料出参(UserProfileDto、各子表Dto)
|
||||
│ │ └─ job/ # 岗位相关出参(JobIntentionDto)
|
||||
│ │ └─ job/ # 岗位相关出参(JobIntentionDto、JobDto、JobMatchScoreDto)
|
||||
│ └─ vo/
|
||||
│ ├─ LoginVo.java # 登录返回(userId + nick)
|
||||
│ └─ RouteMenuVo.java # 路由菜单树形VO(含children子菜单)
|
||||
@@ -127,8 +129,10 @@ offerpie/back-end
|
||||
│ │ ├─ UserProfileCompetition.java # 用户竞赛经历表(bg_user_profile_competition)
|
||||
│ │ ├─ UserProfileSkillTagRelation.java # 用户技能标签关联表(bg_user_profile_skill_tag_relation)
|
||||
│ │ └─ AppJobData.java # 爬虫岗位原始数据表(app_job_data)
|
||||
│ └─ vo/ # ViewObject(OssUrlVo、DescriptionParagraph 等)
|
||||
└─ service/ # 业务 Service(OssService、SmsService、DictCacheService、JobCleanService、JobCleanTransactionService、CompanyCleanService、CompanyCleanTransactionService、UserSkillTagMatchService 等)
|
||||
│ └─ vo/ # ViewObject(OssUrlVo、DescriptionParagraph、JobListItemVo 等)
|
||||
├─ resources/mapper/ # MyBatis XML 映射文件
|
||||
│ └─ JobMapper.xml # 岗位自定义SQL(selectJobPage)
|
||||
└─ service/ # 业务 Service(OssService、SmsService、DictCacheService、JobCleanService、JobCleanTransactionService、CompanyCleanService、CompanyCleanTransactionService、UserSkillTagMatchService、JobMatchService 等)
|
||||
```
|
||||
> **设计理念** – 业务实体和 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️⃣ 用户技能标签匹配设计
|
||||
### 整体架构
|
||||
- **触发时机**:用户保存个人资料(主表或任意子表)后异步触发
|
||||
|
||||
@@ -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;
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user