package com.mydreamjob.offer; 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.notification.NotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; /** * 定时任务工作单元:每个 Offer 的处理都运行在独立事务中(REQUIRES_NEW)。 * *
保证"定时任务重复执行不产生重复记录、单个异常不影响其他 Offer": * 单个 Offer 失败仅回滚其自身事务;乐观锁冲突表明已被其他实例处理,直接跳过。 */ @Slf4j @Component @RequiredArgsConstructor public class OfferExpiryWorker { private static final String OFFER_STATUS_SENT = "SENT"; private static final String OFFER_STATUS_NEGOTIATING = "NEGOTIATING"; private final OfferRepository offerRepository; private final OfferStatusHistoryRepository historyRepository; private final OfferReminderRepository reminderRepository; private final OfferCounterOfferRepository counterOfferRepository; private final CompanyRepository companyRepository; private final NotificationService notificationService; /** * R01-D4-2 F1:Offer 自动过期是评分输入的真实写侧事件 * ({@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数参与 * INTEGRITY 维度),必须与状态变更登记在同一事务。 */ private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; /** 到期自动失效:SENT/NEGOTIATING → EXPIRED */ @Transactional(propagation = Propagation.REQUIRES_NEW) public void expireOne(Long offerId) { Offer offer = offerRepository.findById(offerId) .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND)); String status = offer.getStatus(); if (!OFFER_STATUS_SENT.equals(status) && !OFFER_STATUS_NEGOTIATING.equals(status)) { return; // 已处理过(幂等) } offer.setStatus(OfferStatus.EXPIRED.name()); offer.setActiveKey(null); // 释放"唯一有效 Offer"槽位 offer.setUpdatedBy(OfferService.SYSTEM_OPERATOR_ID); Offer saved = offerRepository.saveAndFlush(offer); historyRepository.save(OfferStatusHistory.builder() .offerId(saved.getId()) .fromStatus(status) .toStatus(OfferStatus.EXPIRED.name()) .operatorId(OfferService.SYSTEM_OPERATOR_ID) .operatorRole("SYSTEM") .reason("Offer expired automatically") .build()); // 同步将 PENDING 还价标记为 EXPIRED counterOfferRepository.expirePendingByOfferId(saved.getId()); // R01-D4-2 F1:状态变更与刷新登记同一事务——任一步失败则二者一起回滚。 scoringRefreshRequestService.requestRefresh(saved.getCandidateId(), LocalDateTime.now()); notifyExpired(saved); log.info("Offer {} expired ({})", saved.getId(), status); } /** * 创建并发送提醒(3 天前 / 1 天前)。 * 唯一索引 (offer_id, reminder_type) + exists 检查保证"截止提醒只发送一次"。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean createReminderOne(Long offerId, String type, LocalDateTime remindAt) { Offer offer = offerRepository.findById(offerId) .orElseThrow(() -> new BusinessException(ErrorCode.OFFER_NOT_FOUND)); if (!OFFER_STATUS_SENT.equals(offer.getStatus()) && !OFFER_STATUS_NEGOTIATING.equals(offer.getStatus())) { return false; // 已接受/拒绝/过期的不再提醒 } if (reminderRepository.existsByOfferIdAndReminderType(offerId, type)) { return false; // 幂等 } OfferReminder reminder = OfferReminder.builder() .offerId(offerId) .reminderType(type) .remindAt(remindAt) .sentAt(LocalDateTime.now()) .status(OfferReminder.STATUS_SENT) .build(); try { reminderRepository.saveAndFlush(reminder); } catch (OptimisticLockingFailureException | DataIntegrityViolationException e) { log.info("Reminder skipped (already exists): offer={}, type={}", offerId, type); return false; } try { notificationService.publishEvent(offer.getCandidateId(), "OFFER_REMINDER", "Offer 到期提醒", "你的 Offer #" + offerId + " 将于 " + offer.getExpireAt() + " 到期", "OFFER", offerId, "offer-remind-" + type.toLowerCase() + "-" + offerId); } catch (Exception e) { log.warn("Reminder notification failed: offer={}, type={}", offerId, type); } return true; } private void notifyExpired(Offer offer) { try { notificationService.publishEvent(offer.getCandidateId(), "OFFER_EXPIRED", "Offer 已过期", "你的 Offer #" + offer.getId() + " 已到期失效", "OFFER", offer.getId(), "offer-expired-" + offer.getId()); } catch (Exception e) { log.warn("Expired notification failed: offer={}", offer.getId()); } Company company = companyRepository.findById(offer.getCompanyId()).orElse(null); if (company == null) return; try { notificationService.publishEvent(company.getOwnerUserId(), "OFFER_EXPIRED", "Offer 已过期", "Offer #" + offer.getId() + " 候选人未在有效期内响应,已自动失效", "OFFER", offer.getId(), "offer-expired-" + offer.getId() + "-company"); } catch (Exception e) { log.warn("Expired company notification failed: offer={}", offer.getId()); } } }