轻松聘AI开发
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.
 
 
 

313 lines
15 KiB

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
*
* <p>权限:仅简历本人(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)。
*
* <p>孤儿文件防护:版本行由 {@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<ResumeVersion> 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;
}
// ── 修改 ──────────────────────────────────────────────────
/**
* 修改版本:可选重命名 + 可选替换文件。
*
* <p>文件一致性:先保存新文件 → 更新数据库 → 事务提交后删除旧文件。
* 保存新文件后注册 {@link TransactionSynchronization}:
* <ul>
* <li>afterCommit:旧文件未被 PENDING/RUNNING 导出任务引用时才删除;</li>
* <li>afterCompletion(非 COMMITTED):事务回滚(含提交阶段失败)时清理本次刚保存的新文件,
* 防止孤儿文件;绝不删除旧文件(数据库仍引用旧文件)。</li>
* </ul>
* 立即异常(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 导出任务。
*
* <p>文件一致性:先完成数据库删除,仅在事务成功提交后删除存储文件;
* 数据库删除失败时不得删除原文件。
*/
@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<String, Object> toView(ResumeVersion version) {
Map<String, Object> 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<Map<String, Object>> toViewList(List<ResumeVersion> 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;
}
/**
* 注册文件生命周期事务回调(替换版本文件场景):
* <ul>
* <li>afterCommit:旧文件未被 PENDING/RUNNING 导出任务引用时删除;</li>
* <li>afterCompletion(非 COMMITTED):清理本次刚保存的新文件(覆盖提交阶段失败导致的外层回滚),
* 绝不删除旧文件(数据库仍引用旧文件)。</li>
* </ul>
* 返回是否注册成功;非事务上下文(单元测试)返回 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);
}
}
}