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