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())等,尚无任何隐私过滤。
- 单次 JOIN 查询
- 详情 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
- 编译:
mvn -f mydreamjob-server/pom.xml -q compile - 测试:
mvn -f mydreamjob-server/pom.xml test(重点关注ApplicationServiceTest、ApplicationControllerTest、privacy.*Test) - 全量校验:
mvn -f mydreamjob-server/pom.xml clean verify - OpenAPI 校验:
python docs/verify_openapi.py(应不受影响,无 path 变更) - 手动契约抽查(可选):企业 token 递减设置不同
ResumeVisibility,调GET /company/candidates/{applicationId},核对简历要素是否按设定省略、contactVisible=true时是否返回 phone/email。 - 投递模块回归(重点):确认学生 投递/撤回/状态流转/时间线 与候选人列表接口行为不变(因接入点仅在详情);可用现有
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、数据库迁移。