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

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) { }
}