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.
307 lines
16 KiB
307 lines
16 KiB
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<String, Object>> MAP = new TypeReference<>() { };
|
|
private static final TypeReference<List<String>> 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<TalentDiscoveryCandidateSource.CandidateSeed> seeds = candidateSource.findEligibleCandidates(company.getId());
|
|
if (seeds.isEmpty()) {
|
|
return page("NO_ELIGIBLE_CANDIDATE", List.of(), 0, page, size, "暂未发现已授权且满足基础条件的候选人");
|
|
}
|
|
Map<Long, TalentDiscoveryCandidateSource.PreferenceSignal> preferences = new HashMap<>();
|
|
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> riasec = new HashMap<>();
|
|
Map<Long, TalentDiscoveryCandidateSource.ScoreSignal> ability = new HashMap<>();
|
|
for (int from = 0; from < seeds.size(); from += BATCH_SIZE) {
|
|
int to = Math.min(from + BATCH_SIZE, seeds.size());
|
|
List<Long> 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<ScoredCandidate> 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<TalentDiscoveryCandidateResponse> 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}。
|
|
*
|
|
* <p>原先此处内联了与 {@code JobRecommendationService} 完全相同的一套 30/20/20/15/15 规则
|
|
* (维度顺序、权重、匹配语义、{@code matched*100/available}、scale=2、HALF_UP 与
|
|
* available==0 → null 均逐字一致)。收束后两处共用唯一实现,**行为不变**。</p>
|
|
*/
|
|
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<String, BigDecimal> scores, Map<String, BigDecimal> expected, Set<String> allowed) {
|
|
if (scores.isEmpty() || expected.isEmpty()) return null;
|
|
BigDecimal numerator = BigDecimal.ZERO;
|
|
BigDecimal denominator = BigDecimal.ZERO;
|
|
for (Map.Entry<String, BigDecimal> 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<String, BigDecimal> parseMap(String raw) {
|
|
if (raw == null || raw.isBlank()) return Map.of();
|
|
try {
|
|
Map<String, Object> input = JSON.readValue(raw, MAP);
|
|
Map<String, BigDecimal> result = new HashMap<>();
|
|
for (Map.Entry<String, Object> 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<String> safeSkills(String raw) {
|
|
if (raw == null || raw.isBlank()) return List.of();
|
|
try {
|
|
List<String> 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<TalentDiscoveryCandidateResponse> 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) { }
|
|
}
|
|
|