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:
+13
@@ -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;
|
||||
|
||||
+28
-13
@@ -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")
|
||||
|
||||
+31
@@ -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);
|
||||
}
|
||||
|
||||
+11
-5
@@ -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);
|
||||
}
|
||||
|
||||
+31
-26
@@ -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),该表永不出现这两个值。
|
||||
*/
|
||||
|
||||
+35
@@ -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 → 终态)。 */
|
||||
|
||||
+105
-69
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+57
-3
@@ -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();
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+38
@@ -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
|
||||
|
||||
+536
@@ -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 放行");
|
||||
}
|
||||
}
|
||||
+162
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
-18
@@ -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"), "负责人应能看到跟进记录");
|
||||
}
|
||||
|
||||
@@ -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(范围扩张,需产品决策,本轮未擅自实施)
|
||||
|
||||
Reference in New Issue
Block a user