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.
 
 
 

222 lines
12 KiB

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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<Offer> result = offerService.listOffers(userId, status, applicationId, page, size);
Map<String, Object> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> 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<ApiResponse<Map<String, Object>>> remind(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
OfferReminder reminder = offerService.remind(userId, id, AuditContext.from(request));
Map<String, Object> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<List<Map<String, Object>>>> listCounterOffers(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<List<Map<String, Object>>>> compare(
@RequestParam("ids") String idsParam,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
List<Long> ids = parseIds(idsParam);
List<Map<String, Object>> items = offerService.compareOffers(userId, ids);
return ResponseEntity.ok(ApiResponse.ok(items, requestIdAccessor.get(request)));
}
@Operation(summary = "Offer 统计", description = "仅企业;统计本企业 Offer 状态分布、接受率、平均响应时间")
@GetMapping("/statistics")
public ResponseEntity<ApiResponse<Map<String, Object>>> statistics(
@RequestParam(required = false, defaultValue = "6") Integer months,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
Map<String, Object> data = offerService.getStatistics(userId, months);
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
/**
* 解析逗号分隔的 Offer id 列表。
* 非数字、空值或非法值一律返回 400/1001,绝不抛出 500。
*/
private List<Long> parseIds(String idsParam) {
List<Long> 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;
}
}