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.
 
 
 

12 KiB

隐私设置落地:企业候选人详情接入 ResumeVisibility 与 contactVisible

一、Summary

将已在 privacy 包实现但从未被调用的 PrivacySettingsService.canCompanyViewResume(...) 接入企业候选人详情接口 GET /company/candidates/{applicationId},使学生的 ResumeVisibility(PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES)真正控制企业可见的简历要素;同时消费 contactVisible 开关,按学生设定选择性地返回 phone/email。

路线决策已在澄清中确认:

  • 仅详情过滤:候选人列表 GET /company/candidates(及 CandidateItemView)保持不变。
  • 严格按枚举:PRIVATE(默认)表示企业不可见简历要素——这是默认行为变更,需同步调整现有测试断言。
  • 仅隐藏简历要素:不可见时隐藏 school/education/graduationYear/skills/bio/location;保留 candidateName 与 candidateAvatarUrl,便于识别投递人。coverLetter 非简历要素,始终返回。

二、Current State Analysis

关键代码位置

  • 隐私策略方法(已存在,未接入):PrivacySettingsService.canCompanyViewResume
    • 签名:boolean canCompanyViewResume(Long studentUserId, Long companyId, Long applicationId)
    • PRIVATE→false;APPLIED_COMPANIES→当该投递存在且状态非 WITHDRAWN/REJECTED 时 true;VERIFIED_COMPANIES→company.verified==true 时 true;无记录默认按 PRIVATE。
  • 详情查询与 DTO 组装:ApplicationService.getCompanyCandidateDetail
    • 单次 JOIN 查询 a, j, sp, u(Application/Job/StudentProfile/User),已能拿到 studentUserId(sp.userId / u.getId())、job.companyId(用于越权校验=companyId)、app.getId()/getStatus(),以及待返回的 u.getPhone()/u.getEmail()。
    • 组装时直接 .candidateSchool(sp.getSchool()) 等,尚无任何隐私过滤。
  • 详情 DTO:CandidateDetailView.java — 无 phone/email,无 @JsonInclude。
  • 现有详情测试:ApplicationServiceTest.java#L292-L374 采用 @Mock EntityManager em + @InjectMocks;getCompanyCandidateDetail_shouldReturnDetail 断言 candidateSchool 等非空。

设计约束

  • 依赖方向:ApplicationService → PrivacySettingsService(新注入),PrivacySettingsService 不依赖 ApplicationService,无循环依赖。
  • 性能:详情本就单条 JOIN;新增隐私判断在单条详情上执行,最坏多 1~2 次只读查询(见第 §三.3 注释),非列表逐行,不构成 N+1。
  • 保持项目既有风格:Controller 拿身份、Service 做权限与组装、ApiResponse 包装、BusinessException 处理。

影响面(对投递/application 模块的边界)

本改动只落在投递模块的一处输出:ApplicationService.getCompanyCandidateDetail 及其返回的 CandidateDetailView。明确「改与不改」:

  • 改:企业候选人详情接口的返回(按隐私隐藏简历要素 / 条件返回 phone/email;@JsonInclude(NON_NULL) 使原 null 字段从 "x":null 变为省略)。
  • 不改:学生投递的 投递/撤回/状态流转/时间线、apply/withdraw/getTimeline 等方法、企业候选人列表 CandidateItemView/CandidateListResponse/列表查询,全部原样。
  • 注意:学生默认 ResumeVisibility=PRIVATE → 接入后企业默认不再看到详情中的简历要素,属预期行为变更(前端契约需同步)。

三、Proposed Changes

1. privacy/PrivacySettingsService.java — 新增一个只读方法(不动现有逻辑)

消费 contactVisible 需要读取开关。新增:

