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 * *

权限模型: *

*/ @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 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 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 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> 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 状态流转。 *
    *
  • 角色:DRAFT→SENT 仅企业;SENT/NEGOTIATING→ACCEPTED/REJECTED/NEGOTIATING 仅候选人;EXPIRED 仅系统。
  • *
  • 幂等:目标状态与当前一致时直接返回,不产生历史;并发冲突经乐观锁重读判定。
  • *
  • 过期保护:非 EXPIRED 目标且已过期的 Offer 拒绝流转。
  • *
*/ @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 toView(Offer offer) { Map 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 toHistoryView(OfferStatusHistory h) { Map 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 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> compareOffers(Long studentUserId, List ids) { if (ids == null || ids.size() < 2 || ids.size() > 5) { throw new BusinessException(ErrorCode.OFFER_COMPARE_TOO_MANY); } Set 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 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 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 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 statusCounts = offerRepository.countByCompanyIdAndStatusSince(company.getId(), since); Map 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 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 histories = historyRepository.findByOfferCompanyAndSince(companyId, since); if (histories.isEmpty()) return null; // 按 offer_id 分组,找到每个 Offer 的 SENT 时间和第一个终态时间 Map sentTimes = new HashMap<>(); Map 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 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 toCounterOfferView(OfferCounterOffer co) { Map 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; }; } }