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.
 
 
 

132 lines
6.0 KiB

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 企业简历访问授权(失败关闭)。
*
* <p>授权顺序严格按设计 §4.2:当前 COMPANY 用户 → 当前 Company → Application+Job →
* Job.companyId → Student → privacy setting。归属信息全部由服务端推导,
* <strong>不信任任何客户端传入的 companyId / studentUserId</strong>。</p>
*
* <p>决策表(设计 §4.3):</p>
* <ul>
* <li>无设置记录 / null / PRIVATE:拒绝(按 PRIVATE 处理)</li>
* <li>APPLIED_COMPANIES:岗位必须属于当前企业,且投递状态为
* SUBMITTED/VIEWED/INTERVIEW/HIRED;WITHDRAWN/REJECTED 拒绝</li>
* <li>VERIFIED_COMPANIES:当前企业 {@code verified=true} <strong>且</strong>拥有该投递岗位;
* 投递状态不额外限制</li>
* </ul>
*
* <p>本类修复了此前 {@code PrivacySettingsService.canCompanyViewResume} 只校验学生与状态、
* 不校验企业归属的缺陷。</p>
*/
@Component
@RequiredArgsConstructor
public class CompanyResumeAccessPolicy {
/**
* {@code APPLIED_COMPANIES} 允许的投递状态:**严格白名单**。
*
* <p>不得改为「不是拒绝状态就允许」的黑名单逻辑 —— 那会让 {@code null}、空白、
* 未知状态或大小写变体(如 {@code "submitted"})失败开放。状态比较区分大小写。</p>
*/
static final Set<String> 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} 委托)。
*
* <p>调用方提供的 {@code studentUserId} 只用于一致性校验:必须与投递记录一致,
* 否则视为不可信并拒绝。</p>
*/
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();
}
}