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 listVersions(Long userId) { profileService.assertStudentRole(userId); return resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(userId, ResumeVersion.STATUS_ACTIVE); } /** 本人版本详情(ACTIVE) */ public ResumeVersion getVersion(Long userId, Long versionId) { profileService.assertStudentRole(userId); ResumeVersion version = requireOwnActiveVersion(userId, versionId); return version; } // ── 修改 ────────────────────────────────────────────────── /** * 修改版本:可选重命名 + 可选替换文件。 * *

文件一致性:先保存新文件 → 更新数据库 → 事务提交后删除旧文件。 * 保存新文件后注册 {@link TransactionSynchronization}: *

* 立即异常(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 导出任务。 * *

文件一致性:先完成数据库删除,仅在事务成功提交后删除存储文件; * 数据库删除失败时不得删除原文件。 */ @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 toView(ResumeVersion version) { Map v = new HashMap<>(); v.put("id", version.getId()); v.put("name", version.getName()); v.put("versionNo", version.getVersionNo()); v.put("fileName", version.getFileName()); v.put("contentType", version.getContentType()); v.put("fileSize", version.getFileSize()); v.put("default", version.isDefault()); v.put("status", version.getStatus()); v.put("createdAt", version.getCreatedAt()); v.put("updatedAt", version.getUpdatedAt()); return v; } public List> toViewList(List versions) { return versions.stream().map(this::toView).collect(Collectors.toList()); } // ── 私有 ────────────────────────────────────────────────── private ResumeVersion requireOwnActiveVersion(Long userId, Long versionId) { // 先按 id 查询:不存在或非 ACTIVE → 404;存在但属于他人 → 403;本人 → 继续 ResumeVersion version = resumeVersionRepository.findById(versionId) .orElseThrow(() -> new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND)); if (!ResumeVersion.STATUS_ACTIVE.equals(version.getStatus())) { throw new BusinessException(ErrorCode.RESUME_VERSION_NOT_FOUND); } if (!version.getUserId().equals(userId)) { throw new BusinessException(ErrorCode.FORBIDDEN); } return version; } private void validateName(String name) { if (name != null && name.trim().length() > MAX_NAME_LENGTH) { throw new BusinessException(ErrorCode.BAD_REQUEST, "name must be at most " + MAX_NAME_LENGTH + " characters"); } } private String normalizeName(String name) { if (name == null) return null; String trimmed = name.trim(); return trimmed.isEmpty() ? null : trimmed; } /** * 注册文件生命周期事务回调(替换版本文件场景): *

* 返回是否注册成功;非事务上下文(单元测试)返回 false,由调用方即时清理。 */ private boolean registerFileLifecycleSynchronization(String oldStoragePath, String newStoragePath) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { return false; } TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { deleteOldFileIfUnreferenced(oldStoragePath); } @Override public void afterCompletion(int status) { if (status != TransactionSynchronization.STATUS_COMMITTED && newStoragePath != null) { log.warn("Resume version update rolled back, cleaning new file: {}", newStoragePath); resumeStorageService.delete(newStoragePath); } } }); return true; } /** 旧文件未被进行中的导出任务引用时删除(保持导出快照一致) */ private void deleteOldFileIfUnreferenced(String oldStoragePath) { if (oldStoragePath == null) return; boolean referenced = exportTaskRepository.existsBySourceStoragePathAndStatusIn(oldStoragePath, List.of(ResumeExportTask.STATUS_PENDING, ResumeExportTask.STATUS_RUNNING)); if (!referenced) { resumeStorageService.delete(oldStoragePath); } } /** * 事务成功提交后删除存储文件;非事务上下文(单元测试)直接删除。 * 数据库失败/回滚时不会执行删除,保证文件与数据库一致。 */ private void deleteFileAfterCommit(String storagePath) { if (storagePath == null) return; if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { resumeStorageService.delete(storagePath); } }); } else { resumeStorageService.delete(storagePath); } } }