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.
 
 
 

220 lines
21 KiB

package com.mydreamjob.recommendation;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.ApplicationRepository;
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.CompanyService;
import com.mydreamjob.company.VerifiedCompanyGuard;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.job.JobService;
import com.mydreamjob.preference.StudentPreference;
import com.mydreamjob.preference.StudentPreferenceRepository;
import com.mydreamjob.recommendation.dto.*;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/** V39 规则型岗位推荐。只读取测评聚合结果,不读取题面、答案或候选评分快照。 */
@Service
@RequiredArgsConstructor
public class JobRecommendationService {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Set<String> RIASEC = Set.of("R","I","A","S","E","C");
private static final Set<String> ABILITY = Set.of("LOGIC","COMMUNICATION","EXECUTION","LEARNING");
private static final Set<String> FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL");
private static final Set<String> DECISIONS = Set.of("APPROVE", "REJECT");
private static final Set<String> REJECT_REASONS = Set.of("TAXONOMY_MISMATCH", "WEIGHT_INVALID", "CONTENT_INCOMPLETE", "OTHER");
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final JobRecommendationTaxonomyRepository taxonomyRepository;
private final JobRecommendationProfileRepository profileRepository;
private final StudentJobRecommendationFeedbackRepository feedbackRepository;
private final JobRepository jobRepository;
private final JobService jobService;
private final CompanyService companyService;
private final UserRepository userRepository;
private final StudentPreferenceRepository preferenceRepository;
private final ApplicationRepository applicationRepository;
private final AuditService auditService;
private final JdbcTemplate jdbcTemplate;
@Value("${recommendation.assessment.enabled:false}") private boolean assessmentEnabled;
@Transactional(readOnly = true)
public List<TaxonomyResponse> taxonomies() {
return taxonomyRepository.findByEnabledTrueOrderBySortOrderAscCodeAsc().stream().map(t -> TaxonomyResponse.builder()
.code(t.getCode()).parentCode(t.getParentCode()).type(t.getTaxonomyType()).displayName(t.getDisplayName()).build()).toList();
}
@Transactional(readOnly = true)
public RecommendationProfileResponse companyProfile(Long userId, Long jobId) {
assertCompanyOwns(userId, jobId);
return profileRepository.findByJobId(jobId).map(this::profileView)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_NOT_FOUND));
}
@Transactional
public RecommendationProfileResponse upsertCompanyProfile(Long userId, Long jobId, RecommendationProfileRequest req, AuditContext ctx) {
assertCompanyOwns(userId, jobId);
validateTaxonomy(req.getIndustryCode(), req.getJobFamilyCode());
validateMaps(req.getRiasecWeights(), req.getAbilityExpectations());
JobRecommendationProfile p = profileRepository.findByJobId(jobId).orElse(null);
if (p != null && !Objects.equals(p.getVersion(), req.getExpectedVersion())) {
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_CONCURRENT_UPDATE);
}
if (p == null && req.getExpectedVersion() != 0L) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_CONCURRENT_UPDATE);
if (p == null) p = JobRecommendationProfile.builder().jobId(jobId).build();
p.setIndustryCode(req.getIndustryCode()); p.setJobFamilyCode(req.getJobFamilyCode());
p.setRiasecWeightsJson(write(req.getRiasecWeights())); p.setAbilityExpectationsJson(write(req.getAbilityExpectations()));
p.setEnabled(req.getEnabled());
if (Boolean.TRUE.equals(req.getEnabled())) { p.setReviewStatus(JobRecommendationProfile.PENDING_REVIEW); p.setReviewReason(null); p.setReviewedAt(null); p.setReviewedByUserId(null); }
else p.setReviewStatus(JobRecommendationProfile.DISABLED);
p = profileRepository.saveAndFlush(p);
auditService.log(userId, "JOB_RECOMMENDATION_PROFILE_UPDATED", "JOB_RECOMMENDATION_PROFILE", p.getId(),
"{\"jobId\":" + jobId + ",\"status\":\"" + p.getReviewStatus() + "\"}", ctx);
return profileView(p);
}
@Transactional(readOnly = true)
public Page<RecommendationProfileResponse> adminProfiles(Long adminId, int page, int size, String status) {
userRoleGuard.requireAdmin(adminId); validatePage(page, size);
Page<JobRecommendationProfile> result = status == null || status.isBlank()
? profileRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")))
: profileRepository.findByReviewStatusOrderByUpdatedAtDesc(status, PageRequest.of(page, size));
return result.map(this::profileView);
}
@Transactional
public RecommendationProfileResponse decide(Long adminId, Long jobId, RecommendationDecisionRequest req, AuditContext ctx) {
userRoleGuard.requireAdmin(adminId);
if (!DECISIONS.contains(req.getDecision()) || ("REJECT".equals(req.getDecision()) && !REJECT_REASONS.contains(req.getReasonCode())))
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);
JobRecommendationProfile p = profileRepository.findByJobId(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_NOT_FOUND));
if (!Objects.equals(p.getVersion(), req.getExpectedVersion())) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_CONCURRENT_UPDATE);
if (!JobRecommendationProfile.PENDING_REVIEW.equals(p.getReviewStatus())) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE);
p.setReviewStatus("APPROVE".equals(req.getDecision()) ? JobRecommendationProfile.APPROVED : JobRecommendationProfile.REJECTED);
p.setReviewReason("REJECT".equals(req.getDecision()) ? req.getReasonCode() : null);
p.setReviewedByUserId(adminId); p.setReviewedAt(LocalDateTime.now()); p = profileRepository.saveAndFlush(p);
auditService.log(adminId, "JOB_RECOMMENDATION_PROFILE_REVIEWED", "JOB_RECOMMENDATION_PROFILE", p.getId(),
"{\"jobId\":" + jobId + ",\"status\":\"" + p.getReviewStatus() + "\"}", ctx);
return profileView(p);
}
@Transactional
public RecommendationFeedbackResponse feedback(Long studentId, Long jobId, RecommendationFeedbackRequest req, AuditContext ctx) {
userRoleGuard.requireStudent(studentId); jobService.getPublicJob(jobId);
if (!FEEDBACK.contains(req.getFeedbackType()) || req.getIdempotencyKey().length() > 64)
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);
String key = studentId + ":" + jobId + ":" + req.getFeedbackType() + ":" + Optional.ofNullable(req.getReasonType()).orElse("");
Optional<StudentJobRecommendationFeedback> replay = feedbackRepository.findByStudentUserIdAndIdempotencyKey(studentId, req.getIdempotencyKey());
if (replay.isPresent()) {
StudentJobRecommendationFeedback old = replay.get();
if (!Objects.equals(old.getFeedbackKey(), key)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT);
return feedbackView(old);
}
StudentJobRecommendationFeedback f = feedbackRepository.findByStudentUserIdAndFeedbackKey(studentId, key).orElse(null);
if (f == null) f = StudentJobRecommendationFeedback.builder().studentUserId(studentId).jobId(jobId).feedbackType(req.getFeedbackType()).reasonType(req.getReasonType()).feedbackKey(key).idempotencyKey(req.getIdempotencyKey()).build();
else { f.setStatus(StudentJobRecommendationFeedback.ACTIVE); f.setIdempotencyKey(req.getIdempotencyKey()); }
try { f = feedbackRepository.saveAndFlush(f); } catch (DataIntegrityViolationException e) { throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE); }
auditService.log(studentId, "JOB_RECOMMENDATION_FEEDBACK_RECORDED", "JOB", jobId,
"{\"jobId\":" + jobId + ",\"status\":\"ACTIVE\"}", ctx);
return feedbackView(f);
}
@Transactional
public void revokeNotInterested(Long studentId, Long jobId, Long expectedVersion, AuditContext ctx) {
userRoleGuard.requireStudent(studentId);
StudentJobRecommendationFeedback f = feedbackRepository.findByStudentUserIdAndJobIdAndFeedbackTypeAndStatus(studentId, jobId, "NOT_INTERESTED", StudentJobRecommendationFeedback.ACTIVE)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_NOT_FOUND));
if (!Objects.equals(f.getVersion(), expectedVersion)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE);
f.setStatus(StudentJobRecommendationFeedback.REVOKED); feedbackRepository.saveAndFlush(f);
auditService.log(studentId, "JOB_RECOMMENDATION_NOT_INTERESTED_REVOKED", "JOB", jobId, "{\"jobId\":" + jobId + ",\"status\":\"REVOKED\"}", ctx);
}
@Transactional(readOnly = true)
public Page<JobRecommendationItem> recommend(Long studentId, int page, int size) {
userRoleGuard.requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now();
Set<Long> applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId));
Set<Long> hidden = feedbackRepository.findByStudentUserIdAndStatusAndFeedbackType(studentId, StudentJobRecommendationFeedback.ACTIVE, "NOT_INTERESTED")
.stream().map(StudentJobRecommendationFeedback::getJobId).collect(Collectors.toSet());
StudentPreference pref = preferenceRepository.findByStudentUserId(studentId).orElse(null);
if (!assessmentEnabled) return fallback(page, size, applied, hidden, pref);
Map<String, BigDecimal> riasec = latestRiasec(studentId);
Map<String, BigDecimal> ability = latestAbility(studentId, now);
if (riasec.isEmpty() && ability.isEmpty() && pref == null) return fallback(page, size, applied, hidden, null);
List<Job> jobs = jobRepository.findAllById(profileRepository.findByReviewStatusAndEnabledTrue(JobRecommendationProfile.APPROVED).stream().map(JobRecommendationProfile::getJobId).toList());
Map<Long, Job> jobsById = jobs.stream().collect(Collectors.toMap(Job::getId, j -> j));
List<JobRecommendationItem> items = profileRepository.findByReviewStatusAndEnabledTrue(JobRecommendationProfile.APPROVED).stream()
.map(p -> scored(p, jobsById.get(p.getJobId()), pref, riasec, ability, applied, hidden)).filter(Objects::nonNull)
.sorted(Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)).toList();
return slice(items, page, size);
}
private JobRecommendationItem scored(JobRecommendationProfile p, Job j, StudentPreference pref, Map<String, BigDecimal> riasec, Map<String, BigDecimal> ability, Set<Long> applied, Set<Long> hidden) {
if (j == null || !"PUBLISHED".equals(j.getStatus()) || applied.contains(j.getId()) || hidden.contains(j.getId()) || !hardPreferenceMatch(pref, j)) return null;
List<BigDecimal> components = new ArrayList<>(); List<BigDecimal> weights = new ArrayList<>(); List<String> reasons = new ArrayList<>();
BigDecimal preference = preferenceScore(pref, p, j); if (preference != null) { components.add(preference); weights.add(BigDecimal.valueOf(40)); reasons.add("岗位偏好匹配"); }
BigDecimal r = weighted(riasec, mapDecimal(p.getRiasecWeightsJson()), RIASEC); if (r != null) { components.add(r); weights.add(BigDecimal.valueOf(35)); reasons.add("职业兴趣匹配"); }
BigDecimal a = abilityScore(ability, mapDecimal(p.getAbilityExpectationsJson())); if (a != null) { components.add(a); weights.add(BigDecimal.valueOf(25)); reasons.add("能力要求匹配"); }
if (weights.isEmpty()) return null;
BigDecimal top = BigDecimal.ZERO, bottom = BigDecimal.ZERO;
for (int i=0;i<weights.size();i++) { top=top.add(components.get(i).multiply(weights.get(i))); bottom=bottom.add(weights.get(i)); }
return JobRecommendationItem.builder().jobId(j.getId()).title(j.getTitle()).city(j.getCity()).employmentType(j.getEmploymentType()).salaryMin(j.getSalaryMin()).salaryMax(j.getSalaryMax()).mode("PERSONALIZED").score(top.divide(bottom,2,RoundingMode.HALF_UP)).reasons(reasons).build();
}
private Page<JobRecommendationItem> fallback(int page,int size,Set<Long> applied,Set<Long> hidden,StudentPreference pref) {
List<JobRecommendationItem> all = jobRepository.findByStatusOrderByCreatedAtDesc("PUBLISHED").stream().filter(j -> !applied.contains(j.getId()) && !hidden.contains(j.getId()) && hardPreferenceMatch(pref,j)).map(j -> JobRecommendationItem.builder().jobId(j.getId()).title(j.getTitle()).city(j.getCity()).employmentType(j.getEmploymentType()).salaryMin(j.getSalaryMin()).salaryMax(j.getSalaryMax()).mode("FALLBACK").reasons(List.of("最新公开岗位")).build()).toList();
return slice(all,page,size);
}
private <T> Page<T> slice(List<T> all,int page,int size) { int from=Math.min(page*size,all.size()), to=Math.min(from+size,all.size()); return new PageImpl<>(all.subList(from,to), PageRequest.of(page,size),all.size()); }
private boolean hardPreferenceMatch(StudentPreference p,Job j) { if(p==null)return true; List<String> cities=list(p.getPreferredCities()); if(!cities.isEmpty()&&!cities.contains(j.getCity()))return false; return p.getExpectedSalaryMin()==null || j.getSalaryMax()==null || j.getSalaryMax()>=p.getExpectedSalaryMin(); }
private BigDecimal preferenceScore(StudentPreference preference, JobRecommendationProfile profile, Job job) {
if (preference == null) return null;
JobPreferenceScoreCalculator.Criteria criteria = new JobPreferenceScoreCalculator.Criteria(
list(preference.getPreferredCities()),
list(preference.getEmploymentTypes()),
preference.getExpectedSalaryMin(),
list(preference.getPreferredIndustries()),
list(preference.getPreferredJobTypes()));
JobPreferenceScoreCalculator.Target target = new JobPreferenceScoreCalculator.Target(
job.getCity(), job.getEmploymentType(), job.getSalaryMax(),
profile.getIndustryCode(), profile.getJobFamilyCode());
return JobPreferenceScoreCalculator.calculate(criteria, target);
}
private BigDecimal weighted(Map<String,BigDecimal> scores,Map<String,BigDecimal> expected,Set<String> allowed){if(scores.isEmpty()||expected.isEmpty())return null; BigDecimal n=BigDecimal.ZERO,d=BigDecimal.ZERO;for(var e:expected.entrySet())if(allowed.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().signum()>0){n=n.add(scores.get(e.getKey()).multiply(e.getValue()));d=d.add(e.getValue());}return d.signum()==0?null:n.divide(d,2,RoundingMode.HALF_UP);}
private BigDecimal abilityScore(Map<String,BigDecimal> scores,Map<String,BigDecimal> expected){if(scores.isEmpty()||expected.isEmpty())return null;BigDecimal total=BigDecimal.ZERO;int n=0;for(var e:expected.entrySet())if(ABILITY.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().compareTo(BigDecimal.ZERO)>0){total=total.add(scores.get(e.getKey()).min(e.getValue()).multiply(BigDecimal.valueOf(100)).divide(e.getValue(),2,RoundingMode.HALF_UP));n++;}return n==0?null:total.divide(BigDecimal.valueOf(n),2,RoundingMode.HALF_UP);}
private Map<String,BigDecimal> latestRiasec(Long student){return latest("SELECT r.dimension_scores_text FROM assessment_results r JOIN assessment_attempts a ON a.id=r.attempt_id JOIN assessment_definitions d ON d.id=a.definition_id WHERE a.student_user_id=? AND a.status='COMPLETED' AND d.type_code='HOLLAND_RIASEC' ORDER BY a.completed_at DESC,a.id DESC LIMIT 1",student,null,RIASEC);}
private Map<String,BigDecimal> latestAbility(Long student,LocalDateTime now){return latest("SELECT r.dimension_scores_json FROM ability_results r JOIN ability_attempts a ON a.id=r.attempt_id JOIN ability_definitions d ON d.id=a.definition_id WHERE a.student_user_id=? AND a.status='COMPLETED' AND d.type_code='WORKPLACE_ABILITY' AND r.valid_until>? ORDER BY a.completed_at DESC,a.id DESC LIMIT 1",student,now,ABILITY);}
private Map<String,BigDecimal> latest(String sql,Long student,LocalDateTime now,Set<String> expected){List<String> rows=now==null?jdbcTemplate.query(sql,(rs,n)->rs.getString(1),student):jdbcTemplate.query(sql,(rs,n)->rs.getString(1),student,now);return rows.isEmpty()?Map.of():scoreEntries(rows.get(0),expected);}
private Map<String,BigDecimal> scoreEntries(String raw,Set<String> expected){try{List<Map<String,Object>> a=JSON.readValue(raw,new TypeReference<>(){});Map<String,BigDecimal>out=new HashMap<>();for(Map<String,Object> e:a){Object key=e.getOrDefault("code",e.get("dimension"));Object score=e.get("score");BigDecimal value=score==null?null:new BigDecimal(String.valueOf(score));if(!(key instanceof String)||value==null||value.compareTo(BigDecimal.ZERO)<0||value.compareTo(BigDecimal.valueOf(100))>0||out.put((String)key,value)!=null)return Map.of();}return out.keySet().equals(expected)?out:Map.of();}catch(Exception e){return Map.of();}}
private Map<String,BigDecimal> mapDecimal(String raw){try{if(raw==null)return Map.of();Map<String,Object>m=JSON.readValue(raw,new TypeReference<>(){});Map<String,BigDecimal>o=new HashMap<>();for(var e:m.entrySet())o.put(e.getKey(),new BigDecimal(String.valueOf(e.getValue())));return o;}catch(Exception e){return Map.of();}}
private List<String> list(String raw){try{return raw==null?List.of():JSON.readValue(raw,new TypeReference<List<String>>(){});}catch(Exception e){return List.of();}}
private String write(Object o){try{return o==null?null:JSON.writeValueAsString(o);}catch(Exception e){throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}}
private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);}
private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}
private void assertCompanyOwns(Long user, Long job) {
Company company = companyService.findCompanyByOwnerUserId(user);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
VerifiedCompanyGuard.requireVerified(company);
jobService.assertOwnerOrThrow(company.getId(), job);
}
private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);}
private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();}
private Map<String,Integer> mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference<Map<String,Integer>>(){});}catch(Exception e){return Map.of();}}
private RecommendationFeedbackResponse feedbackView(StudentJobRecommendationFeedback f){return RecommendationFeedbackResponse.builder().jobId(f.getJobId()).feedbackType(f.getFeedbackType()).reasonType(f.getReasonType()).status(f.getStatus()).version(f.getVersion()).build();}
}