package com.mydreamjob.application; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.application.dto.CandidateDetailView; import com.mydreamjob.application.dto.CandidateItemView; import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.notification.NotificationService; import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.profile.StudentProfile; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @Slf4j @Service @RequiredArgsConstructor public class ApplicationService { private final ApplicationRepository applicationRepository; private final JobService jobService; private final UserRepository userRepository; private final AuditService auditService; private final ApplicationStatusHistoryRepository historyRepository; private final NotificationService notificationService; /** R01-D4-1:投递新增/撤回/状态变更后登记评分快照刷新。 */ private final CandidateScoringRefreshRequestService scoringRefreshRequestService; @PersistenceContext private EntityManager em; private static final ObjectMapper objectMapper = new ObjectMapper(); private static final Set ALLOWED_STATUSES = Set.of( "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); private static final Set ALLOWED_SORTS = Set.of("createdAt", "updatedAt"); private static final Set ALLOWED_ORDERS = Set.of("asc", "desc"); // ── 学生端 ──────────────────────────────────────────────── @Transactional public Application apply(Long studentUserId, ApplicationCreateRequest req) { return apply(studentUserId, req, null); } @Transactional public Application apply(Long studentUserId, ApplicationCreateRequest req, com.mydreamjob.audit.AuditContext ctx) { assertRole(studentUserId, "STUDENT"); Job job = jobService.getJob(req.getJobId()); if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.JOB_NOT_FOUND); if (applicationRepository.existsByJobIdAndStudentUserId(req.getJobId(), studentUserId)) throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE); Application app = Application.builder() .jobId(req.getJobId()).studentUserId(studentUserId) .status(ApplicationStatus.SUBMITTED.name()).build(); app = applicationRepository.save(app); recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied"); auditService.log(studentUserId, "APPLY", "APPLICATION", app.getId(), "Student applied to job " + req.getJobId(), ctx); // 站内通知:投递成功 notificationService.publishEvent(studentUserId, "APPLICATION_APPLIED", "投递成功", "你已成功投递岗位 #" + req.getJobId(), "APPLICATION", app.getId(), "apply-" + studentUserId + "-" + req.getJobId()); // R01-D4-1:投递数量影响 STABILITY/ACTIVITY 聚合 → 登记当前周期刷新。 scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); return app; } /** 学生撤回投递:仅 SUBMITTED/VIEWED 可撤回 */ @Transactional public Application withdraw(Long studentUserId, Long applicationId) { return withdraw(studentUserId, applicationId, null); } @Transactional public Application withdraw(Long studentUserId, Long applicationId, com.mydreamjob.audit.AuditContext ctx) { assertRole(studentUserId, "STUDENT"); Application app = applicationRepository.findById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); if (!app.getStudentUserId().equals(studentUserId)) throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); String currentStatus = app.getStatus(); // 终态或已被撤回不可重复撤回 if ("WITHDRAWN".equals(currentStatus) || "REJECTED".equals(currentStatus) || "HIRED".equals(currentStatus)) throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); if (!"SUBMITTED".equals(currentStatus) && !"VIEWED".equals(currentStatus)) throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); app.setStatus("WITHDRAWN"); app = applicationRepository.save(app); recordHistory(app.getId(), studentUserId, currentStatus, "WITHDRAWN", "Student withdrew"); auditService.log(studentUserId, "WITHDRAW", "APPLICATION", app.getId(), "Student withdrew application", ctx); // 站内通知:投递撤回 notificationService.publishEvent(studentUserId, "APPLICATION_WITHDRAWN", "投递已撤回", "你的投递 #" + app.getId() + " 已撤回", "APPLICATION", app.getId(), "withdraw-" + studentUserId + "-" + app.getId()); // R01-D4-1:撤回参与 WITHDRAWN/总投递比率计算 → 登记当前周期刷新。 scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); return app; } /** 状态时间线 */ public List> getTimeline(Long applicationId, Long userId) { Application app = applicationRepository.findById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); // 仅学生本人或岗位所属企业可查看 Job job = jobService.getJob(app.getJobId()); boolean isStudent = app.getStudentUserId().equals(userId); boolean isCompany = job.getCompanyId().equals(userId); if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN); return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId) .stream().map(h -> { Map v = new HashMap<>(); v.put("id", h.getId()); v.put("fromStatus", h.getFromStatus()); v.put("toStatus", h.getToStatus()); v.put("operatorUserId", h.getOperatorUserId()); v.put("reason", h.getReason()); v.put("createdAt", h.getCreatedAt()); return v; }).collect(Collectors.toList()); } public Page listStudentApplications(Long studentUserId, String status, int page, int size) { if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST); PageRequest pr = PageRequest.of(page, size); if (status != null && !status.isBlank()) return applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr); return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr); } public Application getApplication(Long applicationId, Long studentUserId) { Application app = applicationRepository.findById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); if (!app.getStudentUserId().equals(studentUserId)) throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); return app; } // ── 企业端 ──────────────────────────────────────────────── public List listCompanyApplications(Long companyId, Long jobId) { Job job = jobService.getJob(jobId); if (!job.getCompanyId().equals(companyId)) throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); return applicationRepository.findByJobIdOrderByCreatedAtDesc(jobId); } public Page listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) { if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST); Job job = jobService.getJob(jobId); if (!job.getCompanyId().equals(companyId)) throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); PageRequest pr = PageRequest.of(page, size); if (status != null && !status.isBlank()) return applicationRepository.findByJobIdAndStatusOrderByCreatedAtDesc(jobId, status, pr); return applicationRepository.findByJobIdOrderByCreatedAtDesc(jobId, pr); } @Transactional public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req) { return updateStatus(companyId, applicationId, req, null); } @Transactional public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req, com.mydreamjob.audit.AuditContext ctx) { Application app = applicationRepository.findById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); Job job = jobService.getJob(app.getJobId()); if (!job.getCompanyId().equals(companyId)) throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); ApplicationStatus current = ApplicationStatus.valueOf(app.getStatus()); ApplicationStatus target = ApplicationStatus.valueOf(req.getStatus()); if (!current.canTransitionTo(target)) throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); String from = app.getStatus(); app.setStatus(target.name()); app = applicationRepository.save(app); recordHistory(app.getId(), companyId, from, target.name(), "Company updated status to " + target.name()); auditService.log(companyId, "UPDATE_STATUS", "APPLICATION", app.getId(), "Status changed: " + from + " → " + target.name(), ctx); // 站内通知:投递状态变更 → 学生 notificationService.publishEvent(app.getStudentUserId(), "APPLICATION_STATUS", "投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(), "APPLICATION", app.getId(), "status-" + companyId + "-" + app.getId() + "-" + target.name()); // R01-D4-1:仅状态实际变化(含 WITHDRAWN/HIRED 等聚合口径)时登记刷新。 scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now()); return app; } // ── 视图 ────────────────────────────────────────────────── public Map toView(Application app) { Map view = new HashMap<>(); view.put("id", app.getId()); view.put("jobId", app.getJobId()); view.put("studentUserId", app.getStudentUserId()); view.put("status", app.getStatus()); view.put("createdAt", app.getCreatedAt()); view.put("updatedAt", app.getUpdatedAt()); return view; } // ── 私有 ────────────────────────────────────────────────── private void assertRole(Long userId, String expectedRoleName) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); if (user.getRoles().stream().noneMatch(r -> r.name().equals(expectedRoleName))) throw new BusinessException(ErrorCode.FORBIDDEN); } private void recordHistory(Long applicationId, Long operatorUserId, String fromStatus, String toStatus, String reason) { historyRepository.save(ApplicationStatusHistory.builder() .applicationId(applicationId).operatorUserId(operatorUserId) .fromStatus(fromStatus).toStatus(toStatus).reason(reason) .build()); } // ── 企业候选人看板 ───────────────────────────────────────── /** * 企业候选人列表:跨岗位查询,JOIN student_profiles + users * 排序字段通过白名单映射,禁止拼接原始请求参数 */ @Transactional(readOnly = true) public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status, String keyword, String sort, String order, int page, int size) { // 参数校验 if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST); if (status != null && !ALLOWED_STATUSES.contains(status)) throw new BusinessException(ErrorCode.BAD_REQUEST); if (sort != null && !ALLOWED_SORTS.contains(sort)) throw new BusinessException(ErrorCode.BAD_REQUEST); if (order != null && !ALLOWED_ORDERS.contains(order)) throw new BusinessException(ErrorCode.BAD_REQUEST); String escapedKeyword = escapeLike(keyword); // 白名单映射排序字段 String sortField = (sort == null) ? "a.createdAt" : "a." + sort; String sortOrder = (order == null) ? "DESC" : order.toUpperCase(); // 构建 JPQL String whereClause = "WHERE j.companyId = :companyId"; if (jobId != null) whereClause += " AND a.jobId = :jobId"; if (status != null) whereClause += " AND a.status = :status"; if (escapedKeyword != null) { whereClause += " AND (sp.realName LIKE CONCAT('%', :keyword, '%')" + " OR sp.school LIKE CONCAT('%', :keyword, '%')" + " OR sp.education LIKE CONCAT('%', :keyword, '%'))"; } String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " + "sp.realName, sp.school, sp.education, sp.graduationYear, " + "u.avatarUrl, a.createdAt, a.updatedAt"; String fromClause = "FROM Application a " + "JOIN Job j ON a.jobId = j.id " + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + "LEFT JOIN User u ON a.studentUserId = u.id"; String countJpql = "SELECT COUNT(a) " + fromClause + " " + whereClause; String dataJpql = selectClause + " " + fromClause + " " + whereClause + " ORDER BY " + sortField + " " + sortOrder + ", a.id " + sortOrder; // Count TypedQuery countQuery = em.createQuery(countJpql, Long.class); countQuery.setParameter("companyId", companyId); if (jobId != null) countQuery.setParameter("jobId", jobId); if (status != null) countQuery.setParameter("status", status); if (escapedKeyword != null) countQuery.setParameter("keyword", escapedKeyword); long total = countQuery.getSingleResult(); // Data TypedQuery dataQuery = em.createQuery(dataJpql, Object[].class); dataQuery.setParameter("companyId", companyId); if (jobId != null) dataQuery.setParameter("jobId", jobId); if (status != null) dataQuery.setParameter("status", status); if (escapedKeyword != null) dataQuery.setParameter("keyword", escapedKeyword); dataQuery.setFirstResult(page * size); dataQuery.setMaxResults(size); List rows = dataQuery.getResultList(); List items = rows.stream().map(row -> CandidateItemView.builder() .applicationId((Long) row[0]) .jobId((Long) row[1]) .jobTitle((String) row[2]) .status((String) row[3]) .candidateName((String) row[4]) .candidateSchool((String) row[5]) .candidateEducation((String) row[6]) .candidateGraduationYear(row[7] != null ? ((Number) row[7]).shortValue() : null) .candidateAvatarUrl((String) row[8]) .createdAt((LocalDateTime) row[9]) .updatedAt((LocalDateTime) row[10]) .build()).collect(Collectors.toList()); return CandidateListResponse.builder() .items(items) .total(total) .page(page) .size(size) .totalPages((int) Math.ceil((double) total / size)) .build(); } /** * 企业候选人详情:单次 JOIN 查询,不产生 N+1 */ @Transactional(readOnly = true) public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) { // 单次 JOIN 查询:applications + jobs + student_profiles + users // 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询 TypedQuery query = em.createQuery( "SELECT a, j, sp, u FROM Application a " + "JOIN Job j ON a.jobId = j.id " + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + "LEFT JOIN User u ON a.studentUserId = u.id " + "WHERE a.id = :applicationId", Object[].class); query.setParameter("applicationId", applicationId); List results = query.getResultList(); if (results.isEmpty()) { // 投递不存在 throw new BusinessException(ErrorCode.APPLICATION_NOT_FOUND); } Object[] row = results.get(0); Application app = (Application) row[0]; com.mydreamjob.job.Job job = (com.mydreamjob.job.Job) row[1]; StudentProfile sp = (StudentProfile) row[2]; com.mydreamjob.auth.User u = (com.mydreamjob.auth.User) row[3]; // 数据隔离:投递存在但岗位不属于当前企业 → 越权 if (!job.getCompanyId().equals(companyId)) { throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); } List skills = Collections.emptyList(); if (sp != null && sp.getSkillsJson() != null) { try { skills = objectMapper.readValue(sp.getSkillsJson(), new TypeReference>() {}); } catch (Exception e) { skills = Collections.emptyList(); } } return CandidateDetailView.builder() .applicationId(app.getId()) .jobId(app.getJobId()) .jobTitle(job.getTitle()) .jobCity(job.getCity()) .jobEmploymentType(job.getEmploymentType()) .status(app.getStatus()) .coverLetter(app.getCoverLetter()) .createdAt(app.getCreatedAt()) .updatedAt(app.getUpdatedAt()) .candidateName(sp != null ? sp.getRealName() : null) .candidateAvatarUrl(u != null ? u.getAvatarUrl() : null) .candidateBio(sp != null ? sp.getBio() : null) .candidateLocation(sp != null ? sp.getLocation() : null) .candidateSchool(sp != null ? sp.getSchool() : null) .candidateEducation(sp != null ? sp.getEducation() : null) .candidateGraduationYear(sp != null ? sp.getGraduationYear() : null) .candidateSkills(skills) .build(); } /** * 转义 LIKE 特殊字符,防止用户输入 %、_ 等通配符 */ String escapeLike(String keyword) { if (keyword == null) return null; return keyword .replace("\\", "\\\\") .replace("%", "\\%") .replace("_", "\\_"); } }