package com.mydreamjob.application; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.application.dto.CandidateDetailView; import com.mydreamjob.application.dto.CompanyResumeFile; import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.company.CompanyService; import com.mydreamjob.auth.UserRepository; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @Tag(name = "投递", description = "学生投递、撤回、查询;企业查看和审核投递;时间线") @RestController @RequiredArgsConstructor public class ApplicationController { /** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; /** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */ private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final ApplicationService applicationService; private final CompanyService companyService; private final UserRepository userRepository; private final CompanyResumeService companyResumeService; 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"); // ── 学生投递 ────────────────────────────────────────────── @Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 状态的岗位,不可重复投递") @PostMapping("/applications") public ResponseEntity>> apply( @Valid @RequestBody ApplicationCreateRequest req, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Application app = applicationService.apply(userId, req, com.mydreamjob.audit.AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request))); } // ── 学生撤回投递 ────────────────────────────────────────── @Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆") @PostMapping("/applications/{id}/withdraw") public ResponseEntity>> withdraw( @PathVariable Long id, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Application app = applicationService.withdraw(userId, id, com.mydreamjob.audit.AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request))); } // ── 投递时间线 ──────────────────────────────────────────── @Operation(summary = "投递状态时间线", description = "学生本人或岗位所属企业可查看状态变更历史") @GetMapping("/applications/{id}/timeline") public ResponseEntity>>> timeline( @PathVariable Long id, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); List> data = applicationService.getTimeline(id, userId); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 学生查看自己的投递 ──────────────────────────────────── @Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页") @GetMapping("/applications/me") public ResponseEntity>> listMyApplications( @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); if (size > 100) size = 100; Page result = applicationService.listStudentApplications(userId, status, page, size); Map data = new HashMap<>(); data.put("items", result.getContent().stream().map(applicationService::toView).collect(Collectors.toList())); data.put("total", result.getTotalElements()); data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "投递详情", description = "查看自己的投递记录详情") @GetMapping("/applications/{id}") public ResponseEntity>> getApplication( @PathVariable Long id, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Application app = applicationService.getApplication(id, userId); return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request))); } // ── 企业查看岗位收到的投递 ──────────────────────────────── @Operation(summary = "查看岗位投递列表(企业)", description = "企业查看自己发布的某个岗位收到的投递,支持 status 筛选和分页") @GetMapping("/company/applications") public ResponseEntity>> listCompanyApplications( @RequestParam Long jobId, @RequestParam(required = false) String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); var company = companyService.findCompanyByOwnerUserId(userId); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); if (size > 100) size = 100; Page result = applicationService.listCompanyApplications( company.getId(), jobId, status, page, size); Map data = new HashMap<>(); data.put("items", result.getContent().stream().map(applicationService::toView).collect(Collectors.toList())); data.put("total", result.getTotalElements()); data.put("page", result.getNumber()); data.put("size", result.getSize()); data.put("totalPages", result.getTotalPages()); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── 企业更新投递状态 ────────────────────────────────────── @Operation(summary = "更新投递状态(企业)", description = "SUBMITTED→VIEWED→INTERVIEW→REJECTED/HIRED") @PatchMapping("/applications/{id}/status") public ResponseEntity>> updateStatus( @PathVariable Long id, @Valid @RequestBody ApplicationStatusRequest req, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); var company = companyService.findCompanyByOwnerUserId(userId); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); Application app = applicationService.updateStatus(company.getId(), id, req, com.mydreamjob.audit.AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request))); } // ── 企业候选人看板 ──────────────────────────────────────── @Operation(summary = "企业候选人列表", description = "跨岗位查询本企业所有候选人投递,支持按岗位、状态、关键词筛选,支持分页和排序") @GetMapping("/company/candidates") public ResponseEntity> listCompanyCandidates( @RequestParam(required = false) Long jobId, @RequestParam(required = false) String status, @RequestParam(required = false) String keyword, @RequestParam(required = false) String sort, @RequestParam(required = false) String order, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); userRoleGuard.requireCompany(userId); // 参数校验 if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST); if (jobId != null && jobId <= 0) 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); if (keyword != null) { keyword = keyword.trim(); if (keyword.isEmpty()) keyword = null; else if (keyword.length() > 100) throw new BusinessException(ErrorCode.BAD_REQUEST); } var company = companyService.findCompanyByOwnerUserId(userId); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); CandidateListResponse data = applicationService.listCompanyCandidates( company.getId(), jobId, status, keyword, sort, order, page, size); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "企业候选人详情", description = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递") @GetMapping("/company/candidates/{applicationId}") public ResponseEntity> getCompanyCandidateDetail( @PathVariable Long applicationId, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); userRoleGuard.requireCompany(userId); var company = companyService.findCompanyByOwnerUserId(userId); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); CandidateDetailView data = applicationService.getCompanyCandidateDetail( company.getId(), applicationId); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "企业下载候选人简历", description = "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式") @GetMapping("/company/candidates/{applicationId}/resume") public ResponseEntity downloadCandidateResume( @PathVariable Long applicationId, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); userRoleGuard.requireCompany(userId); // 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。 CompanyResumeFile file = companyResumeService.load(userId, applicationId); ContentDisposition disposition = ContentDisposition.attachment() .filename(file.filename(), StandardCharsets.UTF_8) .build(); return ResponseEntity.ok() .contentType(MediaType.parseMediaType(file.contentType())) .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) // 私有简历不进任何中间缓存。 .header(HttpHeaders.CACHE_CONTROL, "no-store") .body(file.content()); } // ── 辅助 ────────────────────────────────────────────────── }