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
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;
|
|
}
|
|
|
|
}
|
|
|