From 1e7853b9a4ee596453dce4202af30c41867a99c0 Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Thu, 16 Jul 2026 10:02:56 +0800 Subject: [PATCH] feat: Implement job title suggestion service with caching and tokenization --- ruoyi-bussiness/pom.xml | 20 ++ .../cms/controller/app/AppJobController.java | 21 ++ .../main/java/com/ruoyi/cms/cron/JobCron.java | 9 + .../java/com/ruoyi/cms/mapper/JobMapper.java | 7 + .../cms/service/IJobTitleSuggestService.java | 23 +++ .../impl/JobTitleSuggestServiceImpl.java | 187 ++++++++++++++++++ .../cms/util/JobTitleSuggestTokenizer.java | 66 +++++++ .../main/resources/mapper/app/JobMapper.xml | 11 +- 8 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleSuggestService.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleSuggestServiceImpl.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/JobTitleSuggestTokenizer.java diff --git a/ruoyi-bussiness/pom.xml b/ruoyi-bussiness/pom.xml index dea0f85..2e66ffb 100644 --- a/ruoyi-bussiness/pom.xml +++ b/ruoyi-bussiness/pom.xml @@ -102,6 +102,26 @@ easy-es-boot-starter 2.0.0 + + + com.jianggujin + IKAnalyzer-lucene + 8.0.0 + + + org.apache.lucene + lucene-core + + + org.apache.lucene + lucene-queryparser + + + org.apache.lucene + lucene-analyzers-common + + + cn.hutool hutool-all 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 0ca6aef..4696025 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 @@ -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; + /** + * 岗位名称联想 + * + *

缓存由定时任务根据有效岗位名称预先构建,接口只查询 Redis, + * 不在用户输入时访问岗位数据库。

+ */ + @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)); + } + /** * 查询岗位列表 */ diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/cron/JobCron.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/cron/JobCron.java index dc802ff..40f3e2a 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/cron/JobCron.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/cron/JobCron.java @@ -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(); + } } diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/JobMapper.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/JobMapper.java index fd6085e..df5147b 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/JobMapper.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/mapper/JobMapper.java @@ -43,6 +43,13 @@ public interface JobMapper extends BaseMapper { List selectAllJob(Map params); + /** + * 查询去重后的有效岗位名称,供 IK 分词缓存构建使用。 + * + * @return 有效岗位名称 + */ + List selectValidJobTitles(); + void insertBatchRowWork(List batchList); List selectAllInsertRowWork(); diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleSuggestService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleSuggestService.java new file mode 100644 index 0000000..cd4700e --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/IJobTitleSuggestService.java @@ -0,0 +1,23 @@ +package com.ruoyi.cms.service; + +import java.util.List; + +/** + * 岗位名称联想服务。 + */ +public interface IJobTitleSuggestService { + + /** + * 根据输入前缀查询岗位名称建议。 + * + * @param keyword 输入关键词 + * @param limit 最大返回数量 + * @return 岗位名称列表 + */ + List suggest(String keyword, Integer limit); + + /** + * 重建有效岗位名称的 Redis 联想缓存。 + */ + void rebuildCache(); +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleSuggestServiceImpl.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleSuggestServiceImpl.java new file mode 100644 index 0000000..a21ed2f --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleSuggestServiceImpl.java @@ -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 suggest(String keyword, Integer limit) { + String normalizedKeyword = JobTitleSuggestTokenizer.normalize(keyword); + if (normalizedKeyword.isEmpty()) { + return Collections.emptyList(); + } + + int resultLimit = normalizeLimit(limit); + try { + Map tokenTitles = redisCache.getCacheMap(CACHE_KEY); + if (tokenTitles == null || tokenTitles.isEmpty()) { + return Collections.emptyList(); + } + + // Hash field 只来自 IK 分词;这里仅对已有词项做前缀过滤,不生成新的前缀词。 + List> matchedTokens = tokenTitles.entrySet().stream() + .filter(entry -> entry.getKey() != null) + .filter(entry -> entry.getKey().startsWith(normalizedKeyword)) + .sorted(Map.Entry.comparingByKey()) + .collect(Collectors.toList()); + + Set result = new LinkedHashSet<>(); + for (Map.Entry 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 sourceList = jobMapper.selectValidJobTitles(); + Map> 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> buildTokenTitles(List sourceList) { + Map> tokenTitles = new TreeMap<>(); + if (sourceList == null) { + return Collections.emptyMap(); + } + + for (String title : sourceList) { + String displayTitle = normalizeDisplayTitle(title); + if (displayTitle.isEmpty()) { + continue; + } + + Set tokens = JobTitleSuggestTokenizer.tokenize(displayTitle); + for (String token : tokens) { + tokenTitles.computeIfAbsent(token, key -> new TreeSet<>()).add(displayTitle); + } + } + + Map> result = new LinkedHashMap<>(); + for (Map.Entry> entry : tokenTitles.entrySet()) { + result.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + return result; + } + + private void appendTitles(Set 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)); + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/JobTitleSuggestTokenizer.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/JobTitleSuggestTokenizer.java new file mode 100644 index 0000000..858a361 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/util/JobTitleSuggestTokenizer.java @@ -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 分词器。 + * + *

岗位名称中通常同时包含中文、英文、数字和技术栈符号,例如 + * {@code Java开发工程师}、{@code C++工程师}。这里只保留 IKSegmenter + * smart 模式产生的分词结果,不额外生成完整岗位名称、字符片段或前缀。

+ */ +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 tokenize(String title) { + String normalizedTitle = normalize(title); + Set 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; + } + +} diff --git a/ruoyi-bussiness/src/main/resources/mapper/app/JobMapper.xml b/ruoyi-bussiness/src/main/resources/mapper/app/JobMapper.xml index b7b38cf..8f30bb1 100644 --- a/ruoyi-bussiness/src/main/resources/mapper/app/JobMapper.xml +++ b/ruoyi-bussiness/src/main/resources/mapper/app/JobMapper.xml @@ -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} + +