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.
 
 
 

525 lines
23 KiB

package com.mydreamjob.job;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
private final ObjectMapper objectMapper;
@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())
.experience(normalizeText(req.getExperience()))
.education(normalizeText(req.getEducation()))
.benefitsJson(benefitsToJson(req.getBenefits()))
.salaryMonths(validateSalaryMonths(req.getSalaryMonths()))
.workLocation(normalizeText(req.getWorkLocation()))
.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());
// 新增字段:null 保持原值;空串/纯空白清空为 NULL;非空去两端空白后保存。
if (req.getExperience() != null) job.setExperience(normalizeText(req.getExperience()));
if (req.getEducation() != null) job.setEducation(normalizeText(req.getEducation()));
if (req.getWorkLocation() != null) job.setWorkLocation(normalizeText(req.getWorkLocation()));
// benefits:null 保持原值;[] 清空;非空校验后序列化保存。
if (req.getBenefits() != null) job.setBenefitsJson(benefitsToJson(req.getBenefits()));
// salaryMonths:null 保持原值(本批不提供显式清空);非 null 必须为 1..24。
if (req.getSalaryMonths() != null) job.setSalaryMonths(validateSalaryMonths(req.getSalaryMonths()));
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);
// 批量查询企业信息后组装,避免逐条查询造成 N+1;空页时不发起企业查询。
List<Job> jobs = result.getContent();
Set<Long> companyIds = jobs.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));
List<Map<String, Object>> items = jobs.stream()
.map(job -> toPublicView(job, companyMap.get(job.getCompanyId())))
.toList();
return new PageImpl<>(items, result.getPageable(), result.getTotalElements());
}
/**
* 公开岗位详情:返回 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);
}
Company company = companyRepository.findById(job.getCompanyId()).orElse(null);
return toPublicView(job, company);
}
// ── 辅助方法 ────────────────────────────────────────────────
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("experience", job.getExperience());
view.put("education", job.getEducation());
view.put("benefits", benefitsFromJson(job.getBenefitsJson()));
view.put("salaryMonths", job.getSalaryMonths());
view.put("workLocation", job.getWorkLocation());
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("experience", job.getExperience());
view.put("education", job.getEducation());
view.put("benefits", benefitsFromJson(job.getBenefitsJson()));
view.put("salaryMonths", job.getSalaryMonths());
view.put("workLocation", job.getWorkLocation());
view.put("status", job.getStatus());
view.put("publishedAt", job.getPublishedAt());
view.put("createdAt", job.getCreatedAt());
return view;
}
/**
* 公开视图(含企业摘要)。企业不存在时 company 为 null,不编造数据也不抛异常。
*/
public Map<String, Object> toPublicView(Job job, Company company) {
Map<String, Object> view = toPublicView(job);
view.put("company", toCompanyInfo(company));
return view;
}
// ── 字段归一化与校验 ────────────────────────────────────────
/** 空串/纯空白清空为 null;非空去两端空白后返回;null 原样返回。 */
private static String normalizeText(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static final int MAX_BENEFITS = 20;
private static final int MAX_BENEFIT_LENGTH = 40;
/**
* benefits → benefits_json。null/空数组返回 null(清空);非空逐项 trim、拒绝空白项、
* 长度 ≤40、去重保序、最多 20 项,违者 BAD_REQUEST;序列化为 JSON 数组字符串。
*/
private String benefitsToJson(List<String> benefits) {
if (benefits == null || benefits.isEmpty()) return null;
Set<String> unique = new LinkedHashSet<>();
for (String raw : benefits) {
if (raw == null || raw.trim().isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String value = raw.trim();
if (value.length() > MAX_BENEFIT_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
unique.add(value);
}
List<String> normalized = new ArrayList<>(unique);
if (normalized.size() > MAX_BENEFITS) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
try {
return objectMapper.writeValueAsString(normalized);
} catch (JsonProcessingException exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
/** benefits_json → 列表。null 返回空数组;反序列化失败抛 BAD_REQUEST 而非 500。 */
private List<String> benefitsFromJson(String benefitsJson) {
if (benefitsJson == null || benefitsJson.isBlank()) return List.of();
try {
List<String> parsed = objectMapper.readValue(benefitsJson,
new TypeReference<List<String>>() {});
return parsed == null ? List.of() : parsed;
} catch (JsonProcessingException exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
/** salaryMonths 非 null 时必须为 1..24 的整数,0 视为非法。 */
private static Integer validateSalaryMonths(Integer salaryMonths) {
if (salaryMonths == null) return null;
if (salaryMonths < 1 || salaryMonths > 24) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return salaryMonths;
}
// ── 高级搜索 ────────────────────────────────────────────────
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) {
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())
.experience(job.getExperience())
.education(job.getEducation())
.benefits(benefitsFromJson(job.getBenefitsJson()))
.salaryMonths(job.getSalaryMonths())
.workLocation(job.getWorkLocation())
.status(job.getStatus())
.publishedAt(job.getPublishedAt())
.createdAt(job.getCreatedAt())
.company(toCompanyInfo(company))
.build();
}
/** 公开企业摘要(含 id/name/logoUrl/description/industry/verified/companySize);企业不存在返回 null。 */
private JobSearchItem.CompanyInfo toCompanyInfo(Company company) {
if (company == null) return null;
return JobSearchItem.CompanyInfo.builder()
.id(company.getId())
.name(company.getName())
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.verified(company.getVerified())
.companySize(company.getCompanySize())
.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;
}
}