package com.mydreamjob.application; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import com.mydreamjob.privacy.ResumeVisibility; import com.mydreamjob.privacy.StudentPrivacySettings; import com.mydreamjob.privacy.StudentPrivacySettingsRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.Set; /** * S03-B 企业简历访问授权(失败关闭)。 * *

授权顺序严格按设计 §4.2:当前 COMPANY 用户 → 当前 Company → Application+Job → * Job.companyId → Student → privacy setting。归属信息全部由服务端推导, * 不信任任何客户端传入的 companyId / studentUserId。

* *

决策表(设计 §4.3):

* * *

本类修复了此前 {@code PrivacySettingsService.canCompanyViewResume} 只校验学生与状态、 * 不校验企业归属的缺陷。

*/ @Component @RequiredArgsConstructor public class CompanyResumeAccessPolicy { /** * {@code APPLIED_COMPANIES} 允许的投递状态:**严格白名单**。 * *

不得改为「不是拒绝状态就允许」的黑名单逻辑 —— 那会让 {@code null}、空白、 * 未知状态或大小写变体(如 {@code "submitted"})失败开放。状态比较区分大小写。

*/ static final Set APPLIED_COMPANY_VISIBLE_STATUSES = Set.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); private final CompanyRepository companyRepository; private final ApplicationRepository applicationRepository; private final JobRepository jobRepository; private final StudentPrivacySettingsRepository privacyRepository; /** 授权通过后返回候选人用户 ID;这是后续读取简历的唯一依据。 */ public record Decision(Long studentUserId) { } /** * 企业下载接口使用的授权入口:仅凭当前登录公司用户与路径中的 applicationId 完成全部判定。 * * @throws BusinessException 未拥有企业资料 / 跨企业 / 隐私拒绝 → {@code FORBIDDEN(2002)}; * 投递不存在 → {@code APPLICATION_NOT_FOUND(8002)} */ public Decision authorizeForDownload(Long companyUserId, Long applicationId) { if (companyUserId == null || applicationId == null) { throw new BusinessException(ErrorCode.FORBIDDEN); } Company company = companyRepository.findByOwnerUserId(companyUserId).orElse(null); if (company == null) { throw new BusinessException(ErrorCode.FORBIDDEN); } Application application = applicationRepository.findById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); if (!isVisibleToCompanyInternal(application, company.getId())) { throw new BusinessException(ErrorCode.FORBIDDEN); } return new Decision(application.getStudentUserId()); } /** * 既有布尔语义的授权判定(供 {@code PrivacySettingsService} 委托)。 * *

调用方提供的 {@code studentUserId} 只用于一致性校验:必须与投递记录一致, * 否则视为不可信并拒绝。

*/ public boolean isVisible(Long studentUserId, Long companyId, Long applicationId) { if (studentUserId == null || companyId == null || applicationId == null) { return false; } Application application = applicationRepository.findById(applicationId).orElse(null); if (application == null || !studentUserId.equals(application.getStudentUserId())) { return false; } return isVisibleToCompanyInternal(application, companyId); } /** * 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。 */ private boolean isVisibleToCompanyInternal(Application application, Long companyId) { Job job = jobRepository.findById(application.getJobId()).orElse(null); if (job == null || !companyId.equals(job.getCompanyId())) { // 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。 return false; } ResumeVisibility visibility = visibilityOf(application.getStudentUserId()); switch (visibility) { case APPLIED_COMPANIES: // 白名单判定(失败关闭):null / 空白 / 未知 / 大小写变体一律拒绝。 // 先做 null 判断,避免 Set.of(...).contains(null) 抛 NPE。 return application.getStatus() != null && APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus()); case VERIFIED_COMPANIES: Company company = companyRepository.findById(companyId).orElse(null); return company != null && Boolean.TRUE.equals(company.getVerified()); case PRIVATE: default: // 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。 return false; } } private ResumeVisibility visibilityOf(Long studentUserId) { StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); if (settings == null || settings.getResumeVisibility() == null) { return ResumeVisibility.PRIVATE; } return settings.getResumeVisibility(); } }