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.
 
 
 

880 lines
42 KiB

package com.mydreamjob.offer;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
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 jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
/**
* Offer 管理 Service
*
* <p>权限模型:
* <ul>
* <li>企业只能操作自己公司创建的 Offer(companyId 与当前企业一致);</li>
* <li>候选人只能查看/响应属于自己的 Offer(candidateId 与当前用户一致);</li>
* <li>企业不能通过请求参数传入 companyId/candidateId 绕过权限——身份一律来自 JWT。</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OfferService {
/** 定时任务(系统)操作者占位 userId,不指向真实用户行(history.operator_id 无外键) */
public static final Long SYSTEM_OPERATOR_ID = 0L;
private static final String ACTIVE_KEY = "ACTIVE";
private static final int MAX_PAGE_SIZE = 100;
private final OfferRepository offerRepository;
private final OfferStatusHistoryRepository historyRepository;
private final OfferReminderRepository reminderRepository;
private final OfferCounterOfferRepository counterOfferRepository;
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final CompanyRepository companyRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final AuditService auditService;
/** R01-D4-1:Offer 状态变更(ACCEPTED/EXPIRED 履约聚合)后登记评分快照刷新。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
@PersistenceContext
private EntityManager entityManager;
/**
* 操作者角色(由 JWT 身份在 Service 层推导,绝不信任请求参数)
*/
private enum ActorRole { COMPANY, CANDIDATE }
// ── 创建(企业)────────────────────────────────────────────
/**
* 企业创建 Offer 草稿。
* 前置条件:投递存在、岗位属于当前企业、投递状态可发 Offer、该投递无有效 Offer。
*/
@Transactional
public Offer createOffer(Long operatorUserId, OfferCreateRequest req, AuditContext ctx) {
Company company = requireCompany(operatorUserId);
Application app = applicationRepository.findById(req.getApplicationId())
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
if (!company.getId().equals(job.getCompanyId())) {
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
}
if (!isApplicationEligible(app.getStatus())) {
throw new BusinessException(ErrorCode.OFFER_APPLICATION_NOT_ELIGIBLE);
}
if (offerRepository.existsByApplicationIdAndActiveKeyNotNull(app.getId())) {
throw new BusinessException(ErrorCode.DUPLICATE_OFFER);
}
validateSalaryRange(req.getSalaryMin(), req.getSalaryMax());
Offer offer = Offer.builder()
.applicationId(app.getId())
.jobId(job.getId())
.companyId(company.getId())
.candidateId(app.getStudentUserId())
.title(req.getTitle().trim())
.salaryMin(req.getSalaryMin())
.salaryMax(req.getSalaryMax())
.salaryCurrency(defaultIfBlank(req.getSalaryCurrency(), "CNY"))
.salaryPeriod(defaultIfBlank(req.getSalaryPeriod(), "MONTH"))
.workCity(req.getWorkCity() == null ? null : req.getWorkCity().trim())
.employmentType(req.getEmploymentType())
.probationMonths(req.getProbationMonths() == null ? 0 : req.getProbationMonths())
.benefits(req.getBenefits())
.description(req.getDescription().trim())
.expireAt(req.getExpireAt())
.onboardingDate(req.getOnboardingDate())
.baseSalary(req.getBaseSalary())
.housingAllowance(req.getHousingAllowance())
.annualBonusMonths(req.getAnnualBonusMonths())
.otherAllowances(req.getOtherAllowances())
.status(OfferStatus.DRAFT.name())
.activeKey(ACTIVE_KEY)
.createdBy(operatorUserId)
.updatedBy(operatorUserId)
.build();
try {
offer = offerRepository.save(offer);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
// 唯一索引 uk_offers_active 兜底(并发创建)
log.info("Duplicate active offer detected for application {}", app.getId());
throw new BusinessException(ErrorCode.DUPLICATE_OFFER);
}
recordHistory(offer.getId(), null, OfferStatus.DRAFT.name(),
operatorUserId, ActorRole.COMPANY.name(), "Offer created", ctx);
auditService.log(operatorUserId, "CREATE_OFFER", "OFFER", offer.getId(),
"Offer created for application " + app.getId(), ctx);
// 创建草稿不发送通知
return offer;
}
// ── 查询 ──────────────────────────────────────────────────
/**
* 列表:候选人查看自己收到的、企业查看自己发出的 Offer。
* 支持 status、applicationId 筛选,分页,createdAt 倒序。
*/
public Page<Offer> listOffers(Long userId, String status, Long applicationId, int page, int size) {
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST);
User user = loadUser(userId);
PageRequest pr = PageRequest.of(page, size);
if (hasRole(user, User.Role.COMPANY)) {
Company company = requireCompany(userId);
return listByCompany(company.getId(), status, applicationId, pr);
}
if (hasRole(user, User.Role.STUDENT)) {
return listByCandidate(userId, status, applicationId, pr);
}
throw new BusinessException(ErrorCode.FORBIDDEN);
}
private Page<Offer> listByCompany(Long companyId, String status, Long applicationId, PageRequest pr) {
boolean hasStatus = status != null && !status.isBlank();
if (hasStatus && applicationId != null)
return offerRepository.findByCompanyIdAndStatusAndApplicationIdOrderByCreatedAtDesc(
companyId, status, applicationId, pr);
if (hasStatus)
return offerRepository.findByCompanyIdAndStatusOrderByCreatedAtDesc(companyId, status, pr);
if (applicationId != null)
return offerRepository.findByCompanyIdAndApplicationIdOrderByCreatedAtDesc(companyId, applicationId, pr);
return offerRepository.findByCompanyIdOrderByCreatedAtDesc(companyId, pr);
}
private Page<Offer> listByCandidate(Long candidateId, String status, Long applicationId, PageRequest pr) {
boolean hasStatus = status != null && !status.isBlank();
if (hasStatus && applicationId != null)
return offerRepository.findByCandidateIdAndStatusAndApplicationIdOrderByCreatedAtDesc(
candidateId, status, applicationId, pr);
if (hasStatus)
return offerRepository.findByCandidateIdAndStatusOrderByCreatedAtDesc(candidateId, status, pr);
if (applicationId != null)
return offerRepository.findByCandidateIdAndApplicationIdOrderByCreatedAtDesc(candidateId, applicationId, pr);
return offerRepository.findByCandidateIdOrderByCreatedAtDesc(candidateId, pr);
}
/** 详情:仅目标候选人、所属企业可访问 */
public Offer getOffer(Long offerId, Long userId) {
requireVerifiedIfCompany(userId);
Offer offer = requireOffer(offerId);
assertViewAccess(offer, userId);
return offer;
}
/** 状态时间线:访问规则同详情 */
public List<Map<String, Object>> getTimeline(Long offerId, Long userId) {
getOffer(offerId, userId);
return historyRepository.findByOfferIdOrderByCreatedAtAsc(offerId)
.stream().map(this::toHistoryView).collect(Collectors.toList());
}
// ── 修改(仅 DRAFT,企业)──────────────────────────────────
/**
* 修改 Offer:仅 DRAFT 状态可修改;其余状态返回业务错误。
* 身份字段(application/job/company/candidate)不可变更。
*/
@Transactional
public Offer updateOffer(Long operatorUserId, Long offerId, OfferUpdateRequest req, AuditContext ctx) {
Company company = requireCompany(operatorUserId);
Offer offer = requireOffer(offerId);
if (!company.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
if (!OfferStatus.DRAFT.name().equals(offer.getStatus())) {
throw new BusinessException(ErrorCode.OFFER_NOT_EDITABLE);
}
validateSalaryRange(req.getSalaryMin(), req.getSalaryMax());
if (req.getExpireAt() != null && !req.getExpireAt().isAfter(LocalDateTime.now())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "expireAt must be in the future");
}
if (req.getTitle() != null) offer.setTitle(req.getTitle().trim());
if (req.getSalaryMin() != null) offer.setSalaryMin(req.getSalaryMin());
if (req.getSalaryMax() != null) offer.setSalaryMax(req.getSalaryMax());
if (req.getSalaryCurrency() != null) offer.setSalaryCurrency(req.getSalaryCurrency());
if (req.getSalaryPeriod() != null) offer.setSalaryPeriod(req.getSalaryPeriod());
if (req.getWorkCity() != null) offer.setWorkCity(req.getWorkCity().trim());
if (req.getEmploymentType() != null) offer.setEmploymentType(req.getEmploymentType());
if (req.getProbationMonths() != null) offer.setProbationMonths(req.getProbationMonths());
if (req.getBenefits() != null) offer.setBenefits(req.getBenefits());
if (req.getDescription() != null) offer.setDescription(req.getDescription().trim());
if (req.getExpireAt() != null) offer.setExpireAt(req.getExpireAt());
if (req.getOnboardingDate() != null) offer.setOnboardingDate(req.getOnboardingDate());
if (req.getBaseSalary() != null) offer.setBaseSalary(req.getBaseSalary());
if (req.getHousingAllowance() != null) offer.setHousingAllowance(req.getHousingAllowance());
if (req.getAnnualBonusMonths() != null) offer.setAnnualBonusMonths(req.getAnnualBonusMonths());
if (req.getOtherAllowances() != null) offer.setOtherAllowances(req.getOtherAllowances().trim());
offer.setUpdatedBy(operatorUserId);
offer = offerRepository.save(offer);
auditService.log(operatorUserId, "UPDATE_OFFER", "OFFER", offer.getId(),
"Offer draft updated", ctx);
return offer;
}
// ── 状态变更 ──────────────────────────────────────────────
/**
* Offer 状态流转。
* <ul>
* <li>角色:DRAFT→SENT 仅企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 仅候选人;EXPIRED 仅系统。</li>
* <li>幂等:目标状态与当前一致时直接返回,不产生历史;并发冲突经乐观锁重读判定。</li>
* <li>过期保护:非 EXPIRED 目标且已过期的 Offer 拒绝流转。</li>
* </ul>
*/
@Transactional
public Offer transitionStatus(Long operatorUserId, Long offerId, OfferStatusRequest req, AuditContext ctx) {
User user = loadUser(operatorUserId);
boolean isCompany = hasRole(user, User.Role.COMPANY);
boolean isCandidate = hasRole(user, User.Role.STUDENT);
ActorRole actor = isCompany ? ActorRole.COMPANY : (isCandidate ? ActorRole.CANDIDATE : null);
if (actor == null) throw new BusinessException(ErrorCode.FORBIDDEN);
Company currentCompany = actor == ActorRole.COMPANY ? requireCompany(operatorUserId) : null;
Offer offer = requireOffer(offerId);
// 访问控制(身份一律来自 JWT)
if (actor == ActorRole.COMPANY) {
if (!currentCompany.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
} else {
if (!offer.getCandidateId().equals(operatorUserId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
OfferStatus from = OfferStatus.valueOf(offer.getStatus());
OfferStatus to = OfferStatus.valueOf(req.getStatus());
// 幂等:已在目标状态,直接返回,不产生重复历史
if (from == to) return offer;
if (!from.canTransitionTo(to)) {
throw new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION);
}
if (!isRoleAllowed(from, to, actor)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
// 过期保护:除系统过期外,已到期 Offer 不可再接受/拒绝/洽谈/发送
if (to != OfferStatus.EXPIRED && offer.getExpireAt() != null
&& !offer.getExpireAt().isAfter(LocalDateTime.now())) {
throw new BusinessException(ErrorCode.OFFER_EXPIRED);
}
String fromStatus = offer.getStatus();
Offer saved = applyTransition(offer, to, operatorUserId, actor.name(), req.getReason(), ctx);
notifyStatusChange(saved, fromStatus, to);
auditService.log(operatorUserId, "UPDATE_OFFER_STATUS", "OFFER", saved.getId(),
"Offer status changed: " + fromStatus + " → " + to.name(), ctx);
// R01-D4-1:Offer 状态变更影响 ACCEPTED/EXPIRED 履约聚合(INTEGRITY)→ 登记刷新。
scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), java.time.LocalDateTime.now());
return saved;
}
/**
* 应用状态变更并写入历史。
* 乐观锁:并发下 saveAndFlush 校验 version,冲突时重读——若已在目标状态视为幂等成功。
*/
private Offer applyTransition(Offer offer, OfferStatus to, Long operatorId,
String operatorRole, String reason, AuditContext ctx) {
String fromStatus = offer.getStatus();
try {
offer.setStatus(to.name());
if (to == OfferStatus.REJECTED || to == OfferStatus.EXPIRED || to == OfferStatus.WITHDRAWN) {
offer.setActiveKey(null); // 终态释放"唯一有效 Offer"槽位
}
offer.setUpdatedBy(operatorId);
Offer saved = offerRepository.saveAndFlush(offer);
recordHistory(saved.getId(), fromStatus, to.name(),
operatorId, operatorRole, reason, ctx);
// 撤销/过期时同步将 PENDING 还价标记为 EXPIRED
if (to == OfferStatus.WITHDRAWN || to == OfferStatus.EXPIRED) {
counterOfferRepository.expirePendingByOfferId(offer.getId());
}
return saved;
} catch (OptimisticLockingFailureException e) {
// 清除脏上下文,避免提交时对已改脏的 offer 再次 flush 抛出异常
entityManager.clear();
Offer fresh = offerRepository.findById(offer.getId())
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND));
if (fresh.getStatus().equals(to.name())) return fresh; // 并发重复,幂等
throw new BusinessException(ErrorCode.INVALID_OFFER_STATUS_TRANSITION,
"offer was modified concurrently");
}
}
// ── 手动提醒(企业)───────────────────────────────────────
/**
* 企业手动提醒候选人。
* 仅未过期 Offer 可提醒;同一 Offer 的 MANUAL 提醒只创建一次(幂等)。
*/
@Transactional
public OfferReminder remind(Long operatorUserId, Long offerId, AuditContext ctx) {
Company company = requireCompany(operatorUserId);
Offer offer = requireOffer(offerId);
if (!company.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
if (OfferStatus.EXPIRED.name().equals(offer.getStatus())
|| (offer.getExpireAt() != null && !offer.getExpireAt().isAfter(LocalDateTime.now()))) {
throw new BusinessException(ErrorCode.OFFER_EXPIRED);
}
if (!OfferStatus.SENT.name().equals(offer.getStatus())
&& !OfferStatus.NEGOTIATING.name().equals(offer.getStatus())) {
throw new BusinessException(ErrorCode.OFFER_NOT_REMINDABLE);
}
// 幂等:相同提醒不重复创建、不重复通知
return reminderRepository.findByOfferIdAndReminderType(offerId, OfferReminder.TYPE_MANUAL)
.orElseGet(() -> createAndSendReminder(offer, OfferReminder.TYPE_MANUAL,
LocalDateTime.now(), operatorUserId, ctx));
}
/** 创建提醒记录并发送通知(用于定时任务与手动提醒) */
public OfferReminder createAndSendReminder(Offer offer, String type, LocalDateTime remindAt,
Long operatorId, AuditContext ctx) {
OfferReminder reminder = OfferReminder.builder()
.offerId(offer.getId())
.reminderType(type)
.remindAt(remindAt)
.sentAt(LocalDateTime.now())
.status(OfferReminder.STATUS_SENT)
.build();
try {
reminder = reminderRepository.save(reminder);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
log.info("Duplicate reminder skipped: offer={}, type={}", offer.getId(), type);
return reminderRepository.findByOfferIdAndReminderType(offer.getId(), type)
.orElse(reminder);
}
try {
notificationService.publishEvent(offer.getCandidateId(), "OFFER_REMINDER",
"Offer 到期提醒", "你的 Offer #" + offer.getId() + " 即将到期,请及时处理",
"OFFER", offer.getId(),
"offer-remind-" + type.toLowerCase() + "-" + offer.getId());
} catch (Exception e) {
log.warn("Reminder notification failed: offer={}, type={}", offer.getId(), type);
}
if (operatorId != null && operatorId != SYSTEM_OPERATOR_ID) {
auditService.log(operatorId, "REMIND_OFFER", "OFFER", offer.getId(),
"Reminder sent for offer " + offer.getId(), ctx);
}
return reminder;
}
// ── 通知(状态变更)───────────────────────────────────────
private void notifyStatusChange(Offer offer, String fromStatus, OfferStatus to) {
String idempotency = "offer-" + to.name().toLowerCase() + "-" + offer.getId();
if (to == OfferStatus.SENT) {
// 发送 → 通知候选人
notificationService.publishEvent(offer.getCandidateId(), "OFFER_SENT",
"收到 Offer", "你收到来自岗位 #" + offer.getJobId() + " 的 Offer,请及时处理",
"OFFER", offer.getId(), idempotency);
return;
}
Company company = companyRepository.findById(offer.getCompanyId()).orElse(null);
if (company == null) return;
String type;
String title;
switch (to) {
case ACCEPTED -> { type = "OFFER_ACCEPTED"; title = "Offer 已接受"; }
case REJECTED -> { type = "OFFER_REJECTED"; title = "Offer 已拒绝"; }
case NEGOTIATING -> { type = "OFFER_NEGOTIATING"; title = "进入洽谈"; }
case EXPIRED -> { type = "OFFER_EXPIRED"; title = "Offer 已过期"; }
case WITHDRAWN -> { type = "OFFER_WITHDRAWN"; title = "Offer 已撤销"; }
default -> { return; }
}
notificationService.publishEvent(company.getOwnerUserId(), type, title,
"Offer #" + offer.getId() + " 状态:已" + to.name(),
"OFFER", offer.getId(), idempotency);
}
// ── 视图 ──────────────────────────────────────────────────
public Map<String, Object> toView(Offer offer) {
Map<String, Object> v = new HashMap<>();
v.put("id", offer.getId());
v.put("applicationId", offer.getApplicationId());
v.put("jobId", offer.getJobId());
v.put("companyId", offer.getCompanyId());
v.put("candidateId", offer.getCandidateId());
v.put("title", offer.getTitle());
v.put("salaryMin", offer.getSalaryMin());
v.put("salaryMax", offer.getSalaryMax());
v.put("salaryCurrency", offer.getSalaryCurrency());
v.put("salaryPeriod", offer.getSalaryPeriod());
v.put("workCity", offer.getWorkCity());
v.put("employmentType", offer.getEmploymentType());
v.put("probationMonths", offer.getProbationMonths());
v.put("benefits", offer.getBenefits());
v.put("description", offer.getDescription());
v.put("expireAt", offer.getExpireAt());
v.put("onboardingDate", offer.getOnboardingDate());
v.put("baseSalary", offer.getBaseSalary());
v.put("housingAllowance", offer.getHousingAllowance());
v.put("annualBonusMonths", offer.getAnnualBonusMonths());
v.put("otherAllowances", offer.getOtherAllowances());
v.put("status", offer.getStatus());
v.put("version", offer.getVersion());
v.put("createdBy", offer.getCreatedBy());
v.put("updatedBy", offer.getUpdatedBy());
v.put("createdAt", offer.getCreatedAt());
v.put("updatedAt", offer.getUpdatedAt());
return v;
}
private Map<String, Object> toHistoryView(OfferStatusHistory h) {
Map<String, Object> v = new HashMap<>();
v.put("id", h.getId());
v.put("fromStatus", h.getFromStatus());
v.put("toStatus", h.getToStatus());
v.put("operator", h.getOperatorId());
v.put("operatorRole", h.getOperatorRole());
v.put("reason", h.getReason());
v.put("createdAt", h.getCreatedAt());
v.put("requestId", h.getRequestId());
return v;
}
// ── 还价(候选人)────────────────────────────────────────
/**
* 候选人发起还价。
* 前置条件:仅本人 Offer、SENT 或 NEGOTIATING 状态、无 PENDING 还价。
* SENT 发起还价时,Offer 自动进入 NEGOTIATING。
*/
@Transactional
public OfferCounterOffer createCounterOffer(Long studentUserId, Long offerId,
CounterOfferCreateRequest req, AuditContext ctx) {
Offer offer = requireOffer(offerId);
if (!offer.getCandidateId().equals(studentUserId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
String status = offer.getStatus();
if (!OfferStatus.SENT.name().equals(status) && !OfferStatus.NEGOTIATING.name().equals(status)) {
throw new BusinessException(ErrorCode.OFFER_NOT_NEGOTIABLE);
}
// 检查是否已有 PENDING 还价
if (counterOfferRepository.findByOfferIdAndActiveKeyNotNull(offerId).isPresent()) {
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXISTS);
}
// 过期保护
if (offer.getExpireAt() != null && !offer.getExpireAt().isAfter(LocalDateTime.now())) {
throw new BusinessException(ErrorCode.OFFER_EXPIRED);
}
validateSalaryRange(req.getProposedSalaryMin(), req.getProposedSalaryMax());
// SENT → NEGOTIATING(自动流转)
if (OfferStatus.SENT.name().equals(status)) {
OfferStatusRequest sr = new OfferStatusRequest();
sr.setStatus(OfferStatus.NEGOTIATING.name());
sr.setReason("Candidate initiated counter-offer");
transitionStatus(studentUserId, offerId, sr, ctx);
// 重新加载(transitionStatus 已刷新)
offer = requireOffer(offerId);
}
OfferCounterOffer co = OfferCounterOffer.builder()
.offerId(offerId)
.studentId(studentUserId)
.proposedSalaryMin(req.getProposedSalaryMin())
.proposedSalaryMax(req.getProposedSalaryMax())
.proposedOnboardingDate(req.getProposedOnboardingDate())
.reason(req.getReason())
.status(OfferCounterOffer.STATUS_PENDING)
.activeKey(OfferCounterOffer.ACTIVE_KEY)
.build();
try {
co = counterOfferRepository.save(co);
} catch (org.springframework.dao.DataIntegrityViolationException e) {
log.info("Duplicate active counter-offer for offer {}", offerId);
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXISTS);
}
auditService.log(studentUserId, "COUNTER_OFFER", "OFFER", offerId,
"Counter-offer created for offer " + offerId, ctx);
return co;
}
/** 查看还价列表 */
public List<OfferCounterOffer> getCounterOffers(Long offerId, Long userId) {
Offer offer = getOffer(offerId, userId);
return counterOfferRepository.findByOfferIdOrderByCreatedAtAsc(offerId);
}
/**
* 企业接受还价:更新 Offer 薪资和入职日期,将 Offer 变为 ACCEPTED。
*/
@Transactional
public OfferCounterOffer acceptCounterOffer(Long companyUserId, Long offerId, Long coId, AuditContext ctx) {
Company company = requireCompany(companyUserId);
Offer offer = requireOffer(offerId);
if (!company.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
OfferCounterOffer co = counterOfferRepository.findById(coId)
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
if (!co.getOfferId().equals(offerId)) {
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND);
}
if (!OfferCounterOffer.STATUS_PENDING.equals(co.getStatus())) {
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
}
if (OfferStatus.WITHDRAWN.name().equals(offer.getStatus())
|| OfferStatus.EXPIRED.name().equals(offer.getStatus())) {
// Offer 已撤销/过期,标记还价为 EXPIRED
co.setStatus(OfferCounterOffer.STATUS_EXPIRED);
co.setActiveKey(null);
counterOfferRepository.saveAndFlush(co);
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED);
}
try {
// 更新 Offer 字段
if (co.getProposedSalaryMin() != null) offer.setSalaryMin(co.getProposedSalaryMin());
if (co.getProposedSalaryMax() != null) offer.setSalaryMax(co.getProposedSalaryMax());
if (co.getProposedOnboardingDate() != null) offer.setOnboardingDate(co.getProposedOnboardingDate());
offer.setUpdatedBy(companyUserId);
offerRepository.saveAndFlush(offer);
// 更新还价状态
co.setStatus(OfferCounterOffer.STATUS_ACCEPTED);
co.setActiveKey(null);
co = counterOfferRepository.saveAndFlush(co);
} catch (OptimisticLockingFailureException e) {
entityManager.clear();
OfferCounterOffer fresh = counterOfferRepository.findById(coId)
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
if (OfferCounterOffer.STATUS_ACCEPTED.equals(fresh.getStatus())) return fresh;
if (OfferCounterOffer.STATUS_REJECTED.equals(fresh.getStatus()))
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
}
// 将 Offer 变为 ACCEPTED
OfferStatusRequest sr = new OfferStatusRequest();
sr.setStatus(OfferStatus.ACCEPTED.name());
sr.setReason("Counter-offer accepted by company");
transitionStatus(companyUserId, offerId, sr, ctx);
auditService.log(companyUserId, "ACCEPT_COUNTER_OFFER", "OFFER", offerId,
"Counter-offer " + coId + " accepted", ctx);
return co;
}
/**
* 企业拒绝还价:还价变为 REJECTED,Offer 保持 NEGOTIATING。
*/
@Transactional
public OfferCounterOffer rejectCounterOffer(Long companyUserId, Long offerId, Long coId, AuditContext ctx) {
Company company = requireCompany(companyUserId);
Offer offer = requireOffer(offerId);
if (!company.getId().equals(offer.getCompanyId())) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
OfferCounterOffer co = counterOfferRepository.findById(coId)
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
if (!co.getOfferId().equals(offerId)) {
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND);
}
if (!OfferCounterOffer.STATUS_PENDING.equals(co.getStatus())) {
if (OfferCounterOffer.STATUS_REJECTED.equals(co.getStatus())) return co;
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
}
if (OfferStatus.WITHDRAWN.name().equals(offer.getStatus())
|| OfferStatus.EXPIRED.name().equals(offer.getStatus())) {
co.setStatus(OfferCounterOffer.STATUS_EXPIRED);
co.setActiveKey(null);
counterOfferRepository.saveAndFlush(co);
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_EXPIRED);
}
try {
co.setStatus(OfferCounterOffer.STATUS_REJECTED);
co.setActiveKey(null);
co = counterOfferRepository.saveAndFlush(co);
} catch (OptimisticLockingFailureException e) {
entityManager.clear();
OfferCounterOffer fresh = counterOfferRepository.findById(coId)
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_FOUND));
if (OfferCounterOffer.STATUS_REJECTED.equals(fresh.getStatus())) return fresh;
throw new BusinessException(ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING);
}
auditService.log(companyUserId, "REJECT_COUNTER_OFFER", "OFFER", offerId,
"Counter-offer " + coId + " rejected", ctx);
return co;
}
// ── Offer 比较(候选人)───────────────────────────────────
/**
* 候选人比较多个 Offer。
* ids 必须是 2-5 个、不可重复、全部为正数;只能比较本人非 DRAFT 的 Offer。
* 一次 JOIN 查询,返回 Offer 附带企业名称。
*/
public List<Map<String, Object>> compareOffers(Long studentUserId, List<Long> ids) {
if (ids == null || ids.size() < 2 || ids.size() > 5) {
throw new BusinessException(ErrorCode.OFFER_COMPARE_TOO_MANY);
}
Set<Long> idSet = new HashSet<>(ids);
if (idSet.size() != ids.size()) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate offer ids");
}
for (Long id : ids) {
if (id == null || id <= 0) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid offer id");
}
}
List<Object[]> results = offerRepository.findByIdInAndCandidateIdAndStatusNotDraftWithCompany(idSet, studentUserId);
if (results.size() != idSet.size()) {
throw new BusinessException(ErrorCode.OFFER_COMPARE_NOT_OWNER);
}
return results.stream().map(row -> {
Offer offer = (Offer) row[0];
String companyName = (String) row[1];
Map<String, Object> v = toView(offer);
v.put("companyName", companyName);
return v;
}).collect(Collectors.toList());
}
// ── Offer 统计(企业)─────────────────────────────────────
/**
* 企业 Offer 统计看板。
* months 默认 6,范围 1-24。
* acceptanceRate = ACCEPTED / (ACCEPTED + REJECTED),无数据时返回 null。
* avgResponseHours 通过状态历史计算 SENT→ACCEPTED/REJECTED 的平均时间,无数据时返回 null。
*/
public Map<String, Object> getStatistics(Long companyUserId, Integer months) {
Company company = requireCompany(companyUserId);
if (months == null || months < 1 || months > 24) {
months = 6;
}
LocalDateTime since = LocalDateTime.now().minusMonths(months);
List<Object[]> statusCounts = offerRepository.countByCompanyIdAndStatusSince(company.getId(), since);
Map<String, Long> byStatus = new LinkedHashMap<>();
long total = 0;
long accepted = 0, rejected = 0;
for (Object[] row : statusCounts) {
String status = (String) row[0];
Long count = (Long) row[1];
byStatus.put(status, count);
total += count;
if (OfferStatus.ACCEPTED.name().equals(status)) accepted = count;
if (OfferStatus.REJECTED.name().equals(status)) rejected = count;
}
// 补全所有状态
for (OfferStatus s : OfferStatus.values()) {
byStatus.putIfAbsent(s.name(), 0L);
}
Double acceptanceRate = null;
long sum = accepted + rejected;
if (sum > 0) {
acceptanceRate = (double) accepted / sum;
}
Double avgResponseHours = computeAvgResponseHours(company.getId(), since);
Map<String, Object> result = new HashMap<>();
result.put("totalOffers", total);
result.put("byStatus", byStatus);
result.put("acceptanceRate", acceptanceRate);
result.put("avgResponseHours", avgResponseHours);
result.put("periodMonths", months);
return result;
}
private Double computeAvgResponseHours(Long companyId, LocalDateTime since) {
// 通过 status_history 中 SENT → ACCEPTED/REJECTED 的时间差计算平均响应时间
List<OfferStatusHistory> histories = historyRepository.findByOfferCompanyAndSince(companyId, since);
if (histories.isEmpty()) return null;
// 按 offer_id 分组,找到每个 Offer 的 SENT 时间和第一个终态时间
Map<Long, LocalDateTime> sentTimes = new HashMap<>();
Map<Long, LocalDateTime> terminalTimes = new HashMap<>();
for (OfferStatusHistory h : histories) {
Long oid = h.getOfferId();
if ("SENT".equals(h.getToStatus()) && h.getCreatedAt() != null) {
sentTimes.putIfAbsent(oid, h.getCreatedAt());
}
if (("ACCEPTED".equals(h.getToStatus()) || "REJECTED".equals(h.getToStatus()))
&& h.getCreatedAt() != null) {
terminalTimes.putIfAbsent(oid, h.getCreatedAt());
}
}
double totalHours = 0;
int count = 0;
for (Map.Entry<Long, LocalDateTime> entry : terminalTimes.entrySet()) {
LocalDateTime sent = sentTimes.get(entry.getKey());
if (sent != null) {
totalHours += ChronoUnit.HOURS.between(sent, entry.getValue());
count++;
}
}
return count > 0 ? totalHours / count : null;
}
// ── 还价视图 ──────────────────────────────────────────────
public Map<String, Object> toCounterOfferView(OfferCounterOffer co) {
Map<String, Object> v = new HashMap<>();
v.put("id", co.getId());
v.put("offerId", co.getOfferId());
v.put("studentId", co.getStudentId());
v.put("proposedSalaryMin", co.getProposedSalaryMin());
v.put("proposedSalaryMax", co.getProposedSalaryMax());
v.put("proposedOnboardingDate", co.getProposedOnboardingDate());
v.put("reason", co.getReason());
v.put("status", co.getStatus());
v.put("version", co.getVersion());
v.put("createdAt", co.getCreatedAt());
v.put("updatedAt", co.getUpdatedAt());
return v;
}
// ── 私有辅助 ──────────────────────────────────────────────
private Offer requireOffer(Long offerId) {
return offerRepository.findById(offerId)
.orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND));
}
private User loadUser(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}
/** 校验并返回当前用户绑定的企业(COMPANY 角色),未绑定返回 404 */
private Company requireCompany(Long userId) {
User user = loadUser(userId);
if (!hasRole(user, User.Role.COMPANY)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return VerifiedCompanyGuard.requireVerified(companyRepository.findByOwnerUserId(userId).orElse(null));
}
private void requireVerifiedIfCompany(Long userId) {
User user = userRepository.findById(userId).orElse(null);
if (user != null && hasRole(user, User.Role.COMPANY)) requireCompany(userId);
}
private boolean hasRole(User user, User.Role role) {
return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role);
}
private void assertViewAccess(Offer offer, Long userId) {
User user = loadUser(userId);
if (hasRole(user, User.Role.STUDENT) && offer.getCandidateId().equals(userId)) return;
if (hasRole(user, User.Role.COMPANY)) {
Company company = companyRepository.findByOwnerUserId(userId).orElse(null);
if (company != null && company.getId().equals(offer.getCompanyId())) {
VerifiedCompanyGuard.requireVerified(company);
return;
}
}
throw new BusinessException(ErrorCode.FORBIDDEN);
}
/** 投递状态允许发 Offer:SUBMITTED / VIEWED / INTERVIEW;终态与撤回不允许 */
private boolean isApplicationEligible(String applicationStatus) {
return "SUBMITTED".equals(applicationStatus)
|| "VIEWED".equals(applicationStatus)
|| "INTERVIEW".equals(applicationStatus);
}
private void validateSalaryRange(Integer salaryMin, Integer salaryMax) {
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "salaryMin must not be greater than salaryMax");
}
}
private String defaultIfBlank(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
private void recordHistory(Long offerId, String fromStatus, String toStatus,
Long operatorId, String operatorRole, String reason, AuditContext ctx) {
String requestId = ctx == null ? null : truncate(ctx.getRequestId());
historyRepository.save(OfferStatusHistory.builder()
.offerId(offerId)
.fromStatus(fromStatus)
.toStatus(toStatus)
.operatorId(operatorId)
.operatorRole(operatorRole)
.reason(reason)
.requestId(requestId)
.build());
}
private String truncate(String s) {
if (s == null) return null;
return s.length() > 32 ? s.substring(0, 32) : s;
}
/** 校验角色是否可发起某流转(EXPIRED 仅系统任务可触发,WITHDRAWN 仅企业可触发) */
private boolean isRoleAllowed(OfferStatus from, OfferStatus to, ActorRole actor) {
return switch (from) {
case DRAFT -> actor == ActorRole.COMPANY && to == OfferStatus.SENT;
case SENT -> switch (to) {
case NEGOTIATING, ACCEPTED, REJECTED -> actor == ActorRole.CANDIDATE;
case WITHDRAWN -> actor == ActorRole.COMPANY;
case EXPIRED -> false; // 仅系统任务
default -> false;
};
case NEGOTIATING -> switch (to) {
case ACCEPTED -> actor == ActorRole.CANDIDATE || actor == ActorRole.COMPANY;
case REJECTED -> actor == ActorRole.CANDIDATE;
case WITHDRAWN -> actor == ActorRole.COMPANY;
case EXPIRED -> false; // 仅系统任务
default -> false;
};
default -> false;
};
}
}