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
*
*
权限模型:
*
* - 企业只能操作自己公司创建的 Offer(companyId 与当前企业一致);
* - 候选人只能查看/响应属于自己的 Offer(candidateId 与当前用户一致);
* - 企业不能通过请求参数传入 companyId/candidateId 绕过权限——身份一律来自 JWT。
*
*/
@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