feat(matchmaker,contactauth): 受控联系授权机制立项落地(OQ-MATCHMAKER-003 / DEC-MATCHMAKER-003 B1 例外通道)
后端: - V056 matchmaker_contact_authorizations 表:channel CHECK(phone/offline_meeting/full_contact)、六态状态机(pending_approval→pending_consent→active→expired/revoked, →rejected)、部分唯一索引 uq_mm_contact_auth_active 防同红娘-同用户-同渠道多 ACTIVE、双态惰性收敛(active 过期 + pending_consent 超同意窗口) - ContactAuthorizationConfig 五 canonical key(matchmaker.contact.*):enabled 代码缺省 false fail-safe(yml 显式 true)/approver-authorities 双岗并列/consent-deadline-hours 72/default-validity-days 7/retention-days 365,全部 DEFAULT+CONFIG 待产品+法务终审 - ContactAuthorizationPolicy.assertContactAllowed(404 化 deny 先审计后 404) + canContact 徽标查询 - ContactAuthorizationService:request(须有效分配+purpose 必填)/approve(权限码动态判定∈配置+组长组织范围)/consent(用户本人,仅 pending_consent)/decline/revoke(reason 必填)/disclose(唯一披露出口:enabled fail-safe→assertActiveAssignment→assertContactAllowed→仅返回 phone/disclosedAt/authorizationId 三键,每次落 SensitiveAccessLog user_phone_disclosure,phone 不落应用日志) - 双控制器:运营端 /operator/matchmaker/contact-authorizations + C 端 /contact-authorizations(UserPrincipal 归属校验,独立端点无预同意参数),不碰 SecurityConfig 守卫翻转(机械锁强制复审机制按设计生效): - MatchmakerContactGuardTest 由「锁不存在」翻转为「锁新契约」五条:回显白名单类收敛/端点前缀收敛(受治理三包)/到期语义/consent 独立性三端扫描(后端+miniapp+admin-web,修复原只扫后端的预同意参数逃逸缺口)/ReferralService 白名单与无 @Scheduled 保留 - run-backend-contracts CURATED 18→20 前端: - miniapp 新页 pages/contact-consent(目的/渠道/窗口明示+独立同意拒绝按钮无预勾选,app.json 第 64 页) + 红娘工作台申请/列表/撤销 - admin-web「联系授权审批」tab(审批岗队列+本人授权撤销,枚举码中文映射) 验证:mvn verify 895/895(3 skip)、UI 契约 24/24(64 页)、后端契约门禁 228/CURATED 20、小程序真编译 87 WXML 0 失败;负向注入 5(工程师)+2(QA 不同落点)判红→还原复绿;QA 抓 disclose 键数收敛断言缺口已补并回归;QA fresh eyes PASS 0 P0/P1
This commit is contained in:
+55
@@ -0,0 +1,55 @@
|
||||
package com.danyang.match.platform.contactauth.controller;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 受控联系授权控制器(C 端用户,ZC-MM-003 / B1 立项)。
|
||||
*
|
||||
* <p>端点(基路径 {@code /api/v1/contact-authorizations},USER 角色兜底):
|
||||
* <ul>
|
||||
* <li>GET {@code /my} —— 我的授权列表 + 待同意队列;</li>
|
||||
* <li>POST {@code /{id}/consent} —— 用户<b>单独明示同意</b>(独立端点,
|
||||
* 非任何其他操作的捆绑步骤、无预勾选/预同意参数);</li>
|
||||
* <li>POST {@code /{id}/decline} —— 用户拒绝。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>归属校验(userId.equals)在 Service 层 {@code requireOwnedAuthorization} 承担;
|
||||
* 不碰 SecurityConfig(路径落入 {@code /** → hasRole("USER")} 兜底规则)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/contact-authorizations")
|
||||
@RequiredArgsConstructor
|
||||
public class ContactAuthorizationUserController {
|
||||
|
||||
private final ContactAuthorizationService contactAuthorizationService;
|
||||
|
||||
/** 我的授权列表 + 待同意队列。 */
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<Map<String, Object>> my(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.myAuthorizationsForUser(principal.id()));
|
||||
}
|
||||
|
||||
/** 单独明示同意(独立端点,无预同意参数)。 */
|
||||
@PostMapping("/{id}/consent")
|
||||
public ApiResponse<Object> consent(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.consent(id, principal.id()));
|
||||
}
|
||||
|
||||
/** 拒绝授权请求。 */
|
||||
@PostMapping("/{id}/decline")
|
||||
public ApiResponse<Object> decline(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.decline(id, principal.id()));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.danyang.match.platform.matchmaker.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 受控联系授权配置(ZC-MM-003 / B1 立项,对照 {@code ProfileProperties} 范式)。
|
||||
*
|
||||
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):
|
||||
* 五个 canonical key 统一在此管理,禁止散落在 Controller/Service/前端各自写死;
|
||||
* 治理登记见 T06 config-register(OQ-MATCHMAKER-003)。
|
||||
*
|
||||
* <p><b>fail-safe 语义</b>:{@code enabled} 的 Java 字段缺省为 <b>false</b> ——
|
||||
* 若配置键完全缺失(如新环境漏配),机制整体关闭(fail-safe 关闭优于意外开放)。
|
||||
* 生产 {@code application.yml} 显式声明 {@code matchmaker.contact.enabled: true};
|
||||
* "默认开启" 指的是配置注册表中的生效默认值,而非代码缺省。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "matchmaker.contact")
|
||||
public class ContactAuthorizationConfig {
|
||||
|
||||
/**
|
||||
* 机制总开关。
|
||||
* Java 缺省 false(fail-safe:配置缺失 → 机制关闭);生产 yml 显式置 true。
|
||||
* disclose(H4 唯一披露出口)前强校验,关闭时任何披露请求 404 化拒绝。
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* 审批岗权限码(逗号分隔,任一命中即可审批)。
|
||||
* 默认 "matchmaker.team.assign,matchmaker.assign"(组长组织内审批 / 平台授权岗位全量审批)。
|
||||
* 审批权限在 Service 层动态判定(principal 权限 ∈ 本集合),不用 @PreAuthorize 静态注解 ——
|
||||
* 审批岗名单可经 yml 调整而无需改代码。
|
||||
*/
|
||||
private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign";
|
||||
|
||||
/**
|
||||
* 用户同意窗口(小时):pending_consent 超窗未同意 → 惰性 expired。
|
||||
* 默认 72h(治理登记 OQ-MATCHMAKER-003 技术默认,待产品确认)。
|
||||
*/
|
||||
private int consentDeadlineHours = 72;
|
||||
|
||||
/**
|
||||
* 授权默认有效期(天):用户 consent 起算,expires_at = consent + N 天。
|
||||
* 默认 7 天;到期由双态惰性收敛置 expired。
|
||||
*/
|
||||
private int defaultValidityDays = 7;
|
||||
|
||||
/**
|
||||
* 授权审计数据保留期(天):受控联系相关审计/敏感访问记录的保留下限口径。
|
||||
* 默认 365 天;当前以 /my 响应元数据形式下发(retentionDays 字段)供前端展示,
|
||||
* 治理登记 T06 open-questions 同步说明。
|
||||
*/
|
||||
private int retentionDays = 365;
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.danyang.match.platform.matchmaker.controller;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 受控联系授权控制器(运营端,ZC-MM-003 / B1 立项)。
|
||||
*
|
||||
* <p>端点(基路径 {@code /api/v1/operator/matchmaker/contact-authorizations}):
|
||||
* <ul>
|
||||
* <li>POST {@code ""} —— 红娘发起授权申请(matchmaker.service,purpose 必填);</li>
|
||||
* <li>POST {@code /{id}/approve} / {@code /{id}/reject} —— 审批岗批准/拒绝
|
||||
* (审批岗权限码在 Service 层动态判定 ∈ approver-authorities,组长另有组织范围校验);</li>
|
||||
* <li>GET {@code /my} —— 红娘本人授权列表;</li>
|
||||
* <li>POST {@code /{id}/revoke} —— 申请人或审批人撤销(reason 必填);</li>
|
||||
* <li>GET {@code /disclose} —— H4 唯一披露出口(userId/purpose 必填,
|
||||
* 每次落 SensitiveAccessLog;phone 不落任何应用日志)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>类级 {@code matchmaker.view} 覆盖列表/详情读;写动作方法级收敛;
|
||||
* 审批岗动态权限与 G4 404 化拒绝、approve 403 边界均由
|
||||
* {@code ContactAuthorizationService} 承担(T06 open-questions OQ-MATCHMAKER-009)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/matchmaker/contact-authorizations")
|
||||
@PreAuthorize("hasAuthority('matchmaker.view')")
|
||||
@RequiredArgsConstructor
|
||||
public class MatchmakerContactAuthorizationController {
|
||||
|
||||
private final ContactAuthorizationService contactAuthorizationService;
|
||||
|
||||
/** 红娘发起授权申请(须持分配;purpose 必填)。 */
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<MatchmakerContactAuthorization> request(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(contactAuthorizationService.request(
|
||||
principal.id(),
|
||||
body.get("userId"),
|
||||
body.get("channel"),
|
||||
body.get("purpose")));
|
||||
}
|
||||
|
||||
/** 审批岗批准(权限动态判定 + 组长组织范围校验)。 */
|
||||
@PostMapping("/{id}/approve")
|
||||
public ApiResponse<MatchmakerContactAuthorization> approve(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(contactAuthorizationService.approve(
|
||||
id, principal.id(), principal.permissions()));
|
||||
}
|
||||
|
||||
/** 审批岗拒绝。 */
|
||||
@PostMapping("/{id}/reject")
|
||||
public ApiResponse<MatchmakerContactAuthorization> reject(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(contactAuthorizationService.reject(
|
||||
id, principal.id(), principal.permissions()));
|
||||
}
|
||||
|
||||
/** 红娘本人授权列表(含保留期元数据)。 */
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<Map<String, Object>> my(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(contactAuthorizationService.myAuthorizations(principal.id()));
|
||||
}
|
||||
|
||||
/** 申请人或审批人撤销(reason 必填)。 */
|
||||
@PostMapping("/{id}/revoke")
|
||||
public ApiResponse<MatchmakerContactAuthorization> revoke(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(contactAuthorizationService.revoke(
|
||||
id, principal.id(), body.get("reason")));
|
||||
}
|
||||
|
||||
/** H4 唯一披露出口:userId/purpose 必填,channel 缺省 phone。 */
|
||||
@GetMapping("/disclose")
|
||||
public ApiResponse<Map<String, Object>> disclose(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestParam String userId,
|
||||
@RequestParam String purpose,
|
||||
@RequestParam(required = false) String channel) {
|
||||
return ApiResponse.ok(contactAuthorizationService.disclose(
|
||||
principal.id(), userId, channel, purpose));
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.danyang.match.platform.matchmaker.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 受控联系授权(ZC-MM-003 / B1 例外通道,架构师设计 H4)。
|
||||
*
|
||||
* <p>状态机(六态):pending_approval → pending_consent → active / rejected;
|
||||
* pending_consent / active 双态惰性收敛为 expired(有效期或同意窗口届满);
|
||||
* active → revoked(申请人或审批人撤销)。V056 建表。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("matchmaker_contact_authorizations")
|
||||
public class MatchmakerContactAuthorization extends BaseEntity {
|
||||
|
||||
/** 发起申请的红娘 operator id */
|
||||
private String operatorId;
|
||||
/** 被联系用户 id */
|
||||
private String userId;
|
||||
/** 联系渠道:phone / offline_meeting / full_contact */
|
||||
private String channel;
|
||||
/** 授权目的(必填,用户同意页明示) */
|
||||
private String purpose;
|
||||
/** 状态:pending_approval / pending_consent / active / rejected / expired / revoked */
|
||||
private String status;
|
||||
/** 审批岗 operator id */
|
||||
private String approvedBy;
|
||||
private Instant approvedAt;
|
||||
/** 用户单独同意时间(独立端点,无预同意参数) */
|
||||
private Instant userConsentedAt;
|
||||
/** 生效截止:pending 期为同意窗口上界,consent 后重算为 consent + validity days */
|
||||
private Instant expiresAt;
|
||||
private Instant revokedAt;
|
||||
private String revokedBy;
|
||||
private String revokeReason;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.danyang.match.platform.matchmaker.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 受控联系授权 Mapper(ZC-MM-003 / B1 立项)。
|
||||
*
|
||||
* <p>双态惰性收敛(对照 005 expireOverdue 范式,读前触发、无定时任务):
|
||||
* ① active 且 expires_at 届满 → expired(授权有效期到);
|
||||
* ② pending_consent 且同意窗口(expires_at 在 request/approve 期承载窗口上界)届满 → expired。
|
||||
*/
|
||||
@Mapper
|
||||
public interface MatchmakerContactAuthorizationMapper extends BaseMapper<MatchmakerContactAuthorization> {
|
||||
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerContactAuthorization> findById(@Param("id") String id);
|
||||
|
||||
/** 某红娘的全部授权记录(工作台列表,倒序) */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerContactAuthorization> findByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/** 用户待同意队列(pending_consent,C 端同意页数据源) */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
|
||||
"AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " +
|
||||
"ORDER BY created_at DESC")
|
||||
List<MatchmakerContactAuthorization> findPendingConsentByUser(@Param("userId") String userId);
|
||||
|
||||
/** 某红娘对某用户某渠道当前生效中的授权 */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
|
||||
"AND user_id = #{userId} AND channel = #{channel} AND status = 'active' " +
|
||||
"AND expires_at > NOW() AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerContactAuthorization> findActiveByOperatorUserChannel(@Param("operatorId") String operatorId,
|
||||
@Param("userId") String userId,
|
||||
@Param("channel") String channel);
|
||||
|
||||
/** 某用户的全部授权记录(C 端我的授权列表) */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerContactAuthorization> findByUser(@Param("userId") String userId);
|
||||
|
||||
/**
|
||||
* 双态惰性收敛:
|
||||
* ① active 过期 → expired;
|
||||
* ② pending_consent 超同意窗口 → expired(expires_at 即窗口上界)。
|
||||
*
|
||||
* @return 本次被收敛的行数
|
||||
*/
|
||||
@Update("UPDATE matchmaker_contact_authorizations SET status = 'expired', updated_at = NOW() " +
|
||||
"WHERE status IN ('active', 'pending_consent') AND expires_at < NOW() AND deleted_at IS NULL")
|
||||
int expireOverdue();
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.danyang.match.platform.matchmaker.service;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 受控联系授权判定器(ZC-MM-003 / B1 立项,架构师设计 G4)。
|
||||
*
|
||||
* <p>红娘对某用户某渠道能否披露联系方式,唯一事实源是
|
||||
* {@code matchmaker_contact_authorizations} 中「生效中的授权」
|
||||
* (status=active 且未过期,读前双态惰性收敛)。
|
||||
*
|
||||
* <p>判定无效时走 {@link DataScopeGuard#denyAsNotFound}:先落 DATA_SCOPE_DENIED 审计,
|
||||
* 再抛 404「服务不存在」(G4 防探测范式,与分配判定器同款)。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ContactAuthorizationPolicy {
|
||||
|
||||
private final MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
|
||||
/**
|
||||
* 断言生效中的授权;无效时先审计后 404。
|
||||
*
|
||||
* @return 生效中的授权记录(永不返回 null)
|
||||
*/
|
||||
public MatchmakerContactAuthorization assertContactAllowed(String operatorId, String userId,
|
||||
String channel, String purpose) {
|
||||
Optional<MatchmakerContactAuthorization> found = canContact(operatorId, userId, channel);
|
||||
if (found.isPresent()) {
|
||||
return found.get();
|
||||
}
|
||||
dataScopeGuard.denyAsNotFound(operatorId, "matchmaker_contact_authorization", userId,
|
||||
"服务不存在", null, null);
|
||||
return null; // denyAsNotFound 必然抛出,此行不可达
|
||||
}
|
||||
|
||||
/**
|
||||
* 徽标查询(不审计):双态惰性收敛后查生效授权。
|
||||
* 供 UI「可联系」徽标等高频读场景,避免每徽标一次拒绝审计。
|
||||
*/
|
||||
public Optional<MatchmakerContactAuthorization> canContact(String operatorId, String userId,
|
||||
String channel) {
|
||||
authorizationMapper.expireOverdue();
|
||||
return authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId,
|
||||
channel == null ? Constants.CONTACT_CHANNEL_PHONE : channel);
|
||||
}
|
||||
}
|
||||
+401
@@ -0,0 +1,401 @@
|
||||
package com.danyang.match.platform.matchmaker.service;
|
||||
|
||||
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.config.ContactAuthorizationConfig;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||
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.DataScopeGuard;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 受控联系授权服务(ZC-MM-003 / B1 立项,架构师设计 H4)。
|
||||
*
|
||||
* <p>六操作状态机(全矩阵):
|
||||
* <pre>
|
||||
* request : 红娘(须持分配)→ pending_approval,审计含 service_assignment_id
|
||||
* approve : 审批岗(权限码动态判定 ∈ approver-authorities + 组长组织范围)
|
||||
* pending_approval → pending_consent;违规 403(无资源枚举面维持 403)
|
||||
* reject : 审批岗拒绝 pending_approval → rejected
|
||||
* consent : C 端用户本人(归属校验,独立端点、无预同意参数)
|
||||
* pending_consent → active(expires_at = consent + default-validity-days)
|
||||
* decline : C 端用户本人 pending_consent → rejected
|
||||
* revoke : 申请人或审批人,reason 必填 → revoked
|
||||
* disclose : H4 唯一披露出口 —— enabled(fail-safe) → 持分配 → 生效授权;
|
||||
* 返回 {phone, disclosedAt, authorizationId};每次落 SensitiveAccessLog;
|
||||
* phone 不落任何应用日志
|
||||
* </pre>
|
||||
*
|
||||
* <p>拒绝口径(G4 防探测):disclose 及授权有效性判定一律 404 化 deny
|
||||
* (先 DATA_SCOPE_DENIED 审计再 404「服务不存在」);approve/管理类违规维持 403。
|
||||
* 状态机非法转换一律 CONFLICT。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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";
|
||||
|
||||
private final MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||
private final MatchmakerAssignmentService matchmakerAssignmentService;
|
||||
private final ContactAuthorizationPolicy contactAuthorizationPolicy;
|
||||
private final ContactAuthorizationConfig config;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
// ==================== ① request:红娘发起申请 ====================
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization request(String operatorId, String userId,
|
||||
String channel, String purpose) {
|
||||
if (purpose == null || purpose.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
|
||||
}
|
||||
requireValidChannel(channel);
|
||||
|
||||
// 未持分配申请 → 403 + DATA_SCOPE_DENIED 审计(申请凭据是分配而非授权本身)
|
||||
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignment(
|
||||
operatorId, userId, RESOURCE_TYPE);
|
||||
|
||||
// 同 (operator,user,channel) 已有生效授权或待处理申请 → CONFLICT(防重复申请洪泛)
|
||||
if (authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId, channel).isPresent()) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在生效中的授权");
|
||||
}
|
||||
boolean hasPending = authorizationMapper.findByOperator(operatorId).stream()
|
||||
.anyMatch(a -> a.getUserId().equals(userId) && a.getChannel().equals(channel)
|
||||
&& (Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(a.getStatus())
|
||||
|| Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(a.getStatus())));
|
||||
if (hasPending) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在待处理的授权申请");
|
||||
}
|
||||
|
||||
MatchmakerContactAuthorization auth = new MatchmakerContactAuthorization();
|
||||
auth.setOperatorId(operatorId);
|
||||
auth.setUserId(userId);
|
||||
auth.setChannel(channel);
|
||||
auth.setPurpose(purpose.trim());
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL);
|
||||
// pending 期 expires_at 承载同意窗口上界(consent 后重算为有效期)
|
||||
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
|
||||
authorizationMapper.insert(auth);
|
||||
|
||||
log.info("受控联系授权申请: authId={}, operatorId={}, userId={}, channel={}",
|
||||
auth.getId(), operatorId, userId, channel);
|
||||
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(),
|
||||
assignment != null ? assignment.getId() : null,
|
||||
"matchmaker.contact.request", "channel=" + channel + ";purpose=" + auth.getPurpose());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ② approve / ③ reject:审批岗 ====================
|
||||
|
||||
/** 审批岗权限码动态判定(principal 权限 ∈ approver-authorities 配置,非 @PreAuthorize 静态)。 */
|
||||
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);
|
||||
if (!authorized) {
|
||||
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
|
||||
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
|
||||
"非审批岗,禁止审批受控联系授权");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
|
||||
Set<String> principalPermissions) {
|
||||
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
|
||||
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);
|
||||
|
||||
auth.setApprovedBy(approverOperatorId);
|
||||
auth.setApprovedAt(Instant.now());
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT);
|
||||
// 刷新同意窗口上界(给用户完整窗口)
|
||||
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId);
|
||||
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
|
||||
"result=approved;channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization reject(String authId, String approverOperatorId,
|
||||
Set<String> principalPermissions) {
|
||||
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许拒绝");
|
||||
}
|
||||
boolean hasGlobalAssign = principalPermissions != null
|
||||
&& principalPermissions.contains("matchmaker.assign");
|
||||
matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign,
|
||||
auth.getOperatorId(), authId);
|
||||
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId);
|
||||
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
|
||||
"result=rejected;channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ④ consent / ⑤ decline:C 端用户本人 ====================
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization consent(String authId, String userId) {
|
||||
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许同意");
|
||||
}
|
||||
auth.setUserConsentedAt(Instant.now());
|
||||
auth.setExpiresAt(Instant.now().plusSeconds(config.getDefaultValidityDays() * 86400L));
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_ACTIVE);
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
|
||||
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.consent",
|
||||
"channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization decline(String authId, String userId) {
|
||||
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许拒绝");
|
||||
}
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
|
||||
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.decline",
|
||||
"channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ⑥ revoke:申请人或审批人 ====================
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization revoke(String authId, String operatorId, String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "撤销理由必填");
|
||||
}
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
boolean isApplicant = operatorId.equals(auth.getOperatorId());
|
||||
boolean isApprover = operatorId.equals(auth.getApprovedBy());
|
||||
if (!isApplicant && !isApprover) {
|
||||
dataScopeGuard.deny(operatorId, RESOURCE_TYPE, authId,
|
||||
"仅申请人或审批人可撤销授权");
|
||||
}
|
||||
if (!Constants.CONTACT_AUTH_STATUS_ACTIVE.equals(auth.getStatus())
|
||||
&& !Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())
|
||||
&& !Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许撤销");
|
||||
}
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REVOKED);
|
||||
auth.setRevokedAt(Instant.now());
|
||||
auth.setRevokedBy(operatorId);
|
||||
auth.setRevokeReason(reason.trim());
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId);
|
||||
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.revoke",
|
||||
"reason=" + auth.getRevokeReason());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ⑦ disclose:H4 唯一披露出口 ====================
|
||||
|
||||
/**
|
||||
* 披露用户电话(H4 唯一出口)。
|
||||
* 链路:enabled fail-safe → 持分配(404 化)→ 生效授权(404 化)→ 返回三键;
|
||||
* 每次落 SensitiveAccessLog(user_phone_disclosure);<b>phone 不落任何应用日志</b>。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> disclose(String operatorId, String userId, String channel,
|
||||
String purpose) {
|
||||
// fail-safe:机制关闭(配置缺失/显式关闭)→ 404 化拒绝
|
||||
if (!config.isEnabled()) {
|
||||
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
|
||||
"服务不存在", null, null);
|
||||
}
|
||||
if (purpose == null || purpose.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "披露目的必填");
|
||||
}
|
||||
// 持分配校验(404 化,G4)
|
||||
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
|
||||
operatorId, userId, RESOURCE_TYPE, userId);
|
||||
// 生效授权校验(404 化;未同意/已拒绝/已过期/已撤销全部落同一响应)
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationPolicy.assertContactAllowed(
|
||||
operatorId, userId, channel, purpose);
|
||||
|
||||
User user = userMapper.selectById(userId);
|
||||
String phone = user != null ? user.getPhone() : null;
|
||||
|
||||
SensitiveAccessLog accessLog = new SensitiveAccessLog();
|
||||
accessLog.setOperatorId(operatorId);
|
||||
accessLog.setUserId(userId);
|
||||
accessLog.setDataType(SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE);
|
||||
accessLog.setReason(purpose.trim());
|
||||
accessLog.setAccessTarget(auth.getId());
|
||||
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
|
||||
sensitiveAccessLogMapper.insert(accessLog);
|
||||
|
||||
// 审计不含 phone(afterData 只记 channel;log.info 不打 phone)
|
||||
log.info("受控联系披露: authId={}, operatorId={}, userId={}, channel={}",
|
||||
auth.getId(), operatorId, userId, auth.getChannel());
|
||||
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(),
|
||||
assignment != null ? assignment.getId() : null,
|
||||
"matchmaker.contact.disclose", "channel=" + auth.getChannel());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("phone", phone);
|
||||
result.put("disclosedAt", Instant.now());
|
||||
result.put("authorizationId", auth.getId());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
/** 红娘工作台:本人全部授权(含保留期元数据 retentionDays)。 */
|
||||
public Map<String, Object> myAuthorizations(String operatorId) {
|
||||
List<Map<String, Object>> items = authorizationMapper.findByOperator(operatorId).stream()
|
||||
.map(this::toView).collect(Collectors.toList());
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", items);
|
||||
result.put("retentionDays", config.getRetentionDays());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** C 端:我的授权列表 + 待同意队列。 */
|
||||
public Map<String, Object> myAuthorizationsForUser(String userId) {
|
||||
List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream()
|
||||
.map(this::toView).collect(Collectors.toList());
|
||||
List<Map<String, Object>> pendingConsent = authorizationMapper
|
||||
.findPendingConsentByUser(userId).stream()
|
||||
.map(this::toView).collect(Collectors.toList());
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", items);
|
||||
result.put("pendingConsent", pendingConsent);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 视图投影:不含任何联系方式字段(授权表本身无 phone 列)。 */
|
||||
private Map<String, Object> toView(MatchmakerContactAuthorization auth) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("id", auth.getId());
|
||||
item.put("operatorId", auth.getOperatorId());
|
||||
item.put("userId", auth.getUserId());
|
||||
item.put("channel", auth.getChannel());
|
||||
item.put("purpose", auth.getPurpose());
|
||||
item.put("status", auth.getStatus());
|
||||
item.put("createdAt", auth.getCreatedAt());
|
||||
item.put("approvedAt", auth.getApprovedAt());
|
||||
item.put("userConsentedAt", auth.getUserConsentedAt());
|
||||
item.put("expiresAt", auth.getExpiresAt());
|
||||
item.put("revokeReason", auth.getRevokeReason());
|
||||
return item;
|
||||
}
|
||||
|
||||
// ==================== 内部实现 ====================
|
||||
|
||||
private void requireValidChannel(String channel) {
|
||||
boolean valid = Constants.CONTACT_CHANNEL_PHONE.equals(channel)
|
||||
|| Constants.CONTACT_CHANNEL_OFFLINE_MEETING.equals(channel)
|
||||
|| Constants.CONTACT_CHANNEL_FULL_CONTACT.equals(channel);
|
||||
if (!valid) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "不支持的授权渠道: " + channel);
|
||||
}
|
||||
}
|
||||
|
||||
private MatchmakerContactAuthorization requireAuthorization(String authId) {
|
||||
return authorizationMapper.findById(authId)
|
||||
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "授权记录不存在"));
|
||||
}
|
||||
|
||||
/** C 端归属校验:仅授权记录的本人用户可 consent/decline,违规 403。 */
|
||||
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
if (!auth.getUserId().equals(userId)) {
|
||||
dataScopeGuard.deny(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求");
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** 审计用 service_assignment_id:当前 (红娘,用户) 生效中的分配 id,无则 null。 */
|
||||
private String activeAssignmentIdOf(MatchmakerContactAuthorization auth) {
|
||||
Optional<MatchmakerAssignment> assignment =
|
||||
serviceAssignmentPolicy.findActiveAssignment(auth.getOperatorId(), auth.getUserId());
|
||||
return assignment.map(MatchmakerAssignment::getId).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审计写入:purpose_code(matchmaker.contact.*)+ service_assignment_id +
|
||||
* request_trace_id(MDC requestId)。afterData 一律不含 phone。
|
||||
*/
|
||||
private void saveAudit(String operatorId, String action, String authId,
|
||||
String serviceAssignmentId, String purposeCode, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(RESOURCE_TYPE);
|
||||
auditLog.setResourceId(authId);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLog.setPurposeCode(purposeCode);
|
||||
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||
auditLog.setRequestTraceId(MDC.get("requestId"));
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||
+17
@@ -291,6 +291,23 @@ public class MatchmakerAssignmentService {
|
||||
return assignment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 受控联系审批的组织范围校验(ZC-MM-003 / B1 立项)。
|
||||
* {@link #assertAssignScope} 的公开入口:审批岗(team.assign 组长)只能审批
|
||||
* 同组织红娘的授权申请(NULL=NULL 平台直属);matchmaker.assign 持有者不限组织。
|
||||
* 违规走 DataScopeGuard.deny(403,approve 无资源枚举面维持 403)。
|
||||
*
|
||||
* @param approverOperatorId 审批人
|
||||
* @param hasGlobalAssign 审批人是否持有 matchmaker.assign(true=不限组织)
|
||||
* @param targetOperatorId 被审批授权的红娘
|
||||
* @param auditResourceId 审计 resourceId(授权记录 id)
|
||||
*/
|
||||
public void assertTeamScope(String approverOperatorId, boolean hasGlobalAssign,
|
||||
String targetOperatorId, String auditResourceId) {
|
||||
assertAssignScope(approverOperatorId, hasGlobalAssign, targetOperatorId,
|
||||
"组长只能审批同组织红娘的授权申请(NULL=NULL 平台直属)", auditResourceId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织范围判定(D2):hasGlobalAssign(matchmaker.assign)不限组织;
|
||||
* team.assign 时校验操作者与目标红娘 organization_id 一致。
|
||||
|
||||
@@ -362,4 +362,36 @@ public final class Constants {
|
||||
public static final String SENSITIVE_ACCESS_RESULT_SUCCESS = "success";
|
||||
/** 红娘查看服务对象详情的访问理由文案 */
|
||||
public static final String SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL = "matchmaker 工作台查看服务对象详情";
|
||||
|
||||
// ========== ZC-MM-003 / B1 立项:受控联系授权(V056) ==========
|
||||
/** 授权状态:待审批 */
|
||||
public static final String CONTACT_AUTH_STATUS_PENDING_APPROVAL = "pending_approval";
|
||||
/** 授权状态:待用户同意 */
|
||||
public static final String CONTACT_AUTH_STATUS_PENDING_CONSENT = "pending_consent";
|
||||
/** 授权状态:生效中 */
|
||||
public static final String CONTACT_AUTH_STATUS_ACTIVE = "active";
|
||||
/** 授权状态:用户已拒绝 */
|
||||
public static final String CONTACT_AUTH_STATUS_REJECTED = "rejected";
|
||||
/** 授权状态:已过期(有效期/同意窗口届满,双态惰性收敛) */
|
||||
public static final String CONTACT_AUTH_STATUS_EXPIRED = "expired";
|
||||
/** 授权状态:已撤销 */
|
||||
public static final String CONTACT_AUTH_STATUS_REVOKED = "revoked";
|
||||
/** 渠道:电话联系 */
|
||||
public static final String CONTACT_CHANNEL_PHONE = "phone";
|
||||
/** 渠道:线下见面 */
|
||||
public static final String CONTACT_CHANNEL_OFFLINE_MEETING = "offline_meeting";
|
||||
/** 渠道:完整联系方式 */
|
||||
public static final String CONTACT_CHANNEL_FULL_CONTACT = "full_contact";
|
||||
/** 审计动作:红娘发起授权申请 */
|
||||
public static final String CONTACT_AUDIT_ACTION_REQUEST = "MATCHMAKER_CONTACT_REQUEST";
|
||||
/** 审计动作:审批岗批准/拒绝申请 */
|
||||
public static final String CONTACT_AUDIT_ACTION_APPROVE = "MATCHMAKER_CONTACT_APPROVE";
|
||||
/** 审计动作:用户单独同意 */
|
||||
public static final String CONTACT_AUDIT_ACTION_CONSENT = "MATCHMAKER_CONTACT_CONSENT";
|
||||
/** 审计动作:用户拒绝 */
|
||||
public static final String CONTACT_AUDIT_ACTION_DECLINE = "MATCHMAKER_CONTACT_DECLINE";
|
||||
/** 审计动作:披露联系方式(H4 唯一披露出口) */
|
||||
public static final String CONTACT_AUDIT_ACTION_DISCLOSE = "MATCHMAKER_CONTACT_DISCLOSE";
|
||||
/** 审计动作:撤销授权 */
|
||||
public static final String CONTACT_AUDIT_ACTION_REVOKE = "MATCHMAKER_CONTACT_REVOKE";
|
||||
}
|
||||
@@ -92,3 +92,13 @@ spring.security:
|
||||
rate-limit:
|
||||
max-requests-per-window: 20
|
||||
window-seconds: 60
|
||||
|
||||
# 受控联系授权(ZC-MM-003 / B1 立项,canonical key 见 ContactAuthorizationConfig;治理登记 T06 config-register)
|
||||
# 注意:代码缺省 enabled=false(fail-safe),生产显式开启;新环境漏配时机制整体关闭而非意外开放。
|
||||
matchmaker:
|
||||
contact:
|
||||
enabled: true
|
||||
approver-authorities: matchmaker.team.assign,matchmaker.assign
|
||||
consent-deadline-hours: 72
|
||||
default-validity-days: 7
|
||||
retention-days: 365
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
-- ============================================================
|
||||
-- V056: 受控联系授权表 matchmaker_contact_authorizations
|
||||
-- (ZC-MM-003 / B1 例外通道立项,架构师设计 H4/G4)
|
||||
--
|
||||
-- 语义:红娘对分配对象发起联系方式/线下见面授权申请,经审批岗批准、
|
||||
-- 用户单独明示同意(独立端点,无预同意参数)后方可生效;
|
||||
-- 唯一披露出口为 ContactAuthorizationService.disclose(每次落敏感访问审计)。
|
||||
--
|
||||
-- 状态机(六态):
|
||||
-- pending_approval → pending_consent(审批岗批准)
|
||||
-- pending_consent → active(用户单独同意)/ rejected(用户拒绝)
|
||||
-- active / pending_consent → expired(双态惰性收敛:有效期/同意窗口届满)
|
||||
-- active → revoked(申请人或审批人撤销)
|
||||
--
|
||||
-- 幂等:CREATE TABLE / INDEX 全部 IF NOT EXISTS,可重放。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matchmaker_contact_authorizations (
|
||||
id UUID PRIMARY KEY,
|
||||
operator_id UUID NOT NULL REFERENCES operators(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
purpose VARCHAR(200) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending_approval',
|
||||
approved_by UUID,
|
||||
approved_at TIMESTAMPTZ,
|
||||
user_consented_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by UUID,
|
||||
revoke_reason VARCHAR(500),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_contact_auth_channel CHECK (channel IN ('phone', 'offline_meeting', 'full_contact')),
|
||||
CONSTRAINT chk_contact_auth_status CHECK (status IN (
|
||||
'pending_approval', 'pending_consent', 'active', 'rejected', 'expired', 'revoked'
|
||||
))
|
||||
);
|
||||
|
||||
-- 同一 (红娘, 用户, 渠道) 同时最多一条 active(防重复生效授权)
|
||||
-- H2 测试库以生成列 + 唯一索引等价实现(见 schema-h2.sql 注释)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_mm_contact_auth_active
|
||||
ON matchmaker_contact_authorizations(operator_id, user_id, channel)
|
||||
WHERE status = 'active';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_auth_operator_status
|
||||
ON matchmaker_contact_authorizations(operator_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_contact_auth_user_status
|
||||
ON matchmaker_contact_authorizations(user_id, status);
|
||||
Reference in New Issue
Block a user