package com.mydreamjob.onlineresume; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.*; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.common.exception.*; import com.mydreamjob.preference.*; import com.mydreamjob.profile.*; import com.mydreamjob.security.UserRoleGuard; import jakarta.persistence.*; import jakarta.validation.*; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.*; import java.util.function.Function; @Service @RequiredArgsConstructor public class OnlineResumeService { private final OnlineResumeRepository resumes; private final UserRepository users; private final StudentProfileRepository profiles; private final StudentPreferenceRepository preferences; private final CandidateScoringRefreshRequestService refresh; private final UserRoleGuard roles; private final ObjectMapper mapper; private final Validator validator; private final OnlineResumeWriteGuard writeGuard; @Transactional(readOnly = true) public OnlineResumeView get(Long userId) { roles.requireStudent(userId); User u = users.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); return view(u, resumes.findById(userId).orElse(null)); } @Transactional public OnlineResumeView save(Long userId, OnlineResumeSaveRequest request) { User u = writeGuard.lockStudent(userId); OnlineResume row = writeGuard.lockResume(userId); OnlineResumeView current = view(u, row); checkRevision(current.revision(), request.revision()); return persist(u, row, request.content(), current.content()); } /** 四种条目共用整份简历 revision,避免局部编辑与整份保存互相覆盖。 */ @Transactional public OnlineResumeView editEntry(Long userId, String section, String id, Long revision, JsonNode entry, boolean delete) { User u = writeGuard.lockStudent(userId); OnlineResume row = writeGuard.lockResume(userId); OnlineResumeView current = view(u, row); checkRevision(current.revision(), revision); OnlineResumeContent content = copy(current.content()); switch (section) { case "education-experiences" -> edit(content.getEducationExperiences(), id, entry, delete, OnlineResumeContent.Education.class, OnlineResumeContent.Education::getId, OnlineResumeContent.Education::setId); case "work-experiences" -> edit(content.getWorkExperiences(), id, entry, delete, OnlineResumeContent.Work.class, OnlineResumeContent.Work::getId, OnlineResumeContent.Work::setId); case "project-experiences" -> edit(content.getProjectExperiences(), id, entry, delete, OnlineResumeContent.Project.class, OnlineResumeContent.Project::getId, OnlineResumeContent.Project::setId); case "skills" -> edit(content.getSkills(), id, entry, delete, OnlineResumeContent.Skill.class, OnlineResumeContent.Skill::getId, OnlineResumeContent.Skill::setId); default -> throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND); } return persist(u, row, content, current.content()); } private void edit(List list, String id, JsonNode json, boolean delete, Class type, Function getId, java.util.function.BiConsumer setId) { int index = -1; for (int i = 0; i < list.size(); i++) if (Objects.equals(getId.apply(list.get(i)), id)) index = i; if (id != null && index < 0) throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND); if (delete) { list.remove(index); return; } T value; try { value = mapper.readerFor(type).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(json); } catch (java.io.IOException | IllegalArgumentException e) { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid resume entry"); } if (value == null || getId.apply(value) != null && !Objects.equals(getId.apply(value), id)) throw new BusinessException(ErrorCode.BAD_REQUEST, "entry id must match the URL; new entries omit id"); setId.accept(value, id); if (index < 0) list.add(value); else list.set(index, value); } private void checkRevision(long actual, Long expected) { if (expected == null || actual != expected) throw new BusinessException(ErrorCode.ONLINE_RESUME_REVISION_CONFLICT); } private OnlineResumeView persist(User user, OnlineResume row, OnlineResumeContent content, OnlineResumeContent before) { content = copy(content); validate(content); Set skillNames = new HashSet<>(); for (var skill : content.getSkills()) { skill.setName(skill.getName().trim()); if (!skillNames.add(skill.getName())) throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate skill name"); } assignIds(content.getEducationExperiences(), before.getEducationExperiences(), OnlineResumeContent.Education::getId, OnlineResumeContent.Education::setId); assignIds(content.getWorkExperiences(), before.getWorkExperiences(), OnlineResumeContent.Work::getId, OnlineResumeContent.Work::setId); assignIds(content.getProjectExperiences(), before.getProjectExperiences(), OnlineResumeContent.Project::getId, OnlineResumeContent.Project::setId); assignIds(content.getSkills(), before.getSkills(), OnlineResumeContent.Skill::getId, OnlineResumeContent.Skill::setId); String json = encode(content); if (json.getBytes(StandardCharsets.UTF_8).length > 1024 * 1024) throw new BusinessException(ErrorCode.BAD_REQUEST, "resume is too large"); rejectControlCharacters(mapper.valueToTree(content)); Long uid = user.getId(); StudentProfile p = profiles.lockByUserId(uid).orElseGet(() -> StudentProfile.builder().userId(uid).build()); var basic = content.getBasicInfo(); p.setRealName(basic.getRealName()); p.setLocation(basic.getLocation()); p.setEducation(basic.getEducation()); p.setSchool(basic.getSchool()); p.setGraduationYear(basic.getGraduationYear() == null ? null : basic.getGraduationYear().shortValue()); p.setBio(content.getSelfEvaluation()); p.setSkillsJson(encode(content.getSkills().stream().map(OnlineResumeContent.Skill::getName).toList())); // 不触碰 resumeUrl、原始文件名或文件内容;也不改变账号手机号/邮箱。 profiles.save(p); StudentPreference pref = preferences.findByStudentUserId(uid).orElseGet(() -> StudentPreference.builder().studentUserId(uid).build()); var intention = content.getJobIntention(); pref.setExpectedSalaryMin(intention.getExpectedSalaryMin()); pref.setExpectedSalaryMax(intention.getExpectedSalaryMax()); pref.setPreferredCities(encode(intention.getPreferredCities())); pref.setEmploymentTypes(encode(intention.getEmploymentTypes())); pref.setPreferredIndustries(encode(intention.getPreferredIndustries())); pref.setPreferredJobTypes(encode(intention.getPreferredJobTypes())); pref.setRemoteAllowed(intention.isRemoteAllowed()); preferences.save(pref); if (row == null) { row = new OnlineResume(); row.setUserId(uid); } row.setContentJson(json); row.setRevision(Math.addExact(row.getRevision(), 1)); row.setUpdatedAt(LocalDateTime.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS)); resumes.saveAndFlush(row); refresh.requestRefresh(uid, LocalDateTime.now()); return view(user, row); } private void assignIds(List next, List previous, Function getId, java.util.function.BiConsumer setId) { Set allowed = new HashSet<>(); previous.forEach(x -> allowed.add(getId.apply(x))); Set used = new HashSet<>(); for (T value : next) { String id = getId.apply(value); if (id == null) { id = UUID.randomUUID().toString(); setId.accept(value, id); } else if (!allowed.contains(id)) throw new BusinessException(ErrorCode.BAD_REQUEST, "unknown resume entry id"); if (!used.add(id)) throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate resume entry id"); } } private OnlineResumeView view(User user, OnlineResume row) { OnlineResumeContent c = row == null ? new OnlineResumeContent() : decode(row.getContentJson()); var b = c.getBasicInfo(); if (row == null) { b.setPhone(user.getPhone()); b.setEmail(user.getEmail()); } StudentProfile p = profiles.findByUserId(user.getId()).orElse(null); if (p != null) { b.setRealName(p.getRealName()); b.setLocation(p.getLocation()); b.setSchool(p.getSchool()); b.setEducation(p.getEducation()); b.setGraduationYear(p.getGraduationYear() == null ? null : p.getGraduationYear().intValue()); c.setSelfEvaluation(p.getBio()); // 旧资料接口仍可编辑基础技能;按名字保留在线简历的扩展信息。 List names = legacySkills(p.getSkillsJson()); Map details = new LinkedHashMap<>(); c.getSkills().forEach(s -> details.putIfAbsent(s.getName(), s)); List skills = new ArrayList<>(); for (int i = 0; i < names.size(); i++) { String name = names.get(i); OnlineResumeContent.Skill s = details.get(name); if (s == null) { s = new OnlineResumeContent.Skill(); s.setName(name); s.setId(UUID.nameUUIDFromBytes((user.getId() + ":" + i + ":" + name).getBytes(StandardCharsets.UTF_8)).toString()); } skills.add(s); } c.setSkills(skills); } var intention = c.getJobIntention(); preferences.findByStudentUserId(user.getId()).ifPresent(pref -> { intention.setExpectedSalaryMin(pref.getExpectedSalaryMin()); intention.setExpectedSalaryMax(pref.getExpectedSalaryMax()); intention.setPreferredCities(legacySkills(pref.getPreferredCities())); intention.setEmploymentTypes(legacySkills(pref.getEmploymentTypes())); intention.setPreferredIndustries(legacySkills(pref.getPreferredIndustries())); intention.setPreferredJobTypes(legacySkills(pref.getPreferredJobTypes())); intention.setRemoteAllowed(Boolean.TRUE.equals(pref.getRemoteAllowed())); }); OnlineResumeView.Attachment attachment = p == null || p.getResumeUrl() == null ? null : new OnlineResumeView.Attachment(p.getResumeOriginalName(), p.getResumeContentType(), p.getResumeSize(), "/api/v1/profile/me/resume"); return new OnlineResumeView(row == null ? 0 : row.getRevision(), c, attachment, row == null ? null : row.getUpdatedAt()); } private List legacySkills(String json) { if (json == null || json.isBlank()) return new ArrayList<>(); try { // H2 的历史 JSON 列可将 Java String 再包装为 JSON 字符串;同时兼容真实 MySQL 数组。 JsonNode node = mapper.readTree(json); for (int depth = 0; depth < 3 && node != null && node.isTextual(); depth++) node = mapper.readTree(node.textValue()); if (node == null || node.isNull()) return new ArrayList<>(); if (node != null && node.isArray()) { List values = new ArrayList<>(); for (JsonNode item : node) { if (!item.isTextual()) throw new IllegalArgumentException("not a string array"); values.add(item.textValue()); } return values; } } catch (JsonProcessingException | IllegalArgumentException ignored) { /* 兼容历史 [Java, SQL],不静默丢失资料。 */ } String text = json.trim(); if (text.startsWith("[") && text.endsWith("]")) text = text.substring(1, text.length() - 1); return new ArrayList<>(Arrays.stream(text.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList()); } private void validate(OnlineResumeContent content) { Set> errors = validator.validate(content); if (!errors.isEmpty()) throw new ConstraintViolationException(errors); } private void rejectControlCharacters(JsonNode node) { if (node.isTextual()) { if (node.textValue().codePoints().anyMatch(c -> c < 32 && c != 9 && c != 10 && c != 13 || c == 0xfffe || c == 0xffff)) throw new BusinessException(ErrorCode.BAD_REQUEST, "unsupported control character in resume"); } else node.forEach(this::rejectControlCharacters); } private String encode(Object value) { try { return mapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("cannot encode online resume", e); } } private OnlineResumeContent decode(String json) { try { return mapper.readValue(json, OnlineResumeContent.class); } catch (JsonProcessingException e) { throw new IllegalStateException("cannot decode online resume", e); } } private OnlineResumeContent copy(OnlineResumeContent content) { return decode(encode(content)); } }