diff --git a/docs/privacy-settings-api.md b/docs/privacy-settings-api.md index c4415d6..78a2480 100644 --- a/docs/privacy-settings-api.md +++ b/docs/privacy-settings-api.md @@ -93,9 +93,13 @@ ## 5. 隐私脱敏接入说明 -现有企业候选人详情 `/company/candidates/{applicationId}` 与候选人列表 `/company/candidates` 已在响应中排除 `resumeUrl` 与联系方式字段,保持原路径、原权限、原白名单不变,且本轮不新增联系方式字段。 +企业候选人列表 `/company/candidates` 仍保持既有脱敏:响应排除 `resumeUrl` 与联系方式字段(`CandidateItemView` 不含 phone/email)。 -隐私策略方法 `canCompanyViewResume(studentUserId, companyId, applicationId)` 已随本模块提供,供企业侧按 `ResumeVisibility` 判定简历可见性。 +企业候选人**详情** `/company/candidates/{applicationId}` 已接入隐私设置: +- 按学生 `ResumeVisibility`(通过 `PrivacySettingsService.canCompanyViewResume(studentUserId, companyId, applicationId)`)决定是否返回简历要素(`candidateSchool/Education/GraduationYear/Skills/Bio/Location`)。不可见时这些字段置 null,配合 `@JsonInclude(NON_NULL)` 省略;`candidateName`、`candidateAvatarUrl`、`coverLetter` 不受影响。 +- 按学生 `contactVisible` 判定是否返回联系方式:`contactVisible=true` 时才返回新增的 `candidatePhone` / `candidateEmail`,否则省略。 + +预留扩展点:如需企业查看简历文件,可在详情新增受同一可见性策略保护的 `resumeFileId` 并在独立端点鉴权。 ## 6. 审计 diff --git a/docs/privacy-visibility-integration.md b/docs/privacy-visibility-integration.md new file mode 100644 index 0000000..396b538 --- /dev/null +++ b/docs/privacy-visibility-integration.md @@ -0,0 +1,142 @@ +# 隐私设置落地:企业候选人详情接入 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`、数据库迁移。 \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java index 20c670c..d175cc3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java @@ -3,6 +3,8 @@ package com.mydreamjob.application; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -46,4 +48,40 @@ public interface ApplicationRepository extends JpaRepository */ List findByJobIdInAndStatusNotInOrderByCreatedAtAsc(java.util.Collection jobIds, java.util.Collection statuses); + + // ── 企业候选人(公司收到的投递)查询 ───────────────────────── + + /** + * 企业候选人详情:单次 JOIN 取出 application + job + studentProfile + user,避免 N+1。 + * 返回元组 Object[]{Application, Job, StudentProfile, User}。 + * 数据隔离(岗位是否属于当前企业)由 Service 层基于 companyId 判断。 + */ + @Query("SELECT a, j, sp, u FROM Application a " + + "JOIN Job j ON a.jobId = j.id " + + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + + "LEFT JOIN User u ON a.studentUserId = u.id " + + "WHERE a.id = :applicationId") + List findCandidateDetailById(@Param("applicationId") Long applicationId); + + /** + * 企业候选人分页列表:按公司过滤,可选按岗位/状态/关键词过滤,分页排序。 + * 可空条件用 (:x IS NULL OR ...) 表达,与 {@link com.mydreamjob.job.JobRepository#findPublishedJobs} + * 同风格。一次 JOIN 取出 job/studentProfile/user,避免逐行 N+1。 + * 排序字段由 Service 白名单控制(createdAt/updatedAt),并映射到查询别名 a。 + */ + @Query("SELECT a, j, sp, u FROM Application a " + + "JOIN Job j ON a.jobId = j.id " + + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + + "LEFT JOIN User u ON a.studentUserId = u.id " + + "WHERE j.companyId = :companyId " + + "AND (:jobId IS NULL OR a.jobId = :jobId) " + + "AND (:status IS NULL OR a.status = :status) " + + "AND (:keyword IS NULL OR sp.realName LIKE CONCAT('%', :keyword, '%') " + + " OR sp.school LIKE CONCAT('%', :keyword, '%') " + + " OR sp.education LIKE CONCAT('%', :keyword, '%'))") + Page findCompanyCandidates(@Param("companyId") Long companyId, + @Param("jobId") Long jobId, + @Param("status") String status, + @Param("keyword") String keyword, + Pageable pageable); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index fc5fd57..3552278 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -15,14 +15,13 @@ import com.mydreamjob.job.JobService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.privacy.PrivacySettingsService; import com.mydreamjob.profile.StudentProfile; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,9 +45,7 @@ public class ApplicationService { private final AuditService auditService; private final ApplicationStatusHistoryRepository historyRepository; private final NotificationService notificationService; - - @PersistenceContext - private EntityManager em; + private final PrivacySettingsService privacySettingsService; private static final ObjectMapper objectMapper = new ObjectMapper(); private static final Set ALLOWED_STATUSES = Set.of( @@ -268,70 +265,45 @@ public class ApplicationService { String escapedKeyword = escapeLike(keyword); - // 白名单映射排序字段 - String sortField = (sort == null) ? "a.createdAt" : "a." + sort; - String sortOrder = (order == null) ? "DESC" : order.toUpperCase(); - - // 构建 JPQL - String whereClause = "WHERE j.companyId = :companyId"; - if (jobId != null) whereClause += " AND a.jobId = :jobId"; - if (status != null) whereClause += " AND a.status = :status"; - if (escapedKeyword != null) { - whereClause += " AND (sp.realName LIKE CONCAT('%', :keyword, '%')" + - " OR sp.school LIKE CONCAT('%', :keyword, '%')" + - " OR sp.education LIKE CONCAT('%', :keyword, '%'))"; - } - - String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " + - "sp.realName, sp.school, sp.education, sp.graduationYear, " + - "u.avatarUrl, a.createdAt, a.updatedAt"; - String fromClause = "FROM Application a " + - "JOIN Job j ON a.jobId = j.id " + - "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + - "LEFT JOIN User u ON a.studentUserId = u.id"; - - String countJpql = "SELECT COUNT(a) " + fromClause + " " + whereClause; - String dataJpql = selectClause + " " + fromClause + " " + whereClause + - " ORDER BY " + sortField + " " + sortOrder + ", a.id " + sortOrder; - - // Count - TypedQuery countQuery = em.createQuery(countJpql, Long.class); - countQuery.setParameter("companyId", companyId); - if (jobId != null) countQuery.setParameter("jobId", jobId); - if (status != null) countQuery.setParameter("status", status); - if (escapedKeyword != null) countQuery.setParameter("keyword", escapedKeyword); - long total = countQuery.getSingleResult(); - - // Data - TypedQuery dataQuery = em.createQuery(dataJpql, Object[].class); - dataQuery.setParameter("companyId", companyId); - if (jobId != null) dataQuery.setParameter("jobId", jobId); - if (status != null) dataQuery.setParameter("status", status); - if (escapedKeyword != null) dataQuery.setParameter("keyword", escapedKeyword); - dataQuery.setFirstResult(page * size); - dataQuery.setMaxResults(size); - - List rows = dataQuery.getResultList(); - List items = rows.stream().map(row -> CandidateItemView.builder() - .applicationId((Long) row[0]) - .jobId((Long) row[1]) - .jobTitle((String) row[2]) - .status((String) row[3]) - .candidateName((String) row[4]) - .candidateSchool((String) row[5]) - .candidateEducation((String) row[6]) - .candidateGraduationYear(row[7] != null ? ((Number) row[7]).shortValue() : null) - .candidateAvatarUrl((String) row[8]) - .createdAt((LocalDateTime) row[9]) - .updatedAt((LocalDateTime) row[10]) - .build()).collect(Collectors.toList()); + // 排序:白名单字段(createdAt/updatedAt)+ 主键稳定次序,方向取决于校验后的 order + String sortField = (sort == null) ? "createdAt" : sort; + Sort.Direction direction = (order == null || "desc".equalsIgnoreCase(order)) + ? Sort.Direction.DESC : Sort.Direction.ASC; + Sort sortSpec = Sort.by(direction, sortField).and(Sort.by(direction, "id")); + + // 单次 JOIN 分页查询:可空过滤条件在 SQL 内处理,计数与分页由 Page 承担 + Page result = applicationRepository.findCompanyCandidates( + companyId, jobId, status, escapedKeyword, + PageRequest.of(page, size, sortSpec)); + + List items = result.getContent().stream() + .map(row -> { + Application a = (Application) row[0]; + com.mydreamjob.job.Job j = (com.mydreamjob.job.Job) row[1]; + StudentProfile sp = (StudentProfile) row[2]; + com.mydreamjob.auth.User u = (com.mydreamjob.auth.User) row[3]; + return CandidateItemView.builder() + .applicationId(a.getId()) + .jobId(a.getJobId()) + .jobTitle(j.getTitle()) + .status(a.getStatus()) + .candidateName(sp != null ? sp.getRealName() : null) + .candidateSchool(sp != null ? sp.getSchool() : null) + .candidateEducation(sp != null ? sp.getEducation() : null) + .candidateGraduationYear(sp != null ? sp.getGraduationYear() : null) + .candidateAvatarUrl(u != null ? u.getAvatarUrl() : null) + .createdAt(a.getCreatedAt()) + .updatedAt(a.getUpdatedAt()) + .build(); + }) + .collect(Collectors.toList()); return CandidateListResponse.builder() .items(items) - .total(total) + .total(result.getTotalElements()) .page(page) .size(size) - .totalPages((int) Math.ceil((double) total / size)) + .totalPages(result.getTotalPages()) .build(); } @@ -340,18 +312,9 @@ public class ApplicationService { */ @Transactional(readOnly = true) public CandidateDetailView getCompanyCandidateDetail(Long companyId, Long applicationId) { - // 单次 JOIN 查询:applications + jobs + student_profiles + users + // 单次 JOIN 查询:applications + jobs + student_profiles + users(见 ApplicationRepository#findCandidateDetailById) // 查询结果包含岗位 companyId,数据隔离在 Service 层比较,避免额外的 findById 二次查询 - TypedQuery query = em.createQuery( - "SELECT a, j, sp, u FROM Application a " + - "JOIN Job j ON a.jobId = j.id " + - "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + - "LEFT JOIN User u ON a.studentUserId = u.id " + - "WHERE a.id = :applicationId", - Object[].class); - query.setParameter("applicationId", applicationId); - - List results = query.getResultList(); + List results = applicationRepository.findCandidateDetailById(applicationId); if (results.isEmpty()) { // 投递不存在 throw new BusinessException(ErrorCode.APPLICATION_NOT_FOUND); @@ -378,6 +341,14 @@ public class ApplicationService { } } + // 隐私判定:依学生 ResumeVisibility 决定简历要素是否对企业可见,依 contactVisible 决定是否返回联系方式。 + // studentUserId 从 JOIN 结果取(sp.userId 或 u.id);canCompanyViewResume/canViewContact 均只读、仅作用于单条详情,不构成 N+1。 + 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); + return CandidateDetailView.builder() .applicationId(app.getId()) .jobId(app.getJobId()) @@ -390,12 +361,16 @@ public class ApplicationService { .updatedAt(app.getUpdatedAt()) .candidateName(sp != null ? sp.getRealName() : null) .candidateAvatarUrl(u != null ? u.getAvatarUrl() : null) - .candidateBio(sp != null ? sp.getBio() : null) - .candidateLocation(sp != null ? sp.getLocation() : null) - .candidateSchool(sp != null ? sp.getSchool() : null) - .candidateEducation(sp != null ? sp.getEducation() : null) - .candidateGraduationYear(sp != null ? sp.getGraduationYear() : null) - .candidateSkills(skills) + // 简历要素:不可见时置 null,配合 @JsonInclude(NON_NULL) 省略 + .candidateBio(resumeVisible && sp != null ? sp.getBio() : null) + .candidateLocation(resumeVisible && sp != null ? sp.getLocation() : null) + .candidateSchool(resumeVisible && sp != null ? sp.getSchool() : null) + .candidateEducation(resumeVisible && sp != null ? sp.getEducation() : null) + .candidateGraduationYear(resumeVisible && sp != null ? sp.getGraduationYear() : null) + .candidateSkills(resumeVisible ? skills : Collections.emptyList()) + // 联系方式:仅当学生 contactVisible=true 时返回真实值,否则省略 + .candidatePhone(contactVisible && u != null ? u.getPhone() : null) + .candidateEmail(contactVisible && u != null ? u.getEmail() : null) .build(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java index 02ab7a9..5ce81e3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java @@ -1,5 +1,6 @@ package com.mydreamjob.application.dto; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -9,12 +10,22 @@ import java.time.LocalDateTime; import java.util.List; /** - * 企业候选人详情视图 — 不返回 studentUserId、phone、passwordHash、email、resumeUrl、内部字段 + * 企业候选人详情视图 — 不返回 studentUserId、passwordHash、resumeUrl、内部字段。 + * + *

