forked from baskbuddy/zhangshun
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
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;
|
|
}
|
|
}
|
|
|