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.
 
 
 

477 lines
24 KiB

package com.mydreamjob.application;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CandidateItemView;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.TypedQuery;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ApplicationService {
private final ApplicationRepository applicationRepository;
private final JobService jobService;
private final UserRepository userRepository;
private final CompanyRepository companyRepository;
private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService;
/** R01-D4-1:投递新增/撤回/状态变更后登记评分快照刷新。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
@PersistenceContext
private EntityManager em;
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set<String> ALLOWED_SORTS = Set.of("createdAt", "updatedAt");
private static final Set<String> ALLOWED_ORDERS = Set.of("asc", "desc");
// ── 学生端 ────────────────────────────────────────────────
@Transactional
public Application apply(Long studentUserId, ApplicationCreateRequest req) {
return apply(studentUserId, req, null);
}
@Transactional
public Application apply(Long studentUserId, ApplicationCreateRequest req,
com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT");
Job job = jobService.getJobForApplication(req.getJobId());
if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.JOB_NOT_FOUND);
if (applicationRepository.findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc(
req.getJobId(), studentUserId, Set.of("WITHDRAWN")).isPresent())
throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE);
Application previous = applicationRepository
.findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(req.getJobId(), studentUserId)
.orElse(null);
Application app = Application.builder()
.jobId(req.getJobId()).studentUserId(studentUserId)
.attemptNumber(previous == null ? 1 : Math.addExact(previous.getAttemptNumber(), 1))
.previousApplicationId(previous == null ? null : previous.getId())
.status(ApplicationStatus.SUBMITTED.name()).build();
app = applicationRepository.save(app);
recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied");
auditService.log(studentUserId, "APPLY", "APPLICATION", app.getId(),
"Student applied to job " + req.getJobId(), ctx);
// 站内通知:投递成功
notificationService.publishEvent(studentUserId, "APPLICATION_APPLIED", "投递成功",
"你已成功投递岗位 #" + req.getJobId(),
"APPLICATION", app.getId(),
"apply-" + studentUserId + "-" + req.getJobId());
// R01-D4-1:投递数量影响 STABILITY/ACTIVITY 聚合 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now());
return app;
}
/** 学生撤回投递:仅 SUBMITTED/VIEWED 可撤回 */
@Transactional
public Application withdraw(Long studentUserId, Long applicationId) {
return withdraw(studentUserId, applicationId, null);
}
@Transactional
public Application withdraw(Long studentUserId, Long applicationId,
com.mydreamjob.audit.AuditContext ctx) {
assertRole(studentUserId, "STUDENT");
Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
String currentStatus = app.getStatus();
// 终态或已被撤回不可重复撤回
if ("WITHDRAWN".equals(currentStatus)
|| "REJECTED".equals(currentStatus)
|| "HIRED".equals(currentStatus))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
if (!"SUBMITTED".equals(currentStatus) && !"VIEWED".equals(currentStatus))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
app.setStatus("WITHDRAWN");
app = applicationRepository.save(app);
recordHistory(app.getId(), studentUserId, currentStatus, "WITHDRAWN", "Student withdrew");
auditService.log(studentUserId, "WITHDRAW", "APPLICATION", app.getId(),
"Student withdrew application", ctx);
// 站内通知:投递撤回
notificationService.publishEvent(studentUserId, "APPLICATION_WITHDRAWN", "投递已撤回",
"你的投递 #" + app.getId() + " 已撤回",
"APPLICATION", app.getId(),
"withdraw-" + studentUserId + "-" + app.getId());
// R01-D4-1:撤回参与 WITHDRAWN/总投递比率计算 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now());
return app;
}
/** 状态时间线 */
public List<Map<String, Object>> getTimeline(Long applicationId, Long userId) {
User actor = userRepository.findByIdWithRoles(userId).orElse(null);
Company actorCompany = null;
if (actor != null && actor.getRoles() != null && actor.getRoles().contains(User.Role.COMPANY)) {
actorCompany = VerifiedCompanyGuard.requireVerified(
companyRepository.findByOwnerUserId(userId).orElse(null));
}
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
// 仅学生本人或岗位所属企业可查看
Job job = jobService.getJob(app.getJobId());
boolean isStudent = app.getStudentUserId().equals(userId);
boolean isCompany = actorCompany != null && job.getCompanyId().equals(actorCompany.getId());
if (!isStudent && !isCompany) throw new BusinessException(ErrorCode.FORBIDDEN);
return historyRepository.findByApplicationIdOrderByCreatedAtAsc(applicationId)
.stream().map(h -> {
Map<String, Object> v = new HashMap<>();
v.put("id", h.getId());
v.put("fromStatus", h.getFromStatus());
v.put("toStatus", h.getToStatus());
v.put("operatorUserId", h.getOperatorUserId());
v.put("reason", h.getReason());
v.put("createdAt", h.getCreatedAt());
return v;
}).collect(Collectors.toList());
}
public Page<Application> listStudentApplications(Long studentUserId, String status, int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
PageRequest pr = PageRequest.of(page, size);
if (status != null && !status.isBlank())
return applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr);
return applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr);
}
/** 仅补齐本人当前页的岗位摘要;批量投影查询,禁止逐条反查岗位或企业。 */
@Transactional(readOnly = true)
public Page<Map<String, Object>> listStudentApplicationViews(
Long studentUserId, String status, int page, int size) {
Page<Application> applications = listStudentApplications(studentUserId, status, page, size);
Map<Long, Object[]> summaries = new HashMap<>();
if (!applications.isEmpty()) {
List<Long> jobIds = applications.stream().map(Application::getJobId).distinct().toList();
em.createQuery("""
select j.id, j.title, j.companyId, c.name, j.city,
j.salaryMin, j.salaryMax, j.status, j.employmentType
from Job j left join Company c on c.id = j.companyId
where j.id in :jobIds
""", Object[].class)
.setParameter("jobIds", jobIds).getResultList()
.forEach(row -> summaries.put((Long) row[0], row));
}
return applications.map(application -> {
Map<String, Object> view = toView(application);
Object[] row = summaries.get(application.getJobId());
String[] fields = {"jobTitle", "companyId", "companyName", "city",
"salaryMin", "salaryMax", "jobStatus", "employmentType"};
for (int i = 0; i < fields.length; i++) {
view.put(fields[i], row == null ? null : row[i + 1]);
}
return view;
});
}
public Application getApplication(Long applicationId, Long studentUserId) {
Application app = applicationRepository.findById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
if (!app.getStudentUserId().equals(studentUserId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
return app;
}
// ── 企业端 ────────────────────────────────────────────────
public List<Application> listCompanyApplications(Long companyId, Long jobId) {
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
return applicationRepository.findByJobIdOrderByCreatedAtDesc(jobId);
}
public Page<Application> listCompanyApplications(Long companyId, Long jobId, String status, int page, int size) {
if (page < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
jobService.requireVerifiedCompany(companyId);
Job job = jobService.getJob(jobId);
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
PageRequest pr = PageRequest.of(page, size);
if (status != null && !status.isBlank())
return applicationRepository.findByJobIdAndStatusOrderByCreatedAtDesc(jobId, status, pr);
return applicationRepository.findByJobIdOrderByCreatedAtDesc(jobId, pr);
}
@Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req) {
return updateStatus(companyId, applicationId, req, null);
}
@Transactional
public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req,
com.mydreamjob.audit.AuditContext ctx) {
jobService.requireVerifiedCompany(companyId);
Application app = applicationRepository.findLockedById(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND));
Job job = jobService.getJob(app.getJobId());
if (!job.getCompanyId().equals(companyId))
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
ApplicationStatus current = ApplicationStatus.valueOf(app.getStatus());
ApplicationStatus target = ApplicationStatus.valueOf(req.getStatus());
if (!current.canTransitionTo(target))
throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION);
String from = app.getStatus();
app.setStatus(target.name());
app = applicationRepository.save(app);
recordHistory(app.getId(), companyId, from, target.name(),
"Company updated status to " + target.name());
auditService.log(companyId, "UPDATE_STATUS", "APPLICATION", app.getId(),
"Status changed: " + from + " → " + target.name(), ctx);
// 站内通知:投递状态变更 → 学生
notificationService.publishEvent(app.getStudentUserId(), "APPLICATION_STATUS",
"投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(),
"APPLICATION", app.getId(),
"status-" + companyId + "-" + app.getId() + "-" + target.name());
// R01-D4-1:仅状态实际变化(含 WITHDRAWN/HIRED 等聚合口径)时登记刷新。
scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now());
return app;
}
// ── 视图 ──────────────────────────────────────────────────
public Map<String, Object> toView(Application app) {
Map<String, Object> view = new HashMap<>();
view.put("id", app.getId());
view.put("jobId", app.getJobId());
view.put("studentUserId", app.getStudentUserId());
view.put("status", app.getStatus());
view.put("attemptNumber", app.getAttemptNumber());
view.put("previousApplicationId", app.getPreviousApplicationId());
view.put("createdAt", app.getCreatedAt());
view.put("updatedAt", app.getUpdatedAt());
return view;
}
// ── 私有 ──────────────────────────────────────────────────
private void assertRole(Long userId, String expectedRoleName) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r.name().equals(expectedRoleName)))
throw new BusinessException(ErrorCode.FORBIDDEN);
}
private void recordHistory(Long applicationId, Long operatorUserId,
String fromStatus, String toStatus, String reason) {
historyRepository.save(ApplicationStatusHistory.builder()
.applicationId(applicationId).operatorUserId(operatorUserId)
.fromStatus(fromStatus).toStatus(toStatus).reason(reason)
.build());
}
// ── 企业候选人看板 ─────────────────────────────────────────
/**
* 企业候选人列表:跨岗位查询,JOIN student_profiles + users
* 排序字段通过白名单映射,禁止拼接原始请求参数
*/
@Transactional(readOnly = true)
public CandidateListResponse listCompanyCandidates(Long companyId, Long jobId, String status,
String keyword, String sort, String order,
int page, int size) {
jobService.requireVerifiedCompany(companyId);
// 参数校验
if (page < 0 || size < 1 || size > 100)
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (status != null && !ALLOWED_STATUSES.contains(status))
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (sort != null && !ALLOWED_SORTS.contains(sort))
throw new BusinessException(ErrorCode.BAD_REQUEST);
if (order != null && !ALLOWED_ORDERS.contains(order))
throw new BusinessException(ErrorCode.BAD_REQUEST);
String escapedKeyword = escapeLike(keyword);
// 白名单映射排序字段
String sortField = (sort == null) ? "a.createdAt" : "a." + sort;
String sortOrder = (order == null) ? "DESC" : order.toUpperCase();
// 构建 JPQL
String whereClause = "WHERE j.companyId = :companyId";
if (jobId != null) whereClause += " AND a.jobId = :jobId";
if (status != null) whereClause += " AND a.status = :status";
if (escapedKeyword != null) {
whereClause += " AND (sp.realName LIKE CONCAT('%', :keyword, '%')" +
" OR sp.school LIKE CONCAT('%', :keyword, '%')" +
" OR sp.education LIKE CONCAT('%', :keyword, '%'))";
}
String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " +
"sp.realName, sp.school, sp.education, sp.graduationYear, " +
"u.avatarUrl, a.createdAt, a.updatedAt, a.attemptNumber, a.previousApplicationId";
String fromClause = "FROM Application a " +
"JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
"LEFT JOIN User u ON a.studentUserId = u.id";
String countJpql = "SELECT COUNT(a) " + fromClause + " " + whereClause;
String dataJpql = selectClause + " " + fromClause + " " + whereClause +
" ORDER BY " + sortField + " " + sortOrder + ", a.id " + sortOrder;
// Count
TypedQuery<Long> countQuery = em.createQuery(countJpql, Long.class);
countQuery.setParameter("companyId", companyId);
if (jobId != null) countQuery.setParameter("jobId", jobId);
if (status != null) countQuery.setParameter("status", status);
if (escapedKeyword != null) countQuery.setParameter("keyword", escapedKeyword);
long total = countQuery.getSingleResult();
// Data
TypedQuery<Object[]> dataQuery = em.createQuery(dataJpql, Object[].class);
dataQuery.setParameter("companyId", companyId);
if (jobId != null) dataQuery.setParameter("jobId", jobId);
if (status != null) dataQuery.setParameter("status", status);
if (escapedKeyword != null) dataQuery.setParameter("keyword", escapedKeyword);
dataQuery.setFirstResult(page * size);
dataQuery.setMaxResults(size);
List<Object[]> rows = dataQuery.getResultList();
List<CandidateItemView> items = rows.stream().map(row -> CandidateItemView.builder()
.applicationId((Long) row[0])
.jobId((Long) row[1])
.jobTitle((String) row[2])
.status((String) row[3])
.candidateName((String) row[4])
.candidateSchool((String) row[5])
.candidateEducation((String) row[6])
.candidateGraduationYear(row[7] != null ? ((Number) row[7]).shortValue() : null)
.candidateAvatarUrl((String) row[8])
.createdAt((LocalDateTime) row[9])
.updatedAt((LocalDateTime) row[10])
.attemptNumber((Integer) row[11])
.previousApplicationId((Long) row[12])
.build()).collect(Collectors.toList());
return CandidateListResponse.builder()
.items(items)
.total(total)
.page(page)
.size(size)
.totalPages((int) Math.ceil((double) total / size))
.build();
}
/**
* 企业候选人详情:单次 JOIN 查询,不产生 N+1
*/
@Transactional(readOnly = true)
public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) {
jobService.requireVerifiedCompany(companyId);
// 单次 JOIN 查询:applications + jobs + student_profiles + users
// 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询
TypedQuery<Object[]> query = em.createQuery(
"SELECT a, j, sp, u FROM Application a " +
"JOIN Job j ON a.jobId = j.id " +
"LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " +
"LEFT JOIN User u ON a.studentUserId = u.id " +
"WHERE a.id = :applicationId",
Object[].class);
query.setParameter("applicationId", applicationId);
List<Object[]> results = query.getResultList();
if (results.isEmpty()) {
// 投递不存在
throw new BusinessException(ErrorCode.APPLICATION_NOT_FOUND);
}
Object[] row = results.get(0);
Application app = (Application) row[0];
com.mydreamjob.job.Job job = (com.mydreamjob.job.Job) row[1];
StudentProfile sp = (StudentProfile) row[2];
com.mydreamjob.auth.User u = (com.mydreamjob.auth.User) row[3];
// 数据隔离:投递存在但岗位不属于当前企业 → 越权
if (!job.getCompanyId().equals(companyId)) {
throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH);
}
List<String> skills = Collections.emptyList();
if (sp != null && sp.getSkillsJson() != null) {
try {
skills = objectMapper.readValue(sp.getSkillsJson(),
new TypeReference<List<String>>() {});
} catch (Exception e) {
skills = Collections.emptyList();
}
}
return CandidateDetailView.builder()
.applicationId(app.getId())
.attemptNumber(app.getAttemptNumber())
.previousApplicationId(app.getPreviousApplicationId())
.jobId(app.getJobId())
.jobTitle(job.getTitle())
.jobCity(job.getCity())
.jobEmploymentType(job.getEmploymentType())
.status(app.getStatus())
.coverLetter(app.getCoverLetter())
.createdAt(app.getCreatedAt())
.updatedAt(app.getUpdatedAt())
.candidateName(sp != null ? sp.getRealName() : null)
.candidateAvatarUrl(u != null ? u.getAvatarUrl() : null)
.candidateBio(sp != null ? sp.getBio() : null)
.candidateLocation(sp != null ? sp.getLocation() : null)
.candidateSchool(sp != null ? sp.getSchool() : null)
.candidateEducation(sp != null ? sp.getEducation() : null)
.candidateGraduationYear(sp != null ? sp.getGraduationYear() : null)
.candidateSkills(skills)
.build();
}
/**
* 转义 LIKE 特殊字符,防止用户输入 %、_ 等通配符
*/
String escapeLike(String keyword) {
if (keyword == null) return null;
return keyword
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
}
}