feat: Implement job title suggestion service with caching and tokenization

This commit is contained in:
2026-07-16 10:02:56 +08:00
parent 8dea752d48
commit 1e7853b9a4
8 changed files with 343 additions and 1 deletions

View File

@@ -102,6 +102,26 @@
<artifactId>easy-es-boot-starter</artifactId> <artifactId>easy-es-boot-starter</artifactId>
<version>2.0.0</version> <version>2.0.0</version>
</dependency> </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> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>

View File

@@ -6,6 +6,7 @@ import com.ruoyi.cms.domain.query.ESJobSearch;
import com.ruoyi.cms.service.IESJobSearchService; import com.ruoyi.cms.service.IESJobSearchService;
import com.ruoyi.cms.service.IJobCollectionService; import com.ruoyi.cms.service.IJobCollectionService;
import com.ruoyi.cms.service.IJobService; import com.ruoyi.cms.service.IJobService;
import com.ruoyi.cms.service.IJobTitleSuggestService;
import com.ruoyi.cms.util.RoleUtils; import com.ruoyi.cms.util.RoleUtils;
import com.ruoyi.cms.util.sensitiveWord.SensitiveWordChecker; import com.ruoyi.cms.util.sensitiveWord.SensitiveWordChecker;
import com.ruoyi.common.annotation.BussinessLog; import com.ruoyi.common.annotation.BussinessLog;
@@ -22,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.List; import java.util.List;
/** /**
@@ -42,8 +44,27 @@ public class AppJobController extends BaseController
@Autowired @Autowired
private IESJobSearchService esJobSearchService; private IESJobSearchService esJobSearchService;
@Autowired @Autowired
private IJobTitleSuggestService jobTitleSuggestService;
@Autowired
private SensitiveWordChecker sensitiveWordChecker; 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));
}
/** /**
* 查询岗位列表 * 查询岗位列表
*/ */

View File

@@ -5,6 +5,7 @@ import com.ruoyi.cms.service.IBussinessOperLogService;
import com.ruoyi.cms.service.ICompanyService; import com.ruoyi.cms.service.ICompanyService;
import com.ruoyi.cms.service.IESJobSearchService; import com.ruoyi.cms.service.IESJobSearchService;
import com.ruoyi.cms.service.IJobService; import com.ruoyi.cms.service.IJobService;
import com.ruoyi.cms.service.IJobTitleSuggestService;
import com.ruoyi.common.utils.spring.SpringUtils; import com.ruoyi.common.utils.spring.SpringUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -26,4 +27,12 @@ public class JobCron {
public void updateJobCountOfCompany(){ public void updateJobCountOfCompany(){
SpringUtils.getBean(ICompanyService.class).updateJobCountOfCompany(); SpringUtils.getBean(ICompanyService.class).updateJobCountOfCompany();
} }
/**
* 重建岗位名称联想缓存。
* Quartz 任务调用目标jobCron.rebuildJobTitleSuggest()
*/
public void rebuildJobTitleSuggest(){
SpringUtils.getBean(IJobTitleSuggestService.class).rebuildCache();
}
} }

View File

@@ -43,6 +43,13 @@ public interface JobMapper extends BaseMapper<Job> {
List<Job> selectAllJob(Map<String, Object> params); List<Job> selectAllJob(Map<String, Object> params);
/**
* 查询去重后的有效岗位名称,供 IK 分词缓存构建使用。
*
* @return 有效岗位名称
*/
List<String> selectValidJobTitles();
void insertBatchRowWork(List<RowWork> batchList); void insertBatchRowWork(List<RowWork> batchList);
List<RowWork> selectAllInsertRowWork(); List<RowWork> selectAllInsertRowWork();

View File

@@ -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();
}

View File

@@ -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 Hashfield 是 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));
}
}

View File

@@ -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;
}
}

View File

@@ -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 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} WHERE j.del_flag = '0' and job_status='0' limit #{offset},#{batchSize}
</select> </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) &lt;&gt; ''
</select>
<select id="selectAllInsertRowWork" resultType="com.ruoyi.cms.domain.RowWork"> <select id="selectAllInsertRowWork" resultType="com.ruoyi.cms.domain.RowWork">
select Id, TaskId, TaskName, Std_class, SF, ZCMC, Aca112, Acb22a, Aac011, Acb240, select Id, TaskId, TaskName, Std_class, SF, ZCMC, Aca112, Acb22a, Aac011, Acb240,
Recruit_Num, Acb202, Aab302, Acb241, Salary, SalaryLow, SalaryHight, Aae397, Recruit_Num, Acb202, Aab302, Acb241, Salary, SalaryLow, SalaryHight, Aae397,