package com.mydreamjob.application; import com.mydreamjob.application.dto.CompanyResumeFile; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfileRepository; import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeStorageService; import com.mydreamjob.profile.storage.ResumeOwnershipVerifier; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Set; /** * S03-B 企业读取候选人简历文件。 * *

本类只负责「授权已通过之后」的文件读取与响应构建: * 授权全部由 {@link CompanyResumeAccessPolicy} 完成,**任何拒绝路径都不会走到 * {@link ResumeStorageService#read}。

* *

简历正文、存储路径、原始文件名与学生标识都不写入日志、审计或异常。

*/ @Slf4j @Service @RequiredArgsConstructor public class CompanyResumeService { /** Content-Type 白名单:只能来自上传时已校验的三种简历类型。 */ static final Set ALLOWED_CONTENT_TYPES = Set.of( "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); static final String FALLBACK_CONTENT_TYPE = "application/octet-stream"; private final CompanyResumeAccessPolicy accessPolicy; private final StudentProfileRepository profileRepository; private final ResumeStorageService resumeStorageService; private final ResumeOwnershipVerifier resumeOwnership; /** * 读取该投递对应候选人的简历。 * * @throws BusinessException 授权失败({@code FORBIDDEN} / {@code APPLICATION_NOT_FOUND}), * 或授权通过但学生没有简历({@code PROFILE_NOT_FOUND}) */ @Transactional(readOnly = true) public CompanyResumeFile load(Long companyUserId, Long applicationId) { // 先授权,后读文件:拒绝路径绝不触碰存储。 CompanyResumeAccessPolicy.Decision decision = accessPolicy.authorizeForDownload(companyUserId, applicationId); StudentProfile profile = profileRepository.findByUserId(decision.studentUserId()) .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); String storagePath = profile.getResumeUrl(); if (storagePath == null || storagePath.isBlank()) { throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); } // 企业获得的是该候选人的读取授权,不能将历史污染引用当作文件归属证明。 resumeOwnership.assertOwnedBy(storagePath, decision.studentUserId()); byte[] content = resumeStorageService.read(storagePath); String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume"); String contentType = resolveContentType(profile.getResumeContentType()); return new CompanyResumeFile(content, filename, contentType); } private String resolveContentType(String stored) { return (stored != null && ALLOWED_CONTENT_TYPES.contains(stored)) ? stored : FALLBACK_CONTENT_TYPE; } }