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
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());
|
|
}
|
|
|
|
// ── 辅助 ──────────────────────────────────────────────────
|
|
}
|
|
|