diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerAssignmentController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerAssignmentController.java index 4f8cf41..c8a7d7b 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerAssignmentController.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerAssignmentController.java @@ -111,6 +111,19 @@ public class MatchmakerAssignmentController { principal.id(), principal.hasPermission("matchmaker.assign"))); } + /** + * 组长团队统计(ZC-MM-005 / E4 新增)。仅 matchmaker.team.assign 持有者可调; + * 以组长本人 organization_id 为边界(NULL 组长统计平台直属)。 + * 返回纯脱敏计数:activeAssignmentCount / perMatchmakerCounts / activeMatchmakerCount, + * 不含任何用户身份或联系方式。 + */ + @GetMapping("/team/statistics") + @PreAuthorize("hasAuthority('matchmaker.team.assign')") + public ApiResponse> teamStatistics( + @AuthenticationPrincipal OperatorPrincipal principal) { + return ApiResponse.ok(assignmentService.teamStatistics(principal.id())); + } + private Instant parseInstant(String raw) { if (raw == null || raw.isBlank()) { return null; diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java index 18d6ff9..462890b 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/controller/MatchmakerOperatorController.java @@ -38,6 +38,11 @@ public class MatchmakerOperatorController { * 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者 * (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列, * 普通红娘(仅 matchmaker.view/service)403。 + * + *

组长视角说明(ZC-MM-005 / E4,治理登记 007):matchmaker_services 表无组织归属字段 + * (organization_id 恒 null 的已知限制),因此待分配队列无法按组长组织过滤, + * 权衡后对分配权持有者全局可读 —— 待分配服务单尚无负责人,队列不含敏感分配关系。 + * 组织归属字段的补齐登记至 ZC-MM-007。 */ @GetMapping("/pending") @PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')") @@ -63,16 +68,23 @@ public class MatchmakerOperatorController { id, assignerOperatorId, targetOperatorId, reason)); } - @GetMapping("/my-services") - public ApiResponse getMyServices( - @AuthenticationPrincipal OperatorPrincipal principal) { - String operatorId = principal.id(); - return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId)); - } + // GET /my-services 已删除(ZC-MM-005 / E4 死端点清理): + // 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空), + // 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径: + // GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。 + // getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。 /** - * Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。 + * 红娘名下服务的全部推荐列表(运营端工作台用)。 * 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。 + * + *

口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments} + * 中本人生效中的分配(active 且未过有效期)→ 该分配对象(user)名下 + * 服务单上的推荐,经 matchmaker_services 桥接;既不按 + * matchmaker_services.assigned_operator_id 残留列过滤,也不按 + * recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status + * 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见 + * (DEC-002 规则7:撤销即失权)。 */ @GetMapping("/recommendations") public ApiResponse>> getRecommendations( @@ -80,16 +92,19 @@ public class MatchmakerOperatorController { return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id())); } + /** + * 服务详情(ZC-MM-005 / E2 防探测 404)。 + * + *

语义变更:非持有人与查无 serviceId 抛出完全相同的 + * BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致 + * (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。 + * 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。 + */ @GetMapping("/services/{id}") public ApiResponse> getServiceDetail( @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String id) { - // Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情 - Map detail = matchmakerServiceService.getServiceDetail(id, principal.id()); - if (detail == null) { - return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND); - } - return ApiResponse.ok(detail); + return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id())); } @PostMapping("/services/{id}/recommend") diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerAssignmentMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerAssignmentMapper.java index ac37852..20d0fa3 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerAssignmentMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerAssignmentMapper.java @@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -66,4 +67,34 @@ public interface MatchmakerAssignmentMapper extends BaseMapper NOW()) " + + "AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " + + "AND o.organization_id IS NOT DISTINCT FROM #{organizationId}") + long countActiveByOrganization(@Param("organizationId") String organizationId); + + /** 某组织范围内各红娘的生效分配数(operatorId/name/activeCount;别名加引号保持驼峰键名) */ + @Select("SELECT o.id AS \"operatorId\", o.name AS \"operatorName\", COUNT(*) AS \"activeCount\" " + + "FROM matchmaker_assignments ma " + + "JOIN operators o ON o.id = ma.matchmaker_operator_id " + + "WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " + + "AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " + + "AND o.organization_id IS NOT DISTINCT FROM #{organizationId} " + + "GROUP BY o.id, o.name ORDER BY \"activeCount\" DESC") + List> countActiveGroupByOperator(@Param("organizationId") String organizationId); + + /** 某组织范围内持有生效分配的红娘数(去重) */ + @Select("SELECT COUNT(DISTINCT ma.matchmaker_operator_id) FROM matchmaker_assignments ma " + + "JOIN operators o ON o.id = ma.matchmaker_operator_id " + + "WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " + + "AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " + + "AND o.organization_id IS NOT DISTINCT FROM #{organizationId}") + long countActiveOperators(@Param("organizationId") String organizationId); } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java index e89adfa..42aff67 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerRecommendationMapper.java @@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapperZC-MM-005 收口(P1-1):范围事实源由 matchmaker_services.assigned_operator_id + * 残留列切换为 matchmaker_assignments 生效中分配(active 且未过有效期)。 + * 语义 = 「本人当前有效分配对象(user)名下服务单上的推荐」—— 分配 revoke/end 后 + * 残留列不再构成可见凭据(DEC-002 规则7:撤销即失权)。 */ @Select("SELECT r.* FROM matchmaker_recommendations r " + - "JOIN matchmaker_services s ON s.id = r.service_id " + - "WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " + + "JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " + + "JOIN matchmaker_assignments a ON a.user_id = s.user_id " + + "AND a.matchmaker_operator_id = #{operatorId} " + + "AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " + + "AND a.deleted_at IS NULL " + "ORDER BY r.created_at DESC") List findByOperatorId(@Param("operatorId") String operatorId); } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java index 128d421..51c0db3 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/mapper/MatchmakerServiceMapper.java @@ -28,44 +28,49 @@ public interface MatchmakerServiceMapper extends BaseMapper NOW()) " + + "AND a.deleted_at IS NULL " + + "WHERE s.status = 'in_service' AND s.deleted_at IS NULL") long countInProgressByOperator(@Param("operatorId") String operatorId); - /** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-002 / D4) */ - @Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " + - "AND CAST(assigned_at AS DATE) = CURRENT_DATE AND deleted_at IS NULL") + /** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-005 收口 / P1-2:按生效分配 join) */ + @Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " + + "JOIN matchmaker_assignments a ON a.user_id = s.user_id " + + "AND a.matchmaker_operator_id = #{operatorId} " + + "AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " + + "AND a.deleted_at IS NULL " + + "WHERE CAST(s.assigned_at AS DATE) = CURRENT_DATE AND s.deleted_at IS NULL") long countTodayByOperator(@Param("operatorId") String operatorId); - /** 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-002 / D4) */ - @Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " + - "AND status = 'completed' AND deleted_at IS NULL") + /** + * 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-005 收口 / P1-2)。 + * 口径说明(与 E1「服务结束失去访问权」一致):按当前生效分配统计 —— + * 分配 end/revoke 后,该用户的历史 completed 服务单不再计入本人完成数; + * 仅当分配仍生效时,其名下 completed 单才计入。 + */ + @Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " + + "JOIN matchmaker_assignments a ON a.user_id = s.user_id " + + "AND a.matchmaker_operator_id = #{operatorId} " + + "AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " + + "AND a.deleted_at IS NULL " + + "WHERE s.status = 'completed' AND s.deleted_at IS NULL") long countCompletedByOperator(@Param("operatorId") String operatorId); @Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}") List findByUser(@Param("userId") String userId, @Param("limit") int limit); - /** 某红娘名下全部服务(含历史)—— 红娘工作台 */ - @Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " + - "AND deleted_at IS NULL ORDER BY created_at DESC") - List findByOperatorId(@Param("operatorId") String operatorId); - - /** - * Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。 - * 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。 - */ - @Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " + - "AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " + - "AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1") - Optional findActiveByOperatorAndUser(@Param("operatorId") String operatorId, - @Param("userId") String userId); - /** * 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。 - * 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。 + * 状态口径(submitted/assigned/in_service/waiting_user)。 * ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} —— * 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。 */ diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java index 7b35072..65946f2 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerAssignmentService.java @@ -225,6 +225,41 @@ public class MatchmakerAssignmentService { .collect(Collectors.toList()); } + /** + * 组长团队统计(ZC-MM-005 / E4 新增):以组长本人 organization_id 为边界 + * (NULL 组长统计平台直属,SQL 侧 IS NOT DISTINCT FROM 复用 NULL=NULL 规则)。 + * 返回纯脱敏计数,不含任何用户身份或联系方式: + *

+ * + *

字段脱敏决策(P2-4,对照《决策》§5.2 字段分类 + 功能权限矩阵): + * perMatchmakerCounts 中的 operatorName(红娘姓名)保留、不做脱敏。依据: + *

    + *
  1. 红娘姓名属于「本人/团队范围内的工作人员标识」,不是用户 PII + * (§5.2 隐私清单的禁放字段是 phone/wechat/idNumber 等用户敏感数据);
  2. + *
  3. 组长统计本团队工作量属其管理职责(DEC-MATCHMAKER-002 红娘组长: + * 团队内进度与工作量管理),「查看脱敏服务统计:组长=团队范围」是既有授权口径;
  4. + *
  5. 组长已可经 GET /assignable-matchmakers 拿到同批红娘姓名 —— 此字段 + * 非新增泄露面,管理范围 ≠ 用户敏感数据。
  6. + *
+ * 决策依据同时登记于 docs/governance/open-questions.md(OQ-MATCHMAKER-002), + * 避免后人误判"统计含姓名=越权"。 + */ + public Map teamStatistics(String leaderOperatorId) { + Operator leader = operatorMapper.findById(leaderOperatorId) + .orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在")); + String orgId = leader.getOrganizationId(); + Map result = new HashMap<>(); + result.put("organizationId", orgId); + result.put("activeAssignmentCount", assignmentMapper.countActiveByOrganization(orgId)); + result.put("perMatchmakerCounts", assignmentMapper.countActiveGroupByOperator(orgId)); + result.put("activeMatchmakerCount", assignmentMapper.countActiveOperators(orgId)); + return result; + } + // ==================== 内部实现 ==================== /** revoke / end 共用的终态迁移(仅允许 active → 终态)。 */ diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java index accfec8..d6c2410 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java @@ -10,10 +10,11 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper; import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper; import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; import com.danyang.match.platform.notification.service.NotificationService; +import com.danyang.match.platform.profile.entity.UserPrivacySettings; import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.shared.constant.Constants; -import com.danyang.match.platform.shared.security.DataScopeGuard; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +40,18 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine * 存量兼容:assignment 表上线前的服务单仍按 assigned_operator_id 判定(005 阶段收敛); *
  • assignService 同事务联动「创建分配 + 服务单置 assigned」。
  • * + * + *

    ZC-MM-005(E1 双轨切纯判定器)变更说明: + *

      + *
    • 删除 assigned_operator_id 回退分支:002 的 "assignment 优先 + + * assigned_operator_id 回退" 有真漏洞 —— 分配 revoke/end 后服务单残留 + * assigned_operator_id,回退分支会放行已失去访问权的红娘(违反 DEC-002 规则7)。 + * V053 已回填全部存量进行中服务单,数据一致性已建立;终态服务单不回填是有意设计 + * (服务结束即失去访问权)。{@link ServiceAssignmentPolicy} 成为唯一事实源;
    • + *
    • 读详情(getServiceDetail)改用 E2 防探测 404:非持有人响应与"不存在"逐字段一致, + * 删除 viewerIsOwner 双态与"非负责人脱敏详情"整段;
    • + *
    • 行为变化:红娘读自己历史 completed 服务单详情将 404(符合 DEC-002 决策)。
    • + *
    */ @Service @RequiredArgsConstructor @@ -50,10 +63,10 @@ public class MatchmakerServiceService { private final MatchmakerRecommendationMapper recommendationMapper; private final MatchmakerFollowupMapper followupMapper; private final UserProfileMapper userProfileMapper; + private final UserPrivacySettingsMapper userPrivacySettingsMapper; private final NotificationService notificationService; private final AuditLogMapper auditLogMapper; private final ServiceAssignmentPolicy serviceAssignmentPolicy; - private final DataScopeGuard dataScopeGuard; private final MatchmakerAssignmentService matchmakerAssignmentService; /** @@ -151,9 +164,8 @@ public class MatchmakerServiceService { * * Phase 13 复验修复(D3):补归属校验。 * ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments - * 是服务对象归属的唯一事实源);存量兼容:assignment 表上线前的服务单仍按 - * assigned_operator_id 判定。拒绝统一走 DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403), - * 不再抛裸 IllegalStateException。 + * 是服务对象归属的唯一事实源,无任何 assigned_operator_id 回退);拒绝统一走 + * DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),不再抛裸 IllegalStateException。 * * AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。 */ @@ -287,10 +299,20 @@ public class MatchmakerServiceService { /** * 获取红娘工作台数据。 * - * ZC-MM-002(D4):today / completed 改为本人口径(该红娘名下的服务), + * ZC-MM-002(D4):today / completed 改为本人口径(该红娘名下的服务), * pendingCount 保留全局口径(待分配队列是分配权持有者的全局视图); * inProgress 统计的状态口径由错误的 'processing' 修正为 'in_service' * ('processing' 不在 V009 chk_matchmaker_status 白名单,恒为 0)。 + * + *

    ZC-MM-005 收口(P1-2):inProgress / today / completed 三项计数统一切换为 + * matchmaker_assignments 生效中分配口径(SQL 侧 join assignments,见 + * MatchmakerServiceMapper 计数查询)。口径说明: + *

      + *
    • 三项均只统计「本人当前生效分配对象名下」的服务单;
    • + *
    • 分配 end / revoke 后(含他人接手后原红娘),该用户的服务单 + * 不再计入原红娘的 dashboard 计数(DEC-002 规则7:服务结束失去访问权);
    • + *
    • completed 计数同理:历史 completed 单仅在分配仍生效时计入本人。
    • + *
    */ public Map getDashboard(String operatorId) { long pendingCount = serviceMapper.countSubmitted(); @@ -309,50 +331,47 @@ public class MatchmakerServiceService { } /** - * 获取我的服务列表(红娘工作台) - */ - public List getOperatorServices(String operatorId) { - return serviceMapper.findByOperatorId(operatorId); - } - - /** - * 获取服务详情 + * 获取服务详情(ZC-MM-005 / E1+E2 重构)。 * - * Phase 13 复验修复(D4):改为按调用者授权范围返回。 - * 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取 - * **任意** serviceId 的详情,其中包含该用户的完整资料 - * (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。 + *

    此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner + * 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行 + * (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。 * - * 现语义: - * - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups) - * - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情 - * (不含 userProfile;followups 正文也不返回,仅给条数) + *

    现语义(判定器唯一事实源,无回退): + *

      + *
    • 查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");
    • + *
    • 非持有人(无生效中的分配)→ {@link ServiceAssignmentPolicy} + * #assertActiveAssignmentOrNotFound:先落 DATA_SCOPE_DENIED 审计, + * 再抛与"查无"完全相同的 NOT_FOUND 异常 —— 两种失败响应逐字段一致,防探测;
    • + *
    • 持有人 → 完整详情(申请人 userId / 诉求描述 / 白名单 userProfile / 跟进正文 / + * 推荐列表),不再有 viewerIsOwner 双态字段。
    • + *
    * - * @param serviceId 服务 ID + *

    行为变化:红娘读自己历史 completed 服务单详情将 404(分配已终态,符合 DEC-002)。 + * + * @param serviceId 服务 ID * @param requesterId 调用者 operator ID */ public Map getServiceDetail(String serviceId, String requesterId) { MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); - if (service == null) return null; + if (service == null) { + // 与"非持有人"路径抛出完全相同的异常 → 响应体逐字段一致(防探测,E2) + throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); + } - // ZC-MM-002(D4):负责人判定 = assignment 判定器优先,存量服务单回退 assigned_operator_id - boolean isOwner = requesterId != null && - (service.getUserId() == null - ? requesterId.equals(service.getAssignedOperatorId()) - : serviceAssignmentPolicy.canAccessUser(requesterId, service.getUserId()) - || requesterId.equals(service.getAssignedOperatorId())); + // E1:判定器唯一事实源;E2:非持有人防探测 404(先审计后 NOT_FOUND) + serviceAssignmentPolicy.assertActiveAssignmentOrNotFound( + requesterId, service.getUserId(), "matchmaker_service", serviceId); Map detail = new HashMap<>(); detail.put("serviceId", service.getId()); detail.put("serviceType", service.getServiceType()); detail.put("status", service.getStatus()); - detail.put("assignedOperatorId", service.getAssignedOperatorId()); detail.put("createdAt", service.getCreatedAt()); detail.put("assignedAt", service.getAssignedAt()); detail.put("completedAt", service.getCompletedAt()); - detail.put("viewerIsOwner", isOwner); - // 加载推荐列表(推荐是服务进展的必要摘要,两者都可见) + // 推荐列表(服务进展摘要) List recs = recommendationMapper.findByService(serviceId); detail.put("recommendations", recs.stream().map(r -> { Map map = new HashMap<>(); @@ -364,55 +383,72 @@ public class MatchmakerServiceService { return map; }).collect(Collectors.toList())); - if (isOwner) { - // 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文 - detail.put("userId", service.getUserId()); - detail.put("description", service.getDescription()); - userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> { - Map userProfile = new HashMap<>(); - userProfile.put("displayName", profile.getDisplayName()); - userProfile.put("gender", profile.getGender()); - userProfile.put("age", calculateAge(profile.getBirthDate())); - userProfile.put("regionName", profile.getRegionName()); - userProfile.put("educationCode", profile.getEducationCode()); - userProfile.put("occupationCode", profile.getOccupationCode()); - userProfile.put("maritalStatus", profile.getMaritalStatus()); - detail.put("userProfile", userProfile); - }); - detail.put("followups", followupMapper.findByServiceId(serviceId)); - } else { - // 非负责人:只给跟进条数作为进度摘要,不返回正文 - detail.put("followupCount", followupMapper.findByServiceId(serviceId).size()); - } + // 仅持有人可见:申请人 userId / 诉求描述 / 白名单用户资料 / 跟进正文 + detail.put("userId", service.getUserId()); + detail.put("description", service.getDescription()); + UserPrivacySettings privacy = userPrivacySettingsMapper.findByUserId(service.getUserId()) + .orElse(null); + userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> + detail.put("userProfile", assembleMatchmakerUserProfile(profile, privacy))); + detail.put("followups", followupMapper.findByServiceId(serviceId)); return detail; } + /** + * 红娘侧用户资料装配(ZC-MM-005 / E3 字段白名单显式化)。 + * + *

    逐字段对照《安全、隐私、合规与风控体系设计》§5.2 字段级隐私清单: + *

      + *
    • displayName —— 基础展示字段,允许
    • + *
    • gender —— 基础展示字段,允许
    • + *
    • age —— 由 birthDate 计算的派生值(不输出原始 birthDate)
    • + *
    • regionName —— 地区展示名(非精确坐标),允许
    • + *
    • educationCode —— 枚举码,允许
    • + *
    • occupationCode —— 枚举码,受隐私开关 showOccupation 控制: + * 用户明确关闭(false)时不输出;隐私记录缺失或开关未设置(null)按缺省放行
    • + *
    • maritalStatus —— 枚举码,允许
    • + *
    + * §5.2 禁放字段(phone/mobile/wechat/idNumber/birthDate/address/id_card 等) + * 一律不进入装配;守卫见 MatchmakerFieldPrivacyGuardTest(源码级白名单扫描)。 + * + * @param profile 用户资料(可空字段自然输出 null,由前端按缺失处理) + * @param privacy 用户隐私设置;null 表示用户未设置过(缺省=全放行) + */ + private Map assembleMatchmakerUserProfile(UserProfile profile, + UserPrivacySettings privacy) { + // 缺省=全放行:仅在用户显式关闭(showOccupation == false)时隐藏职业 + boolean showOccupation = privacy == null || !Boolean.FALSE.equals(privacy.getShowOccupation()); + Map userProfile = new HashMap<>(); + userProfile.put("displayName", profile.getDisplayName()); + userProfile.put("gender", profile.getGender()); + // 派生年龄:不输出原始 birthDate(§5.2 禁放) + userProfile.put("age", calculateAge(profile.getBirthDate())); + userProfile.put("regionName", profile.getRegionName()); + userProfile.put("educationCode", profile.getEducationCode()); + if (showOccupation) { + userProfile.put("occupationCode", profile.getOccupationCode()); + } + userProfile.put("maritalStatus", profile.getMaritalStatus()); + return userProfile; + } + private int calculateAge(java.time.LocalDate birthDate) { if (birthDate == null) return 0; return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears(); } /** - * 服务归属校验(ZC-MM-002 / D4)。 + * 服务归属校验(ZC-MM-005 / E1 纯判定器版)。 * - * 判定口径: - * 1. assignment 判定器优先 —— 操作者对该服务申请人持有生效中的 matchmaker_assignments - * 分配(含惰性过期);判定无效时由 Policy 走 DataScopeGuard.deny - * (落 DATA_SCOPE_DENIED 审计 + 抛 AccessDeniedException → 403)。 - * 2. 存量兼容:assignment 表上线前的服务单仍按 assigned_operator_id 判定 - * (与 V053 存量回填语义一致;005 阶段读口径统一时收敛)。 + *

    无任何回退:matchmaker_assignments 是服务对象归属的唯一事实源。 + * 002 的 assigned_operator_id 回退分支已删除 —— 分配 revoke/end 后服务单残留的 + * assigned_operator_id 不再构成访问凭据(DEC-002 规则7:撤销即失权)。 + * 写接口口径:判定无效 → DataScopeGuard.deny(DATA_SCOPE_DENIED 审计 + 403)。 */ private void assertServiceAccess(String operatorId, MatchmakerServiceRecord service) { - if (operatorId != null && service.getUserId() != null - && serviceAssignmentPolicy.canAccessUser(operatorId, service.getUserId())) { - return; - } - if (operatorId != null && operatorId.equals(service.getAssignedOperatorId())) { - return; - } - dataScopeGuard.deny(operatorId, "matchmaker_service", service.getId(), - "您不是此服务对象的负责人,禁止操作"); + serviceAssignmentPolicy.assertActiveAssignment(operatorId, service.getUserId(), + "matchmaker_service"); } diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java index 0403268..955e696 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ServiceAssignmentPolicy.java @@ -1,30 +1,46 @@ package com.danyang.match.platform.matchmaker.service; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.security.DataScopeGuard; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; /** - * 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6)。 + * 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6;ZC-MM-005 / E1-E2 增强)。 * *

    红娘对某个用户能否读/写,唯一事实源是 matchmaker_assignments 里 * 「生效中的分配」(active 且未过有效期)。所有读路径前先 {@link #expireOverdue} * 惰性过期(无定时任务,D5 决策),保证过期分配立即失效。 * - *

    判定无效时统一走 {@link DataScopeGuard#deny}:先落 DATA_SCOPE_DENIED 审计, - * 再抛 AccessDeniedException(全局处理器映射为 403 + FORBIDDEN)。 + *

    判定无效时的两种处置: + *

      + *
    • {@link #assertActiveAssignment}:写接口口径 —— 落 DATA_SCOPE_DENIED 审计 + + * 抛 AccessDeniedException(403);
    • + *
    • {@link #assertActiveAssignmentOrNotFound}(ZC-MM-005 / E2):读详情口径 —— + * 先落 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),再抛 + * BusinessException(NOT_FOUND)(响应 404,与"资源不存在"逐字段一致), + * 防止枚举 serviceId 探测资源存在性。
    • + *
    */ +@Slf4j @Component @RequiredArgsConstructor public class ServiceAssignmentPolicy { private final MatchmakerAssignmentMapper assignmentMapper; private final DataScopeGuard dataScopeGuard; + private final AuditLogMapper auditLogMapper; /** * 惰性过期:把 expires_at 已届满的 active 分配置为 expired 并落库。 @@ -63,6 +79,44 @@ public class ServiceAssignmentPolicy { return null; // deny 必然抛出,此行不可达 } + /** + * 读详情口径的断言(ZC-MM-005 / E2 防探测 404)。 + * + *

    无效时先写 DATA_SCOPE_DENIED 审计(字段口径与 {@link DataScopeGuard#deny} 对齐, + * 语义上这是一次越权尝试),再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}。 + * 全局异常处理器将其映射为 404 响应体 {@code {success:false, code:NOT_FOUND, + * message:"服务不存在"}} —— 与查询一个真正不存在的 serviceId 的响应逐字段一致, + * 调用方无法区分"无权"与"不存在",杜绝按 serviceId 枚举探测。 + * + *

    仅用于读详情路径;写接口维持 {@link #assertActiveAssignment} 的 403 现状。 + * + * @param resourceType 审计 resourceType(如 matchmaker_service) + * @param resourceId 审计 resourceId(被探测的 serviceId) + * @return 生效中的分配记录(永不返回 null) + */ + public MatchmakerAssignment assertActiveAssignmentOrNotFound(String operatorId, String userId, + String resourceType, + String resourceId) { + Optional found = findActiveAssignment(operatorId, userId); + if (found.isPresent()) { + return found.get(); + } + AuditLog audit = new AuditLog(); + audit.setId(UUID.randomUUID().toString()); + audit.setOperatorId(operatorId); + audit.setAction("DATA_SCOPE_DENIED"); + audit.setResourceType(resourceType); + audit.setResourceId(resourceId); + audit.setCreatedAt(Instant.now()); + try { + auditLogMapper.insert(audit); + } catch (RuntimeException exception) { + log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}", + operatorId, resourceType, resourceId, exception); + } + throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); + } + /** 某红娘名下全部生效中的分配(GET /my 口径),读前惰性过期。 */ public List findActiveByOperator(String operatorId) { expireOverdue(); diff --git a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java index 4345f64..f840fcc 100644 --- a/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java +++ b/backend/src/main/java/com/danyang/match/platform/referral/service/ReferralService.java @@ -30,7 +30,7 @@ public class ReferralService { private final ActivityRegistrationMapper activityRegistrationMapper; private final OperatorMapper operatorMapper; private final NotificationService notificationService; - private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper; + private final com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy serviceAssignmentPolicy; /** * Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。 @@ -39,16 +39,20 @@ public class ReferralService { * 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站, * 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。 * - * 修复后:先断言存在进行中的 matchmaker_service(assigned_operator_id=红娘, user_id=目标用户), - * 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。 + * 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。 + * + *

    ZC-MM-005 收口(P1-3):授权事实源由 matchmaker_services.assigned_operator_id 残留列 + * ({@code findActiveByOperatorAndUser},按服务单 status 判定)切换为 + * {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy#canAccessUser} + * 判定器 —— 与写接口 recommend / followup 同口径(matchmaker_assignments 生效中分配)。 + * 分配 revoke / end 后残留列不再构成转介凭据(DEC-002 规则7:撤销即失权)。 + * 拒绝统一走 {@code DataScopeGuard.deny}(DATA_SCOPE_DENIED 审计 + 403 AccessDeniedException)。 */ private void assertMatchmakerServesUser(String matchmakerId, String userId) { if (userId == null || userId.isBlank()) { throw new IllegalArgumentException("缺少转介目标用户"); } - matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId) - .orElseThrow(() -> new IllegalStateException( - "您不是该用户的服务负责人,无法发起转介")); + serviceAssignmentPolicy.assertActiveAssignment(matchmakerId, userId, "matchmaker_referral"); } @Transactional diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentPermissionTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentPermissionTest.java index bb65fcb..afe3e53 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentPermissionTest.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerAssignmentPermissionTest.java @@ -44,10 +44,16 @@ class MatchmakerAssignmentPermissionTest { } String expr = preAuthorize.value(); boolean isReadMy = method.getName().equals("my"); + boolean isTeamStatistics = method.getName().equals("teamStatistics"); if (isReadMy) { if (!expr.contains("matchmaker.service")) { violations.add("my: 读接口必须要求 matchmaker.service,实际=" + expr); } + } else if (isTeamStatistics) { + // ZC-MM-005/T03:纯组长数据,只要求 team.assign(更强而非更弱) + if (!expr.contains("matchmaker.team.assign")) { + violations.add("teamStatistics: 必须要求 matchmaker.team.assign,实际=" + expr); + } } else { boolean hasAssignPermission = expr.contains("matchmaker.assign") && expr.contains("matchmaker.team.assign"); @@ -86,6 +92,38 @@ class MatchmakerAssignmentPermissionTest { "D3 决策:claim(红娘接单)端点必须彻底删除,归属由 matchmaker_assignments 建模"); } + @Test + @DisplayName("ZC-MM-005/E4:OperatorController 无 my-services 残留(死端点已删除)") + void operatorControllerMustNotHaveMyServicesResidue() { + List residue = new ArrayList<>(); + for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) { + String lower = method.getName().toLowerCase(); + if (lower.contains("myservices") || lower.contains("my_services")) { + residue.add("方法名残留: " + method.getName()); + } + if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("my-services"))) { + residue.add("路径残留: " + method.getName()); + } + } + assertEquals(List.of(), residue, + "GET /my-services 是死端点(按 C 端 user_id 查 operator 恒空、前后端零调用)," + + "替代路径为 GET /operator/matchmaker/assignments/my"); + } + + @Test + @DisplayName("ZC-MM-005/T03:team/statistics 端点必须显式限定 matchmaker.team.assign") + void teamStatisticsMustRequireTeamAssignPermission() { + Method statistics = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods()) + .filter(m -> m.getName().equals("teamStatistics")) + .findFirst() + .orElseThrow(() -> new AssertionError("teamStatistics 端点不存在")); + PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(statistics, PreAuthorize.class); + assertNotNull(preAuthorize, "team/statistics 缺少 @PreAuthorize"); + assertTrue(preAuthorize.value().contains("matchmaker.team.assign"), + "team/statistics 必须限定 matchmaker.team.assign(纯组长数据),实际=" + + preAuthorize.value()); + } + private List paths(java.lang.reflect.AnnotatedElement element) { RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class); return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0 diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java new file mode 100644 index 0000000..7fd5725 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerDataScope005Test.java @@ -0,0 +1,536 @@ +package com.danyang.match.platform.matchmaker; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController; +import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment; +import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation; +import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper; +import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; +import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService; +import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService; +import com.danyang.match.platform.permission.entity.Operator; +import com.danyang.match.platform.permission.mapper.OperatorMapper; +import com.danyang.match.platform.referral.service.ReferralService; +import com.danyang.match.platform.profile.entity.UserPrivacySettings; +import com.danyang.match.platform.profile.entity.UserProfile; +import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper; +import com.danyang.match.platform.profile.mapper.UserProfileMapper; +import com.danyang.match.platform.shared.constant.Constants; +import com.danyang.match.platform.shared.enums.ApiResponseCode; +import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; +import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ZC-MM-005(统一数据范围与字段隐私)验收测试 —— 读侧纯化 + 防探测 404 + 字段白名单 + 组长统计。 + * + *

    覆盖: + *

      + *
    • MM-PRIVACY-001(负向×3):非持有人读详情 404+审计;查无 serviceId 响应与前者 + * 逐字段一致(防探测);撤销后原持有人再读 404;
    • + *
    • MM-AUTH-001 读侧(正向):持分配红娘读完整详情、无 viewerIsOwner 双态残留;
    • + *
    • MM-AUTH-003 读侧(负向):读他人对象 → 404+审计;
    • + *
    • MM-LIFECYCLE-001 读侧:end/revoke/expired 后 404;
    • + *
    • 组长团队统计正例 + 普通红娘 403+审计(负向);
    • + *
    • showOccupation 隐私开关:false 隐藏 occupationCode,true/未设置放行。
    • + *
    + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MatchmakerDataScope005Test { + + @Autowired private MatchmakerServiceService matchmakerServiceService; + @Autowired private MatchmakerAssignmentService assignmentService; + @Autowired private MatchmakerServiceMapper serviceMapper; + @Autowired private MatchmakerAssignmentMapper assignmentMapper; + @Autowired private MatchmakerRecommendationMapper recommendationMapper; + @Autowired private OperatorMapper operatorMapper; + @Autowired private UserMapper userMapper; + @Autowired private UserProfileMapper userProfileMapper; + @Autowired private UserPrivacySettingsMapper userPrivacySettingsMapper; + @Autowired private AuditLogMapper auditLogMapper; + @Autowired private MatchmakerAssignmentController assignmentController; + @Autowired private ReferralService referralService; + + private String leaderA; // 组长,org X + private String mmB; // 红娘,org X + private String mmC; // 红娘,org Y + private String mmD; // 红娘,org X + private String user1; + private String user2; + private String user3; + + private static final String ORG_X = "33333333-3333-3333-3333-333333333331"; + private static final String ORG_Y = "33333333-3333-3333-3333-333333333332"; + + @BeforeEach + void setUp() { + leaderA = createOperator("组长A-005", ORG_X); + mmB = createOperator("红娘B-005", ORG_X); + mmC = createOperator("红娘C-005", ORG_Y); + mmD = createOperator("红娘D-005", ORG_X); + user1 = createUserWithProfile("ds-user1"); + user2 = createUserWithProfile("ds-user2"); + user3 = createUserWithProfile("ds-user3"); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + // ==================== 数据构造 ==================== + + private String createOperator(String name, String organizationId) { + Operator op = new Operator(); + op.setName(name); + op.setOrganizationId(organizationId); + op.setUsername("ds_" + name + "_" + System.nanoTime()); + op.setPasswordHash("test-hash"); + op.setStatus("active"); + operatorMapper.insert(op); + return op.getId(); + } + + private String createUserWithProfile(String tag) { + User u = new User(); + u.setWechatOpenid("ds_" + tag + "_" + System.nanoTime()); + u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + userMapper.insert(u); + + UserProfile profile = new UserProfile(); + profile.setUserId(u.getId()); + profile.setDisplayName(tag); + profile.setGender("female"); + profile.setBirthDate(java.time.LocalDate.of(1995, 6, 15)); + profile.setRegionName("丹阳市云阳街道"); + profile.setEducationCode("bachelor"); + profile.setOccupationCode("teacher"); + profile.setMaritalStatus("never_married"); + profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED); + userProfileMapper.insert(profile); + return u.getId(); + } + + private String createService(String userId, String operatorId) { + MatchmakerServiceRecord svc = new MatchmakerServiceRecord(); + svc.setUserId(userId); + svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER); + svc.setDescription("希望找一位本地稳定的伴侣"); + // submitted:createAssignment 会联动把服务单置 assigned(A1 同事务联动语义) + svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED); + svc.setAssignedOperatorId(operatorId); + serviceMapper.insert(svc); + return svc.getId(); + } + + /** 平台授权路径创建分配(绕过组织范围校验以构造前置状态),返回 assignmentId */ + private String createAssignment(String operatorId, String userId, String serviceId) { + MatchmakerAssignment a = assignmentService.create(leaderA, true, userId, operatorId, + serviceId, "005 测试前置分配", null); + return a.getId(); + } + + private void authenticate(String operatorId, String... permissions) { + Set permSet = Set.of(permissions); + var authorities = Stream.concat( + Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")), + permSet.stream().map(SimpleGrantedAuthority::new)).toList(); + SecurityContextHolder.getContext().setAuthentication( + UsernamePasswordAuthenticationToken.authenticated( + new OperatorPrincipal(operatorId, permSet), null, authorities)); + } + + private long countAudits(String action, String operatorId) { + return auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, action) + .eq(operatorId != null, AuditLog::getOperatorId, operatorId)); + } + + private long countDeniedAuditsForResource(String operatorId, String resourceId) { + return auditLogMapper.selectCount(new LambdaQueryWrapper() + .eq(AuditLog::getAction, "DATA_SCOPE_DENIED") + .eq(AuditLog::getOperatorId, operatorId) + .eq(AuditLog::getResourceId, resourceId)); + } + + private BusinessException notFoundCall(String operatorId, String serviceId) { + try { + matchmakerServiceService.getServiceDetail(serviceId, operatorId); + } catch (BusinessException e) { + return e; + } + throw new AssertionError("预期抛出 BusinessException(NOT_FOUND),但正常返回了"); + } + + // ==================== MM-PRIVACY-001(负向×3) ==================== + + @Test + @DisplayName("MM-PRIVACY-001a(负向): 非持有人读服务单 → 404 + DATA_SCOPE_DENIED 审计(语义403)") + void mmPrivacy001a_nonHolderReadDetailNotFoundAndAudited() { + String serviceId = createService(user1, mmB); + createAssignment(mmB, user1, serviceId); + + long before = countDeniedAuditsForResource(mmC, serviceId); + BusinessException e = notFoundCall(mmC, serviceId); + + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "非持有人响应必须是 404(防探测)"); + assertEquals("服务不存在", e.getMessage()); + assertEquals(before + 1, countDeniedAuditsForResource(mmC, serviceId), + "越权读尝试必须先落 DATA_SCOPE_DENIED 审计(语义 403)"); + } + + @Test + @DisplayName("MM-PRIVACY-001b(负向): 查无 serviceId 的响应与非持有人 404 逐字段一致(防探测)") + void mmPrivacy001b_notFoundResponseIndistinguishableFromDenied() { + String serviceId = createService(user1, mmB); + createAssignment(mmB, user1, serviceId); + + BusinessException denied = notFoundCall(mmC, serviceId); // 非持有人(真实存在) + BusinessException missing = notFoundCall(mmC, "no-such-service-id"); // 查无 + + // 全局异常处理器把 BusinessException 映射为 ApiResponse.error(code, message), + // code+message 一致 ⇒ 响应体逐字段一致(success/code/message),无法区分"无权"与"不存在" + assertEquals(denied.getCode(), missing.getCode(), "HTTP/业务码必须一致"); + assertEquals(denied.getMessage(), missing.getMessage(), "响应 message 必须一致"); + assertEquals(ApiResponseCode.NOT_FOUND, missing.getCode()); + assertEquals("服务不存在", missing.getMessage()); + } + + @Test + @DisplayName("MM-PRIVACY-001c(负向): 撤销后原持有人再读详情 → 404(E1:无 assigned_operator_id 回退)") + void mmPrivacy001c_revokedHolderReadDetailNotFound() { + String serviceId = createService(user1, mmB); + String assignmentId = createAssignment(mmB, user1, serviceId); + + // 撤销前持有人可读 + Map detail = matchmakerServiceService.getServiceDetail(serviceId, mmB); + assertNotNull(detail); + + assignmentService.revoke(assignmentId, leaderA, false, "005 撤销验证"); + + // E1 核心断言:撤销后服务单仍残留 assigned_operator_id=mmB,但回退分支已删 → 404 + assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(), + "前置确认:服务单 assigned_operator_id 残留(回退分支必须对此免疫)"); + long before = countDeniedAuditsForResource(mmB, serviceId); + BusinessException e = notFoundCall(mmB, serviceId); + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode()); + assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId), + "原持有人失权后的读取也必须落 DATA_SCOPE_DENIED 审计"); + } + + // ==================== MM-AUTH-001 读侧(正向) ==================== + + @Test + @DisplayName("MM-AUTH-001 读侧: 持分配红娘读详情 200 语义、完整字段、无 viewerIsOwner 双态残留") + void mmAuth001_readSideHolderGetsFullDetailWithoutDualState() { + String serviceId = createService(user1, mmB); + createAssignment(mmB, user1, serviceId); + + Map detail = matchmakerServiceService.getServiceDetail(serviceId, mmB); + + assertNotNull(detail); + assertEquals(user1, detail.get("userId")); + assertEquals("希望找一位本地稳定的伴侣", detail.get("description")); + assertTrue(detail.containsKey("userProfile"), "持有人可见白名单用户资料"); + assertTrue(detail.containsKey("followups"), "持有人可见跟进正文"); + assertTrue(detail.containsKey("recommendations"), "持有人可见推荐列表"); + // E1:双态残留字段必须消失 + assertFalse(detail.containsKey("viewerIsOwner"), "viewerIsOwner 双态字段已删除"); + assertFalse(detail.containsKey("followupCount"), "脱敏分支的 followupCount 摘要已随 404 消失"); + // E3:userProfile 白名单键集合 + @SuppressWarnings("unchecked") + Map userProfile = (Map) detail.get("userProfile"); + Set whitelist = Set.of("displayName", "gender", "age", "regionName", + "educationCode", "occupationCode", "maritalStatus"); + assertTrue(whitelist.containsAll(userProfile.keySet()), + "userProfile 键必须落在白名单内,实际=" + userProfile.keySet()); + assertEquals("丹阳市云阳街道", userProfile.get("regionName")); + } + + // ==================== MM-AUTH-003 读侧(负向) ==================== + + @Test + @DisplayName("MM-AUTH-003 读侧(负向): B 读 D 的服务对象详情 → 404 + 审计") + void mmAuth003_readSideCrossHolderAccessDeniedAndAudited() { + String serviceId = createService(user3, mmD); + createAssignment(mmD, user3, serviceId); + + long before = countDeniedAuditsForResource(mmB, serviceId); + BusinessException e = notFoundCall(mmB, serviceId); + assertEquals(ApiResponseCode.NOT_FOUND, e.getCode()); + assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId)); + } + + // ==================== MM-LIFECYCLE-001 读侧 ==================== + + @Test + @DisplayName("MM-LIFECYCLE-001 读侧: end 后 404;expires_at 届满惰性置 expired 后 404") + void mmLifecycle001_readSideTerminalStatesAllNotFound() { + // end 后 404 + String serviceId1 = createService(user1, mmB); + String a1 = createAssignment(mmB, user1, serviceId1); + assignmentService.end(a1, leaderA, false, "005 服务完结"); + assertEquals(ApiResponseCode.NOT_FOUND, + notFoundCall(mmB, serviceId1).getCode(), "end 后读详情必须 404"); + + // expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 惰性 expired → 404 + String serviceId2 = createService(user2, mmB); + MatchmakerAssignment a2 = assignmentService.create(leaderA, true, user2, mmB, + serviceId2, "短期分配", Instant.now().minusSeconds(3600)); + assertEquals(ApiResponseCode.NOT_FOUND, + notFoundCall(mmB, serviceId2).getCode(), "已过期分配读详情必须 404"); + assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED, + assignmentMapper.findById(a2.getId()).orElseThrow().getStatus(), + "惰性过期必须落库"); + } + + // ==================== E1 边界:active 分配 × completed 服务单 ==================== + + @Test + @DisplayName("E1 边界: active 分配 + completed 服务单 → 持有人可读详情(200);分配终态(end)后同单再读→404") + void activeAssignmentWithCompletedServiceStillReadableThen404AfterEnd() { + String serviceId = createService(user1, mmB); + String assignmentId = createAssignment(mmB, user1, serviceId); + + // 推进服务单到 in_service 并完成。completeService 仅改服务单状态、不结束分配 + // (服务单与分配是两条独立生命周期) + MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId); + svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE); + serviceMapper.updateById(svc); + matchmakerServiceService.completeService(serviceId, mmB); + assertEquals(Constants.MATCHMAKER_STATUS_COMPLETED, + serviceMapper.selectById(serviceId).getStatus()); + + // 分配仍处于 active → mmB 在该用户数据范围内,读 completed 单应放行(200,非 404)。 + // 说明:决策"completed 服务单 404"语义实为"分配已终态则 404"(代码 L348 注释"分配已终态"), + // 本用例锁定"分配仍 active"时 completed 单仍属本人数据范围、可正常回看。 + Map detail = matchmakerServiceService.getServiceDetail(serviceId, mmB); + assertNotNull(detail, "active 分配持有人读 completed 服务单应放行(200),不应 404"); + assertEquals(user1, detail.get("userId")); + + // 对照:分配 end(终态)后,同一 completed 单再读 → 404(E1:防 assigned_operator_id 回退) + assignmentService.end(assignmentId, leaderA, false, "005 边界:服务完结后结束分配"); + assertEquals(ApiResponseCode.NOT_FOUND, notFoundCall(mmB, serviceId).getCode(), + "分配终态后读 completed 服务单详情必须 404"); + } + + // ==================== 组长团队统计(T03) ==================== + + @Test + @DisplayName("组长统计: 以组长 org 为边界(跨组织不计入);NULL 组长统计平台直属") + void teamStatisticsScopedByLeaderOrganization() { + createAssignment(mmB, user1, null); + createAssignment(mmD, user3, null); + createAssignment(mmC, user2, null); // org Y,不应计入 org X 组长的统计 + + Map stats = assignmentService.teamStatistics(leaderA); + + assertEquals(ORG_X, stats.get("organizationId")); + assertEquals(2L, stats.get("activeAssignmentCount"), "跨组织分配不得计入"); + assertEquals(2L, stats.get("activeMatchmakerCount")); + @SuppressWarnings("unchecked") + List> perMatchmaker = (List>) stats.get("perMatchmakerCounts"); + assertEquals(2, perMatchmaker.size()); + assertTrue(perMatchmaker.stream().allMatch(row -> + Set.of("operatorId", "operatorName", "activeCount").equals(row.keySet()) + || row.keySet().containsAll(Set.of("operatorId", "operatorName", "activeCount"))), + "分组行键必须为 operatorId/operatorName/activeCount(脱敏,无联系方式),实际=" + perMatchmaker); + + // NULL 组长 → 平台直属边界(IS NOT DISTINCT FROM NULL) + String platformLeader = createOperator("平台直属组长-005", null); + String platformMm = createOperator("平台直属红娘-005", null); + String platformUser = createUserWithProfile("ds-platform-user"); + assignmentService.create(platformLeader, true, platformUser, platformMm, null, + "平台直属分配", null); + Map platformStats = assignmentService.teamStatistics(platformLeader); + assertNull(platformStats.get("organizationId")); + assertEquals(1L, platformStats.get("activeAssignmentCount"), + "NULL 组长统计平台直属(IS NOT DISTINCT FROM NULL)"); + // 该平台直属分配不得计入 orgX 组长的统计 + assertEquals(2L, assignmentService.teamStatistics(leaderA).get("activeAssignmentCount"), + "平台直属分配不得计入有组织组长的统计"); + } + + @Test + @DisplayName("负向: 普通红娘(无 team.assign)调 team/statistics → 403 + AUTHORIZATION_DENIED 审计") + void normalMatchmakerCannotReadTeamStatistics() { + // 必须走 Spring 注入的代理 Bean(@PreAuthorize 方法级安全在代理上生效) + authenticate(mmB, "matchmaker.view", "matchmaker.service"); + + long before = countAudits("AUTHORIZATION_DENIED", mmB); + assertThrows(AccessDeniedException.class, + () -> assignmentController.teamStatistics( + new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))), + "team/statistics 仅组长(team.assign)可读"); + assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB), + "授权拒绝必须落 AUTHORIZATION_DENIED 审计"); + } + + // ==================== showOccupation 隐私开关(E3) ==================== + + // ==================== P1-1:推荐列表切判定器(读路径) ==================== + + private MatchmakerRecommendation createRecommendation(String serviceId, String userId, + String targetUserId, String operatorId) { + MatchmakerRecommendation rec = new MatchmakerRecommendation(); + rec.setId(UUID.randomUUID().toString()); + rec.setServiceId(serviceId); + rec.setUserId(userId); + rec.setTargetUserId(targetUserId); + rec.setOperatorId(operatorId); + rec.setReason("005 收口测试推荐"); + rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW); + recommendationMapper.insert(rec); + return rec; + } + + @Test + @DisplayName("P1-1: 推荐列表按生效分配过滤 —— revoke 后不再可见 assigned_operator_id 残留列放行的推荐") + void recommendationsFollowActiveAssignmentsNotResidualColumn() { + String serviceId = createService(user1, mmB); + String assignmentId = createAssignment(mmB, user1, serviceId); + MatchmakerRecommendation rec = createRecommendation(serviceId, user1, user2, mmB); + + // 正向:active 分配持有人可见本人分配对象名下服务的推荐 + List> visible = matchmakerServiceService.getRecommendationsByOperator(mmB); + assertEquals(1, visible.size(), "active 分配持有人应看到该推荐"); + assertEquals(rec.getId(), visible.get(0).get("id")); + + // 横向:无分配的红娘不可见 + assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmC).isEmpty(), + "无生效分配的红娘不得看到他人对象的推荐"); + + // 负向:revoke 后服务单残留 assigned_operator_id=mmB(前置确认),推荐列表必须不再含该条 + assignmentService.revoke(assignmentId, leaderA, false, "005 P1-1 撤销验证"); + assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(), + "前置确认:残留列仍在(回退式过滤必须对此免疫)"); + assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmB).isEmpty(), + "撤销后推荐列表必须不再包含已失联对象的推荐"); + } + + // ==================== P1-2:dashboard 三计数切判定器(读路径) ==================== + + @Test + @DisplayName("P1-2: dashboard inProgress/today/completed 按生效分配口径 —— end 后全部归零(他人接手的单不再计入)") + void dashboardCountsFollowActiveAssignments() { + String serviceId = createService(user1, mmB); + String assignmentId = createAssignment(mmB, user1, serviceId); + + Map d0 = matchmakerServiceService.getDashboard(mmB); + assertEquals(1L, d0.get("todayCount"), "分配创建时服务单 assigned_at=今天 → today=1"); + assertEquals(0L, d0.get("inProgressCount")); + assertEquals(0L, d0.get("completedCount")); + + // 服务单推进到 in_service → 进行中 +1 + MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId); + svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE); + serviceMapper.updateById(svc); + assertEquals(1L, matchmakerServiceService.getDashboard(mmB).get("inProgressCount")); + + // 完成 → inProgress 归零、completed +1 + matchmakerServiceService.completeService(serviceId, mmB); + Map d1 = matchmakerServiceService.getDashboard(mmB); + assertEquals(0L, d1.get("inProgressCount")); + assertEquals(1L, d1.get("completedCount"), "分配仍生效时历史 completed 计入本人"); + + // 分配 end(终态)→ 三项计数全部归零(DEC-002 规则7:服务结束失去访问权) + assignmentService.end(assignmentId, leaderA, false, "005 P1-2 结束验证"); + Map d2 = matchmakerServiceService.getDashboard(mmB); + assertEquals(0L, d2.get("inProgressCount")); + assertEquals(0L, d2.get("todayCount"), "分配 end 后该用户服务单不再计入本人今日数"); + assertEquals(0L, d2.get("completedCount"), "分配 end 后历史 completed 不再计入本人"); + } + + // ==================== P1-3:转介授权切判定器(写路径) ==================== + + @Test + @DisplayName("P1-3: 转介授权走判定器 —— 有效分配内正常转介(正向);revoke/无分配 → 403 + DATA_SCOPE_DENIED 审计(负向)") + void referralAuthorizationFollowsActiveAssignments() { + String serviceId = createService(user1, mmB); + String assignmentId = createAssignment(mmB, user1, serviceId); + + // 正向:有效分配内红娘可转介 + Map ok = referralService.referToActivity(mmB, user1, "act-005-p13", "紧急联系人", "enc"); + assertNotNull(ok.get("registrationId"), "有效分配内转介必须成功落库"); + + // 负向①:无任何分配的用户 → 403 + 审计 + long before = countDeniedAuditsForResource(mmB, user2); + assertThrows(AccessDeniedException.class, + () -> referralService.referToActivity(mmB, user2, "act-005-p14", "紧急联系人", "enc"), + "无生效分配的红娘不得对任意用户发起转介"); + assertEquals(before + 1, countDeniedAuditsForResource(mmB, user2), + "越权转介必须先落 DATA_SCOPE_DENIED 审计"); + + // 负向②:revoke 后原对象转介 → 403 + 审计(残留 assigned_operator_id 不再构成转介凭据) + assignmentService.revoke(assignmentId, leaderA, false, "005 P1-3 撤销验证"); + assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(), + "前置确认:服务单残留 assigned_operator_id"); + long beforeRevoked = countDeniedAuditsForResource(mmB, user1); + assertThrows(AccessDeniedException.class, + () -> referralService.referToServiceStation(mmB, user1, "loc-005-p13", + "counselor_consultation", null, "备注"), + "撤销分配后红娘对原对象发起服务站转介必须 403"); + assertEquals(beforeRevoked + 1, countDeniedAuditsForResource(mmB, user1)); + } + + @Test + @DisplayName("E3 showOccupation: false→无 occupationCode;true/未设置→有(缺省全放行)") + void showOccupationPrivacySwitch() { + String serviceId = createService(user1, mmB); + createAssignment(mmB, user1, serviceId); + + // 未设置(无隐私记录)→ 放行 + Map detail = matchmakerServiceService.getServiceDetail(serviceId, mmB); + @SuppressWarnings("unchecked") + Map profileDefault = (Map) detail.get("userProfile"); + assertTrue(profileDefault.containsKey("occupationCode"), "未设置隐私记录时缺省放行"); + + // false → 隐藏 occupationCode,其余白名单字段不受影响 + UserPrivacySettings privacyOff = new UserPrivacySettings(); + privacyOff.setUserId(user1); + privacyOff.setShowOccupation(false); + userPrivacySettingsMapper.insert(privacyOff); + Map detailOff = matchmakerServiceService.getServiceDetail(serviceId, mmB); + @SuppressWarnings("unchecked") + Map profileOff = (Map) detailOff.get("userProfile"); + assertFalse(profileOff.containsKey("occupationCode"), "showOccupation=false 必须隐藏职业"); + assertTrue(profileOff.containsKey("displayName") && profileOff.containsKey("educationCode"), + "其他白名单字段不受影响"); + + // true → 放行 + privacyOff.setShowOccupation(true); + userPrivacySettingsMapper.updateById(privacyOff); + Map detailOn = matchmakerServiceService.getServiceDetail(serviceId, mmB); + @SuppressWarnings("unchecked") + Map profileOn = (Map) detailOn.get("userProfile"); + assertTrue(profileOn.containsKey("occupationCode"), "showOccupation=true 放行"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerFieldPrivacyGuardTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerFieldPrivacyGuardTest.java new file mode 100644 index 0000000..921b52a --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerFieldPrivacyGuardTest.java @@ -0,0 +1,162 @@ +package com.danyang.match.platform.matchmaker; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * ZC-MM-005 / E3+MM-PRIVACY-002 字段级隐私守卫(standalone 源码扫描,无需 Spring 上下文)。 + * + *

    锁定两条不变量: + *

      + *
    1. 红娘侧 userProfile 装配(MatchmakerServiceService#assembleMatchmakerUserProfile) + * 的输出键集合 ⊆ §5.2 白名单 {displayName, gender, age, regionName, educationCode, + * occupationCode, maritalStatus};方法体内不得出现 phone/mobile/wechat/idNumber/ + * birthDate/address/id_card 等禁放字段的 getter 调用(birthDate 仅允许作为 + * calculateAge 的输入存在,绝不能成为输出键);
    2. + *
    3. MM-PRIVACY-002:matchmaker / referral 运营端 Controller 不存在 + * export / download / batch 类端点(锁定"不存在"——导出能力引入时守卫即红)。
    4. + *
    + */ +class MatchmakerFieldPrivacyGuardTest { + + private static final Path SERVICE_SOURCE = + Paths.get("backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java"); + private static final Path CONTROLLER_DIRS = Paths.get("backend/src/main/java/com/danyang/match/platform"); + + /** §5.2 红娘侧用户资料白名单(与 assembleMatchmakerUserProfile 一一对照) */ + private static final Set WHITELIST = Set.of( + "displayName", "gender", "age", "regionName", "educationCode", "occupationCode", "maritalStatus"); + + /** §5.2 禁放字段(作为输出键或 getter 调用出现即违规) */ + private static final List FORBIDDEN_KEY_TOKENS = List.of( + "phone", "mobile", "wechat", "idNumber", "birthDate", "address", "id_card"); + + private static final List FORBIDDEN_GETTERS = List.of( + "getPhone", "getMobile", "getWechat", "getIdNumber", "getAddress", "getIdCard"); + + // ==================== E3:userProfile 白名单 ==================== + + @Test + @DisplayName("E3 守卫:userProfile 装配键 ⊆ §5.2 白名单,禁放字段不得出现") + void userProfileAssemblyKeysAreWhitelisted() throws IOException { + String body = extractMethodBody("assembleMatchmakerUserProfile"); + assertTrue(body != null && !body.isBlank(), "未能从源码提取 assembleMatchmakerUserProfile 方法体"); + + // 输出键白名单 + Set keys = new LinkedHashSet<>(); + Matcher put = Pattern.compile("userProfile\\.put\\(\"([^\"]+)\"").matcher(body); + while (put.find()) { + keys.add(put.group(1)); + } + assertFalse(keys.isEmpty(), "装配方法必须显式输出字段(防止装配被搬空后守卫永真)"); + + List violations = new ArrayList<>(); + for (String key : keys) { + if (!WHITELIST.contains(key)) { + violations.add("非白名单输出键: " + key); + } + String lower = key.toLowerCase(); + for (String token : FORBIDDEN_KEY_TOKENS) { + if (lower.contains(token.toLowerCase())) { + violations.add("输出键含禁放字段 token: " + key + "(含 " + token + ")"); + } + } + } + // 禁放字段的 getter 调用(birthDate 仅允许经 calculateAge 派生,无独立 getter 白名单问题) + for (String getter : FORBIDDEN_GETTERS) { + if (body.contains(getter + "(")) { + violations.add("装配方法调用了禁放字段 getter: " + getter); + } + } + + assertEquals(List.of(), violations, + "userProfile 字段级隐私契约被破坏(§5.2):\n - " + String.join("\n - ", violations) + + "\n实际输出键=" + keys); + } + + @Test + @DisplayName("守卫自检:装配方法必须包含 showOccupation 开关逻辑(防装配被改回无隐私开关)") + void assemblyMustKeepOccupationPrivacySwitch() throws IOException { + String body = extractMethodBody("assembleMatchmakerUserProfile"); + assertTrue(body.contains("showOccupation"), + "装配方法必须读取隐私开关 showOccupation(E3)"); + assertTrue(body.contains("Boolean.FALSE.equals"), + "缺省=全放行的语义必须保留(仅显式 false 隐藏)"); + } + + // ==================== MM-PRIVACY-002:无导出端点 ==================== + + @Test + @DisplayName("MM-PRIVACY-002: matchmaker/referral Controller 无 export/download/batch 端点") + void noExportDownloadBatchEndpoints() throws IOException { + List violations = new ArrayList<>(); + List controllerDirs = List.of( + CONTROLLER_DIRS.resolve("matchmaker/controller"), + CONTROLLER_DIRS.resolve("referral/controller")); + for (Path dir : controllerDirs) { + assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir); + try (Stream files = Files.list(dir)) { + for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) { + String src = Files.readString(f, StandardCharsets.UTF_8); + Matcher mapping = Pattern.compile( + "@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src); + while (mapping.find()) { + String args = mapping.group(2).toLowerCase(); + for (String banned : List.of("export", "download", "batch")) { + if (args.contains(banned)) { + violations.add(f.getFileName() + " → " + mapping.group(0) + .replaceAll("\\s+", " ") + " 含禁用能力: " + banned); + } + } + } + } + } + } + assertEquals(List.of(), violations, + "红娘/转介域引入导出/批量能力必须先过隐私评审(§5.2 + MM-PRIVACY-002)"); + } + + // ==================== 解析实现 ==================== + + /** 按花括号配对提取指定方法的方法体(去注释后匹配)。 */ + private String extractMethodBody(String methodName) throws IOException { + String src = Files.readString(SERVICE_SOURCE, StandardCharsets.UTF_8) + .replaceAll("//[^\\n]*", "") + .replaceAll("/\\*.*?\\*/", ""); + int sig = src.indexOf(methodName); + if (sig < 0) { + return null; + } + int open = src.indexOf('{', sig); + if (open < 0) { + return null; + } + int depth = 0; + for (int i = open; i < src.length(); i++) { + char ch = src.charAt(i); + if (ch == '{') depth++; + else if (ch == '}') { + depth--; + if (depth == 0) { + return src.substring(open + 1, i); + } + } + } + return null; + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java index 955d9de..29ad7a7 100644 --- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java +++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerReferralPhase13Test.java @@ -54,6 +54,7 @@ class MatchmakerReferralPhase13Test { @Autowired private com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper; @Autowired private MatchmakerServiceMapper matchmakerServiceMapper; @Autowired private MatchmakerRecommendationMapper recommendationMapper; + @Autowired private com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper matchmakerAssignmentMapper; @Autowired private MatchmakerService matchmakerService; @Autowired private MatchmakerServiceService matchmakerServiceService; @Autowired private ReferralService referralService; @@ -73,6 +74,19 @@ class MatchmakerReferralPhase13Test { target = createUser("mm-target"); // 一条归属 MATCHMAKER_OWNER 的红娘服务 serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED); + // ZC-MM-005(E1):归属判定切纯判定器(无 assigned_operator_id 回退)后, + // 服务层归属断言只认 matchmaker_assignments —— 前置补一条生效分配 + com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment assignment = + new com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment(); + assignment.setUserId(user); + assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER); + assignment.setServiceId(serviceId); + assignment.setAssignedBy(MATCHMAKER_OWNER); + assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY); + assignment.setAssignmentReason("Phase13 前置分配"); + assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE); + assignment.setAssignedAt(java.time.Instant.now()); + matchmakerAssignmentMapper.insert(assignment); } /** @@ -289,12 +303,14 @@ class MatchmakerReferralPhase13Test { @DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)") void otherMatchmakerCannotReferUser() { // MATCHMAKER_OTHER 与该用户之间没有任何服务关系 - assertThrows(IllegalStateException.class, + // ZC-MM-005 收口(P1-3):转介授权切换为 ServiceAssignmentPolicy 判定器后, + // 拒绝从裸 IllegalStateException 改为 DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403) + assertThrows(org.springframework.security.access.AccessDeniedException.class, () -> referralService.referToActivity( MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"), "非该用户服务负责人的红娘不得发起转介"); - assertThrows(IllegalStateException.class, + assertThrows(org.springframework.security.access.AccessDeniedException.class, () -> referralService.referToServiceStation( MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null), "非该用户服务负责人的红娘不得发起服务站转介"); @@ -339,21 +355,15 @@ class MatchmakerReferralPhase13Test { // ==================== 计划项 3:红娘只读摘要,不读敏感正文 ==================== @Test - @DisplayName("计划项3/D4: 非负责人的 operator 读详情只得到脱敏视图(无 userProfile / 无 followup 正文)") - void nonOwnerSeesSanitizedDetail() { - Map detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER); - - assertNotNull(detail); - assertEquals(Boolean.FALSE, detail.get("viewerIsOwner"), - "非负责人必须被标记为非 owner"); - assertFalse(detail.containsKey("userProfile"), - "INV-013 同类口径:非负责人不得读取服务对象的用户资料"); - assertFalse(detail.containsKey("followups"), - "非负责人不得读取跟进正文"); - assertFalse(detail.containsKey("userId"), - "非负责人不需要知道申请人 userId"); - assertEquals(0, ((Number) detail.getOrDefault("followupCount", 0)).intValue(), - "非负责人只能拿到跟进条数摘要"); + @DisplayName("计划项3/D4+ZC-MM-005 E2: 非持有人读详情 → 防探测 404(与「查无」一致,无脱敏双态)") + void nonOwnerReadsDetailIsRejectedAsNotFound() { + // ZC-MM-005(E1+E2):002 的"非负责人脱敏详情"双态已删除; + // 非持有人读任意 serviceId 得到与"查无"逐字段一致的 404,防枚举探测。 + // 读侧不再走 403(写接口维持 403,见 assertServiceAccess 口径)。 + org.junit.jupiter.api.Assertions.assertThrows( + com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class, + () -> matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER), + "非持有人读详情必须被拒(防探测 404)"); } @Test @@ -362,7 +372,8 @@ class MatchmakerReferralPhase13Test { Map detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER); assertNotNull(detail); - assertEquals(Boolean.TRUE, detail.get("viewerIsOwner")); + // ZC-MM-005(E1):viewerIsOwner 双态字段已删除,持有人语义由"能拿到完整视图"表达 + assertFalse(detail.containsKey("viewerIsOwner"), "双态字段已随读侧纯化删除"); assertTrue(detail.containsKey("userId"), "负责人应能看到申请人 userId"); assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录"); } diff --git a/docs/governance/open-questions.md b/docs/governance/open-questions.md index 77a648a..5825ebf 100644 --- a/docs/governance/open-questions.md +++ b/docs/governance/open-questions.md @@ -64,6 +64,20 @@ - **当前实现**:`match.effective` 为 `PROVISIONAL` 保守近似(`min(有真人消息会话, Match 总数)`), 文档已标「禁止对外」。建议口径(Mutual Match + Two-Way Chat)未实现;升 `OFFICIAL` 前需产品裁决。 +## OQ-MATCHMAKER-002 · 组长团队统计 perMatchmakerCounts 的 operatorName 是否脱敏? + +- **状态**:RESOLVED(ZC-MM-005 收口 P2-4,team-lead 裁定 2026-10-10) +- **裁定**:红娘姓名(operatorName)**保留、不脱敏**。依据: + 1. 对照《决策》§5.2 字段分类:红娘姓名属「本人/团队范围内的工作人员标识」,**非用户 PII** + (§5.2 禁放的是 phone/mobile/wechat/idNumber 等用户敏感数据); + 2. 功能权限矩阵「查看脱敏服务统计:组长=团队范围」+ DEC-MATCHMAKER-002 红娘组长职责 + (团队内进度与工作量管理)—— 组长统计本团队工作量属管理职责; + 3. 组长已可经 `GET /assignable-matchmakers` 拿到同批红娘姓名,此字段**非新增泄露面** + (管理范围 ≠ 用户敏感数据)。 +- **代码落点**:`MatchmakerAssignmentService#teamStatistics` 方法注释已写明该决策依据; + 本登记防止后人误判「统计含姓名=越权」。产品如日后要求组长统计姓名脱敏(如仅显示昵称/工号), + 需回到本项重新裁决并同步改 `countActiveGroupByOperator` 查询与前端渲染。 + ## OQ-THIRD-PARTY-* · 第三方开放 / 平台化遗留(7 项) - **状态**:TBD(范围扩张,需产品决策,本轮未擅自实施)