package com.mydreamjob.resume; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.profile.ProfileService; import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeStorageService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * 简历版本管理 Service * *
权限:仅简历本人(JWT 身份)可操作,身份一律不信任请求参数。 * 文件校验复用 {@link ProfileService#validateResumeFile},存储复用 {@link ResumeStorageService}。 */ @Slf4j @Service @RequiredArgsConstructor public class ResumeVersionService { private static final int MAX_NAME_LENGTH = 120; private static final int MAX_VERSION_NO_RETRY = 3; private final ResumeVersionRepository resumeVersionRepository; private final ResumeExportTaskRepository exportTaskRepository; private final ResumeStorageService resumeStorageService; private final ResumeVersionWriter versionWriter; private final ProfileService profileService; // ── 创建 ────────────────────────────────────────────────── /** * 创建简历版本(默认非默认)。版本号每用户递增(max+1), * 并发冲突由 {@link ResumeVersionWriter} 独立事务重试(冲突只回滚单次尝试)。 * 外层事务为角色校验提供持久化上下文(worker 线程无 OSIV)。 * *
孤儿文件防护:版本行由 {@code REQUIRES_NEW} 独立事务写入——
* 写入成功即独立提交,外层事务回滚不会让版本行消失,文件始终被引用;
* 写入失败(重试耗尽或异常)时版本行不存在,由下方 catch 清理已保存的新文件。
* 因此创建流程不存在"外层回滚导致文件孤儿"的问题。
*/
@Transactional
public ResumeVersion createVersion(Long userId, String name, MultipartFile file) {
profileService.assertStudentRole(userId);
validateName(name);
profileService.validateResumeFile(file);
String storagePath = resumeStorageService.store(userId, file);
try {
for (int attempt = 0; attempt < MAX_VERSION_NO_RETRY; attempt++) {
try {
return versionWriter.saveNewVersion(userId, normalizeName(name), storagePath,
ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"),
file.getContentType(), file.getSize());
} catch (DataIntegrityViolationException e) {
// uk_resume_versions_user_no 并发冲突 → 短退避后重试
log.info("Resume version_no conflict for user {}, retry {}/{}", userId, attempt + 1, MAX_VERSION_NO_RETRY);
try {
Thread.sleep(20L); // 等待并发胜者提交,重读时能看到其版本号
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new BusinessException(ErrorCode.BAD_REQUEST, "failed to assign resume version number");
}
}
}
throw new BusinessException(ErrorCode.BAD_REQUEST, "failed to assign resume version number");
} catch (RuntimeException e) {
// 数据库写入最终失败(重试耗尽或异常)→ 清理已保存但未被引用的新文件,避免孤儿文件
log.warn("Resume version create failed, cleaning orphan file: {}", storagePath);
resumeStorageService.delete(storagePath);
throw e;
}
}
// ── 查询 ──────────────────────────────────────────────────
/** 本人 ACTIVE 版本列表,version_no 倒序 */
public List 文件一致性:先保存新文件 → 更新数据库 → 事务提交后删除旧文件。
* 保存新文件后注册 {@link TransactionSynchronization}:
* 文件一致性:先完成数据库删除,仅在事务成功提交后删除存储文件;
* 数据库删除失败时不得删除原文件。
*/
@Transactional
public void deleteVersion(Long userId, Long versionId) {
profileService.assertStudentRole(userId);
ResumeVersion version = requireOwnActiveVersion(userId, versionId);
if (version.isDefault()) {
throw new BusinessException(ErrorCode.DEFAULT_RESUME_DELETE_NOT_ALLOWED);
}
if (exportTaskRepository.existsByResumeVersionIdAndStatusIn(versionId,
List.of(ResumeExportTask.STATUS_PENDING, ResumeExportTask.STATUS_RUNNING))) {
throw new BusinessException(ErrorCode.RESUME_EXPORT_IN_PROGRESS);
}
// 1) 先完成数据库删除
resumeVersionRepository.delete(version);
// 2) 事务提交后再删除存储文件(提交失败则文件保留)
deleteFileAfterCommit(version.getFileStoragePath());
}
// ── 设置默认 ──────────────────────────────────────────────
/**
* 设为默认:同事务内清空旧默认并设置新默认;已默认幂等;并发冲突 409。
*/
@Transactional
public ResumeVersion setDefault(Long userId, Long versionId) {
profileService.assertStudentRole(userId);
ResumeVersion version = requireOwnActiveVersion(userId, versionId);
if (version.isDefault()) {
return version; // 幂等
}
resumeVersionRepository.findFirstByUserIdAndDefaultKey(userId, ResumeVersion.DEFAULT_KEY)
.ifPresent(old -> old.setDefaultKey(null));
version.setDefaultKey(ResumeVersion.DEFAULT_KEY);
try {
return resumeVersionRepository.save(version);
} catch (DataIntegrityViolationException e) {
// uk_resume_versions_default 并发冲突:两个版本同时被设为默认
throw new BusinessException(ErrorCode.DUPLICATE_DEFAULT_RESUME);
}
}
// ── 视图 ──────────────────────────────────────────────────
public Map
*
* 立即异常(repository.save 抛错)仅在非事务上下文(单元测试)直接清理新文件,
* 事务上下文中统一由 afterCompletion 处理,避免重复删除。
*/
@Transactional
public ResumeVersion updateVersion(Long userId, Long versionId, String name, MultipartFile file) {
profileService.assertStudentRole(userId);
validateName(name);
ResumeVersion version = requireOwnActiveVersion(userId, versionId);
if (name != null) {
version.setName(normalizeName(name));
}
String oldStoragePath = version.getFileStoragePath();
String newStoragePath = null;
boolean syncRegistered = false;
if (file != null && !file.isEmpty()) {
profileService.validateResumeFile(file);
// 1) 先保存新文件(绝不在保存前删除旧文件)
newStoragePath = resumeStorageService.store(userId, file);
version.setFileStoragePath(newStoragePath);
version.setFileName(ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"));
version.setContentType(file.getContentType());
version.setFileSize(file.getSize());
// 2) 注册事务回调:提交后删旧文件;回滚时清理新文件
syncRegistered = registerFileLifecycleSynchronization(oldStoragePath, newStoragePath);
}
try {
// 3) 更新数据库记录
version = resumeVersionRepository.save(version);
} catch (RuntimeException e) {
// 立即失败:仅在未注册事务回调(非事务上下文/单元测试)时直接清理新文件;
// 事务上下文中由 afterCompletion(非 COMMITTED) 统一清理,避免重复删除。
if (newStoragePath != null && !syncRegistered) {
log.warn("Resume version update failed, cleaning new file: {}", newStoragePath);
resumeStorageService.delete(newStoragePath);
}
throw e;
}
if (newStoragePath != null && oldStoragePath != null && !syncRegistered) {
// 非事务上下文(单元测试):无 afterCommit 回调,立即执行旧文件清理
deleteOldFileIfUnreferenced(oldStoragePath);
}
return version;
}
// ── 删除 ──────────────────────────────────────────────────
/**
* 删除版本(硬删除)。前置:非默认版本;无 PENDING/RUNNING 导出任务。
*
*