feat(matchmaker,referral): ZC-MM-005 统一数据范围与字段隐私(DEC-MATCHMAKER-005/§5.2 落地)

依据《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》DEC-MATCHMAKER-005 最小必要原则
与 §5.2 字段级隐私,ZC-MM-005 范围=「读接口行级数据范围统一接入判定器 + 字段白名单 +
组长团队脱敏统计」。承接 ZC-MM-002 落地的 matchmaker_assignments 事实源与
ServiceAssignmentPolicy 判定器。

E1 双轨切纯判定器(不留回退):
- assertServiceAccess 纯化(删 assigned_operator_id 回退分支),判定器成唯一事实源
- getServiceDetail 删 viewerIsOwner 双态与「非负责人脱敏详情」整段

E2 非负责人防探测 404:
- ServiceAssignmentPolicy.assertActiveAssignmentOrNotFound:无生效分配→先落
  DATA_SCOPE_DENIED 审计再抛 NOT_FOUND(响应 404),存在性不可枚举,
  与「查无此单」响应体逐字段一致(MM-PRIVACY-001)

E3 字段白名单显式化 + 用户隐私开关:
- 抽取 assembleMatchmakerUserProfile:仅 7 键(displayName/gender/age 派生/regionName/
  educationCode/occupationCode/maritalStatus),无 birthDate 原值/详细地址/手机号/微信/
  身份证;接 UserPrivacySettings.showOccupation(缺省全放行)

E4 死端点清理:
- 删除 GET /operator/matchmaker/my-services(operatorId 误当 user_id、恒空、前后端零调用),
  替代路径 /operator/matchmaker/assignments/my;C 端 /matchmaker/services 与
  counseling myServices 保留

P1 残留收口(读/写路径仍信任废弃 assigned_operator_id 残留列,与 E1 同类洞):
- P1-1 运营端推荐列表:MatchmakerRecommendationMapper.findByOperatorId 改 join
  生效分配(active+expires),撤/结后不再可见
- P1-2 dashboard 三计数:count*ByOperator 改 join 生效分配 + COUNT(DISTINCT) 防重,
  end/revoke 后不再计入(DEC-002 规则7)
- P1-3 转介授权:ReferralService.assertMatchmakerServesUser 切
  serviceAssignmentPolicy.assertActiveAssignment(DATA_SCOPE_DENIED 审计 + 403),
  删残留列查询 findActiveByOperatorAndUser

新端点(组长团队脱敏统计):
- GET /operator/matchmaker/team/statistics(@PreAuthorize matchmaker.team.assign):
  本组织 active 分配数/按红娘分组/在岗红娘数,纯计数无用户 PII;org 边界
  IS NOT DISTINCT FROM(NULL=NULL 平台直属)

P2-4 operatorName 脱敏决策:保留不脱敏(§5.2 字段分类:红娘姓名=团队范围内
工作人员标识非用户 PII;组长统计本团队工作量属管理职责),依据登记
docs/governance/open-questions.md OQ-MATCHMAKER-002(RESOLVED)

死代码清理:getOperatorServices(零调用)+ MatchmakerServiceMapper.findByOperatorId
(残留列查询,调用清零)删除;C 端 MatchmakerService 遗留信任残留列按豁免未动(006/007 退役)

测试:
- MatchmakerDataScope005Test(13 用例):MM-PRIVACY-001/002、MM-AUTH-001/003 读侧、
  MM-LIFECYCLE-001 读侧、P1-1/2/3 残留收口负向(撤/结后推荐不可见/计数归零/转介 403)、
  防探测逐字段一致、showOccupation 三态
- MatchmakerFieldPrivacyGuardTest(3 用例):字段白名单源码扫描 + 无导出端点锁定
- MatchmakerReferralPhase13Test 转介口径收紧(IllegalStateException→AccessDeniedException)
- 负向注入自证(P1-3 残留列变异→判红→恢复复绿)

