添加适配度排序

This commit is contained in:
zk
2026-08-05 15:52:55 +08:00
parent d9239a2b68
commit ff04496117
3 changed files with 99 additions and 6 deletions
@@ -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);
}
/**
* 岗位列表查询(按匹配度排序)
* <p>入参与 /job/list 一致。匹配度是内存计算的,无法在 SQL 层排序,因此先取候选池全量算分排序
* 并整体缓存,再在本方法内对缓存结果切片分页</p>
* <p>排序仅在候选池范围内有效,候选池之外的岗位不参与排序</p>
* <p>未登录用户没有简历和求职意向,匹配度几乎全部相同,排序无意义,直接退回 /job/list 的最新排序</p>
*/
@PostMapping("/list/match")
public PageResult<JobDto> 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<JobDto> 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<JobDto> pageList = new ArrayList<>(sortedList.subList(fromIndex, toIndex));
return new PageResult<>(pageNum.longValue(), pageSize.longValue(), total, pageList);
}
/**
* 岗位列表总数查询
* <p>筛选条件与 /job/list 一致,仅返回符合条件的岗位总数,接口允许不登录访问</p>
@@ -87,6 +87,9 @@ public class JobService {
@Autowired
private AiChatAbility aiChatAbility;
/** 匹配度排序的候选池大小,匹配度无法在 SQL 层排序,只能在此范围内做全量算分后排序 */
public static final int CANDIDATE_POOL_SIZE = 5000;
/**
* 岗位列表查询
* <p>方法逻辑流程:</p>
@@ -166,6 +169,37 @@ public class JobService {
return new PageResult<>(page.getCurrent(), page.getSize(), page.getTotal(), dtoList);
}
/**
* 岗位匹配度排序查询(候选池全量,不分页)
* <p>匹配度是内存计算出来的,无法在 SQL 层 ORDER BY,因此一次性取候选池全量算分后排序</p>
* <p>结果整体缓存,翻页由调用方对返回列表切片,避免每页重复计算</p>
*
* <p>方法逻辑流程:</p>
* <p>1. 复用 listJobs 查询候选池(含匹配度计算)</p>
* <p>2. 按匹配度倒序排序,同分按岗位ID倒序保证顺序稳定</p>
*
* <p>调用约定:缓存 key 由 param 与 userId 共同决定,且在方法执行前就已生成,
* 因此调用方必须先把 pageNum 置为 1、pageSize 置为 CANDIDATE_POOL_SIZE 再传入。
* 否则每一页都会生成独立缓存条目,缓存命中率恒为 0(方法内部无法补救)</p>
*
* @param param 查询条件,pageNum 须为 1、pageSize 须为 CANDIDATE_POOL_SIZE
* @param userId 用户ID,匹配度因人而异,参与缓存 key
* @return 按匹配度倒序排列的候选池全量列表
*/
@Cacheable(cacheNames = CacheConfig.JOB_MATCH_SORT)
public List<JobDto> listJobsSortedByMatch(JobQueryParam param, Long userId) {
// 1. 查询候选池(listJobs 内部已完成匹配度计算)
PageResult<JobDto> candidates = listJobs(param, userId);
List<JobDto> 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;
}
/**
* 岗位列表总数查询
* <p>过滤条件与 listJobs 完全一致(SQL 层共用 jobPageWhere 片段),不分页、不查收藏/投递状态、不算匹配度</p>
@@ -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;
/**
* 缓存管理器
* <p>三个热门榜单各注册一份独立的 Caffeine 实例,过期时间互相独立(单位:分钟)</p>
@@ -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<Object, Object> buildCache(long expireMinutes) {
private Cache<Object, Object> buildCache(long expireMinutes, long maximumSize) {
return Caffeine.newBuilder()
.expireAfterWrite(expireMinutes, TimeUnit.MINUTES)
.maximumSize(MAX_SIZE)
.maximumSize(maximumSize)
.build();
}
}