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 RIASEC = Set.of("R","I","A","S","E","C"); private static final Set ABILITY = Set.of("LOGIC","COMMUNICATION","EXECUTION","LEARNING"); private static final Set FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL"); private static final Set DECISIONS = Set.of("APPROVE", "REJECT"); private static final Set 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 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 adminProfiles(Long adminId, int page, int size, String status) { userRoleGuard.requireAdmin(adminId); validatePage(page, size); Page 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 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 recommend(Long studentId, int page, int size) { userRoleGuard.requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now(); Set applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId)); Set 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 riasec = latestRiasec(studentId); Map ability = latestAbility(studentId, now); if (riasec.isEmpty() && ability.isEmpty() && pref == null) return fallback(page, size, applied, hidden, null); List jobs = jobRepository.findAllById(profileRepository.findByReviewStatusAndEnabledTrue(JobRecommendationProfile.APPROVED).stream().map(JobRecommendationProfile::getJobId).toList()); Map jobsById = jobs.stream().collect(Collectors.toMap(Job::getId, j -> j)); List 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 riasec, Map ability, Set applied, Set hidden) { if (j == null || !"PUBLISHED".equals(j.getStatus()) || applied.contains(j.getId()) || hidden.contains(j.getId()) || !hardPreferenceMatch(pref, j)) return null; List components = new ArrayList<>(); List weights = new ArrayList<>(); List 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 fallback(int page,int size,Set applied,Set hidden,StudentPreference pref) { List 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 Page slice(List 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 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 scores,Map expected,Set 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 scores,Map 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 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 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 latest(String sql,Long student,LocalDateTime now,Set expected){List 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 scoreEntries(String raw,Set expected){try{List> a=JSON.readValue(raw,new TypeReference<>(){});Mapout=new HashMap<>();for(Map 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 mapDecimal(String raw){try{if(raw==null)return Map.of();Mapm=JSON.readValue(raw,new TypeReference<>(){});Mapo=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 list(String raw){try{return raw==null?List.of():JSON.readValue(raw,new TypeReference>(){});}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 r,Mapa){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 mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference>(){});}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();} }