/** 企业是否可查看该学生的联系方式(phone/email)。无记录或 contactVisible=false → 不可见。 */
public boolean canViewContact(Long studentUserId) {
    StudentPrivacySettings s = privacyRepository.findByStudentUserId(studentUserId).orElse(null);
    return s != null && Boolean.TRUE.equals(s.getContactVisible());
}
  • Why:application 侧复用 privacy 包封装,保持逻辑单一来源。
  • How:仅新增方法,不改 canCompanyViewResume 与既有方法。

2. application/dto/CandidateDetailView.java — 加联系方式字段 + JsonInclude

字段「当前 → 改后」映射(企业候选人详情):

字段 当前 改后
applicationId/jobId/jobTitle/jobCity/jobEmploymentType/status/createdAt/updatedAt 返回 不变(始终)
coverLetter 返回 不变(非简历要素)
candidateName、candidateAvatarUrl 返回 不变(保留,识别投递人)
candidateSchool/candidateEducation/candidateGraduationYear/candidateSkills/candidateBio/candidateLocation 返回 resumeVisible 决定(可见才返回,否则 null 省略)
candidatePhone、candidateEmail 不返回 新增:contactVisible=true 才返回,否则 null 省略

新增:

private String candidatePhone;
private String candidateEmail;
  • 类上加 @JsonInclude(JsonInclude.Include.NON_NULL);隐藏的简历要素与未开放的 phone/email 会因 null 被省略(符合"省略而非暴露 null"的契约)。
  • Why:contactVisible=false 时省略联系方式;不可见时简历要素整体省略。
  • How:仅加字段与注解;CandidateItemView 不动。

3. application/ApplicationService.java — 注入隐私服务并接入详情

注入:

private final PrivacySettingsService privacySettingsService;

(@RequiredArgsConstructor,构造参数自动生效。)

getCompanyCandidateDetail 内、越权校验 job.getCompanyId().equals(companyId) 之后:

Long studentUserId = sp != null ? sp.getUserId() : (u != null ? u.getId() : null);
boolean resumeVisible = studentUserId != null
        && privacySettingsService.canCompanyViewResume(studentUserId, companyId, app.getId());
boolean contactVisible = studentUserId != null
        && privacySettingsService.canViewContact(studentUserId);

组装 DTO 时:

  • 简历要素(candidateSchool/candidateEducation/candidateGraduationYear/candidateSkills/candidateBio/candidateLocation)改为 resumeVisible ? <真实值> : null。

  • 新增 candidatePhone/candidateEmail 为 contactVisible ? u.getPhone()/u.getEmail() : null。

  • 其余字段(applicationId/jobId/jobTitle/jobCity/jobEmploymentType/status/coverLetter/createdAt/updatedAt/candidateName/candidateAvatarUrl)保持原样。

  • Why:canCompanyViewResume 已封装三档判定(含默认 PRIVATE、APPLIED 的有效投递排除、VERIFIED 的企业认证),直接复用避免重复实现。

  • How:只读组装;性能说明——canCompanyViewResume 分支:VERIFIED_COMPANIES 多 1 次 companyRepository.findById,APPLIED_COMPANIES 多 1 次 applicationRepository.findById,PRIVATE 提前返回不额外查;仅作用于单条详情,非列表逐行,不构成 N+1。

可选(扩展预留,本次不实现):后续如需企业查看简历文件,可在详情上附带一个 resumeFileId 并在独立端点用同一 canCompanyViewResume 保护;本次仅在 DTO 注释/文档标注扩展点,不落地。

