package com.mydreamjob.offer; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.offer.dto.CounterOfferCreateRequest; import com.mydreamjob.offer.dto.OfferCreateRequest; import com.mydreamjob.offer.dto.OfferStatusRequest; import com.mydreamjob.offer.dto.OfferUpdateRequest; 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.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * Offer 管理接口(录用邀请) */ @Tag(name = "Offer", description = "企业录用邀请:创建、发送、接受/拒绝/洽谈、到期失效、提醒与时间线") @RestController @RequestMapping("/offers") @RequiredArgsConstructor public class OfferController { /** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; /** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */ private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; private final OfferService offerService; @Operation(summary = "创建 Offer", description = "仅企业账号;投递存在、岗位属于本企业、投递状态可发、无有效 Offer;默认 DRAFT") @PostMapping public ResponseEntity>> create( @Valid @RequestBody OfferCreateRequest req, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Offer offer = offerService.createOffer(userId, req, AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request))); } @Operation(summary = "Offer 列表", description = "候选人查看收到的、企业查看发出的 Offer;支持 status、applicationId 筛选与分页") @GetMapping public ResponseEntity>> list( @RequestParam(required = false) String status, @RequestParam(required = false) Long applicationId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Page result = offerService.listOffers(userId, status, applicationId, page, size); Map data = new HashMap<>(); data.put("items", result.getContent().stream().map(offerService::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 = "Offer 详情", description = "仅目标候选人、所属企业可访问;其他账号返回无权限错误") @GetMapping("/{id}") public ResponseEntity>> get( @PathVariable Long id, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Offer offer = offerService.getOffer(id, userId); return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request))); } @Operation(summary = "修改 Offer", description = "仅所属企业、仅 DRAFT 状态可修改;其余状态返回业务错误") @PutMapping("/{id:\\d+}") public ResponseEntity>> update( @PathVariable Long id, @Valid @RequestBody OfferUpdateRequest req, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Offer offer = offerService.updateOffer(userId, id, req, AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request))); } @Operation(summary = "Offer 状态变更", description = "DRAFT→SENT 企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 候选人;重复提交幂等") @PatchMapping("/{id:\\d+}/status") public ResponseEntity>> changeStatus( @PathVariable Long id, @Valid @RequestBody OfferStatusRequest req, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Offer offer = offerService.transitionStatus(userId, id, req, AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(offerService.toView(offer), requestIdAccessor.get(request))); } @Operation(summary = "Offer 状态时间线", description = "完整状态变更历史,含操作者、原因与请求 ID") @GetMapping("/{id:\\d+}/timeline") public ResponseEntity>>> timeline( @PathVariable Long id, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); List> data = offerService.getTimeline(id, userId); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } @Operation(summary = "手动提醒", description = "仅所属企业;仅 SENT/NEGOTIATING 可提醒;相同提醒不重复创建") @PostMapping("/{id:\\d+}/remind") public ResponseEntity>> remind( @PathVariable Long id, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); OfferReminder reminder = offerService.remind(userId, id, AuditContext.from(request)); Map data = new HashMap<>(); data.put("offerId", reminder.getOfferId()); data.put("reminderType", reminder.getReminderType()); data.put("status", reminder.getStatus()); data.put("sentAt", reminder.getSentAt()); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } // ── Offer 进阶接口 ───────────────────────────────────────── @Operation(summary = "发起还价", description = "仅候选人本人;仅 SENT/NEGOTIATING 可发起;SENT 时自动进入 NEGOTIATING") @PostMapping("/{id:\\d+}/counter-offers") public ResponseEntity>> createCounterOffer( @PathVariable Long id, @Valid @RequestBody CounterOfferCreateRequest req, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); OfferCounterOffer co = offerService.createCounterOffer(userId, id, req, AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), requestIdAccessor.get(request))); } @Operation(summary = "还价列表", description = "候选人查看本人 Offer 的还价、企业查看本企业 Offer 的还价") @GetMapping("/{id:\\d+}/counter-offers") public ResponseEntity>>> listCounterOffers( @PathVariable Long id, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); List> items = offerService.getCounterOffers(id, userId) .stream().map(offerService::toCounterOfferView).collect(Collectors.toList()); return ResponseEntity.ok(ApiResponse.ok(items, requestIdAccessor.get(request))); } @Operation(summary = "接受还价", description = "仅所属企业;接受后更新 Offer 薪资/入职日期并变为 ACCEPTED") @PatchMapping("/{id:\\d+}/counter-offers/{coId:\\d+}/accept") public ResponseEntity>> acceptCounterOffer( @PathVariable Long id, @PathVariable Long coId, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); OfferCounterOffer co = offerService.acceptCounterOffer(userId, id, coId, AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), requestIdAccessor.get(request))); } @Operation(summary = "拒绝还价", description = "仅所属企业;拒绝后 Offer 保持 NEGOTIATING") @PatchMapping("/{id:\\d+}/counter-offers/{coId:\\d+}/reject") public ResponseEntity>> rejectCounterOffer( @PathVariable Long id, @PathVariable Long coId, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); OfferCounterOffer co = offerService.rejectCounterOffer(userId, id, coId, AuditContext.from(request)); return ResponseEntity.ok(ApiResponse.ok(offerService.toCounterOfferView(co), requestIdAccessor.get(request))); } @Operation(summary = "Offer 比较", description = "仅候选人本人;比较 2-5 个非 DRAFT 的 Offer") @GetMapping("/compare") public ResponseEntity>>> compare( @RequestParam("ids") String idsParam, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); List ids = parseIds(idsParam); List> items = offerService.compareOffers(userId, ids); return ResponseEntity.ok(ApiResponse.ok(items, requestIdAccessor.get(request))); } @Operation(summary = "Offer 统计", description = "仅企业;统计本企业 Offer 状态分布、接受率、平均响应时间") @GetMapping("/statistics") public ResponseEntity>> statistics( @RequestParam(required = false, defaultValue = "6") Integer months, HttpServletRequest request) { Long userId = currentUserAccessor.requireUserId(); Map data = offerService.getStatistics(userId, months); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request))); } /** * 解析逗号分隔的 Offer id 列表。 * 非数字、空值或非法值一律返回 400/1001,绝不抛出 500。 */ private List parseIds(String idsParam) { List ids = new ArrayList<>(); if (idsParam == null || idsParam.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "ids is required"); } for (String token : idsParam.split(",")) { String part = token.trim(); if (part.isEmpty()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id"); } try { Long id = Long.valueOf(part); if (id <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id"); } ids.add(id); } catch (NumberFormatException e) { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id"); } } return ids; } }