package com.example.hrlab.recruitment; import com.example.hrlab.audit.AuditService; import com.example.hrlab.common.ApiResponse; import com.example.hrlab.common.BusinessException; import com.example.hrlab.common.ErrorCodes; import com.example.hrlab.common.JsonUtils; import com.example.hrlab.common.PageResponse; import com.example.hrlab.common.Rows; import jakarta.validation.Valid; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/v1") public class RecruitmentController { private final JdbcTemplate jdbcTemplate; private final AuditService auditService; public RecruitmentController(JdbcTemplate jdbcTemplate, AuditService auditService) { this.jdbcTemplate = jdbcTemplate; this.auditService = auditService; } @GetMapping("/job-requisitions") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')") public ApiResponse>> jobRequisitions(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.ok(page("job_requisitions", "id DESC", page, size)); } @PostMapping("/job-requisitions") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> createJobRequisition(@Valid @RequestBody JobRequisitionRequest request) { Long id = jdbcTemplate.queryForObject(""" INSERT INTO job_requisitions(req_no, department_id, position_id, planned_headcount, status, opened_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id """, Long.class, request.reqNo(), request.departmentId(), request.positionId(), request.plannedHeadcount(), defaultString(request.status(), "OPEN"), request.openedAt(), request.closedAt()); auditService.record("JOB_REQUISITION_CREATE", "job_requisitions", id, null, request); return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在")); } @PutMapping("/job-requisitions/{id}") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> updateJobRequisition(@PathVariable Long id, @Valid @RequestBody JobRequisitionRequest request) { Map before = find("job_requisitions", id, "招聘需求不存在"); jdbcTemplate.update(""" UPDATE job_requisitions SET req_no = ?, department_id = ?, position_id = ?, planned_headcount = ?, status = ?, opened_at = ?, closed_at = ?, version = version + 1, updated_at = now() WHERE id = ? """, request.reqNo(), request.departmentId(), request.positionId(), request.plannedHeadcount(), defaultString(request.status(), "OPEN"), request.openedAt(), request.closedAt(), id); auditService.record("JOB_REQUISITION_UPDATE", "job_requisitions", id, before, request); return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在")); } @DeleteMapping("/job-requisitions/{id}") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> closeJobRequisition(@PathVariable Long id) { Map before = find("job_requisitions", id, "招聘需求不存在"); jdbcTemplate.update("UPDATE job_requisitions SET status = 'CLOSED', closed_at = now(), version = version + 1, updated_at = now() WHERE id = ?", id); auditService.record("JOB_REQUISITION_CLOSE", "job_requisitions", id, before, Map.of("status", "CLOSED")); return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在")); } @GetMapping("/candidates") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')") public ApiResponse>> candidates(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.ok(page("candidates", "id DESC", page, size)); } @PostMapping("/candidates") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> createCandidate(@Valid @RequestBody CandidateRequest request) { Map requisition = find("job_requisitions", request.requisitionId(), "招聘需求不存在"); if (!"OPEN".equals(requisition.get("status"))) { throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "已关闭岗位不能新增候选人"); } long duplicates = jdbcTemplate.queryForObject(""" SELECT count(*) FROM candidates WHERE requisition_id = ? AND (email = ? OR phone = ?) """, Long.class, request.requisitionId(), request.email(), request.phone()); if (duplicates > 0) { throw new BusinessException(ErrorCodes.CONFLICT, "候选人联系方式已存在", HttpStatus.CONFLICT); } Long id = jdbcTemplate.queryForObject(""" INSERT INTO candidates(requisition_id, candidate_code, display_name, email, phone, source, status, ext_jsonb) VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb) RETURNING id """, Long.class, request.requisitionId(), request.candidateCode(), request.displayName(), request.email(), request.phone(), defaultString(request.source(), "LOCAL"), defaultString(request.status(), "NEW"), JsonUtils.toJson(request.ext() == null ? Map.of() : request.ext())); auditService.record("CANDIDATE_CREATE", "candidates", id, null, request); return ApiResponse.ok(find("candidates", id, "候选人不存在")); } @PutMapping("/candidates/{id}") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> updateCandidate(@PathVariable Long id, @Valid @RequestBody CandidateRequest request) { Map before = find("candidates", id, "候选人不存在"); jdbcTemplate.update(""" UPDATE candidates SET requisition_id = ?, candidate_code = ?, display_name = ?, email = ?, phone = ?, source = ?, status = ?, ext_jsonb = ?::jsonb, version = version + 1, updated_at = now() WHERE id = ? """, request.requisitionId(), request.candidateCode(), request.displayName(), request.email(), request.phone(), defaultString(request.source(), "LOCAL"), defaultString(request.status(), "NEW"), JsonUtils.toJson(request.ext() == null ? Map.of() : request.ext()), id); auditService.record("CANDIDATE_UPDATE", "candidates", id, before, request); return ApiResponse.ok(find("candidates", id, "候选人不存在")); } @DeleteMapping("/candidates/{id}") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> archiveCandidate(@PathVariable Long id) { Map before = find("candidates", id, "候选人不存在"); jdbcTemplate.update("UPDATE candidates SET status = 'ARCHIVED', version = version + 1, updated_at = now() WHERE id = ?", id); auditService.record("CANDIDATE_ARCHIVE", "candidates", id, before, Map.of("status", "ARCHIVED")); return ApiResponse.ok(find("candidates", id, "候选人不存在")); } @PostMapping("/candidates/{id}/schedule-interview") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> scheduleInterview(@PathVariable Long id, @Valid @RequestBody InterviewRequest request) { Map candidate = find("candidates", id, "候选人不存在"); requireCandidateNotTerminal(candidate); if (!request.startAt().isBefore(request.endAt())) { throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "面试开始时间必须早于结束时间"); } long conflicts = jdbcTemplate.queryForObject(""" SELECT count(*) FROM interviews WHERE candidate_id = ? AND status <> 'CANCELLED' AND start_at < ? AND end_at > ? """, Long.class, id, request.endAt(), request.startAt()); if (conflicts > 0) { throw new BusinessException(ErrorCodes.CONFLICT, "面试时间冲突", HttpStatus.CONFLICT); } Long interviewId = jdbcTemplate.queryForObject(""" INSERT INTO interviews(candidate_id, interviewer_employee_id, start_at, end_at, status, feedback, score) VALUES (?, ?, ?, ?, 'SCHEDULED', ?, ?) RETURNING id """, Long.class, id, request.interviewerEmployeeId(), request.startAt(), request.endAt(), request.feedback(), request.score()); jdbcTemplate.update("UPDATE candidates SET status = 'INTERVIEWING', version = version + 1, updated_at = now() WHERE id = ?", id); auditService.record("INTERVIEW_SCHEDULE", "interviews", interviewId, null, request); return ApiResponse.ok(find("interviews", interviewId, "面试不存在")); } @GetMapping("/candidates/{id}/interviews") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')") public ApiResponse candidateInterviews(@PathVariable Long id) { find("candidates", id, "候选人不存在"); return ApiResponse.ok(jdbcTemplate.queryForList(""" SELECT * FROM interviews WHERE candidate_id = ? ORDER BY start_at DESC, id DESC """, id)); } @PostMapping("/interviews/{id}/result") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> interviewResult(@PathVariable Long id, @Valid @RequestBody InterviewResultRequest request) { Map before = find("interviews", id, "面试不存在"); String normalized = request.result().trim().toUpperCase(); String interviewStatus = switch (normalized) { case "PASSED", "INTERVIEW_PASSED" -> "PASSED"; case "FAILED", "INTERVIEW_FAILED" -> "FAILED"; case "NO_SHOW", "INTERVIEW_NO_SHOW" -> "NO_SHOW"; default -> throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "面试结果不合法"); }; String candidateStatus = switch (interviewStatus) { case "PASSED" -> "INTERVIEW_PASSED"; case "FAILED" -> "INTERVIEW_FAILED"; default -> "INTERVIEW_NO_SHOW"; }; Long candidateId = ((Number) before.get("candidate_id")).longValue(); jdbcTemplate.update(""" UPDATE interviews SET status = ?, feedback = ?, score = ?, updated_at = now() WHERE id = ? """, interviewStatus, request.feedback(), request.score(), id); jdbcTemplate.update("UPDATE candidates SET status = ?, version = version + 1, updated_at = now() WHERE id = ?", candidateStatus, candidateId); auditService.record("INTERVIEW_RESULT", "interviews", id, before, Map.of("candidateId", candidateId, "status", candidateStatus)); return ApiResponse.ok(find("interviews", id, "面试不存在")); } @PostMapping("/candidates/{id}/issue-offer") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> issueOffer(@PathVariable Long id) { Map before = find("candidates", id, "候选人不存在"); if (!"INTERVIEW_PASSED".equals(before.get("status"))) { throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有面试通过的候选人才能发放 Offer"); } jdbcTemplate.update("UPDATE candidates SET status = 'OFFERED', version = version + 1, updated_at = now() WHERE id = ?", id); jdbcTemplate.update(""" INSERT INTO local_notifications(channel, recipient, title, content, status) VALUES ('LOCAL', ?, '录用通知', '候选人录用流程已生成', 'RECORDED') """, before.get("display_name")); auditService.record("OFFER_ISSUE", "candidates", id, before, Map.of("status", "OFFERED")); return ApiResponse.ok(find("candidates", id, "候选人不存在")); } @PostMapping("/candidates/{id}/offer-decision") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> offerDecision(@PathVariable Long id, @Valid @RequestBody OfferDecisionRequest request) { Map before = find("candidates", id, "候选人不存在"); if (!"OFFERED".equals(before.get("status"))) { throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有已发放 Offer 的候选人才能反馈 Offer 决策"); } String decision = request.decision().trim().toUpperCase(); String status = switch (decision) { case "ACCEPT", "ACCEPTED", "OFFER_ACCEPTED" -> "OFFER_ACCEPTED"; case "REJECT", "REJECTED", "OFFER_REJECTED" -> "OFFER_REJECTED"; default -> throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "Offer 决策不合法"); }; jdbcTemplate.update("UPDATE candidates SET status = ?, version = version + 1, updated_at = now() WHERE id = ?", status, id); auditService.record("OFFER_DECISION", "candidates", id, before, Map.of("status", status, "reason", request.reason() == null ? "" : request.reason())); return ApiResponse.ok(find("candidates", id, "候选人不存在")); } @PostMapping("/candidates/{id}/convert-to-onboarding") @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')") public ApiResponse> convertToOnboarding(@PathVariable Long id) { Map candidate = find("candidates", id, "候选人不存在"); if (candidate.get("converted_employee_id") != null) { throw new BusinessException(ErrorCodes.CONFLICT, "候选人已转入职", HttpStatus.CONFLICT); } if (!"OFFER_ACCEPTED".equals(candidate.get("status"))) { throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有接受 Offer 的候选人才能转入职"); } Map requisition = find("job_requisitions", ((Number) candidate.get("requisition_id")).longValue(), "招聘需求不存在"); Long employeeId = jdbcTemplate.queryForObject(""" INSERT INTO employees(employee_no, department_id, position_id, display_name, employment_status, hire_date, emergency_contact_jsonb, tags, ext_jsonb) VALUES (?, ?, ?, ?, 'PENDING_ONBOARD', CURRENT_DATE, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb) RETURNING id """, Long.class, "E-" + candidate.get("candidate_code"), requisition.get("department_id"), requisition.get("position_id"), candidate.get("display_name")); Long caseId = jdbcTemplate.queryForObject(""" INSERT INTO onboarding_cases(candidate_id, target_employee_id, status, checklist_jsonb, expected_join_date) VALUES (?, ?, 'OPEN', ?::jsonb, CURRENT_DATE) RETURNING id """, Long.class, id, employeeId, JsonUtils.toJson(List.of( Map.of("name", "资料确认", "required", true, "done", false), Map.of("name", "账号准备", "required", true, "done", false), Map.of("name", "岗位确认", "required", true, "done", false)))); jdbcTemplate.update("UPDATE candidates SET converted_employee_id = ?, status = 'ONBOARDING', version = version + 1, updated_at = now() WHERE id = ?", employeeId, id); auditService.record("CANDIDATE_CONVERT_ONBOARDING", "onboarding_cases", caseId, candidate, Map.of("employeeId", employeeId)); return ApiResponse.ok(find("onboarding_cases", caseId, "入职单不存在")); } private PageResponse> page(String table, String order, int page, int size) { int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 100); long total = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class); var rows = jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY " + order + " LIMIT ? OFFSET ?", (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize); return new PageResponse<>(rows, safePage, safeSize, total); } private Map find(String table, Long id, String message) { return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> { if (!rs.next()) { throw new BusinessException(ErrorCodes.NOT_FOUND, message, HttpStatus.NOT_FOUND); } return Rows.map(rs); }, id); } private String defaultString(String value, String defaultValue) { return value == null || value.isBlank() ? defaultValue : value; } private void requireCandidateNotTerminal(Map candidate) { String status = String.valueOf(candidate.get("status")); if (List.of("INTERVIEW_FAILED", "INTERVIEW_NO_SHOW", "OFFER_REJECTED", "ONBOARDING", "NO_SHOW", "ARCHIVED").contains(status)) { throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "候选人当前状态不能继续安排面试"); } } }