diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index a7f2a0c..c25ae04 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -447,6 +447,8 @@ Authorization: Bearer - 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。 - 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。 +- 上传仍仅接受 PDF/DOC/DOCX 且不超过 10MB;服务端会同时校验扩展名、Content-Type 和实际文件结构。伪装扩展名、损坏文件、危险 DOCX 压缩包或含路径/控制字符的文件名返回 400,前端不要仅依据浏览器 MIME 判断文件有效。 +- 上传替换失败时原简历保持可用;成功提交后旧文件才会清理。该流程不改变现有请求字段或响应结构。 ### 12.12 graduationYear 特别说明 diff --git a/docs/resume-api.md b/docs/resume-api.md index e27a598..17534d0 100644 --- a/docs/resume-api.md +++ b/docs/resume-api.md @@ -48,7 +48,7 @@ POST /profile/me/resume-versions (multipart/form-data) | 字段 | 必填 | 约束 | |---|---|---| -| `file` | 是 | PDF/DOC/DOCX,≤10MB,扩展名与 Content-Type 一致 | +| `file` | 是 | PDF/DOC/DOCX,≤10MB;扩展名、Content-Type 与实际文件结构必须一致 | | `name` | 否 | ≤120 字符,可空 | 默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。 @@ -157,6 +157,10 @@ GET /resume-export-tasks/{taskId}/download - 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**; - 不能读取其他用户的简历(owner 校验); - 不返回内部堆栈;错误响应不泄露数据库结构。 +- 上传文件名拒绝路径分隔符、控制字符和响应头换行;下载历史文件时再次净化文件名。 +- PDF 校验文件头和结尾标记;DOC 校验 OLE/Word 文档结构标记;DOCX 校验 OOXML 必需条目,并限制 ZIP 条目数、解压总量和压缩比,拒绝路径穿越及压缩炸弹。 +- 文件替换采用“新文件落盘 → 数据库更新 → 事务提交后删除旧文件”;回滚时清理新文件。底层本地存储先写 `.uploading` 临时文件,再原子移动到最终随机存储名,失败不暴露半文件。 +- 上述措施属于格式与资源安全校验,**不等同于病毒查杀或沙箱检测**;生产部署仍应在存储接入层配置恶意文件扫描。 ## 5. 异步导出设计 @@ -199,6 +203,7 @@ GET /resume-export-tasks/{taskId}/download 4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。 5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。 6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。 +7. **不包含恶意软件扫描**:当前只执行文件格式、OOXML 结构、路径和资源消耗防护;不对宏、嵌入对象或恶意载荷作杀毒结论。 ## 10. 错误码(新增 82xx) @@ -210,4 +215,3 @@ GET /resume-export-tasks/{taskId}/download | 8204 | `RESUME_EXPORT_IN_PROGRESS` | 409 | 导出任务进行中,不可删除版本 | | 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 | | 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 | - diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java index 4a5a8c3..4262ba1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java @@ -2,6 +2,7 @@ package com.mydreamjob.profile; import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.common.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -115,7 +116,7 @@ public class ProfileController { ProfileService.ResumeFile rf = profileService.getResume(userId); MediaType mediaType = resolveContentType(rf.getContentType()); ContentDisposition cd = ContentDisposition.inline() - .filename(rf.getFilename(), StandardCharsets.UTF_8) + .filename(ResumeFilenameSanitizer.forDownload(rf.getFilename(), "resume"), StandardCharsets.UTF_8) .build(); return ResponseEntity.ok() .contentType(mediaType) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index f1345bd..996f12d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java @@ -6,6 +6,8 @@ import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.profile.dto.StudentProfileResponse; import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; +import com.mydreamjob.profile.storage.ResumeFileValidator; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeStorageService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; @@ -14,12 +16,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; 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.Set; @Slf4j @Service @@ -29,22 +32,10 @@ public class ProfileService { private final StudentProfileRepository profileRepository; private final ResumeStorageService resumeStorageService; private final UserRepository userRepository; + private final ResumeFileValidator resumeFileValidator; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final Set ALLOWED_CONTENT_TYPES = Set.of( - "application/pdf", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ); - private static final Set ALLOWED_EXTENSIONS = Set.of(".pdf", ".doc", ".docx"); - private static final Map EXT_TO_CONTENT_TYPE = Map.of( - ".pdf", "application/pdf", - ".doc", "application/msword", - ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ); - private static final long MAX_RESUME_SIZE = 10 * 1024 * 1024L; - // ── 角色校验 ────────────────────────────────────────────── public void assertStudentRole(Long userId) { @@ -94,32 +85,7 @@ public class ProfileService { // ── 简历校验 ────────────────────────────────────────────── public void validateResumeFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - if (file.getSize() > MAX_RESUME_SIZE) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - - String originalName = file.getOriginalFilename(); - if (originalName == null || !originalName.contains(".")) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - String ext = originalName.substring(originalName.lastIndexOf('.')).toLowerCase(); - if (!ALLOWED_EXTENSIONS.contains(ext)) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - - String contentType = file.getContentType(); - if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } - - // 验证扩展名与 Content-Type 一致 - String expectedContentType = EXT_TO_CONTENT_TYPE.get(ext); - if (!contentType.equals(expectedContentType)) { - throw new BusinessException(ErrorCode.BAD_REQUEST); - } + resumeFileValidator.validate(file); } // ── 简历上传 ────────────────────────────────────────────── @@ -132,17 +98,27 @@ public class ProfileService { StudentProfile profile = profileRepository.findByUserId(userId) .orElse(StudentProfile.builder().userId(userId).build()); - // 删除旧文件 - if (profile.getResumeUrl() != null) { - resumeStorageService.delete(profile.getResumeUrl()); - } - + String oldPath = profile.getResumeUrl(); String newPath = resumeStorageService.store(userId, file); + if (newPath == null || newPath.isBlank()) { + throw new IllegalStateException("resume storage returned no path"); + } + boolean synchronizationRegistered = registerReplacementLifecycle(oldPath, newPath); profile.setResumeUrl(newPath); - profile.setResumeOriginalName(file.getOriginalFilename()); + profile.setResumeOriginalName(ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume")); profile.setResumeContentType(file.getContentType()); profile.setResumeSize(file.getSize()); - profileRepository.save(profile); + try { + profileRepository.save(profile); + } catch (RuntimeException e) { + if (!synchronizationRegistered && !newPath.equals(oldPath)) { + resumeStorageService.delete(newPath); + } + throw e; + } + if (!synchronizationRegistered && oldPath != null && !oldPath.equals(newPath)) { + resumeStorageService.delete(oldPath); + } return toResumeView(profile); } @@ -170,12 +146,13 @@ public class ProfileService { StudentProfile profile = profileRepository.findByUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); if (profile.getResumeUrl() != null) { - resumeStorageService.delete(profile.getResumeUrl()); + String storagePath = profile.getResumeUrl(); profile.setResumeUrl(null); profile.setResumeOriginalName(null); profile.setResumeContentType(null); profile.setResumeSize(null); profileRepository.save(profile); + deleteFileAfterCommit(storagePath); } } @@ -242,4 +219,39 @@ public class ProfileService { this.content = content; } } + + private boolean registerReplacementLifecycle(String oldPath, String newPath) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + return false; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + if (oldPath != null && !oldPath.equals(newPath)) { + resumeStorageService.delete(oldPath); + } + } + + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_COMMITTED && !newPath.equals(oldPath)) { + resumeStorageService.delete(newPath); + } + } + }); + return true; + } + + private void deleteFileAfterCommit(String storagePath) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + resumeStorageService.delete(storagePath); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + resumeStorageService.delete(storagePath); + } + }); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java index 17d8b8f..2d84fa2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java @@ -49,15 +49,27 @@ public class LocalResumeStorageService implements ResumeStorageService { String ext = getExtension(originalName); String storedName = userId + "_" + UUID.randomUUID().toString().substring(0, 8) + ext; Path target = basePath.resolve(storedName).normalize(); + Path temporary = basePath.resolve(storedName + ".uploading").normalize(); // 禁止路径穿越 - if (!target.startsWith(basePath)) { + if (!target.startsWith(basePath) || !temporary.startsWith(basePath)) { throw new BusinessException(ErrorCode.BAD_REQUEST); } try { - Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + // Never expose a partially copied file under its final storage key. + Files.copy(file.getInputStream(), temporary, StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } } catch (IOException e) { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupFailure) { + e.addSuppressed(cleanupFailure); + } throw new RuntimeException("Failed to store resume", e); } log.info("Resume stored: userId={}, path={}", userId, storedName); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java new file mode 100644 index 0000000..b697e4b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java @@ -0,0 +1,216 @@ +package com.mydreamjob.profile.storage; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipInputStream; + +/** + * 简历上传白名单校验器。 + * + *