验证:定向 73/73;全量 851/851(3 预存 skip);UI 契约 24/24。
This commit is contained in:
WorkBuddy
2026-10-10 11:51:15 +08:00
parent 861c78efb5
commit 3caf5f8816
14 changed files with 1100 additions and 140 deletions
@@ -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<Map<String, Object>> teamStatistics(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(assignmentService.teamStatistics(principal.id()));
}
private Instant parseInstant(String raw) {
if (raw == null || raw.isBlank()) {
return null;
@@ -38,6 +38,11 @@ public class MatchmakerOperatorController {
* 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者
* (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列,
* 普通红娘(仅 matchmaker.view/service)403。
*
* <p>组长视角说明(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 的契约缺口。
*
* <p>口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments}
* 中本人<b>生效中的分配</b>(active 且未过有效期)→ 该分配对象(user)名下
* 服务单上的推荐,经 matchmaker_services 桥接;既不按
* matchmaker_services.assigned_operator_id 残留列过滤,也<b>不</b>按
* recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status
* 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见
* (DEC-002 规则7:撤销即失权)。
*/
@GetMapping("/recommendations")
public ApiResponse<List<Map<String, Object>>> getRecommendations(
@@ -80,16 +92,19 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
}
/**
* 服务详情(ZC-MM-005 / E2 防探测 404)。
*
* <p>语义变更:非持有人与查无 serviceId 抛出完全相同的
* BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致
* (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。
* 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。
*/
@GetMapping("/services/{id}")
public ApiResponse<Map<String, Object>> getServiceDetail(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
Map<String, Object> 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")
@@ -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<MatchmakerAssignm
"WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() " +
"AND deleted_at IS NULL")
int expireOverdue();
// ==================== ZC-MM-005 / E4:组长团队统计(纯脱敏计数) ====================
// 组织边界:organization_id IS NOT DISTINCT FROM #{organizationId} ——
// NULL=NULL 平台直属规则在 SQL 层等价复用(有组织组长统计本组织;NULL 组长统计平台直属)。
/** 某组织范围内生效中的分配总数 */
@Select("SELECT COUNT(*) 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 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<Map<String, Object>> 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);
}
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
int markViewed(@Param("id") String id);
/**
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
* 避免运营端越权看到他人推荐。
* 某红娘推荐列表(运营端工作台用)。
*
* <p>ZC-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<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
}
@@ -28,44 +28,49 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
/**
* 某红娘进行中的服务数(工作台统计)。
* ZC-MM-002(D4):状态口径修正 'processing' → 'in_service'
* ('processing' 不在 V009 chk_matchmaker_status 白名单,原查询恒为 0)。
* ZC-MM-005 收口(P1-2):范围事实源由 assigned_operator_id 残留列切换为
* matchmaker_assignments 生效中分配(active 且未过有效期)——「本人名下进行中」
* = 本人 active 分配关联 user 的服务单中 status='in_service'。
* 分配 end/revoke 后,该用户的服务单不再计入本人(DEC-002 规则7:服务结束失去访问权)。
* 状态口径沿用 ZC-MM-002(D4)修正:'in_service'('processing' 不在 V009 白名单)。
*/
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND status = 'in_service' AND deleted_at IS NULL")
@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 = '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 服务单<b>不再计入</b>本人完成数;
* 仅当分配仍生效时,其名下 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<MatchmakerServiceRecord> 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<MatchmakerServiceRecord> 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<MatchmakerServiceRecord> 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),该表永不出现这两个值。
*/
@@ -225,6 +225,41 @@ public class MatchmakerAssignmentService {
.collect(Collectors.toList());
}
/**
* 组长团队统计(ZC-MM-005 / E4 新增):以组长本人 organization_id 为边界
* (NULL 组长统计平台直属,SQL 侧 IS NOT DISTINCT FROM 复用 NULL=NULL 规则)。
* 返回纯脱敏计数,不含任何用户身份或联系方式:
* <ul>
* <li>activeAssignmentCount —— 组织内生效中的分配总数;</li>
* <li>perMatchmakerCounts —— 各红娘的生效分配数(operatorId/name/activeCount);</li>
* <li>activeMatchmakerCount —— 持有生效分配的红娘数(去重)。</li>
* </ul>
*
* <p><b>字段脱敏决策(P2-4,对照《决策》§5.2 字段分类 + 功能权限矩阵)</b>:
* perMatchmakerCounts 中的 <b>operatorName(红娘姓名)保留、不做脱敏</b>。依据:
* <ol>
* <li>红娘姓名属于「本人/团队范围内的工作人员标识」,<b>不是用户 PII</b>
* (§5.2 隐私清单的禁放字段是 phone/wechat/idNumber 等用户敏感数据);</li>
* <li>组长统计本团队工作量属其管理职责(DEC-MATCHMAKER-002 红娘组长:
* 团队内进度与工作量管理),「查看脱敏服务统计:组长=团队范围」是既有授权口径;</li>
* <li>组长已可经 GET /assignable-matchmakers 拿到同批红娘姓名 —— 此字段
* 非新增泄露面,管理范围 ≠ 用户敏感数据。</li>
* </ol>
* 决策依据同时登记于 docs/governance/open-questions.md(OQ-MATCHMAKER-002),
* 避免后人误判"统计含姓名=越权"。
*/
public Map<String, Object> teamStatistics(String leaderOperatorId) {
Operator leader = operatorMapper.findById(leaderOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
String orgId = leader.getOrganizationId();
Map<String, Object> 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 → 终态)。 */
@@ -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 阶段收敛);</li>
* <li>assignService 同事务联动「创建分配 + 服务单置 assigned」。</li>
* </ul>
*
* <p>ZC-MM-005(E1 双轨切纯判定器)变更说明:
* <ul>
* <li><b>删除 assigned_operator_id 回退分支</b>:002 的 "assignment 优先 +
* assigned_operator_id 回退" 有真漏洞 —— 分配 revoke/end 后服务单残留
* assigned_operator_id,回退分支会放行已失去访问权的红娘(违反 DEC-002 规则7)。
* V053 已回填全部存量进行中服务单,数据一致性已建立;终态服务单不回填是有意设计
* (服务结束即失去访问权)。{@link ServiceAssignmentPolicy} 成为唯一事实源;</li>
* <li>读详情(getServiceDetail)改用 E2 防探测 404:非持有人响应与"不存在"逐字段一致,
* 删除 viewerIsOwner 双态与"非负责人脱敏详情"整段;</li>
* <li>行为变化:红娘读自己历史 completed 服务单详情将 404(符合 DEC-002 决策)。</li>
* </ul>
*/
@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 改为<b>本人口径</b>(该红娘名下的服务),
* ZC-MM-002(D4):today / completed 改为本人口径(该红娘名下的服务),
* pendingCount 保留全局口径(待分配队列是分配权持有者的全局视图);
* inProgress 统计的状态口径由错误的 'processing' 修正为 'in_service'
* ('processing' 不在 V009 chk_matchmaker_status 白名单,恒为 0)。
*
* <p>ZC-MM-005 收口(P1-2):inProgress / today / completed 三项计数统一切换为
* <b>matchmaker_assignments 生效中分配</b>口径(SQL 侧 join assignments,见
* MatchmakerServiceMapper 计数查询)。口径说明:
* <ul>
* <li>三项均只统计「本人当前生效分配对象名下」的服务单;</li>
* <li>分配 end / revoke 后(含他人接手后原红娘),该用户的服务单
* <b>不再计入</b>原红娘的 dashboard 计数(DEC-002 规则7:服务结束失去访问权);</li>
* <li>completed 计数同理:历史 completed 单仅在分配仍生效时计入本人。</li>
* </ul>
*/
public Map<String, Object> getDashboard(String operatorId) {
long pendingCount = serviceMapper.countSubmitted();
@@ -309,50 +331,47 @@ public class MatchmakerServiceService {
}
/**
* 获取我的服务列表(红娘工作台)
*/
public List<MatchmakerServiceRecord> 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)→ 越权读。
* <p>此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner
* 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行
* (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。
*
* 现语义:
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
* (不含 userProfile;followups 正文也不返回,仅给条数)
* <p>现语义(判定器唯一事实源,无回退):
* <ul>
* <li>查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");</li>
* <li>非持有人(无生效中的分配)→ {@link ServiceAssignmentPolicy}
* #assertActiveAssignmentOrNotFound:先落 DATA_SCOPE_DENIED 审计,
* 再抛与"查无"完全相同的 NOT_FOUND 异常 —— 两种失败响应逐字段一致,防探测;</li>
* <li>持有人 → 完整详情(申请人 userId / 诉求描述 / 白名单 userProfile / 跟进正文 /
* 推荐列表),不再有 viewerIsOwner 双态字段。</li>
* </ul>
*
* @param serviceId 服务 ID
* <p>行为变化:红娘读自己历史 completed 服务单详情将 404(分配已终态,符合 DEC-002)。
*
* @param serviceId 服务 ID
* @param requesterId 调用者 operator ID
*/
public Map<String, Object> 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<String, Object> 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<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
detail.put("recommendations", recs.stream().map(r -> {
Map<String, Object> 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<String, Object> 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 字段白名单显式化)。
*
* <p>逐字段对照《安全、隐私、合规与风控体系设计》§5.2 字段级隐私清单:
* <ul>
* <li>displayName —— 基础展示字段,允许</li>
* <li>gender —— 基础展示字段,允许</li>
* <li>age —— 由 birthDate 计算的派生值(<b>不输出原始 birthDate</b>)</li>
* <li>regionName —— 地区展示名(非精确坐标),允许</li>
* <li>educationCode —— 枚举码,允许</li>
* <li>occupationCode —— 枚举码,<b>受隐私开关 showOccupation 控制</b>:
* 用户明确关闭(false)时不输出;隐私记录缺失或开关未设置(null)按缺省放行</li>
* <li>maritalStatus —— 枚举码,允许</li>
* </ul>
* §5.2 禁放字段(phone/mobile/wechat/idNumber/birthDate/address/id_card 等)
* 一律不进入装配;守卫见 MatchmakerFieldPrivacyGuardTest(源码级白名单扫描)。
*
* @param profile 用户资料(可空字段自然输出 null,由前端按缺失处理)
* @param privacy 用户隐私设置;null 表示用户未设置过(缺省=全放行)
*/
private Map<String, Object> assembleMatchmakerUserProfile(UserProfile profile,
UserPrivacySettings privacy) {
// 缺省=全放行:仅在用户显式关闭(showOccupation == false)时隐藏职业
boolean showOccupation = privacy == null || !Boolean.FALSE.equals(privacy.getShowOccupation());
Map<String, Object> 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 阶段读口径统一时收敛)。
* <p><b>无任何回退</b>: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");
}
@@ -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 增强)。
*
* <p>红娘对某个用户能否读/写,唯一事实源是 matchmaker_assignments 里
* 「生效中的分配」(active 且未过有效期)。所有读路径前先 {@link #expireOverdue}
* 惰性过期(无定时任务,D5 决策),保证过期分配立即失效。
*
* <p>判定无效时统一走 {@link DataScopeGuard#deny}:先落 DATA_SCOPE_DENIED 审计,
* 再抛 AccessDeniedException(全局处理器映射为 403 + FORBIDDEN)。
* <p>判定无效时的两种处置:
* <ul>
* <li>{@link #assertActiveAssignment}:写接口口径 —— 落 DATA_SCOPE_DENIED 审计 +
* 抛 AccessDeniedException(403);</li>
* <li>{@link #assertActiveAssignmentOrNotFound}(ZC-MM-005 / E2):读详情口径 ——
* 先落 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),再抛
* BusinessException(NOT_FOUND)(响应 404,与"资源不存在"逐字段一致),
* 防止枚举 serviceId 探测资源存在性。</li>
* </ul>
*/
@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)。
*
* <p>无效时先写 DATA_SCOPE_DENIED 审计(字段口径与 {@link DataScopeGuard#deny} 对齐,
* 语义上这是一次越权尝试),再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}。
* 全局异常处理器将其映射为 404 响应体 {@code {success:false, code:NOT_FOUND,
* message:"服务不存在"}} —— 与查询一个真正不存在的 serviceId 的响应逐字段一致,
* 调用方无法区分"无权"与"不存在",杜绝按 serviceId 枚举探测。
*
* <p>仅用于读详情路径;写接口维持 {@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<MatchmakerAssignment> 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<MatchmakerAssignment> findActiveByOperator(String operatorId) {
expireOverdue();
@@ -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=目标用户),
* 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
* 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
*
* <p>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
@@ -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<String> 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<String> paths(java.lang.reflect.AnnotatedElement element) {
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
@@ -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 + 字段白名单 + 组长统计。
*
* <p>覆盖:
* <ul>
* <li>MM-PRIVACY-001(负向×3):非持有人读详情 404+审计;查无 serviceId 响应与前者
* 逐字段一致(防探测);撤销后原持有人再读 404;</li>
* <li>MM-AUTH-001 读侧(正向):持分配红娘读完整详情、无 viewerIsOwner 双态残留;</li>
* <li>MM-AUTH-003 读侧(负向):读他人对象 → 404+审计;</li>
* <li>MM-LIFECYCLE-001 读侧:end/revoke/expired 后 404;</li>
* <li>组长团队统计正例 + 普通红娘 403+审计(负向);</li>
* <li>showOccupation 隐私开关:false 隐藏 occupationCode,true/未设置放行。</li>
* </ul>
*/
@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<String> 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<AuditLog>()
.eq(AuditLog::getAction, action)
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
}
private long countDeniedAuditsForResource(String operatorId, String resourceId) {
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.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<String, Object> 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<String, Object> 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<String, Object> userProfile = (Map<String, Object>) detail.get("userProfile");
Set<String> 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<String, Object> 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<String, Object> stats = assignmentService.teamStatistics(leaderA);
assertEquals(ORG_X, stats.get("organizationId"));
assertEquals(2L, stats.get("activeAssignmentCount"), "跨组织分配不得计入");
assertEquals(2L, stats.get("activeMatchmakerCount"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> perMatchmaker = (List<Map<String, Object>>) 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
@SuppressWarnings("unchecked")
Map<String, Object> profileDefault = (Map<String, Object>) detail.get("userProfile");
assertTrue(profileDefault.containsKey("occupationCode"), "未设置隐私记录时缺省放行");
// false → 隐藏 occupationCode,其余白名单字段不受影响
UserPrivacySettings privacyOff = new UserPrivacySettings();
privacyOff.setUserId(user1);
privacyOff.setShowOccupation(false);
userPrivacySettingsMapper.insert(privacyOff);
Map<String, Object> detailOff = matchmakerServiceService.getServiceDetail(serviceId, mmB);
@SuppressWarnings("unchecked")
Map<String, Object> profileOff = (Map<String, Object>) detailOff.get("userProfile");
assertFalse(profileOff.containsKey("occupationCode"), "showOccupation=false 必须隐藏职业");
assertTrue(profileOff.containsKey("displayName") && profileOff.containsKey("educationCode"),
"其他白名单字段不受影响");
// true → 放行
privacyOff.setShowOccupation(true);
userPrivacySettingsMapper.updateById(privacyOff);
Map<String, Object> detailOn = matchmakerServiceService.getServiceDetail(serviceId, mmB);
@SuppressWarnings("unchecked")
Map<String, Object> profileOn = (Map<String, Object>) detailOn.get("userProfile");
assertTrue(profileOn.containsKey("occupationCode"), "showOccupation=true 放行");
}
}
@@ -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 上下文)。
*
* <p>锁定两条不变量:
* <ol>
* <li>红娘侧 userProfile 装配(MatchmakerServiceService#assembleMatchmakerUserProfile)
* 的输出键集合 ⊆ §5.2 白名单 {displayName, gender, age, regionName, educationCode,
* occupationCode, maritalStatus};方法体内不得出现 phone/mobile/wechat/idNumber/
* birthDate/address/id_card 等禁放字段的 getter 调用(birthDate 仅允许作为
* calculateAge 的输入存在,绝不能成为输出键);</li>
* <li>MM-PRIVACY-002:matchmaker / referral 运营端 Controller 不存在
* export / download / batch 类端点(锁定"不存在"——导出能力引入时守卫即红)。</li>
* </ol>
*/
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<String> WHITELIST = Set.of(
"displayName", "gender", "age", "regionName", "educationCode", "occupationCode", "maritalStatus");
/** §5.2 禁放字段(作为输出键或 getter 调用出现即违规) */
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
"phone", "mobile", "wechat", "idNumber", "birthDate", "address", "id_card");
private static final List<String> 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<String> keys = new LinkedHashSet<>();
Matcher put = Pattern.compile("userProfile\\.put\\(\"([^\"]+)\"").matcher(body);
while (put.find()) {
keys.add(put.group(1));
}
assertFalse(keys.isEmpty(), "装配方法必须显式输出字段(防止装配被搬空后守卫永真)");
List<String> 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<String> violations = new ArrayList<>();
List<Path> 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<Path> 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;
}
}
@@ -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<String, Object> 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<String, Object> 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"), "负责人应能看到跟进记录");
}
+14
View File
@@ -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(范围扩张,需产品决策,本轮未擅自实施)