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.
225 lines
14 KiB
225 lines
14 KiB
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 <T> void edit(List<T> list, String id, JsonNode json, boolean delete, Class<T> type,
|
|
Function<T, String> getId, java.util.function.BiConsumer<T, String> 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<String> 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 <T> void assignIds(List<T> next, List<T> previous, Function<T, String> getId,
|
|
java.util.function.BiConsumer<T, String> setId) {
|
|
Set<String> allowed = new HashSet<>(); previous.forEach(x -> allowed.add(getId.apply(x)));
|
|
Set<String> 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<String> names = legacySkills(p.getSkillsJson());
|
|
Map<String, OnlineResumeContent.Skill> details = new LinkedHashMap<>();
|
|
c.getSkills().forEach(s -> details.putIfAbsent(s.getName(), s));
|
|
List<OnlineResumeContent.Skill> 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<String> 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<String> 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<ConstraintViolation<OnlineResumeContent>> 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)); }
|
|
}
|
|
|