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 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,支持筛选和分页 * *

DEF-01:关键词在此处规范化,使其与 {@code GET /jobs/search} * ({@code JobSearchSpecification} 的 {@code keyword.trim()} + {@code isBlank()} 判空) * 的输入语义一致 —— 只去两端空白,空串与纯空白等同「未传关键词」, * 不折叠中间空格、不改大小写,也不改变任何过滤/排序/分页语义。

*/ public Page> 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 result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, salaryMin, salaryMax, pageable); return result.map(this::toPublicView); } /** * 公开岗位详情:返回 PUBLISHED 岗位;CLOSED 岗位也允许查看但不可投递(本阶段不处理投递) */ public Map 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 toView(Job job) { Map 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 toPublicView(Job job) { Map 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 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 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 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 companyIds = jobPage.getContent().stream() .map(Job::getCompanyId) .collect(Collectors.toSet()); Map 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 findWithSalarySort(Specification spec, int page, int size) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); // 查询 CriteriaQuery cq = cb.createQuery(Job.class); Root 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 query = entityManager.createQuery(cq); query.setFirstResult(page * size); query.setMaxResults(size); List jobs = query.getResultList(); // 计数 CriteriaQuery countCq = cb.createQuery(Long.class); Root 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 getFilterOptions() { List cities = jobRepository.findDistinctCitiesPublished(); List employmentTypes = jobRepository.findDistinctEmploymentTypesPublished(); Integer minSalary = jobRepository.findMinSalaryPublished(); Integer maxSalary = jobRepository.findMaxSalaryPublished(); Map salaryRange = new HashMap<>(); salaryRange.put("min", minSalary); salaryRange.put("max", maxSalary); Map 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; } }