From 0cd29d6e5fb3bd2676dccaabebbc91d9ba764303 Mon Sep 17 00:00:00 2001 From: sh Date: Fri, 17 Jul 2026 17:07:58 +0800 Subject: [PATCH] =?UTF-8?q?1.=E6=B7=BB=E5=8A=A0=E5=AF=B9es=E7=B2=BE?= =?UTF-8?q?=E5=87=86=E6=9F=A5=E8=AF=A2=EF=BC=8C=E5=8E=BB=E9=99=A4=E5=8E=BB?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E6=93=8D=E4=BD=9C=202.=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E6=97=B6=E5=AF=B9=E6=9D=A1=E4=BB=B6=E5=B0=B1?= =?UTF-8?q?=E8=A1=8C=E6=8B=86=E5=88=86=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cms/controller/app/AppJobController.java | 8 + .../controller/cms/JobSearchController.java | 34 ++ .../cms/domain/ik/JobSearchCondition.java | 33 ++ .../ruoyi/cms/domain/ik/ParserDictDTO.java | 20 + .../ruoyi/cms/service/IJobTitleService.java | 3 + .../cms/service/JobNaturalSearchService.java | 116 ++++++ .../cms/service/impl/ESJobSearchImpl.java | 176 ++++----- .../cms/service/impl/JobTitleServiceImpl.java | 19 +- .../com/ruoyi/cms/util/ik/EsAnalyzeUtil.java | 63 ++++ .../cms/util/ik/TextConditionParserUtil.java | 349 ++++++++++++++++++ .../ruoyi/common/constant/CacheConstants.java | 5 + 11 files changed, 737 insertions(+), 89 deletions(-) create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/JobSearchController.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/JobSearchCondition.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/ParserDictDTO.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/JobNaturalSearchService.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/EsAnalyzeUtil.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/TextConditionParserUtil.java diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppJobController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppJobController.java index 6053fde..d8bb9aa 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppJobController.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppJobController.java @@ -3,6 +3,7 @@ package com.ruoyi.cms.controller.app; import com.ruoyi.cms.domain.ESJobDocument; import com.ruoyi.cms.domain.Job; import com.ruoyi.cms.domain.JobApply; +import com.ruoyi.cms.domain.ik.JobSearchCondition; import com.ruoyi.cms.domain.query.ESJobSearch; import com.ruoyi.cms.service.*; import com.ruoyi.cms.util.RoleUtils; @@ -17,6 +18,7 @@ import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.SM4Utils; import com.ruoyi.common.utils.SiteSecurityUtils; import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.bean.BeanUtils; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; @@ -50,6 +52,8 @@ public class AppJobController extends BaseController private SensitiveWordChecker sensitiveWordChecker; @Autowired private IJobApplyService jobApplyService; + @Autowired + private JobNaturalSearchService jobNaturalSearchService; /** * 查询岗位列表 @@ -58,6 +62,10 @@ public class AppJobController extends BaseController @GetMapping("/list") public TableDataInfo list(ESJobSearch job) { + if(!StringUtils.isBlank(job.getJobTitle())){ + JobSearchCondition parserDictDTO=jobNaturalSearchService.parseNaturalText(job.getJobTitle()); + BeanUtils.copyBeanProp(job, parserDictDTO); + } EsPageInfo list = jobService.appList(job); return getTableDataInfo2(list,job); } diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/JobSearchController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/JobSearchController.java new file mode 100644 index 0000000..eec881d --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/JobSearchController.java @@ -0,0 +1,34 @@ +package com.ruoyi.cms.controller.cms; + +import com.ruoyi.cms.domain.ik.JobSearchCondition; +import com.ruoyi.cms.service.JobNaturalSearchService; +import com.ruoyi.common.annotation.Anonymous; +import org.elasticsearch.index.query.BoolQueryBuilder; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import javax.annotation.Resource; + +@RestController +public class JobSearchController { + + @Resource + private JobNaturalSearchService jobNaturalSearchService; + + @PreAuthorize("@ss.hasPermi('job:naturalSearch')") + @GetMapping("/job/naturalSearch") +// @Anonymous + public Object naturalSearch(@RequestParam String keyword) { + // 1、解析自然语句 + JobSearchCondition condition = jobNaturalSearchService.parseNaturalText(keyword); + // 2、组装ES查询条件 + BoolQueryBuilder boolQuery = jobNaturalSearchService.buildEsQuery(condition); + + System.out.println("解析后的条件:" + condition); + System.out.println("构建的ES查询DSL:" + boolQuery); + + // TODO 此处执行ES搜索,传入boolQuery 查询职位数据 + return condition; + } +} \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/JobSearchCondition.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/JobSearchCondition.java new file mode 100644 index 0000000..d8caa7a --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/JobSearchCondition.java @@ -0,0 +1,33 @@ +package com.ruoyi.cms.domain.ik; + +import lombok.Data; +import java.math.BigDecimal; + +@Data +public class JobSearchCondition { + /** 岗位名称 */ + private String jobTitle; + /** 工作地点文本(原始区县名称) */ + private String address; + /** 区划编码(业务编码) */ + private String jobLocationAreaCode; + + /** 最低年限(数字区间range) */ + private Integer workYearMin; + /** 最高年限(数字区间range) */ + private Integer workYearMax; + /** 工作经验字典编码 0~7 */ + private String experience; + + /** 最低薪资(元) */ + private Long minSalary; + /** 最高薪资(元) */ + private Long maxSalary; + + /** 学历编码 -1 ~ 6 */ + private String education; + /** 匹配到的学历原始文本(本科/大专...用于过滤岗位名称) */ + private String educationText; + /** 匹配到的工作经验原始文本(3-5年/应届毕业生...用于过滤岗位名称) */ + private String workExpText; +} \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/ParserDictDTO.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/ParserDictDTO.java new file mode 100644 index 0000000..329c9af --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/ik/ParserDictDTO.java @@ -0,0 +1,20 @@ +package com.ruoyi.cms.domain.ik; + +import lombok.Data; + +import java.util.Map; +import java.util.Set; + +@Data +public class ParserDictDTO { + /** 区县名称->编码 */ + private Map areaMap; + /** 工作经验文本->编码 */ + private Map workExpMap; + /** 学历文本->编码 */ + private Map educationMap; + //岗位列表 + private Set standardJobSet; + + private Map jobIndexMap; +} \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleService.java index 2435e9f..88d2fd4 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleService.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleService.java @@ -1,6 +1,7 @@ package com.ruoyi.cms.service; import java.util.List; +import java.util.Set; import com.ruoyi.common.core.domain.TreeSelect; import com.ruoyi.common.core.domain.entity.JobTitle; @@ -62,4 +63,6 @@ public interface IJobTitleService void importJobTitle(); List levelOne(); + + Set jobTitleNameSets(); } diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/JobNaturalSearchService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/JobNaturalSearchService.java new file mode 100644 index 0000000..f260e89 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/JobNaturalSearchService.java @@ -0,0 +1,116 @@ +package com.ruoyi.cms.service; + +import com.ruoyi.cms.domain.BussinessDictData; +import com.ruoyi.cms.domain.ik.JobSearchCondition; +import com.ruoyi.cms.domain.ik.ParserDictDTO; +import com.ruoyi.cms.util.DictUtils; +import com.ruoyi.cms.util.ik.EsAnalyzeUtil; +import com.ruoyi.cms.util.ik.TextConditionParserUtil; +import org.elasticsearch.index.query.BoolQueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class JobNaturalSearchService { + + @Autowired + private EsAnalyzeUtil esAnalyzeUtil; + @Autowired + private IJobTitleService jobTitleService; + + /** + * 自然语言解析入口 + */ + public JobSearchCondition parseNaturalText(String inputText) { + // 1、ES分词 + List tokenList = esAnalyzeUtil.analyze(inputText, "ik_max_word"); + + // 2、【从Redis加载三份词典】 + List areaDictList = DictUtils.getDictCache("area"); + List expDictList = DictUtils.getDictCache("experience"); + List eduDictList = DictUtils.getDictCache("education"); + Set standardJobSet = jobTitleService.jobTitleNameSets(); + Map jobIndexMap = new HashMap<>(standardJobSet.size()); + for(String name : standardJobSet){ + jobIndexMap.put(name,name); + } + + ParserDictDTO dictDTO = new ParserDictDTO(); + dictDTO.setAreaMap(convertDictListToMap(areaDictList)); + dictDTO.setWorkExpMap(convertDictListToMap(expDictList)); + dictDTO.setEducationMap(convertDictListToMap(eduDictList)); + dictDTO.setStandardJobSet(standardJobSet); + dictDTO.setJobIndexMap(jobIndexMap); + + // 3、传入词典执行解析 + return TextConditionParserUtil.parse(inputText, tokenList, dictDTO); + } + + /** + * 构建ES查询BoolQuery + */ + public BoolQueryBuilder buildEsQuery(JobSearchCondition condition) { + BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); + + if (condition.getJobTitle() != null && condition.getJobTitle().trim().isEmpty()) { + boolQuery.must(QueryBuilders.matchQuery("jobName", condition.getJobTitle())); + } + if (condition.getJobLocationAreaCode() != null) { + boolQuery.filter(QueryBuilders.termQuery("areaCode", condition.getJobLocationAreaCode())); + } + if (condition.getExperience() != null) { + boolQuery.filter(QueryBuilders.termQuery("workExpCode", condition.getExperience())); + } + if (condition.getEducation() != null) { + boolQuery.filter(QueryBuilders.termQuery("educationCode", condition.getEducation())); + } + if (condition.getWorkYearMin() != null) { + boolQuery.filter(QueryBuilders.rangeQuery("workYear").gte(condition.getWorkYearMin())); + } + if (condition.getWorkYearMax() != null) { + boolQuery.filter(QueryBuilders.rangeQuery("workYear").lte(condition.getWorkYearMax())); + } + if (condition.getMinSalary() != null) { + boolQuery.filter(QueryBuilders.rangeQuery("salaryMin").gte(condition.getMinSalary())); + } + if (condition.getMaxSalary() != null) { + boolQuery.filter(QueryBuilders.rangeQuery("salaryMax").lte(condition.getMaxSalary())); + } + return boolQuery; + } + + /** + * 字典转换 + * @param dictDataList + * @return + */ + private Map convertDictListToMap(List dictDataList) { + if (dictDataList == null || dictDataList.isEmpty()) { + return new HashMap<>(); + } + return dictDataList.stream() + .filter(item -> item.getDictLabel() != null && item.getDictValue() != null) + .collect(Collectors.toMap( + BussinessDictData::getDictLabel, + item -> { + try { + return Integer.parseInt(item.getDictValue()); + } catch (NumberFormatException e) { + return null; + } + }, + (oldVal, newVal) -> oldVal + )) + .entrySet().stream() + .filter(entry -> entry.getValue() != null) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } +} \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/ESJobSearchImpl.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/ESJobSearchImpl.java index fdb0c83..5d8600d 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/ESJobSearchImpl.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/ESJobSearchImpl.java @@ -264,50 +264,50 @@ public class ESJobSearchImpl implements IESJobSearchService List esJobDocuments = esJobDocumentMapper.selectList(wrapper); - if (!isCompanyUser &&esJobDocuments.size() < esJobSearch.getPageSize()) { - // 定义要逐步放宽的搜索条件字段 - List relaxConditions = new ArrayList<>(); - relaxConditions.add(() -> newSearch.setArea(null)); - relaxConditions.add(() -> newSearch.setExperience(null)); - relaxConditions.add(() -> newSearch.setMaxSalary(null)); - relaxConditions.add(() -> newSearch.setMinSalary(null)); - relaxConditions.add(() -> newSearch.setEducation(null)); - relaxConditions.add(()-> newSearch.setJobTitle(null)); - // 保存所有查询到的文档 - List allDocuments = new ArrayList<>(esJobDocuments); - - // 记录已经放宽的条件数量 - int relaxedConditions = 0; - - // 继续放宽条件直到满足分页或所有条件都已放宽 - while (allDocuments.size() < esJobSearch.getPageSize() - && relaxedConditions < relaxConditions.size()) { - // 放宽下一个条件 - relaxConditions.get(relaxedConditions).run(); - relaxedConditions++; - - // 查询新增的文档(不包含之前已经查询到的) - wrapper = getWrapper(newSearch, jobIds); - wrapper.limit(esJobSearch.getPageSize() - allDocuments.size()); - if (!allDocuments.isEmpty()) { - // 排除已经查询到的文档ID - Set existingIds = allDocuments.stream() - .map(ESJobDocument::getId) - .collect(Collectors.toSet()); - wrapper.not().in(ESJobDocument::getId, existingIds); - } - - List newDocuments = esJobDocumentMapper.selectList(wrapper); - allDocuments.addAll(newDocuments); - } - - // 如果总数超过分页大小,截取所需数量 - if (allDocuments.size() > esJobSearch.getPageSize()) { - esJobDocuments = allDocuments.subList(0, esJobSearch.getPageSize()); - } else { - esJobDocuments = allDocuments; - } - } +// if (!isCompanyUser &&esJobDocuments.size() < esJobSearch.getPageSize()) { +// // 定义要逐步放宽的搜索条件字段 +// List relaxConditions = new ArrayList<>(); +// relaxConditions.add(() -> newSearch.setArea(null)); +// relaxConditions.add(() -> newSearch.setExperience(null)); +// relaxConditions.add(() -> newSearch.setMaxSalary(null)); +// relaxConditions.add(() -> newSearch.setMinSalary(null)); +// relaxConditions.add(() -> newSearch.setEducation(null)); +// relaxConditions.add(()-> newSearch.setJobTitle(null)); +// // 保存所有查询到的文档 +// List allDocuments = new ArrayList<>(esJobDocuments); +// +// // 记录已经放宽的条件数量 +// int relaxedConditions = 0; +// +// // 继续放宽条件直到满足分页或所有条件都已放宽 +// while (allDocuments.size() < esJobSearch.getPageSize() +// && relaxedConditions < relaxConditions.size()) { +// // 放宽下一个条件 +// relaxConditions.get(relaxedConditions).run(); +// relaxedConditions++; +// +// // 查询新增的文档(不包含之前已经查询到的) +// wrapper = getWrapper(newSearch, jobIds); +// wrapper.limit(esJobSearch.getPageSize() - allDocuments.size()); +// if (!allDocuments.isEmpty()) { +// // 排除已经查询到的文档ID +// Set existingIds = allDocuments.stream() +// .map(ESJobDocument::getId) +// .collect(Collectors.toSet()); +// wrapper.not().in(ESJobDocument::getId, existingIds); +// } +// +// List newDocuments = esJobDocumentMapper.selectList(wrapper); +// allDocuments.addAll(newDocuments); +// } +// +// // 如果总数超过分页大小,截取所需数量 +// if (allDocuments.size() > esJobSearch.getPageSize()) { +// esJobDocuments = allDocuments.subList(0, esJobSearch.getPageSize()); +// } else { +// esJobDocuments = allDocuments; +// } +// } return esJobDocuments; } @@ -920,50 +920,50 @@ public class ESJobSearchImpl implements IESJobSearchService wrapper.limit(esJobSearch.getPageSize()); List esJobDocuments = esJobDocumentMapper.selectList(wrapper); - if (esJobDocuments.size() < esJobSearch.getPageSize()) { - // 定义要逐步放宽的搜索条件字段 - List relaxConditions = new ArrayList<>(); - relaxConditions.add(() -> newSearch.setArea(null)); - relaxConditions.add(() -> newSearch.setExperience(null)); - relaxConditions.add(() -> newSearch.setMaxSalary(null)); - relaxConditions.add(() -> newSearch.setMinSalary(null)); - relaxConditions.add(() -> newSearch.setEducation(null)); - relaxConditions.add(()-> newSearch.setJobTitle(null)); - // 保存所有查询到的文档 - List allDocuments = new ArrayList<>(esJobDocuments); - - // 记录已经放宽的条件数量 - int relaxedConditions = 0; - - // 继续放宽条件直到满足分页或所有条件都已放宽 - while (allDocuments.size() < esJobSearch.getPageSize() - && relaxedConditions < relaxConditions.size()) { - // 放宽下一个条件 - relaxConditions.get(relaxedConditions).run(); - relaxedConditions++; - - // 查询新增的文档(不包含之前已经查询到的) - wrapper = getWrapper(newSearch, jobIds); - wrapper.limit(esJobSearch.getPageSize() - allDocuments.size()); - if (!allDocuments.isEmpty()) { - // 排除已经查询到的文档ID - Set existingIds = allDocuments.stream() - .map(ESJobDocument::getId) - .collect(Collectors.toSet()); - wrapper.not().in(ESJobDocument::getId, existingIds); - } - - List newDocuments = esJobDocumentMapper.selectList(wrapper); - allDocuments.addAll(newDocuments); - } - - // 如果总数超过分页大小,截取所需数量 - if (allDocuments.size() > esJobSearch.getPageSize()) { - esJobDocuments = allDocuments.subList(0, esJobSearch.getPageSize()); - } else { - esJobDocuments = allDocuments; - } - } +// if (esJobDocuments.size() < esJobSearch.getPageSize()) { +// // 定义要逐步放宽的搜索条件字段 +// List relaxConditions = new ArrayList<>(); +// relaxConditions.add(() -> newSearch.setArea(null)); +// relaxConditions.add(() -> newSearch.setExperience(null)); +// relaxConditions.add(() -> newSearch.setMaxSalary(null)); +// relaxConditions.add(() -> newSearch.setMinSalary(null)); +// relaxConditions.add(() -> newSearch.setEducation(null)); +// relaxConditions.add(()-> newSearch.setJobTitle(null)); +// // 保存所有查询到的文档 +// List allDocuments = new ArrayList<>(esJobDocuments); +// +// // 记录已经放宽的条件数量 +// int relaxedConditions = 0; +// +// // 继续放宽条件直到满足分页或所有条件都已放宽 +// while (allDocuments.size() < esJobSearch.getPageSize() +// && relaxedConditions < relaxConditions.size()) { +// // 放宽下一个条件 +// relaxConditions.get(relaxedConditions).run(); +// relaxedConditions++; +// +// // 查询新增的文档(不包含之前已经查询到的) +// wrapper = getWrapper(newSearch, jobIds); +// wrapper.limit(esJobSearch.getPageSize() - allDocuments.size()); +// if (!allDocuments.isEmpty()) { +// // 排除已经查询到的文档ID +// Set existingIds = allDocuments.stream() +// .map(ESJobDocument::getId) +// .collect(Collectors.toSet()); +// wrapper.not().in(ESJobDocument::getId, existingIds); +// } +// +// List newDocuments = esJobDocumentMapper.selectList(wrapper); +// allDocuments.addAll(newDocuments); +// } +// +// // 如果总数超过分页大小,截取所需数量 +// if (allDocuments.size() > esJobSearch.getPageSize()) { +// esJobDocuments = allDocuments.subList(0, esJobSearch.getPageSize()); +// } else { +// esJobDocuments = allDocuments; +// } +// } return esJobDocuments; } diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleServiceImpl.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleServiceImpl.java index 9db4a74..fd5e37d 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleServiceImpl.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleServiceImpl.java @@ -6,8 +6,8 @@ import java.util.stream.Collectors; import com.alibaba.fastjson2.JSONArray; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.ruoyi.cms.constant.CommonConstant; -import com.ruoyi.cms.domain.SubwayLine; import com.ruoyi.cms.util.JobTitleUtils; +import com.ruoyi.common.constant.CacheConstants; import com.ruoyi.common.core.domain.TreeSelect; import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.utils.StringUtils; @@ -30,6 +30,8 @@ public class JobTitleServiceImpl extends ServiceImpl im { @Autowired private JobTitleMapper jobTitleMapper; + @Autowired + private RedisCache redisCache; /** * 查询岗位 @@ -228,4 +230,19 @@ public class JobTitleServiceImpl extends ServiceImpl im public List levelOne() { return jobTitleMapper.selectList(Wrappers.lambdaQuery().eq(JobTitle::getParentId, 0l)); } + + @Override + public Set jobTitleNameSets(){ + Set labelSet = redisCache.getCacheObject(CacheConstants.JOB_LABEL_REDIS_KEY); + if(labelSet == null || labelSet.isEmpty()){ + List list = jobTitleMapper.selectJobTitleList(new JobTitle()); + labelSet = list.stream() + .map(JobTitle::getJobName) + .filter(label -> label != null && !label.trim().isEmpty()) + .map(String::trim) + .collect(Collectors.toSet()); + redisCache.setCacheObject(CacheConstants.JOB_LABEL_REDIS_KEY, labelSet); + } + return labelSet; + } } diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/EsAnalyzeUtil.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/EsAnalyzeUtil.java new file mode 100644 index 0000000..e6332b2 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/EsAnalyzeUtil.java @@ -0,0 +1,63 @@ +package com.ruoyi.cms.util.ik; + +import org.elasticsearch.client.RequestOptions; +import org.elasticsearch.client.RestHighLevelClient; +import org.elasticsearch.client.indices.AnalyzeRequest; +import org.elasticsearch.client.indices.AnalyzeResponse; +import org.springframework.stereotype.Component; +import javax.annotation.Resource; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class EsAnalyzeUtil { + + @Resource + private RestHighLevelClient restHighLevelClient; + + /** + * 获取分词Token,携带偏移量 + * @param text 原文 + * @param analyzer ik_max_word / ik_smart + */ + public List analyze(String text, String analyzer) { + AnalyzeRequest request = AnalyzeRequest.withGlobalAnalyzer(analyzer, text); + try { + AnalyzeResponse response = restHighLevelClient.indices().analyze(request, RequestOptions.DEFAULT); + return response.getTokens().stream() + .map(token -> { + TokenItem item = new TokenItem(); + item.term = token.getTerm(); + item.startOffset = token.getStartOffset(); + item.endOffset = token.getEndOffset(); + return item; + }) + .collect(Collectors.toList()); + } catch (IOException e) { + throw new RuntimeException("调用ES分词接口异常", e); + } + } + + public static class TokenItem { + private String term; + private int startOffset; + private int endOffset; + + public String getLexemeText() { + return term; + } + + public String getTerm() { + return term; + } + + public int getStartOffset() { + return startOffset; + } + + public int getEndOffset() { + return endOffset; + } + } +} \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/TextConditionParserUtil.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/TextConditionParserUtil.java new file mode 100644 index 0000000..510ebbd --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/ik/TextConditionParserUtil.java @@ -0,0 +1,349 @@ +package com.ruoyi.cms.util.ik; + +import com.ruoyi.cms.domain.ik.JobSearchCondition; +import com.ruoyi.cms.domain.ik.ParserDictDTO; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + + +public class TextConditionParserUtil { + + // ====================== 正则规则【不变】 ====================== + private static final Pattern YEAR_PATTERN = Pattern.compile("(\\d+)\\s*-\\s*(\\d+)年|(\\d+)年以上|(\\d+)年以内"); + /** 区间 3000~6000|3k-6k|3千~6千 */ + private static final Pattern P_SALARY_RANGE = Pattern.compile( + "(\\d+)\\s*[-~~]\\s*(\\d+)|(\\d+)k\\s*[-~~]\\s*(\\d+)k|(\\d+)千\\s*[-~~]\\s*(\\d+)千" + ); + /** 不低于 / 以上 >= */ + private static final Pattern P_SALARY_GE = Pattern.compile( + "(月薪|薪资|工资)不低于\\s*(\\d+)(k|千)?|(\\d+)(k|千)?\\s*(以上)" + ); + /** 不高于 / 以下 <= */ + private static final Pattern P_SALARY_LE = Pattern.compile( + "(月薪|薪资|工资)不高于\\s*(\\d+)(k|千)?|(\\d+)(k|千)?\\s*(以下)" + ); + /** 精准薪资:月薪5000、工资5000、薪资5000 */ + private static final Pattern P_SALARY_NUM = Pattern.compile( + "(月薪|薪资|工资)\\s*(\\d+)(k|千)?" + ); + + /** 精准中文薪资:月薪五千、薪资一万【独立正则,不和阿拉伯混合!重点修复】 */ + private static final Pattern P_SALARY_CHINESE = Pattern.compile( + "(月薪|薪资|工资)\\s*(一|二|三|四|五|六|七|八|九|十)(千|万)" + ); + + //中文数字映射表 + private static Long getChineseNum(String numChar, String unit) { + int num; + switch (numChar) { + case "一": num = 1; break; + case "二": num = 2; break; + case "三": num = 3; break; + case "四": num = 4; break; + case "五": num = 5; break; + case "六": num = 6; break; + case "七": num = 7; break; + case "八": num = 8; break; + case "九": num = 9; break; + case "十": num = 10; break; + default: return null; + } + Long base = (long) num; + if ("千".equals(unit)) { + base = base * 1000L; + } else if ("万".equals(unit)) { + base = base * 10000L; + } + return base; + } + + // 匹配单独年限:3年、5年工作经验 + private static final Pattern SINGLE_YEAR_PATTERN = Pattern.compile("(\\d+)年"); + + /** + * 解析入口,词典由上层Service传入(来自Redis) + * @param input 用户输入原文 + * @param tokenList ES分词结果 + * @param dictDTO 字典数据(Redis加载) + */ + public static JobSearchCondition parse(String input, + List tokenList, + ParserDictDTO dictDTO) { + if (input == null || input.trim().isEmpty()) { + return new JobSearchCondition(); + } + if(dictDTO == null){ + throw new RuntimeException("解析字典不能为空"); + } + input = input.trim(); + JobSearchCondition condition = new JobSearchCondition(); + + // 1.薪资、年限区间抽取 + extractYearRange(input, condition); + extractSalaryRange(input, condition); + + // 2.区县识别【长词优先】 + extractAreaCode(input, condition, dictDTO.getAreaMap()); + // 3.工作经验识别【长词优先】 + extractWorkExpCode(input, condition, dictDTO.getWorkExpMap()); + if (condition.getExperience() == null) { + Matcher singleYearMatcher = SINGLE_YEAR_PATTERN.matcher(input); + if (singleYearMatcher.find()) { + int year = Integer.parseInt(singleYearMatcher.group(1)); + Integer expCode = convertYearToExpCode(year); + condition.setExperience(String.valueOf(expCode)); + // 设置文本用于过滤jobName + condition.setWorkExpText(singleYearMatcher.group()); + } + } + // 4.学历识别【长词优先】 + extractEducationCode(input, condition, dictDTO.getEducationMap()); + + // 5.拼接岗位名称,过滤已识别维度词汇 + List matchJobList= extractStandardJobName(input, tokenList,dictDTO.getStandardJobSet(),dictDTO.getJobIndexMap()); + if (matchJobList != null && !matchJobList.isEmpty()) { + condition.setJobTitle(String.join(",", matchJobList)); + } else { + condition.setJobTitle(input); + } + return condition; + } + + private static void extractAreaCode(String text, JobSearchCondition condition, Map areaMap) { + List> entryList = new ArrayList<>(areaMap.entrySet()); + entryList.sort((o1, o2) -> Integer.compare(o2.getKey().length(), o1.getKey().length())); + for (Map.Entry entry : entryList) { + String areaName = entry.getKey(); + if (text.contains(areaName)) { + condition.setAddress(areaName); + condition.setJobLocationAreaCode(String.valueOf(entry.getValue())); + break; + } + } + } + + private static void extractWorkExpCode(String text, JobSearchCondition condition, Map expMap) { + List> entryList = new ArrayList<>(expMap.entrySet()); + entryList.sort((o1, o2) -> Integer.compare(o2.getKey().length(), o1.getKey().length())); + for (Map.Entry entry : entryList) { + String key = entry.getKey(); + if (text.contains(key)) { + condition.setExperience(String.valueOf(entry.getValue())); + // 新增 + condition.setWorkExpText(key); + break; + } + } + } + + private static void extractEducationCode(String text, JobSearchCondition condition, Map eduMap) { + List> entryList = new ArrayList<>(eduMap.entrySet()); + entryList.sort((o1, o2) -> Integer.compare(o2.getKey().length(), o1.getKey().length())); + for (Map.Entry entry : entryList) { + String key = entry.getKey(); + if (text.contains(key)) { + condition.setEducation(String.valueOf(entry.getValue())); + condition.setEducationText(key); + break; + } + } + } + + private static void extractYearRange(String text, JobSearchCondition condition) { + Matcher matcher = YEAR_PATTERN.matcher(text); + if (matcher.find()) { + if (matcher.group(1) != null && matcher.group(2) != null) { + condition.setWorkYearMin(Integer.parseInt(matcher.group(1))); + condition.setWorkYearMax(Integer.parseInt(matcher.group(2))); + } else if (matcher.group(3) != null) { + condition.setWorkYearMin(Integer.parseInt(matcher.group(3))); + } else if (matcher.group(4) != null) { + condition.setWorkYearMax(Integer.parseInt(matcher.group(4))); + } + } + } + + private static void extractSalaryRange(String text, JobSearchCondition condition) { + text = text.trim(); + Matcher m; + + // 1.优先匹配区间 + m = P_SALARY_RANGE.matcher(text); + if (m.find()) { + if (m.group(1) != null && m.group(2) != null) { + condition.setMinSalary(Long.parseLong(m.group(1))); + condition.setMaxSalary(Long.parseLong(m.group(2))); + } else if (m.group(3) != null && m.group(4) != null) { + // 3k~6k + Long num1 = Long.parseLong(m.group(3)) * 1000L; + Long num2 = Long.parseLong(m.group(4)) * 1000L; + condition.setMinSalary(num1); + condition.setMaxSalary(num2); + } else if (m.group(5) != null && m.group(6) != null) { + Long num1 = Long.parseLong(m.group(5)) * 1000L; + Long num2 = Long.parseLong(m.group(6)) * 1000L; + condition.setMinSalary(num1); + condition.setMaxSalary(num2); + } + return; + } + + // 2.匹配 >= + m = P_SALARY_GE.matcher(text); + if (m.find()) { + String numStr = null; + if (m.group(2) != null) numStr = m.group(2); + if (m.group(4) != null) numStr = m.group(4); + if (numStr != null) { + // 字符串转Long + Long num = Long.parseLong(numStr); + // 判断单位 k / 千,乘以1000 + if ("k".equals(m.group(3)) || "千".equals(m.group(3)) + || "k".equals(m.group(5)) || "千".equals(m.group(5))) { + num = num * 1000L; + } + condition.setMinSalary(num); + } + return; + } + + // 3.匹配 <= + m = P_SALARY_LE.matcher(text); + if (m.find()) { + String numStr = null; + if (m.group(2) != null) numStr = m.group(2); + if (m.group(4) != null) numStr = m.group(4); + if (numStr != null) { + Long num = Long.parseLong(numStr); + if ("k".equals(m.group(3)) || "千".equals(m.group(3)) + || "k".equals(m.group(5)) || "千".equals(m.group(5))) { + num = num * 1000L; + } + condition.setMaxSalary(num); + } + return; + } + + // 4.阿拉伯数字薪资 + m = P_SALARY_NUM.matcher(text); + if (m.find()) { + Long num = safeParseLong(m.group(2)); + if (num != null) { + if ("k".equals(m.group(3)) || "千".equals(m.group(3))) { + num = num * 1000L; + } + condition.setMinSalary(num); + //condition.setMaxSalary(num); + } + return; + } + + // 5.中文薪资【月薪五千】单独匹配 + m = P_SALARY_CHINESE.matcher(text); + if (m.find()) { + Long num = getChineseNum(m.group(2), m.group(3)); + condition.setMinSalary(num); + //condition.setMaxSalary(num); + } + } + + /** + * 根据工作年限数字,换算成 workExpCode + */ + private static Integer convertYearToExpCode(int year) { + if (year < 1) { + return 3; + } else if (year <= 3) { + return 4; + } else if (year <= 5) { + return 5; + } else if (year <= 10) { + return 6; + } else { + return 7; + } + } + + /** + * 正向匹配国标岗位名称(长词优先) + * @param input 用户原始输入文本 + * @param standardJobSet 国标岗位集合 + * @return 匹配到的岗位名称,无匹配返回null + */ + private static List extractStandardJobName(String input,List tokenList, Set standardJobSet, Map jobIndexMap) { + if (standardJobSet == null || standardJobSet.isEmpty() || jobIndexMap == null || jobIndexMap.isEmpty()) { + return null; + } + String text = input.replace("/", ""); + List jobList = new ArrayList<>(standardJobSet); + // 长名称优先,优先匹配更长岗位,避免短词根优先命中 + jobList.sort((s1, s2) -> Integer.compare(s2.length(), s1.length())); + final int MAX_MATCH_SIZE = 5; + + for (String jobName : jobList) { + if (text.contains(jobName)) { + return Collections.singletonList(jobName); + } + } + + Set matchSet = new HashSet<>(); + if (tokenList != null && !tokenList.isEmpty()) { + for (EsAnalyzeUtil.TokenItem token : tokenList) { + String word = token.getLexemeText().trim(); + if (word.length() < 2) { + continue; + } + for (String jobName : jobList) { + if (jobName.contains(word)) { + matchSet.add(jobName); + if (matchSet.size() >= MAX_MATCH_SIZE) { + return new ArrayList<>(matchSet); + } + } + } + } + } + + if (!matchSet.isEmpty()) { + return new ArrayList<>(matchSet); + } + return null; + } + + private static boolean isFilterWord(String word, JobSearchCondition condition) { + // 基础过滤词 + if (word.matches("\\d+") || new ArrayList<>(Arrays.asList( + "k", "千", "年", "月薪", "薪资", "工资", + "以上", "以下", "的", "不低于", "不高于", + "学历", "工作经验" + )).contains(word)) { + return true; + } + // 过滤区县 + if (condition.getAddress() != null && condition.getAddress().contains(word)) { + return true; + } + // 过滤学历文本(本科、大专...) + if (condition.getEducationText() != null && condition.getEducationText().contains(word)) { + return true; + } + // 过滤经验文本(1-3年、应届毕业生...) + if (condition.getWorkExpText() != null && condition.getWorkExpText().contains(word)) { + return true; + } + return false; + } + + private static Long safeParseLong(String str) { + if (str == null || str.trim().isEmpty()) { + return null; + } + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + return null; + } + } +} \ No newline at end of file diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java b/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java index 71d375f..13c1111 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java @@ -54,4 +54,9 @@ public class CacheConstants * 刷新token */ public static final String LOGIN_SITE_REFRESH_KEY = "login_site_user:refresh_token:"; + + /** + * 国标岗位名称 + */ + public static final String JOB_LABEL_REDIS_KEY = "dict:job_title:label_list"; }