From 28e551285d572cc35d820453a0d5e0b4f07939bf Mon Sep 17 00:00:00 2001 From: zk Date: Fri, 20 Mar 2026 18:32:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B2=97=E4=BD=8D=E5=88=97=E8=A1=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .kiro/steering/数据清洗方案.md | 6 +- .kiro/steering/项目结构说明.md | 67 ++++++- .../jiayunet/controller/JobController.java | 36 ++++ .../org/jiayunet/pojo/dto/job/JobDto.java | 56 ++++++ .../pojo/dto/job/JobMatchScoreDto.java | 25 +++ .../pojo/param/job/JobQueryParam.java | 29 +++ .../java/org/jiayunet/service/JobService.java | 153 +++++++++++++++ .../java/org/jiayunet/mapper/JobMapper.java | 10 + .../main/java/org/jiayunet/pojo/po/Job.java | 15 +- .../org/jiayunet/pojo/vo/JobListItemVo.java | 65 +++++++ .../org/jiayunet/service/JobCleanService.java | 4 +- .../service/JobCleanTransactionService.java | 21 +- .../org/jiayunet/service/JobMatchService.java | 181 ++++++++++++++++++ .../src/main/resources/mapper/JobMapper.xml | 85 ++++++++ 14 files changed, 734 insertions(+), 19 deletions(-) create mode 100644 client-api/src/main/java/org/jiayunet/controller/JobController.java create mode 100644 client-api/src/main/java/org/jiayunet/pojo/dto/job/JobDto.java create mode 100644 client-api/src/main/java/org/jiayunet/pojo/dto/job/JobMatchScoreDto.java create mode 100644 client-api/src/main/java/org/jiayunet/pojo/param/job/JobQueryParam.java create mode 100644 client-api/src/main/java/org/jiayunet/service/JobService.java create mode 100644 manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java create mode 100644 manager/src/main/java/org/jiayunet/service/JobMatchService.java create mode 100644 manager/src/main/resources/mapper/JobMapper.xml diff --git a/.kiro/steering/数据清洗方案.md b/.kiro/steering/数据清洗方案.md index 742a4a6..6441bf3 100644 --- a/.kiro/steering/数据清洗方案.md +++ b/.kiro/steering/数据清洗方案.md @@ -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 | 简单直接,用于去重 | diff --git a/.kiro/steering/项目结构说明.md b/.kiro/steering/项目结构说明.md index bf01f3e..e37794d 100644 --- a/.kiro/steering/项目结构说明.md +++ b/.kiro/steering/项目结构说明.md @@ -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`(岗位列表 + 匹配度 + 收藏状态) + ## 4.5️⃣ 用户技能标签匹配设计 ### 整体架构 - **触发时机**:用户保存个人资料(主表或任意子表)后异步触发 diff --git a/client-api/src/main/java/org/jiayunet/controller/JobController.java b/client-api/src/main/java/org/jiayunet/controller/JobController.java new file mode 100644 index 0000000..01d875a --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/controller/JobController.java @@ -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; + + /** + * 岗位列表查询 + *

支持按地区/岗位类型/行业/工作类型筛选,自动排除不感兴趣的岗位/公司/地区/行业,返回匹配度和收藏状态

+ */ + @PostMapping("/list") + public PageResult listJobs(@Validated @RequestBody JobQueryParam param) { + Long userId = UserSecurityTool.getUserId(); + return jobService.listJobs(param, userId); + } +} 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 new file mode 100644 index 0000000..9046c0b --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobDto.java @@ -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 tags; + + /** 来源链接 */ + private String sourceUrl; + + /** 是否收藏 */ + private Boolean isFavorite; + + /** 匹配总分(0-90) */ + private Integer matchScore; + + /** 匹配度详情 */ + private JobMatchScoreDto matchDetail; +} 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 new file mode 100644 index 0000000..b35c957 --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/pojo/dto/job/JobMatchScoreDto.java @@ -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; +} diff --git a/client-api/src/main/java/org/jiayunet/pojo/param/job/JobQueryParam.java b/client-api/src/main/java/org/jiayunet/pojo/param/job/JobQueryParam.java new file mode 100644 index 0000000..684c3a5 --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/pojo/param/job/JobQueryParam.java @@ -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 regionCodes; + + /** 岗位类型ID列表 */ + private List categoryIds; + + /** 行业ID列表 */ + private List industryIds; + + /** 工作类型 0=全职 1=兼职 */ + private Integer employmentType; +} diff --git a/client-api/src/main/java/org/jiayunet/service/JobService.java b/client-api/src/main/java/org/jiayunet/service/JobService.java new file mode 100644 index 0000000..afad7ea --- /dev/null +++ b/client-api/src/main/java/org/jiayunet/service/JobService.java @@ -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; + +/** + * 岗位服务 + *

主要功能:岗位列表查询、匹配度计算

+ *

依赖:JobMatchService(匹配度计算)

+ *

使用表:bg_job(查询岗位)、bg_user_job_dislike(查询不感兴趣记录)、bg_user_job_favorite(查询收藏状态)、bg_china_regions_code(扩展地区子级)、bg_job_category(扩展岗位类型子级)、bg_industry(扩展行业子级)

+ * + * @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; + + /** + * 岗位列表查询 + *

1. 扩展筛选条件子级 2. 查询不感兴趣记录 3. 扩展不感兴趣的地区和行业子级 4. 执行分页查询 5. 查询收藏状态 6. 批量计算匹配度 7. 组装返回数据

+ */ + public PageResult listJobs(JobQueryParam param, Long userId) { + // 1. 扩展筛选条件的子级 + List expandedRegionCodes = expandRegionCodes(param.getRegionCodes()); + List expandedCategoryIds = expandCategoryIds(param.getCategoryIds()); + List expandedIndustryIds = expandIndustryIds(param.getIndustryIds()); + + // 2. 查询用户不感兴趣记录 + List dislikes = userJobDislikeMapper.selectList(new LambdaQueryWrapper().eq(UserJobDislike::getUserId, userId)); + + // 3. 提取排除列表 + List excludeJobIds = dislikes.stream().map(UserJobDislike::getJobId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); + List excludeCompanyIds = dislikes.stream().map(UserJobDislike::getCompanyId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); + + // 4. 提取不感兴趣的地区(直接使用,不扩展子级) + List excludeRegionCodes = dislikes.stream().map(UserJobDislike::getRegionCode).filter(Objects::nonNull).distinct().collect(Collectors.toList()); + + // 5. 提取不感兴趣的行业(直接使用,不扩展子级) + List excludeIndustryIds = dislikes.stream().map(UserJobDislike::getIndustryId).filter(Objects::nonNull).distinct().collect(Collectors.toList()); + + // 6. 执行分页查询 + Page page = jobMapper.selectJobPage(param.toPage(), expandedRegionCodes, expandedCategoryIds, expandedIndustryIds, param.getEmploymentType(), excludeJobIds, excludeCompanyIds, excludeRegionCodes, excludeIndustryIds); + + // 7. 查询收藏状态 + List jobIds = page.getRecords().stream().map(JobListItemVo::getId).collect(Collectors.toList()); + Map favoriteMap = getFavoriteMap(userId, jobIds); + + // 8. 批量计算匹配度 + Map> matchScoreMap = jobMatchService.batchCalculateMatchScore(page.getRecords(), userId); + + // 9. 组装返回数据 + List dtoList = page.getRecords().stream().map(vo -> { + JobDto dto = new JobDto(); + BeanUtils.copyProperties(vo, dto); + dto.setIsFavorite(favoriteMap.getOrDefault(vo.getId(), false)); + Map 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); + } + + /** + * 扩展地区编码(包含自身和所有子级) + *

一次查询:code本身 + provinceCode匹配 + cityCode匹配

+ */ + private List expandRegionCodes(List codes) { + if (codes == null || codes.isEmpty()) { + return Collections.emptyList(); + } + List regions = regionMapper.selectList(new LambdaQueryWrapper().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(包含自身和所有子级) + *

一次查询:id本身 + rootId匹配 + parentId匹配

+ */ + private List expandCategoryIds(List ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + List categories = categoryMapper.selectList(new LambdaQueryWrapper().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(包含自身和所有子级) + *

一次查询:id本身 + rootId匹配 + parentId匹配

+ */ + private List expandIndustryIds(List ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + List industries = industryMapper.selectList(new LambdaQueryWrapper().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 + *

批量查询用户对岗位的收藏状态

+ */ + private Map getFavoriteMap(Long userId, List jobIds) { + if (jobIds == null || jobIds.isEmpty()) { + return Collections.emptyMap(); + } + List favorites = userJobFavoriteMapper.selectList(new LambdaQueryWrapper().eq(UserJobFavorite::getUserId, userId).in(UserJobFavorite::getJobId, jobIds)); + return favorites.stream().collect(Collectors.toMap(UserJobFavorite::getJobId, f -> true)); + } +} diff --git a/manager/src/main/java/org/jiayunet/mapper/JobMapper.java b/manager/src/main/java/org/jiayunet/mapper/JobMapper.java index 695c87d..d36d1a2 100644 --- a/manager/src/main/java/org/jiayunet/mapper/JobMapper.java +++ b/manager/src/main/java/org/jiayunet/mapper/JobMapper.java @@ -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 { + + /** + * 分页查询岗位列表 + */ + Page selectJobPage(Page page, @Param("regionCodes") List regionCodes, @Param("categoryIds") List categoryIds, @Param("industryIds") List industryIds, @Param("employmentType") Integer employmentType, @Param("excludeJobIds") List excludeJobIds, @Param("excludeCompanyIds") List excludeCompanyIds, @Param("excludeRegionCodes") List excludeRegionCodes, @Param("excludeIndustryIds") List excludeIndustryIds); } diff --git a/manager/src/main/java/org/jiayunet/pojo/po/Job.java b/manager/src/main/java/org/jiayunet/pojo/po/Job.java index df75ef5..9f36b64 100644 --- a/manager/src/main/java/org/jiayunet/pojo/po/Job.java +++ b/manager/src/main/java/org/jiayunet/pojo/po/Job.java @@ -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 tags; /** 技能标签(JSON数组) */ - private String skillTags; + @TableField(typeHandler = JacksonTypeHandler.class) + private List 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; diff --git a/manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java b/manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java new file mode 100644 index 0000000..6175f8d --- /dev/null +++ b/manager/src/main/java/org/jiayunet/pojo/vo/JobListItemVo.java @@ -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 tags; + + /** 来源链接 */ + private String sourceUrl; + + /** 要求的行业经验ID */ + private Long requiredIndustryId; + + /** 最低工作年限 */ + private Integer minExperience; +} diff --git a/manager/src/main/java/org/jiayunet/service/JobCleanService.java b/manager/src/main/java/org/jiayunet/service/JobCleanService.java index 6144098..4f47a5a 100644 --- a/manager/src/main/java/org/jiayunet/service/JobCleanService.java +++ b/manager/src/main/java/org/jiayunet/service/JobCleanService.java @@ -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 去掉地区后缀、招聘后缀、括号内容,保持简洁 diff --git a/manager/src/main/java/org/jiayunet/service/JobCleanTransactionService.java b/manager/src/main/java/org/jiayunet/service/JobCleanTransactionService.java index 45b23e9..cc35d4d 100644 --- a/manager/src/main/java/org/jiayunet/service/JobCleanTransactionService.java +++ b/manager/src/main/java/org/jiayunet/service/JobCleanTransactionService.java @@ -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 + JsonNode tagsNode = root.path("tags"); + if (tagsNode.isArray()) { + List tags = new java.util.ArrayList<>(); + tagsNode.forEach(node -> tags.add(node.asText())); + job.setTags(tags); + } + + JsonNode skillTagsNode = root.path("skillTags"); + if (skillTagsNode.isArray()) { + List 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); diff --git a/manager/src/main/java/org/jiayunet/service/JobMatchService.java b/manager/src/main/java/org/jiayunet/service/JobMatchService.java new file mode 100644 index 0000000..4fda888 --- /dev/null +++ b/manager/src/main/java/org/jiayunet/service/JobMatchService.java @@ -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; + +/** + * 岗位匹配度计算服务 + *

依赖:无

+ *

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

+ * + * @author zk + */ +@Slf4j +@Service +public class JobMatchService { + + @Autowired + private UserProfileMapper userProfileMapper; + + @Autowired + private JobSkillTagRelationMapper jobSkillTagRelationMapper; + + @Autowired + private UserProfileSkillTagRelationMapper userProfileSkillTagRelationMapper; + + @Autowired + private IndustryMapper industryMapper; + + /** + * 批量计算岗位匹配度 + *

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

+ */ + public Map> batchCalculateMatchScore(List jobs, Long userId) { + if (jobs == null || jobs.isEmpty()) { + return Collections.emptyMap(); + } + + // 1. 查询用户简历 + UserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper().eq(UserProfile::getUserId, userId)); + + // 2. 查询用户技能标签(转换为 Set 提升性能) + Set userSkillTagSet = Collections.emptySet(); + if (profile != null) { + List userSkillRelations = userProfileSkillTagRelationMapper.selectList(new LambdaQueryWrapper().eq(UserProfileSkillTagRelation::getUserId, userId)); + userSkillTagSet = userSkillRelations.stream().map(UserProfileSkillTagRelation::getSkillTagId).collect(Collectors.toSet()); + } + + // 3. 批量查询岗位技能标签 + List jobIds = jobs.stream().map(JobListItemVo::getId).collect(Collectors.toList()); + 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)); + } + + // 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); + + // 加权计算总分:行业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; + } + + /** + * 计算行业匹配得分(百分制) + *

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

+ */ + private int calculateIndustryScore(Long jobIndustryId, UserProfile profile, Map industryMap) { + // 岗位无要求 + if (jobIndustryId == null) { + return 100; + } + + // 用户无简历或无行业经验 + if (profile == null || profile.getExperienceIndustryIds() == null || profile.getExperienceIndustryIds().isEmpty()) { + return 0; + } + + List 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; + } + + /** + * 计算技能匹配得分(百分制) + *

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

+ */ + private int calculateSkillScore(Long jobId, 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; + } +} diff --git a/manager/src/main/resources/mapper/JobMapper.xml b/manager/src/main/resources/mapper/JobMapper.xml new file mode 100644 index 0000000..eb6bd85 --- /dev/null +++ b/manager/src/main/resources/mapper/JobMapper.xml @@ -0,0 +1,85 @@ + + + + + + +