隐私控制: + *

    + *
  • 简历要素(candidateSchool/Education/GraduationYear/Skills/Bio/Location)由 {@code resumeVisible} + * 决定,不可见时置 null 被 {@link JsonInclude.Include#NON_NULL} 省略;
  • + *
  • 联系方式 candidatePhone/candidateEmail 仅当学生 {@code contactVisible=true} 时填充,否则省略。
  • + *
+ * 预留扩展点:如需支持企业查看简历文件,可在此新增受同一可见性策略保护的 {@code resumeFileId} 字段, + * 由独立文件访问端点按 {@link com.mydreamjob.privacy.PrivacySettingsService#canCompanyViewResume} 鉴权。 */ @Data @Builder @NoArgsConstructor @AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) public class CandidateDetailView { // 投递信息 @@ -37,4 +48,8 @@ public class CandidateDetailView { private String candidateEducation; private Short candidateGraduationYear; private List candidateSkills; + + // 联系方式(仅当学生 contactVisible=true 时由 Service 填充,否则保持 null 被省略) + private String candidatePhone; + private String candidateEmail; } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java index d99c992..d4fb899 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java @@ -134,6 +134,17 @@ public class PrivacySettingsService { } } + /** + * 企业是否可查看某学生的联系方式(phone/email)。 + * + *

读取学生隐私设置中的 {@code contactVisible} 开关;无记录或 {@code contactVisible != true} 时不可见。 + * 与 {@link #canCompanyViewResume} 相互独立:简历可见性与联系方式可见性分开控制。 + */ + public boolean canViewContact(Long studentUserId) { + StudentPrivacySettings s = privacyRepository.findByStudentUserId(studentUserId).orElse(null); + return s != null && Boolean.TRUE.equals(s.getContactVisible()); + } + private void validate(PrivacySettingsUpdateRequest req) { if (req.getResumeVisibility() == null || req.getContactVisible() == null) { throw new BusinessException(ErrorCode.BAD_REQUEST); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index 092393d..1c8b498 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java @@ -7,24 +7,23 @@ import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.application.dto.CandidateDetailView; import com.mydreamjob.application.dto.CandidateListResponse; +import com.mydreamjob.privacy.PrivacySettingsService; import com.mydreamjob.profile.StudentProfile; -import jakarta.persistence.EntityManager; -import jakarta.persistence.TypedQuery; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -36,7 +35,7 @@ class ApplicationServiceTest { @Mock private AuditService auditService; @Mock private ApplicationStatusHistoryRepository historyRepository; @Mock private com.mydreamjob.notification.NotificationService notificationService; - @Mock private EntityManager em; + @Mock private PrivacySettingsService privacySettingsService; @InjectMocks private ApplicationService applicationService; private static final Long STUDENT_ID = 100L; @@ -48,7 +47,6 @@ class ApplicationServiceTest { @BeforeEach void setUp() { - ReflectionTestUtils.setField(applicationService, "em", em); publishedJob = com.mydreamjob.job.Job.builder().id(JOB_ID).companyId(COMPANY_ID).title("Java").status("PUBLISHED").build(); application = Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build(); } @@ -166,33 +164,32 @@ class ApplicationServiceTest { // ── 企业候选人列表 ───────────────────────────────────────── @SuppressWarnings("unchecked") - private TypedQuery mockCountQuery(long total) { - TypedQuery q = mock(TypedQuery.class); - when(q.getSingleResult()).thenReturn(total); - return q; - } - - @SuppressWarnings("unchecked") - private TypedQuery mockDataQuery(Object[]... rows) { - TypedQuery q = mock(TypedQuery.class); - when(q.getResultList()).thenReturn(List.of(rows)); - return q; + private Page mockCandidatePage(Object[]... rows) { + return new PageImpl<>(List.of(rows)); } private Object[] buildRow(Long appId, Long jobId, String jobTitle, String status, String name, String school, String edu, Short gradYear, String avatarUrl) { - return new Object[]{appId, jobId, jobTitle, status, name, school, edu, gradYear, avatarUrl, - java.time.LocalDateTime.now(), java.time.LocalDateTime.now()}; + // 与 listCompanyCandidates 的实体元组查询 SELECT a, j, sp, u 对齐 + Application a = Application.builder().id(appId).jobId(jobId).status(status) + .createdAt(java.time.LocalDateTime.now()) + .updatedAt(java.time.LocalDateTime.now()).build(); + com.mydreamjob.job.Job j = com.mydreamjob.job.Job.builder() + .id(jobId).companyId(COMPANY_ID).title(jobTitle).build(); + StudentProfile sp = name == null ? null : StudentProfile.builder() + .userId(1L).realName(name).school(school).education(edu) + .graduationYear(gradYear).build(); + com.mydreamjob.auth.User u = mock(com.mydreamjob.auth.User.class); + when(u.getAvatarUrl()).thenReturn(avatarUrl); + return new Object[]{a, j, sp, u}; } @Test void listCompanyCandidates_shouldReturnPaginatedResults() { Object[] row = buildRow(200L, JOB_ID, "Java工程师", "SUBMITTED", "张三", "清华大学", "本科", (short)2026, "https://img/1.png"); - TypedQuery countQ = mockCountQuery(1L); - TypedQuery dataQ = mockDataQuery(row); - when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class))) + .thenReturn(mockCandidatePage(row)); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, null, null, null, null, 0, 20); @@ -204,10 +201,8 @@ class ApplicationServiceTest { @Test void listCompanyCandidates_shouldFilterByJobId() { Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); - TypedQuery countQ = mockCountQuery(1L); - TypedQuery dataQ = mockDataQuery(row); - when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class))) + .thenReturn(mockCandidatePage(row)); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, JOB_ID, null, null, null, null, 0, 20); @@ -218,10 +213,8 @@ class ApplicationServiceTest { @Test void listCompanyCandidates_shouldFilterByStatus() { Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); - TypedQuery countQ = mockCountQuery(1L); - TypedQuery dataQ = mockDataQuery(row); - when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class))) + .thenReturn(mockCandidatePage(row)); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, "SUBMITTED", null, null, null, 0, 20); @@ -229,10 +222,8 @@ class ApplicationServiceTest { } @Test void listCompanyCandidates_shouldReturnEmptyForNoResults() { - TypedQuery countQ = mockCountQuery(0L); - TypedQuery dataQ = mockDataQuery(); - when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class))) + .thenReturn(mockCandidatePage()); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, null, null, null, null, 0, 20); @@ -279,10 +270,8 @@ class ApplicationServiceTest { @Test void listCompanyCandidates_shouldSortByUpdatedAt() { Object[] row = buildRow(200L, JOB_ID, "Java", "SUBMITTED", "张三", "清华", "本科", null, null); - TypedQuery countQ = mockCountQuery(1L); - TypedQuery dataQ = mockDataQuery(row); - when(em.createQuery(anyString(), eq(Long.class))).thenReturn(countQ); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(dataQ); + when(applicationRepository.findCompanyCandidates(anyLong(), any(), any(), any(), any(Pageable.class))) + .thenReturn(mockCandidatePage(row)); CandidateListResponse resp = applicationService.listCompanyCandidates( COMPANY_ID, null, null, null, "updatedAt", "asc", 0, 20); @@ -302,13 +291,13 @@ class ApplicationServiceTest { .location("北京").graduationYear((short)2026).skillsJson("[\"Java\",\"Spring\"]").build(); com.mydreamjob.auth.User u = mock(com.mydreamjob.auth.User.class); when(u.getAvatarUrl()).thenReturn("https://img/1.png"); + when(u.getPhone()).thenReturn("13800000001"); + when(u.getEmail()).thenReturn("stu@example.com"); Object[] row = new Object[]{app, job, sp, u}; - @SuppressWarnings("unchecked") - TypedQuery detailQuery = mock(TypedQuery.class); - when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); - when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.singletonList(row)); + when(privacySettingsService.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(true); + when(privacySettingsService.canViewContact(STUDENT_ID)).thenReturn(true); CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); assertEquals("张三", detail.getCandidateName()); @@ -316,6 +305,9 @@ class ApplicationServiceTest { assertEquals("北京", detail.getJobCity()); assertEquals(2, detail.getCandidateSkills().size()); assertEquals("https://img/1.png", detail.getCandidateAvatarUrl()); + assertEquals("清华", detail.getCandidateSchool()); + assertEquals("13800000001", detail.getCandidatePhone()); + assertEquals("stu@example.com", detail.getCandidateEmail()); // 成功详情查询不产生额外 Repository 查询(无 N+1) verify(applicationRepository, never()).findById(anyLong()); } @@ -327,11 +319,7 @@ class ApplicationServiceTest { com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); Object[] row = new Object[]{app, job, null, null}; - @SuppressWarnings("unchecked") - TypedQuery detailQuery = mock(TypedQuery.class); - when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); - when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.singletonList(row)); var ex = assertThrows(BusinessException.class, () -> applicationService.getCompanyCandidateDetail(999L, APP_ID)); @@ -341,11 +329,7 @@ class ApplicationServiceTest { } @Test void getCompanyCandidateDetail_appNotFound_shouldThrow() { - @SuppressWarnings("unchecked") - TypedQuery detailQuery = mock(TypedQuery.class); - when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); - when(detailQuery.getResultList()).thenReturn(Collections.emptyList()); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.emptyList()); var ex = assertThrows(BusinessException.class, () -> applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID)); @@ -361,11 +345,7 @@ class ApplicationServiceTest { .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); Object[] row = new Object[]{app, job, null, null}; - @SuppressWarnings("unchecked") - TypedQuery detailQuery = mock(TypedQuery.class); - when(detailQuery.setParameter(anyString(), any())).thenReturn(detailQuery); - when(detailQuery.getResultList()).thenReturn(Collections.singletonList(row)); - when(em.createQuery(anyString(), eq(Object[].class))).thenReturn(detailQuery); + when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.singletonList(row)); CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); assertNull(detail.getCandidateName()); @@ -373,6 +353,62 @@ class ApplicationServiceTest { assertNull(detail.getCoverLetter()); } + @Test void getCompanyCandidateDetail_resumeNotVisible_hidesResumeFieldsKeepsIdentity() { + // 学生 ResumeVisibility=PRIVATE(canCompanyViewResume=false)→ 隐藏简历要素,保留姓名/头像/coverLetter + Application app = Application.builder().id(APP_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").coverLetter("cover").build(); + com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() + .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); + StudentProfile sp = StudentProfile.builder().userId(STUDENT_ID) + .realName("张三").school("清华").education("本科").bio("bio") + .location("北京").graduationYear((short)2026).skillsJson("[\"Java\",\"Spring\"]").build(); + com.mydreamjob.auth.User u = mock(com.mydreamjob.auth.User.class); + when(u.getAvatarUrl()).thenReturn("https://img/1.png"); + + Object[] row = new Object[]{app, job, sp, u}; + when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.singletonList(row)); + when(privacySettingsService.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(false); + when(privacySettingsService.canViewContact(STUDENT_ID)).thenReturn(false); + + CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); + // 简历要素被隐藏 + assertNull(detail.getCandidateSchool()); + assertNull(detail.getCandidateEducation()); + assertNull(detail.getCandidateGraduationYear()); + assertTrue(detail.getCandidateSkills().isEmpty()); + assertNull(detail.getCandidateBio()); + assertNull(detail.getCandidateLocation()); + // 联系方式不返回 + assertNull(detail.getCandidatePhone()); + assertNull(detail.getCandidateEmail()); + // 身份识别与投递信息保留 + assertEquals("张三", detail.getCandidateName()); + assertEquals("https://img/1.png", detail.getCandidateAvatarUrl()); + assertEquals("cover", detail.getCoverLetter()); + } + + @Test void getCompanyCandidateDetail_contactNotVisible_phoneEmailNull() { + // 简历可见但 contactVisible=false → 联系方式仍省略 + Application app = Application.builder().id(APP_ID).jobId(JOB_ID) + .studentUserId(STUDENT_ID).status("SUBMITTED").build(); + com.mydreamjob.job.Job job = com.mydreamjob.job.Job.builder() + .id(JOB_ID).companyId(COMPANY_ID).title("Java").build(); + StudentProfile sp = StudentProfile.builder().userId(STUDENT_ID) + .realName("张三").school("清华").build(); + com.mydreamjob.auth.User u = mock(com.mydreamjob.auth.User.class); + when(u.getAvatarUrl()).thenReturn("https://img/1.png"); + + Object[] row = new Object[]{app, job, sp, u}; + when(applicationRepository.findCandidateDetailById(APP_ID)).thenReturn(Collections.singletonList(row)); + when(privacySettingsService.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(true); + when(privacySettingsService.canViewContact(STUDENT_ID)).thenReturn(false); + + CandidateDetailView detail = applicationService.getCompanyCandidateDetail(COMPANY_ID, APP_ID); + assertEquals("清华", detail.getCandidateSchool()); // 简历仍可见 + assertNull(detail.getCandidatePhone()); // 联系方式隐藏 + assertNull(detail.getCandidateEmail()); + } + // ── getTimeline 已知行为回归 ──────────────────────────────── @Test void getTimeline_companyUser_knownBehavior() {