| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- 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<PageResponse<Map<String, Object>>> 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<Map<String, Object>> 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<Map<String, Object>> updateJobRequisition(@PathVariable Long id,
- @Valid @RequestBody JobRequisitionRequest request) {
- Map<String, Object> 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<Map<String, Object>> closeJobRequisition(@PathVariable Long id) {
- Map<String, Object> 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<PageResponse<Map<String, Object>>> 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<Map<String, Object>> createCandidate(@Valid @RequestBody CandidateRequest request) {
- Map<String, Object> 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<Map<String, Object>> updateCandidate(@PathVariable Long id, @Valid @RequestBody CandidateRequest request) {
- Map<String, Object> 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<Map<String, Object>> archiveCandidate(@PathVariable Long id) {
- Map<String, Object> 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<Map<String, Object>> scheduleInterview(@PathVariable Long id, @Valid @RequestBody InterviewRequest request) {
- Map<String, Object> 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<Object> 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<Map<String, Object>> interviewResult(@PathVariable Long id,
- @Valid @RequestBody InterviewResultRequest request) {
- Map<String, Object> 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<Map<String, Object>> issueOffer(@PathVariable Long id) {
- Map<String, Object> 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<Map<String, Object>> offerDecision(@PathVariable Long id,
- @Valid @RequestBody OfferDecisionRequest request) {
- Map<String, Object> 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<Map<String, Object>> convertToOnboarding(@PathVariable Long id) {
- Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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, "候选人当前状态不能继续安排面试");
- }
- }
- }
|