From 7d715736edda541505dd7c0dbb6985958ad45b2a Mon Sep 17 00:00:00 2001 From: chenshaohua <635616957@qq.com> Date: Wed, 29 Jul 2026 22:24:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=AF=AF=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-local.yml | 158 ++ .../cms/service/impl/ESJobSearchImpl.java | 1318 +++++++++++++++++ .../impl/JobTitleSuggestServiceImpl.java | 187 +++ 3 files changed, 1663 insertions(+) create mode 100644 ruoyi-admin/src/main/resources/application-local.yml create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/ESJobSearchImpl.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/JobTitleSuggestServiceImpl.java diff --git a/ruoyi-admin/src/main/resources/application-local.yml b/ruoyi-admin/src/main/resources/application-local.yml new file mode 100644 index 0000000..6fa3ae7 --- /dev/null +++ b/ruoyi-admin/src/main/resources/application-local.yml @@ -0,0 +1,158 @@ +# 项目相关配置 +ruoyi: + # 名称 + name: RuoYi + # 版本 + version: 3.8.8 + # 版权年份 + copyrightYear: 2024 + # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) + profile: /Users/chenyanchang/logs/ruoyi/uploadPath + # 获取ip地址开关 + addressEnabled: false + # 验证码类型 math 数字计算 char 字符验证 + captchaType: math + +# 数据源配置 +spring: + datasource: + type: com.alibaba.druid.pool.DruidDataSource + driverClassName: com.highgo.jdbc.Driver + druid: + # 主库数据源 + # master: + # url: jdbc:highgo://124.243.245.42:5866/shz?useUnicode=true&characterEncoding=utf8¤tSchema=shz&stringtype=unspecified + # #username: syssso + # username: sysdba + # password: ZKR2024@comzkr + # 正式环境 + # master: + # url: jdbc:highgo://39.98.44.136:6023/highgo?useUnicode=true&characterEncoding=utf8¤tSchema=shz&stringtype=unspecified + # username: sysdba + # password: Hello@2026 + # 测试环境 + master: + url: jdbc:highgo://47.111.103.66:5866/highgo?useUnicode=true&characterEncoding=utf8¤tSchema=shz&stringtype=unspecified + username: sysdba + password: Hello@2026 + # 从库数据源 + slave: + # 从数据源开关/默认关闭 + enabled: false + url: + username: + password: + # 初始连接数 + initialSize: 10 + # 最小连接池数量 + minIdle: 30 + # 最大连接池数量 + maxActive: 50 + # 配置获取连接等待超时的时间 + maxWait: 60000 + # 配置连接超时时间 + connectTimeout: 30000 + # 配置网络超时时间 + socketTimeout: 60000 + # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 60000 + # 配置一个连接在池中最小生存的时间,单位是毫秒 + minEvictableIdleTimeMillis: 300000 + # 配置一个连接在池中最大生存的时间,单位是毫秒 + maxEvictableIdleTimeMillis: 900000 + # 配置检测连接是否有效 + validationQuery: SELECT version() + testWhileIdle: true + testOnBorrow: false + testOnReturn: false + webStatFilter: + enabled: true + statViewServlet: + enabled: true + # 设置白名单,不填则允许所有访问 + allow: + url-pattern: /druid/* + # 控制台管理用户名和密码 + login-username: ruoyi + login-password: 123456 + filter: + stat: + enabled: true + # 慢SQL记录 + log-slow-sql: true + slow-sql-millis: 1000 + merge-sql: true + wall: + config: + multi-statement-allow: true + redis: + # 本地 + #host: 127.0.0.1 + #port: 6379 + host: 124.243.245.42 + port: 5379 + # 数据库索引 + database: 0 + # 密码(本地通常无密码) + password: + # 连接超时时间 + timeout: 100s + lettuce: + pool: + # 连接池中的最小空闲连接 + min-idle: 0 + # 连接池中的最大空闲连接 + max-idle: 8 + # 连接池的最大数据库连接数 + max-active: 8 + # #连接池最大阻塞等待时间(使用负值表示没有限制) + max-wait: -1ms + +# easy-es +easy-es: + enable: true + banner: false + address: 124.243.245.42:9200 + global-config: + process-index-mode: manual + db-config: + refresh-policy: immediate + username: elastic + password: zkr2024@@.com + +#ai +chat: + baseUrl: http://39.98.44.136:8082 + chatUrl: /v1/chat/completions + chatDetailUrl: /core/chat/getPaginationRecords + chatHistoryUrl: /core/chat/getHistories + updateNameUrl: /core/chat/updateHistory + stickChatUrl: /core/chat/updateHistory + delChatUrl: /core/chat/delHistory + delAllChatUrl: /core/chat/clearHistories + guestUrl: /v1/chat/completions + praiseUrl: /core/chat/feedback/updateUserFeedback + appId: 67cd49095e947ae0ca7fadd8 + apiKey: fastgpt-qMl63276wPZvKAxEkW77bur0sSJpmuC6Ngg9lzyEjufLhsBAurjT55j + model: qd-job-turbo + +audioText: + asr: http://127.0.0.1:8000/asr/file + tts: http://127.0.0.1:19527/synthesize + +#浪潮单点登录相关 +#互联网端: +lc_web_auth: + appId: cloud-out-2fb6330e9c0843e1a1424efda5d604c0 + appSecret: x14lueHbtLQL7Pz2G7gE4wcGCV6TDblO5xfeu9V2wGk= + getTokenUrl: http://218.31.252.15:9081/prod-psout-api/auth/token + getUserInfoUrl: http://218.31.252.15:9081/prod-psout-api/system/app/authorize/user/info + editUserInfoUrl: http://218.31.252.15:9081/prod-psout-api/auth/user/edit +#监管端: +lc_cms_auth: + appId: cloud-9793ee8a8c3d47b8871007ffc4128502 + appSecret: Yi+NACK70UPg8rFvsnnfBUq1wcLD4nm6ilC4II/4C4k= + getTokenUrl: http://218.31.252.15:9081/prod-api/auth/token + getUserInfoUrl: http://218.31.252.15:9081/prod-api/system/app/authorize/user/info + resrtPwdUrl: http://218.31.252.15:9081/prod-api/ps/user/resetPwd/zkr + changeStatusUrl: http://218.31.252.15:9081/prod-api/ps/user/change/status/zkr 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 new file mode 100644 index 0000000..d92469d --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/ESJobSearchImpl.java @@ -0,0 +1,1318 @@ +package com.ruoyi.cms.service.impl; + +import com.alibaba.fastjson.JSON; +import com.ruoyi.cms.domain.vo.CompanyVo; +import com.ruoyi.cms.mapper.*; +import com.ruoyi.cms.service.ICompanyService; +import com.ruoyi.cms.util.remommend.JobRecommendUtil; +import com.ruoyi.common.core.domain.entity.AppUser; +import com.ruoyi.cms.domain.ESJobDocument; +import com.ruoyi.cms.domain.Job; +import com.ruoyi.cms.domain.query.ESJobSearch; +import com.ruoyi.cms.mapper.es.EsJobDocumentMapper; +import com.ruoyi.cms.service.IESJobSearchService; +import com.ruoyi.common.core.domain.entity.JobTitle; +import com.ruoyi.cms.util.ListUtil; +import com.ruoyi.cms.util.StringUtil; +import com.ruoyi.common.core.domain.entity.Company; +import com.ruoyi.common.core.domain.entity.UserWorkExperiences; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.core.text.Convert; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.SiteSecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.bean.BeanUtils; +import org.dromara.easyes.core.biz.EsPageInfo; +import org.dromara.easyes.core.conditions.select.LambdaEsQueryWrapper; +import org.dromara.easyes.core.conditions.update.LambdaEsUpdateWrapper; +import org.elasticsearch.common.geo.GeoPoint; +import org.elasticsearch.common.unit.DistanceUnit; +import org.elasticsearch.search.sort.SortBuilders; +import org.elasticsearch.search.sort.SortOrder; +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.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 实体模板Service业务层处理 + * + * @author ruoyi + */ +@Service +public class ESJobSearchImpl implements IESJobSearchService +{ + @Autowired + private EsJobDocumentMapper esJobDocumentMapper; + + @Autowired + private JobMapper jobMapper; + + @Autowired + private AppUserServiceImpl appUserService; + @Autowired + private ICompanyService iCompanyService; + @Autowired + private RedisCache redisCache; + @Autowired + private AppUserBlockCompanyMapper appUserBlockCompanyMapper; + @Autowired + private JobApplyMapper jobApplyMapper; + @Autowired + private JobCollectionMapper jobCollectionMapper; + @Autowired + private UserWorkExperiencesMapper userWorkExperiencesMapper; + + // 锁的key(唯一标识ES索引初始化) + private static final String ES_INIT_LOCK_KEY = "es:job_document:init:lock"; + // 锁过期时间(30分钟,确保初始化完成) + private static final Integer LOCK_EXPIRE_SECONDS = 1800; + // 等待锁时间(5分钟,避免无限等待) + private static final Integer WAIT_LOCK_SECONDS = 300; + + + @Autowired + private BussinessDictDataServiceImpl bussinessDictDataServicel; + @Autowired + private JobTitleMapper jobTitleMapper; + Logger logger = LoggerFactory.getLogger(JobServiceImpl.class); + @Autowired + private CompanyMapper companyMapper; + + /** + * 项目启动时,初始化索引及数据 + */ + @PostConstruct + public void init() + { + boolean isLockAcquired = false; + try { + isLockAcquired = acquireDistributedLock(); + if (isLockAcquired) { + resetTextCache(); + } else { + logger.info("其他节点正在初始化ES索引,直接复用,无需重复执行"); + } + } catch (InterruptedException e) { + logger.error("ES索引初始化等待锁异常", e); + Thread.currentThread().interrupt(); + } finally { + if (isLockAcquired) { + releaseDistributedLock(); + } + } + } + + /** + * 基于自定义RedisCache实现分布式锁(无getRedisTemplate适配版) + */ + private boolean acquireDistributedLock() throws InterruptedException { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < WAIT_LOCK_SECONDS * 1000) { + boolean success = redisCache.setIfAbsent(ES_INIT_LOCK_KEY,"es_init_locked", + LOCK_EXPIRE_SECONDS,TimeUnit.SECONDS + ); + if (success) { + logger.info("成功获取ES初始化分布式锁,key:{}", ES_INIT_LOCK_KEY); + return true; + } + TimeUnit.MILLISECONDS.sleep(500); + } + logger.warn("等待{}秒未获取到ES初始化锁,放弃执行", WAIT_LOCK_SECONDS); + return false; + } + + /** + * 释放分布式锁(用自定义RedisCache的deleteObject方法) + */ + private void releaseDistributedLock() { + try { + redisCache.deleteObject(ES_INIT_LOCK_KEY); + logger.info("已释放ES初始化分布式锁,key:{}", ES_INIT_LOCK_KEY); + } catch (Exception e) { + logger.error("释放ES初始化锁异常", e); + } + } + + @Override + public void checkEsAndFix() { + try { + esJobDocumentMapper.getIndex(); + }catch (Exception e){ + logger.warn("es索引丢失,正在重新刷新"); + this.init(); + } + } + + /** + * 重置全文检索数据 + */ + //todo 定时任务 + @Override + public void resetTextCache() { + logger.info("正在重新刷新es"); + // 删除并重新创建索引 + /*esJobDocumentMapper.deleteIndex("job_document"); + esJobDocumentMapper.createIndex();*/ + if (esJobDocumentMapper.existsIndex("job_document")) { + esJobDocumentMapper.deleteIndex("job_document"); + logger.info("已删除原有job_document索引"); + try { + TimeUnit.MILLISECONDS.sleep(500); + } catch (InterruptedException e) { + logger.error("删除索引后休眠异常", e); + } + } + + if (!esJobDocumentMapper.existsIndex("job_document")) { + esJobDocumentMapper.createIndex(); + logger.info("已创建job_document索引"); + } else { + logger.info("索引已被其他节点创建,直接复用"); + } + + // 分批次处理数据 + int batchSize = 1000; // 每批次处理的数据量 + int offset = 0; // 起始偏移量 + boolean hasMoreData = true; // 是否还有更多数据 + + while (hasMoreData) { + // 分页查询数据 + Map params = new HashMap<>(); + params.put("batchSize", batchSize); + params.put("offset", offset*batchSize); + List jobList = jobMapper.selectAllJob(params); + + if (jobList.isEmpty()) { + hasMoreData = false; // 没有更多数据,退出循环 + } else { + // 转换数据并插入到 Elasticsearch + List esJobDocumentList = new ArrayList<>(); + for (Job job : jobList) { + ESJobDocument esJobDocument = new ESJobDocument(); + BeanUtils.copyBeanProp(esJobDocument, job); + esJobDocument.setJobCategory(resolveJobCategoryLabel(job.getJobCategory())); + //类型转换导致赋值问题,重新赋值 + esJobDocument.setScale(org.apache.commons.lang3.StringUtils.isNotEmpty(job.getScale()) ? Integer.parseInt(job.getScale()) : 0); + //企业信息 + CompanyVo vo=job.getCompanyVo(); + esJobDocument.setCompanyVoJson(JSON.toJSONString(vo)); + //岗位联系人 + esJobDocument.setJobContactVoJson(JSON.toJSONString(job.getJobContactList())); + esJobDocument.setAppJobUrl("https://www.xjksly.cn/app#/packageA/pages/post/post?jobId="+ job.getJobId()); + if(!StringUtil.isEmptyOrNull(job.getScale())){ + esJobDocument.setScale(Integer.valueOf(job.getScale())); + }else { + esJobDocument.setScale(8); + } + if(!StringUtil.isEmptyOrNull(job.getExperience())){ + esJobDocument.setExperience_int(Integer.valueOf(job.getExperience())); + }else { + esJobDocument.setExperience("0"); + esJobDocument.setEducation_int(0); + } + if(!StringUtil.isEmptyOrNull(job.getEducation())){ + esJobDocument.setEducation_int(Integer.valueOf(job.getEducation())); + }else { + esJobDocument.setEducation("-1"); + esJobDocument.setExperience_int(-1); + } + if (esJobDocument.getLatitude() != null) { + esJobDocument.setLatAndLon(esJobDocument.getLatitude().toString() + "," + esJobDocument.getLongitude().toString()); + } + if (StringUtils.isNotEmpty(job.getPostingDate())) { + Date date = DateUtils.dateTime(DateUtils.YYYY_MM_DD_HH_MM_SS,job.getPostingDate()); + esJobDocument.setPostingDate(date); + } + esJobDocumentList.add(esJobDocument); + } + + // 批量插入当前批次的数据 + esJobDocumentMapper.insertBatch(esJobDocumentList); + + // 更新偏移量 + offset = offset+1; + } + } + } + + @Override + public List selectTextListExceptJobId(ESJobSearch esJobSearch, List jobIds) { + + ESJobSearch newSearch = new ESJobSearch(); + BeanUtils.copyProperties(esJobSearch,newSearch); + boolean isCompanyUser = StringUtil.IS_COMPANY_USER.equals(esJobSearch.getUserType()); + //查询 + if(SiteSecurityUtils.isLogin()){ + AppUser appUser = appUserService.selectAppUserByUserId(SiteSecurityUtils.getUserId()); + if (!StringUtil.isEmptyOrNull(esJobSearch.getCode())) { + newSearch.setCode(esJobSearch.getCode()); + } + //求职者 + if (!isCompanyUser) { + setJobSeekerParams(appUser, esJobSearch, newSearch,jobIds); + } + } + + LambdaEsQueryWrapper wrapper = getWrapper(newSearch,jobIds); + + //todo 暂时 + if (isCompanyUser) { + int current = esJobSearch.getCurrent() == null ? 0 : esJobSearch.getCurrent(); + current = Math.max(current, 0); // 非负校验 + int pageSize = esJobSearch.getPageSize() == null ? 10 : esJobSearch.getPageSize(); + pageSize = Math.min(pageSize, 50); // 限制最大条数 + int from = current * pageSize; + wrapper.orderByAsc(ESJobDocument::getId); + wrapper.limit(from, pageSize); + }else{ + wrapper.limit(esJobSearch.getPageSize()); + } + + 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)); + // 保存所有查询到的文档 + 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; + } + + /** + * 拼装参数 + * @param appUser + * @param esJobSearch + * @param newSearch + */ + private void setJobSeekerParams(AppUser appUser, ESJobSearch esJobSearch, ESJobSearch newSearch,List jobIds) { + if(!ListUtil.isEmptyOrNull(appUser.getJobTitle())){ + List jobTitle = appUser.getJobTitle(); + newSearch.setJobTitle(String.join(",", jobTitle)); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getJobTitle())){ + newSearch.setJobTitle(esJobSearch.getJobTitle()); + } + if(!StringUtil.isEmptyOrNull(appUser.getEducation())){ + newSearch.setEducation(appUser.getEducation()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getEducation())){ + newSearch.setEducation(esJobSearch.getEducation()); + } + + if(!StringUtil.isEmptyOrNull(appUser.getArea())){ + newSearch.setArea(appUser.getArea()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getArea())){ + newSearch.setArea(esJobSearch.getArea()); + } + //影响后面按照经验打分,先注释 +// if(!StringUtil.isEmptyOrNull(appUser.getWorkExperience())){ +// newSearch.setExperience(appUser.getWorkExperience()); +// } + if(!StringUtil.isEmptyOrNull(esJobSearch.getExperience())){ + newSearch.setExperience(esJobSearch.getExperience()); + } + if(!StringUtil.isEmptyOrNull(appUser.getSalaryMax())){ + newSearch.setMaxSalary(Long.valueOf(appUser.getSalaryMax())); + } + if(!StringUtil.isEmptyOrNull(appUser.getSalaryMin())){ + newSearch.setMinSalary(Long.valueOf(appUser.getSalaryMin())); + } + // 请求参数覆盖用户资料中的薪资偏好 + if(Objects.nonNull(esJobSearch.getSalaryMin()) || Objects.nonNull(esJobSearch.getMinSalary())){ + newSearch.setMinSalary(Objects.nonNull(esJobSearch.getSalaryMin()) ? esJobSearch.getSalaryMin() : esJobSearch.getMinSalary()); + } + if(Objects.nonNull(esJobSearch.getSalaryMax()) || Objects.nonNull(esJobSearch.getMaxSalary())){ + newSearch.setMaxSalary(Objects.nonNull(esJobSearch.getSalaryMax()) ? esJobSearch.getSalaryMax() : esJobSearch.getMaxSalary()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getJobType())){ + newSearch.setJobType(esJobSearch.getJobType()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getType())){ + newSearch.setType(esJobSearch.getType()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getDescription())){ + newSearch.setDescription(esJobSearch.getDescription()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getCompanyNature())){ + newSearch.setCompanyNature(esJobSearch.getCompanyNature()); + } + //屏蔽企业集合 + List jobs=appUserBlockCompanyMapper.selectBlockCompanyIdsByUserIdJobs(appUser.getUserId()); + if (jobs != null) { + jobIds.addAll(jobs.stream().distinct().collect(Collectors.toList())); + } + //查询投递集合 + List applyNames=jobApplyMapper.queryApplyNames(appUser.getUserId()); + newSearch.setApplyNames(applyNames); + //查询收藏集合 + List collectNames=jobCollectionMapper.queryCollectNames(appUser.getUserId()); + newSearch.setCollectNames(collectNames); + //工作经历集合 + UserWorkExperiences expQuery = new UserWorkExperiences(); + expQuery.setUserId(appUser.getUserId()); + List userWorkExperiences=userWorkExperiencesMapper.getWorkExperiencesList(expQuery); + newSearch.setUserWorkExperiences(userWorkExperiences); + } + + /** + * 新增全文检索数据 + */ + @Override + public Integer addESJobDocument(ESJobDocument esJobDocument) + { + return esJobDocumentMapper.insert(esJobDocument); + } + + /** + * 修改全文检索数据 + */ + @Override + public Integer updateESJobDocument(ESJobDocument esJobDocument) + { + + LambdaEsUpdateWrapper wrapper = new LambdaEsUpdateWrapper<>(); + wrapper.eq(ESJobDocument::getJobId, esJobDocument.getJobId()); + return esJobDocumentMapper.update(esJobDocument, wrapper); + } + + /** + * 根据ID查询全文检索数据 + */ + @Override + public ESJobDocument getESJobDocument(Long jobId) + { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper(); + wrapper.eq(ESJobDocument::getJobId, jobId); + return esJobDocumentMapper.selectOne(wrapper); + } + + /** + * 根据ID删除全文检索数据 + */ + @Override + public void deleteESJobDocument(String ids) + { + for (Long jobId : Convert.toLongArray(ids)) + { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper(); + wrapper.eq(ESJobDocument::getJobId, jobId); + esJobDocumentMapper.delete(wrapper); + } + } + + @Override + public EsPageInfo nearJob(ESJobSearch jobQuery) { + Integer pageNum = jobQuery.getCurrent(); + Integer pageSize = jobQuery.getPageSize(); + //添加屏蔽的企业 + List jobIds=null; + if(SiteSecurityUtils.isLogin()) { + AppUser appUser = appUserService.selectAppUserByUserId(SiteSecurityUtils.getUserId()); + jobIds = appUserBlockCompanyMapper.selectBlockCompanyIdsByUserIdJobs(appUser.getUserId()); + } + LambdaEsQueryWrapper wrapper = getWrapper(jobQuery,jobIds); + EsPageInfo esJobDocumentEsPageInfo = esJobDocumentMapper.pageQuery(wrapper, pageNum, pageSize); + return esJobDocumentEsPageInfo; + } + + @Override + public EsPageInfo countyJobList(ESJobSearch jobQuery) { + Integer pageNum = jobQuery.getCurrent(); + Integer pageSize = jobQuery.getPageSize(); + LambdaEsQueryWrapper wrapper = getWrapper(jobQuery, null); + //添加屏蔽的企业 + List jobIds=null; + if(SiteSecurityUtils.isLogin()) { + AppUser appUser = appUserService.selectAppUserByUserId(SiteSecurityUtils.getUserId()); + jobIds = appUserBlockCompanyMapper.selectBlockCompanyIdsByUserIdJobs(appUser.getUserId()); + wrapper.not().in(ESJobDocument::getJobId, jobIds); + } + if(jobQuery.getCountyIds()!=null){ + wrapper.and(x->x.in(ESJobDocument::getJobLocationAreaCode,jobQuery.getCountyIds())); + } + EsPageInfo esJobDocumentEsPageInfo = esJobDocumentMapper.pageQuery(wrapper, pageNum, pageSize); + return esJobDocumentEsPageInfo; + } + + @Override + public EsPageInfo subway(ESJobSearch jobQuery) { + Integer pageNum = jobQuery.getCurrent(); + Integer pageSize = jobQuery.getPageSize(); + List jobIds=null; + if(SiteSecurityUtils.isLogin()) { + AppUser appUser = appUserService.selectAppUserByUserId(SiteSecurityUtils.getUserId()); + jobIds = appUserBlockCompanyMapper.selectBlockCompanyIdsByUserIdJobs(appUser.getUserId()); + } + LambdaEsQueryWrapper wrapper = getWrapper(jobQuery,jobIds); + EsPageInfo esJobDocumentEsPageInfo = esJobDocumentMapper.pageQuery(wrapper, pageNum, pageSize); + return esJobDocumentEsPageInfo; + } + + @Override + public EsPageInfo commercialArea(ESJobSearch jobQuery) { + Integer pageNum = jobQuery.getCurrent(); + Integer pageSize = jobQuery.getPageSize(); + List jobIds=null; + if(SiteSecurityUtils.isLogin()) { + AppUser appUser = appUserService.selectAppUserByUserId(SiteSecurityUtils.getUserId()); + jobIds = appUserBlockCompanyMapper.selectBlockCompanyIdsByUserIdJobs(appUser.getUserId()); + } + LambdaEsQueryWrapper wrapper = getWrapper(jobQuery,jobIds); + if(jobQuery.getLongitude()!=null){ + wrapper.geoDistance(ESJobDocument::getLatAndLon,Double.valueOf(jobQuery.getRadius()), DistanceUnit.KILOMETERS,new GeoPoint(Double.parseDouble(jobQuery.getLatitude().toString()), Double.parseDouble(jobQuery.getLongitude().toString()))); + } + EsPageInfo esJobDocumentEsPageInfo = esJobDocumentMapper.pageQuery(wrapper, pageNum, pageSize); + return esJobDocumentEsPageInfo; + } + + @Override + public List littleVideo(ESJobSearch esJobSearch) { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + wrapper.eq(ESJobDocument::getIsExplain, 1); + wrapper.limit(esJobSearch.getPageSize()); + return esJobDocumentMapper.selectList(wrapper); + } + + @Override + public void updateEs() { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + esJobDocumentMapper.selectList(wrapper).forEach(esJobDocument -> { + esJobDocument.setPostingDate(new Date()); + this.updateESJobDocument(esJobDocument); + }); + } + + @Override + public List littleVideoRandom(List cacheList, Integer count,String jobTitle) { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + wrapper.eq(ESJobDocument::getIsExplain, 1); + if(!ListUtil.isListEmptyOrNull(cacheList)){ + wrapper.not().in(ESJobDocument::getJobId,cacheList); + } + if(count==null){ + count = 1; + } + if(StringUtil.isEmptyOrNull(jobTitle)){ + wrapper.or(a -> a.like(ESJobDocument::getJobTitle, jobTitle, 5.0f) + .or() + .like(ESJobDocument::getDescription, jobTitle, 1.0f) + ); + } + wrapper.limit(count); + return esJobDocumentMapper.selectList(wrapper); + } + + private LambdaEsQueryWrapper getWrapper(ESJobSearch esJobSearch,List jobIds){ + + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + /*if(!StringUtil.isEmptyOrNull(esJobSearch.getJobTitle())){ + for (String keyWord:esJobSearch.getJobTitle().split(",")) { + wrapper.and(a -> a.match(ESJobDocument::getJobTitle, keyWord, 5.0f) + ); + } + + }*/ + //优化岗位名称查询,只有命中岗位名称或者分类就行 + String titleStr = esJobSearch.getJobTitle(); + String cateStr = esJobSearch.getJobCategory(); + boolean hasTitle = !StringUtil.isEmptyOrNull(titleStr); + boolean hasCate = !StringUtil.isEmptyOrNull(cateStr); + + if (hasTitle || hasCate) { + wrapper.and(sub -> { + if (hasTitle) { + String[] words = titleStr.split(","); + for (String w : words) { + String word = w.trim(); + if (StringUtil.isEmptyOrNull(word)) { + continue; + } + sub.or(inner -> inner + .match(ESJobDocument::getJobTitle, word, 5.0f) + .or() + .match(ESJobDocument::getJobCategory, word, 5.0f) + ); + } + } + if (hasCate) { + String[] words = cateStr.split(","); + for (String w : words) { + String word = w.trim(); + if (StringUtil.isEmptyOrNull(word)) { + continue; + } + sub.or(inner -> inner + .match(ESJobDocument::getJobTitle, word, 5.0f) + .or() + .match(ESJobDocument::getJobCategory, word, 5.0f) + ); + } + } + }); + } + + if(esJobSearch.getLongitude()!=null){ + wrapper.geoDistance(ESJobDocument::getLatAndLon,Double.valueOf(esJobSearch.getRadius()), DistanceUnit.KILOMETERS,new GeoPoint(Double.parseDouble(esJobSearch.getLatitude().toString()), Double.parseDouble(esJobSearch.getLongitude().toString()))); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getEducation())){ + Integer maxValue = StringUtil.findMaxValue(esJobSearch.getEducation()); + wrapper.and(x->x.eq(ESJobDocument::getEducation_int,maxValue)); + } + // 薪资范围筛选:兼容前端字段 salaryMin/salaryMax 和 minSalary/maxSalary + Long userMinSalary = Objects.nonNull(esJobSearch.getSalaryMin()) ? esJobSearch.getSalaryMin() : esJobSearch.getMinSalary(); + Long userMaxSalary = Objects.nonNull(esJobSearch.getSalaryMax()) ? esJobSearch.getSalaryMax() : esJobSearch.getMaxSalary(); + + // 岗位最高薪资必须在用户筛选范围内 + // 条件:岗位最大薪资 <= 用户最大薪资 AND 岗位最大薪资 >= 用户最小薪资 + if(Objects.nonNull(userMinSalary) && Objects.nonNull(userMaxSalary)){ + wrapper.and(x->x.le(ESJobDocument::getMaxSalary,userMaxSalary) + .ge(ESJobDocument::getMaxSalary,userMinSalary)); + } else if(Objects.nonNull(userMinSalary)){ + // 只有最小薪资:岗位最大薪资 >= 用户最小薪资 + wrapper.and(x->x.ge(ESJobDocument::getMaxSalary,userMinSalary)); + } else if(Objects.nonNull(userMaxSalary)){ + // 只有最大薪资:岗位最大薪资 <= 用户最大薪资 + wrapper.and(x->x.le(ESJobDocument::getMaxSalary,userMaxSalary)); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getExperience())){ + Integer maxValue = StringUtil.findMaxValue(esJobSearch.getExperience()); + wrapper.and(x->x.eq(ESJobDocument::getExperience_int,maxValue)); + } +// if(!StringUtil.isEmptyOrNull(esJobSearch.getJobCategory())){ +// String targetValue = esJobSearch.getJobCategory(); +// wrapper.and(x -> x.eq(ESJobDocument::getJobCategory, targetValue)); +// } + if(!StringUtil.isEmptyOrNull(esJobSearch.getScale())){ + Integer maxValue = StringUtil.findMaxValue(esJobSearch.getScale()); + wrapper.and(x->x.le(ESJobDocument::getScale,maxValue)); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getArea())){ + List integers = StringUtil.convertStringToIntegerList(esJobSearch.getArea()); + wrapper.and(x->x.in(ESJobDocument::getJobLocationAreaCode,integers)); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getJobType())){ + wrapper.and(x->x.eq(ESJobDocument::getJobType,esJobSearch.getJobType())); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getType())){ + wrapper.and(x->x.eq(ESJobDocument::getType,esJobSearch.getType())); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getDescription())){ + wrapper.and(x->x.like(ESJobDocument::getDescription,esJobSearch.getDescription())); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getCompanyNature())){ + wrapper.and(x->x.eq(ESJobDocument::getCompanyNature,esJobSearch.getCompanyNature())); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getCode())){ + wrapper.and(x->x.eq(ESJobDocument::getCode,esJobSearch.getCode())); + } + if(esJobSearch.getJobId()!=null){ + wrapper.and(x->x.eq(ESJobDocument::getJobId,esJobSearch.getJobId())); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getJobAddress())){ + wrapper.and(x->x.like(ESJobDocument::getJobAddress,esJobSearch.getJobAddress())); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getJobLocation())){ + wrapper.and(x->x.like(ESJobDocument::getJobLocation,esJobSearch.getJobLocation())); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getCompanyName())){ + wrapper.and(x->x.like(ESJobDocument::getCompanyName,esJobSearch.getCompanyName())); + } + // 1.简历工作经历(最高权重) + JobRecommendUtil.appendWorkExpMatch(wrapper, esJobSearch.getUserWorkExperiences()); + //投递记录 + JobRecommendUtil.appendJobTitleMatch(wrapper, esJobSearch.getApplyNames(), JobRecommendUtil.APPLY_TITLE_BOOST); + //收藏记录 + JobRecommendUtil.appendJobTitleMatch(wrapper, esJobSearch.getCollectNames(), JobRecommendUtil.COLLECT_TITLE_BOOST); + + //按时间段来查询数据 + Date startDate = esJobSearch.getStartDate(); + Date endDate = esJobSearch.getEndDate(); + if (Objects.nonNull(startDate) && Objects.nonNull(endDate)) { + String startStr = DateUtils.ES_DATE_FORMATTER.format(startDate.toInstant()); + String endStr = DateUtils.ES_DATE_FORMATTER.format(endDate.toInstant()); + wrapper.between(ESJobDocument::getPostingDate, startStr, endStr); + } else if (Objects.nonNull(startDate)) { + wrapper.ge(ESJobDocument::getPostingDate, DateUtils.ES_DATE_FORMATTER.format(startDate.toInstant())); + } else if (Objects.nonNull(endDate)) { + wrapper.le(ESJobDocument::getPostingDate, DateUtils.ES_DATE_FORMATTER.format(endDate.toInstant())); + } + // 先开启打分、优先按匹配分数排序(关键词前台命中越高越靠前) + wrapper.trackScores(); + wrapper.sortByScore(SortOrder.DESC); + if(Objects.nonNull(esJobSearch.getOrder())){ + switch (esJobSearch.getOrder()) { + case 0: // 推荐:权重 → 最新发布 + wrapper.sort(SortBuilders.fieldSort("postingDate").order(SortOrder.DESC).missing("_last")); + break; + case 1: // 最热:权重 → 热度 + wrapper.orderByDesc(ESJobDocument::getIsHot); + wrapper.orderByDesc(ESJobDocument::getApplyNum); + wrapper.orderByDesc(ESJobDocument::getView); + break; + case 2: // 最新发布:权重 → 最新发布 + wrapper.sort(SortBuilders.fieldSort("postingDate").order(SortOrder.DESC).missing("_last")); + break; + case 3: // 最大薪资:权重 → 最高薪资 + wrapper.orderByDesc(ESJobDocument::getMaxSalary); + break; + default: // 默认:权重 → 最新发布 + wrapper.sort(SortBuilders.fieldSort("postingDate").order(SortOrder.DESC).missing("_last")); + } + } + //企业用户排除es去除jobIds + boolean isCompanyUser = StringUtil.IS_COMPANY_USER.equals(esJobSearch.getUserType()); + if (!isCompanyUser && !ListUtil.isListEmptyOrNull(jobIds)) { + wrapper.not().in(ESJobDocument::getJobId, jobIds); + } + return wrapper; + } + + //todo 存储用户搜索 + @Override + public EsPageInfo appList(ESJobSearch jobQuery) { + Integer pageNum = jobQuery.getCurrent(); + Integer pageSize = jobQuery.getPageSize(); + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + if(SiteSecurityUtils.isLogin()){ + //AppUser appUser = appUserService.selectAppUserByUserId(SiteSecurityUtils.getUserId()); + List jobs=appUserBlockCompanyMapper.selectBlockCompanyIdsByUserIdJobs(SiteSecurityUtils.getUserId()); + if (!ListUtil.isListEmptyOrNull(jobs)) { + wrapper.not().in(ESJobDocument::getJobId, jobs); + } +// if(!ListUtil.isEmptyOrNull(appUser.getJobTitle())){ +// List jobTitle = appUser.getJobTitle(); +// jobQuery.setJobTitle(String.join(",", jobTitle)); +// } + } + if(hasText(jobQuery.getJobTitle())){ + wrapper.and(a->a.match(ESJobDocument::getJobTitle,jobQuery.getJobTitle().trim(),5.0f)); + } + if(hasText(jobQuery.getEducation())){ + // 支持逗号分隔的多值筛选(如 "3,4") + String eduStr = jobQuery.getEducation().trim(); + if (eduStr.contains(",")) { + List eduList = StringUtil.convertStringToStringList(eduStr); + wrapper.and(a->a.in(ESJobDocument::getEducation,eduList)); + } else { + wrapper.and(a->a.eq(ESJobDocument::getEducation,eduStr)); + } + } + if(hasText(jobQuery.getArea())){ + List integers = StringUtil.convertStringToIntegerList(jobQuery.getArea().trim()); + wrapper.and(x->x.in(ESJobDocument::getJobLocationAreaCode,integers)); + } + if(!StringUtil.isEmptyOrNull(jobQuery.getExperience())){ + List integers = StringUtil.convertStringToIntegerList(jobQuery.getExperience()); + wrapper.and(a->a.in(ESJobDocument::getExperience,integers)); + } + Long userMinSalary = Objects.nonNull(jobQuery.getSalaryMin()) ? jobQuery.getSalaryMin() : jobQuery.getMinSalary(); + Long userMaxSalary = Objects.nonNull(jobQuery.getSalaryMax()) ? jobQuery.getSalaryMax() : jobQuery.getMaxSalary(); + if (Objects.nonNull(userMinSalary)) { + wrapper.filter(x -> x.ge(ESJobDocument::getMaxSalary, userMinSalary)); + } + if (Objects.nonNull(userMaxSalary)) { + wrapper.filter(x -> x.le(ESJobDocument::getMinSalary, userMaxSalary)); + } + if(hasText(jobQuery.getScaleDictCode())){ + wrapper.and(a->a.eq(ESJobDocument::getScaleDictCode,jobQuery.getScaleDictCode().trim())); + } + if(hasText(jobQuery.getJobCategory())){ + wrapper.and(a->a.eq(ESJobDocument::getJobCategory,jobQuery.getJobCategory().trim())); + } + if(hasText(jobQuery.getIndustry())){ + wrapper.and(a->a.eq(ESJobDocument::getIndustry,jobQuery.getIndustry().trim())); + } + if(jobQuery.getLongitude()!=null){ + wrapper.geoDistance(ESJobDocument::getLatAndLon,Double.valueOf(jobQuery.getRadius()), DistanceUnit.KILOMETERS,new GeoPoint(Double.parseDouble(jobQuery.getLatitude().toString()), Double.parseDouble(jobQuery.getLongitude().toString()))); + } + if(jobQuery.getJobLocationAreaCode()!=null){ + wrapper.and(a->a.eq(ESJobDocument::getJobLocationAreaCode,jobQuery.getJobLocationAreaCode())); + } + if(hasText(jobQuery.getScale())){ + wrapper.and(a->a.eq(ESJobDocument::getScale,jobQuery.getScale().trim())); + } + if(hasText(jobQuery.getSearchValue())){ + wrapper.and(a->a.match(ESJobDocument::getDescription,jobQuery.getSearchValue().trim(),4.0f)); + } + if(hasText(jobQuery.getCompanyNature())){ + wrapper.and(a->a.eq(ESJobDocument::getCompanyNature,jobQuery.getCompanyNature().trim())); + } + if(hasText(jobQuery.getType())){ + wrapper.and(a->a.eq(ESJobDocument::getType,jobQuery.getType().trim())); + } + //开始时间 + Date startDate = jobQuery.getStartDate(); + Date endDate = jobQuery.getEndDate(); + if (Objects.nonNull(startDate) && Objects.nonNull(endDate)) { + String startStr = DateUtils.ES_DATE_FORMATTER.format(startDate.toInstant()); + String endStr = DateUtils.ES_DATE_FORMATTER.format(endDate.toInstant()); + wrapper.between(ESJobDocument::getPostingDate, startStr, endStr); + } else if (Objects.nonNull(startDate)) { + wrapper.ge(ESJobDocument::getPostingDate, DateUtils.ES_DATE_FORMATTER.format(startDate.toInstant())); + } else if (Objects.nonNull(endDate)) { + wrapper.le(ESJobDocument::getPostingDate, DateUtils.ES_DATE_FORMATTER.format(endDate.toInstant())); + } + // 先开启打分、优先按匹配分数排序(关键词前台命中越高越靠前) +// wrapper.trackScores(); +// wrapper.sortByScore(SortOrder.DESC); + if(Objects.nonNull(jobQuery.getOrder())){ + switch (jobQuery.getOrder()) { + case 0: // 推荐:权重 → 最新发布 + wrapper.sort(SortBuilders.fieldSort("postingDate").order(SortOrder.DESC).missing("_last")); + break; + case 1: // 最热:权重 → 热度 + wrapper.orderByDesc(ESJobDocument::getIsHot); + wrapper.orderByDesc(ESJobDocument::getApplyNum); + wrapper.orderByDesc(ESJobDocument::getView); + break; + case 2: // 最新发布:权重 → 最新发布 + wrapper.sort(SortBuilders.fieldSort("postingDate").order(SortOrder.DESC).missing("_last")); + break; + case 3: // 最大薪资:权重 → 最高薪资 + wrapper.orderByDesc(ESJobDocument::getMaxSalary); + break; + default: // 默认:权重 → 最新发布 + wrapper.sort(SortBuilders.fieldSort("postingDate").order(SortOrder.DESC).missing("_last")); + } + } + EsPageInfo esJobDocumentEsPageInfo = esJobDocumentMapper.pageQuery(wrapper, pageNum, pageSize); + return esJobDocumentEsPageInfo; + } + @Override + public EsPageInfo aiList(ESJobSearch jobQuery) { + Integer pageNum = jobQuery.getCurrent(); + Integer pageSize = jobQuery.getPageSize(); + + // 第一次查询:包含区域条件 + EsPageInfo result = doAiListQuery(jobQuery, pageNum, pageSize, true); + + // 降级查询:如果没有找到岗位且有区域条件,忽略区域重新查询 + if ((result.getList() == null || result.getList().isEmpty()) + && (hasText(jobQuery.getArea()) || jobQuery.getJobLocationAreaCode() != null)) { + logger.info("AI岗位查询降级:忽略区域条件重新查询"); + result = doAiListQuery(jobQuery, pageNum, pageSize, false); + } + + return result; + } + + /** + * AI岗位查询核心逻辑 + * @param jobQuery 查询条件 + * @param pageNum 页码 + * @param pageSize 每页大小 + * @param includeArea 是否包含区域条件 + */ + private EsPageInfo doAiListQuery(ESJobSearch jobQuery, Integer pageNum, Integer pageSize, boolean includeArea) { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + + if(hasText(jobQuery.getJobTitle())){ + wrapper.and(a->a.match(ESJobDocument::getJobTitle,jobQuery.getJobTitle().trim(),5.0f) + .or() + .match(ESJobDocument::getDescription,jobQuery.getJobTitle().trim(),1.0f)); + } + if(hasText(jobQuery.getEducation())){ + wrapper.and(a->a.le(ESJobDocument::getEducation,jobQuery.getEducation().trim())); + } + // 区域条件:根据 includeArea 决定是否加入 + if(includeArea && hasText(jobQuery.getArea())){ + List integers = StringUtil.convertStringToIntegerList(jobQuery.getArea().trim()); + wrapper.and(x->x.in(ESJobDocument::getJobLocationAreaCode,integers)); + } + if(hasText(jobQuery.getExperience())){ + wrapper.and(a->a.le(ESJobDocument::getExperience,jobQuery.getExperience().trim())); + } + // 薪资范围:岗位最高薪资必须在用户筛选范围内 + if(Objects.nonNull(jobQuery.getMaxSalary())){ + wrapper.and(x->x.le(ESJobDocument::getMaxSalary,jobQuery.getMaxSalary())); + } + if(Objects.nonNull(jobQuery.getMinSalary())){ + wrapper.and(x->x.ge(ESJobDocument::getMaxSalary,jobQuery.getMinSalary())); + } + if(hasText(jobQuery.getScaleDictCode())){ + wrapper.and(a->a.eq(ESJobDocument::getScaleDictCode,jobQuery.getScaleDictCode().trim())); + } + if(hasText(jobQuery.getIndustry())){ + wrapper.and(a->a.eq(ESJobDocument::getIndustry,jobQuery.getIndustry().trim())); + } + if(jobQuery.getLongitude()!=null){ + wrapper.geoDistance(ESJobDocument::getLatAndLon,Double.valueOf(jobQuery.getRadius()), DistanceUnit.KILOMETERS,new GeoPoint(Double.parseDouble(jobQuery.getLatitude().toString()), Double.parseDouble(jobQuery.getLongitude().toString()))); + } + // 区域编码条件:根据 includeArea 决定是否加入 + if(includeArea && jobQuery.getJobLocationAreaCode()!=null){ + wrapper.and(a->a.eq(ESJobDocument::getJobLocationAreaCode,jobQuery.getJobLocationAreaCode())); + } + if(hasText(jobQuery.getScale())){ + wrapper.and(a->a.eq(ESJobDocument::getScale,jobQuery.getScale().trim())); + } + if(hasText(jobQuery.getSearchValue())){ + wrapper.and(a->a.match(ESJobDocument::getDescription,jobQuery.getSearchValue().trim(),4.0f)); + } + if(hasText(jobQuery.getCompanyNature())){ + wrapper.and(a->a.eq(ESJobDocument::getCompanyNature,jobQuery.getCompanyNature().trim())); + } + if(hasText(jobQuery.getType())){ + wrapper.and(a->a.eq(ESJobDocument::getType,jobQuery.getType().trim())); + } + if(Objects.nonNull(jobQuery.getOrder())){ + if (jobQuery.getOrder()==2){ + wrapper.orderByDesc(ESJobDocument::getPostingDate); + } + if (jobQuery.getOrder()==1){ + wrapper.orderByDesc(ESJobDocument::getIsHot); + wrapper.orderByDesc(ESJobDocument::getApplyNum); + wrapper.orderByDesc(ESJobDocument::getView); + } + } + return esJobDocumentMapper.pageQuery(wrapper, pageNum, pageSize); + } + /** + * 判断字符串是否有实际内容(非null、非空、非纯空白) + */ + private boolean hasText(String str) { + return str != null && !str.trim().isEmpty(); + } + + @Override + public ESJobDocument selectById(Long jobId) { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + wrapper.eq(ESJobDocument::getJobId,jobId); + return esJobDocumentMapper.selectOne(wrapper); + } + + @Override + public void fix() { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + wrapper.eq(ESJobDocument::getIsExplain,1); + List esJobDocuments = esJobDocumentMapper.selectList(wrapper); + for (ESJobDocument esJobDocument : esJobDocuments) { + Job job = new Job(); + BeanUtils.copyProperties(esJobDocument,job); + job.setJobId(null); + jobMapper.insert(job); + } + } + + @Override + public void deleteJob(Long jobId) { + ensureJobDocumentIndexExists(); + LambdaEsQueryWrapper lambdaEsQueryWrapper = new LambdaEsQueryWrapper(); + lambdaEsQueryWrapper.eq(ESJobDocument::getJobId,jobId); + esJobDocumentMapper.delete(lambdaEsQueryWrapper); + } + + @Override + public void updateJob(Long jobId) { + ESJobDocument esJobDocument = new ESJobDocument(); + Job job = jobMapper.selectById(jobId); + if(job!=null){ + if(job.getCompanyId()!=null){ + Company company=iCompanyService.selectCompanyByCompanyId(job.getCompanyId()); + if (company != null) { + job.setCode(company.getCode()); + job.setCompanyNature(company.getCompanyNature()); + } + } + } + + BeanUtils.copyBeanProp(esJobDocument, job); + esJobDocument.setJobCategory(resolveJobCategoryLabel(job.getJobCategory())); + esJobDocument.setAppJobUrl("https://xjshzly.longbiosphere.com/app#/packageA/pages/post/post?jobId="+ Base64.getEncoder().encodeToString(String.valueOf(job.getJobId()).getBytes())); + if(!StringUtil.isEmptyOrNull(job.getScale())){ + esJobDocument.setScale(Integer.valueOf(job.getScale())); + }else { + esJobDocument.setScale(0); + } + if(!StringUtil.isEmptyOrNull(job.getExperience())){ + esJobDocument.setExperience_int(Integer.valueOf(job.getExperience())); + }else { + esJobDocument.setExperience("0"); + esJobDocument.setExperience_int(0); + } + if(!StringUtil.isEmptyOrNull(job.getEducation())){ + esJobDocument.setEducation_int(Integer.valueOf(job.getEducation())); + }else { + esJobDocument.setEducation("-1"); + esJobDocument.setEducation_int(-1); + } + if (esJobDocument.getLatitude() != null) { + esJobDocument.setLatAndLon(esJobDocument.getLatitude().toString() + "," + esJobDocument.getLongitude().toString()); + } + ensureJobDocumentIndexExists(); + + LambdaEsQueryWrapper lambdaEsQueryWrapper = new LambdaEsQueryWrapper(); + lambdaEsQueryWrapper.eq(ESJobDocument::getJobId,job.getJobId()); + esJobDocumentMapper.delete(lambdaEsQueryWrapper); + esJobDocumentMapper.insert(esJobDocument); + } + + /** + * 确保job_document索引存在;缺失时懒创建(仅建空索引与映射,不重建全量数据)。 + * process-index-mode: manual 下easy-es不会自动建索引,缺失时delete/update等写操作 + * 会抛 index_not_found_exception 导致业务接口500并回滚事务,故在写操作前调用以自愈。 + */ + private void ensureJobDocumentIndexExists() { + try { + if (!esJobDocumentMapper.existsIndex("job_document")) { + esJobDocumentMapper.createIndex(); + logger.info("job_document索引不存在,已自动创建"); + } + } catch (Exception e) { + // 并发场景下其他请求可能已创建索引,忽略“索引已存在”类异常 + logger.warn("自动创建job_document索引异常(可能已被其他请求创建,忽略)", e); + } + } + + public List selectByIds(Long[] jobIds) { + LambdaEsQueryWrapper wrapper = new LambdaEsQueryWrapper<>(); + wrapper.in(ESJobDocument::getJobId,jobIds); + return esJobDocumentMapper.selectList(wrapper); + } + + /** + * pc端查询es + * @param esJobSearch + * @param jobIds + * @param appUser + * @return + */ + @Override + public List selectSysTextListExceptJobId(ESJobSearch esJobSearch, List jobIds,AppUser appUser) { + + ESJobSearch newSearch = new ESJobSearch(); + BeanUtils.copyProperties(esJobSearch,newSearch); + + // 判断是否为用户主动搜索(有明确的搜索条件),而非纯推荐场景 + boolean isExplicitSearch = !StringUtil.isEmptyOrNull(esJobSearch.getJobTitle()) + || !StringUtil.isEmptyOrNull(esJobSearch.getJobCategory()) + || !StringUtil.isEmptyOrNull(esJobSearch.getCompanyName()) + || !StringUtil.isEmptyOrNull(esJobSearch.getDescription()); + + // 用户资料偏好仅在"纯推荐"场景下作为个性化过滤条件; + // 用户主动搜索时不应被资料中的薪资/学历/经验/区域等偏好缩小结果范围 + if(appUser != null && !isExplicitSearch){ + if(!ListUtil.isEmptyOrNull(appUser.getJobTitle())){ + List jobTitle = appUser.getJobTitle(); + newSearch.setJobTitle(String.join(",", jobTitle)); + } + if(!StringUtil.isEmptyOrNull(appUser.getEducation())){ + newSearch.setEducation(appUser.getEducation()); + } + if(!StringUtil.isEmptyOrNull(appUser.getArea())){ + newSearch.setArea(appUser.getArea()); + } + if(!StringUtil.isEmptyOrNull(appUser.getWorkExperience())){ + newSearch.setExperience(appUser.getWorkExperience()); + } + if(!StringUtil.isEmptyOrNull(appUser.getSalaryMax())){ + newSearch.setMaxSalary(Long.valueOf(appUser.getSalaryMax())); + } + if(!StringUtil.isEmptyOrNull(appUser.getSalaryMin())){ + newSearch.setMinSalary(Long.valueOf(appUser.getSalaryMin())); + } + //查询投递集合 + List applyNames=jobApplyMapper.queryApplyNames(appUser.getUserId()); + newSearch.setApplyNames(applyNames); + //查询收藏集合 + List collectNames=jobCollectionMapper.queryCollectNames(appUser.getUserId()); + newSearch.setCollectNames(collectNames); + //工作经历集合 + UserWorkExperiences expQuery = new UserWorkExperiences(); + expQuery.setUserId(appUser.getUserId()); + List userWorkExperiences=userWorkExperiencesMapper.getWorkExperiencesList(expQuery); + newSearch.setUserWorkExperiences(userWorkExperiences); + } + + // 请求参数始终优先于用户资料偏好 + if(Objects.nonNull(esJobSearch.getSalaryMin()) || Objects.nonNull(esJobSearch.getMinSalary())){ + newSearch.setMinSalary(Objects.nonNull(esJobSearch.getSalaryMin()) ? esJobSearch.getSalaryMin() : esJobSearch.getMinSalary()); + } + if(Objects.nonNull(esJobSearch.getSalaryMax()) || Objects.nonNull(esJobSearch.getMaxSalary())){ + newSearch.setMaxSalary(Objects.nonNull(esJobSearch.getSalaryMax()) ? esJobSearch.getSalaryMax() : esJobSearch.getMaxSalary()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getArea())){ + newSearch.setArea(esJobSearch.getArea()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getEducation())){ + newSearch.setEducation(esJobSearch.getEducation()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getExperience())){ + newSearch.setExperience(esJobSearch.getExperience()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getJobTitle())){ + newSearch.setJobTitle(esJobSearch.getJobTitle()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getJobType())){ + newSearch.setJobType(esJobSearch.getJobType()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getType())){ + newSearch.setType(esJobSearch.getType()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getDescription())){ + newSearch.setDescription(esJobSearch.getDescription()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getCompanyNature())){ + newSearch.setCompanyNature(esJobSearch.getCompanyNature()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getCode())){ + newSearch.setCode(esJobSearch.getCode()); + } + if(!StringUtil.isEmptyOrNull(esJobSearch.getCompanyName())){ + newSearch.setCompanyName(esJobSearch.getCompanyName()); + } + + LambdaEsQueryWrapper wrapper = getWrapper(newSearch,jobIds); + + //todo 暂时 + 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)); + // 保存所有查询到的文档 + 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; + } + + /** + * 将 jobCategory 从数字ID解析为标签文字(如 "45" → "销售顾问") + * 如果已经是文字标签或解析失败,返回原值 + */ + private String resolveJobCategoryLabel(String jobCategory) { + if (StringUtils.isEmpty(jobCategory)) { + return jobCategory; + } + try { + Long categoryId = Long.parseLong(jobCategory); + JobTitle jobTitle = jobTitleMapper.selectById(categoryId); + if (jobTitle != null && StringUtils.isNotEmpty(jobTitle.getJobName())) { + return jobTitle.getJobName(); + } + } catch (NumberFormatException e) { + // 已经是文字标签,直接返回原值 + } + return jobCategory; + } + + /** + * 根据岗位id批量更新es + * @param jobIdList + */ + @Override + public void batchUpdateJob(List jobIdList) { + if (jobIdList == null || jobIdList.isEmpty()) { + return; + } + //索引初始化 + ensureJobDocumentIndexExists(); + //查询所有岗位数据 + List allJobList = jobMapper.selectBatchIds(jobIdList); + if (allJobList == null || allJobList.isEmpty()) { + LambdaEsQueryWrapper delWrapper = new LambdaEsQueryWrapper<>(); + delWrapper.in(ESJobDocument::getJobId, jobIdList); + esJobDocumentMapper.delete(delWrapper); + return; + } + //查询所有企业数据 + List companyIdList = allJobList.stream() + .map(Job::getCompanyId) + .filter(id -> id != null) + .distinct() + .collect(Collectors.toList()); + List companyList = new ArrayList<>(); + if (!companyIdList.isEmpty()) { + companyList = companyMapper.selectBatchIds(companyIdList); + } + Map companyMap = companyList.stream() + .collect(Collectors.toMap(Company::getCompanyId, c -> c)); + + // 批量转换ES + List esDocList = allJobList.stream() + .map(job -> convertToEsDoc(job, companyMap)) + .collect(Collectors.toList()); + + try { + // 批量删除旧索引 + LambdaEsQueryWrapper delWrapper = new LambdaEsQueryWrapper<>(); + delWrapper.in(ESJobDocument::getJobId, jobIdList); + esJobDocumentMapper.delete(delWrapper); + // 批量插入新索引 + esJobDocumentMapper.insertBatch(esDocList); + } catch (Exception e) { + throw new RuntimeException("ES批量更新失败", e); + } + } + + /** + * Job 转 ESJobDocument 抽取单独方法 + */ + private ESJobDocument convertToEsDoc(Job job, Map companyMap) { + ESJobDocument esDoc = new ESJobDocument(); + BeanUtils.copyBeanProp(esDoc, job); + // 企业编码赋值 + if (job.getCompanyId() != null) { + Company company = companyMap.get(job.getCompanyId()); + if (company != null) { + esDoc.setCode(company.getCode()); + esDoc.setCompanyNature(company.getCompanyNature()); + } + } + + esDoc.setJobCategory(resolveJobCategoryLabel(job.getJobCategory())); + esDoc.setAppJobUrl("https://www.xjksly.cn/app#/packageA/pages/post/post?jobId="+ job.getJobId()); + esDoc.setScale(parseIntDefaultZero(job.getScale())); + //工作经验 + if (StringUtil.isEmptyOrNull(job.getExperience())) { + esDoc.setExperience("0"); + esDoc.setExperience_int(0); + } else { + Integer expInt = parseIntDefaultZero(job.getExperience()); + esDoc.setExperience_int(expInt); + } + + // 学历字段统一处理 + if (StringUtil.isEmptyOrNull(job.getEducation())) { + esDoc.setEducation("-1"); + esDoc.setEducation_int(-1); + } else { + Integer eduInt = parseIntDefaultMinusOne(job.getEducation()); + esDoc.setEducation_int(eduInt); + } + + // 经纬度拼接 + if (esDoc.getLatitude() != null && esDoc.getLongitude() != null) { + esDoc.setLatAndLon(esDoc.getLatitude() + "," + esDoc.getLongitude()); + } + + return esDoc; + } + + /** + * 字符串转数字,空/非法返回0 + */ + private Integer parseIntDefaultZero(String str) { + if (StringUtil.isEmptyOrNull(str)) { + return 0; + } + try { + return Integer.valueOf(str); + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * 字符串转数字,空/非法返回-1 + */ + private Integer parseIntDefaultMinusOne(String str) { + if (StringUtil.isEmptyOrNull(str)) { + return -1; + } + try { + return Integer.valueOf(str); + } catch (NumberFormatException e) { + return -1; + } + } + + /** + * 岗位名称推荐 + * @param wrapper + * @param nameList + * @param boost + */ + /*private void appendJobTitleMatch(LambdaEsQueryWrapper wrapper, List nameList, Float boost) { + if (CollUtil.isEmpty(nameList)) { + return; + } + for (String title : nameList) { + wrapper.should(w -> w.match(ESJobDocument::getJobTitle, title, boost)); + } + }*/ +} 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)); + } +}