diff --git a/client-api/src/main/java/org/jiayunet/controller/JobController.java b/client-api/src/main/java/org/jiayunet/controller/JobController.java index 9f1d08b..fb7b3fc 100644 --- a/client-api/src/main/java/org/jiayunet/controller/JobController.java +++ b/client-api/src/main/java/org/jiayunet/controller/JobController.java @@ -16,9 +16,13 @@ import java.util.List; import org.jiayunet.pojo.vo.JobFavoriteCountVo; import org.jiayunet.service.JobService; import org.jiayunet.tool.UserSecurityTool; +import org.springframework.beans.BeanUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.ArrayList; +import java.util.Collections; + /** * 岗位接口 * @@ -50,6 +54,51 @@ public class JobController { return jobService.listJobs(param, userId); } + /** + * 岗位列表查询(按匹配度排序) + *

入参与 /job/list 一致。匹配度是内存计算的,无法在 SQL 层排序,因此先取候选池全量算分排序 + * 并整体缓存,再在本方法内对缓存结果切片分页

+ *

排序仅在候选池范围内有效,候选池之外的岗位不参与排序

+ *

未登录用户没有简历和求职意向,匹配度几乎全部相同,排序无意义,直接退回 /job/list 的最新排序

+ */ + @PostMapping("/list/match") + public PageResult listJobsByMatch(@Validated @RequestBody JobQueryParam param) { + + Long userId = 0L; + try { + userId = UserSecurityTool.getUserId(); + } catch (Exception e) { + // 接口允许不登录,不处理 + } + + // 未登录:匹配度无区分度,走原列表逻辑,省掉候选池全量算分 + if (userId == 0L) { + return jobService.listJobs(param, userId); + } + + Integer pageNum = param.getPageNum(); + Integer pageSize = param.getPageSize(); + + // 归一化候选池查询条件:pageNum/pageSize 参与缓存 key,必须固定,否则每页各自成为独立缓存条目 + JobQueryParam poolParam = new JobQueryParam(); + BeanUtils.copyProperties(param, poolParam); + poolParam.setPageNum(1); + poolParam.setPageSize(JobService.CANDIDATE_POOL_SIZE); + + List sortedList = jobService.listJobsSortedByMatch(poolParam, userId); + + // 手动分页:缓存中的列表为共享对象,切片后另建集合返回,避免污染缓存 + long total = sortedList.size(); + int fromIndex = (pageNum - 1) * pageSize; + if (fromIndex >= sortedList.size()) { + return new PageResult<>(pageNum.longValue(), pageSize.longValue(), total, Collections.emptyList()); + } + int toIndex = Math.min(fromIndex + pageSize, sortedList.size()); + List pageList = new ArrayList<>(sortedList.subList(fromIndex, toIndex)); + + return new PageResult<>(pageNum.longValue(), pageSize.longValue(), total, pageList); + } + /** * 岗位列表总数查询 *

筛选条件与 /job/list 一致,仅返回符合条件的岗位总数,接口允许不登录访问

diff --git a/client-api/src/main/java/org/jiayunet/service/JobService.java b/client-api/src/main/java/org/jiayunet/service/JobService.java index 955d7b1..015f280 100644 --- a/client-api/src/main/java/org/jiayunet/service/JobService.java +++ b/client-api/src/main/java/org/jiayunet/service/JobService.java @@ -87,6 +87,9 @@ public class JobService { @Autowired private AiChatAbility aiChatAbility; + /** 匹配度排序的候选池大小,匹配度无法在 SQL 层排序,只能在此范围内做全量算分后排序 */ + public static final int CANDIDATE_POOL_SIZE = 5000; + /** * 岗位列表查询 *

方法逻辑流程:

@@ -166,6 +169,37 @@ public class JobService { return new PageResult<>(page.getCurrent(), page.getSize(), page.getTotal(), dtoList); } + /** + * 岗位匹配度排序查询(候选池全量,不分页) + *

匹配度是内存计算出来的,无法在 SQL 层 ORDER BY,因此一次性取候选池全量算分后排序

+ *

结果整体缓存,翻页由调用方对返回列表切片,避免每页重复计算

+ * + *

方法逻辑流程:

+ *

1. 复用 listJobs 查询候选池(含匹配度计算)

+ *

2. 按匹配度倒序排序,同分按岗位ID倒序保证顺序稳定

+ * + *

调用约定:缓存 key 由 param 与 userId 共同决定,且在方法执行前就已生成, + * 因此调用方必须先把 pageNum 置为 1、pageSize 置为 CANDIDATE_POOL_SIZE 再传入。 + * 否则每一页都会生成独立缓存条目,缓存命中率恒为 0(方法内部无法补救)

+ * + * @param param 查询条件,pageNum 须为 1、pageSize 须为 CANDIDATE_POOL_SIZE + * @param userId 用户ID,匹配度因人而异,参与缓存 key + * @return 按匹配度倒序排列的候选池全量列表 + */ + @Cacheable(cacheNames = CacheConfig.JOB_MATCH_SORT) + public List listJobsSortedByMatch(JobQueryParam param, Long userId) { + + // 1. 查询候选池(listJobs 内部已完成匹配度计算) + PageResult candidates = listJobs(param, userId); + List list = new ArrayList<>(candidates.getList()); + + // 2. 匹配度倒序,同分按岗位ID倒序(同分数量大,缺少二级排序键会导致翻页重复或遗漏) + list.sort(Comparator.comparingInt(JobDto::getMatchScore).reversed().thenComparing(JobDto::getId, Comparator.reverseOrder())); + + log.info("匹配度排序完成 userId:{} 候选池:{}条", userId, list.size()); + return list; + } + /** * 岗位列表总数查询 *

过滤条件与 listJobs 完全一致(SQL 层共用 jobPageWhere 片段),不分页、不查收藏/投递状态、不算匹配度

diff --git a/common/src/main/java/org/jiayunet/config/CacheConfig.java b/common/src/main/java/org/jiayunet/config/CacheConfig.java index bf2e847..0dadf1e 100644 --- a/common/src/main/java/org/jiayunet/config/CacheConfig.java +++ b/common/src/main/java/org/jiayunet/config/CacheConfig.java @@ -30,9 +30,15 @@ public class CacheConfig { /** 缓存名:热门城市 */ public static final String HOT_CITY = "hotCity"; + /** 缓存名:岗位匹配度排序结果(单条目为5000个JobDto,占用大,容量单独控制) */ + public static final String JOB_MATCH_SORT = "jobMatchSort"; + /** 单个缓存最大条目数 */ private static final long MAX_SIZE = 100; + /** 岗位匹配度排序缓存最大条目数(单条目约5~10MB,不能按 MAX_SIZE 放开) */ + private static final long JOB_MATCH_SORT_MAX_SIZE = 20; + /** * 缓存管理器 *

三个热门榜单各注册一份独立的 Caffeine 实例,过期时间互相独立(单位:分钟)

@@ -48,22 +54,26 @@ public class CacheConfig { .maximumSize(MAX_SIZE)); // 各缓存独立注册,独立过期时间 - cacheManager.registerCustomCache(HOT_INDUSTRY, buildCache(60)); - cacheManager.registerCustomCache(HOT_JOB_CATEGORY, buildCache(60)); - cacheManager.registerCustomCache(HOT_CITY, buildCache(60)); + cacheManager.registerCustomCache(HOT_INDUSTRY, buildCache(60, MAX_SIZE)); + cacheManager.registerCustomCache(HOT_JOB_CATEGORY, buildCache(60, MAX_SIZE)); + cacheManager.registerCustomCache(HOT_CITY, buildCache(60, MAX_SIZE)); + + // 匹配度排序结果:单条目占用大,容量收紧;内含收藏/投递状态快照,时长不宜过长 + cacheManager.registerCustomCache(JOB_MATCH_SORT, buildCache(5, JOB_MATCH_SORT_MAX_SIZE)); return cacheManager; } /** - * 构建一个指定过期时长的 Caffeine 缓存实例 + * 构建一个 Caffeine 缓存实例 * * @param expireMinutes 写入后过期时长(分钟) + * @param maximumSize 最大条目数 */ - private Cache buildCache(long expireMinutes) { + private Cache buildCache(long expireMinutes, long maximumSize) { return Caffeine.newBuilder() .expireAfterWrite(expireMinutes, TimeUnit.MINUTES) - .maximumSize(MAX_SIZE) + .maximumSize(maximumSize) .build(); } }