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
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("_", "\\_");
|
|
}
|
|
}
|
|
|