Browse Source

feat: enforce privacy visibility on company candidate detail

- Wire canCompanyViewResume into candidate detail so ResumeVisibility (PRIVATE/APPLIED_COMPANIES/VERIFIED_COMPANIES) actually hides resume fields from companies.
- Add candidatePhone/candidateEmail to candidate detail, populated only when contactVisible=true, otherwise omitted via JsonInclude(NON_NULL).
- Migrate company candidate list/detail queries from EntityManager to ApplicationRepository Query, aligning with repository conventions.
- Document privacy behavior and add unit tests for visibility and contact logic.
backup/master-local-20261012
pwklppg 2 months ago
parent
commit
6a1e870390
  1. 8
      docs/privacy-settings-api.md
  2. 142
      docs/privacy-visibility-integration.md
  3. 38
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  4. 139
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  5. 17
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java
  6. 11
      mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java
  7. 158
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

8
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. 审计

142
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`、数据库迁移。

38
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<Application, Long>
*/
List<Application> findByJobIdInAndStatusNotInOrderByCreatedAtAsc(java.util.Collection<Long> jobIds,
java.util.Collection<String> 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<Object[]> 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<Object[]> findCompanyCandidates(@Param("companyId") Long companyId,
@Param("jobId") Long jobId,
@Param("status") String status,
@Param("keyword") String keyword,
Pageable pageable);
}

139
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<String> 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<Long> 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<Object[]> 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<Object[]> rows = dataQuery.getResultList();
List<CandidateItemView> 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<Object[]> result = applicationRepository.findCompanyCandidates(
companyId, jobId, status, escapedKeyword,
PageRequest.of(page, size, sortSpec));
List<CandidateItemView> 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<Object[]> 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<Object[]> results = query.getResultList();
List<Object[]> 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();
}

17
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、内部字段。
*
* <p>隐私控制:
* <ul>
* <li>简历要素(candidateSchool/Education/GraduationYear/Skills/Bio/Location)由 {@code resumeVisible}
* 决定,不可见时置 null 被 {@link JsonInclude.Include#NON_NULL} 省略;</li>
* <li>联系方式 candidatePhone/candidateEmail 仅当学生 {@code contactVisible=true} 时填充,否则省略。</li>
* </ul>
* 预留扩展点:如需支持企业查看简历文件,可在此新增受同一可见性策略保护的 {@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<String> candidateSkills;
// 联系方式(仅当学生 contactVisible=true 时由 Service 填充,否则保持 null 被省略)
private String candidatePhone;
private String candidateEmail;
}

11
mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java

@ -134,6 +134,17 @@ public class PrivacySettingsService {
}
}
/**
* 企业是否可查看某学生的联系方式(phone/email)。
*
* <p>读取学生隐私设置中的 {@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);

158
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<Long> mockCountQuery(long total) {
TypedQuery<Long> q = mock(TypedQuery.class);
when(q.getSingleResult()).thenReturn(total);
return q;
}
@SuppressWarnings("unchecked")
private TypedQuery<Object[]> mockDataQuery(Object[]... rows) {
TypedQuery<Object[]> q = mock(TypedQuery.class);
when(q.getResultList()).thenReturn(List.of(rows));
return q;
private Page<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(0L);
TypedQuery<Object[]> 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<Long> countQ = mockCountQuery(1L);
TypedQuery<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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() {

Loading…
Cancel
Save