package com.mydreamjob.talentdiscovery; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; 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.JobRepository; import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; import com.mydreamjob.recommendation.JobRecommendationProfile; import com.mydreamjob.recommendation.JobRecommendationProfileRepository; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryCandidateResponse; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPageResponse; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceResponse; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryPreferenceUpdateRequest; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; /** V41 企业人才发现服务。只读候选池,不写推荐历史、曝光或候选评分快照。 */ @Service @RequiredArgsConstructor public class TalentDiscoveryService { private static final ObjectMapper JSON = new ObjectMapper(); private static final TypeReference> MAP = new TypeReference<>() { }; private static final TypeReference> LIST = new TypeReference<>() { }; private static final int BATCH_SIZE = 500; private static final Pattern SAFE_SKILL = Pattern.compile("[\\p{L}\\p{N}+#./()()·\\-_ ]+"); private final com.mydreamjob.security.UserRoleGuard userRoleGuard; private final StudentTalentDiscoveryPreferenceRepository preferenceRepository; private final UserRepository userRepository; private final CompanyRepository companyRepository; private final JobRepository jobRepository; private final JobRecommendationProfileRepository profileRepository; private final TalentDiscoveryCandidateSource candidateSource; private final TalentDiscoveryScoreCalculator scoreCalculator; private final CandidateKeyGenerator candidateKeyGenerator; private final AuditService auditService; @Transactional(readOnly = true) public TalentDiscoveryPreferenceResponse getPreference(Long userId) { userRoleGuard.requireStudent(userId); StudentTalentDiscoveryPreference value = preferenceRepository.findByStudentUserId(userId).orElse(null); return toPreference(value); } @Transactional public TalentDiscoveryPreferenceResponse updatePreference(Long userId, TalentDiscoveryPreferenceUpdateRequest request, AuditContext context) { userRoleGuard.requireStudent(userId); if (request == null || request.getEnabled() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); } StudentTalentDiscoveryPreference existing = preferenceRepository.findByStudentUserId(userId).orElse(null); Long expected = request.getExpectedVersion(); if (existing == null && expected != null && expected != 0L) { throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); } if (existing != null && expected != null && !java.util.Objects.equals(existing.getVersion(), expected)) { throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); } try { StudentTalentDiscoveryPreference saved; if (existing == null) { saved = preferenceRepository.saveAndFlush(StudentTalentDiscoveryPreference.builder() .studentUserId(userId).enabled(request.getEnabled()).version(0L).build()); } else { existing.setEnabled(request.getEnabled()); saved = preferenceRepository.saveAndFlush(existing); } String requestId = context == null ? null : context.getRequestId(); String details = "{\"enabled\":" + saved.getEnabled() + ",\"version\":" + saved.getVersion() + ",\"requestId\":" + (requestId == null ? "null" : quote(requestId)) + "}"; auditService.log(userId, "TALENT_DISCOVERY_PREFERENCE_UPDATED", "STUDENT_TALENT_DISCOVERY_PREFERENCE", saved.getId(), details, context); return toPreference(saved); } catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { throw new BusinessException(ErrorCode.PRIVACY_SETTINGS_CONCURRENT_UPDATE); } } @Transactional(readOnly = true) public TalentDiscoveryPageResponse recommendations(Long userId, Long jobId, int page, int size) { validatePage(jobId, page, size); Company company = requireVerifiedCompany(userId); Job job = jobRepository.findById(jobId).orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); if (!company.getId().equals(job.getCompanyId())) { throw new BusinessException(ErrorCode.FORBIDDEN); } if (!"PUBLISHED".equals(job.getStatus())) { throw new BusinessException(ErrorCode.INVALID_JOB_STATUS_TRANSITION); } JobRecommendationProfile profile = profileRepository.findByJobId(jobId).orElse(null); if (profile == null || !Boolean.TRUE.equals(profile.getEnabled()) || !JobRecommendationProfile.APPROVED.equals(profile.getReviewStatus())) { return page("UNAVAILABLE", List.of(), 0, page, size, "该岗位尚无审核通过的推荐画像"); } LocalDateTime capturedNow = LocalDateTime.now(); List seeds = candidateSource.findEligibleCandidates(company.getId()); if (seeds.isEmpty()) { return page("NO_ELIGIBLE_CANDIDATE", List.of(), 0, page, size, "暂未发现已授权且满足基础条件的候选人"); } Map preferences = new HashMap<>(); Map riasec = new HashMap<>(); Map ability = new HashMap<>(); for (int from = 0; from < seeds.size(); from += BATCH_SIZE) { int to = Math.min(from + BATCH_SIZE, seeds.size()); List ids = seeds.subList(from, to).stream() .map(TalentDiscoveryCandidateSource.CandidateSeed::studentUserId).toList(); preferences.putAll(candidateSource.findPreferences(ids)); riasec.putAll(candidateSource.findLatestRiasec(ids)); ability.putAll(candidateSource.findLatestAbility(ids, capturedNow)); } List scored = new ArrayList<>(); for (TalentDiscoveryCandidateSource.CandidateSeed seed : seeds) { BigDecimal p = preferenceScore(preferences.get(seed.studentUserId()), profile, job); TalentDiscoveryCandidateSource.ScoreSignal iSignal = riasec.get(seed.studentUserId()); BigDecimal i = weighted(iSignal == null ? Map.of() : iSignal.scores(), parseMap(profile.getRiasecWeightsJson()), Set.of("R", "I", "A", "S", "E", "C")); TalentDiscoveryCandidateSource.ScoreSignal aSignal = ability.get(seed.studentUserId()); BigDecimal a = weighted(aSignal == null ? Map.of() : aSignal.scores(), parseMap(profile.getAbilityExpectationsJson()), Set.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING")); var calculation = scoreCalculator.calculate(p, i, a); if (calculation.isEmpty()) continue; TalentDiscoveryScoreCalculator.Calculation value = calculation.get(); scored.add(new ScoredCandidate(seed, value)); } if (scored.isEmpty()) { return page("NO_ELIGIBLE_CANDIDATE", List.of(), 0, page, size, "暂未发现已授权且满足基础条件的候选人"); } scored.sort(Comparator.comparing((ScoredCandidate c) -> c.calculation.fitScore()).reversed() .thenComparing(c -> c.seed.profileUpdatedAt(), Comparator.nullsLast(Comparator.reverseOrder())) .thenComparing(c -> c.seed.studentUserId(), Comparator.reverseOrder())); int total = scored.size(); long offset = (long) page * size; int from = offset >= total ? total : (int) offset; int to = Math.min(from + size, total); List items = scored.subList(from, to).stream() .map(c -> toCandidate(c, company.getId(), job.getId(), capturedNow.toLocalDate())).toList(); return page("PERSONALIZED", items, total, page, size, null); } private TalentDiscoveryCandidateResponse toCandidate(ScoredCandidate scored, Long companyId, Long jobId, LocalDate date) { TalentDiscoveryCandidateSource.CandidateSeed seed = scored.seed; return TalentDiscoveryCandidateResponse.builder() .candidateKey(candidateKeyGenerator.generate(companyId, jobId, seed.studentUserId(), date)) .candidateLabel("开放人才候选人") .city(safeText(seed.city(), 100)) .education(safeText(seed.education(), 100)) .graduationYear(seed.graduationYear()) .skills(safeSkills(seed.skillsJson())) .fitBand(scored.calculation.fitBand()) .summary(summary(scored.calculation.fitBand())) .signalsAvailable(scored.calculation.signalsAvailable()) .build(); } /** * R01-C:改为委托 {@link JobPreferenceScoreCalculator}。 * *

原先此处内联了与 {@code JobRecommendationService} 完全相同的一套 30/20/20/15/15 规则 * (维度顺序、权重、匹配语义、{@code matched*100/available}、scale=2、HALF_UP 与 * available==0 → null 均逐字一致)。收束后两处共用唯一实现,**行为不变**。

*/ private BigDecimal preferenceScore(TalentDiscoveryCandidateSource.PreferenceSignal preference, JobRecommendationProfile profile, Job job) { if (preference == null) return null; return JobPreferenceScoreCalculator.calculate( new JobPreferenceScoreCalculator.Criteria( preference.cities(), preference.employmentTypes(), preference.expectedSalaryMin(), preference.industries(), preference.jobTypes()), new JobPreferenceScoreCalculator.Target( job.getCity(), job.getEmploymentType(), job.getSalaryMax(), profile.getIndustryCode(), profile.getJobFamilyCode())); } private BigDecimal weighted(Map scores, Map expected, Set allowed) { if (scores.isEmpty() || expected.isEmpty()) return null; BigDecimal numerator = BigDecimal.ZERO; BigDecimal denominator = BigDecimal.ZERO; for (Map.Entry entry : expected.entrySet()) { if (!allowed.contains(entry.getKey()) || !scores.containsKey(entry.getKey()) || entry.getValue() == null || entry.getValue().signum() <= 0) continue; numerator = numerator.add(scores.get(entry.getKey()).multiply(entry.getValue())); denominator = denominator.add(entry.getValue()); } return denominator.signum() == 0 ? null : numerator.divide(denominator, 2, RoundingMode.HALF_UP); } private Map parseMap(String raw) { if (raw == null || raw.isBlank()) return Map.of(); try { Map input = JSON.readValue(raw, MAP); Map result = new HashMap<>(); for (Map.Entry entry : input.entrySet()) { if (entry.getKey() == null || entry.getValue() == null) return Map.of(); BigDecimal value = new BigDecimal(String.valueOf(entry.getValue())); if (value.compareTo(BigDecimal.ZERO) < 0 || value.compareTo(BigDecimal.valueOf(100)) > 0) return Map.of(); result.put(entry.getKey(), value); } return result; } catch (Exception ignored) { return Map.of(); } } private List safeSkills(String raw) { if (raw == null || raw.isBlank()) return List.of(); try { List values = JSON.readValue(raw, LIST); if (values == null) return List.of(); return values.stream().map(v -> safeSkill(v)).filter(v -> v != null && !v.isBlank()) .distinct().limit(10).toList(); } catch (Exception ignored) { return List.of(); } } private String safeSkill(String value) { String text = safeText(value, 40); return text != null && SAFE_SKILL.matcher(text).matches() ? text : null; } private String safeText(String value, int max) { if (value == null) return null; String text = value.trim(); if (text.isEmpty()) return null; return text.length() <= max ? text : text.substring(0, max); } private String summary(String band) { return switch (band) { case "HIGH" -> "岗位画像与候选人公开资料存在较高匹配度"; case "MEDIUM" -> "岗位画像与候选人公开资料存在一定匹配度"; default -> "候选人满足部分岗位画像条件"; }; } private Company requireVerifiedCompany(Long userId) { User user = requireUser(userId); if (!hasRole(user, User.Role.COMPANY)) throw new BusinessException(ErrorCode.FORBIDDEN); Company company = companyRepository.findByOwnerUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); return VerifiedCompanyGuard.requireVerified(company); } private User requireUser(Long userId) { if (userId == null) throw new BusinessException(ErrorCode.UNAUTHORIZED); return userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); } private boolean hasRole(User user, User.Role role) { return user != null && ((user.getRoles() != null && user.getRoles().contains(role)) || user.getRole() == role); } private void validatePage(Long jobId, int page, int size) { if (jobId == null || jobId <= 0 || page < 0 || size < 1 || size > 50) { throw new BusinessException(ErrorCode.BAD_REQUEST); } } private TalentDiscoveryPreferenceResponse toPreference(StudentTalentDiscoveryPreference value) { if (value == null) return TalentDiscoveryPreferenceResponse.builder().enabled(false).version(0L).build(); return TalentDiscoveryPreferenceResponse.builder().enabled(Boolean.TRUE.equals(value.getEnabled())) .version(value.getVersion() == null ? 0L : value.getVersion()) .createdAt(value.getCreatedAt()).updatedAt(value.getUpdatedAt()).build(); } private TalentDiscoveryPageResponse page(String mode, List items, long total, int page, int size, String message) { int totalPages = total == 0 ? 0 : (int) Math.ceil((double) total / size); return TalentDiscoveryPageResponse.builder().mode(mode).items(items).total(total) .page(page).size(size).totalPages(totalPages).message(message).build(); } private String quote(String value) { return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; } private record ScoredCandidate(TalentDiscoveryCandidateSource.CandidateSeed seed, TalentDiscoveryScoreCalculator.Calculation calculation) { } }