扩展名和客户端 Content-Type 只作为第一层校验;随后检查文件实际签名。 + * DOCX 还会验证必要的 Office Open XML 条目,并限制条目数、解压总量和压缩比, + * 避免将任意 ZIP、路径穿越条目或压缩炸弹当作简历保存。

+ * + *

本校验不等同于恶意软件扫描。生产环境如需病毒查杀,应在独立安全组件中完成。

+ */ +@Component +public class ResumeFileValidator { + + static final long MAX_FILE_SIZE = 10L * 1024 * 1024; + static final int MAX_DOCX_ENTRIES = 1_024; + static final long MAX_DOCX_UNCOMPRESSED_BYTES = 40L * 1024 * 1024; + static final long MAX_DOCX_COMPRESSION_RATIO = 100L; + private static final int BUFFER_SIZE = 8 * 1024; + + private static final Set ALLOWED_EXTENSIONS = Set.of(".pdf", ".doc", ".docx"); + private static final Map EXT_TO_CONTENT_TYPE = Map.of( + ".pdf", "application/pdf", + ".doc", "application/msword", + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + + private static final byte[] PDF_SIGNATURE = {'%', 'P', 'D', 'F', '-'}; + private static final byte[] OLE_SIGNATURE = { + (byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, + (byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 + }; + private static final byte[] WORD_DOCUMENT_UTF16_LE = { + 'W', 0, 'o', 0, 'r', 0, 'd', 0, 'D', 0, 'o', 0, 'c', 0, + 'u', 0, 'm', 0, 'e', 0, 'n', 0, 't', 0 + }; + + public void validate(MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() <= 0 || file.getSize() > MAX_FILE_SIZE) { + reject(); + } + + String originalName = file.getOriginalFilename(); + ResumeFilenameSanitizer.validateUploadName(originalName); + String extension = extensionOf(originalName); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + reject(); + } + + String contentType = file.getContentType(); + if (!EXT_TO_CONTENT_TYPE.get(extension).equals(contentType)) { + reject(); + } + + byte[] content; + try { + content = file.getBytes(); + } catch (IOException e) { + reject(); + return; + } + if (content.length == 0 || content.length > MAX_FILE_SIZE) { + reject(); + } + + switch (extension) { + case ".pdf" -> validatePdf(content); + case ".doc" -> validateLegacyDoc(content); + case ".docx" -> validateDocx(content); + default -> reject(); + } + } + + private void validatePdf(byte[] content) { + int offset = startsWith(content, new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}) ? 3 : 0; + while (offset < content.length && offset < 16 && isAsciiWhitespace(content[offset])) { + offset++; + } + if (!startsWithAt(content, PDF_SIGNATURE, offset)) { + reject(); + } + int searchStart = Math.max(0, content.length - 2_048); + if (indexOf(content, new byte[]{'%', '%', 'E', 'O', 'F'}, searchStart) < 0) { + reject(); + } + } + + private void validateLegacyDoc(byte[] content) { + if (!startsWith(content, OLE_SIGNATURE) || indexOf(content, WORD_DOCUMENT_UTF16_LE, 0) < 0) { + reject(); + } + } + + private void validateDocx(byte[] content) { + if (!startsWith(content, new byte[]{'P', 'K'})) { + reject(); + } + + boolean contentTypesFound = false; + boolean documentFound = false; + int entryCount = 0; + long uncompressedBytes = 0; + byte[] buffer = new byte[BUFFER_SIZE]; + + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entryCount++; + if (entryCount > MAX_DOCX_ENTRIES || unsafeEntryName(entry.getName())) { + reject(); + } + if ("[Content_Types].xml".equals(entry.getName())) { + contentTypesFound = true; + } else if ("word/document.xml".equals(entry.getName())) { + documentFound = true; + } + + int read; + while ((read = zip.read(buffer)) != -1) { + uncompressedBytes += read; + if (uncompressedBytes > MAX_DOCX_UNCOMPRESSED_BYTES + || uncompressedBytes > safeMultiply(content.length, MAX_DOCX_COMPRESSION_RATIO)) { + reject(); + } + } + zip.closeEntry(); + } + } catch (ZipException e) { + reject(); + } catch (IOException e) { + reject(); + } + + if (!contentTypesFound || !documentFound) { + reject(); + } + } + + private boolean unsafeEntryName(String name) { + if (name == null || name.isBlank() || name.startsWith("/") || name.startsWith("\\") + || name.contains("\\") || name.indexOf('\0') >= 0 || name.contains(":")) { + return true; + } + for (String segment : name.split("/")) { + if ("..".equals(segment)) { + return true; + } + } + return false; + } + + private long safeMultiply(long value, long multiplier) { + if (value > Long.MAX_VALUE / multiplier) { + return Long.MAX_VALUE; + } + return value * multiplier; + } + + private String extensionOf(String filename) { + int dot = filename.lastIndexOf('.'); + if (dot <= 0 || dot == filename.length() - 1) { + reject(); + } + return filename.substring(dot).toLowerCase(java.util.Locale.ROOT); + } + + private boolean startsWith(byte[] content, byte[] signature) { + return startsWithAt(content, signature, 0); + } + + private boolean startsWithAt(byte[] content, byte[] signature, int offset) { + if (offset < 0 || content.length - offset < signature.length) { + return false; + } + for (int i = 0; i < signature.length; i++) { + if (content[offset + i] != signature[i]) { + return false; + } + } + return true; + } + + private int indexOf(byte[] content, byte[] target, int start) { + outer: + for (int i = Math.max(0, start); i <= content.length - target.length; i++) { + for (int j = 0; j < target.length; j++) { + if (content[i + j] != target[j]) { + continue outer; + } + } + return i; + } + return -1; + } + + private boolean isAsciiWhitespace(byte value) { + return value == ' ' || value == '\t' || value == '\r' || value == '\n' || value == '\f'; + } + + private void reject() { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java new file mode 100644 index 0000000..276b22c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java @@ -0,0 +1,54 @@ +package com.mydreamjob.profile.storage; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; + +/** 文件名白名单处理:阻止路径字符、控制字符和响应头换行进入存储元数据或下载响应。 */ +public final class ResumeFilenameSanitizer { + + private static final int MAX_FILENAME_LENGTH = 255; + + private ResumeFilenameSanitizer() { + } + + public static void validateUploadName(String filename) { + if (filename == null || filename.isBlank() || filename.length() > MAX_FILENAME_LENGTH) { + reject(); + } + for (int i = 0; i < filename.length(); i++) { + char c = filename.charAt(i); + if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { + reject(); + } + } + if (".".equals(filename) || "..".equals(filename)) { + reject(); + } + } + + /** + * 兼容历史数据的下载文件名净化。新上传文件已在入口处拒绝危险字符, + * 此方法为旧记录提供防御性兜底,不返回路径片段或换行。 + */ + public static String forDownload(String filename, String fallback) { + String safeFallback = (fallback == null || fallback.isBlank()) ? "resume" : fallback; + if (filename == null || filename.isBlank()) { + return safeFallback; + } + StringBuilder safe = new StringBuilder(Math.min(filename.length(), MAX_FILENAME_LENGTH)); + for (int i = 0; i < filename.length() && safe.length() < MAX_FILENAME_LENGTH; i++) { + char c = filename.charAt(i); + if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { + safe.append('_'); + } else { + safe.append(c); + } + } + String result = safe.toString().trim(); + return result.isEmpty() || ".".equals(result) || "..".equals(result) ? safeFallback : result; + } + + private static void reject() { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java index b0f19f8..d01fc56 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java @@ -3,6 +3,7 @@ package com.mydreamjob.resume; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -127,7 +128,7 @@ public class ResumeVersionController { ResumeExportService.DownloadFile df = resumeExportService.download(userId, taskId); MediaType mediaType = resolveContentType(df.getContentType()); ContentDisposition cd = ContentDisposition.attachment() - .filename(df.getFilename(), StandardCharsets.UTF_8) + .filename(ResumeFilenameSanitizer.forDownload(df.getFilename(), "resume"), StandardCharsets.UTF_8) .build(); return ResponseEntity.ok() .contentType(mediaType) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java index cfd599d..4f74262 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java @@ -3,6 +3,7 @@ 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; @@ -61,7 +62,8 @@ public class ResumeVersionService { for (int attempt = 0; attempt < MAX_VERSION_NO_RETRY; attempt++) { try { return versionWriter.saveNewVersion(userId, normalizeName(name), storagePath, - file.getOriginalFilename(), file.getContentType(), file.getSize()); + 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); @@ -129,7 +131,7 @@ public class ResumeVersionService { // 1) 先保存新文件(绝不在保存前删除旧文件) newStoragePath = resumeStorageService.store(userId, file); version.setFileStoragePath(newStoragePath); - version.setFileName(file.getOriginalFilename()); + version.setFileName(ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume")); version.setContentType(file.getContentType()); version.setFileSize(file.getSize()); // 2) 注册事务回调:提交后删旧文件;回滚时清理新文件 diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java index 1f4706b..3be5e93 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java @@ -221,4 +221,20 @@ class ResumeControllerTest { "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), resp.getHeaders().getContentType()); } + + @Test + void downloadResume_legacyUnsafeFilenameCannotInjectResponseHeader() { + mockAuth(STUDENT_ID); + ProfileService.ResumeFile rf = new ProfileService.ResumeFile( + "..\\resume\r\nX-Injected: secret.pdf", "application/pdf", "data".getBytes()); + when(profileService.getResume(STUDENT_ID)).thenReturn(rf); + + var resp = controller.downloadResume(request); + + String contentDisposition = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + assertNotNull(contentDisposition); + assertFalse(contentDisposition.contains("\r")); + assertFalse(contentDisposition.contains("\n")); + assertFalse(contentDisposition.contains("X-Injected:")); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java index e8bd16b..be0b725 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java @@ -3,6 +3,7 @@ package com.mydreamjob.profile; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.profile.storage.ResumeFileValidator; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import org.junit.jupiter.api.BeforeEach; @@ -10,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; @@ -32,6 +34,9 @@ class ResumeServiceTest { @Mock private UserRepository userRepository; + @Spy + private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); + @InjectMocks private ProfileService profileService; @@ -70,7 +75,7 @@ class ResumeServiceTest { void uploadResume_asStudent_shouldSucceed() { mockStudentRole(); MultipartFile file = new MockMultipartFile("file", "resume.pdf", - "application/pdf", "content".getBytes()); + "application/pdf", validPdf("content")); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf"); @@ -88,7 +93,7 @@ class ResumeServiceTest { void uploadResume_noExistingProfile_shouldCreateProfile() { mockStudentRole(); MultipartFile file = new MockMultipartFile("file", "r.pdf", - "application/pdf", "content".getBytes()); + "application/pdf", validPdf("content")); when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty()); when(storageService.store(eq(STUDENT_ID), any())).thenReturn("r.pdf"); @@ -159,6 +164,58 @@ class ResumeServiceTest { () -> profileService.uploadResume(STUDENT_ID, file)); } + @Test + void uploadResume_fakePdfContent_shouldReturn400AndNotStore() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "application/pdf", "".getBytes()); + + assertThrows(BusinessException.class, + () -> profileService.uploadResume(STUDENT_ID, file)); + + verify(storageService, never()).store(anyLong(), any()); + } + + @Test + void uploadResume_storageFailure_keepsOldFile() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "application/pdf", validPdf("content")); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(storageService.store(eq(STUDENT_ID), any())).thenThrow(new RuntimeException("disk full")); + + assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file)); + + verify(storageService, never()).delete("old_resume.pdf"); + verify(profileRepository, never()).save(any()); + } + + @Test + void uploadResume_databaseFailure_cleansNewFileAndKeepsOldFile() { + mockStudentRole(); + MultipartFile file = new MockMultipartFile("file", "resume.pdf", + "application/pdf", validPdf("content")); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf"); + when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable")); + + assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file)); + + verify(storageService).delete("new_resume.pdf"); + verify(storageService, never()).delete("old_resume.pdf"); + } + + @Test + void deleteResume_databaseFailure_keepsStoredFile() { + mockStudentRole(); + when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable")); + + assertThrows(RuntimeException.class, () -> profileService.deleteResume(STUDENT_ID)); + + verify(storageService, never()).delete("old_resume.pdf"); + } + // ── 获取 / 删除 ────────────────────────────────────────── @Test @@ -187,4 +244,9 @@ class ResumeServiceTest { assertNull(profile.getResumeContentType()); assertNull(profile.getResumeSize()); } + + private byte[] validPdf(String content) { + return ("%PDF-1.7\n" + content + "\n%%EOF\n") + .getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java new file mode 100644 index 0000000..027b7f7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java @@ -0,0 +1,153 @@ +package com.mydreamjob.profile; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.profile.storage.ResumeStorageService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ActiveProfiles("integration-test") +class ResumeUploadConsistencyIntegrationTest { + + private static final AtomicLong PHONE_SEQUENCE = new AtomicLong(800_000); + + @Autowired private ProfileService profileService; + @Autowired private StudentProfileRepository profileRepository; + @Autowired private UserRepository userRepository; + @Autowired private ResumeStorageService storageService; + @Autowired private PlatformTransactionManager transactionManager; + + @MockBean private RedisTemplate redisTemplate; + + private final List testStoragePaths = new ArrayList<>(); + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(any())).thenReturn(false); + + User user = User.builder() + .phone("177" + String.format("%08d", PHONE_SEQUENCE.incrementAndGet())) + .nickname("上传一致性测试") + .role(User.Role.STUDENT) + .active(true) + .verified(true) + .build(); + user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + userId = userRepository.saveAndFlush(user).getId(); + } + + @AfterEach + void tearDown() { + for (String path : testStoragePaths) { + storageService.delete(path); + } + profileRepository.findByUserId(userId).ifPresent(profileRepository::delete); + userRepository.findById(userId).ifPresent(userRepository::delete); + } + + @Test + void successfulReplacementCommitsNewReferenceBeforeDeletingOldFile() { + String oldPath = store(pdf("old")); + saveProfile(oldPath, "old.pdf"); + + @SuppressWarnings("unchecked") + String newPath = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) + .get("resumeUrl"); + testStoragePaths.add(newPath); + + StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); + assertEquals(newPath, persisted.getResumeUrl()); + assertArrayEquals(pdf("new"), storageService.read(newPath)); + assertThrows(BusinessException.class, () -> storageService.read(oldPath)); + } + + @Test + void outerRollbackRestoresOldReferenceAndDeletesOnlyNewFile() { + String oldPath = store(pdf("old")); + saveProfile(oldPath, "old.pdf"); + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + + String newPath = transaction.execute(status -> { + String stored = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) + .get("resumeUrl"); + testStoragePaths.add(stored); + status.setRollbackOnly(); + return stored; + }); + + StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); + assertEquals(oldPath, persisted.getResumeUrl()); + assertArrayEquals(pdf("old"), storageService.read(oldPath)); + assertThrows(BusinessException.class, () -> storageService.read(newPath)); + } + + @Test + void deleteRollbackKeepsDatabaseReferenceAndStoredFile() { + String oldPath = store(pdf("old")); + saveProfile(oldPath, "old.pdf"); + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + + transaction.executeWithoutResult(status -> { + profileService.deleteResume(userId); + status.setRollbackOnly(); + }); + + StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); + assertEquals(oldPath, persisted.getResumeUrl()); + assertArrayEquals(pdf("old"), storageService.read(oldPath)); + } + + private String store(byte[] content) { + String path = storageService.store(userId, multipart("resume.pdf", content)); + testStoragePaths.add(path); + return path; + } + + private void saveProfile(String path, String filename) { + profileRepository.saveAndFlush(StudentProfile.builder() + .userId(userId) + .resumeUrl(path) + .resumeOriginalName(filename) + .resumeContentType("application/pdf") + .resumeSize((long) pdf("old").length) + .build()); + } + + private MockMultipartFile multipart(String filename, byte[] content) { + return new MockMultipartFile("file", filename, "application/pdf", content); + } + + private byte[] pdf(String body) { + return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java index 64d4d6c..0520ec4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java @@ -9,7 +9,8 @@ import org.springframework.web.multipart.MultipartFile; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Arrays; +import java.io.IOException; +import java.io.InputStream; import static org.junit.jupiter.api.Assertions.*; @@ -105,6 +106,32 @@ class LocalResumeStorageServiceTest { assertNotNull(path); } + @Test + void store_interruptedCopy_removesTemporaryAndFinalFiles() throws Exception { + MultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", "ignored".getBytes()) { + @Override + public InputStream getInputStream() { + return new InputStream() { + private final byte[] content = "partial-content".getBytes(); + private int position; + + @Override + public int read() throws IOException { + if (position >= 4) { + throw new IOException("simulated interrupted upload"); + } + return content[position++]; + } + }; + } + }; + + assertThrows(RuntimeException.class, () -> service.store(6L, file)); + try (var files = Files.list(tempDir)) { + assertEquals(0, files.count(), "failed upload must leave neither final nor .uploading files"); + } + } + // ── 文件不存在 ──────────────────────────────────────────── @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java new file mode 100644 index 0000000..f3d3ddc --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java @@ -0,0 +1,142 @@ +package com.mydreamjob.profile.storage; + +import com.mydreamjob.common.exception.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ResumeFileValidatorTest { + + private final ResumeFileValidator validator = new ResumeFileValidator(); + + @Test + void validPdfDocAndDocxAreAccepted() throws Exception { + assertDoesNotThrow(() -> validator.validate(file("resume.pdf", "application/pdf", pdf("profile")))); + assertDoesNotThrow(() -> validator.validate(file("resume.doc", "application/msword", legacyDoc()))); + assertDoesNotThrow(() -> validator.validate(file("resume.docx", docxType(), docx("docProps/core.xml", "profile")))); + } + + @Test + void fakePdfAndFakeLegacyDocAreRejected() { + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.pdf", "application/pdf", "not a pdf".getBytes()))); + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.doc", "application/msword", "not a doc".getBytes()))); + } + + @Test + void extensionContentTypeMismatchIsRejectedEvenWhenSignatureLooksValid() { + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.pdf", "application/msword", pdf("profile")))); + } + + @Test + void arbitraryZipAndMissingDocumentPartAreRejected() throws Exception { + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), + zip(new Entry("payload.txt", "not office"))))); + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), + zip(new Entry("[Content_Types].xml", "types"))))); + } + + @Test + void docxPathTraversalEntryIsRejected() throws Exception { + byte[] malicious = zip( + new Entry("[Content_Types].xml", "types"), + new Entry("word/document.xml", "document"), + new Entry("../outside.txt", "secret")); + + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), malicious))); + } + + @Test + void highlyCompressedDocxPayloadIsRejected() throws Exception { + byte[] repeated = new byte[1024 * 1024]; + byte[] bomb = zip( + new Entry("[Content_Types].xml", "types"), + new Entry("word/document.xml", "document"), + new Entry("word/media/bomb.bin", repeated)); + + assertThrows(BusinessException.class, + () -> validator.validate(file("resume.docx", docxType(), bomb))); + } + + @Test + void unsafeOrOversizedFilenameIsRejected() { + assertThrows(BusinessException.class, + () -> validator.validate(file("..\\resume.pdf", "application/pdf", pdf("profile")))); + assertThrows(BusinessException.class, + () -> validator.validate(file("resume\r\nX-Test.pdf", "application/pdf", pdf("profile")))); + assertThrows(BusinessException.class, + () -> validator.validate(file("a".repeat(252) + ".pdf", "application/pdf", pdf("profile")))); + } + + @Test + void downloadFilenameSanitizerProtectsLegacyMetadata() { + String safe = ResumeFilenameSanitizer.forDownload("..\\old\r\nInjected: value.pdf", "resume.pdf"); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\\")); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\r")); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\n")); + org.junit.jupiter.api.Assertions.assertFalse(safe.contains(":")); + } + + static byte[] pdf(String body) { + return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } + + static byte[] legacyDoc() { + byte[] marker = "WordDocument".getBytes(java.nio.charset.StandardCharsets.UTF_16LE); + byte[] bytes = new byte[512 + marker.length]; + byte[] signature = { + (byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, + (byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 + }; + System.arraycopy(signature, 0, bytes, 0, signature.length); + System.arraycopy(marker, 0, bytes, 512, marker.length); + return bytes; + } + + static byte[] docx(String extraName, String extraContent) throws IOException { + return zip( + new Entry("[Content_Types].xml", ""), + new Entry("word/document.xml", ""), + new Entry(extraName, extraContent)); + } + + private static byte[] zip(Entry... entries) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(output)) { + for (Entry entry : entries) { + zip.putNextEntry(new ZipEntry(entry.name())); + if (entry.bytes() != null) { + zip.write(entry.bytes()); + } + zip.closeEntry(); + } + } + return output.toByteArray(); + } + + private static MockMultipartFile file(String name, String contentType, byte[] content) { + return new MockMultipartFile("file", name, contentType, content); + } + + private static String docxType() { + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } + + private record Entry(String name, byte[] bytes) { + Entry(String name, String content) { + this(name, content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java index 6335daa..4943ead 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java @@ -57,7 +57,8 @@ class ResumeVersionPermissionControllerTest { ownerUserId = createStudent(); otherUserId = createStudent(); - MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", + "%PDF-1.7\npermission-fixture\n%%EOF\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); ResumeVersion version = resumeVersionService.createVersion(ownerUserId, "秋招版", file); versionId = version.getId(); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java index 135f93a..c896bbc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java @@ -162,7 +162,7 @@ class ResumeVersionPersistenceIntegrationTest { ExecutorService pool = Executors.newFixedThreadPool(2); CountDownLatch start = new CountDownLatch(1); AtomicReference failure = new AtomicReference<>(); - MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes()); + MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("data")); pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "a", file), failure)); pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "b", file), failure)); start.countDown(); @@ -183,8 +183,10 @@ class ResumeVersionPersistenceIntegrationTest { */ @Test void export_afterVersionModified_shouldUseOriginalFile() throws Exception { - MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes()); - MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes()); + byte[] contentA = pdf("AAA"); + byte[] contentB = pdf("BBB"); + MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", contentA); + MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", contentB); ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA); ResumeExportTask task = resumeExportService.createExportTask(userId, v1.getId()); // 快照 = A 的路径 @@ -194,7 +196,7 @@ class ResumeVersionPersistenceIntegrationTest { ResumeExportTask done = awaitTerminal(task.getId()); assertEquals(ResumeExportTask.STATUS_SUCCESS, done.getStatus(), "导出应成功: " + done.getErrorMessage()); - assertArrayEquals("AAA".getBytes(), resumeExportStorageService.read(done.getOutputPath())); + assertArrayEquals(contentA, resumeExportStorageService.read(done.getOutputPath())); assertEquals("r.pdf", done.getOutputName()); } @@ -216,8 +218,8 @@ class ResumeVersionPersistenceIntegrationTest { */ @Test void updateVersion_outerRollback_shouldCleanNewFileAndKeepOldFileAndDb() throws Exception { - MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes()); - MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes()); + MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("AAA")); + MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", pdf("BBB")); ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA); String pathA = v1.getFileStoragePath(); List before = listMyResumeFiles(); @@ -260,4 +262,9 @@ class ResumeVersionPersistenceIntegrationTest { failure.compareAndSet(null, t); } } + + private byte[] pdf(String content) { + return ("%PDF-1.7\n" + content + "\n%%EOF\n") + .getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } }