feat: Implement job title suggestion service with caching and tokenization
This commit is contained in:
@@ -102,6 +102,26 @@
|
||||
<artifactId>easy-es-boot-starter</artifactId>
|
||||
<version>2.0.0</version>
|
||||
</dependency>
|
||||
<!-- Java 侧岗位名称预分词;使用 IKSegmenter,不覆盖 Elasticsearch 自带的 Lucene 版本 -->
|
||||
<dependency>
|
||||
<groupId>com.jianggujin</groupId>
|
||||
<artifactId>IKAnalyzer-lucene</artifactId>
|
||||
<version>8.0.0</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.apache.lucene</groupId>
|
||||
<artifactId>lucene-core</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.apache.lucene</groupId>
|
||||
<artifactId>lucene-queryparser</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.apache.lucene</groupId>
|
||||
<artifactId>lucene-analyzers-common</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.ruoyi.cms.domain.query.ESJobSearch;
|
||||
import com.ruoyi.cms.service.IESJobSearchService;
|
||||
import com.ruoyi.cms.service.IJobCollectionService;
|
||||
import com.ruoyi.cms.service.IJobService;
|
||||
import com.ruoyi.cms.service.IJobTitleSuggestService;
|
||||
import com.ruoyi.cms.util.RoleUtils;
|
||||
import com.ruoyi.cms.util.sensitiveWord.SensitiveWordChecker;
|
||||
import com.ruoyi.common.annotation.BussinessLog;
|
||||
@@ -22,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -42,8 +44,27 @@ public class AppJobController extends BaseController
|
||||
@Autowired
|
||||
private IESJobSearchService esJobSearchService;
|
||||
@Autowired
|
||||
private IJobTitleSuggestService jobTitleSuggestService;
|
||||
@Autowired
|
||||
private SensitiveWordChecker sensitiveWordChecker;
|
||||
|
||||
/**
|
||||
* 岗位名称联想
|
||||
*
|
||||
* <p>缓存由定时任务根据有效岗位名称预先构建,接口只查询 Redis,
|
||||
* 不在用户输入时访问岗位数据库。</p>
|
||||
*/
|
||||
@ApiOperation("岗位名称联想")
|
||||
@GetMapping("/suggest")
|
||||
public AjaxResult suggest(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false, defaultValue = "10") Integer limit)
|
||||
{
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return success(Collections.emptyList());
|
||||
}
|
||||
return success(jobTitleSuggestService.suggest(keyword, limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询岗位列表
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.ruoyi.cms.service.IBussinessOperLogService;
|
||||
import com.ruoyi.cms.service.ICompanyService;
|
||||
import com.ruoyi.cms.service.IESJobSearchService;
|
||||
import com.ruoyi.cms.service.IJobService;
|
||||
import com.ruoyi.cms.service.IJobTitleSuggestService;
|
||||
import com.ruoyi.common.utils.spring.SpringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -26,4 +27,12 @@ public class JobCron {
|
||||
public void updateJobCountOfCompany(){
|
||||
SpringUtils.getBean(ICompanyService.class).updateJobCountOfCompany();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建岗位名称联想缓存。
|
||||
* Quartz 任务调用目标:jobCron.rebuildJobTitleSuggest()
|
||||
*/
|
||||
public void rebuildJobTitleSuggest(){
|
||||
SpringUtils.getBean(IJobTitleSuggestService.class).rebuildCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ public interface JobMapper extends BaseMapper<Job> {
|
||||
|
||||
List<Job> selectAllJob(Map<String, Object> params);
|
||||
|
||||
/**
|
||||
* 查询去重后的有效岗位名称,供 IK 分词缓存构建使用。
|
||||
*
|
||||
* @return 有效岗位名称
|
||||
*/
|
||||
List<String> selectValidJobTitles();
|
||||
|
||||
void insertBatchRowWork(List<RowWork> batchList);
|
||||
|
||||
List<RowWork> selectAllInsertRowWork();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ruoyi.cms.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 岗位名称联想服务。
|
||||
*/
|
||||
public interface IJobTitleSuggestService {
|
||||
|
||||
/**
|
||||
* 根据输入前缀查询岗位名称建议。
|
||||
*
|
||||
* @param keyword 输入关键词
|
||||
* @param limit 最大返回数量
|
||||
* @return 岗位名称列表
|
||||
*/
|
||||
List<String> suggest(String keyword, Integer limit);
|
||||
|
||||
/**
|
||||
* 重建有效岗位名称的 Redis 联想缓存。
|
||||
*/
|
||||
void rebuildCache();
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.ruoyi.cms.service.impl;
|
||||
|
||||
import com.ruoyi.cms.mapper.JobMapper;
|
||||
import com.ruoyi.cms.service.IJobTitleSuggestService;
|
||||
import com.ruoyi.cms.util.JobTitleSuggestTokenizer;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 岗位名称 IK 分词联想服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class JobTitleSuggestServiceImpl implements IJobTitleSuggestService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JobTitleSuggestServiceImpl.class);
|
||||
|
||||
/**
|
||||
* Redis Hash:field 是 IK 分词,value 是包含该分词的完整岗位名称列表。
|
||||
*/
|
||||
private static final String CACHE_KEY = "app:job:title:suggest";
|
||||
private static final String BUILDING_CACHE_KEY = CACHE_KEY + ":building";
|
||||
private static final String LOCK_KEY = CACHE_KEY + ":rebuild:lock";
|
||||
|
||||
private static final int DEFAULT_LIMIT = 10;
|
||||
private static final int MAX_LIMIT = 20;
|
||||
private static final int LOCK_EXPIRE_MINUTES = 30;
|
||||
|
||||
@Autowired
|
||||
private JobMapper jobMapper;
|
||||
|
||||
@Autowired
|
||||
private RedisCache redisCache;
|
||||
|
||||
/**
|
||||
* 确保服务启动后 Redis 中已有一份缓存;后续由 Quartz 定时任务周期性重建。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
rebuildCache();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggest(String keyword, Integer limit) {
|
||||
String normalizedKeyword = JobTitleSuggestTokenizer.normalize(keyword);
|
||||
if (normalizedKeyword.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
int resultLimit = normalizeLimit(limit);
|
||||
try {
|
||||
Map<String, Object> tokenTitles = redisCache.getCacheMap(CACHE_KEY);
|
||||
if (tokenTitles == null || tokenTitles.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Hash field 只来自 IK 分词;这里仅对已有词项做前缀过滤,不生成新的前缀词。
|
||||
List<Map.Entry<String, Object>> matchedTokens = tokenTitles.entrySet().stream()
|
||||
.filter(entry -> entry.getKey() != null)
|
||||
.filter(entry -> entry.getKey().startsWith(normalizedKeyword))
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
for (Map.Entry<String, Object> entry : matchedTokens) {
|
||||
if (result.size() >= resultLimit) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 先返回匹配到的 IK 分词本身。
|
||||
result.add(entry.getKey());
|
||||
|
||||
// 再返回包含该 IK 分词的完整岗位名称。
|
||||
appendTitles(result, entry.getValue(), resultLimit);
|
||||
}
|
||||
return new ArrayList<>(result).subList(0, Math.min(result.size(), resultLimit));
|
||||
} catch (Exception e) {
|
||||
// 联想属于非核心能力,Redis 异常时返回空列表,不影响岗位主搜索。
|
||||
logger.warn("查询岗位名称 IK 分词缓存失败,keyword={}", keyword, e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rebuildCache() {
|
||||
String lockValue = UUID.randomUUID().toString();
|
||||
boolean lockAcquired = false;
|
||||
try {
|
||||
lockAcquired = redisCache.setIfAbsent(
|
||||
LOCK_KEY, lockValue, LOCK_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
if (!lockAcquired) {
|
||||
logger.info("其他节点正在重建岗位名称 IK 分词缓存,本节点跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> sourceList = jobMapper.selectValidJobTitles();
|
||||
Map<String, List<String>> cacheData = buildTokenTitles(sourceList);
|
||||
|
||||
// 先写临时 Hash,再原子替换正式 Hash,避免接口读到半成品。
|
||||
redisCache.deleteObject(BUILDING_CACHE_KEY);
|
||||
if (cacheData.isEmpty()) {
|
||||
redisCache.deleteObject(CACHE_KEY);
|
||||
} else {
|
||||
redisCache.setCacheMap(BUILDING_CACHE_KEY, cacheData);
|
||||
redisCache.redisTemplate.rename(BUILDING_CACHE_KEY, CACHE_KEY);
|
||||
}
|
||||
logger.info("岗位名称 IK 分词缓存重建完成,有效岗位名称{}个,IK 词{}个",
|
||||
sourceList == null ? 0 : sourceList.size(), cacheData.size());
|
||||
} catch (Exception e) {
|
||||
logger.error("重建岗位名称 IK 分词缓存失败", e);
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
redisCache.deleteObject(LOCK_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<String>> buildTokenTitles(List<String> sourceList) {
|
||||
Map<String, Set<String>> tokenTitles = new TreeMap<>();
|
||||
if (sourceList == null) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
for (String title : sourceList) {
|
||||
String displayTitle = normalizeDisplayTitle(title);
|
||||
if (displayTitle.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<String> tokens = JobTitleSuggestTokenizer.tokenize(displayTitle);
|
||||
for (String token : tokens) {
|
||||
tokenTitles.computeIfAbsent(token, key -> new TreeSet<>()).add(displayTitle);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<String>> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Set<String>> entry : tokenTitles.entrySet()) {
|
||||
result.put(entry.getKey(), new ArrayList<>(entry.getValue()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void appendTitles(Set<String> result, Object cachedTitles, int resultLimit) {
|
||||
if (!(cachedTitles instanceof Iterable)) {
|
||||
return;
|
||||
}
|
||||
for (Object cachedTitle : (Iterable<?>) cachedTitles) {
|
||||
if (cachedTitle == null || result.size() >= resultLimit) {
|
||||
break;
|
||||
}
|
||||
String title = String.valueOf(cachedTitle);
|
||||
if (!title.isEmpty()) {
|
||||
result.add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeDisplayTitle(String title) {
|
||||
if (title == null) {
|
||||
return "";
|
||||
}
|
||||
return title.trim().replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private int normalizeLimit(Integer limit) {
|
||||
if (limit == null) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
return Math.max(1, Math.min(limit, MAX_LIMIT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ruoyi.cms.util;
|
||||
|
||||
import org.wltea.analyzer.core.IKSegmenter;
|
||||
import org.wltea.analyzer.core.Lexeme;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.text.Normalizer;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 岗位名称联想用的 IK 分词器。
|
||||
*
|
||||
* <p>岗位名称中通常同时包含中文、英文、数字和技术栈符号,例如
|
||||
* {@code Java开发工程师}、{@code C++工程师}。这里只保留 IKSegmenter
|
||||
* smart 模式产生的分词结果,不额外生成完整岗位名称、字符片段或前缀。</p>
|
||||
*/
|
||||
public final class JobTitleSuggestTokenizer {
|
||||
|
||||
private JobTitleSuggestTokenizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对岗位名称或查询词做统一标准化。
|
||||
*/
|
||||
public static String normalize(String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
String normalized = Normalizer.normalize(text, Normalizer.Form.NFKC)
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("\\s+", "")
|
||||
.trim();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成岗位名称的 IK 分词结果。
|
||||
*/
|
||||
public static Set<String> tokenize(String title) {
|
||||
String normalizedTitle = normalize(title);
|
||||
Set<String> result = new LinkedHashSet<>();
|
||||
if (normalizedTitle.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try (StringReader reader = new StringReader(normalizedTitle)) {
|
||||
// true 对应 IK 的 smart 分词模式,和 ES 中的 IK_SMART 保持一致。
|
||||
IKSegmenter segmenter = new IKSegmenter(reader, true);
|
||||
Lexeme lexeme;
|
||||
while ((lexeme = segmenter.next()) != null) {
|
||||
String token = normalize(lexeme.getLexemeText());
|
||||
if (!token.isEmpty()) {
|
||||
result.add(token);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// StringReader 通常不会发生 I/O 异常;异常时不添加非 IK 结果,
|
||||
// 保证 Redis 中始终只保留 IK 分词结果。
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -305,6 +305,15 @@
|
||||
LEFT JOIN (SELECT jc1.*, ROW_NUMBER() OVER(PARTITION BY jc1.job_id ORDER BY jc1.id) rn FROM job_contact jc1 WHERE jc1.del_flag='0') jc ON jc.job_id = j.job_id AND jc.rn=1
|
||||
WHERE j.del_flag = '0' and job_status='0' limit #{offset},#{batchSize}
|
||||
</select>
|
||||
|
||||
<select id="selectValidJobTitles" resultType="java.lang.String">
|
||||
SELECT DISTINCT trim(job_title)
|
||||
FROM job
|
||||
WHERE del_flag = '0'
|
||||
AND job_status = '0'
|
||||
AND job_title IS NOT NULL
|
||||
AND trim(job_title) <> ''
|
||||
</select>
|
||||
<select id="selectAllInsertRowWork" resultType="com.ruoyi.cms.domain.RowWork">
|
||||
select Id, TaskId, TaskName, Std_class, SF, ZCMC, Aca112, Acb22a, Aac011, Acb240,
|
||||
Recruit_Num, Acb202, Aab302, Acb241, Salary, SalaryLow, SalaryHight, Aae397,
|
||||
|
||||
Reference in New Issue
Block a user