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.
 
 
 

241 lines
13 KiB

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<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt");
private static final Set<String> ALLOWED_ORDERS = Set.of("asc", "desc");
// ── 学生投递 ──────────────────────────────────────────────
@Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 状态的岗位,不可重复投递")
@PostMapping("/applications")
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> data = applicationService.getTimeline(id, userId);
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
// ── 学生查看自己的投递 ────────────────────────────────────
@Operation(summary = "我的投递列表", description = "查看当前学生自己的投递记录,支持 status 筛选和分页")
@GetMapping("/applications/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<Application> result = applicationService.listStudentApplications(userId, status, page, size);
Map<String, Object> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<Application> result = applicationService.listCompanyApplications(
company.getId(), jobId, status, page, size);
Map<String, Object> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<CandidateListResponse>> 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<ApiResponse<CandidateDetailView>> 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<byte[]> 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());
}
// ── 辅助 ──────────────────────────────────────────────────
}