4. 测试 (application/ApplicationServiceTest.java)

  • 新增 @Mock private com.mydreamjob.privacy.PrivacySettingsService privacySettingsService;(@InjectMocks 自动注入)。
  • 调整现有用例(因默认 PRIVATE 会隐藏,必须在用例中 stub):
    • getCompanyCandidateDetail_shouldReturnDetail:when(privacySettingsService.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(true); 并补充断言 candidatePhone/candidateEmail 依 canViewContact 结果。
    • getCompanyCandidateDetail_noStudentProfile_shouldReturnNullFields:不变(学生 profile 为空本就 null)。
    • getCompanyCandidateDetail_wrongCompany / appNotFound:在越权/不存在分支后不调用隐私方法,无需 stub;需确认越权分支在隐私判断之前先抛错。
  • 新增用例:
    • resumeVisibility_private_hidesResumeFieldsWhileKeepsIdentity:canCompanyViewResume=false 时,candidateSchool/candidateEducation/candidateGraduationYear/candidateSkills/candidateBio/candidateLocation 为 null,但 candidateName/candidateAvatarUrl/coverLetter 仍非空。
    • resumeVisibility_appliedCompanies_validVisible / withdrawnHidden:验证详情侧透传给 canCompanyViewResume 的参数正确(可 verify 调用参数)。
    • contactVisible_true_returnsPhoneEmail / false_hidden:canViewContact 为 true 时返回 phone/email,false 时为 null。

5. 可选 (若需序列化断言) application/ApplicationControllerTest.java

Controller 由 applicationService.getCompanyCandidateDetail 直接返回 DTO,本身不涉隐私。仅在需要验证 JSON 省略时可补充:构建含 phone/email 的 detail,断言 $.data.candidatePhone/$.data.candidateEmail;及未设置时字段不存在。若现有用例已覆盖 DTO 序列化,跳过此文件改动。

6. 文档与校验(可选、最小改动)

  • privacy-settings-api.md §5 补充:详情接口已接入 ResumeVisibility(隐藏简历要素)与 contactVisible(条件返回 phone/email),列表仍脱敏不变。
  • verify_openapi.py 只断言 path 数量(126),新增字段不改 path,无需改动。

四、Assumptions & Decisions

决策点 结论
过滤范围 仅详情接口;列表 CandidateItemView 不变
隐藏字段 仅简历要素school/education/graduationYear/skills/bio/location;保留 candidateName/avatarUrl 与 coverLetter
PRIVATE 语义 严格按枚举——PRIVATE(默认)企业不可见简历要素(默认行为变更,同步测试)
联系方式 contactVisible=true 才返回 phone/email,否则省略
数据来源 复用详情既有的 JOIN 结果(sp.userId/u.getId()/u.getPhone()/u.getEmail()),不新增 DB 查询,无 N+1
简历文件 本次不新增企业查看简历文件端点;在 DTO 注释与文档标注扩展点
privacy 包 仅新增只读方法 canViewContact;canCompanyViewResume 等现有逻辑不动

五、Verification

  1. 编译:mvn -f mydreamjob-server/pom.xml -q compile
  2. 测试:mvn -f mydreamjob-server/pom.xml test(重点关注 ApplicationServiceTest、ApplicationControllerTest、privacy.*Test)
  3. 全量校验:mvn -f mydreamjob-server/pom.xml clean verify
  4. OpenAPI 校验:python docs/verify_openapi.py(应不受影响,无 path 变更)
  5. 手动契约抽查(可选):企业 token 递减设置不同 ResumeVisibility,调 GET /company/candidates/{applicationId},核对简历要素是否按设定省略、contactVisible=true 时是否返回 phone/email。
  6. 投递模块回归(重点):确认学生 投递/撤回/状态流转/时间线 与候选人列表接口行为不变(因接入点仅在详情);可用现有 ApplicationServiceTest 中 apply/withdraw/getTimeline 相关用例回归验证。

六、将修改/新增的文件清单

改:

  • mydreamjob-server/.../privacy/PrivacySettingsService.java(新增 canViewContact)
  • mydreamjob-server/.../application/ApplicationService.java(注入 + 详情过滤/联系方式)
  • mydreamjob-server/.../application/dto/CandidateDetailView.java(+phone/email +@JsonInclude)
  • mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java
  • (可选)mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java、docs/privacy-settings-api.md

不改:CandidateItemView、CandidateListResponse、列表查询、ResumeVisibility、StudentPrivacySettings、数据库迁移。