Compare commits
2
Commits
v1.2.0
...
21275ef770
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21275ef770 | ||
|
|
d3735e3afa |
@@ -66,6 +66,36 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
|
||||
<el-tab-pane label="联系授权审批" name="contactAuths">
|
||||
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
|
||||
<el-table-column prop="operatorId" label="红娘ID" width="120" />
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="channel" label="渠道" width="110">
|
||||
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purpose" label="授权目的" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }"><el-tag :type="contactStatusTag(row.status)">{{ contactStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="申请时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAt" label="窗口/有效期至" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending_approval' && canApprove">
|
||||
<el-button size="small" type="primary" @click="doContactApprove(row)">批准</el-button>
|
||||
<el-button size="small" type="danger" @click="doContactReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
<el-button v-if="['active','pending_approval','pending_consent'].includes(row.status)"
|
||||
size="small" @click="doContactRevoke(row)">撤销</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
@@ -136,7 +166,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -192,6 +222,19 @@ const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
|
||||
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
|
||||
}
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权(枚举码不上屏,中文映射) =====
|
||||
const CONTACT_CHANNEL_LABEL: Record<string, string> = {
|
||||
phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式'
|
||||
}
|
||||
const CONTACT_STATUS_LABEL: Record<string, string> = {
|
||||
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
|
||||
rejected: '已拒绝', expired: '已过期', revoked: '已撤销'
|
||||
}
|
||||
const CONTACT_STATUS_TAG: Record<string, string> = {
|
||||
pending_approval: 'warning', pending_consent: 'warning', active: 'success',
|
||||
rejected: 'danger', expired: 'info', revoked: 'info'
|
||||
}
|
||||
|
||||
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
@@ -201,6 +244,45 @@ function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] ||
|
||||
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
|
||||
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
|
||||
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
|
||||
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权 =====
|
||||
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
|
||||
const canApprove = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
const contactAuths = ref<any[]>([])
|
||||
const contactAuthsLoading = ref(false)
|
||||
|
||||
async function loadContactAuths() {
|
||||
contactAuthsLoading.value = true
|
||||
try { const res: any = await get('/operator/matchmaker/contact-authorizations/my'); contactAuths.value = res?.data?.list || [] } finally { contactAuthsLoading.value = false }
|
||||
}
|
||||
async function doContactApprove(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/approve`, {})
|
||||
ElMessage.success('已批准,等待用户单独确认'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactReject(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/reject`, {})
|
||||
ElMessage.success('已拒绝'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactRevoke(row: any) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('撤销理由(必填)', '撤销联系授权', {
|
||||
confirmButtonText: '确认撤销', cancelButtonText: '取消',
|
||||
inputValidator: (v: string) => (v && v.trim() ? true : '撤销理由必填'),
|
||||
})
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/revoke`, { reason: value.trim() })
|
||||
ElMessage.success('已撤销'); loadContactAuths()
|
||||
} catch { /* 用户取消 */ }
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
loading.value = true
|
||||
@@ -226,6 +308,7 @@ async function loadAssignableMatchmakers() {
|
||||
async function refresh() {
|
||||
if (activeTab.value === 'services') await loadServices()
|
||||
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||
else if (activeTab.value === 'contactAuths') await loadContactAuths()
|
||||
else await loadRecommendations()
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
+3
-1
@@ -36,7 +36,9 @@ class MigrationValidationTest {
|
||||
// V053 matchmaker_assignments 建表 + 存量回填;V054 matchmaker_leader 角色与团队分配权限
|
||||
// ZC-MM-006(T01 审计字段扩展):V055 audit_logs 补 purpose_code / service_assignment_id /
|
||||
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
|
||||
private static final int LATEST_MIGRATION = 55;
|
||||
// ZC-MM-003(B1 例外通道立项):V056 matchmaker_contact_authorizations 受控联系授权表
|
||||
// (六态状态机 + active 部分唯一索引)
|
||||
private static final int LATEST_MIGRATION = 56;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
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.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||
import com.danyang.match.platform.permission.entity.Operator;
|
||||
import com.danyang.match.platform.permission.mapper.OperatorMapper;
|
||||
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.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-003 / B1 立项验收:受控联系授权机制(V056 + Policy/Service 六操作)。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>正例全生命周期:request → approve → consent → disclose(phone 返回 +
|
||||
* SensitiveAccessLog success 行)→ revoke → 再 disclose 404;</li>
|
||||
* <li>负向×6:无分配申请 403 / 非审批岗 approve 403 / 未同意 disclose 404 /
|
||||
* decline 后 404 / 过期后 404+审计 / 非本人 consent 403;</li>
|
||||
* <li>状态机非法转换 CONFLICT(approve 二次 / 越状态 consent / 终态 revoke);</li>
|
||||
* <li>purpose_code 断言:request/approve/consent/disclose/revoke 审计行均含
|
||||
* matchmaker.contact.* + service_assignment_id + request_trace_id。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerContactAuthorizationTest {
|
||||
|
||||
@Autowired private ContactAuthorizationService contactAuthorizationService;
|
||||
@Autowired private MatchmakerAssignmentService assignmentService;
|
||||
@Autowired private MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
|
||||
@Autowired private MatchmakerServiceMapper serviceMapper;
|
||||
@Autowired private OperatorMapper operatorMapper;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private AuditLogMapper auditLogMapper;
|
||||
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||
|
||||
private String mmOwner; // 分配持有人(红娘,org X)
|
||||
private String leaderA; // 组长,org X(team.assign 审批岗)
|
||||
private String leaderY; // 组长,org Y(跨组织)
|
||||
private String user;
|
||||
private String userPhone;
|
||||
private String assignmentId;
|
||||
|
||||
private static final String ORG_X = "55555555-5555-5555-5555-555555555551";
|
||||
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");
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mmOwner = createOperator("联系授权红娘", ORG_X);
|
||||
leaderA = createOperator("联系授权组长X", ORG_X);
|
||||
leaderY = createOperator("联系授权组长Y", ORG_Y);
|
||||
userPhone = "13800001234";
|
||||
user = createUser(userPhone);
|
||||
|
||||
// 前置:红娘持分配
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setUserId(user);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setDescription("联系授权测试服务单");
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
svc.setAssignedOperatorId(mmOwner);
|
||||
serviceMapper.insert(svc);
|
||||
MatchmakerAssignment assignment = assignmentService.create(leaderA, true,
|
||||
user, mmOwner, svc.getId(), "联系授权前置分配", null);
|
||||
assignmentId = assignment.getId();
|
||||
MDC.put("requestId", "contacttest0001");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
MDC.remove("requestId");
|
||||
org.springframework.security.core.context.SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ==================== 数据构造 ====================
|
||||
|
||||
private String createOperator(String name, String organizationId) {
|
||||
Operator op = new Operator();
|
||||
op.setName(name);
|
||||
op.setOrganizationId(organizationId);
|
||||
op.setUsername("ca_" + name + "_" + System.nanoTime());
|
||||
op.setPasswordHash("test-hash");
|
||||
op.setStatus("active");
|
||||
operatorMapper.insert(op);
|
||||
return op.getId();
|
||||
}
|
||||
|
||||
private String createUser(String phone) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("ca_user_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setPhone(phone);
|
||||
userMapper.insert(u);
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private long deniedAudits(String operatorId, String resourceId) {
|
||||
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
|
||||
.eq(AuditLog::getOperatorId, operatorId)
|
||||
.eq(AuditLog::getResourceId, resourceId));
|
||||
}
|
||||
|
||||
private AuditLog latestContactAudit(String action, String authId) {
|
||||
return auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
|
||||
.eq(AuditLog::getAction, action)
|
||||
.eq(AuditLog::getResourceId, authId)
|
||||
.orderByDesc(AuditLog::getCreatedAt))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
// ==================== 正例:全生命周期 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 正例: request→approve→consent→disclose(phone+敏感审计)→revoke→再 disclose 404")
|
||||
void fullLifecycleRequestApproveConsentDiscloseRevoke() {
|
||||
// ① request → pending_approval
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "安排线下初次见面,需电话沟通时间地点");
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL, auth.getStatus());
|
||||
assertNotNull(auth.getId());
|
||||
assertNotNull(auth.getExpiresAt(), "pending 期 expires_at 承载同意窗口上界");
|
||||
|
||||
// ② approve(组长 X,team.assign)→ pending_consent
|
||||
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
|
||||
auth.getId(), leaderA, P_LEADER);
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
|
||||
assertEquals(leaderA, approved.getApprovedBy());
|
||||
|
||||
// ③ consent(用户本人)→ active,有效期 = consent + 7d
|
||||
Instant beforeConsent = Instant.now();
|
||||
MatchmakerContactAuthorization consented = contactAuthorizationService.consent(auth.getId(), user);
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_ACTIVE, consented.getStatus());
|
||||
assertNotNull(consented.getUserConsentedAt());
|
||||
long days = ChronoUnit.HOURS.between(beforeConsent, consented.getExpiresAt());
|
||||
assertTrue(days >= 24 * 7 - 1 && days <= 24 * 7 + 1,
|
||||
"有效期应为 consent + 7 天,实际小时数=" + days);
|
||||
|
||||
// ④ disclose(H4 唯一出口)→ phone + SensitiveAccessLog success 行
|
||||
Map<String, Object> disclosed = contactAuthorizationService.disclose(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话约定见面时间");
|
||||
assertEquals(userPhone, disclosed.get("phone"));
|
||||
assertEquals(auth.getId(), disclosed.get("authorizationId"));
|
||||
assertNotNull(disclosed.get("disclosedAt"));
|
||||
// H4 唯一披露出口返回体必须收敛为恰好三键(phone/disclosedAt/authorizationId),
|
||||
// 严禁夹带任何其他字段(含联系方式或用户资料面)——守卫①对本文件白名单放行,
|
||||
// 键收敛只能由本行为断言锁定(独立验收补充,QA 严过关)。
|
||||
assertEquals(Set.of("phone", "disclosedAt", "authorizationId"),
|
||||
disclosed.keySet(), "disclose 返回体必须恰好三键,不得夹带额外字段");
|
||||
List<SensitiveAccessLog> accessRows = sensitiveAccessLogMapper.selectList(
|
||||
new LambdaQueryWrapper<SensitiveAccessLog>()
|
||||
.eq(SensitiveAccessLog::getDataType, "user_phone_disclosure")
|
||||
.eq(SensitiveAccessLog::getAccessTarget, auth.getId()));
|
||||
assertEquals(1, accessRows.size(), "披露必须落 user_phone_disclosure 敏感访问行");
|
||||
assertEquals("success", accessRows.get(0).getAccessResult());
|
||||
assertEquals(mmOwner, accessRows.get(0).getOperatorId());
|
||||
|
||||
// ⑤ revoke(申请人)→ revoked
|
||||
contactAuthorizationService.revoke(auth.getId(), mmOwner, "服务关系结束");
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_REVOKED,
|
||||
authorizationMapper.findById(auth.getId()).orElseThrow().getStatus());
|
||||
|
||||
// ⑥ 再 disclose → 404 + DATA_SCOPE_DENIED 审计(G4 防探测)
|
||||
long before = deniedAudits(mmOwner, user);
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "再次获取"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||
assertEquals("服务不存在", e.getMessage());
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, user), "失效后披露必须 404 化拒绝并落审计");
|
||||
}
|
||||
|
||||
// ==================== purpose_code 审计断言 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 审计: request/approve/consent/disclose/revoke 各落行含 matchmaker.contact.* + assignment_id + trace")
|
||||
void auditRowsCarryPurposeCodeAssignmentIdAndTraceId() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通服务安排");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.consent(auth.getId(), user);
|
||||
contactAuthorizationService.disclose(mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "联系用户");
|
||||
contactAuthorizationService.revoke(auth.getId(), mmOwner, "测试撤销");
|
||||
|
||||
String[][] expected = {
|
||||
{Constants.CONTACT_AUDIT_ACTION_REQUEST, "matchmaker.contact.request"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_APPROVE, "matchmaker.contact.approve"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_CONSENT, "matchmaker.contact.consent"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_DISCLOSE, "matchmaker.contact.disclose"},
|
||||
{Constants.CONTACT_AUDIT_ACTION_REVOKE, "matchmaker.contact.revoke"},
|
||||
};
|
||||
for (String[] pair : expected) {
|
||||
AuditLog audit = latestContactAudit(pair[0], auth.getId());
|
||||
assertNotNull(audit, pair[0] + " 审计行必须存在");
|
||||
assertEquals(pair[1], audit.getPurposeCode(), pair[0] + " purpose_code 必须落库");
|
||||
assertEquals(assignmentId, audit.getServiceAssignmentId(),
|
||||
pair[0] + " 必须关联 service_assignment_id");
|
||||
assertEquals("contacttest0001", audit.getRequestTraceId(),
|
||||
pair[0] + " 必须携带请求追踪 ID");
|
||||
assertFalse(String.valueOf(audit.getAfterData()).contains(userPhone),
|
||||
pair[0] + " 审计内容不得含 phone");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 负向×6 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向①: 未持分配申请 → 403 + DATA_SCOPE_DENIED 审计")
|
||||
void requestWithoutAssignmentDenied403() {
|
||||
String outsiderUser = createUser("13900009999");
|
||||
long before = deniedAudits(mmOwner, outsiderUser);
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.request(mmOwner, outsiderUser,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "无分配申请"),
|
||||
"未持分配的红娘不得发起授权申请");
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, outsiderUser));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向②: 非审批岗 approve → 403(权限码动态判定)")
|
||||
void nonApproverCannotApprove() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
long before = deniedAudits(leaderA, auth.getId());
|
||||
// leaderA 只有 matchmaker.view(无 team.assign/assign)→ 非审批岗
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.approve(auth.getId(), leaderA, Set.of("matchmaker.view")),
|
||||
"非审批岗不得 approve");
|
||||
assertEquals(before + 1, deniedAudits(leaderA, auth.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向③: 跨组织组长 approve → 403(组织范围校验)")
|
||||
void crossOrgLeaderCannotApprove() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER),
|
||||
"跨组织组长不得审批");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向④: 未同意 disclose → 404 + DATA_SCOPE_DENIED(G4 防探测)")
|
||||
void discloseBeforeConsentNotFound() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
|
||||
long before = deniedAudits(mmOwner, user);
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "尚未同意"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
|
||||
assertEquals("服务不存在", e.getMessage());
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, user));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向⑤: decline 后 disclose → 404;过期后 disclose → 404 + 审计")
|
||||
void discloseAfterDeclineOrExpiryNotFound() {
|
||||
// decline 后
|
||||
MatchmakerContactAuthorization auth1 = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通A");
|
||||
contactAuthorizationService.approve(auth1.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.decline(auth1.getId(), user);
|
||||
BusinessException e1 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "被拒后披露"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e1.getCode());
|
||||
|
||||
// 过期后(直接注入已过期的 expires_at,模拟窗口/有效期届满)
|
||||
MatchmakerContactAuthorization auth2 = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通B");
|
||||
contactAuthorizationService.approve(auth2.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.consent(auth2.getId(), user);
|
||||
MatchmakerContactAuthorization expired = authorizationMapper.findById(auth2.getId()).orElseThrow();
|
||||
expired.setExpiresAt(Instant.now().minusSeconds(60));
|
||||
authorizationMapper.updateById(expired);
|
||||
|
||||
long before = deniedAudits(mmOwner, user);
|
||||
BusinessException e2 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.disclose(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "过期后披露"));
|
||||
assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode());
|
||||
assertEquals(before + 1, deniedAudits(mmOwner, user), "过期披露必须 404 化拒绝并落审计");
|
||||
// 惰性收敛必须落库
|
||||
assertEquals(Constants.CONTACT_AUTH_STATUS_EXPIRED,
|
||||
authorizationMapper.findById(auth2.getId()).orElseThrow().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 负向⑥: 非本人 consent → 403")
|
||||
void nonOwnerCannotConsent() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
|
||||
String otherUser = createUser("13700009999");
|
||||
assertThrows(AccessDeniedException.class,
|
||||
() -> contactAuthorizationService.consent(auth.getId(), otherUser),
|
||||
"仅本人可同意授权");
|
||||
}
|
||||
|
||||
// ==================== 状态机非法转换 CONFLICT ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 状态机: approve 二次 / 越状态 consent / 终态 revoke 一律 CONFLICT")
|
||||
void illegalTransitionsAreConflict() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
|
||||
// pending_approval 直接 consent → CONFLICT
|
||||
BusinessException e1 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.consent(auth.getId(), user));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e1.getCode());
|
||||
|
||||
// approve → pending_consent 后再 approve → CONFLICT
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
BusinessException e2 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e2.getCode());
|
||||
|
||||
// consent → active 后 revoke → revoked,再 revoke → CONFLICT
|
||||
contactAuthorizationService.consent(auth.getId(), user);
|
||||
contactAuthorizationService.revoke(auth.getId(), mmOwner, "一次撤销");
|
||||
BusinessException e3 = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.revoke(auth.getId(), mmOwner, "二次撤销"));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e3.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("B1 request 防重: 同渠道生效授权存在时再申请 → CONFLICT")
|
||||
void duplicateRequestConflict() {
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
|
||||
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
|
||||
contactAuthorizationService.approve(auth.getId(), leaderA, P_LEADER);
|
||||
contactAuthorizationService.consent(auth.getId(), user);
|
||||
|
||||
BusinessException e = assertThrows(BusinessException.class,
|
||||
() -> contactAuthorizationService.request(mmOwner, user,
|
||||
Constants.CONTACT_CHANNEL_PHONE, "重复申请"));
|
||||
assertEquals(ApiResponseCode.CONFLICT, e.getCode());
|
||||
}
|
||||
}
|
||||
+260
-140
@@ -18,25 +18,26 @@ import java.util.stream.Stream;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* ZC-MM-003(B1 联系规则护栏)+ ZC-MM-004 佐证 —— standalone 源码扫描守卫
|
||||
* ZC-MM-003(B1 例外通道立项)守卫 —— standalone 源码扫描
|
||||
* (沿用 {@code MatchmakerFieldPrivacyGuardTest} 范式,无需 Spring 上下文)。
|
||||
*
|
||||
* <p>锁定四条不变量(架构师设计 T01):
|
||||
* <p><b>ZC-MM-006→003 守卫翻转说明</b>:受控联系授权机制(V056 + ContactAuthorizationPolicy/
|
||||
* Service,架构师设计 H4/G4)立项后,本守卫从「锁机制不存在」翻转为「锁新契约」:
|
||||
* <ol>
|
||||
* <li><b>MM-CONTACT-002</b>:ReferralService 全部响应 put 键 ⊆ 白名单(逐键对照实际代码
|
||||
* 核定,见 {@link #RESPONSE_KEY_WHITELIST}),且键名不得含 emergencyContact / phone /
|
||||
* mobile / wechat / address / idNumber 等联系方式字样 —— 锁定「红娘侧零联系方式回显」;
|
||||
* emergencyContact* 是纯入口 write-only(写入 activity_registration 的紧急联系人字段),
|
||||
* 连红娘本人都无读回面。</li>
|
||||
* <li><b>MM-CONTACT-003</b>:matchmaker / referral Controller 无 contact / call / phone /
|
||||
* channel / offline 类端点;全仓无 ContactAuthorization 类、无
|
||||
* matchmaker_contact_authorizations 建表迁移 —— 锁定「不存在联系授权机制」。
|
||||
* 契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新,守卫即红。</li>
|
||||
* <li><b>MM-CONTACT-004</b>:无联系授权机制即无授权到期场景 —— 与 MM-CONTACT-003 同源断言
|
||||
* 承载(若未来引入 ContactAuthorization / matchmaker_contact_authorizations,
|
||||
* 003/004 两条守卫同时判红,迫使开发者补「到期 + 审计」语义并过 007 评审)。</li>
|
||||
* <li><b>ZC-MM-004 佐证</b>:matchmaker / referral 包无 @Scheduled —— 全仓无批处理
|
||||
* 代操作面(禁止代替用户决定的自动化通道不存在)。</li>
|
||||
* <li><b>① 联系回显文件收敛</b>:全仓响应装配中含 phone/mobile/wechat 等联系方式字样的
|
||||
* put 键,仅允许出现在白名单文件(受控联系披露出口 + 既有合法落点,见
|
||||
* {@link #CONTACT_ECHO_ALLOWED_FILES});其余文件出现即红;</li>
|
||||
* <li><b>② 联系类端点前缀收敛</b>:contact/phone/channel/offline 字样端点仅限
|
||||
* {@code /operator/matchmaker/contact-authorizations/**} 与
|
||||
* {@code /contact-authorizations/**} 两前缀;</li>
|
||||
* <li><b>③ 到期语义完整契约</b>:双态惰性收敛(active 过期 / pending_consent 超窗 → expired)
|
||||
* 机制必须存在,disclose 必须走 assertContactAllowed(404 化拒绝)+ 落敏感访问审计;
|
||||
* 行为面(未同意/已拒绝/已过期/已撤销 disclose → 404+审计)由
|
||||
* {@code MatchmakerContactAuthorizationTest} 行为锁定;</li>
|
||||
* <li><b>④ consent 独立性</b>:同意/拒绝必须是独立 POST 端点、无 @RequestBody、
|
||||
* 全仓无预同意/预勾选参数 —— PIPL 单独同意动作不可被捆绑;</li>
|
||||
* <li><b>⑤ 保留不变</b>:ReferralService 16 键白名单 + 联系字样禁词(红娘侧转介仍零联系回显)
|
||||
* 与 matchmaker/referral 包无 @Scheduled(无批处理代操作面)。</li>
|
||||
* </ol>
|
||||
*/
|
||||
class MatchmakerContactGuardTest {
|
||||
@@ -44,27 +45,24 @@ class MatchmakerContactGuardTest {
|
||||
private static final Path SRC_ROOT = resolveSourceRoot();
|
||||
private static final Path REFERRAL_SERVICE =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral/service/ReferralService.java");
|
||||
private static final Path CONTACT_AUTH_CONTROLLER =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller/MatchmakerContactAuthorizationController.java");
|
||||
private static final Path CONTACT_USER_CONTROLLER =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/contactauth/controller/ContactAuthorizationUserController.java");
|
||||
private static final Path CONTACT_AUTH_MAPPER =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/mapper/MatchmakerContactAuthorizationMapper.java");
|
||||
private static final Path CONTACT_AUTH_SERVICE =
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java");
|
||||
private static final List<Path> CONTROLLER_DIRS = List.of(
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral/controller"));
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral/controller"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/contactauth/controller"));
|
||||
private static final List<Path> PACKAGE_DIRS = List.of(
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker"),
|
||||
SRC_ROOT.resolve("com/danyang/match/platform/referral"));
|
||||
|
||||
/**
|
||||
* ReferralService 响应键白名单(ZC-MM-003 / T01:以实际代码逐键核对后核定):
|
||||
* <ul>
|
||||
* <li>referToServiceStation → appointmentId / status / referralType /
|
||||
* referralFromOperatorId / needsUserConfirmation</li>
|
||||
* <li>referToActivity → registrationId / activityId / status / referralType /
|
||||
* referralFromOperatorId / needsUserConfirmation</li>
|
||||
* <li>confirmReferral → id / status / confirmedAt</li>
|
||||
* <li>getReferralHistory → type / id / serviceType|activityId / status /
|
||||
* createdByOperatorId / userConfirmedAt / createdAt|registeredAt</li>
|
||||
* <li>getReferralHistoryByOperator → 同上 + userId(operator 查看本人发起的
|
||||
* 转介记录需要 userId 标识服务对象;userId 是内部标识符,非联系方式,
|
||||
* 不落入 B1 禁放范畴)</li>
|
||||
* </ul>
|
||||
* ReferralService 响应键白名单(ZC-MM-003 原契约,053 阶段逐键核定,本阶段保留不变)。
|
||||
*/
|
||||
private static final Set<String> RESPONSE_KEY_WHITELIST = Set.of(
|
||||
"appointmentId", "registrationId", "activityId", "id", "status", "type",
|
||||
@@ -72,170 +70,292 @@ class MatchmakerContactGuardTest {
|
||||
"confirmedAt", "userConfirmedAt", "createdByOperatorId", "createdAt",
|
||||
"registeredAt", "userId");
|
||||
|
||||
/** 联系方式字样(出现在响应键名中即违规;emergencyContact* 仅允许作为入口 write-only 存在) */
|
||||
/** 联系方式字样(出现在响应键名中即触发收敛判定) */
|
||||
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
|
||||
"emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
|
||||
"idnumber", "id_card");
|
||||
|
||||
/** MM-CONTACT-003:联系/触达类禁用端点词 */
|
||||
private static final List<String> FORBIDDEN_ENDPOINT_TOKENS = List.of(
|
||||
"contact", "call", "phone", "channel", "offline");
|
||||
/** 联系/触达类端点词(出现在 mapping 路径中即触发前缀收敛判定) */
|
||||
private static final List<String> CONTACT_ENDPOINT_TOKENS = List.of(
|
||||
"contact", "phone", "channel", "offline");
|
||||
|
||||
// ==================== MM-CONTACT-002:红娘侧零联系方式回显 ====================
|
||||
/** 联系类端点仅允许的两个前缀(受控联系授权机制专属) */
|
||||
private static final List<String> CONTACT_ENDPOINT_ALLOWED_PREFIXES = List.of(
|
||||
"/operator/matchmaker/contact-authorizations",
|
||||
"/contact-authorizations");
|
||||
|
||||
/**
|
||||
* ① 联系回显白名单文件(逐个核定,出现新落点必须显式登记于此并过 007 评审):
|
||||
* <ul>
|
||||
* <li>ContactAuthorizationService —— H4 唯一披露出口(disclose 返回 phone 三键);</li>
|
||||
* <li>MatchmakerContactAuthorizationController —— 受控联系授权运营端控制器(授权数据通道);</li>
|
||||
* <li>AuthService —— {@code phoneBound} 布尔标志(是否已绑手机号,非号码本体);</li>
|
||||
* <li>ActivityService —— {@code emergencyContactName}(用户报名数据本人可见);</li>
|
||||
* <li>RealIdentityVerificationProvider —— {@code idNumber}/{@code idNumberHash}
|
||||
* (服务端 → 实名核验 provider 请求体,非 C 端响应回显)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static final Set<String> CONTACT_ECHO_ALLOWED_FILES = Set.of(
|
||||
"ContactAuthorizationService.java",
|
||||
"MatchmakerContactAuthorizationController.java",
|
||||
"AuthService.java",
|
||||
"ActivityService.java",
|
||||
"RealIdentityVerificationProvider.java");
|
||||
|
||||
// ==================== ① 联系回显文件收敛 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-002: ReferralService 全部响应 put 键 ⊆ 白名单,无任何联系方式回显")
|
||||
void referralResponseKeysAreWhitelistedWithNoContactEcho() throws IOException {
|
||||
String src = stripComments(Files.readString(REFERRAL_SERVICE, StandardCharsets.UTF_8));
|
||||
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(src);
|
||||
while (put.find()) {
|
||||
keys.add(put.group(1));
|
||||
}
|
||||
// 防止"装配被搬空后守卫永真":核心语义键必须仍在
|
||||
assertTrue(keys.contains("needsUserConfirmation"),
|
||||
"must-confirm 语义键必须存在(守卫非永真自检)");
|
||||
assertTrue(keys.contains("referralFromOperatorId"),
|
||||
"来源追溯键必须存在(守卫非永真自检)");
|
||||
assertTrue(keys.contains("status"), "状态键必须存在(守卫非永真自检)");
|
||||
|
||||
@DisplayName("① 联系回显收敛: 含联系方式字样的响应装配仅允许白名单文件(披露出口 + 既有合法落点)")
|
||||
void contactEchoConfinedToWhitelistedFiles() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
for (String key : keys) {
|
||||
if (!RESPONSE_KEY_WHITELIST.contains(key)) {
|
||||
violations.add("非白名单响应键: " + key);
|
||||
}
|
||||
String lower = key.toLowerCase();
|
||||
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||
if (lower.contains(token)) {
|
||||
violations.add("响应键含联系方式 token: " + key + "(含 " + token + ")");
|
||||
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String fileName = f.getFileName().toString();
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(src);
|
||||
while (put.find()) {
|
||||
String key = put.group(1);
|
||||
String lower = key.toLowerCase();
|
||||
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||
if (lower.contains(token) && !CONTACT_ECHO_ALLOWED_FILES.contains(fileName)) {
|
||||
violations.add(fileName + " 装配联系字段响应键: " + key
|
||||
+ "(含 " + token + ")—— 联系方式回显必须收敛到受控联系披露出口,"
|
||||
+ "新增落点须登记白名单并过 007 评审");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"B1 联系规则契约被破坏(MM-CONTACT-002):响应键出现白名单外键或联系方式字样。\n"
|
||||
+ "实际输出键=" + keys + "\n违规项:\n - " + String.join("\n - ", violations));
|
||||
"联系方式回显出现白名单外落点(守卫①)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== MM-CONTACT-003:无联系端点 / 无授权机制 ====================
|
||||
// ==================== ② 联系类端点前缀收敛 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-003: matchmaker/referral Controller 无 contact 类端点;全仓无 ContactAuthorization 机制")
|
||||
void noContactEndpointsAndNoContactAuthorizationMechanism() throws IOException {
|
||||
@DisplayName("② 端点收敛: 受治理域内 contact/phone/channel/offline 字样端点仅限受控联系授权两前缀")
|
||||
void contactEndpointsConfinedToAuthorizationPrefixes() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// a) 端点扫描:matchmaker / referral Controller 无联系/触达类路径
|
||||
// 扫描面 = B1 联系规则受治理域(matchmaker / referral / contactauth 三包的 Controller)。
|
||||
// 域外既有端点(如 /auth/phone/bind 用户本人绑手机号、/content/.../offline 内容下线)
|
||||
// 由各自域的规则治理,不在本守卫职责内。
|
||||
for (Path dir : CONTROLLER_DIRS) {
|
||||
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String src = Files.readString(f, StandardCharsets.UTF_8);
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
String classPrefix = classMappingPrefix(src);
|
||||
// 类级前缀 + 方法级路径任一含联系字样 → 完整路径必须落在白名单前缀内
|
||||
Matcher mapping = Pattern.compile(
|
||||
"@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
|
||||
"@(Get|Post|Put|Delete|Patch)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
|
||||
while (mapping.find()) {
|
||||
String args = mapping.group(2).toLowerCase();
|
||||
for (String banned : FORBIDDEN_ENDPOINT_TOKENS) {
|
||||
if (args.contains(banned)) {
|
||||
violations.add(f.getFileName() + " → " + mapping.group(0)
|
||||
.replaceAll("\\s+", " ") + " 含联系类端点: " + banned);
|
||||
String rawArgs = mapping.group(2).replaceAll("\"", "").trim();
|
||||
String fullPath = compose(classPrefix, rawArgs);
|
||||
String lowerPath = fullPath.toLowerCase();
|
||||
for (String token : CONTACT_ENDPOINT_TOKENS) {
|
||||
if (lowerPath.contains(token)
|
||||
&& CONTACT_ENDPOINT_ALLOWED_PREFIXES.stream().noneMatch(lowerPath::startsWith)) {
|
||||
violations.add(f.getFileName() + " → " + fullPath
|
||||
+ "(含 " + token + ")—— 联系类端点仅限受控联系授权前缀");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// b) 机制扫描:全仓 main 源码无 ContactAuthorization 类;迁移目录无建表
|
||||
violations.addAll(scanForContactAuthorizationMechanism());
|
||||
|
||||
assertEquals(List.of(), violations,
|
||||
"B1 联系规则契约被破坏(MM-CONTACT-003):出现联系类端点或联系授权机制。\n"
|
||||
+ "契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新。\n违规项:\n - "
|
||||
"联系类端点出现收敛前缀外落点(守卫②)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== MM-CONTACT-004:无机制即无到期场景 ====================
|
||||
// ==================== ③ 到期语义完整契约 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("MM-CONTACT-004: 无联系授权机制 ⇒ 不存在授权到期场景(同源断言承载)")
|
||||
void noContactAuthorizationMechanismImpliesNoExpiryScenario() throws IOException {
|
||||
/*
|
||||
* ZC-MM-003(B1)「临时授权到期」场景的守卫:授权机制本身不存在(MM-CONTACT-003),
|
||||
* 因此不存在"到期后权限回收"场景。本用例与 MM-CONTACT-003 共享同一底层断言 ——
|
||||
* 若未来引入 ContactAuthorizationPolicy / matchmaker_contact_authorizations 表,
|
||||
* 两条守卫同时判红,迫使实现者补齐「授权期限 + 到期回收 + 审计」语义,
|
||||
* 并同步 007 治理登记(安全默认 = 不开放完整联系方式 / 未授权线下联系)。
|
||||
*/
|
||||
List<String> violations = scanForContactAuthorizationMechanism();
|
||||
assertEquals(List.of(), violations,
|
||||
"MM-CONTACT-004:联系授权机制出现 ⇒ 必须同步实现到期回收与审计,并过 007 评审。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ZC-MM-004 佐证:无批处理代操作面 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("ZC-MM-004 佐证: matchmaker/referral 包无 @Scheduled(无批处理代操作通道)")
|
||||
void noScheduledTasksInMatchmakerAndReferralPackages() throws IOException {
|
||||
@DisplayName("③ 到期语义: 双态惰性收敛(active/pending_consent→expired)+ disclose 走判定器 + 敏感审计")
|
||||
void expirySemanticsContractPresent() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// a) Mapper:expireOverdue 必须同时收敛 active 与 pending_consent 两个态
|
||||
String mapperSrc = stripComments(Files.readString(CONTACT_AUTH_MAPPER, StandardCharsets.UTF_8));
|
||||
int expireIdx = mapperSrc.indexOf("int expireOverdue()");
|
||||
assertTrue(expireIdx > 0, "expireOverdue 双态惰性收敛方法必须存在(守卫③非永真自检)");
|
||||
String expireSql = mapperSrc.substring(Math.max(0, expireIdx - 600), expireIdx);
|
||||
if (!(expireSql.contains("'active'") && expireSql.contains("'pending_consent'")
|
||||
&& expireSql.contains("'expired'"))) {
|
||||
violations.add("expireOverdue SQL 必须同时收敛 active 与 pending_consent 两态为 expired");
|
||||
}
|
||||
|
||||
// b) Service:disclose 必须走判定器(404 化拒绝)+ 每次落敏感访问审计
|
||||
String serviceSrc = stripComments(Files.readString(CONTACT_AUTH_SERVICE, StandardCharsets.UTF_8));
|
||||
if (!serviceSrc.contains("assertContactAllowed")) {
|
||||
violations.add("disclose 必须经 ContactAuthorizationPolicy.assertContactAllowed(404 化拒绝)");
|
||||
}
|
||||
if (!serviceSrc.contains("SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE")
|
||||
|| !serviceSrc.contains("sensitiveAccessLogMapper.insert")) {
|
||||
violations.add("disclose 必须每次落 SensitiveAccessLog(user_phone_disclosure)");
|
||||
}
|
||||
if (!serviceSrc.contains("config.isEnabled()")) {
|
||||
violations.add("disclose 必须含 enabled fail-safe 检查(配置缺失→机制关闭)");
|
||||
}
|
||||
|
||||
// c) 行为面交叉引用:404+审计的完整行为契约由 MatchmakerContactAuthorizationTest 锁定
|
||||
Path integrationTest = Path.of("src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java");
|
||||
if (!Files.exists(integrationTest)) {
|
||||
integrationTest = Path.of("backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java");
|
||||
}
|
||||
assertTrue(Files.exists(integrationTest),
|
||||
"行为面集成测试 MatchmakerContactAuthorizationTest 必须存在(到期语义行为契约承载)");
|
||||
|
||||
assertEquals(List.of(), violations,
|
||||
"受控联系授权到期/披露语义契约被破坏(守卫③)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ④ consent 独立性(PIPL 单独同意) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("④ consent 独立性: 独立 POST 端点、无 @RequestBody、全仓无预同意参数")
|
||||
void consentMustBeIndependentAction() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
String src = stripComments(Files.readString(CONTACT_USER_CONTROLLER, StandardCharsets.UTF_8));
|
||||
if (!src.contains("@PostMapping(\"/{id}/consent\")")) {
|
||||
violations.add("consent 必须是独立 POST /{id}/consent 端点(不可捆绑到其他操作)");
|
||||
}
|
||||
if (!src.contains("@PostMapping(\"/{id}/decline\")")) {
|
||||
violations.add("decline 必须是独立 POST /{id}/decline 端点");
|
||||
}
|
||||
// consent/decline 不得有 @RequestBody(无预同意/勾选参数载体)
|
||||
Matcher methodBlocks = Pattern.compile(
|
||||
"@PostMapping\\(\"/\\{id\\}/(consent|decline)\"\\)[^}]*?\\)", Pattern.DOTALL).matcher(src);
|
||||
while (methodBlocks.find()) {
|
||||
if (methodBlocks.group().contains("@RequestBody")) {
|
||||
violations.add("consent/decline 端点不得携带 @RequestBody(杜绝预同意参数载体)");
|
||||
}
|
||||
}
|
||||
// 全仓不得出现预同意/预勾选参数命名(后端 main 源码 + miniapp 小程序 + admin-web 管理端
|
||||
// —— PIPL 单独同意的动作面横跨三端,任一端出现预同意参数即红)
|
||||
List<Path> scanRoots = new ArrayList<>();
|
||||
scanRoots.add(SRC_ROOT);
|
||||
Path repoRoot = null;
|
||||
// 从 SRC_ROOT 绝对路径逐级上溯,找到含 miniapp/ 的仓库根
|
||||
// (相对路径 "src/main/java" 上溯到 "src" 即 null,必须 toAbsolutePath 后再走)
|
||||
for (Path p = SRC_ROOT.toAbsolutePath().getParent(); p != null; p = p.getParent()) {
|
||||
if (Files.isDirectory(p.resolve("miniapp"))) {
|
||||
repoRoot = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (repoRoot != null) {
|
||||
Path miniapp = repoRoot.resolve("miniapp");
|
||||
Path adminWeb = repoRoot.resolve("admin-web/src");
|
||||
if (Files.isDirectory(miniapp)) scanRoots.add(miniapp);
|
||||
if (Files.isDirectory(adminWeb)) scanRoots.add(adminWeb);
|
||||
}
|
||||
for (Path scanRoot : scanRoots) {
|
||||
try (Stream<Path> files = Files.walk(scanRoot)) {
|
||||
for (Path f : files.filter(p -> {
|
||||
String n = p.toString();
|
||||
return n.endsWith(".java") || n.endsWith(".js") || n.endsWith(".ts") || n.endsWith(".vue");
|
||||
}).toList()) {
|
||||
String body = stripComments(Files.readString(f, StandardCharsets.UTF_8)).toLowerCase();
|
||||
for (String token : new String[]{"preconsent", "pre_consent", "preagree", "pre_agree",
|
||||
"autoconsent", "auto_consent"}) {
|
||||
if (body.contains(token)) {
|
||||
violations.add(f.getFileName() + " 出现预同意参数字样: " + token
|
||||
+ "(PIPL 单独同意不可被捆绑/预勾选)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"用户单独同意独立性被破坏(守卫④)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== ⑤ 保留不变:转介白名单 + 无批处理 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("⑤ 保留: ReferralService 16 键白名单+联系禁词 与 matchmaker/referral 无 @Scheduled")
|
||||
void referralWhitelistAndNoScheduledPreserved() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// a) ReferralService 响应键白名单(B1 立项后红娘侧转介仍零联系回显)
|
||||
String referralSrc = stripComments(Files.readString(REFERRAL_SERVICE, StandardCharsets.UTF_8));
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(referralSrc);
|
||||
while (put.find()) {
|
||||
keys.add(put.group(1));
|
||||
}
|
||||
assertTrue(keys.contains("needsUserConfirmation"), "must-confirm 语义键必须存在(守卫非永真自检)");
|
||||
for (String key : keys) {
|
||||
if (!RESPONSE_KEY_WHITELIST.contains(key)) {
|
||||
violations.add("ReferralService 非白名单响应键: " + key);
|
||||
}
|
||||
String lower = key.toLowerCase();
|
||||
for (String token : FORBIDDEN_KEY_TOKENS) {
|
||||
if (lower.contains(token)) {
|
||||
violations.add("ReferralService 响应键含联系方式 token: " + key + "(含 " + token + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// b) matchmaker/referral 包无 @Scheduled(无批处理代操作面)
|
||||
for (Path dir : PACKAGE_DIRS) {
|
||||
assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir);
|
||||
try (Stream<Path> files = Files.walk(dir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
if (src.contains("@Scheduled")) {
|
||||
String body = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
if (body.contains("@Scheduled")) {
|
||||
violations.add(f.getFileName() + " 含 @Scheduled 定时任务"
|
||||
+ "(ZC-MM-004:禁止引入代替用户决定/代操作的自动化通道)");
|
||||
+ "(禁止引入代替用户决定/代操作的自动化通道)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), violations,
|
||||
"红娘/转介域出现定时任务必须先过「禁止代用户决定」评审(ZC-MM-004)");
|
||||
"既有 B1 契约(转介白名单/无批处理)被破坏(守卫⑤)。\n违规项:\n - "
|
||||
+ String.join("\n - ", violations));
|
||||
}
|
||||
|
||||
// ==================== 解析实现 ====================
|
||||
|
||||
/** 全仓 main 源码 + 迁移脚本扫描 ContactAuthorization 机制(003/004 共享)。 */
|
||||
private List<String> scanForContactAuthorizationMechanism() throws IOException {
|
||||
List<String> violations = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
|
||||
String name = f.getFileName().toString();
|
||||
if (name.contains("ContactAuthorization")) {
|
||||
violations.add("出现联系授权类(机制被引入): " + name);
|
||||
continue;
|
||||
}
|
||||
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
|
||||
if (src.contains("ContactAuthorization")) {
|
||||
violations.add("源码引用联系授权机制: " + f.getFileName());
|
||||
}
|
||||
/** 提取类级 @RequestMapping 路径前缀(无则空串)。 */
|
||||
private String classMappingPrefix(String src) {
|
||||
Matcher classMapping = Pattern.compile("@RequestMapping\\(([^)]*)\\)").matcher(src);
|
||||
if (!classMapping.find()) {
|
||||
return "";
|
||||
}
|
||||
String args = classMapping.group(1).replaceAll("\"", "").trim();
|
||||
// 去掉 value=/path= 前缀写法,取第一段路径
|
||||
for (String part : args.split(",")) {
|
||||
String candidate = part.trim();
|
||||
if (candidate.startsWith("value=") || candidate.startsWith("path=")) {
|
||||
candidate = candidate.substring(candidate.indexOf('=') + 1).trim();
|
||||
}
|
||||
if (candidate.startsWith("/")) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
for (Path migrationDir : migrationDirCandidates()) {
|
||||
if (!Files.isDirectory(migrationDir)) {
|
||||
continue;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(migrationDir)) {
|
||||
for (Path f : files.filter(p -> p.toString().endsWith(".sql")).toList()) {
|
||||
String src = Files.readString(f, StandardCharsets.UTF_8).toLowerCase();
|
||||
if (src.contains("matchmaker_contact_authorizations")) {
|
||||
violations.add("出现 matchmaker_contact_authorizations 建表迁移: " + f.getFileName()
|
||||
+ "(安全默认=不开放;仅当产品确认开放联系功能时随 006 评审引入)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 迁移脚本目录(兼容仓库根与单模块两种布局)。 */
|
||||
private List<Path> migrationDirCandidates() {
|
||||
Path parent = SRC_ROOT.getParent();
|
||||
return List.of(
|
||||
parent != null ? parent.resolve("resources/db/migration") : Path.of("backend/src/main/resources/db/migration"),
|
||||
Path.of("backend/src/main/resources/db/migration"),
|
||||
Path.of("src/main/resources/db/migration"));
|
||||
/** 类级前缀 + 方法路径合成完整路径(Spring mapping 拼接语义)。 */
|
||||
private String compose(String classPrefix, String methodPath) {
|
||||
if (methodPath == null || methodPath.isEmpty() || methodPath.startsWith("(")) {
|
||||
return classPrefix;
|
||||
}
|
||||
if (classPrefix.isEmpty()) {
|
||||
return methodPath;
|
||||
}
|
||||
if (methodPath.startsWith("/")) {
|
||||
return classPrefix + methodPath;
|
||||
}
|
||||
return classPrefix + "/" + methodPath;
|
||||
}
|
||||
|
||||
private static String stripComments(String src) {
|
||||
|
||||
+4
@@ -7,6 +7,8 @@ import com.danyang.match.platform.appeal.controller.AppealOperatorController;
|
||||
import com.danyang.match.platform.audit.controller.AuditLogController;
|
||||
import com.danyang.match.platform.content.controller.ContentController;
|
||||
import com.danyang.match.platform.counseling.controller.CounselingController;
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerContactAuthorizationController;
|
||||
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
|
||||
import com.danyang.match.platform.operator.controller.OperatorController;
|
||||
import com.danyang.match.platform.organization.controller.OrganizationController;
|
||||
@@ -41,6 +43,8 @@ class OperatorEndpointPermissionCoverageTest {
|
||||
CheckinController.class, AnalyticsController.class,
|
||||
AppealOperatorController.class, AuditLogController.class,
|
||||
ContentController.class, CounselingController.class,
|
||||
MatchmakerAssignmentController.class,
|
||||
MatchmakerContactAuthorizationController.class,
|
||||
MatchmakerOperatorController.class, OperatorController.class,
|
||||
OrganizationController.class, ReviewController.class,
|
||||
ReferralController.class,
|
||||
|
||||
@@ -550,6 +550,41 @@ CREATE TABLE operators (
|
||||
);
|
||||
CREATE UNIQUE INDEX uk_operator_username_h2 ON operators(username);
|
||||
|
||||
-- V056(ZC-MM-003 B1 立项):matchmaker_contact_authorizations 受控联系授权表。
|
||||
-- 生产为部分唯一索引 uq_mm_contact_auth_active WHERE status='active';
|
||||
-- H2 不支持部分索引,用「生成列 + 唯一索引」等价替代(对照 V053 范式):
|
||||
-- active_contact_key 仅在 status='active' 时取 operator|user|channel 复合键,否则 NULL。
|
||||
-- 去外键(operators/users 本表均已存在,与既有「去外键」惯例一致)。
|
||||
CREATE TABLE matchmaker_contact_authorizations (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
operator_id VARCHAR(36) NOT NULL,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
channel VARCHAR(32) NOT NULL,
|
||||
purpose VARCHAR(200) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending_approval',
|
||||
approved_by VARCHAR(36),
|
||||
approved_at TIMESTAMP WITH TIME ZONE,
|
||||
user_consented_at TIMESTAMP WITH TIME ZONE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
revoked_by VARCHAR(36),
|
||||
revoke_reason VARCHAR(500),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
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_contact_key VARCHAR(200) GENERATED ALWAYS AS (
|
||||
CASE WHEN status = 'active'
|
||||
THEN operator_id || '|' || user_id || '|' || channel END
|
||||
)
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_mm_contact_auth_active_h2 ON matchmaker_contact_authorizations(active_contact_key);
|
||||
CREATE INDEX idx_contact_auth_operator_status_h2 ON matchmaker_contact_authorizations(operator_id, status);
|
||||
CREATE INDEX idx_contact_auth_user_status_h2 ON matchmaker_contact_authorizations(user_id, status);
|
||||
|
||||
-- V001 配套(ZC-MM-006 / T02):权限四表 —— JwtAuthFilter 运营分支实时查库
|
||||
-- (PermissionMapper join role_permissions/operator_roles)在 H2 上的真实 DB 路径支撑;
|
||||
-- 此前缺失导致所有 operator token 测试只能 @MockBean PermissionService 绕过。
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
| DEC-MATCHMAKER-004 不得代替用户决定 | Like/Pass/活动意向/开启聊天等用户本人操作 | ✅ 已完成(LOCKED;5e484d0 锁定) |
|
||||
| DEC-MATCHMAKER-005 数据最小必要 | RBAC + 数据范围 + 字段隐私 + 服务授权 | ✅ 已完成(LOCKED;3caf5f8 + 1be0d98) |
|
||||
| DEC-MATCHMAKER-006 全程留痕 | 分配/受限查看/越权拒绝等审计 + 第八节字段 | ✅ 已完成(LOCKED;V055 + 1be0d98) |
|
||||
| 受控联系(电话/线下)授权机制 | 未立项;ContactAuthorizationPolicy DEFERRED | ⏳ 待产品/法务确认(OQ-MATCHMAKER-003;MatchmakerContactGuardTest 机械锁强制复审) |
|
||||
| 受控联系(电话/线下)授权机制 | **已立项实现**(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,终审后改配置生效 |
|
||||
| C 端死代码退役 3 项 | MatchmakerServiceService.getCandidates / cancelService / getRecommendationsForUser(grep 全仓零调用已核,2026-10-10) | ⏳ 待产品确认:是否批准专项治理批次执行(与 ZC-MM-006 G6 评估清单合并为一个退役 PR,不混入功能提交) |
|
||||
|
||||
---
|
||||
@@ -146,5 +146,5 @@
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-10-10 — 红娘角色权限专项回写:DEC-MATCHMAKER-001~006 全部 LOCKED 且已落地(ZC-MM-001~007 全链路,验证 mvn verify 884/884 / UI 24/24 / 后端契约 198 / 负向注入 7 处);新增待产品确认:受控联系授权机制开放(OQ-MATCHMAKER-003,LEGAL_VERIFY)与 C 端死代码退役 3 项专项治理批次。此前:2026-09-20 V1.0.1 配置与规则纠偏(消解「推荐阈值 60 已确认 / 待定」矛盾等)。*
|
||||
*最后更新: 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 已确认 / 待定」矛盾等)。*
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 配置登记表 — config-register.md
|
||||
|
||||
> **版本**:V1.0.1 配置与规则纠偏基线 · **生效**:2026-09-20 · **owner**:WorkBuddy
|
||||
> **代码载体**:`FeatureFlagConfig`(feature flag,前缀 `features`)/ `MatchRuleConfigProperties`(前缀 `match`)/ `ProfileProperties`(前缀 `profile`)/ `AccountConfig`(前缀 `account`)
|
||||
> **代码载体**:`FeatureFlagConfig`(feature flag,前缀 `features`)/ `MatchRuleConfigProperties`(前缀 `match`)/ `ProfileProperties`(前缀 `profile`)/ `AccountConfig`(前缀 `account`)/ `ContactAuthorizationConfig`(前缀 `matchmaker.contact`)
|
||||
> **一致性守卫**:`FeatureFlagPersistenceGuardTest`、`MatchRuleConfigPropertiesTest`、`EntityMigrationParityTest`、`MigrationValidationTest`
|
||||
>
|
||||
> **核心纪律**(AGENTS.md #12 + V1.0.1 纠偏):**技术默认值 ≠ 产品正式确认规则。**
|
||||
@@ -39,6 +39,11 @@
|
||||
| `account.data-retention.months` | `account.data-retention-months` | LOCKED | `36` | 是(2026-09-19 产品确认下限 36 个月) | 是 | `AccountConfig`;注销后保留期下限,到期人工审核匿名化(V049)。 |
|
||||
| `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.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` 下发前端。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,5 +57,5 @@
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-09-21 — V1.0.1 配置与规则纠偏 + canonical key 字面化:登记 identity.liveness.enabled / profile.photo.maxCount / matching.recommendation.minScore 三项 canonical 键;V051 把活体键收敛为 `feature_flag_overrides` 唯一正式键(KEY_ALIASES 读兼容遗留键);消除 MatchConfig 双 bean 与前后端照片上限失同步。*
|
||||
*最后更新: 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 字面化。*
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
| DEC-MATCHMAKER-003 / B1 | 纯护栏(零生产代码改动):MM-CONTACT-001~004 守卫锁定(无联系端点/无授权机制/零联系方式回显,含紧急联系人 write-only);受控联系机制 DEFERRED(见 OQ-MATCHMAKER-003) | 5e484d0(ZC-MM-003) | MatchmakerContactGuardTest(负向注入) |
|
||||
| DEC-MATCHMAKER-004 | 代操作核查 + 锁定:红娘可调写端点逐一无代用户意思表示;operator 主体调用户决策端点认证层 403(SecurityConfig 身份类型防线);转介 must-confirm 语义测试锁死 | 5e484d0(ZC-MM-004) | MatchmakerActionGuardTest(负向注入) |
|
||||
| DEC-MATCHMAKER-005/006 | V055 audit_logs 补 purpose_code/service_assignment_id/actor_role_snapshot/request_trace_id;JWT 权限实时查库回收(零缓存);SensitiveAccessLog 接入服务详情;写接口 404 化防探测统一 | 1be0d98(ZC-MM-006) | mvn verify 884/884、后端契约 198 |
|
||||
| **DEC-MATCHMAKER-003-B1 例外通道立项**(2026-10-11 LOCKED→已实现) | 受控联系全链路:V056 `matchmaker_contact_authorizations` 六态状态机(申请→审批→**用户单独同意**[PIPL,独立端点/无预同意参数]→限时生效→撤销/过期)+ ContactAuthorizationPolicy/Service + 双控制器 + 前端三端(miniapp 同意页/工作台入口、admin-web 审批 tab);**唯一披露出口** disclose 专用端点(enabled fail-safe + 404 化 deny + user_phone_disclosure 敏感审计 + phone 不落应用日志);守卫翻转五条(ReferralService 响应键白名单与无 @Scheduled 保留,其余从「锁不存在」改「锁新机制契约」);审批岗/有效期/保留期等全部 DEFAULT+CONFIG(config-register `matchmaker.contact.*` 五键,非 LOCKED 待终审) | d3735e3 | mvn verify **895/895**、UI 契约 **24/24**、后端契约 **228**/CURATED 20、小程序真编译 **87 WXML**、守卫负向注入 **5+2** |
|
||||
|
||||
**专项验证汇总**:mvn verify 全量 **884/884**、UI 契约 **24/24**、后端契约 **198**、守卫负向注入变异验证 **7 处判红→还原**(守卫有效性自证)。
|
||||
|
||||
@@ -64,4 +65,4 @@
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-10-10 — 登记红娘角色权限专项 DEC-MATCHMAKER-001~006 全部 LOCKED(ZC-MM-001~007 全链路:审计 2d60f78 → A1 建模 861c78e → 数据范围/字段隐私 3caf5f8 → B1/代操作护栏 5e484d0 → 审计与权限回收 1be0d98);受控联系开放前置确认项在 open-questions OQ-MATCHMAKER-003,ContactAuthorizationPolicy 标记 DEFERRED。此前:2026-09-20 登记 15 项已确认决策。*
|
||||
*最后更新: 2026-10-11 — 登记 DEC-MATCHMAKER-003-B1 受控联系例外通道立项(V056 / 提交 d3735e3,QA PASS 0 P0/P1);OQ-MATCHMAKER-003 转 IN_PROGRESS,剩余 LEGAL_VERIFY 终审项与审批双岗收敛见 open-questions OQ-MATCHMAKER-003/009,参数 canonical key 见 config-register `matchmaker.contact.*`。此前:2026-10-10 登记红娘角色权限专项 DEC-MATCHMAKER-001~006(ZC-MM-001~007 全链路);2026-09-20 登记 15 项已确认决策。*
|
||||
|
||||
@@ -80,17 +80,30 @@
|
||||
|
||||
## OQ-MATCHMAKER-003 · 受控联系(电话/线下)开放前的产品/法务确认项【LEGAL_VERIFY】
|
||||
|
||||
- **状态**:TBD(受控联系功能未立项;安全默认 = 不开放)
|
||||
- **背景**:DEC-MATCHMAKER-003 / B1 已 LOCKED——默认平台内渠道联系,电话/线下联系须专项授权。当前系统**不存在**任何电话/线下联系功能与授权机制(守卫锁定,见下)。
|
||||
- **开放前必须逐项确认(①②标 LEGAL_VERIFY,需产品 + 法务共同裁决)**:
|
||||
1. 电话/线下联系例外由**哪个岗位批准**【LEGAL_VERIFY】;
|
||||
2. 例外授权是否需要**用户单独同意**、如何记录【LEGAL_VERIFY】;
|
||||
3. 临时授权的**有效期限**(expires_at 语义);
|
||||
4. 联系授权记录/联系结果摘要的**保留周期**;
|
||||
5. 授权审批与用户同意的**审计字段与查询面**。
|
||||
- **DEFERRED**:`ContactAuthorizationPolicy`(形态已预置:`assertContactAllowed(operatorId, userId, channel, purpose)` → `DataScopeGuard.deny`)与 `matchmaker_contact_authorizations` 表**仅在产品确认开放受控联系时立项**。
|
||||
- **机械锁**:`MatchmakerContactGuardTest`(ZC-MM-003 落地)断言全仓无 ContactAuthorization 类、无 matchmaker_contact_authorizations 迁移、无联系端点——**未来任何人引入受控联系必先触红,强制回到本项复审**,不存在静默绕过路径。
|
||||
- **关联**:DEC-MATCHMAKER-003 规则 7、《决策》§十一;提交 5e484d0(守卫)、1be0d98(DEFERRED 登记)。
|
||||
- **状态**:**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),产品/法务终审后改配置即可,无需改代码。
|
||||
- **机械锁(已翻转)**:`MatchmakerContactGuardTest` 现锁定新机制契约——披露能力仅限专用白名单类、端点前缀白名单、到期/撤销/拒绝/未同意状态下 disclose 一律 404+DATA_SCOPE_DENIED、consent 端点独立性、ReferralService 禁词与无 @Scheduled 保留;未来绕过专用出口的披露路径必触红。
|
||||
- **关联**:DEC-MATCHMAKER-003 规则 7、《决策》§十一;V056;提交 d3735e3。
|
||||
|
||||
## 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`;改配置即可生效,无需改代码。
|
||||
|
||||
## OQ-MATCHMAKER-010 · QA P2-2:`matchmaker.contact.enabled=false` 行为用例待补
|
||||
|
||||
- **状态**:TBD(QA 观察项移交,2026-10-11)
|
||||
- **现状**:fail-safe 语义(配置缺失=机制关闭)已在配置层实现,但 `enabled=false` 下的**行为级**测试用例(申请/审批/consent/disclose 各端点在关闭态的拒绝响应与审计)尚未进入自动化基线。
|
||||
- **待办**:补集成用例(建议挂 `MatchmakerContactAuthorizationTest`,同文件串行追加);在补齐前,关闭态行为由代码评审保证。
|
||||
|
||||
## OQ-MATCHMAKER-004 · 红娘组长组织边界的 NULL=NULL「平台直属」规则
|
||||
|
||||
@@ -133,4 +146,4 @@
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-10-10 — 红娘专项新增 OQ-MATCHMAKER-003(受控联系开放前 5 项确认,①②LEGAL_VERIFY;ContactAuthorizationPolicy DEFERRED + MatchmakerContactGuardTest 机械锁)/004(NULL=NULL 平台直属)/005(零缓存压测复议)/006(写接口 404/403 语义边界,RESOLVED)/007(purpose_code matchmaker.service.view 未使用口径漂移)/008(运营域 active 与用户域 normal 状态口径差)。此前:2026-09-20 登记 OQ-IDENTITY/PROFILE/MATCH-001 及本地资格保留期、服务站时间、咨询资质、Banner 数量、报送口径、第三方开放遗留。*
|
||||
*最后更新: 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。*
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@
|
||||
"pages/content-detail/content-detail",
|
||||
"pages/anti-fraud/anti-fraud",
|
||||
"pages/service-program/service-program",
|
||||
"pages/service-history/service-history"
|
||||
"pages/service-history/service-history",
|
||||
"pages/contact-consent/contact-consent"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTitleText": "丹阳公益婚恋",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 联系授权确认页(C 端用户,ZC-MM-003 / B1 例外通道)
|
||||
*
|
||||
* PIPL 单独同意契约:
|
||||
* - 授权目的 / 渠道 / 有效期窗口逐条明示后才可操作;
|
||||
* - 同意与拒绝是两个独立按钮,无预勾选、无捆绑(后端亦为独立 POST 端点,
|
||||
* 守卫 MatchmakerContactGuardTest ④ 锁定);
|
||||
* - 数据源 GET /contact-authorizations/my(pendingConsent 待同意队列 + 全部记录)。
|
||||
*/
|
||||
const api = require('../../utils/api');
|
||||
|
||||
const CHANNEL_LABELS = {
|
||||
phone: '电话联系',
|
||||
offline_meeting: '线下见面',
|
||||
full_contact: '完整联系方式',
|
||||
};
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
pendingConsent: [],
|
||||
history: [],
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadAuthorizations();
|
||||
},
|
||||
|
||||
async loadAuthorizations() {
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
const res = await api.get('/contact-authorizations/my');
|
||||
if (res.success) {
|
||||
const pending = (res.data.pendingConsent || []).map((item) => this.decorate(item));
|
||||
const history = (res.data.list || []).map((item) => this.decorate(item));
|
||||
this.setData({ pendingConsent: pending, history });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载联系授权失败:', err);
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** 渠道/状态/有效期 → 中文展示(枚举码不上屏) */
|
||||
decorate(item) {
|
||||
const labels = {
|
||||
pending_approval: '待审批', pending_consent: '待你确认', active: '生效中',
|
||||
rejected: '已拒绝', expired: '已过期', revoked: '已撤销',
|
||||
};
|
||||
return Object.assign({}, item, {
|
||||
channelLabel: CHANNEL_LABELS[item.channel] || item.channel,
|
||||
statusLabel: labels[item.status] || item.status,
|
||||
expireText: item.expiresAt ? this.formatTime(item.expiresAt) : '',
|
||||
});
|
||||
},
|
||||
|
||||
formatTime(timeStr) {
|
||||
if (!timeStr) return '';
|
||||
const d = new Date(timeStr);
|
||||
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
/** 独立同意(单独明示动作,无捆绑参数) */
|
||||
async onConsent(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
const item = this.data.pendingConsent.find((a) => a.id === id);
|
||||
if (!item) return;
|
||||
wx.showModal({
|
||||
title: '确认同意联系授权',
|
||||
content: `同意「${item.channelLabel}」:${item.purpose}。同意后红娘可在有效期内通过该方式联系你。`,
|
||||
confirmText: '同意',
|
||||
cancelText: '再想想',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return;
|
||||
try {
|
||||
const resp = await api.post(`/contact-authorizations/${id}/consent`);
|
||||
if (resp.success) {
|
||||
wx.showToast({ title: '已同意', icon: 'success' });
|
||||
this.loadAuthorizations();
|
||||
}
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 独立拒绝 */
|
||||
async onDecline(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
try {
|
||||
const resp = await api.post(`/contact-authorizations/${id}/decline`);
|
||||
if (resp.success) {
|
||||
wx.showToast({ title: '已拒绝', icon: 'none' });
|
||||
this.loadAuthorizations();
|
||||
}
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '操作失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "联系授权确认",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- 联系授权确认页(ZC-MM-003 / B1:单独明示同意,无预勾选、无捆绑) -->
|
||||
<view class="page">
|
||||
<view class="intro">
|
||||
<view class="intro-title">红娘联系授权</view>
|
||||
<view class="intro-text">红娘经平台审批后向你发起联系授权申请。你可以单独决定同意或拒绝,同意后授权在有效期内生效,到期自动失效,也可随时被撤销。</view>
|
||||
</view>
|
||||
|
||||
<!-- 待确认队列 -->
|
||||
<view class="section" wx:if="{{pendingConsent.length > 0}}">
|
||||
<view class="section-title">待你确认({{pendingConsent.length}})</view>
|
||||
<view class="card" wx:for="{{pendingConsent}}" wx:key="id">
|
||||
<view class="row"><text class="label">授权渠道</text><text class="value strong">{{item.channelLabel}}</text></view>
|
||||
<view class="row"><text class="label">授权目的</text><text class="value">{{item.purpose}}</text></view>
|
||||
<view class="row"><text class="label">确认截止</text><text class="value">{{item.expireText}}</text></view>
|
||||
<view class="actions">
|
||||
<button class="btn decline" data-id="{{item.id}}" bindtap="onDecline">拒绝</button>
|
||||
<button class="btn consent" data-id="{{item.id}}" bindtap="onConsent">同意</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:if="{{!loading && pendingConsent.length === 0}}">
|
||||
<view class="empty-text">暂无待确认的授权申请</view>
|
||||
</view>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<view class="section" wx:if="{{history.length > 0}}">
|
||||
<view class="section-title">历史记录</view>
|
||||
<view class="card slim" wx:for="{{history}}" wx:key="id">
|
||||
<view class="row"><text class="label">渠道</text><text class="value">{{item.channelLabel}}</text></view>
|
||||
<view class="row"><text class="label">目的</text><text class="value">{{item.purpose}}</text></view>
|
||||
<view class="row"><text class="label">状态</text><text class="value">{{item.statusLabel}}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="loading-wrap" wx:if="{{loading}}">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,21 @@
|
||||
/* 联系授权确认页样式 */
|
||||
.page { padding: 24rpx; background: #f7f8fa; min-height: 100vh; box-sizing: border-box; }
|
||||
.intro { background: #eef4ff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; }
|
||||
.intro-title { font-size: 32rpx; font-weight: 600; color: #1f2d3d; margin-bottom: 12rpx; }
|
||||
.intro-text { font-size: 26rpx; color: #5e6d82; line-height: 1.6; }
|
||||
.section { margin-bottom: 28rpx; }
|
||||
.section-title { font-size: 30rpx; font-weight: 600; color: #1f2d3d; margin-bottom: 16rpx; }
|
||||
.card { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
|
||||
.card.slim { padding: 20rpx; }
|
||||
.row { display: flex; margin-bottom: 12rpx; }
|
||||
.label { width: 150rpx; color: #909399; font-size: 26rpx; flex-shrink: 0; }
|
||||
.value { color: #303133; font-size: 26rpx; flex: 1; word-break: break-all; }
|
||||
.value.strong { font-weight: 600; color: #1f2d3d; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 20rpx; margin-top: 16rpx; }
|
||||
.btn { font-size: 28rpx; border-radius: 12rpx; padding: 0 40rpx; line-height: 72rpx; margin: 0; }
|
||||
.btn.decline { background: #f4f4f5; color: #606266; }
|
||||
.btn.consent { background: #2f7df6; color: #fff; }
|
||||
.empty { text-align: center; padding: 60rpx 0; }
|
||||
.empty-text { color: #909399; font-size: 26rpx; }
|
||||
.loading-wrap { text-align: center; padding: 40rpx 0; }
|
||||
.loading-text { color: #909399; font-size: 26rpx; }
|
||||
@@ -23,6 +23,16 @@ Page({
|
||||
assignmentCount: 0,
|
||||
loading: true,
|
||||
currentDate: '',
|
||||
// ZC-MM-003 / B1:受控联系授权(申请入口 + 本人授权列表/撤销;purpose 必填)
|
||||
contactTargets: [],
|
||||
contactTargetIndex: 0,
|
||||
contactChannelLabels: ['电话联系', '线下见面', '完整联系方式'],
|
||||
contactChannels: ['phone', 'offline_meeting', 'full_contact'],
|
||||
contactChannelIndex: 0,
|
||||
contactPurpose: '',
|
||||
contactAuths: [],
|
||||
contactAuthCount: 0,
|
||||
submittingAuth: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -70,10 +80,18 @@ Page({
|
||||
|
||||
if (assignmentRes.success) {
|
||||
const assignments = assignmentRes.data || [];
|
||||
// 联系授权申请目标 = 本人生效中的分配对象(B1:只能对分配对象发起)
|
||||
const targets = assignments.map((a) => ({
|
||||
userId: a.userId,
|
||||
label: '用户 ' + String(a.userId).slice(0, 8) + '…(' + (a.assignmentReason || '服务对象') + ')',
|
||||
}));
|
||||
this.setData({
|
||||
myAssignments: assignments,
|
||||
assignmentCount: assignments.length,
|
||||
contactTargets: targets,
|
||||
contactTargetIndex: 0,
|
||||
});
|
||||
this.loadContactAuthorizations();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载工作台数据失败:', err);
|
||||
@@ -91,6 +109,98 @@ Page({
|
||||
wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}` });
|
||||
},
|
||||
|
||||
// ==================== ZC-MM-003 / B1:受控联系授权 ====================
|
||||
|
||||
/** 本人授权列表(GET /operator/matchmaker/contact-authorizations/my) */
|
||||
async loadContactAuthorizations() {
|
||||
try {
|
||||
const token = wx.getStorageSync('operatorToken');
|
||||
const res = await api.get('/operator/matchmaker/contact-authorizations/my',
|
||||
{}, { Authorization: `Bearer ${token}` });
|
||||
if (res.success) {
|
||||
const items = (res.data.list || []).map((item) => Object.assign({}, item, {
|
||||
channelLabel: this.contactChannelLabel(item.channel),
|
||||
statusLabel: this.contactStatusLabel(item.status),
|
||||
expireText: item.expiresAt ? this.formatTime(item.expiresAt) : '',
|
||||
revocable: item.status === 'active' || item.status === 'pending_approval'
|
||||
|| item.status === 'pending_consent',
|
||||
}));
|
||||
this.setData({ contactAuths: items, contactAuthCount: items.length });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载联系授权失败:', err);
|
||||
}
|
||||
},
|
||||
|
||||
contactChannelLabel(code) {
|
||||
const labels = { phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式' };
|
||||
return labels[code] || code || '';
|
||||
},
|
||||
|
||||
contactStatusLabel(code) {
|
||||
const labels = {
|
||||
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
|
||||
rejected: '用户已拒绝', expired: '已过期', revoked: '已撤销',
|
||||
};
|
||||
return labels[code] || code || '';
|
||||
},
|
||||
|
||||
onContactTargetChange(e) { this.setData({ contactTargetIndex: Number(e.detail.value) }); },
|
||||
onContactChannelChange(e) { this.setData({ contactChannelIndex: Number(e.detail.value) }); },
|
||||
onContactPurposeInput(e) { this.setData({ contactPurpose: e.detail.value }); },
|
||||
|
||||
/** 发起授权申请(purpose 必填;B1:用户随后单独明示同意,本页不产生任何用户授权) */
|
||||
async submitContactRequest() {
|
||||
if (this.data.submittingAuth) return;
|
||||
const target = this.data.contactTargets[this.data.contactTargetIndex];
|
||||
if (!target) { wx.showToast({ title: '暂无可申请的服务对象', icon: 'none' }); return; }
|
||||
const purpose = (this.data.contactPurpose || '').trim();
|
||||
if (!purpose) { wx.showToast({ title: '授权目的必填', icon: 'none' }); return; }
|
||||
const channel = this.data.contactChannels[this.data.contactChannelIndex];
|
||||
this.setData({ submittingAuth: true });
|
||||
try {
|
||||
const token = wx.getStorageSync('operatorToken');
|
||||
const res = await api.post('/operator/matchmaker/contact-authorizations', {
|
||||
userId: target.userId, channel: channel, purpose: purpose,
|
||||
}, { Authorization: `Bearer ${token}` });
|
||||
if (res.success) {
|
||||
wx.showToast({ title: '申请已提交,等待审批', icon: 'none' });
|
||||
this.setData({ contactPurpose: '' });
|
||||
this.loadContactAuthorizations();
|
||||
}
|
||||
} catch (err) {
|
||||
wx.showToast({ title: err.message || '申请失败', icon: 'none' });
|
||||
} finally {
|
||||
this.setData({ submittingAuth: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** 撤销本人授权(reason 必填) */
|
||||
revokeContactAuth(e) {
|
||||
const id = e.currentTarget.dataset.id;
|
||||
wx.showModal({
|
||||
title: '撤销联系授权',
|
||||
editable: true,
|
||||
placeholderText: '请输入撤销理由(必填)',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return;
|
||||
const reason = (res.content || '').trim();
|
||||
if (!reason) { wx.showToast({ title: '撤销理由必填', icon: 'none' }); return; }
|
||||
try {
|
||||
const token = wx.getStorageSync('operatorToken');
|
||||
const resp = await api.post(`/operator/matchmaker/contact-authorizations/${id}/revoke`,
|
||||
{ reason: reason }, { Authorization: `Bearer ${token}` });
|
||||
if (resp.success) {
|
||||
wx.showToast({ title: '已撤销', icon: 'none' });
|
||||
this.loadContactAuthorizations();
|
||||
}
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '撤销失败', icon: 'none' });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// 分配状态 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
|
||||
assignmentStatusLabel(code) {
|
||||
const labels = {
|
||||
|
||||
@@ -72,6 +72,57 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 联系授权(ZC-MM-003 / B1:受控联系,用户单独同意后才可生效) -->
|
||||
<view class="section" wx:if="{{!loading}}">
|
||||
<view class="section-header">
|
||||
<view class="section-title-wrap">
|
||||
<text class="section-dot"></text>
|
||||
<text class="section-title">联系授权(受控联系)</text>
|
||||
</view>
|
||||
<text class="section-count" wx:if="{{contactAuthCount > 0}}">共{{contactAuthCount}}条</text>
|
||||
</view>
|
||||
|
||||
<!-- 申请入口:对本人分配对象发起,purpose 必填 -->
|
||||
<view class="contact-form" wx:if="{{contactTargets.length > 0}}">
|
||||
<picker mode="selector" range="{{contactTargets}}" range-key="label" value="{{contactTargetIndex}}" bindchange="onContactTargetChange">
|
||||
<view class="form-row">
|
||||
<text class="form-label">服务对象</text>
|
||||
<text class="form-value">{{contactTargets[contactTargetIndex].label}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" range="{{contactChannelLabels}}" value="{{contactChannelIndex}}" bindchange="onContactChannelChange">
|
||||
<view class="form-row">
|
||||
<text class="form-label">联系渠道</text>
|
||||
<text class="form-value">{{contactChannelLabels[contactChannelIndex]}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="form-row">
|
||||
<text class="form-label">授权目的</text>
|
||||
<input class="form-input" value="{{contactPurpose}}" bindinput="onContactPurposeInput"
|
||||
placeholder="必填:说明为何需要联系该用户" maxlength="200" />
|
||||
</view>
|
||||
<view class="form-hint">提交后须经平台审批,并由用户单独确认同意后授权方可生效</view>
|
||||
<button class="submit-btn" size="mini" bindtap="submitContactRequest" disabled="{{submittingAuth}}">提交申请</button>
|
||||
</view>
|
||||
<view class="empty-state" wx:else>
|
||||
<view class="empty-hint">暂无服务对象分配,无法发起联系授权</view>
|
||||
</view>
|
||||
|
||||
<!-- 本人授权列表 -->
|
||||
<view class="auth-list" wx:if="{{contactAuths.length > 0}}">
|
||||
<view class="auth-card" wx:for="{{contactAuths}}" wx:key="id">
|
||||
<view class="auth-row">
|
||||
<text class="auth-channel">{{item.channelLabel}}</text>
|
||||
<text class="auth-status">{{item.statusLabel}}</text>
|
||||
</view>
|
||||
<view class="auth-purpose">{{item.purpose}}</view>
|
||||
<view class="auth-meta" wx:if="{{item.expireText}}">有效期至 {{item.expireText}}</view>
|
||||
<button class="revoke-btn" size="mini" wx:if="{{item.revocable}}"
|
||||
data-id="{{item.id}}" bindtap="revokeContactAuth">撤销</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<view class="loading-wrap" wx:if="{{loading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
|
||||
@@ -264,3 +264,20 @@
|
||||
border-radius:42rpx;
|
||||
border:2rpx solid var(--color-border);
|
||||
min-width:var(--tap-min);}
|
||||
|
||||
/* ===== 联系授权(ZC-MM-003 / B1) ===== */
|
||||
.contact-form { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
|
||||
.form-row { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f0f0f0; }
|
||||
.form-label { width: 160rpx; color: #909399; font-size: 26rpx; flex-shrink: 0; }
|
||||
.form-value { color: #303133; font-size: 26rpx; flex: 1; }
|
||||
.form-input { flex: 1; font-size: 26rpx; color: #303133; }
|
||||
.form-hint { font-size: 22rpx; color: #b0b3b8; margin-top: 12rpx; line-height: 1.5; }
|
||||
.submit-btn { margin-top: 16rpx; background: #2f7df6; color: #fff; }
|
||||
.auth-list { margin-top: 8rpx; }
|
||||
.auth-card { background: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; margin-bottom: 14rpx; }
|
||||
.auth-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.auth-channel { font-size: 28rpx; font-weight: 600; color: #1f2d3d; }
|
||||
.auth-status { font-size: 24rpx; color: #909399; }
|
||||
.auth-purpose { font-size: 26rpx; color: #5e6d82; margin-top: 8rpx; word-break: break-all; }
|
||||
.auth-meta { font-size: 22rpx; color: #b0b3b8; margin-top: 8rpx; }
|
||||
.revoke-btn { margin-top: 12rpx; background: #f4f4f5; color: #606266; }
|
||||
|
||||
@@ -50,6 +50,9 @@ const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java')
|
||||
// OperatorPermissionRevocationTest —— JWT 权限回收实时查库(禁用 401 / 撤权 403+审计)
|
||||
// MatchmakerSensitiveAccessAuditTest —— 服务详情敏感访问日志(success 行 / 双记录互斥)
|
||||
// MatchmakerWriteDenyUniformityTest —— 写接口 404 统一防探测(5 拒绝 ≡ 读详情 404)
|
||||
// 第 19~20 项(ZC-MM-003 B1 立项):
|
||||
// MatchmakerContactAuthorizationTest —— 受控联系授权生命周期/状态机/404 化拒绝/审计
|
||||
// MatchmakerContactGuardTest —— 守卫翻转:联系回显收敛/端点前缀/到期语义/consent 独立性
|
||||
// 新增/删减请同步修改本数组(下限随之变化)。
|
||||
const CURATED = [
|
||||
'AuthServiceRefreshTtlTest',
|
||||
@@ -70,6 +73,8 @@ const CURATED = [
|
||||
'OperatorPermissionRevocationTest',
|
||||
'MatchmakerSensitiveAccessAuditTest',
|
||||
'MatchmakerWriteDenyUniformityTest',
|
||||
'MatchmakerContactAuthorizationTest',
|
||||
'MatchmakerContactGuardTest',
|
||||
]
|
||||
|
||||
const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length)
|
||||
|
||||
@@ -16,7 +16,8 @@ function assertInOrder(source, labels, scope) {
|
||||
}
|
||||
|
||||
const appConfig = JSON.parse(read('miniapp/app.json'))
|
||||
assert.equal(appConfig.pages.length, 63, '小程序页面清单必须完整覆盖 63 页')
|
||||
// ZC-MM-003(B1 立项):contact-consent 联系授权确认页(用户单独明示同意)→ 63 → 64
|
||||
assert.equal(appConfig.pages.length, 64, '小程序页面清单必须完整覆盖 64 页')
|
||||
const redesignMatrix = read('docs/UI_REDESIGN_PAGE_MATRIX_V1.1.md')
|
||||
for (const page of [
|
||||
'pages/index/index', 'pages/discover/discover', 'pages/messages/messages',
|
||||
|
||||
Reference in New Issue
Block a user