2 Commits
Author SHA1 Message Date
WorkBuddy 16c82739db docs(governance): 受控联系产品裁决落账——OQ-003/009/010 全部 RESOLVED
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
- open-questions.md: OQ-MATCHMAKER-003 RESOLVED(5 项 LEGAL_VERIFY 全闭环:①组长主审+管理员兜底 ②用户单独同意已实现 ③有效期 7 天维持 ④保留期 365 天维持 ⑤审计面满足+NULL=NULL 维持;建议放量前转法务复核留痕);OQ-009 RESOLVED(分层审批:主审岗=approver-authorities 首段走组织范围,兜底岗固定 matchmaker.assign 全量,落地证据=4 条分层用例+注入自证+主理人 Docker 独立实跑 20/20);OQ-010 RESOLVED(MatchmakerContactDisabledTest 行为基线,顺带修复 request 未查 enabled 真实缺口)
- config-register.md: approver-authorities 转「已裁决/分层语义」(键名默认值不变);enabled/request 读点更新
- PENDING_DECISIONS.md: 受控联系行改「产品裁决全部落定」,剩余决策清零
2026-10-11 16:01:13 +08:00
WorkBuddy f73a116a10 feat(matchmaker): 受控联系审批分层落地(OQ-MATCHMAKER-009 裁决:组长主审+管理员兜底)+ enabled fail-safe 行为基线
- ContactAuthorizationService approve/reject 分层语义:matchmaker.assign 平台管理员=兜底岗(全量审批、不做组织校验);approver-authorities 首段(默认 matchmaker.team.assign)=主审岗(assertTeamScope 组织范围,hasGlobalAssign 固定 false 防权限面双通道,NULL=NULL 平台直属可审);两者皆无 403 deny 不变
- 真实缺口修复(OQ-010 引出):request 入口原未查 matchmaker.contact.enabled,补 denyAsNotFound fail-safe(与 disclose 口径一致:最先、404 化、防探测)
- 测试:MatchmakerContactAuthorizationTest 10→13(管理员跨组织兜底/组长他组织 403/组长审平台直属 NULL=NULL/双岗兜底全量,均 HTTP+审计双断言);新增 MatchmakerContactDisabledTest(enabled=false 时 disclose/request 404 化、零 SensitiveAccessLog、零 contact 成功审计、fail-safe 先于 purpose 校验)
- 负向注入:移除兜底分支 → 恰好 2 例分层用例判红(主审路径保持绿,判别力精准)→ 还原复绿
- 验证:主理人 Docker 独立实跑 20/20(授权 13+disabled 2+守卫 5);全量 mvn verify 900/900(3 skip);后端契约 234/CURATED 20;UI 24/24
- 裁决依据:OQ-MATCHMAKER-009(组长主审+管理员兜底)、LEGAL_VERIFY③ 7 天/④ 365 天/⑤ NULL=NULL 维持(登记于 governance)
2026-10-11 15:57:56 +08:00
7 changed files with 286 additions and 49 deletions
@@ -29,10 +29,20 @@ public class ContactAuthorizationConfig {
private boolean enabled = false; private boolean enabled = false;
/** /**
* 审批岗权限码(逗号分隔,任一命中即可审批)。 * 审批岗权限码(逗号分隔,<b>分层语义</b>,用户裁决①:组长主审 + 管理员兜底)。
* 默认 "matchmaker.team.assign,matchmaker.assign"(组长组织内审批 / 平台授权岗位全量审批)。 * 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。
* 审批权限在 Service 层动态判定(principal 权限 ∈ 本集合),不用 @PreAuthorize 静态注解 —— *
* 审批岗名单可经 yml 调整而无需改代码。 * <p>分层规则(ContactAuthorizationService.assertApproverAuthority):
* <ol>
* <li>列表<b>首段 = 主审岗</b>(默认 {@code matchmaker.team.assign},组长)——
* 仅可审批本组织红娘的申请(NULL=NULL 平台直属,assertTeamScope 复用);</li>
* <li><b>兜底岗键固定 {@code matchmaker.assign}</b>(平台管理员)——
* 可审批全部申请且不做组织校验;兜底键不受本列表调整影响(权限面不放大);</li>
* <li>两者皆无 → 403 拒绝。</li>
* </ol>
*
* <p>审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize
* 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。
*/ */
private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign"; private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign";
@@ -34,10 +34,11 @@ import java.util.stream.Collectors;
* *
* <p>六操作状态机(全矩阵): * <p>六操作状态机(全矩阵):
* <pre> * <pre>
* request : 红娘(须持分配)→ pending_approval,审计含 service_assignment_id * request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
* approve : 审批岗(权限码动态判定 ∈ approver-authorities + 组长组织范围) * approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
* pending_approval → pending_consent;违规 403(无资源枚举面维持 403) * {@link #assertApproverAuthority})pending_approval → pending_consent;
* reject : 审批岗拒绝 pending_approval → rejected * 违规 403(无资源枚举面维持 403)
* reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
* consent : C 端用户本人(归属校验,独立端点、无预同意参数) * consent : C 端用户本人(归属校验,独立端点、无预同意参数)
* pending_consent → active(expires_at = consent + default-validity-days) * pending_consent → active(expires_at = consent + default-validity-days)
* decline : C 端用户本人 pending_consent → rejected * decline : C 端用户本人 pending_consent → rejected
@@ -59,6 +60,11 @@ public class ContactAuthorizationService {
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization"; private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */ /** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure"; public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure";
/**
* 兜底审批岗权限键(用户裁决① 固定):持有者可审批全部申请且不做组织校验。
* 主审岗权限键仍由 approver-authorities 列表首段配置(默认 matchmaker.team.assign)。
*/
private static final String FALLBACK_APPROVER_AUTHORITY = "matchmaker.assign";
private final MatchmakerContactAuthorizationMapper authorizationMapper; private final MatchmakerContactAuthorizationMapper authorizationMapper;
private final ServiceAssignmentPolicy serviceAssignmentPolicy; private final ServiceAssignmentPolicy serviceAssignmentPolicy;
@@ -75,6 +81,12 @@ public class ContactAuthorizationService {
@Transactional @Transactional
public MatchmakerContactAuthorization request(String operatorId, String userId, public MatchmakerContactAuthorization request(String operatorId, String userId,
String channel, String purpose) { String channel, String purpose) {
// fail-safe 最先(与 disclose 同口径):机制关闭(配置缺失/显式关闭)→ 404 化拒绝。
// ZC-MM-003 裁决落地 T-C:request 原实现漏查 enabled(真实缺口,本轮补齐)。
if (!config.isEnabled()) {
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
"服务不存在", null, null);
}
if (purpose == null || purpose.isBlank()) { if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填"); throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
} }
@@ -114,17 +126,28 @@ public class ContactAuthorizationService {
return auth; return auth;
} }
// ==================== ② approve / ③ reject:审批岗 ==================== // ==================== ② approve / ③ reject:审批岗(分层) ====================
/** 审批岗权限码动态判定(principal 权限 ∈ approver-authorities 配置,非 @PreAuthorize 静态)。 */ /**
* 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
*
* <p>分层规则:
* <ol>
* <li>持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请,
* <b>不做组织校验</b>(见 {@link #assertOrgScopeForApprover});</li>
* <li>否则持主审岗权限键(approver-authorities 列表首段,默认
* {@code matchmaker.team.assign},组长)→ 主审,须通过
* {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);</li>
* <li>两者皆无 → 403 deny(不变)。</li>
* </ol>
*
* <p>主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign}
* —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。
*/
private void assertApproverAuthority(String approverOperatorId, String authId, private void assertApproverAuthority(String approverOperatorId, String authId,
Set<String> principalPermissions) { Set<String> principalPermissions) {
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(",")) boolean authorized = isFallbackApprover(principalPermissions)
.map(String::trim) || isPrimaryApprover(principalPermissions);
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
boolean authorized = principalPermissions != null
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
if (!authorized) { if (!authorized) {
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009) // 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId, dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
@@ -132,6 +155,35 @@ public class ContactAuthorizationService {
} }
} }
/** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */
private boolean isFallbackApprover(Set<String> principalPermissions) {
return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY);
}
/** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */
private boolean isPrimaryApprover(Set<String> principalPermissions) {
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
return principalPermissions != null
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
}
/**
* 分层组织范围校验(用户裁决①):兜底岗(持 matchmaker.assign)跳过组织校验;
* 主审岗(组长)固定走 {@code assertTeamScope}(hasGlobalAssign=false ——
* 是否兜底由本层判定,不再向底层传组织豁免,防止权限面双通道扩大)。
*/
private void assertOrgScopeForApprover(String approverOperatorId, String authId,
Set<String> principalPermissions, String targetOperatorId) {
if (isFallbackApprover(principalPermissions)) {
return;
}
matchmakerAssignmentService.assertTeamScope(approverOperatorId, false,
targetOperatorId, authId);
}
@Transactional @Transactional
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId, public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
Set<String> principalPermissions) { Set<String> principalPermissions) {
@@ -141,11 +193,9 @@ public class ContactAuthorizationService {
throw new BusinessException(ApiResponseCode.CONFLICT, throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许审批"); "当前状态(" + auth.getStatus() + ")不允许审批");
} }
// 组长组织范围校验(002 assertAssignScope 复用):matchmaker.assign 持有者不限组织 // 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
boolean hasGlobalAssign = principalPermissions != null assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
&& principalPermissions.contains("matchmaker.assign"); auth.getOperatorId());
matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign,
auth.getOperatorId(), authId);
auth.setApprovedBy(approverOperatorId); auth.setApprovedBy(approverOperatorId);
auth.setApprovedAt(Instant.now()); auth.setApprovedAt(Instant.now());
@@ -170,10 +220,9 @@ public class ContactAuthorizationService {
throw new BusinessException(ApiResponseCode.CONFLICT, throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许拒绝"); "当前状态(" + auth.getStatus() + ")不允许拒绝");
} }
boolean hasGlobalAssign = principalPermissions != null // 分层组织范围校验(用户裁决①,与 approve 同口径)
&& principalPermissions.contains("matchmaker.assign"); assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign, auth.getOperatorId());
auth.getOperatorId(), authId);
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED); auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
authorizationMapper.updateById(auth); authorizationMapper.updateById(auth);
@@ -79,6 +79,9 @@ class MatchmakerContactAuthorizationTest {
private static final String ORG_Y = "55555555-5555-5555-5555-555555555552"; private static final String ORG_Y = "55555555-5555-5555-5555-555555555552";
private static final Set<String> P_LEADER = Set.of("matchmaker.view", "matchmaker.team.assign"); private static final Set<String> P_LEADER = Set.of("matchmaker.view", "matchmaker.team.assign");
private static final Set<String> P_PLATFORM = Set.of("matchmaker.view", "matchmaker.assign"); private static final Set<String> P_PLATFORM = Set.of("matchmaker.view", "matchmaker.assign");
/** 双岗:team.assign(主审)+ assign(兜底)——按分层规则含 assign 即兜底全量 */
private static final Set<String> P_DUAL =
Set.of("matchmaker.view", "matchmaker.team.assign", "matchmaker.assign");
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@@ -266,13 +269,85 @@ class MatchmakerContactAuthorizationTest {
} }
@Test @Test
@DisplayName("B1 负向③: 跨组织组长 approve → 403(组织范围校验)") @DisplayName("B1 负向③: 仅 team.assign 组长审他组织 → 403 + DATA_SCOPE_DENIED 审计(分层·主审岗)")
void crossOrgLeaderCannotApprove() { void crossOrgLeaderCannotApprove() {
MatchmakerContactAuthorization auth = contactAuthorizationService.request( MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通"); mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
long before = deniedAudits(leaderY, auth.getId());
assertThrows(AccessDeniedException.class, assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER), () -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER),
"跨组织组长不得审批"); "跨组织组长(仅主审岗)不得审批");
assertEquals(before + 1, deniedAudits(leaderY, auth.getId()),
"跨组织组长审批拒绝必须落 DATA_SCOPE_DENIED 审计");
}
// ==================== 分层审批语义(用户裁决①:组长主审 + 管理员兜底) ====================
@Test
@DisplayName("分层正例①: 仅 assign 管理员(兜底岗)审批跨组织申请 → 成功 + 审计行")
void platformFallbackApproverCanApproveCrossOrg() {
// 管理员挂任意组织(org Y)——兜底岗不做组织校验
String admin = createOperator("联系授权平台管理员", ORG_Y);
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "跨组织管理员兜底审批");
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
auth.getId(), admin, P_PLATFORM);
// 结果码断言:approve 成功 → pending_consent
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
assertEquals(admin, approved.getApprovedBy());
// 审计行断言:approve 审计行存在且 purpose_code 正确、操作者=管理员
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
assertNotNull(audit, "兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
assertEquals(admin, audit.getOperatorId());
}
@Test
@DisplayName("分层正例②: 平台直属组长(org NULL)审平台直属红娘申请 → 成功 + 审计行")
void platformDirectLeaderCanApproveNullOrgMatchmaker() {
// 平台直属链路:org=NULL 组长 + org=NULL 红娘(NULL=NULL 视为平台直属,现状⑤维持)
String leaderN = createOperator("平台直属组长", null);
String mmNull = createOperator("平台直属红娘", null);
String userNull = createUser("13600009998");
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setUserId(userNull);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setDescription("平台直属联系授权测试服务单");
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
svc.setAssignedOperatorId(mmNull);
serviceMapper.insert(svc);
// 主审组长(hasGlobalAssign=false)走 NULL=NULL 组织校验创建分配
assignmentService.create(leaderN, false, userNull, mmNull, svc.getId(),
"平台直属分配", null);
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmNull, userNull, Constants.CONTACT_CHANNEL_PHONE, "平台直属电话沟通");
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
auth.getId(), leaderN, P_LEADER);
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
assertEquals(leaderN, approved.getApprovedBy());
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
assertNotNull(audit, "平台直属组长审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
assertEquals(leaderN, audit.getOperatorId());
}
@Test
@DisplayName("分层正例③: 双岗(team.assign+assign)审他组织 → 含 assign 即兜底全量 + 审计行")
void dualRoleApproverFallsBackToFullScope() {
// 用户裁决①分层规则的既定语义:兜底岗键固定 matchmaker.assign,
// 双岗持有者按兜底全量处理(不做组织校验)——权限面以最高档收敛,不叠加受限。
String dual = createOperator("双岗审批人", ORG_Y);
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "双岗跨组织审批");
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
auth.getId(), dual, P_DUAL);
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
assertEquals(dual, approved.getApprovedBy());
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
assertNotNull(audit, "双岗兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
assertEquals(dual, audit.getOperatorId());
} }
@Test @Test
@@ -0,0 +1,104 @@
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.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
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 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.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* OQ-010 验收:机制总开关关闭(matchmaker.contact.enabled=false)时的 fail-safe 行为。
*
* <p>fail-safe 语义(ContactAuthorizationConfig.enabled Java 缺省 false,配置缺失=机制关闭):
* <ul>
* <li>disclose → 404 化拒绝(fail-safe 校验最先,G4 防探测:与"资源不存在"同响应);</li>
* <li>request → 404 化拒绝(ZC-MM-003 裁决落地 T-C 补齐:原实现漏查 enabled,
* 属真实缺口,本轮在 Service 层补 fail-safe 校验,口径与 disclose 一致);</li>
* <li>拒绝时<b>不落</b> SensitiveAccessLog、<b>不落</b>任何 matchmaker.contact.* 成功审计行
* (DATA_SCOPE_DENIED deny 审计可有,用于事后追责)。</li>
* </ul>
*/
@SpringBootTest(properties = "matchmaker.contact.enabled=false")
@ActiveProfiles("test")
@Transactional
class MatchmakerContactDisabledTest {
private static final String OPERATOR_ID = "disabled-test-operator-0001";
private static final String USER_ID = "disabled-test-user-0001";
@Autowired private ContactAuthorizationService contactAuthorizationService;
@Autowired private MatchmakerContactAuthorizationMapper authorizationMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
@Test
@DisplayName("开关关闭: disclose → 404 化拒绝,不落 SensitiveAccessLog / 成功审计行")
void discloseRejectedWhenDisabled() {
BusinessException e = assertThrows(BusinessException.class,
() -> contactAuthorizationService.disclose(OPERATOR_ID, USER_ID,
Constants.CONTACT_CHANNEL_PHONE, "机制关闭后披露"),
"机制关闭时披露必须 fail-safe 拒绝");
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(G4 防探测)");
assertEquals("服务不存在", e.getMessage());
// 不落 SensitiveAccessLog(成功行)
Long sensitiveCount = sensitiveAccessLogMapper.selectCount(new LambdaQueryWrapper<SensitiveAccessLog>()
.eq(SensitiveAccessLog::getOperatorId, OPERATOR_ID)
.eq(SensitiveAccessLog::getDataType,
ContactAuthorizationService.SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE));
assertEquals(0L, sensitiveCount, "机制关闭的拒绝披露不得落 SensitiveAccessLog");
// 不落 matchmaker.contact.disclose 成功审计行(DATA_SCOPE_DENIED deny 审计可有)
Long discloseAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_DISCLOSE)
.eq(AuditLog::getOperatorId, OPERATOR_ID));
assertEquals(0L, discloseAuditCount, "机制关闭的拒绝披露不得落成功审计行");
}
@Test
@DisplayName("开关关闭: request → 404 化拒绝(T-C 补齐真实缺口),不落任何成功审计行")
void requestRejectedWhenDisabled() {
BusinessException e = assertThrows(BusinessException.class,
() -> contactAuthorizationService.request(OPERATOR_ID, USER_ID,
Constants.CONTACT_CHANNEL_PHONE, "机制关闭后申请"),
"机制关闭时申请必须 fail-safe 拒绝");
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(与 disclose 同口径)");
assertEquals("服务不存在", e.getMessage());
// 不产生任何授权记录(fail-safe 校验先于一切业务逻辑)
Long authCount = authorizationMapper.selectCount(new LambdaQueryWrapper<MatchmakerContactAuthorization>()
.eq(MatchmakerContactAuthorization::getOperatorId, OPERATOR_ID));
assertEquals(0L, authCount, "机制关闭的拒绝申请不得产生授权记录");
// 不落 matchmaker.contact.request 成功审计行(DATA_SCOPE_DENIED deny 审计可有)
Long requestAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_REQUEST)
.eq(AuditLog::getOperatorId, OPERATOR_ID));
assertEquals(0L, requestAuditCount, "机制关闭的拒绝申请不得落成功审计行");
// fail-safe 必须先于 purpose/channel 业务校验(缺目的的申请同样 404 化拒绝)
BusinessException e2 = assertThrows(BusinessException.class,
() -> contactAuthorizationService.request(OPERATOR_ID, USER_ID,
Constants.CONTACT_CHANNEL_PHONE, null));
assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode(),
"fail-safe 必须最先判定,不得让业务校验异常(400/422)穿透");
assertNotNull(e2.getMessage());
}
}
+2 -2
View File
@@ -74,7 +74,7 @@
| DEC-MATCHMAKER-004 不得代替用户决定 | Like/Pass/活动意向/开启聊天等用户本人操作 | ✅ 已完成(LOCKED;5e484d0 锁定) | | DEC-MATCHMAKER-004 不得代替用户决定 | Like/Pass/活动意向/开启聊天等用户本人操作 | ✅ 已完成(LOCKED;5e484d0 锁定) |
| DEC-MATCHMAKER-005 数据最小必要 | RBAC + 数据范围 + 字段隐私 + 服务授权 | ✅ 已完成(LOCKED;3caf5f8 + 1be0d98) | | DEC-MATCHMAKER-005 数据最小必要 | RBAC + 数据范围 + 字段隐私 + 服务授权 | ✅ 已完成(LOCKED;3caf5f8 + 1be0d98) |
| DEC-MATCHMAKER-006 全程留痕 | 分配/受限查看/越权拒绝等审计 + 第八节字段 | ✅ 已完成(LOCKED;V055 + 1be0d98) | | DEC-MATCHMAKER-006 全程留痕 | 分配/受限查看/越权拒绝等审计 + 第八节字段 | ✅ 已完成(LOCKED;V055 + 1be0d98) |
| 受控联系(电话/线下)授权机制 | **已立项实现**(DEC-MATCHMAKER-003 B1 例外通道,V056,提交 d3735e3,QA PASS 0 P0/P1):申请(须持有效分配)→ 审批(双岗 DEFAULT+CONFIG)→ **用户单独同意**(PIPL 单独同意动作:独立端点、无预同意参数、守卫三端锁定)→ 限时生效(默认 7 天)→ 撤销/过期全链路上线;唯一披露出口专用 disclose 端点(enabled fail-safe + 404 化 deny + user_phone_disclosure 敏感审计 + phone 不落日志) | ⏳ 剩余决策:双岗收敛(OQ-MATCHMAKER-009)+ 5 项 LEGAL_VERIFY 终审(OQ-MATCHMAKER-003);参数均 DEFAULT+CONFIG,终审后改配置生效 | | 受控联系(电话/线下)授权机制 | **已立项实现 + 产品裁决全部落定**(DEC-MATCHMAKER-003 B1 例外通道,V056,提交 d3735e3 → f73a116,QA PASS 0 P0/P1):申请(须持有效分配)→ **审批(组长主审 + 平台管理员兜底,分层已实现)**→ **用户单独同意**(PIPL 单独同意动作:独立端点、无预同意参数、守卫三端锁定)→ 限时生效(7 天,DEFAULT+CONFIG)→ 撤销/过期全链路上线;唯一披露出口专用 disclose 端点(enabled fail-safe [request 入口已同步补齐] + 404 化 deny + user_phone_disclosure 敏感审计 + phone 不落日志) | ✅ 产品裁决闭环(2026-10-11):①审批岗收敛=组长主审+管理员兜底(OQ-009 RESOLVED)②用户单独同意已实现 ③有效期 7 天维持 ④保留期 365 天维持 ⑤NULL=NULL 组长可审维持;建议正式放量前将裁决记录转法务复核留痕 |
| C 端死代码退役 3 项 | MatchmakerServiceService.getCandidates / cancelService / getRecommendationsForUser(grep 全仓零调用已核,2026-10-10) | ⏳ 待产品确认:是否批准专项治理批次执行(与 ZC-MM-006 G6 评估清单合并为一个退役 PR,不混入功能提交) | | C 端死代码退役 3 项 | MatchmakerServiceService.getCandidates / cancelService / getRecommendationsForUser(grep 全仓零调用已核,2026-10-10) | ⏳ 待产品确认:是否批准专项治理批次执行(与 ZC-MM-006 G6 评估清单合并为一个退役 PR,不混入功能提交) |
--- ---
@@ -146,5 +146,5 @@
--- ---
*最后更新: 2026-10-11 — 受控联系行更新为「已立项实现」(DEC-MATCHMAKER-003-B1,V056,提交 d3735e3,QA PASS 0 P0/P1;验证 mvn verify 895/895 / UI 24/24 / 后端契约 228/CURATED 20 / 真编译 87 WXML / 负向注入 5+2);剩余决策 = OQ-MATCHMAKER-009 双岗收敛 + OQ-MATCHMAKER-003 的 5 项 LEGAL_VERIFY 终审。此前:2026-10-10 红娘角色权限专项回写(DEC-MATCHMAKER-001~006 全部 LOCKED 已落地,ZC-MM-001~007 全链路);2026-09-20 V1.0.1 配置与规则纠偏(消解「推荐阈值 60 已确认 / 待定」矛盾等)。* *最后更新: 2026-10-11(二次)— 受控联系产品裁决全部落定(OQ-MATCHMAKER-003 RESOLVED / 009 RESOLVED / 010 RESOLVED,提交 f73a116 分层审批落地;裁决内容=组长主审+管理员兜底、有效期 7 天、保留期 365 天、NULL=NULL 组长可审;建议放量前转法务复核留痕)。此前:2026-10-11 受控联系立项(DEC-MATCHMAKER-003-B1,V056,d3735e3);2026-10-10 红娘角色权限专项回写(DEC-MATCHMAKER-001~006 全部 LOCKED 已落地,ZC-MM-001~007 全链路);2026-09-20 V1.0.1 配置与规则纠偏。*
+2 -2
View File
@@ -40,7 +40,7 @@
| `account.feature-flags.*`(活动/红娘/图片聊天/申诉/转介/互选/专项 开关) | `features.*` | CONFIG | 见 `FeatureFlagConfig` | 部分 | 是 | `FeatureFlagConfig`;`feature_flag_overrides`(V048)持久化覆盖。 | | `account.feature-flags.*`(活动/红娘/图片聊天/申诉/转介/互选/专项 开关) | `features.*` | CONFIG | 见 `FeatureFlagConfig` | 部分 | 是 | `FeatureFlagConfig`;`feature_flag_overrides`(V048)持久化覆盖。 |
| `idempotency.ttl` | 应用层 `idempotency_records`(TTL 默认 86400s) | CONFIG / LEGAL_VERIFY | `86400s` | 否 | 是 | 幂等去重窗口;开放第三方时需决定是否按调用方分级(OQ,见 open-questions)。 | | `idempotency.ttl` | 应用层 `idempotency_records`(TTL 默认 86400s) | CONFIG / LEGAL_VERIFY | `86400s` | 否 | 是 | 幂等去重窗口;开放第三方时需决定是否按调用方分级(OQ,见 open-questions)。 |
| `matchmaker.contact.enabled` | `matchmaker.contact.enabled`(Java 字段 `enabled`) | CONFIG / TBD | 代码缺省 **`false`**(fail-safe:配置缺失=机制关闭);生产 yml 显式 `true` | 否 | 是 | `ContactAuthorizationConfig`;受控联系(DEC-MATCHMAKER-003 B1 例外通道)功能总开关,紧急停用入口;判定点 `ContactAuthorizationService` 全部写路径与 disclose 前置。 | | `matchmaker.contact.enabled` | `matchmaker.contact.enabled`(Java 字段 `enabled`) | CONFIG / TBD | 代码缺省 **`false`**(fail-safe:配置缺失=机制关闭);生产 yml 显式 `true` | 否 | 是 | `ContactAuthorizationConfig`;受控联系(DEC-MATCHMAKER-003 B1 例外通道)功能总开关,紧急停用入口;判定点 `ContactAuthorizationService` 全部写路径与 disclose 前置。 |
| `matchmaker.contact.approver-authorities` | `matchmaker.contact.approver-authorities`(逗号分隔权限码) | CONFIG / TBD | `"matchmaker.team.assign,matchmaker.assign"` | 否 | 是 | `ContactAuthorizationConfig`;审批岗位(LEGAL_VERIFY① 默认双岗并列,待产品终审收敛单岗,见 OQ-MATCHMAKER-009);读点 `approve`/`reject` 的 Service 层动态判定(@PreAuthorize 无法配置化,故下沉 Service)。 | | `matchmaker.contact.approver-authorities` | `matchmaker.contact.approver-authorities`(逗号分隔权限码) | CONFIG / 已裁决(2026-10-11) | `"matchmaker.team.assign,matchmaker.assign"` | 是(分层语义) | 是 | `ContactAuthorizationConfig`;**分层审批(OQ-MATCHMAKER-009 RESOLVED)**:列表首段(默认 matchmaker.team.assign)=主审岗(组织范围校验,NULL=NULL 平台直属可审),兜底岗键固定 `matchmaker.assign`(全量审批不受组织限制、不受本配置调整影响);两者皆无 403。读点 `approve`/`reject` Service 层动态判定。 |
| `matchmaker.contact.consent-deadline-hours` | `matchmaker.contact.consent-deadline-hours` | CONFIG / TBD | `72` | 否 | 是 | `ContactAuthorizationConfig`;批准后用户单独同意窗口,超时 pending_consent 惰性收敛为 expired。 | | `matchmaker.contact.consent-deadline-hours` | `matchmaker.contact.consent-deadline-hours` | CONFIG / TBD | `72` | 否 | 是 | `ContactAuthorizationConfig`;批准后用户单独同意窗口,超时 pending_consent 惰性收敛为 expired。 |
| `matchmaker.contact.default-validity-days` | `matchmaker.contact.default-validity-days` | CONFIG / TBD | `7` | 否 | 是 | `ContactAuthorizationConfig`;授权生效期(LEGAL_VERIFY③ 默认值,待终审),expires_at = user_consented_at + N 天。 | | `matchmaker.contact.default-validity-days` | `matchmaker.contact.default-validity-days` | CONFIG / TBD | `7` | 否 | 是 | `ContactAuthorizationConfig`;授权生效期(LEGAL_VERIFY③ 默认值,待终审),expires_at = user_consented_at + N 天。 |
| `matchmaker.contact.retention-days` | `matchmaker.contact.retention-days` | CONFIG / TBD | `365` | 否 | 是 | `ContactAuthorizationConfig`;授权记录保留期(LEGAL_VERIFY④ 默认值,对齐 OQ-LOCAL-ELIG-001 现行 365);读点 `GET .../my` 响应 `retentionDays` 下发前端。 | | `matchmaker.contact.retention-days` | `matchmaker.contact.retention-days` | CONFIG / TBD | `365` | 否 | 是 | `ContactAuthorizationConfig`;授权记录保留期(LEGAL_VERIFY④ 默认值,对齐 OQ-LOCAL-ELIG-001 现行 365);读点 `GET .../my` 响应 `retentionDays` 下发前端。 |
@@ -57,5 +57,5 @@
--- ---
*最后更新: 2026-10-11 — 受控联系立项(V056 / 提交 d373e53 前后端,QA PASS):登记 matchmaker.contact.* 五个 canonical key(enabled / approver-authorities / consent-deadline-hours / default-validity-days / retention-days,均 DEFAULT+CONFIG、产品正式确认列=否,LEGAL_VERIFY①③④ 待产品+法务终审,见 OQ-MATCHMAKER-003/009);代码载体补 `ContactAuthorizationConfig`。此前:2026-09-21 V1.0.1 配置与规则纠偏 + canonical key 字面化。* *最后更新: 2026-10-11(二次)— 受控联系产品裁决落账:approver-authorities 转「已裁决/分层语义」(组长主审+管理员兜底,OQ-009 RESOLVED);enabled/consent-deadline-hours/default-validity-days/retention-days 裁决维持默认值(OQ-003 RESOLVED);request 读点补 enabled fail-safe(OQ-010 落地顺带修复真实缺口)。此前:2026-10-11 受控联系立项登记五 key。*
+17 -18
View File
@@ -80,30 +80,29 @@
## OQ-MATCHMAKER-003 · 受控联系(电话/线下)开放前的产品/法务确认项【LEGAL_VERIFY】 ## OQ-MATCHMAKER-003 · 受控联系(电话/线下)开放前的产品/法务确认项【LEGAL_VERIFY】
- **状态**:**IN_PROGRESS**(2026-10-11 立项实现:V056 + ContactAuthorizationPolicy/Service + 双控制器 + 前端三端,提交 d3735e3,QA PASS 0 P0/P1;双守卫翻转完成——`MatchmakerContactGuardTest` 改写为新机制契约、`MatchmakerActionGuardTest` 保留) - **状态**:**RESOLVED**(2026-10-11 产品裁决全部落定 + 实现/测试闭环,提交 d3735e3 → f73a116;建议正式放量前将本节产品裁决记录转法务复核留痕)
- **背景**:DEC-MATCHMAKER-003 / B1 LOCKED——默认平台内渠道联系,电话/线下联系须专项授权。B1 例外通道已按「申请(须持有效分配)→ 审批(双岗)→ **用户单独同意**(PIPL 单独同意动作,独立端点、无预同意参数)→ 限时生效 → 撤销/过期」全链路上线。 - **背景**:DEC-MATCHMAKER-003 / B1 LOCKED——默认平台内渠道联系,电话/线下联系须专项授权。B1 例外通道已按「申请(须持有效分配)→ 审批(组长主审+管理员兜底)→ **用户单独同意**(PIPL 单独同意动作,独立端点、无预同意参数)→ 限时生效 → 撤销/过期」全链路上线。
- **关闭前必须逐项终审(①②标 LEGAL_VERIFY,需产品 + 法务共同裁决)**: - **五项终审裁定(2026-10-11,产品侧全部确认)**:
1. 电话/线下联系例外由**哪个岗位批准**【LEGAL_VERIFY】——当前默认 `matchmaker.team.assign,matchmaker.assign` 双岗并列(待收敛,见 OQ-MATCHMAKER-009); 1. 电话/线下联系例外由**哪个岗位批准**【LEGAL_VERIFY①】——**RESOLVED:组长主审 + 平台管理员兜底**(OQ-MATCHMAKER-009 裁决,分层审批已实现,提交 f73a116);
2. 例外授权是否需要**用户单独同意**、如何记录【LEGAL_VERIFY】——当前实现已满足"单独端点 + consent 留痕 + 无捆绑默认勾选",待法务确认该形式合规; 2. 例外授权是否需要**用户单独同意**、如何记录【LEGAL_VERIFY②】——**RESOLVED:已实现**(单独端点 + consent 留痕 + 无捆绑默认勾选 + 三端守卫锁定);
3. 临时授权的**有效期限**——当前默认 `default-validity-days=7`(DEFAULT+CONFIG); 3. 临时授权的**有效期限**【LEGAL_VERIFY③】——**RESOLVED:维持 7 天**(`default-validity-days=7`,DEFAULT+CONFIG,调整改配置即生效);
4. 联系授权记录/联系结果摘要的**保留周期**——当前默认 `retention-days=365`(DEFAULT+CONFIG); 4. 联系授权记录/联系结果摘要的**保留周期**【LEGAL_VERIFY④】——**RESOLVED:维持 365 天**(`retention-days=365`,对齐平台审计口径);
5. 授权审批与用户同意的**审计字段与查询面**——当前已实现(purpose_code=matchmaker.contact.* 全链路 + service_assignment_id + request_trace_id + user_phone_disclosure 敏感审计),待确认是否满足合规查询要求。 5. 授权审批与用户同意的**审计字段与查询面**【LEGAL_VERIFY⑤/现 OQ-003 第 5 项】——**RESOLVED:现状满足**(purpose_code=matchmaker.contact.* 全链路 + service_assignment_id + request_trace_id + user_phone_disclosure 敏感审计;平台直属 org=NULL 审批边界按 NULL=NULL 规则维持,与分配建模口径一致)。
- **以上默认值均非 LOCKED**:全部经 `matchmaker.contact.*` canonical key 配置化(config-register),产品/法务终审后改配置即可,无需改代码。 - **产品裁决记录**:2026-10-11 四项裁决全部按推荐选项确认(审批岗收敛/有效期 7 天/保留期 365 天/NULL=NULL 组长可审);`matchmaker.contact.*` 全部 canonical key 保持 DEFAULT+CONFIG,后续调整改配置即可。
- **机械锁(已翻转)**:`MatchmakerContactGuardTest` 现锁定新机制契约——披露能力仅限专用白名单类、端点前缀白名单、到期/撤销/拒绝/未同意状态下 disclose 一律 404+DATA_SCOPE_DENIED、consent 端点独立性、ReferralService 禁词与无 @Scheduled 保留;未来绕过专用出口的披露路径必触红。 - **机械锁(已翻转)**:`MatchmakerContactGuardTest` 现锁定新机制契约——披露能力仅限专用白名单类、端点前缀白名单、到期/撤销/拒绝/未同意状态下 disclose 一律 404+DATA_SCOPE_DENIED、consent 端点独立性、ReferralService 禁词与无 @Scheduled 保留;未来绕过专用出口的披露路径必触红。
- **关联**:DEC-MATCHMAKER-003 规则 7、《决策》§十一;V056;提交 d3735e3。 - **关联**:DEC-MATCHMAKER-003 规则 7、《决策》§十一;V056;提交 d3735e3、f73a116。
## OQ-MATCHMAKER-009 · 受控联系审批双岗并列待收敛 ## OQ-MATCHMAKER-009 · 受控联系审批双岗并列待收敛
- **状态**:OPEN(待产品确认) - **状态**:RESOLVED(2026-10-11 产品裁决:组长主审 + 平台管理员兜底,提交 f73a116)
- **现状**:`matchmaker.contact.approver-authorities` 默认 `"matchmaker.team.assign,matchmaker.assign"`——红娘组长(同组织范围)或平台管理员**任一**即可批准/拒绝受控联系申请,无会签、无分歧裁决规则。 - **裁定**:分层审批——`matchmaker.assign`(平台管理员)为**兜底岗**(全量审批、不做组织范围校验);`approver-authorities` 首段(默认 `matchmaker.team.assign`)为**主审岗**(assertTeamScope 组织范围,hasGlobalAssign 固定不透传、防权限面双通道;NULL=NULL 平台直属可审);两者皆无 403 deny。双岗兼任者按兜底岗口径全量审批。
- **待确认**:收敛为单岗(组长 or 平台管理员)?或定义会签(双岗同时批准才生效)?或按渠道分级(线下见面需更高岗位)? - **落地证据**:`MatchmakerContactAuthorizationTest` 4 条分层用例(管理员跨组织兜底/组长他组织 403/组长审平台直属/双岗兜底全量,HTTP+审计双断言);负向注入移除兜底分支→恰好 2 例分层用例判红→还原复绿;主理人 Docker 独立实跑 20/20。
- **关联**:OQ-MATCHMAKER-003 第 1 项【LEGAL_VERIFY】;config-register `matchmaker.contact.approver-authorities`;改配置即可生效,无需改代码。 - **关联**:OQ-MATCHMAKER-003 第 1 项;config-register `matchmaker.contact.approver-authorities`(键名/默认值不变,语义分层)。
## OQ-MATCHMAKER-010 · QA P2-2:`matchmaker.contact.enabled=false` 行为用例待补 ## OQ-MATCHMAKER-010 · QA P2-2:`matchmaker.contact.enabled=false` 行为用例待补
- **状态**:TBD(QA 观察项移交,2026-10-11) - **状态**:RESOLVED(2026-10-11 补齐,提交 f73a116)
- **现状**:fail-safe 语义(配置缺失=机制关闭)已在配置层实现,但 `enabled=false` 下的**行为级**测试用例(申请/审批/consent/disclose 各端点在关闭态的拒绝响应与审计)尚未进入自动化基线。 - **落地**:新增 `MatchmakerContactDisabledTest`(@SpringBootTest properties 覆盖 `matchmaker.contact.enabled=false`)——disclose/request 均 404 化拒绝、零 SensitiveAccessLog、零 contact 成功审计行、fail-safe 先于 purpose 业务校验。补齐过程中**发现并修复真实缺口**:request 入口原未查 enabled(disclose 有、request 无),已补 denyAsNotFound 与 disclose 口径一致。
- **待办**:补集成用例(建议挂 `MatchmakerContactAuthorizationTest`,同文件串行追加);在补齐前,关闭态行为由代码评审保证。
## OQ-MATCHMAKER-004 · 红娘组长组织边界的 NULL=NULL「平台直属」规则 ## OQ-MATCHMAKER-004 · 红娘组长组织边界的 NULL=NULL「平台直属」规则
@@ -146,4 +145,4 @@
--- ---
*最后更新: 2026-10-11 — OQ-MATCHMAKER-003 由 DEFERRED 转 IN_PROGRESS(受控联系已立项实现 V056 / 提交 d3735e3,双守卫翻转,5 项 LEGAL_VERIFY 待产品+法务终审后关闭,默认值均 DEFAULT+CONFIG);新增 OQ-MATCHMAKER-009(审批双岗并列待收敛,OPEN)与 OQ-MATCHMAKER-010(QA P2-2:enabled=false 行为用例待补,TBD)。此前:2026-10-10 红娘专项新增 OQ-MATCHMAKER-003~008。* *最后更新: 2026-10-11(二次)— 产品裁决落账:OQ-MATCHMAKER-003 RESOLVED(5 项 LEGAL_VERIFY 全闭环:①组长主审+管理员兜底 ②用户单独同意已实现 ③有效期 7 天 ④保留期 365 天 ⑤审计面满足+NULL=NULL 维持;建议放量前转法务复核留痕)、OQ-MATCHMAKER-009 RESOLVED(分层审批落地,提交 f73a116)、OQ-MATCHMAKER-010 RESOLVED(disabled 行为基线补齐,顺带修复 request 未查 enabled 真实缺口)。此前:2026-10-11 OQ-003 转 IN_PROGRESS / 新增 009~010;2026-10-10 红娘专项新增 OQ-MATCHMAKER-003~008。*