Compare commits
2
Commits
21275ef770
...
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16c82739db | ||
|
|
f73a116a10 |
+14
-4
@@ -29,10 +29,20 @@ public class ContactAuthorizationConfig {
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* 审批岗权限码(逗号分隔,任一命中即可审批)。
|
||||
* 默认 "matchmaker.team.assign,matchmaker.assign"(组长组织内审批 / 平台授权岗位全量审批)。
|
||||
* 审批权限在 Service 层动态判定(principal 权限 ∈ 本集合),不用 @PreAuthorize 静态注解 ——
|
||||
* 审批岗名单可经 yml 调整而无需改代码。
|
||||
* 审批岗权限码(逗号分隔,<b>分层语义</b>,用户裁决①:组长主审 + 管理员兜底)。
|
||||
* 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。
|
||||
*
|
||||
* <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";
|
||||
|
||||
|
||||
+70
-21
@@ -34,10 +34,11 @@ import java.util.stream.Collectors;
|
||||
*
|
||||
* <p>六操作状态机(全矩阵):
|
||||
* <pre>
|
||||
* request : 红娘(须持分配)→ pending_approval,审计含 service_assignment_id
|
||||
* approve : 审批岗(权限码动态判定 ∈ approver-authorities + 组长组织范围)
|
||||
* pending_approval → pending_consent;违规 403(无资源枚举面维持 403)
|
||||
* reject : 审批岗拒绝 pending_approval → rejected
|
||||
* request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
|
||||
* approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
|
||||
* {@link #assertApproverAuthority})pending_approval → pending_consent;
|
||||
* 违规 403(无资源枚举面维持 403)
|
||||
* reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
|
||||
* consent : C 端用户本人(归属校验,独立端点、无预同意参数)
|
||||
* pending_consent → active(expires_at = consent + default-validity-days)
|
||||
* decline : C 端用户本人 pending_consent → rejected
|
||||
@@ -59,6 +60,11 @@ public class ContactAuthorizationService {
|
||||
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
|
||||
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
|
||||
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 ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||
@@ -75,6 +81,12 @@ public class ContactAuthorizationService {
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization request(String operatorId, String userId,
|
||||
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()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
|
||||
}
|
||||
@@ -114,17 +126,28 @@ public class ContactAuthorizationService {
|
||||
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,
|
||||
Set<String> principalPermissions) {
|
||||
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
boolean authorized = principalPermissions != null
|
||||
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
|
||||
boolean authorized = isFallbackApprover(principalPermissions)
|
||||
|| isPrimaryApprover(principalPermissions);
|
||||
if (!authorized) {
|
||||
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
|
||||
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
|
||||
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
|
||||
Set<String> principalPermissions) {
|
||||
@@ -141,11 +193,9 @@ public class ContactAuthorizationService {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许审批");
|
||||
}
|
||||
// 组长组织范围校验(002 assertAssignScope 复用):matchmaker.assign 持有者不限组织
|
||||
boolean hasGlobalAssign = principalPermissions != null
|
||||
&& principalPermissions.contains("matchmaker.assign");
|
||||
matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign,
|
||||
auth.getOperatorId(), authId);
|
||||
// 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
|
||||
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
|
||||
auth.getOperatorId());
|
||||
|
||||
auth.setApprovedBy(approverOperatorId);
|
||||
auth.setApprovedAt(Instant.now());
|
||||
@@ -170,10 +220,9 @@ public class ContactAuthorizationService {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许拒绝");
|
||||
}
|
||||
boolean hasGlobalAssign = principalPermissions != null
|
||||
&& principalPermissions.contains("matchmaker.assign");
|
||||
matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign,
|
||||
auth.getOperatorId(), authId);
|
||||
// 分层组织范围校验(用户裁决①,与 approve 同口径)
|
||||
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
|
||||
auth.getOperatorId());
|
||||
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
+77
-2
@@ -79,6 +79,9 @@ class MatchmakerContactAuthorizationTest {
|
||||
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_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
|
||||
void setUp() {
|
||||
@@ -266,13 +269,85 @@ class MatchmakerContactAuthorizationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向③: 跨组织组长 approve → 403(组织范围校验)")
|
||||
@DisplayName("B1 负向③: 仅 team.assign 组长审他组织 → 403 + DATA_SCOPE_DENIED 审计(分层·主审岗)")
|
||||
void crossOrgLeaderCannotApprove() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
long before = deniedAudits(leaderY, auth.getId());
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> 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
|
||||
|
||||
+104
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
| DEC-MATCHMAKER-004 不得代替用户决定 | Like/Pass/活动意向/开启聊天等用户本人操作 | ✅ 已完成(LOCKED;5e484d0 锁定) |
|
||||
| DEC-MATCHMAKER-005 数据最小必要 | RBAC + 数据范围 + 字段隐私 + 服务授权 | ✅ 已完成(LOCKED;3caf5f8 + 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,不混入功能提交) |
|
||||
|
||||
---
|
||||
@@ -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 配置与规则纠偏。*
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| `account.feature-flags.*`(活动/红娘/图片聊天/申诉/转介/互选/专项 开关) | `features.*` | CONFIG | 见 `FeatureFlagConfig` | 部分 | 是 | `FeatureFlagConfig`;`feature_flag_overrides`(V048)持久化覆盖。 |
|
||||
| `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.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.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` 下发前端。 |
|
||||
@@ -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。*
|
||||
|
||||
|
||||
@@ -80,30 +80,29 @@
|
||||
|
||||
## OQ-MATCHMAKER-003 · 受控联系(电话/线下)开放前的产品/法务确认项【LEGAL_VERIFY】
|
||||
|
||||
- **状态**:**IN_PROGRESS**(2026-10-11 立项实现:V056 + ContactAuthorizationPolicy/Service + 双控制器 + 前端三端,提交 d3735e3,QA PASS 0 P0/P1;双守卫翻转完成——`MatchmakerContactGuardTest` 改写为新机制契约、`MatchmakerActionGuardTest` 保留)
|
||||
- **背景**:DEC-MATCHMAKER-003 / B1 LOCKED——默认平台内渠道联系,电话/线下联系须专项授权。B1 例外通道已按「申请(须持有效分配)→ 审批(双岗)→ **用户单独同意**(PIPL 单独同意动作,独立端点、无预同意参数)→ 限时生效 → 撤销/过期」全链路上线。
|
||||
- **关闭前必须逐项终审(①②标 LEGAL_VERIFY,需产品 + 法务共同裁决)**:
|
||||
1. 电话/线下联系例外由**哪个岗位批准**【LEGAL_VERIFY】——当前默认 `matchmaker.team.assign,matchmaker.assign` 双岗并列(待收敛,见 OQ-MATCHMAKER-009);
|
||||
2. 例外授权是否需要**用户单独同意**、如何记录【LEGAL_VERIFY】——当前实现已满足"单独端点 + consent 留痕 + 无捆绑默认勾选",待法务确认该形式合规;
|
||||
3. 临时授权的**有效期限**——当前默认 `default-validity-days=7`(DEFAULT+CONFIG);
|
||||
4. 联系授权记录/联系结果摘要的**保留周期**——当前默认 `retention-days=365`(DEFAULT+CONFIG);
|
||||
5. 授权审批与用户同意的**审计字段与查询面**——当前已实现(purpose_code=matchmaker.contact.* 全链路 + service_assignment_id + request_trace_id + user_phone_disclosure 敏感审计),待确认是否满足合规查询要求。
|
||||
- **以上默认值均非 LOCKED**:全部经 `matchmaker.contact.*` canonical key 配置化(config-register),产品/法务终审后改配置即可,无需改代码。
|
||||
- **状态**:**RESOLVED**(2026-10-11 产品裁决全部落定 + 实现/测试闭环,提交 d3735e3 → f73a116;建议正式放量前将本节产品裁决记录转法务复核留痕)
|
||||
- **背景**:DEC-MATCHMAKER-003 / B1 LOCKED——默认平台内渠道联系,电话/线下联系须专项授权。B1 例外通道已按「申请(须持有效分配)→ 审批(组长主审+管理员兜底)→ **用户单独同意**(PIPL 单独同意动作,独立端点、无预同意参数)→ 限时生效 → 撤销/过期」全链路上线。
|
||||
- **五项终审裁定(2026-10-11,产品侧全部确认)**:
|
||||
1. 电话/线下联系例外由**哪个岗位批准**【LEGAL_VERIFY①】——**RESOLVED:组长主审 + 平台管理员兜底**(OQ-MATCHMAKER-009 裁决,分层审批已实现,提交 f73a116);
|
||||
2. 例外授权是否需要**用户单独同意**、如何记录【LEGAL_VERIFY②】——**RESOLVED:已实现**(单独端点 + consent 留痕 + 无捆绑默认勾选 + 三端守卫锁定);
|
||||
3. 临时授权的**有效期限**【LEGAL_VERIFY③】——**RESOLVED:维持 7 天**(`default-validity-days=7`,DEFAULT+CONFIG,调整改配置即生效);
|
||||
4. 联系授权记录/联系结果摘要的**保留周期**【LEGAL_VERIFY④】——**RESOLVED:维持 365 天**(`retention-days=365`,对齐平台审计口径);
|
||||
5. 授权审批与用户同意的**审计字段与查询面**【LEGAL_VERIFY⑤/现 OQ-003 第 5 项】——**RESOLVED:现状满足**(purpose_code=matchmaker.contact.* 全链路 + service_assignment_id + request_trace_id + user_phone_disclosure 敏感审计;平台直属 org=NULL 审批边界按 NULL=NULL 规则维持,与分配建模口径一致)。
|
||||
- **产品裁决记录**:2026-10-11 四项裁决全部按推荐选项确认(审批岗收敛/有效期 7 天/保留期 365 天/NULL=NULL 组长可审);`matchmaker.contact.*` 全部 canonical key 保持 DEFAULT+CONFIG,后续调整改配置即可。
|
||||
- **机械锁(已翻转)**:`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 · 受控联系审批双岗并列待收敛
|
||||
|
||||
- **状态**:OPEN(待产品确认)
|
||||
- **现状**:`matchmaker.contact.approver-authorities` 默认 `"matchmaker.team.assign,matchmaker.assign"`——红娘组长(同组织范围)或平台管理员**任一**即可批准/拒绝受控联系申请,无会签、无分歧裁决规则。
|
||||
- **待确认**:收敛为单岗(组长 or 平台管理员)?或定义会签(双岗同时批准才生效)?或按渠道分级(线下见面需更高岗位)?
|
||||
- **关联**:OQ-MATCHMAKER-003 第 1 项【LEGAL_VERIFY】;config-register `matchmaker.contact.approver-authorities`;改配置即可生效,无需改代码。
|
||||
- **状态**:RESOLVED(2026-10-11 产品裁决:组长主审 + 平台管理员兜底,提交 f73a116)
|
||||
- **裁定**:分层审批——`matchmaker.assign`(平台管理员)为**兜底岗**(全量审批、不做组织范围校验);`approver-authorities` 首段(默认 `matchmaker.team.assign`)为**主审岗**(assertTeamScope 组织范围,hasGlobalAssign 固定不透传、防权限面双通道;NULL=NULL 平台直属可审);两者皆无 403 deny。双岗兼任者按兜底岗口径全量审批。
|
||||
- **落地证据**:`MatchmakerContactAuthorizationTest` 4 条分层用例(管理员跨组织兜底/组长他组织 403/组长审平台直属/双岗兜底全量,HTTP+审计双断言);负向注入移除兜底分支→恰好 2 例分层用例判红→还原复绿;主理人 Docker 独立实跑 20/20。
|
||||
- **关联**:OQ-MATCHMAKER-003 第 1 项;config-register `matchmaker.contact.approver-authorities`(键名/默认值不变,语义分层)。
|
||||
|
||||
## OQ-MATCHMAKER-010 · QA P2-2:`matchmaker.contact.enabled=false` 行为用例待补
|
||||
|
||||
- **状态**:TBD(QA 观察项移交,2026-10-11)
|
||||
- **现状**:fail-safe 语义(配置缺失=机制关闭)已在配置层实现,但 `enabled=false` 下的**行为级**测试用例(申请/审批/consent/disclose 各端点在关闭态的拒绝响应与审计)尚未进入自动化基线。
|
||||
- **待办**:补集成用例(建议挂 `MatchmakerContactAuthorizationTest`,同文件串行追加);在补齐前,关闭态行为由代码评审保证。
|
||||
- **状态**:RESOLVED(2026-10-11 补齐,提交 f73a116)
|
||||
- **落地**:新增 `MatchmakerContactDisabledTest`(@SpringBootTest properties 覆盖 `matchmaker.contact.enabled=false`)——disclose/request 均 404 化拒绝、零 SensitiveAccessLog、零 contact 成功审计行、fail-safe 先于 purpose 业务校验。补齐过程中**发现并修复真实缺口**:request 入口原未查 enabled(disclose 有、request 无),已补 denyAsNotFound 与 disclose 口径一致。
|
||||
|
||||
## 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。*
|
||||
|
||||
Reference in New Issue
Block a user