Compare commits
7
Commits
5556690788
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f6a19a11 | ||
|
|
ff04496117 | ||
|
|
d9239a2b68 | ||
|
|
5074297775 | ||
|
|
243b1ffc5a | ||
|
|
9ce99ab3d7 | ||
|
|
04b6f94935 |
@@ -8,14 +8,21 @@ import org.jiayunet.pojo.dto.job.JobDetailDto;
|
||||
import org.jiayunet.pojo.dto.job.JobDto;
|
||||
import org.jiayunet.pojo.dto.job.JobAgentRecommendDto;
|
||||
import org.jiayunet.pojo.param.job.*;
|
||||
import org.jiayunet.pojo.vo.HotCityVo;
|
||||
import org.jiayunet.pojo.vo.HotIndustryVo;
|
||||
import org.jiayunet.pojo.vo.HotJobCategoryVo;
|
||||
import org.jiayunet.pojo.vo.JobApplyCountVo;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 岗位接口
|
||||
*
|
||||
@@ -47,6 +54,102 @@ public class JobController {
|
||||
return jobService.listJobs(param, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位列表查询(按匹配度排序)
|
||||
* <p>入参与 /job/list 一致。匹配度是内存计算的,无法在 SQL 层排序,因此先取候选池全量算分排序
|
||||
* 并整体缓存,再在本方法内对缓存结果切片分页</p>
|
||||
* <p>排序仅在候选池范围内有效,候选池之外的岗位不参与排序</p>
|
||||
* <p>未登录用户没有简历和求职意向,匹配度几乎全部相同,排序无意义,直接退回 /job/list 的最新排序</p>
|
||||
*/
|
||||
@PostMapping("/list/match")
|
||||
@FuncPermission(value = "job_list", key = "#param.pageNum")
|
||||
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>
|
||||
*/
|
||||
@PostMapping("/list/count")
|
||||
public Long countJobs(@Validated @RequestBody JobQueryParam param) {
|
||||
|
||||
Long userId = 0L;
|
||||
try {
|
||||
userId = UserSecurityTool.getUserId();
|
||||
} catch (Exception e) {
|
||||
// 接口允许不登录,不处理
|
||||
}
|
||||
|
||||
return jobService.countJobs(param, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位热门行业
|
||||
* <p>按上架岗位数量倒序返回二级行业,接口允许不登录访问</p>
|
||||
*
|
||||
* @param limit 返回条数,默认10
|
||||
*/
|
||||
@GetMapping("/industry/hot")
|
||||
public List<HotIndustryVo> listHotIndustries(@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
return jobService.listHotIndustries(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门岗位类型
|
||||
* <p>按上架岗位数量倒序返回二级岗位类型,接口允许不登录访问</p>
|
||||
*
|
||||
* @param limit 返回条数,默认10
|
||||
*/
|
||||
@GetMapping("/category/hot")
|
||||
public List<HotJobCategoryVo> listHotJobCategories(@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
return jobService.listHotJobCategories(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门城市
|
||||
* <p>按上架岗位数量倒序返回城市,接口允许不登录访问</p>
|
||||
*
|
||||
* @param limit 返回条数,默认10
|
||||
*/
|
||||
@GetMapping("/city/hot")
|
||||
public List<HotCityVo> listHotCities(@RequestParam(required = false, defaultValue = "10") Integer limit) {
|
||||
return jobService.listHotCities(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 岗位详情
|
||||
* <p>返回岗位完整信息、公司信息、匹配度、收藏状态</p>
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.jiayunet.controller;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.jiayunet.pojo.dto.SmsLoginDto;
|
||||
import org.jiayunet.pojo.dto.SuperAdminLoginDto;
|
||||
import org.jiayunet.pojo.vo.LoginVo;
|
||||
import org.jiayunet.service.LoginService;
|
||||
import org.jiayunet.tool.UserSecurityTool;
|
||||
@@ -37,6 +38,17 @@ public class LoginController {
|
||||
return loginService.smsLogin(dto.getMobileNumber(), dto.getCode(), dto.getInviteCode(), request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管密码登录
|
||||
* <p>用手机号+超管密码直接登录,用户不存在则自动注册并赠送正式会员</p>
|
||||
*/
|
||||
@PostMapping("/login/superAdminLogin")
|
||||
public LoginVo superAdminLogin(@Validated @RequestBody SuperAdminLoginDto dto,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
return loginService.superAdminLogin(dto.getMobileNumber(), dto.getPassword(), request, response);
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public boolean logout(HttpServletRequest request, HttpServletResponse response) {
|
||||
loginService.logout(request, response);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.jiayunet.pojo.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.Pattern;
|
||||
|
||||
/**
|
||||
* 超管密码登录入参
|
||||
*
|
||||
* @author zk
|
||||
*/
|
||||
@Data
|
||||
public class SuperAdminLoginDto {
|
||||
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||
private String mobileNumber;
|
||||
|
||||
@NotBlank(message = "超管密码不能为空")
|
||||
private String password;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.jiayunet.pojo.PageParam;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -44,4 +45,13 @@ public class JobQueryParam extends PageParam {
|
||||
|
||||
/** 公司ID */
|
||||
private Long companyId;
|
||||
|
||||
/** 学历要求列表 0=不限 1=大专 2=本科 3=硕士 4=博士(可多选,null或空=不限制) */
|
||||
private List<Integer> educations;
|
||||
|
||||
/** 发布开始时间(对应 bg_job.expire_at,闭区间) */
|
||||
private Instant publishStartTime;
|
||||
|
||||
/** 发布截止时间(对应 bg_job.expire_at,闭区间) */
|
||||
private Instant publishEndTime;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.jiayunet.service;
|
||||
|
||||
import org.jiayunet.ai.AiChatAbility;
|
||||
import org.jiayunet.ai.AiResponseCleanTool;
|
||||
import org.jiayunet.config.CacheConfig;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.util.StopWatch;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
@@ -22,6 +24,9 @@ import org.jiayunet.pojo.po.*;
|
||||
import org.jiayunet.tool.HttpTool;
|
||||
import org.jiayunet.exception.BusinessException;
|
||||
import org.jiayunet.exception.BusinessExpCodeEnum;
|
||||
import org.jiayunet.pojo.vo.HotCityVo;
|
||||
import org.jiayunet.pojo.vo.HotIndustryVo;
|
||||
import org.jiayunet.pojo.vo.HotJobCategoryVo;
|
||||
import org.jiayunet.pojo.vo.JobApplyCountVo;
|
||||
import org.jiayunet.pojo.vo.JobFavoriteCountVo;
|
||||
import org.jiayunet.pojo.vo.JobListItemVo;
|
||||
@@ -82,6 +87,9 @@ public class JobService {
|
||||
@Autowired
|
||||
private AiChatAbility aiChatAbility;
|
||||
|
||||
/** 匹配度排序的候选池大小,匹配度无法在 SQL 层排序,只能在此范围内做全量算分后排序 */
|
||||
public static final int CANDIDATE_POOL_SIZE = 5000;
|
||||
|
||||
/**
|
||||
* 岗位列表查询
|
||||
* <p>方法逻辑流程:</p>
|
||||
@@ -127,7 +135,7 @@ public class JobService {
|
||||
// 6. 执行分页查询
|
||||
Page<JobListItemVo> pageParam = param.toPage();
|
||||
pageParam.setSearchCount(false);
|
||||
Page<JobListItemVo> page = jobMapper.selectJobPage(pageParam, param.getJobIds(),param.getStatusFilter(), param.getKeyword(), expandedRegionCodes, expandedCategoryIds, expandedIndustryIds, param.getEmploymentType(), excludeJobIds, excludeCompanyIds, excludeRegionCodes, excludeIndustryIds, param.getRecruitCategory(), param.getCompanyTypes(), param.getCompanyId());
|
||||
Page<JobListItemVo> page = jobMapper.selectJobPage(pageParam, param.getJobIds(),param.getStatusFilter(), param.getKeyword(), expandedRegionCodes, expandedCategoryIds, expandedIndustryIds, param.getEmploymentType(), excludeJobIds, excludeCompanyIds, excludeRegionCodes, excludeIndustryIds, param.getRecruitCategory(), param.getCompanyTypes(), param.getCompanyId(), param.getEducations(), param.getPublishStartTime(), param.getPublishEndTime());
|
||||
|
||||
// 7. 查询收藏状态
|
||||
List<Long> jobIds = page.getRecords().stream().map(JobListItemVo::getId).collect(Collectors.toList());
|
||||
@@ -161,6 +169,110 @@ 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>
|
||||
* <p>方法逻辑流程:</p>
|
||||
* <p>1. 扩展筛选条件的子级(地区/岗位类型/行业)</p>
|
||||
* <p>2. 查询用户不感兴趣记录</p>
|
||||
* <p>3. 提取排除列表(岗位/公司/地区/行业)</p>
|
||||
* <p>4. 执行统计查询</p>
|
||||
*/
|
||||
public Long countJobs(JobQueryParam param, Long userId) {
|
||||
|
||||
// 默认只查有效岗位
|
||||
if (param.getStatusFilter() == null) {
|
||||
param.setStatusFilter(Collections.singletonList(0));
|
||||
}
|
||||
|
||||
// 1. 扩展筛选条件的子级
|
||||
List<String> expandedRegionCodes = expandRegionCodes(param.getRegionCodes());
|
||||
List<Long> expandedCategoryIds = expandCategoryIds(param.getCategoryIds());
|
||||
List<Long> expandedIndustryIds = expandIndustryIds(param.getIndustryIds());
|
||||
|
||||
// 2. 查询用户不感兴趣记录
|
||||
List<UserJobDislike> dislikes = userJobDislikeMapper.selectList(new LambdaQueryWrapper<UserJobDislike>().eq(UserJobDislike::getUserId, userId));
|
||||
|
||||
// 3. 提取排除列表
|
||||
List<Long> excludeJobIds = dislikes.stream().map(UserJobDislike::getJobId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
||||
if (param.getExcludeJobIds() != null && !param.getExcludeJobIds().isEmpty()) {
|
||||
excludeJobIds.addAll(param.getExcludeJobIds());
|
||||
}
|
||||
List<Long> excludeCompanyIds = dislikes.stream().map(UserJobDislike::getCompanyId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
||||
|
||||
// 4. 提取不感兴趣的地区(直接使用,不扩展子级)
|
||||
List<String> excludeRegionCodes = dislikes.stream().map(UserJobDislike::getRegionCode).filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
||||
|
||||
// 5. 提取不感兴趣的行业(直接使用,不扩展子级)
|
||||
List<Long> excludeIndustryIds = dislikes.stream().map(UserJobDislike::getIndustryId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
||||
|
||||
// 6. 执行统计查询
|
||||
Long total = jobMapper.countJobPage(param.getJobIds(), param.getStatusFilter(), param.getKeyword(), expandedRegionCodes, expandedCategoryIds, expandedIndustryIds, param.getEmploymentType(), excludeJobIds, excludeCompanyIds, excludeRegionCodes, excludeIndustryIds, param.getRecruitCategory(), param.getCompanyTypes(), param.getCompanyId(), param.getEducations(), param.getPublishStartTime(), param.getPublishEndTime());
|
||||
|
||||
return total == null ? 0L : total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门行业查询(二级行业)
|
||||
* <p>岗位关联公司、公司关联行业(bg_industry level=2),按上架岗位数量倒序取前 limit 个</p>
|
||||
* <p>只返回行业ID和名称,岗位数量仅用于排序不对外暴露</p>
|
||||
*/
|
||||
@Cacheable(cacheNames = CacheConfig.HOT_INDUSTRY)
|
||||
public List<HotIndustryVo> listHotIndustries(Integer limit) {
|
||||
return jobMapper.selectHotIndustries(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门岗位类型查询(二级岗位类型)
|
||||
* <p>bg_job.category_id 存三级叶子,SQL 内自关联上溯到二级后按二级分组,按上架岗位数量倒序取前 limit 个</p>
|
||||
* <p>只返回岗位类型ID和名称,岗位数量仅用于排序不对外暴露</p>
|
||||
*/
|
||||
@Cacheable(cacheNames = CacheConfig.HOT_JOB_CATEGORY)
|
||||
public List<HotJobCategoryVo> listHotJobCategories(Integer limit) {
|
||||
return jobMapper.selectHotJobCategories(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门城市查询
|
||||
* <p>岗位关联公司、公司关联地区,按上架岗位数量倒序取前 limit 个</p>
|
||||
* <p>只返回地区编码和名称,岗位数量仅用于排序不对外暴露</p>
|
||||
*/
|
||||
@Cacheable(cacheNames = CacheConfig.HOT_CITY)
|
||||
public List<HotCityVo> listHotCities(Integer limit) {
|
||||
return jobMapper.selectHotCities(limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展地区编码(包含自身和所有子级)
|
||||
* <p>一次查询:code本身 + provinceCode匹配 + cityCode匹配</p>
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -33,7 +34,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 登录服务
|
||||
* <p>依赖:SmsService(验证码发送与校验)、UserRegisterService(自动注册)</p>
|
||||
* <p>依赖:SmsService(验证码发送与校验)、UserRegisterService(自动注册/超管注册)</p>
|
||||
* <p>使用表:bg_user(查询/创建用户)</p>
|
||||
* <p>使用Redis:login:token:{userId}(登录令牌与多设备管理)</p>
|
||||
*
|
||||
@@ -52,6 +53,9 @@ public class LoginService {
|
||||
@Value("${app.login.device_online_quantity:5}")
|
||||
private int deviceOnlineQuantity;
|
||||
|
||||
@Value("${app.super_admin.password:}")
|
||||
private String superAdminPassword;
|
||||
|
||||
@Autowired
|
||||
private SmsService smsService;
|
||||
|
||||
@@ -106,6 +110,65 @@ public class LoginService {
|
||||
throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "账号已被禁用");
|
||||
}
|
||||
|
||||
// 清除验证码
|
||||
smsService.clear(mobileNumber, SmsTemplateEnum.UNIVERSAL);
|
||||
|
||||
|
||||
return doLogin(user, request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 超管密码登录
|
||||
* <p>1. 校验超管密码 2. 查询用户,不存在则走超管注册 3. 检查用户状态 4. 完成登录(签发令牌、多设备管理、写Cookie)</p>
|
||||
*
|
||||
* @param mobileNumber 手机号
|
||||
* @param password 超管密码,取 app.super_admin.password,未配置时禁用该登录方式
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public LoginVo superAdminLogin(String mobileNumber, String password, HttpServletRequest request, HttpServletResponse response) {
|
||||
Assert.hasText(mobileNumber, "手机号不能为空");
|
||||
Assert.hasText(password, "超管密码不能为空");
|
||||
|
||||
// 未配置超管密码时该登录方式不可用
|
||||
if (!StringUtils.hasText(superAdminPassword)) {
|
||||
log.warn("超管登录被拒绝:未配置超管密码 mobileNumber:{}", mobileNumber);
|
||||
throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "超管密码错误");
|
||||
}
|
||||
|
||||
// 校验超管密码
|
||||
if (!superAdminPassword.equals(password)) {
|
||||
log.warn("超管登录密码错误 mobileNumber:{} ip:{}", mobileNumber, HttpIpTool.gteRealIP(request));
|
||||
throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "超管密码错误");
|
||||
}
|
||||
|
||||
// 查询用户
|
||||
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getMobileNumber, mobileNumber));
|
||||
|
||||
// 用户不存在则走超管注册
|
||||
if (user == null) {
|
||||
user = userRegisterService.supperMasterRegister(mobileNumber);
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (user.getStatus() != null && user.getStatus() == 1) {
|
||||
throw new BusinessException(BusinessExpCodeEnum.UNKNOWN_ERROR, "账号已被禁用");
|
||||
}
|
||||
|
||||
log.info("超管登录成功 userId:{} mobileNumber:{} ip:{}", user.getId(), mobileNumber, HttpIpTool.gteRealIP(request));
|
||||
|
||||
return doLogin(user, request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成登录:签发令牌、维护多设备在线、写入Cookie
|
||||
* <p>1. 生成uuId并签发JWT 2. 读取或初始化Redis登录信息 3. 过滤过期设备并按上限剔除最早设备 4. 追加当前设备后回写Redis 5. 设置Token Cookie</p>
|
||||
*
|
||||
* @param user 已通过身份校验的用户
|
||||
* @param request 用于获取真实登录IP
|
||||
* @param response 用于写入Token Cookie
|
||||
* @return 登录结果
|
||||
*/
|
||||
public LoginVo doLogin(User user, HttpServletRequest request, HttpServletResponse response) {
|
||||
// 生成JWT
|
||||
String uuId = UUID.randomUUID().toString().replace("-", "");
|
||||
Algorithm algorithm = Algorithm.HMAC256(secret);
|
||||
@@ -150,8 +213,6 @@ public class LoginService {
|
||||
info.setLoginDevices(devices);
|
||||
redisServerTool.set(redisKey, info, tokenExceedTime, TimeUnit.SECONDS);
|
||||
|
||||
// 清除验证码
|
||||
smsService.clear(mobileNumber, SmsTemplateEnum.UNIVERSAL);
|
||||
|
||||
// 设置Cookie
|
||||
Cookie cookie = new Cookie("Token", token);
|
||||
|
||||
@@ -67,6 +67,37 @@ public class UserRegisterService {
|
||||
@Value("${app.reward.invite-milestone-days:150}")
|
||||
private int inviteMilestoneDays;
|
||||
|
||||
@Value("${app.super_admin.register_gift_days:365}")
|
||||
private int superAdminRegisterGiftDays;
|
||||
|
||||
/**
|
||||
* 超级管理注册新用户
|
||||
* <p>1. 创建用户并生成邀请码 2. 发放会员,天数取 app.super_admin.register_gift_days</p>
|
||||
*
|
||||
* @param mobileNumber 手机号
|
||||
* @return 新注册的用户
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public User supperMasterRegister(String mobileNumber) {
|
||||
// 创建用户
|
||||
User user = new User();
|
||||
user.setMobileNumber(mobileNumber);
|
||||
user.setNick("用户" + mobileNumber.substring(mobileNumber.length() - 4));
|
||||
user.setInviteCode(generateInviteCode());
|
||||
user.setStatus(0);
|
||||
user.setIsDelete(0L);
|
||||
user.setCreateTime(Instant.now());
|
||||
userMapper.insert(user);
|
||||
|
||||
|
||||
// 发放新用户注册会员权益
|
||||
memberGrantService.grant(user.getId(), superAdminRegisterGiftDays, 1); // 1=正式
|
||||
messageService.sendToUser(1, user.getId(), "欢迎加入OfferPie", "注册成功,已赠送您" + superAdminRegisterGiftDays + "天会员权益,快来体验AI求职助手吧!", null, null);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 注册新用户
|
||||
* <p>1. 创建用户并生成邀请码 2. 若携带邀请码则绑定邀请关系</p>
|
||||
|
||||
@@ -57,6 +57,13 @@ app:
|
||||
#设备在线数量
|
||||
device_online_quantity: 3
|
||||
|
||||
# 超级管理配置
|
||||
super_admin:
|
||||
# 超级管理密码(可绕过验证码直接登录,务必通过环境变量注入)
|
||||
password: ${SUPER_ADMIN_PASSWORD:H7KQ2XR9LP}
|
||||
# 超级管理注册赠送会员天数
|
||||
register_gift_days: ${SUPER_ADMIN_REGISTER_GIFT_DAYS:365}
|
||||
|
||||
# 短信
|
||||
sms:
|
||||
service_provider: aliyun
|
||||
|
||||
@@ -57,6 +57,13 @@ app:
|
||||
#设备在线数量
|
||||
device_online_quantity: 3
|
||||
|
||||
# 超级管理配置
|
||||
super_admin:
|
||||
# 超级管理密码(可绕过验证码直接登录,务必通过环境变量注入)
|
||||
password: ${SUPER_ADMIN_PASSWORD:H7KQ2XR9LP}
|
||||
# 超级管理注册赠送会员天数
|
||||
register_gift_days: ${SUPER_ADMIN_REGISTER_GIFT_DAYS:365}
|
||||
|
||||
# 短信
|
||||
sms:
|
||||
service_provider: aliyun
|
||||
|
||||
@@ -58,6 +58,13 @@ app:
|
||||
#设备在线数量
|
||||
device_online_quantity: 3
|
||||
|
||||
# 超级管理配置
|
||||
super_admin:
|
||||
# 超级管理密码(可绕过验证码直接登录,务必通过环境变量注入)
|
||||
password: ${SUPER_ADMIN_PASSWORD:H7KQ2XR9LP}
|
||||
# 超级管理注册赠送会员天数
|
||||
register_gift_days: ${SUPER_ADMIN_REGISTER_GIFT_DAYS:365}
|
||||
|
||||
# 短信
|
||||
sms:
|
||||
service_provider: aliyun
|
||||
|
||||
@@ -45,6 +45,15 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Cache 抽象 + Caffeine 本地缓存,用于热门榜单等全局只读数据 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.redisson</groupId>
|
||||
<artifactId>redisson-spring-boot-starter</artifactId>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.jiayunet.config;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 本地缓存配置
|
||||
* <p>基于 Caffeine 的 Spring Cache 实现,用于热门榜单等全局只读、变化慢、允许短时陈旧的数据</p>
|
||||
* <p>每个缓存独立注册、独立过期时间,调整某个缓存的时长直接改注册时传入的分钟数,互不影响</p>
|
||||
*
|
||||
* @author zk
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
/** 缓存名:热门行业 */
|
||||
public static final String HOT_INDUSTRY = "hotIndustry";
|
||||
|
||||
/** 缓存名:热门岗位类型 */
|
||||
public static final String HOT_JOB_CATEGORY = "hotJobCategory";
|
||||
|
||||
/** 缓存名:热门城市 */
|
||||
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>
|
||||
* <p>另设一份兜底策略:业务里如果用了未登记的缓存名,动态创建时同样带过期时间,不会永不过期</p>
|
||||
*/
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
|
||||
// 兜底策略(动态创建未登记缓存时生效)
|
||||
cacheManager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(30, TimeUnit.MINUTES)
|
||||
.maximumSize(MAX_SIZE));
|
||||
|
||||
// 各缓存独立注册,独立过期时间
|
||||
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 缓存实例
|
||||
*
|
||||
* @param expireMinutes 写入后过期时长(分钟)
|
||||
* @param maximumSize 最大条目数
|
||||
*/
|
||||
private Cache<Object, Object> buildCache(long expireMinutes, long maximumSize) {
|
||||
return Caffeine.newBuilder()
|
||||
.expireAfterWrite(expireMinutes, TimeUnit.MINUTES)
|
||||
.maximumSize(maximumSize)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.jiayunet.pojo.po.Job;
|
||||
import org.jiayunet.pojo.vo.HotCityVo;
|
||||
import org.jiayunet.pojo.vo.HotIndustryVo;
|
||||
import org.jiayunet.pojo.vo.HotJobCategoryVo;
|
||||
import org.jiayunet.pojo.vo.JobListItemVo;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -19,5 +23,29 @@ public interface JobMapper extends CommonMapper<Job> {
|
||||
/**
|
||||
* 分页查询岗位列表
|
||||
*/
|
||||
Page<JobListItemVo> selectJobPage(Page<JobListItemVo> page, @Param("jobIds") List<Long> jobIds, @Param("statusFilter") List<Integer> statusFilter, @Param("keyword") String keyword, @Param("regionCodes") List<String> regionCodes, @Param("categoryIds") List<Long> categoryIds, @Param("industryIds") List<Long> industryIds, @Param("employmentType") Integer employmentType, @Param("excludeJobIds") List<Long> excludeJobIds, @Param("excludeCompanyIds") List<Long> excludeCompanyIds, @Param("excludeRegionCodes") List<String> excludeRegionCodes, @Param("excludeIndustryIds") List<Long> excludeIndustryIds, @Param("recruitCategory") Integer recruitCategory, @Param("companyTypes") List<String> companyTypes, @Param("companyId") Long companyId);
|
||||
Page<JobListItemVo> selectJobPage(Page<JobListItemVo> page, @Param("jobIds") List<Long> jobIds, @Param("statusFilter") List<Integer> statusFilter, @Param("keyword") String keyword, @Param("regionCodes") List<String> regionCodes, @Param("categoryIds") List<Long> categoryIds, @Param("industryIds") List<Long> industryIds, @Param("employmentType") Integer employmentType, @Param("excludeJobIds") List<Long> excludeJobIds, @Param("excludeCompanyIds") List<Long> excludeCompanyIds, @Param("excludeRegionCodes") List<String> excludeRegionCodes, @Param("excludeIndustryIds") List<Long> excludeIndustryIds, @Param("recruitCategory") Integer recruitCategory, @Param("companyTypes") List<String> companyTypes, @Param("companyId") Long companyId, @Param("educations") List<Integer> educations, @Param("publishStartTime") Instant publishStartTime, @Param("publishEndTime") Instant publishEndTime);
|
||||
|
||||
/**
|
||||
* 统计岗位列表总数
|
||||
* <p>过滤条件与 selectJobPage 共用同一段 SQL(jobPageWhere),保证列表与总数口径一致</p>
|
||||
*/
|
||||
Long countJobPage(@Param("jobIds") List<Long> jobIds, @Param("statusFilter") List<Integer> statusFilter, @Param("keyword") String keyword, @Param("regionCodes") List<String> regionCodes, @Param("categoryIds") List<Long> categoryIds, @Param("industryIds") List<Long> industryIds, @Param("employmentType") Integer employmentType, @Param("excludeJobIds") List<Long> excludeJobIds, @Param("excludeCompanyIds") List<Long> excludeCompanyIds, @Param("excludeRegionCodes") List<String> excludeRegionCodes, @Param("excludeIndustryIds") List<Long> excludeIndustryIds, @Param("recruitCategory") Integer recruitCategory, @Param("companyTypes") List<String> companyTypes, @Param("companyId") Long companyId, @Param("educations") List<Integer> educations, @Param("publishStartTime") Instant publishStartTime, @Param("publishEndTime") Instant publishEndTime);
|
||||
|
||||
/**
|
||||
* 查询热门行业(二级行业)
|
||||
* <p>岗位关联公司、公司关联行业,按上架岗位数量倒序取前 limit 个</p>
|
||||
*/
|
||||
List<HotIndustryVo> selectHotIndustries(@Param("limit") Integer limit);
|
||||
|
||||
/**
|
||||
* 查询热门岗位类型(二级岗位类型)
|
||||
* <p>岗位类型表自关联,从三级叶子上溯到二级后分组,按上架岗位数量倒序取前 limit 个</p>
|
||||
*/
|
||||
List<HotJobCategoryVo> selectHotJobCategories(@Param("limit") Integer limit);
|
||||
|
||||
/**
|
||||
* 查询热门城市
|
||||
* <p>岗位关联公司、公司关联地区,按上架岗位数量倒序取前 limit 个</p>
|
||||
*/
|
||||
List<HotCityVo> selectHotCities(@Param("limit") Integer limit);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.jiayunet.pojo.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 热门城市VO
|
||||
*
|
||||
* @author zk
|
||||
*/
|
||||
@Data
|
||||
public class HotCityVo {
|
||||
|
||||
/** 地区编码 */
|
||||
private String code;
|
||||
|
||||
/** 地区名称 */
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.jiayunet.pojo.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 热门行业VO(二级行业)
|
||||
*
|
||||
* @author zk
|
||||
*/
|
||||
@Data
|
||||
public class HotIndustryVo {
|
||||
|
||||
/** 行业ID */
|
||||
private Long id;
|
||||
|
||||
/** 行业名称 */
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.jiayunet.pojo.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 热门岗位类型VO(二级岗位类型)
|
||||
*
|
||||
* @author zk
|
||||
*/
|
||||
@Data
|
||||
public class HotJobCategoryVo {
|
||||
|
||||
/** 岗位类型ID */
|
||||
private Long id;
|
||||
|
||||
/** 岗位类型名称 */
|
||||
private String name;
|
||||
}
|
||||
@@ -30,37 +30,11 @@
|
||||
<result column="company_industry_id" property="companyIndustryId"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="selectJobPage" resultMap="JobListItemVoMap">
|
||||
SELECT
|
||||
j.id,
|
||||
j.title,
|
||||
j.salary,
|
||||
j.education,
|
||||
j.tags,
|
||||
j.source_url,
|
||||
j.required_industry_id,
|
||||
j.min_experience,
|
||||
j.required_major_ids,
|
||||
j.major_sensitivity,
|
||||
j.status,
|
||||
j.expire_at,
|
||||
j.pyname,
|
||||
j.recruit_category,
|
||||
c.id AS company_id,
|
||||
c.name AS company_name,
|
||||
c.short_name AS company_short_name,
|
||||
c.company_type,
|
||||
c.logo_url AS company_logo_url,
|
||||
c.tags AS company_tags,
|
||||
c.industry_id AS company_industry_id,
|
||||
c.region_code,
|
||||
r.name AS region_name,
|
||||
cat.id AS category_id,
|
||||
cat.name AS category_name
|
||||
FROM bg_job j force index (`PRIMARY`)
|
||||
INNER JOIN bg_company c ON j.company_id = c.id
|
||||
INNER JOIN bg_china_regions_code r ON c.region_code = r.code
|
||||
INNER JOIN bg_job_category cat ON j.category_id = cat.id
|
||||
<!--
|
||||
岗位查询过滤条件(selectJobPage 与 countJobPage 共用)
|
||||
新增/修改筛选条件只需改这里,保证列表与总数口径一致
|
||||
-->
|
||||
<sql id="jobPageWhere">
|
||||
WHERE c.status = 1
|
||||
<!-- 岗位状态过滤 -->
|
||||
<if test="statusFilter != null and statusFilter.size() > 0">
|
||||
@@ -153,7 +127,126 @@
|
||||
<if test="companyId != null">
|
||||
AND j.company_id = #{companyId}
|
||||
</if>
|
||||
<!-- 筛选条件:学历要求(可多选) -->
|
||||
<if test="educations != null and educations.size() > 0">
|
||||
AND j.education IN
|
||||
<foreach collection="educations" item="education" open="(" separator="," close=")">
|
||||
#{education}
|
||||
</foreach>
|
||||
</if>
|
||||
<!-- 筛选条件:发布开始时间 -->
|
||||
<if test="publishStartTime != null">
|
||||
AND j.expire_at >= #{publishStartTime}
|
||||
</if>
|
||||
<!-- 筛选条件:发布截止时间 -->
|
||||
<if test="publishEndTime != null">
|
||||
AND j.expire_at <= #{publishEndTime}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<select id="selectJobPage" resultMap="JobListItemVoMap">
|
||||
SELECT
|
||||
j.id,
|
||||
j.title,
|
||||
j.salary,
|
||||
j.education,
|
||||
j.tags,
|
||||
j.source_url,
|
||||
j.required_industry_id,
|
||||
j.min_experience,
|
||||
j.required_major_ids,
|
||||
j.major_sensitivity,
|
||||
j.status,
|
||||
j.expire_at,
|
||||
j.pyname,
|
||||
j.recruit_category,
|
||||
c.id AS company_id,
|
||||
c.name AS company_name,
|
||||
c.short_name AS company_short_name,
|
||||
c.company_type,
|
||||
c.logo_url AS company_logo_url,
|
||||
c.tags AS company_tags,
|
||||
c.industry_id AS company_industry_id,
|
||||
c.region_code,
|
||||
r.name AS region_name,
|
||||
cat.id AS category_id,
|
||||
cat.name AS category_name
|
||||
FROM bg_job j force index (`PRIMARY`)
|
||||
INNER JOIN bg_company c ON j.company_id = c.id
|
||||
INNER JOIN bg_china_regions_code r ON c.region_code = r.code
|
||||
INNER JOIN bg_job_category cat ON j.category_id = cat.id
|
||||
<include refid="jobPageWhere"/>
|
||||
ORDER BY j.id DESC
|
||||
</select>
|
||||
|
||||
<select id="countJobPage" resultType="java.lang.Long">
|
||||
SELECT COUNT(*)
|
||||
FROM bg_job j
|
||||
INNER JOIN bg_company c ON j.company_id = c.id
|
||||
INNER JOIN bg_china_regions_code r ON c.region_code = r.code
|
||||
INNER JOIN bg_job_category cat ON j.category_id = cat.id
|
||||
<include refid="jobPageWhere"/>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
热门行业查询(二级行业)
|
||||
岗位 → 公司 → 公司所属行业,按上架岗位数量倒序取前 limit 个
|
||||
-->
|
||||
<select id="selectHotIndustries" resultType="org.jiayunet.pojo.vo.HotIndustryVo">
|
||||
SELECT
|
||||
i.id,
|
||||
i.name
|
||||
FROM bg_job j
|
||||
INNER JOIN bg_company c ON j.company_id = c.id
|
||||
INNER JOIN bg_industry i ON c.industry_id = i.id
|
||||
WHERE j.status = 0
|
||||
AND c.status = 1
|
||||
AND i.level = 2
|
||||
GROUP BY i.id, i.name
|
||||
ORDER BY COUNT(j.id) DESC
|
||||
<if test="limit != null">
|
||||
LIMIT #{limit}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
热门岗位类型查询(二级岗位类型)
|
||||
bg_job.category_id 存三级叶子,自关联上溯到二级后按二级分组,按上架岗位数量倒序取前 limit 个
|
||||
-->
|
||||
<select id="selectHotJobCategories" resultType="org.jiayunet.pojo.vo.HotJobCategoryVo">
|
||||
SELECT
|
||||
cat2.id,
|
||||
cat2.name
|
||||
FROM bg_job j
|
||||
INNER JOIN bg_job_category cat3 ON j.category_id = cat3.id
|
||||
INNER JOIN bg_job_category cat2 ON cat3.parent_id = cat2.id
|
||||
WHERE j.status = 0
|
||||
AND cat2.level = 2
|
||||
GROUP BY cat2.id, cat2.name
|
||||
ORDER BY COUNT(j.id) DESC
|
||||
<if test="limit != null">
|
||||
LIMIT #{limit}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!--
|
||||
热门城市查询
|
||||
岗位 → 公司 → 公司所在地区,按上架岗位数量倒序取前 limit 个
|
||||
-->
|
||||
<select id="selectHotCities" resultType="org.jiayunet.pojo.vo.HotCityVo">
|
||||
SELECT
|
||||
r.code,
|
||||
r.name
|
||||
FROM bg_job j
|
||||
INNER JOIN bg_company c ON j.company_id = c.id
|
||||
INNER JOIN bg_china_regions_code r ON c.region_code = r.code
|
||||
WHERE j.status = 0
|
||||
AND c.status = 1
|
||||
GROUP BY r.code, r.name
|
||||
ORDER BY COUNT(j.id) DESC
|
||||
<if test="limit != null">
|
||||
LIMIT #{limit}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
Reference in New Issue
Block a user