# 隐私设置落地:企业候选人详情接入 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](file:///d:/1/zhi/zhilai/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java#L116-L135) - 签名:`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](file:///d:/1/zhi/zhilai/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java#L341-L400) - 单次 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](file:///d:/1/zhi/zhilai/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java) — 无 `phone/email`,无 `@JsonInclude`。 - 现有详情测试:[ApplicationServiceTest.java#L292-L374](file:///d:/1/zhi/zhilai/mydreamjob-server/src/test/java/com/mydreamjob/application/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` 需要读取开关。新增: ```java /** 企业是否可查看该学生的联系方式(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 省略 | 新增: ```java private String candidatePhone; private String candidateEmail; ``` - 类上加 `@JsonInclude(JsonInclude.Include.NON_NULL)`;隐藏的简历要素与未开放的 phone/email 会因 null 被省略(符合"省略而非暴露 null"的契约)。 - Why:`contactVisible=false` 时省略联系方式;不可见时简历要素整体省略。 - How:仅加字段与注解;`CandidateItemView` 不动。 ### 3. `application/ApplicationService.java` — 注入隐私服务并接入详情 **注入:** ```java private final PrivacySettingsService privacySettingsService; ``` (`@RequiredArgsConstructor`,构造参数自动生效。) **`getCompanyCandidateDetail` 内、越权校验 `job.getCompanyId().equals(companyId)` 之后:** ```java 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](file:///d:/1/zhi/zhilai/docs/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`、数据库迁移。