forked from baskbuddy/zhangshun
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
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);
|
|
}
|
|
}
|
|
}
|
|
|