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.
 
 
 

135 lines
6.3 KiB

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)。
*
* <p>保证"定时任务重复执行不产生重复记录、单个异常不影响其他 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());
}
}
}