轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

404 lines
17 KiB

package com.mydreamjob.job;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobSearchItem;
import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.job.dto.JobUpdateRequest;
import com.mydreamjob.job.dto.CompanyManagedJobItem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 岗位管理 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class JobService {
private final JobRepository jobRepository;
private final CompanyRepository companyRepository;
@PersistenceContext
private EntityManager entityManager;
// ── 企业端接口 ──────────────────────────────────────────────
/**
* 创建岗位(默认 DRAFT)
*/
@Transactional
public Job createJob(Long companyId, JobCreateRequest req) {
Job job = Job.builder()
.companyId(companyId)
.title(req.getTitle())
.description(req.getDescription())
.requirements(req.getRequirements())
.city(req.getCity())
.employmentType(req.getEmploymentType())
.salaryMin(req.getSalaryMin())
.salaryMax(req.getSalaryMax())
.status(JobStatus.DRAFT.name())
.build();
return jobRepository.save(job);
}
/**
* 查询岗位详情(含非 PUBLISHED 状态)
*/
public Job getJob(Long jobId) {
return jobRepository.findById(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND));
}
/**
* 更新岗位(仅岗位所属企业可操作,部分字段更新)
*/
@Transactional
public Job updateJob(Long companyId, Long jobId, JobUpdateRequest req) {
Job job = getCompanyJob(companyId, jobId);
if (!JobStatus.DRAFT.name().equals(job.getStatus())) {
requireVerifiedCompany(companyId);
}
if (req.getTitle() != null) job.setTitle(req.getTitle());
if (req.getDescription() != null) job.setDescription(req.getDescription());
if (req.getRequirements() != null) job.setRequirements(req.getRequirements());
if (req.getCity() != null) job.setCity(req.getCity());
if (req.getEmploymentType() != null) job.setEmploymentType(req.getEmploymentType());
if (req.getSalaryMin() != null) job.setSalaryMin(req.getSalaryMin());
if (req.getSalaryMax() != null) job.setSalaryMax(req.getSalaryMax());
return jobRepository.save(job);
}
/**
* 状态流转:DRAFT→PUBLISHED,PUBLISHED→CLOSED;禁止非法回退
*/
@Transactional
public Job transitionStatus(Long companyId, Long jobId, JobStatusRequest req) {
String target = req.getStatus();
// 未认证企业的发布请求必须在按 jobId 查询前拒绝,避免通过 400/404 差异探测岗位。
if (JobStatus.PUBLISHED.name().equals(target)) {
requireVerifiedCompany(companyId);
}
Job job = getCompanyJob(companyId, jobId);
String current = job.getStatus();
// 校验状态流转合法性
boolean valid = switch (current) {
case "DRAFT" -> "PUBLISHED".equals(target);
case "PUBLISHED" -> "CLOSED".equals(target);
case "CLOSED" -> false; // CLOSED 不可再流转
default -> false;
};
if (!valid) {
throw new BusinessException(ErrorCode.INVALID_JOB_STATUS_TRANSITION);
}
job.setStatus(target);
if ("PUBLISHED".equals(target)) {
job.setPublishedAt(LocalDateTime.now());
}
return jobRepository.save(job);
}
/** 用企业主键解析权威认证状态,供岗位与投递服务在已确认归属后执行统一门禁。 */
public Company requireVerifiedCompany(Long companyId) {
return VerifiedCompanyGuard.requireVerified(
companyRepository.findById(companyId).orElse(null));
}
/**
* 读取当前企业自己的岗位;对外统一将其他企业资源与不存在资源表示为 JOB_NOT_FOUND,
* 不向调用方泄露跨企业资源是否存在。
*/
public Job getCompanyJob(Long companyId, Long jobId) {
return jobRepository.findByIdAndCompanyId(jobId, companyId)
.orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND));
}
/** 查询当前企业自己的全部岗位状态;不调用发布门禁,也不产生写入副作用。 */
@Transactional(readOnly = true)
public Page<CompanyManagedJobItem> listCompanyJobs(Long companyId, String status,
String keyword, String city,
int page, int size) {
if (companyId == null || companyId <= 0 || page < 0 || size < 1 || size > 100) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (status != null) {
try {
JobStatus.valueOf(status);
} catch (IllegalArgumentException exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
String normalizedKeyword = normalizeFilter(keyword, 120);
String normalizedCity = normalizeFilter(city, 80);
Pageable pageable = PageRequest.of(page, size, Sort.by(
Sort.Order.desc("createdAt"), Sort.Order.desc("id")));
return jobRepository.findAll(CompanyJobManagementSpecification.forCompany(
companyId, status, normalizedKeyword, normalizedCity), pageable)
.map(CompanyManagedJobItem::from);
}
private String normalizeFilter(String value, int maxLength) {
if (value == null || value.isBlank()) return null;
String normalized = value.trim();
if (normalized.length() > maxLength) throw new BusinessException(ErrorCode.BAD_REQUEST);
return normalized;
}
// ── 公开接口 ────────────────────────────────────────────────
/**
* 公开岗位列表:仅返回 PUBLISHED,支持筛选和分页
*
* <p>DEF-01:关键词在此处规范化,使其与 {@code GET /jobs/search}
* ({@code JobSearchSpecification} 的 {@code keyword.trim()} + {@code isBlank()} 判空)
* 的输入语义一致 —— 只去两端空白,空串与纯空白等同「未传关键词」,
* 不折叠中间空格、不改大小写,也不改变任何过滤/排序/分页语义。</p>
*/
public Page<Map<String, Object>> listPublishedJobs(String keyword, String city,
String employmentType,
Integer salaryMin, Integer salaryMax,
int page, int size) {
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim();
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<Job> result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType,
salaryMin, salaryMax, pageable);
return result.map(this::toPublicView);
}
/**
* 公开岗位详情:返回 PUBLISHED 岗位;CLOSED 岗位也允许查看但不可投递(本阶段不处理投递)
*/
public Map<String, Object> getPublicJob(Long jobId) {
Job job = getJob(jobId);
if (!"PUBLISHED".equals(job.getStatus()) && !"CLOSED".equals(job.getStatus())) {
// DRAFT 岗位对公众不可见
throw new BusinessException(ErrorCode.JOB_NOT_FOUND);
}
return toPublicView(job);
}
// ── 辅助方法 ────────────────────────────────────────────────
public Job assertOwnerOrThrow(Long companyId, Long jobId) {
Job job = getJob(jobId);
if (!companyId.equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.JOB_OWNER_MISMATCH);
}
return job;
}
/**
* 企业端视图(含完整字段)
*/
public Map<String, Object> toView(Job job) {
Map<String, Object> view = new HashMap<>();
view.put("id", job.getId());
view.put("companyId", job.getCompanyId());
view.put("title", job.getTitle());
view.put("description", job.getDescription());
view.put("requirements", job.getRequirements());
view.put("city", job.getCity());
view.put("employmentType", job.getEmploymentType());
view.put("salaryMin", job.getSalaryMin());
view.put("salaryMax", job.getSalaryMax());
view.put("status", job.getStatus());
view.put("publishedAt", job.getPublishedAt());
view.put("createdAt", job.getCreatedAt());
view.put("updatedAt", job.getUpdatedAt());
return view;
}
/**
* 公开视图(精简字段)
*/
public Map<String, Object> toPublicView(Job job) {
Map<String, Object> view = new HashMap<>();
view.put("id", job.getId());
view.put("companyId", job.getCompanyId());
view.put("title", job.getTitle());
view.put("description", job.getDescription());
view.put("requirements", job.getRequirements());
view.put("city", job.getCity());
view.put("employmentType", job.getEmploymentType());
view.put("salaryMin", job.getSalaryMin());
view.put("salaryMax", job.getSalaryMax());
view.put("status", job.getStatus());
view.put("publishedAt", job.getPublishedAt());
view.put("createdAt", job.getCreatedAt());
return view;
}
// ── 高级搜索 ────────────────────────────────────────────────
private static final Set<String> ALLOWED_SORTS = Set.of("latest", "salary", "relevance");
private static final int MAX_PAGE_SIZE = 50;
/**
* 高级搜索:仅返回 PUBLISHED 岗位,支持多条件筛选、排序、分页。
* 企业信息通过批量查询组装,避免 N+1。
* sort=salary 时通过 CriteriaQuery 显式构建 CASE 排序,确保 salaryMax=NULL 排在最后。
*/
public Page<JobSearchItem> searchJobs(String keyword, String city, String employmentType,
Integer salaryMin, Integer salaryMax, Long companyId,
String sort, int page, int size) {
// 参数校验
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (sort != null && !ALLOWED_SORTS.contains(sort)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (page < 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
var spec = JobSearchSpecification.searchJobs(keyword, city, employmentType,
salaryMin, salaryMax, companyId);
Page<Job> jobPage;
if ("salary".equals(sort)) {
// salary 排序:显式 CASE WHEN 确保 NULL 排在最后
jobPage = findWithSalarySort(spec, page, size);
} else {
// latest 和 relevance 都按 latest 排序
Sort sortObj = Sort.by(
new Sort.Order(Sort.Direction.DESC, "createdAt"),
new Sort.Order(Sort.Direction.DESC, "id"));
Pageable pageable = PageRequest.of(page, size, sortObj);
jobPage = jobRepository.findAll(spec, pageable);
}
// 批量查询企业信息
Set<Long> companyIds = jobPage.getContent().stream()
.map(Job::getCompanyId)
.collect(Collectors.toSet());
Map<Long, Company> companyMap = companyIds.isEmpty()
? Map.of()
: companyRepository.findAllById(companyIds).stream()
.collect(Collectors.toMap(Company::getId, c -> c));
return jobPage.map(job -> toSearchItem(job, companyMap.get(job.getCompanyId())));
}
/**
* 使用 CriteriaQuery + CASE WHEN 实现 salaryMax DESC NULLS LAST。
*/
Page<Job> findWithSalarySort(Specification<Job> spec, int page, int size) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
// 查询
CriteriaQuery<Job> cq = cb.createQuery(Job.class);
Root<Job> root = cq.from(Job.class);
Predicate predicate = spec.toPredicate(root, cq, cb);
if (predicate != null) {
cq.where(predicate);
}
// CASE WHEN salaryMax IS NULL THEN 1 ELSE 0 END → NULL 排最后
cq.orderBy(
cb.asc(cb.selectCase()
.when(cb.isNull(root.get("salaryMax")), 1)
.otherwise(0)),
cb.desc(root.get("salaryMax")),
cb.desc(root.get("id"))
);
TypedQuery<Job> query = entityManager.createQuery(cq);
query.setFirstResult(page * size);
query.setMaxResults(size);
List<Job> jobs = query.getResultList();
// 计数
CriteriaQuery<Long> countCq = cb.createQuery(Long.class);
Root<Job> countRoot = countCq.from(Job.class);
Predicate countPredicate = spec.toPredicate(countRoot, countCq, cb);
if (countPredicate != null) {
countCq.where(countPredicate);
}
countCq.select(cb.count(countRoot));
Long total = entityManager.createQuery(countCq).getSingleResult();
return new PageImpl<>(jobs, PageRequest.of(page, size), total);
}
private JobSearchItem toSearchItem(Job job, Company company) {
JobSearchItem.CompanyInfo info = null;
if (company != null) {
info = JobSearchItem.CompanyInfo.builder()
.id(company.getId())
.name(company.getName())
.logoUrl(company.getLogoUrl())
.verified(company.getVerified())
.build();
}
return JobSearchItem.builder()
.id(job.getId())
.title(job.getTitle())
.description(job.getDescription())
.requirements(job.getRequirements())
.city(job.getCity())
.employmentType(job.getEmploymentType())
.salaryMin(job.getSalaryMin())
.salaryMax(job.getSalaryMax())
.status(job.getStatus())
.publishedAt(job.getPublishedAt())
.createdAt(job.getCreatedAt())
.company(info)
.build();
}
// ── 筛选选项 ────────────────────────────────────────────────
/**
* 获取筛选选项:仅基于 PUBLISHED 岗位,使用数据库聚合查询,不加载全部岗位到内存。
*/
public Map<String, Object> getFilterOptions() {
List<String> cities = jobRepository.findDistinctCitiesPublished();
List<String> employmentTypes = jobRepository.findDistinctEmploymentTypesPublished();
Integer minSalary = jobRepository.findMinSalaryPublished();
Integer maxSalary = jobRepository.findMaxSalaryPublished();
Map<String, Object> salaryRange = new HashMap<>();
salaryRange.put("min", minSalary);
salaryRange.put("max", maxSalary);
Map<String, Object> result = new HashMap<>();
result.put("cities", cities != null ? cities : Collections.emptyList());
result.put("employmentTypes", employmentTypes != null ? employmentTypes : Collections.emptyList());
result.put("salaryRange", salaryRange);
return result;
}
}