11 Commits
Author SHA1 Message Date
WorkBuddy 21275ef770 docs(governance): 受控联系立项治理登记(OQ-003 转 IN_PROGRESS / OQ-009~010 新增 / DEC-MATCHMAKER-003-B1 落账)
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
- config-register.md: matchmaker.contact.* 五 canonical key 登记(enabled fail-safe 缺省 false / approver-authorities 双岗并列 / consent-deadline-hours 72 / default-validity-days 7 / retention-days 365,均 DEFAULT+CONFIG 非 LOCKED 待产品+法务终审)
- open-questions.md: OQ-MATCHMAKER-003 DEFERRED→IN_PROGRESS(V056 立项实现,双守卫翻转,5 项 LEGAL_VERIFY 逐项标注当前实现态与默认配置键);新增 OQ-MATCHMAKER-009(审批双岗并列待收敛,OPEN)与 OQ-MATCHMAKER-010(enabled=false 行为用例待补,QA P2-2)
- PENDING_DECISIONS.md: 受控联系行更新为已立项实现(DEC-MATCHMAKER-003-B1,V056,提交 d3735e3);剩余决策=双岗收敛+LEGAL_VERIFY 终审
- decision-register.md: DEC-MATCHMAKER-003-B1(2026-10-11 LOCKED→已实现)登记,验证数字 mvn verify 895/895、UI 24/24、后端契约 228/CURATED 20、真编译 87 WXML、负向注入 5+2
2026-10-11 13:11:52 +08:00
WorkBuddy d3735e3afa feat(matchmaker,contactauth): 受控联系授权机制立项落地(OQ-MATCHMAKER-003 / DEC-MATCHMAKER-003 B1 例外通道)
后端:
- V056 matchmaker_contact_authorizations 表:channel CHECK(phone/offline_meeting/full_contact)、六态状态机(pending_approval→pending_consent→active→expired/revoked, →rejected)、部分唯一索引 uq_mm_contact_auth_active 防同红娘-同用户-同渠道多 ACTIVE、双态惰性收敛(active 过期 + pending_consent 超同意窗口)
- ContactAuthorizationConfig 五 canonical key(matchmaker.contact.*):enabled 代码缺省 false fail-safe(yml 显式 true)/approver-authorities 双岗并列/consent-deadline-hours 72/default-validity-days 7/retention-days 365,全部 DEFAULT+CONFIG 待产品+法务终审
- ContactAuthorizationPolicy.assertContactAllowed(404 化 deny 先审计后 404) + canContact 徽标查询
- ContactAuthorizationService:request(须有效分配+purpose 必填)/approve(权限码动态判定∈配置+组长组织范围)/consent(用户本人,仅 pending_consent)/decline/revoke(reason 必填)/disclose(唯一披露出口:enabled fail-safe→assertActiveAssignment→assertContactAllowed→仅返回 phone/disclosedAt/authorizationId 三键,每次落 SensitiveAccessLog user_phone_disclosure,phone 不落应用日志)
- 双控制器:运营端 /operator/matchmaker/contact-authorizations + C 端 /contact-authorizations(UserPrincipal 归属校验,独立端点无预同意参数),不碰 SecurityConfig

守卫翻转(机械锁强制复审机制按设计生效):
- MatchmakerContactGuardTest 由「锁不存在」翻转为「锁新契约」五条:回显白名单类收敛/端点前缀收敛(受治理三包)/到期语义/consent 独立性三端扫描(后端+miniapp+admin-web,修复原只扫后端的预同意参数逃逸缺口)/ReferralService 白名单与无 @Scheduled 保留
- run-backend-contracts CURATED 18→20

前端:
- miniapp 新页 pages/contact-consent(目的/渠道/窗口明示+独立同意拒绝按钮无预勾选,app.json 第 64 页) + 红娘工作台申请/列表/撤销
- admin-web「联系授权审批」tab(审批岗队列+本人授权撤销,枚举码中文映射)

验证:mvn verify 895/895(3 skip)、UI 契约 24/24(64 页)、后端契约门禁 228/CURATED 20、小程序真编译 87 WXML 0 失败;负向注入 5(工程师)+2(QA 不同落点)判红→还原复绿;QA 抓 disclose 键数收敛断言缺口已补并回归;QA fresh eyes PASS 0 P0/P1
2026-10-11 13:08:02 +08:00
WorkBuddy 4248e9574e chore(release): 版本号 1.1.0 -> 1.2.0(红娘角色权限专项 ZC-MM-001~007 落地固化)
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
2026-10-11 10:48:33 +08:00
WorkBuddy 6c2601950b docs(governance): ZC-MM-007 同步治理文档——红娘专项 DEC-MATCHMAKER-001~006 收口
- decision-register.md: 登记 DEC-MATCHMAKER-001~006 全部 LOCKED(实施摘要 + 落地提交链 2d60f78/861c78e/5e484d0/3caf5f8/1be0d98 + 验证数字 mvn verify 884/884、UI 契约 24/24、后端契约 198、负向注入 7 处判红→还原)
- open-questions.md: 新增 OQ-MATCHMAKER-003~008——003 受控联系开放前 5 项确认(①②LEGAL_VERIFY)+ ContactAuthorizationPolicy DEFERRED(MatchmakerContactGuardTest 机械锁强制复审)/ 004 NULL=NULL 平台直属规则 / 005 零缓存实时查库压测复议条件 / 006 写接口 404 化语义边界(RESOLVED)/ 007 purpose_code matchmaker.service.view 定义未使用口径漂移(QA P2①)/ 008 运营域 active 与用户域 normal 状态口径差(QA P2②)
- PENDING_DECISIONS.md: 红娘专项六项决策标已落地;新增两项待产品确认——受控联系授权机制开放(OQ-003 引用)、C 端死代码退役 3 项(getCandidates/cancelService/getRecommendationsForUser,grep 零调用已核)专项治理批次

ZC-MM-001~007 全链路闭环(架构师落盘 + 主理人 grep 锚点终验:DEC-MATCHMAKER/DEFERRED/LEGAL_VERIFY/OQ-003~008 全命中,零代码变更)
2026-10-11 10:26:35 +08:00
WorkBuddy 1be0d98ebd ZC-MM-006 审计与权限回收(阶段4)
- T01: V055 audit_logs 扩展 4 字段(purpose_code/service_assignment_id/actor_role_snapshot/request_trace_id, 全可空幂等) + AuditLog/DataScopeGuard/AuthorizationAuditListener/MatchmakerServiceService 写入链路改造 + schema-h2 同步 + LATEST_MIGRATION 54→55
- T02: JwtAuthFilter 运营分支改实时查库权限回收(每请求查 operators.status + 权限, 停用即401/撤权即403, 零缓存, claims 权限降级只写不读), 不碰 SecurityConfig/JwtTokenService/用户端
- T03: getServiceDetail 持有人分支接入 SensitiveAccessLog(成功查看落行 dataType=matchmaker_service_detail, 拒绝路径只落 audit_logs DATA_SCOPE_DENIED, 双记录结构性互斥)
- T04: 写接口 404 统一防探测(transfer/revoke/end/recommend/followup/complete/转介范围违规 404 化 deny, 先审计后 404, create/assign 无资源寻址维持 403, ReferralService 消灭裸 IllegalArgumentException)
- T05: OperatorEndpointPermissionCoverageTest +3 守卫(类级 RequestMapping 前缀合成/缺类级则逐方法 PreAuthorize/matchmaker+referral 禁 PatchMapping) + run-backend-contracts CURATED 14→18
- 新增测试 4 类 20 用例: MatchmakerAuditFieldsTest / OperatorPermissionRevocationTest / MatchmakerSensitiveAccessAuditTest / MatchmakerWriteDenyUniformityTest
- 验证: mvn verify 884/884(3 skip) + UI 契约 24/24 + 后端契约门禁 198 通过; 负向注入 5(工程师)+2(QA) 判红→还原复绿; QA fresh eyes 独立验收 PASS(0 P0/P1, 2 P2 观察项移交 ZC-MM-007)
- ContactAuthorizationPolicy 维持 DEFERRED; C 端死代码退役评估 3 项留待 007/专项治理
2026-10-11 09:19:01 +08:00
WorkBuddy 5e484d0e74 test(matchmaker,referral): ZC-MM-003/004 纯护栏——B1 联系规则锁定 + 禁止代用户决定固化
依据《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》DEC-MATCHMAKER-003(B1
平台内优先、授权例外)与 DEC-MATCHMAKER-004(红娘不得代替用户执行交友决策)。
本阶段生产代码零改动,交付物为护栏测试(MM-CONTACT-001~004、MM-ACTION-001~003
全部有自动化用例)。

MatchmakerContactGuardTest(4 用例,standalone 源码扫描,FieldPrivacyGuardTest 范式):
- MM-CONTACT-002:ReferralService 全部响应 put 键 ⊆ 16 键白名单(逐键对账吻合),
  禁词 {emergencycontact,phone,mobile,wechat,weixin,address,idnumber,id_card}——
  锁定红娘侧零联系方式回显(紧急联系人信息纯入口 write-only,连红娘本人都无读回面);
  含防清空自检(三关键键必须在场)
- MM-CONTACT-003:红娘/转介 Controller 无 contact/call/phone/channel/offline 端点;
  全仓无 ContactAuthorization 类、无 matchmaker_contact_authorizations 迁移表——
  锁定「无授权机制」;未来引入联系功能必须连同本守卫+治理同步更新
- MM-CONTACT-004:机制不存在 ⇒ 无授权到期场景(与 003 同源,未来引入同时变红)
- 004 佐证:matchmaker/referral 包 @Scheduled=0(无批处理代决策面)

MatchmakerActionGuardTest(6 用例,@SpringBootTest 行为锁定):
- MM-ACTION-001(3 例负向):operator token 调用户决策端点
  (/matchmaker/recommendations/{id}/accept、/pass、/referrals/{id}/confirm)→ 403
  且业务状态零变化(403 来源于 SecurityConfig hasRole("USER") 身份类型防线,
  与 PermissionService mock 无关,变异验证锚定真实)
- MM-ACTION-002:operator token 开启会话端点 → 403
- MM-ACTION-003 must-confirm 语义锁定:红娘代建转介 → needsUserConfirmation=true/
  userConfirmedAt=null/status=pending,后续 recommend/followup/complete 不改确认态
  (「代填草稿、用户确认」灰色点永久锁定)
- MM-CONTACT-001(正向):红娘对本人分配对象创建推荐/转介 → 平台内服务通知落库,
  title/content 无手机号/微信模式,红娘 operatorId 不在通知收件面

负向注入自证:
- 注入 A:ReferralService 临时加 emergencyContactPhoneEnc 回显 → 守卫三重命中判红 → 还原
- 注入 B:SecurityConfig hasRole("USER")→authenticated() → 4/4 负向用例判红
  (403→401/500)→ 还原复绿

验证:定向 42/42、全量 861/861(3 预存 skip)、UI 契约 24/24。
003 方案 A(纯测试锁定)决策记录与五项 LEGAL_VERIFY open questions 随 ZC-MM-007 登记;
ContactAuthorizationPolicy 桩 + matchmaker_contact_authorizations 表推迟至 ZC-MM-006
(仅当产品确认开放受控联系时立项)。
2026-10-10 14:46:53 +08:00
WorkBuddy 3caf5f8816 feat(matchmaker,referral): ZC-MM-005 统一数据范围与字段隐私(DEC-MATCHMAKER-005/§5.2 落地)
依据《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》DEC-MATCHMAKER-005 最小必要原则
与 §5.2 字段级隐私,ZC-MM-005 范围=「读接口行级数据范围统一接入判定器 + 字段白名单 +
组长团队脱敏统计」。承接 ZC-MM-002 落地的 matchmaker_assignments 事实源与
ServiceAssignmentPolicy 判定器。

E1 双轨切纯判定器(不留回退):
- assertServiceAccess 纯化(删 assigned_operator_id 回退分支),判定器成唯一事实源
- getServiceDetail 删 viewerIsOwner 双态与「非负责人脱敏详情」整段

E2 非负责人防探测 404:
- ServiceAssignmentPolicy.assertActiveAssignmentOrNotFound:无生效分配→先落
  DATA_SCOPE_DENIED 审计再抛 NOT_FOUND(响应 404),存在性不可枚举,
  与「查无此单」响应体逐字段一致(MM-PRIVACY-001)

E3 字段白名单显式化 + 用户隐私开关:
- 抽取 assembleMatchmakerUserProfile:仅 7 键(displayName/gender/age 派生/regionName/
  educationCode/occupationCode/maritalStatus),无 birthDate 原值/详细地址/手机号/微信/
  身份证;接 UserPrivacySettings.showOccupation(缺省全放行)

E4 死端点清理:
- 删除 GET /operator/matchmaker/my-services(operatorId 误当 user_id、恒空、前后端零调用),
  替代路径 /operator/matchmaker/assignments/my;C 端 /matchmaker/services 与
  counseling myServices 保留

P1 残留收口(读/写路径仍信任废弃 assigned_operator_id 残留列,与 E1 同类洞):
- P1-1 运营端推荐列表:MatchmakerRecommendationMapper.findByOperatorId 改 join
  生效分配(active+expires),撤/结后不再可见
- P1-2 dashboard 三计数:count*ByOperator 改 join 生效分配 + COUNT(DISTINCT) 防重,
  end/revoke 后不再计入(DEC-002 规则7)
- P1-3 转介授权:ReferralService.assertMatchmakerServesUser 切
  serviceAssignmentPolicy.assertActiveAssignment(DATA_SCOPE_DENIED 审计 + 403),
  删残留列查询 findActiveByOperatorAndUser

新端点(组长团队脱敏统计):
- GET /operator/matchmaker/team/statistics(@PreAuthorize matchmaker.team.assign):
  本组织 active 分配数/按红娘分组/在岗红娘数,纯计数无用户 PII;org 边界
  IS NOT DISTINCT FROM(NULL=NULL 平台直属)

P2-4 operatorName 脱敏决策:保留不脱敏(§5.2 字段分类:红娘姓名=团队范围内
工作人员标识非用户 PII;组长统计本团队工作量属管理职责),依据登记
docs/governance/open-questions.md OQ-MATCHMAKER-002(RESOLVED)

死代码清理:getOperatorServices(零调用)+ MatchmakerServiceMapper.findByOperatorId
(残留列查询,调用清零)删除;C 端 MatchmakerService 遗留信任残留列按豁免未动(006/007 退役)

测试:
- MatchmakerDataScope005Test(13 用例):MM-PRIVACY-001/002、MM-AUTH-001/003 读侧、
  MM-LIFECYCLE-001 读侧、P1-1/2/3 残留收口负向(撤/结后推荐不可见/计数归零/转介 403)、
  防探测逐字段一致、showOccupation 三态
- MatchmakerFieldPrivacyGuardTest(3 用例):字段白名单源码扫描 + 无导出端点锁定
- MatchmakerReferralPhase13Test 转介口径收紧(IllegalStateException→AccessDeniedException)
- 负向注入自证(P1-3 残留列变异→判红→恢复复绿)

验证:定向 73/73;全量 851/851(3 预存 skip);UI 契约 24/24。
2026-10-10 11:51:15 +08:00
WorkBuddy 861c78efb5 feat(matchmaker): ZC-MM-002 A1 服务对象分配建模(DEC-MATCHMAKER-002 LOCKED 落地)
数据库(V053/V054):
- 新表 matchmaker_assignments:独立分配关系(assigned_by/reason/scope/
  expires_at/ended_at/end_reason),状态机 active/ended/revoked/transferred/
  expired(CHECK 白名单),部分唯一索引防同用户多 ACTIVE 主责分配,
  存量进行中服务单幂等回填
- 新角色 matchmaker_leader(014) + 新权限 matchmaker.team.assign(10000000-…-023);
  组长授 view/service/team.assign(不给平台级 matchmaker.assign)

后端:
- ServiceAssignmentPolicy 有效分配判定器(读前惰性过期,失效分配即时拒绝)
- 分配/转分配/撤销/结束四操作:状态机非法转换拒绝、理由必填、
  同用户主责冲突拒绝;组织范围校验(team.assign 同组织,NULL=NULL 视为
  平台直属,技术默认安全值待治理登记);transfer 同时校验原持有人与新目标范围
- 全操作审计 MATCHMAKER_ASSIGNMENT_CREATE/TRANSFER/REVOKE/END,
  operatorId=当前操作者,afterData 仅记摘要(无敏感内容)
- 删除认领端点 POST /services/{id}/claim(A1 禁止普通红娘认领,MM-AUTH-004=404)
- GET /pending 收敛为分配权持有者(assign or team.assign);
  dashboard today/completed 改本人口径,inProgress 口径修正 'processing'→'in_service'
- recommend/followup/complete/detail 归属判定接入判定器,拒绝走
  DataScopeGuard(自动 DATA_SCOPE_DENIED 审计),不再裸 IllegalStateException;
  assignService 与创建分配同事务联动;删除死查询 findAssignedTo

前端:
- admin-web 分配弹窗去硬编码假数据(operator_wang/chen)→ assignable-matchmakers
  真实接口(仅 id/name/organizationId,无联系方式)+ 理由必填;
  队列 tab 按 assign/team.assign 显隐;新增「服务对象分配」tab(转分配/撤销/结束)
- 小程序红娘工作台:删除接单入口,数据源切 GET /operator/matchmaker/assignments/my,
  枚举码不上屏(中文映射),空 serviceId 不跳转

测试:
- MatchmakerAssignmentA1Test 14 用例(MM-AUTH-001~004、MM-AUDIT-001、
  MM-LIFECYCLE-001、组长跨组织拒绝、NULL=NULL 平台直属边界、transfer 原持有人
  范围负向、/pending 403+审计),负向注入自证有效
- MatchmakerAssignmentPermissionTest 3 用例(端点权限契约 + claim 残留扫描)
- MatchmakerServiceTest 补 assignment 状态/scope 白名单守卫;迁移基线 LATEST=54
- 验证:定向 38/38 全绿;UI 契约 24/24;小程序 wcc/wcsc 真编译 0 失败
2026-10-09 22:46:32 +08:00
WorkBuddy 6187ec69df test(ui-guards): 导航契约守卫基线对齐后端真实权限码
- admin-role-navigation-contracts.mjs L40-41 原断言 referral.manage /
  matchmaker.service.view,但后端 permissions 表不存在这两个幽灵码——
  v1.1.1 权限码对齐(4edb761)时漏更新守卫基线,导致该守卫自 v1.1.1 起恒红
- 修正为后端真实 authority 码:/referral→matchmaker.service、
  /matchmaker→matchmaker.view(守卫内容修正,非删守卫,MIN_GUARDS 不变)
- 实测 UI 契约 24/24 全绿
2026-10-09 22:46:32 +08:00
WorkBuddy 2d60f7899f docs(audit): ZC-MM-001 红娘角色权限差距审计报告
- 基准:《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》(DEC-MATCHMAKER-001~006
  全部 LOCKED,A1/B1 已确认)
- 审计范围:backend matchmaker/referral/audit/security/auth、Flyway V001~V052、
  admin-web 红娘页面、现有测试、治理文档;全部结论带文件:行号证据
- 核心结论:A1 分配关系未建模(分配=工单指派)、claim/followup/complete 无归属校验、
  /pending 全局队列对全体红娘可见、无红娘组长角色、模块内越权拒绝不落审计
- P0×5 / P1×6 / P2×6 差距清单 + 可复用模块(CounselingService INV-012 范式、
  DataScopeGuard、recordSensitiveAccess)+ ZC-MM-002~007 实施顺序建议
- 首次建立 docs/audits/ 目录
2026-10-09 22:46:32 +08:00
WorkBuddy f76bda1efa fix(miniapp): 开发态 API base URL 由 localhost 改为 127.0.0.1
- 规避模拟器 localhost→::1 的 IPv6 解析歧义导致的 ERR_CONNECTION_REFUSED
  (onLaunch 全量启动拉取 /app/config 失败后不自动重试,热重载不重跑)
- 生产 base 不变;仅 env.ts/env.js 的 isDev 分支
2026-10-09 22:46:31 +08:00
67 changed files with 7002 additions and 257 deletions
@@ -3,7 +3,8 @@
<el-card>
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
<el-tabs v-model="activeTab">
<el-tab-pane label="服务请求队列" name="services">
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
<el-table :data="services" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="160">
@@ -19,7 +20,7 @@
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
</template>
</el-table-column>
</el-table>
@@ -38,16 +39,77 @@
</el-table-column>
</el-table>
</el-tab-pane>
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
<el-tab-pane label="服务对象分配" name="assignments">
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="assignmentScope" label="范围" width="90">
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
</el-table-column>
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="assignedAt" label="分配时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
</el-table-column>
<el-table-column prop="expiresAt" label="有效期至" width="170">
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
</template>
</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>
<!-- 分配弹窗 -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
<el-form label-width="80px">
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
<el-option label="王红娘" value="operator_wang" />
<el-option label="陈红娘" value="operator_chen" />
</el-select></el-form-item>
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
<el-form label-width="90px">
<el-form-item label="红娘">
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select>
</el-form-item>
<el-form-item label="分配理由" required>
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button>
@@ -55,6 +117,37 @@
</template>
</el-dialog>
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
<el-form label-width="90px">
<el-form-item label="新红娘">
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select>
</el-form-item>
<el-form-item label="转分配理由" required>
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
</template>
</el-dialog>
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
<el-form label-width="90px">
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="terminateDialogVisible=false">取消</el-button>
<el-button type="danger" @click="doTerminate">确认</el-button>
</template>
</el-dialog>
<!-- 创建推荐弹窗 -->
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
<el-form label-width="100px">
@@ -71,18 +164,35 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
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 activeTab = ref('services')
const authStore = useAuthStore()
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
const canAssign = computed(() => {
const perms: string[] = authStore.permissions || []
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
})
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
const loading = ref(false)
const services = ref<any[]>([])
const recommendations = ref<any[]>([])
const assignments = ref<any[]>([])
const assignmentsLoading = ref(false)
const assignableMatchmakers = ref<any[]>([])
const assignDialogVisible = ref(false)
const recDialogVisible = ref(false)
const transferDialogVisible = ref(false)
const terminateDialogVisible = ref(false)
const currentServiceId = ref('')
const assignForm = ref({ operatorId: '' })
const currentUserId = ref('')
const currentAssignmentId = ref('')
const terminateAction = ref<'revoke' | 'end'>('revoke')
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
const terminateForm = ref({ reason: '' })
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = {
@@ -90,10 +200,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
referral_activity: '转介活动', referral_station: '转介服务站'
}
const STATUS_LABEL: Record<string, string> = {
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
processing: '处理中', completed: '已完成', cancelled: '已取消'
}
const STATUS_TAG: Record<string, string> = {
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
processing: '', completed: 'success', cancelled: 'danger'
}
const REC_STATUS_LABEL: Record<string, string> = {
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
@@ -103,18 +215,79 @@ const REC_STATUS_TAG: Record<string, string> = {
pending_view: 'warning', viewed: 'info', liked: 'primary',
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
}
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
}
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' }
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || 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
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
}
async function loadRecommendations() {
@@ -123,19 +296,81 @@ async function loadRecommendations() {
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
}
async function loadAssignments() {
assignmentsLoading.value = true
// ZC-MM-002:本人名下生效中的服务对象分配
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
}
async function loadAssignableMatchmakers() {
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
}
async function refresh() {
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
if (activeTab.value === 'services') await loadServices()
else if (activeTab.value === 'assignments') await loadAssignments()
else if (activeTab.value === 'contactAuths') await loadContactAuths()
else await loadRecommendations()
}
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
async function openAssign(row: any) {
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
currentServiceId.value = row.serviceId || row.id
currentUserId.value = row.userId
assignForm.value = { matchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
assignDialogVisible.value = true
}
async function doAssign() {
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
await post('/operator/matchmaker/assignments', {
userId: currentUserId.value,
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
serviceId: currentServiceId.value,
reason: assignForm.value.reason.trim()
})
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function openTransfer(row: any) {
currentAssignmentId.value = row.id
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
transferDialogVisible.value = true
}
async function doTransfer() {
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
reason: transferForm.value.reason.trim()
})
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openTerminate(row: any, action: 'revoke' | 'end') {
currentAssignmentId.value = row.id
terminateAction.value = action
terminateForm.value = { reason: '' }
terminateDialogVisible.value = true
}
async function doTerminate() {
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
}
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
reason: terminateForm.value.reason.trim()
})
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openRecommendation(row: any) {
currentServiceId.value = row.id
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
+3 -1
View File
@@ -31,6 +31,8 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || [])
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
const hasPermission = (code: string) => permissions.value.includes(code)
function applyAuthData(data: any) {
token.value = data.accessToken || ''
@@ -87,5 +89,5 @@ export const useAuthStore = defineStore('auth', () => {
clearAccessToken()
}
return { token, operator, isLoggedIn, permissions, login, logout, initializeSession }
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
})
+1 -1
View File
@@ -13,7 +13,7 @@
<groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId>
<version>1.1.0</version>
<version>1.2.0</version>
<name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
@@ -19,4 +19,15 @@ public class AuditLog extends BaseEntity {
private String afterData;
private String ip;
private String userAgent;
// ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ====================
/** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */
private String purposeCode;
/** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */
private String serviceAssignmentId;
/** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */
private String actorRoleSnapshot;
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
private String requestTraceId;
}
@@ -52,6 +52,11 @@ public class AuditLogService {
m.put("ip", log.getIp());
m.put("userAgent", log.getUserAgent());
m.put("createdAt", log.getCreatedAt());
// ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容)
m.put("purposeCode", log.getPurposeCode());
m.put("serviceAssignmentId", log.getServiceAssignmentId());
m.put("actorRoleSnapshot", log.getActorRoleSnapshot());
m.put("requestTraceId", log.getRequestTraceId());
return m;
}).collect(java.util.stream.Collectors.toList());
@@ -73,11 +78,23 @@ public class AuditLogService {
*/
@Transactional
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
recordSensitiveAccess(operatorId, resourceType, resourceId, null, null);
}
/**
* ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。
* 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。
*/
@Transactional
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId,
String purposeCode, String serviceAssignmentId) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction("SENSITIVE_ACCESS");
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
HttpServletRequest req = attrs.getRequest();
auditLog.setIp(req.getRemoteAddr());
@@ -101,6 +101,9 @@ public class OperatorAuthService {
.toList();
// 签发 Token
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
Map<String, Object> claims = new HashMap<>();
claims.put("operatorId", operator.getId());
claims.put("organizationId", operator.getOrganizationId());
@@ -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()));
}
}
@@ -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;
}
@@ -0,0 +1,149 @@
package com.danyang.match.platform.matchmaker.controller;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
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.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 服务对象分配接口(运营端,ZC-MM-002 / A1)。
*
* <p>权限模型(D2):
* <ul>
* <li>写操作(创建/转分配/撤销/结束/可分配列表):
* {@code matchmaker.assign}(平台授权人员,不限组织)
* 或 {@code matchmaker.team.assign}(红娘组长,同组织范围);</li>
* <li>读自己名下分配(/my):{@code matchmaker.service}(普通红娘即可)。</li>
* </ul>
* operatorId 一律取当前认证主体(principal),不信任请求参数。
*/
@RestController
@RequestMapping("/operator/matchmaker/assignments")
@RequiredArgsConstructor
public class MatchmakerAssignmentController {
private final MatchmakerAssignmentService assignmentService;
/**
* 创建分配。
* Body: userId / matchmakerOperatorId / serviceId? / reason(必填)/ expiresAt?(ISO-8601)
*/
@PostMapping
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> create(@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, String> body) {
MatchmakerAssignment created = assignmentService.create(
principal.id(),
principal.hasPermission("matchmaker.assign"),
body.get("userId"),
body.get("matchmakerOperatorId"),
body.getOrDefault("serviceId", null),
body.get("reason"),
parseInstant(body.get("expiresAt")));
return ApiResponse.ok(assignmentView(created));
}
/** 转分配:旧记录置 transferred,同事务新建 active。reason 必填。 */
@PostMapping("/{id}/transfer")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> transfer(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
MatchmakerAssignment created = assignmentService.transfer(
id,
principal.id(),
principal.hasPermission("matchmaker.assign"),
body.get("newMatchmakerOperatorId"),
body.get("reason"));
return ApiResponse.ok(assignmentView(created));
}
/** 撤销分配。reason 必填。 */
@PostMapping("/{id}/revoke")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> revoke(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(assignmentView(assignmentService.revoke(
id, principal.id(), principal.hasPermission("matchmaker.assign"),
body.get("reason"))));
}
/** 结束分配。reason 必填。 */
@PostMapping("/{id}/end")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> end(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(assignmentView(assignmentService.end(
id, principal.id(), principal.hasPermission("matchmaker.assign"),
body.get("reason"))));
}
/** 我名下生效中的服务对象分配(普通红娘工作台)。 */
@GetMapping("/my")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<List<Map<String, Object>>> my(
@AuthenticationPrincipal OperatorPrincipal principal) {
List<Map<String, Object>> list = assignmentService.listMy(principal.id()).stream()
.map(this::assignmentView)
.toList();
return ApiResponse.ok(list);
}
/**
* 可分配红娘列表(创建/转分配弹窗数据源)。
* 返回 id/name/organizationId,不含联系方式(字段级隐私,ZC-MM-005 范围)。
*/
@GetMapping("/assignable-matchmakers")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> assignable(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(assignmentService.listAssignableMatchmakers(
principal.id(), principal.hasPermission("matchmaker.assign")));
}
/**
* 组长团队统计(ZC-MM-005 / E4 新增)。仅 matchmaker.team.assign 持有者可调;
* 以组长本人 organization_id 为边界(NULL 组长统计平台直属)。
* 返回纯脱敏计数:activeAssignmentCount / perMatchmakerCounts / activeMatchmakerCount,
* 不含任何用户身份或联系方式。
*/
@GetMapping("/team/statistics")
@PreAuthorize("hasAuthority('matchmaker.team.assign')")
public ApiResponse<Map<String, Object>> teamStatistics(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(assignmentService.teamStatistics(principal.id()));
}
private Instant parseInstant(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
return Instant.parse(raw);
}
private Map<String, Object> assignmentView(MatchmakerAssignment a) {
return Map.ofEntries(
Map.entry("id", a.getId()),
Map.entry("userId", a.getUserId()),
Map.entry("matchmakerOperatorId", a.getMatchmakerOperatorId()),
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
Map.entry("assignmentScope", a.getAssignmentScope()),
Map.entry("status", a.getStatus()),
Map.entry("assignmentReason", a.getAssignmentReason()),
Map.entry("assignedAt", a.getAssignedAt() == null ? "" : a.getAssignedAt().toString()),
Map.entry("expiresAt", a.getExpiresAt() == null ? "" : a.getExpiresAt().toString()),
Map.entry("endedAt", a.getEndedAt() == null ? "" : a.getEndedAt().toString()),
Map.entry("endReason", a.getEndReason() == null ? "" : a.getEndReason())
);
}
}
@@ -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));
}
}
@@ -13,6 +13,11 @@ import java.util.Map;
/**
* 红娘工作台控制器(Operator调用)
*
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
* 服务单状态由分配动作同事务联动(submitted → assigned)。
*/
@RestController
@RequestMapping("/operator/matchmaker")
@@ -29,42 +34,57 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId));
}
/**
* 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者
* (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列,
* 普通红娘(仅 matchmaker.view/service)403。
*
* <p>组长视角说明(ZC-MM-005 / E4,治理登记 007):matchmaker_services 表无组织归属字段
* (organization_id 恒 null 的已知限制),因此待分配队列无法按组长组织过滤,
* 权衡后对分配权持有者全局可读 —— 待分配服务单尚无负责人,队列不含敏感分配关系。
* 组织归属字段的补齐登记至 ZC-MM-007。
*/
@GetMapping("/pending")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> getPending(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(matchmakerServiceService.getPendingServices());
}
/**
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
*/
@PostMapping("/services/{id}/assign")
@PreAuthorize("hasAuthority('matchmaker.assign')")
public ApiResponse<?> assignService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = principal.id();
String assignOperatorId = body.get("operatorId");
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null));
String assignerOperatorId = principal.id();
String targetOperatorId = body.get("operatorId");
String reason = body.getOrDefault("reason", "红娘服务单分配");
return ApiResponse.ok(matchmakerServiceService.assignService(
id, assignerOperatorId, targetOperatorId, reason));
}
@PostMapping("/services/{id}/claim")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<?> claimService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
String operatorId = principal.id();
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
}
@GetMapping("/my-services")
public ApiResponse<?> getMyServices(
@AuthenticationPrincipal OperatorPrincipal principal) {
String operatorId = principal.id();
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
}
// GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
// 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
// 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
// GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
// getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
/**
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
* 红娘名下服务的全部推荐列表(运营端工作台用)。
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
*
* <p>口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments}
* 中本人<b>生效中的分配</b>(active 且未过有效期)→ 该分配对象(user)名下
* 服务单上的推荐,经 matchmaker_services 桥接;既不按
* matchmaker_services.assigned_operator_id 残留列过滤,也<b>不</b>按
* recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status
* 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见
* (DEC-002 规则7:撤销即失权)。
*/
@GetMapping("/recommendations")
public ApiResponse<List<Map<String, Object>>> getRecommendations(
@@ -72,16 +92,19 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
}
/**
* 服务详情(ZC-MM-005 / E2 防探测 404)。
*
* <p>语义变更:非持有人与查无 serviceId 抛出完全相同的
* BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致
* (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。
* 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。
*/
@GetMapping("/services/{id}")
public ApiResponse<Map<String, Object>> getServiceDetail(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
if (detail == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
}
return ApiResponse.ok(detail);
return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
}
@PostMapping("/services/{id}/recommend")
@@ -114,7 +137,8 @@ public class MatchmakerOperatorController {
public ApiResponse<?> completeService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(matchmakerServiceService.completeService(id));
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy(服务层完成)
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
}
}
@@ -0,0 +1,62 @@
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-002 / A1)
* 对应 DB: matchmaker_assignments(V053)
*
* <p>建模说明:
* <ul>
* <li>{@code matchmakerOperatorId}:持有该用户服务对象的红娘(operators 主体,
* 沿用 matchmaker_services.assigned_operator_id 惯例)。</li>
* <li>{@code assignedBy}:做出本次分配的操作者(组长 / 平台授权人员)。</li>
* <li>状态机(D5):active → transferred / revoked / ended;
* expires_at 届满由 {@code ServiceAssignmentPolicy} 惰性置 expired(无定时任务);
* 终态一律拒绝再转换。</li>
* <li>scope:primary(主责,同用户仅一条生效中,DB 部分唯一索引兜底)/
* collaborating(协同)。</li>
* </ul>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_assignments")
public class MatchmakerAssignment extends BaseEntity {
/** 被服务用户 */
private String userId;
/** 持有服务对象的红娘(operators.id) */
private String matchmakerOperatorId;
/** 关联的服务单(可空:允许脱离服务单的用户级分配) */
private String serviceId;
/** 做出本次分配的操作者(operators.id) */
private String assignedBy;
/** 分配范围:primary / collaborating(与 V053 chk_mm_assignment_scope 白名单一致) */
private String assignmentScope;
/** 分配理由(必填) */
private String assignmentReason;
/** 状态:active/ended/revoked/transferred/expired(与 V053 chk_mm_assignment_status 白名单一致) */
private String status;
private Instant assignedAt;
/** 有效期届满时间;null 表示长期有效 */
private Instant expiresAt;
/** 终态时间(ended/revoked/transferred/expired 时落库) */
private Instant endedAt;
/** 终态理由(revoked/ended 必填;transferred 记录转分配理由;expired 为固定文案) */
private String endReason;
}
@@ -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;
}
@@ -0,0 +1,100 @@
package com.danyang.match.platform.matchmaker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
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.Map;
import java.util.Optional;
/**
* 服务对象分配 Mapper(ZC-MM-002 / A1)
*
* <p>「生效中」的统一口径:status = 'active' 且(expires_at IS NULL OR expires_at &gt; NOW())。
* 惰性过期由 {@link #expireOverdue} 负责(读前触发,无定时任务),
* 这里的 expires 条件是双保险:即使惰性过期尚未执行,已届满的行也不会被当作生效中。
*/
@Mapper
public interface MatchmakerAssignmentMapper extends BaseMapper<MatchmakerAssignment> {
@Select("SELECT * FROM matchmaker_assignments WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findById(@Param("id") String id);
/** 某红娘名下全部生效中的分配(GET /my、GET /assignable 的工作台口径) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findActiveByOperator(@Param("operatorId") String operatorId);
/** 某红娘对某用户是否持有生效中的分配(数据范围判定核心查询) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND user_id = #{userId} AND status = 'active' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
/** 某用户当前生效中的主责分配(创建/转分配前的唯一性预检) */
@Select("SELECT * FROM matchmaker_assignments WHERE user_id = #{userId} " +
"AND status = 'active' AND assignment_scope = 'primary' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findActivePrimaryByUser(@Param("userId") String userId);
/** 某服务单上生效中的分配(服务单维度的归属判定) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByService(@Param("serviceId") String serviceId);
/** 某服务单的全部分配历史(追溯用) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findByService(@Param("serviceId") String serviceId);
/**
* 惰性过期:把 expires_at 已届满的 active 行置为 expired 并落库。
* 由 {@code ServiceAssignmentPolicy} 在读取路径前触发,无定时任务。
*
* @return 本次被置为 expired 的行数
*/
@Update("UPDATE matchmaker_assignments SET status = 'expired', " +
"ended_at = NOW(), end_reason = '有效期届满自动失效', updated_at = NOW() " +
"WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() " +
"AND deleted_at IS NULL")
int expireOverdue();
// ==================== ZC-MM-005 / E4:组长团队统计(纯脱敏计数) ====================
// 组织边界:organization_id IS NOT DISTINCT FROM #{organizationId} ——
// NULL=NULL 平台直属规则在 SQL 层等价复用(有组织组长统计本组织;NULL 组长统计平台直属)。
/** 某组织范围内生效中的分配总数 */
@Select("SELECT COUNT(*) FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
long countActiveByOrganization(@Param("organizationId") String organizationId);
/** 某组织范围内各红娘的生效分配数(operatorId/name/activeCount;别名加引号保持驼峰键名) */
@Select("SELECT o.id AS \"operatorId\", o.name AS \"operatorName\", COUNT(*) AS \"activeCount\" " +
"FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId} " +
"GROUP BY o.id, o.name ORDER BY \"activeCount\" DESC")
List<Map<String, Object>> countActiveGroupByOperator(@Param("organizationId") String organizationId);
/** 某组织范围内持有生效分配的红娘数(去重) */
@Select("SELECT COUNT(DISTINCT ma.matchmaker_operator_id) FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
long countActiveOperators(@Param("organizationId") String organizationId);
}
@@ -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();
}
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
int markViewed(@Param("id") String id);
/**
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
* 避免运营端越权看到他人推荐。
* 某红娘推荐列表(运营端工作台用)。
*
* <p>ZC-MM-005 收口(P1-1):范围事实源由 matchmaker_services.assigned_operator_id
* 残留列切换为 matchmaker_assignments 生效中分配(active 且未过有效期)。
* 语义 = 「本人当前有效分配对象(user)名下服务单上的推荐」—— 分配 revoke/end 后
* 残留列不再构成可见凭据(DEC-002 规则7:撤销即失权)。
*/
@Select("SELECT r.* FROM matchmaker_recommendations r " +
"JOIN matchmaker_services s ON s.id = r.service_id " +
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
"JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"ORDER BY r.created_at DESC")
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
}
@@ -26,35 +26,51 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
long countSubmitted();
/** 某红娘进行中的服务数(工作台统计) */
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND status = 'processing' AND deleted_at IS NULL")
/**
* 某红娘进行中的服务数(工作台统计)。
* ZC-MM-005 收口(P1-2):范围事实源由 assigned_operator_id 残留列切换为
* matchmaker_assignments 生效中分配(active 且未过有效期)——「本人名下进行中」
* = 本人 active 分配关联 user 的服务单中 status='in_service'。
* 分配 end/revoke 后,该用户的服务单不再计入本人(DEC-002 规则7:服务结束失去访问权)。
* 状态口径沿用 ZC-MM-002(D4)修正:'in_service'('processing' 不在 V009 白名单)。
*/
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE s.status = 'in_service' AND s.deleted_at IS NULL")
long countInProgressByOperator(@Param("operatorId") String operatorId);
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-005 收口 / P1-2:按生效分配 join) */
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE CAST(s.assigned_at AS DATE) = CURRENT_DATE AND s.deleted_at IS NULL")
long countTodayByOperator(@Param("operatorId") String operatorId);
/**
* 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-005 收口 / P1-2)。
* 口径说明(与 E1「服务结束失去访问权」一致):按当前生效分配统计 ——
* 分配 end/revoke 后,该用户的历史 completed 服务单<b>不再计入</b>本人完成数;
* 仅当分配仍生效时,其名下 completed 单才计入。
*/
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE s.status = 'completed' AND s.deleted_at IS NULL")
long countCompletedByOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
/**
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
*/
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " +
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
/**
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。
* 状态口径(submitted/assigned/in_service/waiting_user)。
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
*/
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,406 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
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.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 服务对象分配服务(ZC-MM-002 / A1,架构师设计 D5)。
*
* <p>状态机矩阵(status ∈ active/ended/revoked/transferred/expired):
* <pre>
* active → transferred(transfer:旧记录置 transferred + end_reason,同事务新建 active)
* active → revoked (撤销,end_reason 必填)
* active → ended (结束,end_reason 必填)
* 终态(ended/revoked/transferred/expired)一律拒绝再转换 → CONFLICT
* </pre>
*
* <p>权限模型(D2):
* <ul>
* <li>{@code matchmaker.assign}:平台授权人员,不限组织;</li>
* <li>{@code matchmaker.team.assign}:红娘组长,团队边界 = 同 organization_id。</li>
* </ul>
* 组织边界规则(技术默认,治理登记后续补充):
* <b>NULL=NULL 视为平台直属</b> —— 平台直属组长只能分配平台直属红娘;
* 有组织组长不能分配 org=NULL 红娘;org=NULL 普通红娘只能由 matchmaker.assign 持有者分配。
* 范围违规统一走 {@link DataScopeGuard#deny}(自动落 DATA_SCOPE_DENIED 审计,403)。
*
* <p>唯一性:同 user_id + primary scope 同时最多一条 active(DB 部分唯一索引兜底),
* 业务层预检 + 捕获 DuplicateKeyException 双保险,均映射 CONFLICT。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class MatchmakerAssignmentService {
private static final String RESOURCE_TYPE = "matchmaker_assignment";
private final MatchmakerAssignmentMapper assignmentMapper;
private final MatchmakerServiceMapper serviceMapper;
private final OperatorMapper operatorMapper;
private final DataScopeGuard dataScopeGuard;
private final AuditLogMapper auditLogMapper;
/** 读路径复用判定器(含惰性过期);Policy 不反向依赖本 Service,无环。 */
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
/**
* 创建分配(primary)。含可选服务单联动:serviceId 非空时同事务把服务单置为 assigned。
*
* @param assignerOperatorId 当前操作者(分配人)
* @param hasGlobalAssign 操作者是否持有 matchmaker.assign(true=不限组织)
* @param userId 被服务用户
* @param matchmakerOperatorId 目标红娘
* @param serviceId 关联服务单(可空)
* @param reason 分配理由(必填)
* @param expiresAt 有效期(可空=长期)
*/
@Transactional
public MatchmakerAssignment create(String assignerOperatorId, boolean hasGlobalAssign,
String userId, String matchmakerOperatorId,
String serviceId, String reason, Instant expiresAt) {
requireReason(reason, "分配理由必填");
Operator target = requireActiveOperator(matchmakerOperatorId, "目标红娘不存在");
// create/assign 类无资源寻址端点维持 403(ZC-MM-006 / T04 口径)
assertAssignScope(assignerOperatorId, hasGlobalAssign, matchmakerOperatorId,
"组长只能分配同组织的红娘(NULL=NULL 平台直属)", matchmakerOperatorId, false);
assignmentMapper.findActivePrimaryByUser(userId).ifPresent(existing -> {
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配,请先转分配或撤销");
});
if (serviceId != null && !serviceId.isBlank()) {
linkServiceRow(serviceId, matchmakerOperatorId);
}
MatchmakerAssignment assignment = new MatchmakerAssignment();
assignment.setUserId(userId);
assignment.setMatchmakerOperatorId(matchmakerOperatorId);
assignment.setServiceId(serviceId);
assignment.setAssignedBy(assignerOperatorId);
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
assignment.setAssignmentReason(reason);
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
assignment.setAssignedAt(Instant.now());
assignment.setExpiresAt(expiresAt);
try {
assignmentMapper.insert(assignment);
} catch (DuplicateKeyException e) {
// 并发竞态兜底:uq_mm_assignment_active_primary 部分唯一索引拒绝第二条 active+primary
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配(并发创建被唯一索引拒绝)");
}
log.info("服务对象分配创建: assignmentId={}, userId={}, matchmakerOperatorId={}, by={}",
assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(),
auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason));
return assignment;
}
/**
* 转分配(active → transferred + 同事务新建 active)。
* 旧记录置 transferred + end_reason,新记录 assigned_by = 当前操作者。
*/
@Transactional
public MatchmakerAssignment transfer(String assignmentId, String assignerOperatorId,
boolean hasGlobalAssign, String newMatchmakerOperatorId,
String reason) {
requireReason(reason, "转分配理由必填");
MatchmakerAssignment old = requireAssignment(assignmentId);
assertActiveForTransition(old, "转分配");
// 防御纵深(QA P2-1):不仅新目标红娘要在操作者范围内,
// 被转记录的**原持有人**也必须在范围内(与 terminate 路径同口径;
// 否则 org=X 组长可把 org=Y 红娘持有的分配"顺手"转走)。
// ZC-MM-006 / T04:transfer 为资源寻址写端点,范围违规 404 化(防探测)。
assertAssignScope(assignerOperatorId, hasGlobalAssign, old.getMatchmakerOperatorId(),
"组长只能处置同组织红娘持有的分配(NULL=NULL 平台直属)", assignmentId, true);
requireActiveOperator(newMatchmakerOperatorId, "目标红娘不存在");
assertAssignScope(assignerOperatorId, hasGlobalAssign, newMatchmakerOperatorId,
"组长只能转分配给同组织的红娘(NULL=NULL 平台直属)", assignmentId, true);
// 1) 旧记录置 transferred
old.setStatus(Constants.ASSIGNMENT_STATUS_TRANSFERRED);
old.setEndReason(reason);
old.setEndedAt(Instant.now());
assignmentMapper.updateById(old);
// 2) 同事务新建 active 分配
MatchmakerAssignment created = new MatchmakerAssignment();
created.setUserId(old.getUserId());
created.setMatchmakerOperatorId(newMatchmakerOperatorId);
created.setServiceId(old.getServiceId());
created.setAssignedBy(assignerOperatorId);
created.setAssignmentScope(old.getAssignmentScope());
created.setAssignmentReason(reason);
created.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
created.setAssignedAt(Instant.now());
created.setExpiresAt(old.getExpiresAt());
try {
assignmentMapper.insert(created);
} catch (DuplicateKeyException e) {
// 事务内旧记录已非 active,正常不应触发;仍兜底映射 CONFLICT(整个事务回滚)
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配(并发转分配被唯一索引拒绝)");
}
log.info("服务对象转分配: oldId={}, newId={}, fromUser={}, toOperator={}, by={}",
old.getId(), created.getId(), old.getUserId(), newMatchmakerOperatorId, assignerOperatorId);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, assignmentId,
auditSummary(newMatchmakerOperatorId, created.getAssignmentScope(), reason));
return created;
}
/** 撤销分配(active → revoked,end_reason 必填)。 */
@Transactional
public MatchmakerAssignment revoke(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason) {
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
Constants.ASSIGNMENT_STATUS_REVOKED, Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, "撤销");
}
/** 结束分配(active → ended,end_reason 必填)。 */
@Transactional
public MatchmakerAssignment end(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason) {
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
Constants.ASSIGNMENT_STATUS_ENDED, Constants.AUDIT_ACTION_ASSIGNMENT_END, "结束");
}
/** 我(当前红娘)名下生效中的服务对象分配。读前惰性过期。 */
public List<MatchmakerAssignment> listMy(String operatorId) {
return serviceAssignmentPolicy.findActiveByOperator(operatorId);
}
/**
* 可分配红娘列表(创建分配弹窗数据源)。
* operators.status='active';组长按组织过滤、平台授权人员(matchmaker.assign)返回全部。
* 返回 id/name/organizationId,<b>不含联系方式</b>(字段级隐私,ZC-MM-005 范围)。
*/
public List<Map<String, Object>> listAssignableMatchmakers(String assignerOperatorId,
boolean hasGlobalAssign) {
String assignerOrg = null;
if (!hasGlobalAssign) {
Operator assigner = operatorMapper.findById(assignerOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
assignerOrg = assigner.getOrganizationId();
}
String orgFilter = assignerOrg;
return operatorMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Operator>()
.eq(Operator::getStatus, "active")
.isNull(Operator::getDeletedAt))
.stream()
.filter(op -> hasGlobalAssign || Objects.equals(orgFilter, op.getOrganizationId()))
.map(op -> {
Map<String, Object> item = new HashMap<>();
item.put("id", op.getId());
item.put("name", op.getName());
item.put("organizationId", op.getOrganizationId());
return item;
})
.collect(Collectors.toList());
}
/**
* 组长团队统计(ZC-MM-005 / E4 新增):以组长本人 organization_id 为边界
* (NULL 组长统计平台直属,SQL 侧 IS NOT DISTINCT FROM 复用 NULL=NULL 规则)。
* 返回纯脱敏计数,不含任何用户身份或联系方式:
* <ul>
* <li>activeAssignmentCount —— 组织内生效中的分配总数;</li>
* <li>perMatchmakerCounts —— 各红娘的生效分配数(operatorId/name/activeCount);</li>
* <li>activeMatchmakerCount —— 持有生效分配的红娘数(去重)。</li>
* </ul>
*
* <p><b>字段脱敏决策(P2-4,对照《决策》§5.2 字段分类 + 功能权限矩阵)</b>:
* perMatchmakerCounts 中的 <b>operatorName(红娘姓名)保留、不做脱敏</b>。依据:
* <ol>
* <li>红娘姓名属于「本人/团队范围内的工作人员标识」,<b>不是用户 PII</b>
* (§5.2 隐私清单的禁放字段是 phone/wechat/idNumber 等用户敏感数据);</li>
* <li>组长统计本团队工作量属其管理职责(DEC-MATCHMAKER-002 红娘组长:
* 团队内进度与工作量管理),「查看脱敏服务统计:组长=团队范围」是既有授权口径;</li>
* <li>组长已可经 GET /assignable-matchmakers 拿到同批红娘姓名 —— 此字段
* 非新增泄露面,管理范围 ≠ 用户敏感数据。</li>
* </ol>
* 决策依据同时登记于 docs/governance/open-questions.md(OQ-MATCHMAKER-002),
* 避免后人误判"统计含姓名=越权"。
*/
public Map<String, Object> teamStatistics(String leaderOperatorId) {
Operator leader = operatorMapper.findById(leaderOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
String orgId = leader.getOrganizationId();
Map<String, Object> result = new HashMap<>();
result.put("organizationId", orgId);
result.put("activeAssignmentCount", assignmentMapper.countActiveByOrganization(orgId));
result.put("perMatchmakerCounts", assignmentMapper.countActiveGroupByOperator(orgId));
result.put("activeMatchmakerCount", assignmentMapper.countActiveOperators(orgId));
return result;
}
// ==================== 内部实现 ====================
/** revoke / end 共用的终态迁移(仅允许 active → 终态)。 */
private MatchmakerAssignment terminate(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason,
String targetStatus, String auditAction,
String actionLabel) {
requireReason(reason, actionLabel + "理由必填");
MatchmakerAssignment assignment = requireAssignment(assignmentId);
assertActiveForTransition(assignment, actionLabel);
// 范围校验:操作者必须有权处置该红娘的分配(assign 不限组织 / team.assign 同组织)
// ZC-MM-006 / T04:revoke/end 为资源寻址写端点,范围违规 404 化(防探测)
assertAssignScope(operatorId, hasGlobalAssign, assignment.getMatchmakerOperatorId(),
"组长只能处置同组织红娘的分配(NULL=NULL 平台直属)", assignmentId, true);
assignment.setStatus(targetStatus);
assignment.setEndReason(reason);
assignment.setEndedAt(Instant.now());
assignmentMapper.updateById(assignment);
log.info("服务对象分配{}: assignmentId={}, operatorId={}, by={}",
actionLabel, assignmentId, assignment.getMatchmakerOperatorId(), operatorId);
saveAudit(operatorId, auditAction, assignmentId,
auditSummary(assignment.getMatchmakerOperatorId(), assignment.getAssignmentScope(), reason));
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 一致。
* <b>NULL=NULL 视为平台直属</b>:平台直属组长只能分配平台直属红娘;
* 有组织组长不能分配 org=NULL 红娘。违规统一先落 DATA_SCOPE_DENIED 审计。
*
* <p>拒绝口径(ZC-MM-006 / T04):
* <ul>
* <li>{@code notFoundOnDeny=false}(create/assign 类,无资源寻址)→ 403 AccessDeniedException;</li>
* <li>{@code notFoundOnDeny=true}(transfer/revoke/end,资源寻址写端点)→ 404 化
* {@link DataScopeGuard#denyAsNotFound}(响应与"资源不存在"逐字段一致,防探测)。</li>
* </ul>
*/
private void assertAssignScope(String assignerOperatorId, boolean hasGlobalAssign,
String targetOperatorId, String denyMessage,
String auditResourceId, boolean notFoundOnDeny) {
if (hasGlobalAssign) {
return; // 平台授权人员(matchmaker.assign),不限组织
}
Operator assigner = operatorMapper.findById(assignerOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
Operator target = operatorMapper.findById(targetOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "目标红娘不存在"));
// Objects.equals 的 NULL=NULL → true 语义即「平台直属对平台直属」规则
if (Objects.equals(assigner.getOrganizationId(), target.getOrganizationId())) {
return;
}
if (notFoundOnDeny) {
dataScopeGuard.denyAsNotFound(assignerOperatorId, RESOURCE_TYPE, auditResourceId,
"服务不存在", null, null);
} else {
dataScopeGuard.deny(assignerOperatorId, RESOURCE_TYPE, auditResourceId, denyMessage);
}
}
/** 服务单联动(同事务):仅 submitted 状态允许关联分配并置为 assigned。 */
private void linkServiceRow(String serviceId, String matchmakerOperatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联服务单不存在");
}
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单当前状态不允许关联分配");
}
service.setAssignedOperatorId(matchmakerOperatorId);
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
service.setAssignedAt(Instant.now());
serviceMapper.updateById(service);
}
private MatchmakerAssignment requireAssignment(String assignmentId) {
return assignmentMapper.findById(assignmentId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "分配记录不存在"));
}
private Operator requireActiveOperator(String operatorId, String notFoundMessage) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, notFoundMessage));
if (!"active".equals(operator.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "目标红娘账号非 active 状态");
}
return operator;
}
/** 终态一律拒绝再转换(D5 状态机矩阵)。 */
private void assertActiveForTransition(MatchmakerAssignment assignment, String actionLabel) {
if (!Constants.ASSIGNMENT_STATUS_ACTIVE.equals(assignment.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"分配已处于终态(" + assignment.getStatus() + "),不允许" + actionLabel);
}
}
private void requireReason(String reason, String message) {
if (reason == null || reason.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
}
}
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
String summary = "matchmakerOperatorId=" + matchmakerOperatorId
+ ";scope=" + scope
+ ";reason=" + (reason == null ? "" : reason);
return summary.length() > 500 ? summary.substring(0, 500) : summary;
}
private void saveAudit(String operatorId, String action, String resourceId, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(RESOURCE_TYPE);
auditLog.setResourceId(resourceId);
auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog);
}
}
@@ -1,9 +1,11 @@
package com.danyang.match.platform.matchmaker.service;
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.dto.MatchmakerRecommendationDTO;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
@@ -11,7 +13,9 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
@@ -27,6 +31,31 @@ import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 红娘服务服务层。
*
* <p>ZC-MM-002(A1)变更说明:
* <ul>
* <li>claimService(红娘接单)已删除(D3:不保留组长认领),服务对象归属统一由
* matchmaker_assignments 建模;</li>
* <li>写接口(recommend/followup/complete)归属判定切换到
* {@link ServiceAssignmentPolicy}(D4),拒绝统一走 DataScopeGuard(审计 + 403);
* 存量兼容:assignment 表上线前的服务单仍按 assigned_operator_id 判定(005 阶段收敛);</li>
* <li>assignService 同事务联动「创建分配 + 服务单置 assigned」。</li>
* </ul>
*
* <p>ZC-MM-005(E1 双轨切纯判定器)变更说明:
* <ul>
* <li><b>删除 assigned_operator_id 回退分支</b>:002 的 "assignment 优先 +
* assigned_operator_id 回退" 有真漏洞 —— 分配 revoke/end 后服务单残留
* assigned_operator_id,回退分支会放行已失去访问权的红娘(违反 DEC-002 规则7)。
* V053 已回填全部存量进行中服务单,数据一致性已建立;终态服务单不回填是有意设计
* (服务结束即失去访问权)。{@link ServiceAssignmentPolicy} 成为唯一事实源;</li>
* <li>读详情(getServiceDetail)改用 E2 防探测 404:非持有人响应与"不存在"逐字段一致,
* 删除 viewerIsOwner 双态与"非负责人脱敏详情"整段;</li>
* <li>行为变化:红娘读自己历史 completed 服务单详情将 404(符合 DEC-002 决策)。</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class MatchmakerServiceService {
@@ -37,8 +66,12 @@ public class MatchmakerServiceService {
private final MatchmakerRecommendationMapper recommendationMapper;
private final MatchmakerFollowupMapper followupMapper;
private final UserProfileMapper userProfileMapper;
private final UserPrivacySettingsMapper userPrivacySettingsMapper;
private final NotificationService notificationService;
private final AuditLogMapper auditLogMapper;
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
private final MatchmakerAssignmentService matchmakerAssignmentService;
/**
* 用户申请红娘服务
@@ -82,43 +115,46 @@ public class MatchmakerServiceService {
}
/**
* 分配服务给红娘
* 分配服务给红娘(ZC-MM-002 / D4 联动版)。
*
* <p>同事务完成:① 创建 matchmaker_assignments 主责分配(含组织范围校验、
* 唯一性约束、CREATE 审计,由 {@link MatchmakerAssignmentService#create} 承担);
* ② 服务单 submitted → assigned、写回 assigned_operator_id。
* 任何一步失败整体回滚。
*
* @param serviceId 服务单 ID
* @param assignerOperatorId 当前操作者(分配人,principal)
* @param targetOperatorId 目标红娘
* @param reason 分配理由(默认文案由 Controller 传入)
*/
@Transactional
public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) {
public MatchmakerServiceRecord assignService(String serviceId, String assignerOperatorId,
String targetOperatorId, String reason) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
}
service.setAssignedOperatorId(operatorId);
service.setOrganizationId(organizationId);
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
service.setAssignedAt(Instant.now());
serviceMapper.updateById(service);
log.info("分配红娘服务: serviceId={}, operatorId={}", serviceId, operatorId);
saveAuditLog(operatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null);
return service;
}
/**
* 红娘接单
*/
@Transactional
public MatchmakerServiceRecord claimService(String operatorId, String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许接单");
if (targetOperatorId == null || targetOperatorId.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "目标红娘必填");
}
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
serviceMapper.updateById(service);
// ① 创建分配(matchmaker.assign 入口为平台授权,hasGlobalAssign=true)
MatchmakerAssignment createdAssignment = matchmakerAssignmentService.create(assignerOperatorId, true,
service.getUserId(), targetOperatorId, serviceId, reason, null);
log.info("红娘接单: operatorId={}, serviceId={}", operatorId, serviceId);
return service;
// ② 服务单联动(create 内已对 submitted 服务单置 assigned;此处再校验一次确保联动生效)
MatchmakerServiceRecord after = serviceMapper.selectById(serviceId);
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(after.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单分配联动失败");
}
log.info("分配红娘服务: serviceId={}, targetOperatorId={}, by={}",
serviceId, targetOperatorId, assignerOperatorId);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(assignerOperatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null,
"matchmaker.service.assign", createdAssignment.getId());
return after;
}
/**
@@ -132,10 +168,10 @@ public class MatchmakerServiceService {
/**
* 红娘推荐候选人给用户
*
* Phase 13 复验修复(D3):补 assigned_operator 归属校验。
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator
* 都能对**任意** serviceId 创建推荐(越权)。与
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。
* Phase 13 复验修复(D3):补归属校验。
* ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments
* 是服务对象归属的唯一事实源,无任何 assigned_operator_id 回退);拒绝统一走
* DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),不再抛裸 IllegalStateException。
*
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
*/
@@ -145,10 +181,8 @@ public class MatchmakerServiceService {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
if (!operatorId.equals(service.getAssignedOperatorId())) {
throw new IllegalStateException("您不是此服务的负责人");
}
// D4:归属校验 —— 只有该服务对象的负责人(分配持有人)才能推荐
MatchmakerAssignment assignment = assertServiceAccess(operatorId, service);
// 不能推荐给自己(服务申请人)
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
throw new IllegalArgumentException("不能推荐用户给自己");
@@ -171,7 +205,9 @@ public class MatchmakerServiceService {
"matchmaker_recommendation", rec.getId().toString());
log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId);
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null,
"matchmaker.service.recommend", assignment != null ? assignment.getId() : null);
Map<String, Object> result = new HashMap<>();
result.put("recommendationId", rec.getId());
@@ -182,11 +218,18 @@ public class MatchmakerServiceService {
/**
* 红娘添加跟进记录
*
* ZC-MM-002(D4):补归属校验(此前无任何校验,任何持 matchmaker.service 权限的
* operator 可对任意 serviceId 写跟进 → 越权写)。判定口径同 recommendPartner。
*/
@Transactional
public MatchmakerFollowup addFollowup(String serviceId, String operatorId,
String followupType, String content,
String nextFollowupAt) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
MatchmakerAssignment followupAssignment = assertServiceAccess(operatorId, service);
MatchmakerFollowup followup = new MatchmakerFollowup();
followup.setServiceId(serviceId);
followup.setOperatorId(operatorId);
@@ -198,17 +241,23 @@ public class MatchmakerServiceService {
followupMapper.insert(followup);
log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType);
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null,
"matchmaker.service.followup", followupAssignment != null ? followupAssignment.getId() : null);
return followup;
}
/**
* 完成服务
*
* ZC-MM-002(D4):补归属校验(判定口径同 recommendPartner);
* operatorId 由 Controller 传入 principal,不信任请求参数。
*/
@Transactional
public MatchmakerServiceRecord completeService(String serviceId) {
public MatchmakerServiceRecord completeService(String serviceId, String operatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
MatchmakerAssignment completeAssignment = assertServiceAccess(operatorId, service);
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
@@ -219,7 +268,9 @@ public class MatchmakerServiceService {
serviceMapper.updateById(service);
log.info("红娘服务完成: serviceId={}", serviceId);
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null,
"matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null);
return service;
}
@@ -257,6 +308,24 @@ public class MatchmakerServiceService {
.collect(java.util.stream.Collectors.toList());
}
/**
* 获取红娘工作台数据。
*
* ZC-MM-002(D4):today / completed 改为本人口径(该红娘名下的服务),
* pendingCount 保留全局口径(待分配队列是分配权持有者的全局视图);
* inProgress 统计的状态口径由错误的 'processing' 修正为 'in_service'
* ('processing' 不在 V009 chk_matchmaker_status 白名单,恒为 0)。
*
* <p>ZC-MM-005 收口(P1-2):inProgress / today / completed 三项计数统一切换为
* <b>matchmaker_assignments 生效中分配</b>口径(SQL 侧 join assignments,见
* MatchmakerServiceMapper 计数查询)。口径说明:
* <ul>
* <li>三项均只统计「本人当前生效分配对象名下」的服务单;</li>
* <li>分配 end / revoke 后(含他人接手后原红娘),该用户的服务单
* <b>不再计入</b>原红娘的 dashboard 计数(DEC-002 规则7:服务结束失去访问权);</li>
* <li>completed 计数同理:历史 completed 单仅在分配仍生效时计入本人。</li>
* </ul>
*/
public Map<String, Object> getDashboard(String operatorId) {
long pendingCount = serviceMapper.countSubmitted();
long inProgressCount = serviceMapper.countInProgressByOperator(operatorId);
@@ -264,57 +333,69 @@ public class MatchmakerServiceService {
Map<String, Object> dashboard = new HashMap<>();
dashboard.put("pendingCount", pendingCount);
dashboard.put("inProgressCount", inProgressCount);
long todayCount = serviceMapper.selectCount(
new LambdaQueryWrapper<MatchmakerServiceRecord>()
.eq(MatchmakerServiceRecord::getStatus, "submitted"));
// 本人口径:今天新流转到该红娘名下(按 assigned_at)的服务数
long todayCount = serviceMapper.countTodayByOperator(operatorId);
dashboard.put("todayCount", todayCount);
long completedCount = serviceMapper.selectCount(
new LambdaQueryWrapper<MatchmakerServiceRecord>()
.eq(MatchmakerServiceRecord::getStatus, "completed"));
// 本人口径:该红娘名下已完成的服务数
long completedCount = serviceMapper.countCompletedByOperator(operatorId);
dashboard.put("completedCount", completedCount);
return dashboard;
}
/**
* 获取我的服务列表(红娘工作台)
*/
public List<MatchmakerServiceRecord> getOperatorServices(String operatorId) {
return serviceMapper.findByOperatorId(operatorId);
}
/**
* 获取服务详情
* 获取服务详情(ZC-MM-005 / E1+E2 重构)。
*
* Phase 13 复验修复(D4):改为按调用者授权范围返回。
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取
* **任意** serviceId 的详情,其中包含该用户的完整资料
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
* <p>此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner
* 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行
* (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。
*
* 现语义:
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
* (不含 userProfile;followups 正文也不返回,仅给条数)
* <p>现语义(判定器唯一事实源,无回退):
* <ul>
* <li>查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");</li>
* <li>非持有人(无生效中的分配)→ {@link ServiceAssignmentPolicy}
* #assertActiveAssignmentOrNotFound:先落 DATA_SCOPE_DENIED 审计,
* 再抛与"查无"完全相同的 NOT_FOUND 异常 —— 两种失败响应逐字段一致,防探测;</li>
* <li>持有人 → 完整详情(申请人 userId / 诉求描述 / 白名单 userProfile / 跟进正文 /
* 推荐列表),不再有 viewerIsOwner 双态字段。</li>
* </ul>
*
* @param serviceId 服务 ID
* <p>行为变化:红娘读自己历史 completed 服务单详情将 404(分配已终态,符合 DEC-002)。
*
* @param serviceId 服务 ID
* @param requesterId 调用者 operator ID
*/
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) return null;
if (service == null) {
// 与"非持有人"路径抛出完全相同的异常 → 响应体逐字段一致(防探测,E2)
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
}
boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId());
// E1:判定器唯一事实源;E2:非持有人防探测 404(先审计后 NOT_FOUND)
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
requesterId, service.getUserId(), "matchmaker_service", serviceId);
// ZC-MM-006 / T03:持有人读取服务对象详情 = 敏感数据访问,必须落 sensitive_access_logs
// (AGENTS.md #8:查看动作本身必须可溯源)。拒绝路径走判定器的 DATA_SCOPE_DENIED 审计,
// 双记录互斥 —— 非持有人不会走到本行(不重复写 SensitiveAccessLog)。
SensitiveAccessLog accessLog = new SensitiveAccessLog();
accessLog.setOperatorId(requesterId);
accessLog.setUserId(service.getUserId());
accessLog.setDataType(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL);
accessLog.setReason(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL);
accessLog.setAccessTarget(serviceId);
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
sensitiveAccessLogMapper.insert(accessLog);
Map<String, Object> detail = new HashMap<>();
detail.put("serviceId", service.getId());
detail.put("serviceType", service.getServiceType());
detail.put("status", service.getStatus());
detail.put("assignedOperatorId", service.getAssignedOperatorId());
detail.put("createdAt", service.getCreatedAt());
detail.put("assignedAt", service.getAssignedAt());
detail.put("completedAt", service.getCompletedAt());
detail.put("viewerIsOwner", isOwner);
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见)
// 推荐列表(服务进展摘要)
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
detail.put("recommendations", recs.stream().map(r -> {
Map<String, Object> map = new HashMap<>();
@@ -326,35 +407,78 @@ public class MatchmakerServiceService {
return map;
}).collect(Collectors.toList()));
if (isOwner) {
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文
detail.put("userId", service.getUserId());
detail.put("description", service.getDescription());
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
Map<String, Object> userProfile = new HashMap<>();
userProfile.put("displayName", profile.getDisplayName());
userProfile.put("gender", profile.getGender());
userProfile.put("age", calculateAge(profile.getBirthDate()));
userProfile.put("regionName", profile.getRegionName());
userProfile.put("educationCode", profile.getEducationCode());
userProfile.put("occupationCode", profile.getOccupationCode());
userProfile.put("maritalStatus", profile.getMaritalStatus());
detail.put("userProfile", userProfile);
});
detail.put("followups", followupMapper.findByServiceId(serviceId));
} else {
// 非负责人:只给跟进条数作为进度摘要,不返回正文
detail.put("followupCount", followupMapper.findByServiceId(serviceId).size());
}
// 仅持有人可见:申请人 userId / 诉求描述 / 白名单用户资料 / 跟进正文
detail.put("userId", service.getUserId());
detail.put("description", service.getDescription());
UserPrivacySettings privacy = userPrivacySettingsMapper.findByUserId(service.getUserId())
.orElse(null);
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile ->
detail.put("userProfile", assembleMatchmakerUserProfile(profile, privacy)));
detail.put("followups", followupMapper.findByServiceId(serviceId));
return detail;
}
/**
* 红娘侧用户资料装配(ZC-MM-005 / E3 字段白名单显式化)。
*
* <p>逐字段对照《安全、隐私、合规与风控体系设计》§5.2 字段级隐私清单:
* <ul>
* <li>displayName —— 基础展示字段,允许</li>
* <li>gender —— 基础展示字段,允许</li>
* <li>age —— 由 birthDate 计算的派生值(<b>不输出原始 birthDate</b>)</li>
* <li>regionName —— 地区展示名(非精确坐标),允许</li>
* <li>educationCode —— 枚举码,允许</li>
* <li>occupationCode —— 枚举码,<b>受隐私开关 showOccupation 控制</b>:
* 用户明确关闭(false)时不输出;隐私记录缺失或开关未设置(null)按缺省放行</li>
* <li>maritalStatus —— 枚举码,允许</li>
* </ul>
* §5.2 禁放字段(phone/mobile/wechat/idNumber/birthDate/address/id_card 等)
* 一律不进入装配;守卫见 MatchmakerFieldPrivacyGuardTest(源码级白名单扫描)。
*
* @param profile 用户资料(可空字段自然输出 null,由前端按缺失处理)
* @param privacy 用户隐私设置;null 表示用户未设置过(缺省=全放行)
*/
private Map<String, Object> assembleMatchmakerUserProfile(UserProfile profile,
UserPrivacySettings privacy) {
// 缺省=全放行:仅在用户显式关闭(showOccupation == false)时隐藏职业
boolean showOccupation = privacy == null || !Boolean.FALSE.equals(privacy.getShowOccupation());
Map<String, Object> userProfile = new HashMap<>();
userProfile.put("displayName", profile.getDisplayName());
userProfile.put("gender", profile.getGender());
// 派生年龄:不输出原始 birthDate(§5.2 禁放)
userProfile.put("age", calculateAge(profile.getBirthDate()));
userProfile.put("regionName", profile.getRegionName());
userProfile.put("educationCode", profile.getEducationCode());
if (showOccupation) {
userProfile.put("occupationCode", profile.getOccupationCode());
}
userProfile.put("maritalStatus", profile.getMaritalStatus());
return userProfile;
}
private int calculateAge(java.time.LocalDate birthDate) {
if (birthDate == null) return 0;
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
}
/**
* 服务归属校验(ZC-MM-005 / E1 纯判定器版;ZC-MM-006 / T04 写接口 404 化)。
*
* <p><b>无任何回退</b>:matchmaker_assignments 是服务对象归属的唯一事实源。
* 002 的 assigned_operator_id 回退分支已删除 —— 分配 revoke/end 后服务单残留的
* assigned_operator_id 不再构成访问凭据(DEC-002 规则7:撤销即失权)。
* 写接口(recommend/followup/complete,资源寻址端点)口径:判定无效 →
* {@link ServiceAssignmentPolicy#assertActiveAssignmentOrNotFound}:
* 先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」—— 与读详情口径逐字段一致,防探测。
*
* @return 生效中的分配记录(供审计写入 service_assignment_id;deny 必然抛出,不会返回 null)
*/
private MatchmakerAssignment assertServiceAccess(String operatorId, MatchmakerServiceRecord service) {
return serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(operatorId, service.getUserId(),
"matchmaker_service", service.getId());
}
/**
* 获取红娘为该用户推荐的人员列表
@@ -394,11 +518,25 @@ public class MatchmakerServiceService {
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写(负向用例覆盖)
saveAuditLog(operatorId, action, resourceType, resourceId, beforeData, afterData, null, null);
}
/**
* ZC-MM-006 / T01 扩展版审计写入:附加 purpose_code 与 service_assignment_id。
*/
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData,
String purposeCode, String serviceAssignmentId) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,111 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/**
* 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6;ZC-MM-005 / E1-E2 增强)。
*
* <p>红娘对某个用户能否读/写,唯一事实源是 matchmaker_assignments 里
* 「生效中的分配」(active 且未过有效期)。所有读路径前先 {@link #expireOverdue}
* 惰性过期(无定时任务,D5 决策),保证过期分配立即失效。
*
* <p>判定无效时的两种处置:
* <ul>
* <li>{@link #assertActiveAssignment}:写接口口径 —— 落 DATA_SCOPE_DENIED 审计 +
* 抛 AccessDeniedException(403);</li>
* <li>{@link #assertActiveAssignmentOrNotFound}(ZC-MM-005 / E2):读详情口径 ——
* 先落 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),再抛
* BusinessException(NOT_FOUND)(响应 404,与"资源不存在"逐字段一致),
* 防止枚举 serviceId 探测资源存在性。</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ServiceAssignmentPolicy {
private final MatchmakerAssignmentMapper assignmentMapper;
private final DataScopeGuard dataScopeGuard;
/**
* 惰性过期:把 expires_at 已届满的 active 分配置为 expired 并落库。
* 读路径入口统一调用;无定时任务(D5 决策)。
*/
public int expireOverdue() {
return assignmentMapper.expireOverdue();
}
/** 查询某红娘对某用户的生效中分配;不存在返回 empty。 */
public Optional<MatchmakerAssignment> findActiveAssignment(String operatorId, String userId) {
expireOverdue();
return assignmentMapper.findActiveByOperatorAndUser(operatorId, userId);
}
/** 某红娘当前是否可访问某用户(存在生效中的分配)。 */
public boolean canAccessUser(String operatorId, String userId) {
return findActiveAssignment(operatorId, userId).isPresent();
}
/**
* 断言某红娘对某用户持有生效中的分配;无效时:
* 落 DATA_SCOPE_DENIED 审计 + 抛 AccessDeniedException(403)。
*
* @param resourceType 审计 resourceType(如 matchmaker_service / matchmaker_user)
* @return 生效中的分配记录(永不返回 null)
*/
public MatchmakerAssignment assertActiveAssignment(String operatorId, String userId,
String resourceType) {
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
if (found.isPresent()) {
return found.get();
}
dataScopeGuard.deny(operatorId, resourceType, userId,
"未持有该服务对象的分配关系,禁止访问");
return null; // deny 必然抛出,此行不可达
}
/**
* 读详情口径的断言(ZC-MM-005 / E2 防探测 404)。
*
* <p>无效时先写 DATA_SCOPE_DENIED 审计(字段口径与 {@link DataScopeGuard#deny} 对齐,
* 语义上这是一次越权尝试),再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}。
* 全局异常处理器将其映射为 404 响应体 {@code {success:false, code:NOT_FOUND,
* message:"服务不存在"}} —— 与查询一个真正不存在的 serviceId 的响应逐字段一致,
* 调用方无法区分"无权"与"不存在",杜绝按 serviceId 枚举探测。
*
* <p>适用面(ZC-MM-006 / T04 扩展):读详情 + <b>带资源寻址的写端点</b>
* (recommend / followup / complete / 转介)范围校验失败统一 404 化(防探测);
* create / assign 类无资源寻址端点维持 {@link #assertActiveAssignment} 的 403 口径。
*
* @param resourceType 审计 resourceType(如 matchmaker_service)
* @param resourceId 审计 resourceId(被探测的 serviceId)
* @return 生效中的分配记录(永不返回 null)
*/
public MatchmakerAssignment assertActiveAssignmentOrNotFound(String operatorId, String userId,
String resourceType,
String resourceId) {
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
if (found.isPresent()) {
return found.get();
}
// ZC-MM-006 / T04:防探测 404 化 deny —— 审计与 404 抛出统一收敛到
// {@link DataScopeGuard#denyAsNotFound}(审计含权限快照 + 请求追踪 ID)。
// 该方法同时服务读详情与(本阶段起)写接口范围校验,拒绝口径全程一致。
dataScopeGuard.denyAsNotFound(operatorId, resourceType, resourceId, "服务不存在", null, null);
return null; // denyAsNotFound 必然抛出,此行不可达
}
/** 某红娘名下全部生效中的分配(GET /my 口径),读前惰性过期。 */
public List<MatchmakerAssignment> findActiveByOperator(String operatorId) {
expireOverdue();
return assignmentMapper.findActiveByOperator(operatorId);
}
}
@@ -30,7 +30,7 @@ public class ReferralService {
private final ActivityRegistrationMapper activityRegistrationMapper;
private final OperatorMapper operatorMapper;
private final NotificationService notificationService;
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
private final com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy serviceAssignmentPolicy;
/**
* Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
@@ -39,16 +39,24 @@ public class ReferralService {
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。
*
* 修复后:先断言存在进行中的 matchmaker_service(assigned_operator_id=红娘, user_id=目标用户),
* 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
* 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
*
* <p>ZC-MM-005 收口(P1-3):授权事实源切换为
* {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy}
* 判定器 —— 与写接口 recommend / followup 同口径(matchmaker_assignments 生效中分配)。
* 分配 revoke / end 后残留列不再构成转介凭据(DEC-002 规则7:撤销即失权)。
*
* <p>ZC-MM-006 / T04(写接口 404 统一·防探测):转介为资源寻址写端点,拒绝路径
* 404 化 {@code denyAsNotFound}(先 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」,
* 与读详情口径逐字段一致);并消灭裸 IllegalArgumentException ——
* 缺失目标用户也统一映射为同一 404 响应,不泄露"目标是否存在"的任何信号。
*/
private void assertMatchmakerServesUser(String matchmakerId, String userId) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("缺少转介目标用户");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
}
matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId)
.orElseThrow(() -> new IllegalStateException(
"您不是该用户的服务负责人,无法发起转介"));
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(matchmakerId, userId,
"matchmaker_referral", userId);
}
@Transactional
@@ -330,9 +330,68 @@ public final class Constants {
public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker";
public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review";
// ========== 服务对象分配(ZC-MM-002 / A1,与 V053 数据库 CHECK 白名单一致)==========
// 状态机:active → transferred / revoked / ended;expires_at 届满由判定器惰性置 expired。
// 终态(ended/revoked/transferred/expired)一律拒绝再转换。
public static final String ASSIGNMENT_STATUS_ACTIVE = "active";
public static final String ASSIGNMENT_STATUS_ENDED = "ended";
public static final String ASSIGNMENT_STATUS_REVOKED = "revoked";
public static final String ASSIGNMENT_STATUS_TRANSFERRED = "transferred";
public static final String ASSIGNMENT_STATUS_EXPIRED = "expired";
// 分配范围:primary(主责,同用户仅一条生效中)/ collaborating(协同)
public static final String ASSIGNMENT_SCOPE_PRIMARY = "primary";
public static final String ASSIGNMENT_SCOPE_COLLABORATING = "collaborating";
// 服务对象分配审计 action(resourceType = matchmaker_assignment)
public static final String AUDIT_ACTION_ASSIGNMENT_CREATE = "MATCHMAKER_ASSIGNMENT_CREATE";
public static final String AUDIT_ACTION_ASSIGNMENT_TRANSFER = "MATCHMAKER_ASSIGNMENT_TRANSFER";
public static final String AUDIT_ACTION_ASSIGNMENT_REVOKE = "MATCHMAKER_ASSIGNMENT_REVOKE";
public static final String AUDIT_ACTION_ASSIGNMENT_END = "MATCHMAKER_ASSIGNMENT_END";
// ========== V1.1 本地资格审核 ==========
/** 本地资格审核任务类型 */
public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility";
/** 本地资格佐证数据类型(用于敏感访问审计) */
public static final String SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE = "local_eligibility_evidence";
// ========== ZC-MM-006 / T03 红娘域敏感访问审计 ==========
/** 红娘工作台服务对象详情数据类型(用于敏感访问审计) */
public static final String SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL = "matchmaker_service_detail";
/** 敏感访问成功结果(与 SensitiveAccessLog.accessResult 白名单 success/denied/failed 对齐) */
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";
}
@@ -1,6 +1,7 @@
package com.danyang.match.platform.shared.filter;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.permission.service.PermissionService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
@@ -34,6 +35,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
private final PermissionService permissionService;
private final OperatorMapper operatorMapper;
private static final List<String> PERMITTED_PATHS = List.of(
"/api/v1/auth/**",
@@ -87,6 +89,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String subject = jwtTokenService.getUserIdFromToken(token);
String tokenType = jwtTokenService.parseToken(token).get("type", String.class);
boolean operatorToken = "operator_access".equals(tokenType);
if (operatorToken) {
// ZC-MM-006 / T02(JWT 权限回收·实时查库路线,不建撤销名单):
// 运营 token 每请求实时查 operators.status —— 账号不存在(含软删除)或非 active
// 一律 401、不建 principal,已签发 token 立即失效;零缓存。
// 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL),
// 运营账号禁用/待激活均不得通过过滤器。
var operator = operatorMapper.findById(subject);
if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) {
sendUnauthorized(response);
return;
}
}
// ZC-MM-006 / T02:权限实时查库(PermissionMapper join role_permissions/operator_roles),
// 每请求生效;token claims 中的 permissions 仅作展示快照(只写不读),不是授权事实源。
List<String> permissions = operatorToken
? permissionService.getPermissions(subject)
: List.of();
@@ -35,6 +35,9 @@ public class AuthorizationAuditListener {
audit.setAction("AUTHORIZATION_DENIED");
audit.setResourceType("authorization");
audit.setResourceId(limit(String.valueOf(event.getObject()), 128));
// ZC-MM-006 / T01:权限快照(permissions 逗号拼接,截断 128)+ 请求追踪 ID
audit.setActorRoleSnapshot(limit(String.join(",", principal.permissions()), 128));
audit.setRequestTraceId(org.slf4j.MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
@@ -2,13 +2,18 @@ package com.danyang.match.platform.shared.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.UUID;
import org.slf4j.MDC;
/** Denies out-of-scope operator access and records the attempt before returning 403. */
@Slf4j
@@ -19,12 +24,28 @@ public class DataScopeGuard {
private final AuditLogMapper auditLogMapper;
public void deny(String operatorId, String resourceType, String resourceId, String message) {
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写
deny(operatorId, resourceType, resourceId, message, null, null);
}
/**
* ZC-MM-006 / T01 扩展版拒绝审计:附加业务目的码与关联分配 ID。
*
* @param purposeCode 业务目的码(如 matchmaker.service.view),可空
* @param serviceAssignmentId 关联的 matchmaker_assignments.id(拒绝路径通常无生效分配 → null)
*/
public void deny(String operatorId, String resourceType, String resourceId, String message,
String purposeCode, String serviceAssignmentId) {
AuditLog audit = new AuditLog();
audit.setId(UUID.randomUUID().toString());
audit.setOperatorId(operatorId);
audit.setAction("DATA_SCOPE_DENIED");
audit.setResourceType(resourceType);
audit.setResourceId(resourceId);
audit.setPurposeCode(purposeCode);
audit.setServiceAssignmentId(serviceAssignmentId);
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
auditLogMapper.insert(audit);
@@ -34,4 +55,57 @@ public class DataScopeGuard {
}
throw new AccessDeniedException(message);
}
}
/**
* ZC-MM-006 / T04(写接口 404 统一·防探测):范围校验失败的写端点 ——
* 先落与 {@link #deny} 完全相同的 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),
* 再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}:响应与"资源不存在"逐字段一致,
* 调用方无法区分"无权"与"不存在",杜绝按资源 ID 枚举探测写端点。
*
* <p>适用面:带资源寻址的写端点(transfer/revoke/end/recommend/followup/complete/转介);
* create/assign 类无资源寻址端点维持 {@link #deny} 的 403 口径。
*
* @param message 404 响应 message(统一 "服务不存在",与读详情口径一致)
*/
public void denyAsNotFound(String operatorId, String resourceType, String resourceId, String message,
String purposeCode, String serviceAssignmentId) {
AuditLog audit = new AuditLog();
audit.setId(UUID.randomUUID().toString());
audit.setOperatorId(operatorId);
audit.setAction("DATA_SCOPE_DENIED");
audit.setResourceType(resourceType);
audit.setResourceId(resourceId);
audit.setPurposeCode(purposeCode);
audit.setServiceAssignmentId(serviceAssignmentId);
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
auditLogMapper.insert(audit);
} catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception);
}
throw new BusinessException(ApiResponseCode.NOT_FOUND, message);
}
/**
* 当前操作者权限快照(与 {@link AuthorizationAuditListener} 同口径):
* OperatorPrincipal.permissions() 逗号拼接,截断 128。
*/
private String currentActorRoleSnapshot() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal()
instanceof com.danyang.match.platform.shared.security.principal.OperatorPrincipal principal) {
return limit(String.join(",", principal.permissions()), 128);
}
return null;
}
private String limit(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
}
@@ -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
@@ -0,0 +1,79 @@
-- ============================================================
-- V053: 服务对象分配表(ZC-MM-002 / A1 服务对象分配建模)
--
-- 建模决策(架构师设计 D1/D5):
-- * 决策文档 5.1 的 matchmaker_user_id 对应 operators 主体,
-- 定名 matchmaker_operator_id(沿用 matchmaker_services.assigned_operator_id 惯例)。
-- * status 状态机:active → transferred / revoked / ended;
-- expires_at < now 的 active 行由 ServiceAssignmentPolicy 惰性置 expired(无定时任务);
-- 终态(transferred/revoked/ended/expired)一律不允许再次转换。
-- * assignment_scope:primary(主责)/ collaborating(协同)。
-- * 同一用户同时最多一条 active + primary 分配 → 部分唯一索引兜底
-- (业务层 SELECT-then-INSERT 有竞态,只有 DB 唯一索引能兜住)。
--
-- 存量回填说明:
-- matchmaker_services.assigned_operator_id IS NOT NULL 且服务仍处于非终态
-- (assigned / in_service / waiting_user,与 V009 chk_matchmaker_status 白名单一致)的行,
-- 回填为 active + primary 分配。assigned_by 无历史记录可考,取技术回填值
-- = assigned_operator_id(仅表示"分配关系成立",不代表当年实际操作者)。
-- 幂等:NOT EXISTS 防重复回填(按 service_id 关联)。
-- ============================================================
CREATE TABLE IF NOT EXISTS matchmaker_assignments (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
matchmaker_operator_id UUID NOT NULL REFERENCES operators(id),
service_id UUID REFERENCES matchmaker_services(id),
assigned_by UUID NOT NULL REFERENCES operators(id),
assignment_scope VARCHAR(32) NOT NULL DEFAULT 'primary',
assignment_reason VARCHAR(500) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
assigned_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
end_reason VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_status CHECK (status IN (
'active', 'ended', 'revoked', 'transferred', 'expired'
));
ALTER TABLE matchmaker_assignments ADD CONSTRAINT chk_mm_assignment_scope CHECK (assignment_scope IN (
'primary', 'collaborating'
));
-- 部分唯一索引:同一用户最多一条「生效中的主责分配」。
-- 终态行(ended/revoked/transferred/expired)不占位,允许历史多条。
CREATE UNIQUE INDEX IF NOT EXISTS uq_mm_assignment_active_primary
ON matchmaker_assignments(user_id)
WHERE status = 'active' AND assignment_scope = 'primary';
CREATE INDEX IF NOT EXISTS idx_mm_assignment_operator ON matchmaker_assignments(matchmaker_operator_id, status);
CREATE INDEX IF NOT EXISTS idx_mm_assignment_user ON matchmaker_assignments(user_id, status);
CREATE INDEX IF NOT EXISTS idx_mm_assignment_service ON matchmaker_assignments(service_id);
-- 存量回填(幂等)
INSERT INTO matchmaker_assignments (
id, user_id, matchmaker_operator_id, service_id, assigned_by,
assignment_scope, assignment_reason, status, assigned_at, created_at, updated_at
)
SELECT
gen_random_uuid(),
ms.user_id,
ms.assigned_operator_id,
ms.id,
ms.assigned_operator_id, -- assigned_by 为技术回填值:历史分配操作者未记录
'primary',
'V053 存量回填:原始分配人未记录',
'active',
COALESCE(ms.assigned_at, NOW()),
NOW(),
NOW()
FROM matchmaker_services ms
WHERE ms.assigned_operator_id IS NOT NULL
AND ms.status IN ('assigned', 'in_service', 'waiting_user')
AND NOT EXISTS (
SELECT 1 FROM matchmaker_assignments ma WHERE ma.service_id = ms.id
);
@@ -0,0 +1,38 @@
-- ============================================================
-- V054: 新增红娘组长角色与团队分配权限(ZC-MM-002 / A1,架构师设计 D2)
--
-- 角色权限边界:
-- * matchmaker_leader(014)授 view(020) + service(022) + team.assign(023),
-- **不给** matchmaker.assign(021) —— 组长只能在同组织范围内分配;
-- 组织边界判定:组长团队边界 = 同 organization_id,NULL=NULL 视为平台直属
-- (平台直属组长只能分配平台直属红娘;有组织组长不能分配 org=NULL 红娘;
-- org=NULL 普通红娘只能由 matchmaker.assign 持有者分配 —— 该技术默认
-- 写在 ServiceAssignmentPolicy / MatchmakerAssignmentService 代码注释,
-- 治理登记后续补充)。
-- * 普通红娘(005)权限现状不变(view/service),由 V052 授予。
--
-- 幂等:全部 ON CONFLICT DO NOTHING(对照 V052 风格)。
-- ============================================================
INSERT INTO roles (id, code, name, description, system_role) VALUES (
'00000000-0000-0000-0000-000000000014',
'matchmaker_leader',
'红娘组长',
'公益红娘团队组长,可在同组织范围内分配/转分配服务对象',
TRUE
) ON CONFLICT (id) DO NOTHING;
INSERT INTO permissions (id, code, name, module, description) VALUES (
'10000000-0000-0000-0000-000000000023',
'matchmaker.team.assign',
'红娘团队分配',
'matchmaker',
'在组长同组织范围内分配/转分配/撤销/结束服务对象分配'
) ON CONFLICT (id) DO NOTHING;
-- 红娘组长:view + service + team.assign(不含 matchmaker.assign)
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000000-0000-0000-0000-000000000014', id
FROM permissions
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.team.assign')
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -0,0 +1,18 @@
-- ============================================================
-- V055: audit_logs 扩展红娘域审计字段(ZC-MM-006 / T01)
--
-- 新增 4 列(全部可空、存量不回填、语句幂等):
-- purpose_code 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view
-- service_assignment_id 关联的 matchmaker_assignments.id(可空:非分配场景为 NULL)
-- actor_role_snapshot 审计落库时操作者的权限快照(OperatorPrincipal.permissions() 逗号拼接,截断 128)
-- request_trace_id 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId,16 位)
--
-- 兼容性:全部可空 → 缺省调用(不传新字段)仍可正常写入,存量行为不变。
-- 幂等:H2/PostgreSQL 均无 ADD COLUMN IF NOT EXISTS 统一保障时,
-- 用 information_schema 判空后执行(对照 V034/V035 风格)。
-- ============================================================
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS purpose_code VARCHAR(64);
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS service_assignment_id UUID;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS actor_role_snapshot VARCHAR(128);
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS request_trace_id VARCHAR(64);
@@ -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);
@@ -32,7 +32,13 @@ class MigrationValidationTest {
// V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一
// —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled),
// 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭)
private static final int LATEST_MIGRATION = 51;
// ZC-MM-002(A1 服务对象分配建模):V052 补齐 matchmaker 角色权限(V052 其实早于本轮,漏计);
// V053 matchmaker_assignments 建表 + 存量回填;V054 matchmaker_leader 角色与团队分配权限
// ZC-MM-006(T01 审计字段扩展):V055 audit_logs 补 purpose_code / service_assignment_id /
// actor_role_snapshot / request_trace_id 四列(全可空、存量不回填、幂等)
// 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/";
@@ -0,0 +1,341 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.auth.token.TokenBlacklistService;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.notification.entity.Notification;
import com.danyang.match.platform.notification.mapper.NotificationMapper;
import com.danyang.match.platform.referral.service.ReferralService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ZC-MM-004(禁止代替用户决定复核)+ MM-CONTACT-001 行为锁定
* ({@code @SpringBootTest} 走真实安全过滤器链,沿用 MatchmakerReferralPhase13Test 数据构造范式)。
*
* <p>锁定四条行为不变量(架构师设计 T02):
* <ol>
* <li><b>MM-ACTION-001 强化(负向)</b>:operator 主体 token 打用户专属决定端点
* (推荐 accept / pass、转介 confirm)→ 认证层 403
* (SecurityConfig 兜底 {@code hasRole("USER")}:运营身份根本进不到 Controller,
* SecurityPathCoverageTest 已机械锁定该兜底不得退回 authenticated()),
* 且 recommendation / registration 状态零变化 —— 「红娘不能替用户 Like/Pass/确认转介」。</li>
* <li><b>MM-ACTION-002</b>:会话开启端点(POST /conversations/from-match/{matchId})同为
* UserPrincipal 路径 → operator token 403(match 的产生同样只在用户 Like 路径,
* 无 operator 触达面)。</li>
* <li><b>MM-ACTION-003 / must-confirm 语义</b>:红娘创建转介 → 响应
* needsUserConfirmation=true、库中 userConfirmedAt=null、status 停留 pending;
* 红娘侧 recommend / followup / complete 动作不改变用户确认状态 ——
* 「转介代建 = 代填草稿、用户确认」语义被永久锁定。</li>
* <li><b>MM-CONTACT-001(正向)</b>:红娘对本人分配对象创建推荐/转介 → notifications 表
* 落平台内服务通知(type=matchmaker),title/body 不含手机号/微信号模式;
* 通知收件面只有 C 端用户(无 operator 收件行)—— 平台内联系经通知中转,
* 红娘与用户之间不存在直连联系方式交换。</li>
* </ol>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
@AutoConfigureMockMvc
class MatchmakerActionGuardTest {
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenService jwtTokenService;
/** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */
@MockBean private TokenBlacklistService tokenBlacklistService;
/**
* ZC-MM-006 / T02 起由真实 DB 路径接管权限装载(schema-h2 已补权限四表),
* 此处打桩仅为兼容既有断言口径(本测试只依赖身份类型 403,不依赖具体权限集)。
*/
@MockBean private com.danyang.match.platform.permission.service.PermissionService permissionService;
@Autowired private com.danyang.match.platform.permission.mapper.OperatorMapper operatorMapper;
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
@Autowired private MatchmakerAssignmentMapper matchmakerAssignmentMapper;
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
@Autowired private NotificationMapper notificationMapper;
@Autowired private UserMapper userMapper;
@Autowired private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
@Autowired private UserIdentityMapper userIdentityMapper;
@Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private ReferralService referralService;
private static final String MATCHMAKER_OWNER = "op-ag-owner";
private String user;
private String target;
private String serviceId;
private String operatorToken;
/** 手机号模式(中国大陆 11 位) */
private static final Pattern PHONE_PATTERN = Pattern.compile("1[3-9]\\d{9}");
/** 微信号/微信引导词模式 */
private static final List<String> WECHAT_TOKENS = List.of("wechat", "weixin", "微信", "vx", "加v", "v信");
@BeforeEach
void setUp() {
user = createUser("ag-user");
target = createUser("ag-target");
// 一条归属 MATCHMAKER_OWNER 的红娘服务(assigned),并补生效分配(E1 纯判定器口径)
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
MatchmakerAssignment assignment = new MatchmakerAssignment();
assignment.setUserId(user);
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
assignment.setServiceId(serviceId);
assignment.setAssignedBy(MATCHMAKER_OWNER);
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
assignment.setAssignmentReason("003/004 守卫前置分配");
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
assignment.setAssignedAt(Instant.now());
matchmakerAssignmentMapper.insert(assignment);
// operator token:type=operator_access → JwtAuthFilter 授 ROLE_OPERATOR
// ZC-MM-006 / T02:JwtAuthFilter 实时查 operators.status,须为 token subject 落一条 active 运营行
com.danyang.match.platform.permission.entity.Operator op =
new com.danyang.match.platform.permission.entity.Operator();
op.setId(MATCHMAKER_OWNER);
op.setName("003/004 守卫红娘");
op.setUsername("ag_mm_owner_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
when(permissionService.getPermissions(anyString())).thenReturn(List.of());
operatorToken = jwtTokenService.generateOperatorToken(MATCHMAKER_OWNER, null);
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ==================== 数据构造(Phase13 范式) ====================
/** 基础资格满足的用户:正常账号 + 实名 + 资料通过(RelationService.like 路径可走) */
private String createUser(String tag) {
User u = new User();
u.setWechatOpenid("ag_" + tag + "_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
u.setRecommendationEnabled(true);
userMapper.insert(u);
var profile = new com.danyang.match.platform.profile.entity.UserProfile();
profile.setUserId(u.getId());
profile.setDisplayName(tag);
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
userProfileMapper.insert(profile);
var identity = new com.danyang.match.platform.verification.entity.UserIdentity();
identity.setUserId(u.getId());
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
userIdentityMapper.insert(identity);
return u.getId();
}
private String createService(String userId, String operatorId, String status) {
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setStatus(status);
svc.setAssignedOperatorId(operatorId);
matchmakerServiceMapper.insert(svc);
return svc.getId();
}
private MatchmakerRecommendation insertRecommendation(String recUser, String recTarget) {
MatchmakerRecommendation rec = new MatchmakerRecommendation();
rec.setId(UUID.randomUUID().toString());
rec.setServiceId(serviceId);
rec.setUserId(recUser);
rec.setTargetUserId(recTarget);
rec.setOperatorId(MATCHMAKER_OWNER);
rec.setReason("004 守卫测试推荐");
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
recommendationMapper.insert(rec);
return rec;
}
private ActivityRegistration insertReferralRegistration(String regUser, String activityId) {
ActivityRegistration reg = new ActivityRegistration();
reg.setId(UUID.randomUUID().toString());
reg.setActivityId(activityId);
reg.setUserId(regUser);
reg.setStatus(Constants.REGISTRATION_STATUS_PENDING);
reg.setSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
reg.setRequestSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
reg.setCreatedByOperatorId(MATCHMAKER_OWNER);
reg.setCreatedAt(Instant.now());
activityRegistrationMapper.insert(reg);
return reg;
}
// ==================== MM-ACTION-001:红娘不能替用户做 Like/Pass/确认决定 ====================
@Test
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 accept → 403,推荐状态零变化")
void operatorCannotAcceptRecommendationOnBehalfOfUser() throws Exception {
MatchmakerRecommendation rec = insertRecommendation(user, target);
mockMvc.perform(post("/matchmaker/recommendations/{id}/accept", rec.getId())
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden());
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
"越权 accept 被认证层 403 拒绝后,推荐状态必须零变化");
assertNull(after.getRespondedAt(), "越权 accept 不得写入 respondedAt");
}
@Test
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 pass → 403,推荐状态零变化")
void operatorCannotPassRecommendationOnBehalfOfUser() throws Exception {
MatchmakerRecommendation rec = insertRecommendation(user, target);
mockMvc.perform(post("/matchmaker/recommendations/{id}/pass", rec.getId())
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden());
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
"越权 pass 被拒绝后,推荐状态必须零变化(仍 pending_view)");
assertNull(after.getRespondedAt(), "越权 pass 不得写入 respondedAt");
}
@Test
@DisplayName("MM-ACTION-001(负向): operator token 打转介 confirm → 403,用户确认状态零变化")
void operatorCannotConfirmReferralOnBehalfOfUser() throws Exception {
ActivityRegistration reg = insertReferralRegistration(user, "act-ag-001");
mockMvc.perform(post("/referrals/{id}/confirm", reg.getId())
.header("Authorization", "Bearer " + operatorToken)
.contentType("application/json")
.content("{\"targetType\":\"activity\"}"))
.andExpect(status().isForbidden());
ActivityRegistration after = activityRegistrationMapper.selectById(reg.getId());
assertNull(after.getUserConfirmedAt(),
"越权 confirm 被拒绝后,userConfirmedAt 必须仍为 null(用户决定权)");
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
"越权 confirm 后报名状态必须停留 pending");
}
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
@Test
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)")
void operatorCannotOpenConversationFromMatch() throws Exception {
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden());
}
// ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
@Test
@DisplayName("MM-ACTION-003: 转介代建=代填草稿(needsUserConfirmation=true、userConfirmedAt=null);红娘后续动作不改变确认状态")
void referralCreationStaysPendingUntilUserConfirms() {
// 红娘创建转介(本人分配对象)
Map<String, Object> result = referralService.referToActivity(
MATCHMAKER_OWNER, user, "act-ag-002", "紧急联系人", "enc-phone");
// must-confirm 语义:响应标记待确认,库中确认字段为空、状态停留 pending
assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"),
"红娘创建转介必须标记 needsUserConfirmation=true(不替用户完成报名)");
String regId = (String) result.get("registrationId");
ActivityRegistration reg = activityRegistrationMapper.selectById(regId);
assertNotNull(reg, "转介必须真实落库");
assertNull(reg.getUserConfirmedAt(), "创建时 userConfirmedAt 必须为 null");
assertEquals(Constants.REGISTRATION_STATUS_PENDING, reg.getStatus(),
"创建时报名状态必须停留 pending");
// 红娘侧后续动作:recommend / followup / complete —— 均不得改变用户确认状态
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "合适人选");
matchmakerServiceService.addFollowup(serviceId, MATCHMAKER_OWNER, "note", "003 守卫跟进", null);
MatchmakerServiceRecord svc = matchmakerServiceMapper.selectById(serviceId);
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
matchmakerServiceMapper.updateById(svc);
matchmakerServiceService.completeService(serviceId, MATCHMAKER_OWNER);
ActivityRegistration after = activityRegistrationMapper.selectById(regId);
assertNull(after.getUserConfirmedAt(),
"recommend/followup/complete 红娘侧动作不得代替用户确认(userConfirmedAt 必须仍为 null)");
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
"红娘侧动作不得推动报名状态(必须停留 pending 等待用户确认)");
}
// ==================== MM-CONTACT-001:平台内联系 = 通知中转,无联系方式交换 ====================
@Test
@DisplayName("MM-CONTACT-001(正向): 推荐/转介经平台通知触达用户,文案无手机号/微信号模式,收件面仅用户")
void platformNotificationsCarryNoContactPatternsAndOnlyTargetUsers() {
// 推荐路径:recommendPartner → sendAsync(type=matchmaker, businessType=matchmaker_recommendation)
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "003 正向推荐");
// 转介路径:referToServiceStation → sendMatchmakerReferralCreated(type=matchmaker, businessType=service_station)
referralService.referToServiceStation(MATCHMAKER_OWNER, user, "loc-ag-001",
"counselor_consultation", null, "备注");
List<Notification> notes = notificationMapper.selectList(new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, user)
.eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER));
assertFalse(notes.isEmpty(), "推荐/转介必须为用户落平台内服务通知(type=matchmaker)");
for (Notification note : notes) {
String title = note.getTitle() == null ? "" : note.getTitle();
String content = note.getContent() == null ? "" : note.getContent();
assertFalse(PHONE_PATTERN.matcher(title).find(),
"通知标题不得含手机号模式: " + title);
assertFalse(PHONE_PATTERN.matcher(content).find(),
"通知正文不得含手机号模式: " + content);
for (String token : WECHAT_TOKENS) {
assertFalse(title.toLowerCase().contains(token),
"通知标题不得含微信引导词「" + token + "」: " + title);
assertFalse(content.toLowerCase().contains(token),
"通知正文不得含微信引导词「" + token + "」: " + content);
}
}
// 负向佐证:通知收件面只有 C 端用户 —— 红娘 operatorId 不在 notification 收件面
long operatorInbox = notificationMapper.selectCount(new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, MATCHMAKER_OWNER));
assertEquals(0L, operatorInbox,
"红娘不得成为通知收件人(联系方式交换无 operator 侧触达面)");
}
}
@@ -0,0 +1,511 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy;
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.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ZC-MM-002(A1 服务对象分配建模)验收测试 —— 真实 H2 数据构造。
*
* <p>覆盖:
* <ul>
* <li>MM-AUTH-001:红娘对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象;</li>
* <li>MM-AUTH-002(负向):访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计;</li>
* <li>MM-AUTH-003(负向):访问他人(红娘D)分配对象 → 拒绝 + 审计;</li>
* <li>MM-AUTH-004(负向):POST /services/{id}/claim → 404(D3:claim 端点已删除);</li>
* <li>MM-AUDIT-001:create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者;</li>
* <li>MM-LIFECYCLE-001:end/revoke 后 canAccessUser=false;expires_at 届满惰性 expired;</li>
* <li>负向:组长跨组织分配 → 403 + 审计;普通红娘调 /pending → 403 + 审计。</li>
* </ul>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MatchmakerAssignmentA1Test {
@Autowired private MatchmakerAssignmentService assignmentService;
@Autowired private ServiceAssignmentPolicy assignmentPolicy;
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
@Autowired private OperatorMapper operatorMapper;
@Autowired private UserMapper userMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private MatchmakerAssignmentController assignmentController;
@Autowired private MatchmakerOperatorController operatorController;
private String leaderA; // 组长,org X
private String mmB; // 红娘,org X
private String mmC; // 红娘,org Y
private String mmD; // 红娘,org X
private String user1; // B 的服务对象
private String user2; // 无人分配
private String user3; // D 的服务对象
private static final String ORG_X = "11111111-1111-1111-1111-111111111111";
private static final String ORG_Y = "22222222-2222-2222-2222-222222222222";
@BeforeEach
void setUp() {
leaderA = createOperator("组长A", ORG_X);
mmB = createOperator("红娘B", ORG_X);
mmC = createOperator("红娘C", ORG_Y);
mmD = createOperator("红娘D", ORG_X);
user1 = createUser("a1-user1");
user2 = createUser("a1-user2");
user3 = createUser("a1-user3");
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ==================== 数据构造 ====================
private String createOperator(String name, String organizationId) {
Operator op = new Operator();
op.setName(name);
op.setOrganizationId(organizationId);
op.setUsername("a1_" + name + "_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
return op.getId();
}
private String createUser(String tag) {
User u = new User();
u.setWechatOpenid("a1_" + tag + "_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.insert(u);
return u.getId();
}
/** 以指定操作者身份 + 权限集设置 SecurityContext(方法级 @PreAuthorize 据此判定)。 */
private void authenticate(String operatorId, String... permissions) {
Set<String> permSet = Set.of(permissions);
var authorities = Stream.concat(
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new OperatorPrincipal(operatorId, permSet), null, authorities));
}
private long countAudits(String action, String operatorId) {
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, action)
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
}
// ==================== MM-AUTH-001 ====================
@Test
@DisplayName("MM-AUTH-001: 红娘B 对本人有效分配对象 canAccessUser=true,GET /assignments/my 含该对象")
void mmAuth001_matchmakerAccessesOwnAssignedUser() {
// 组长A(同组织 X,team.assign 路径)创建 B → user1 分配
MatchmakerAssignment created = assignmentService.create(
leaderA, false, user1, mmB, null, "同一社区,就近服务", null);
assertNotNull(created.getId());
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, created.getStatus());
assertEquals(Constants.ASSIGNMENT_SCOPE_PRIMARY, created.getAssignmentScope());
assertTrue(assignmentPolicy.canAccessUser(mmB, user1),
"红娘B 必须能访问其名下生效分配的服务对象");
// GET /assignments/my(matchmaker.service 权限)包含该对象
authenticate(mmB, "matchmaker.view", "matchmaker.service");
var result = assignmentController.my(new OperatorPrincipal(mmB,
Set.of("matchmaker.view", "matchmaker.service")));
assertTrue(result.isSuccess());
List<Map<String, Object>> data = result.getData();
assertNotNull(data);
assertEquals(1, data.size(), "GET /my 应恰好返回红娘B 名下的生效分配");
assertEquals(user1, data.get(0).get("userId"));
}
// ==================== MM-AUTH-002 / 003 ====================
@Test
@DisplayName("MM-AUTH-002(负向): B 访问未分配用户 → DataScopeGuard 拒绝 + DATA_SCOPE_DENIED 审计")
void mmAuth002_accessUnassignedUserDeniedAndAudited() {
assertFalse(assignmentPolicy.canAccessUser(mmB, user2));
long before = countAudits("DATA_SCOPE_DENIED", mmB);
assertThrows(AccessDeniedException.class,
() -> assignmentPolicy.assertActiveAssignment(mmB, user2, "matchmaker_user"),
"未持有分配的红娘访问用户必须被拒绝");
long after = countAudits("DATA_SCOPE_DENIED", mmB);
assertEquals(before + 1, after, "拒绝必须落一条 DATA_SCOPE_DENIED 审计行");
}
@Test
@DisplayName("MM-AUTH-003(负向): B 访问他人(红娘D)分配对象 → 拒绝 + 审计")
void mmAuth003_accessOthersAssignedUserDeniedAndAudited() {
// D 名下分配(组长A 同组织创建)
assignmentService.create(leaderA, false, user3, mmD, null, "D 负责该用户", null);
assertTrue(assignmentPolicy.canAccessUser(mmD, user3));
assertFalse(assignmentPolicy.canAccessUser(mmB, user3), "他人的分配对象对 B 不可见");
long before = countAudits("DATA_SCOPE_DENIED", mmB);
assertThrows(AccessDeniedException.class,
() -> assignmentPolicy.assertActiveAssignment(mmB, user3, "matchmaker_user"),
"B 访问红娘D 的分配对象必须被拒绝");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", mmB),
"拒绝必须落 DATA_SCOPE_DENIED 审计");
}
// ==================== MM-AUTH-004 ====================
@Test
@DisplayName("MM-AUTH-004(负向): POST /services/{id}/claim → 404(claim 端点已删除)")
void mmAuth004_claimEndpointRemoved() throws Exception {
// standalone MockMvc:仅注册真实控制器路由,验证 /claim 已不在映射中(D3 决策)
MatchmakerServiceService stubService = Mockito.mock(MatchmakerServiceService.class);
MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new MatchmakerOperatorController(stubService))
.build();
mockMvc.perform(post("/operator/matchmaker/services/some-id/claim"))
.andExpect(status().isNotFound());
}
// ==================== MM-AUDIT-001 ====================
@Test
@DisplayName("MM-AUDIT-001: create/transfer/revoke/end 各产生对应 action 审计且 operatorId=操作者")
void mmAudit001_eachOperationWritesAuditWithActor() {
// 1) create: A 分配 B → user1
MatchmakerAssignment a1 = assignmentService.create(
leaderA, false, user1, mmB, null, "初始分配", null);
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA) > 0,
"create 必须落 MATCHMAKER_ASSIGNMENT_CREATE 审计");
// 2) transfer: B → D
MatchmakerAssignment a2 = assignmentService.transfer(
a1.getId(), leaderA, false, mmD, "B 休假,转给 D");
assertEquals(mmD, a2.getMatchmakerOperatorId());
assertEquals(Constants.ASSIGNMENT_STATUS_TRANSFERRED,
assignmentMapper.findById(a1.getId()).orElseThrow().getStatus());
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, leaderA) > 0,
"transfer 必须落 MATCHMAKER_ASSIGNMENT_TRANSFER 审计");
// 3) revoke: 撤销 D 名下的新分配
assignmentService.revoke(a2.getId(), leaderA, false, "用户主动申请更换");
assertEquals(Constants.ASSIGNMENT_STATUS_REVOKED,
assignmentMapper.findById(a2.getId()).orElseThrow().getStatus());
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, leaderA) > 0,
"revoke 必须落 MATCHMAKER_ASSIGNMENT_REVOKE 审计");
// 4) end: 新建再结束
MatchmakerAssignment a3 = assignmentService.create(
leaderA, false, user2, mmB, null, "第二次分配", null);
assignmentService.end(a3.getId(), leaderA, false, "服务完结");
assertEquals(Constants.ASSIGNMENT_STATUS_ENDED,
assignmentMapper.findById(a3.getId()).orElseThrow().getStatus());
assertTrue(countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_END, leaderA) > 0,
"end 必须落 MATCHMAKER_ASSIGNMENT_END 审计");
// 审计行 operatorId 必须是操作者(组长A),且 resourceType 正确
// 注:本用例共 5 次操作(create×2 + transfer + revoke + end),审计 1:1 对应
List<AuditLog> audits = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getOperatorId, leaderA)
.in(AuditLog::getAction, List.of(
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE,
Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER,
Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE,
Constants.AUDIT_ACTION_ASSIGNMENT_END)));
assertEquals(5, audits.size(), "五次操作各产生一条审计(create×2/transfer/revoke/end)");
assertEquals(2, audits.stream().filter(a ->
Constants.AUDIT_ACTION_ASSIGNMENT_CREATE.equals(a.getAction())).count());
assertTrue(audits.stream().allMatch(a -> "matchmaker_assignment".equals(a.getResourceType())),
"resourceType 必须是 matchmaker_assignment");
// afterData 摘要口径:只含 matchmakerOperatorId/scope/reason
assertTrue(audits.stream().allMatch(a -> a.getAfterData() != null
&& a.getAfterData().contains("matchmakerOperatorId=")
&& a.getAfterData().contains("scope=")
&& a.getAfterData().contains("reason=")),
"afterData 必须记录 operatorId/scope/reason 摘要");
}
// ==================== MM-LIFECYCLE-001 ====================
@Test
@DisplayName("MM-LIFECYCLE-001: end/revoke 后 canAccessUser=false;expires_at 届满惰性置 expired")
void mmLifecycle001_terminalStatesRevokeAccessAndLazyExpiry() {
// end 之后不可访问
MatchmakerAssignment a1 = assignmentService.create(
leaderA, false, user1, mmB, null, "待结束分配", null);
assertTrue(assignmentPolicy.canAccessUser(mmB, user1));
assignmentService.end(a1.getId(), leaderA, false, "服务完结");
assertFalse(assignmentPolicy.canAccessUser(mmB, user1), "end 后必须立即失去访问权");
// revoke 之后不可访问
MatchmakerAssignment a2 = assignmentService.create(
leaderA, false, user2, mmB, null, "待撤销分配", null);
assignmentService.revoke(a2.getId(), leaderA, false, "误分配");
assertFalse(assignmentPolicy.canAccessUser(mmB, user2), "revoke 后必须立即失去访问权");
// expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 读路径惰性置 expired
MatchmakerAssignment a3 = assignmentService.create(
leaderA, false, user3, mmB, null, "短期分配",
Instant.now().minusSeconds(3600));
assertTrue(assignmentPolicy.canAccessUser(mmB, user3) == false,
"已过有效期的分配必须被视为失效");
MatchmakerAssignment after = assignmentMapper.findById(a3.getId()).orElseThrow();
assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED, after.getStatus(),
"惰性过期必须落库(无定时任务,读路径触发)");
assertNotNull(after.getEndedAt());
assertNotNull(after.getEndReason());
// 终态一律拒绝再转换(D5 状态机矩阵)
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.revoke(a3.getId(), leaderA, false, "对已过期分配撤销"),
"expired 是终态,不允许再转换");
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.end(a1.getId(), leaderA, false, "对已结束分配再结束"),
"ended 是终态,不允许再转换");
}
// ==================== 组长组织边界(负向) ====================
@Test
@DisplayName("负向: 组长A 跨组织分配红娘C(org=Y) → 403 + DATA_SCOPE_DENIED 审计")
void leaderCannotAssignCrossOrganization() {
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
assertThrows(AccessDeniedException.class,
() -> assignmentService.create(leaderA, false, user2, mmC, null, "跨组织分配", null),
"组长(org=X)不得分配 org=Y 的红娘");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
"跨组织分配拒绝必须落 DATA_SCOPE_DENIED 审计");
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user2)).isEmpty(),
"被拒绝的分配不得留下任何分配记录");
}
@Test
@DisplayName("负向: 平台授权人员(matchmaker.assign)可跨组织分配;组长不可分配 org=NULL 红娘")
void globalAssignScopeAndNullOrgBoundary() {
// matchmaker.assign 持有者不受组织限制(leaderA 只是被用作操作者身份)
assertDoesNotThrow(() ->
assignmentService.create(leaderA, true, user1, mmC, null, "平台授权分配", null),
"matchmaker.assign 持有者不限组织");
// org=NULL 的红娘:平台直属组长只能分配平台直属红娘(NULL=NULL 规则)
String platformLeader = createOperator("平台直属组长", null);
String platformMm = createOperator("平台直属红娘", null);
assertDoesNotThrow(() ->
assignmentService.create(platformLeader, false, user2, platformMm, null, "平台直属互配", null),
"NULL=NULL(平台直属 → 平台直属)必须放行");
assertThrows(AccessDeniedException.class,
() -> assignmentService.create(platformLeader, false, user3, mmB, null, "平台直属分配有组织红娘", null),
"平台直属组长不得分配有组织的红娘(NULL≠org X)");
}
// ==================== /pending 方法级权限(负向) ====================
@Test
@DisplayName("负向: 普通红娘(无分配权限)调 /pending → 403 + AUTHORIZATION_DENIED 审计")
void normalMatchmakerCannotListPendingQueue() {
authenticate(mmB, "matchmaker.view", "matchmaker.service");
long before = countAudits("AUTHORIZATION_DENIED", mmB);
assertThrows(AccessDeniedException.class,
() -> operatorController.getPending(
new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))),
"/pending 方法级权限收敛后,普通红娘必须 403");
assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB),
"方法级授权拒绝必须落 AUTHORIZATION_DENIED 审计");
}
// ==================== QA 验收补充(Edward):边界与异常路径 ====================
@Test
@DisplayName("QA补充(负向): 理由为 null/空串/纯空格 → 400 VALIDATION_ERROR,且不落任何分配记录或 CREATE 审计")
void blankReasonMustBeRejectedWithValidationError() {
// null 理由
var ex1 = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmB, null, null, null),
"reason=null 必须被拒绝");
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex1.getCode(),
"理由缺失必须是 400 VALIDATION_ERROR,而非 500/403");
// 空串理由
var ex2 = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmB, null, "", null));
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex2.getCode());
// 纯空格理由(isBlank 语义,防止" "绕过)
var ex3 = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmB, null, " ", null));
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR, ex3.getCode());
// revoke/end/transfer 同口径
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.VALIDATION_ERROR,
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.transfer("any-id", leaderA, false, mmB, " ")).getCode(),
"转分配理由空白同样必须 VALIDATION_ERROR");
// 三次被拒的 create 不得留下分配记录,也不得落 CREATE 审计
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user1)).isEmpty(),
"被拒绝的分配不得留下任何分配记录");
assertEquals(0, countAudits(Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, leaderA),
"被拒绝的分配不得产生 CREATE 审计");
}
@Test
@DisplayName("QA补充: collaborating 分配与 active+primary 共存,不触发主责部分唯一索引冲突")
void collaboratingAssignmentMustNotConflictWithActivePrimary() {
// 先建立主责分配(组长A → 红娘B)
assignmentService.create(leaderA, false, user1, mmB, null, "主责分配", null);
// 直接落一条同用户、同红娘的 collaborating 生效中分配(协同注记不占主责唯一位)
MatchmakerAssignment collab = new MatchmakerAssignment();
collab.setUserId(user1);
collab.setMatchmakerOperatorId(mmB);
collab.setAssignedBy(leaderA);
collab.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_COLLABORATING);
collab.setAssignmentReason("协同服务(组长指派协同)");
collab.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
collab.setAssignedAt(Instant.now());
assertDoesNotThrow(() -> assignmentMapper.insert(collab),
"collaborating 生效中分配不得触发 uq_mm_assignment_active_primary 冲突");
// 主责仍在、协同也在,两条均为生效中
assertEquals(2, assignmentMapper.selectCount(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user1)
.eq(MatchmakerAssignment::getStatus, Constants.ASSIGNMENT_STATUS_ACTIVE)),
"同一用户应同时存在 1 条 active 主责 + 1 条 active 协同");
// 主责唯一性仍然生效:同用户第二条 primary 必须被拒(对照组)
assertThrows(Exception.class,
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null),
"同用户第二条 active primary 必须被唯一性约束拒绝");
// 预检路径的 CONFLICT
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.create(leaderA, false, user1, mmD, null, "第二条主责", null)).getCode());
}
@Test
@DisplayName("QA补充(负向): 有组织组长分配 org=NULL(平台直属)红娘 → 403(Objects.equals 非对称方向的显式覆盖)")
void orgLeaderMustNotAssignPlatformDirectMatchmaker() {
// org=NULL 红娘(平台直属)
String platformMm = createOperator("平台直属红娘-供拒配", null);
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
// leaderA(org X)→ org=NULL 红娘:Objects.equals(orgX, null)=false,必须拒绝
assertThrows(AccessDeniedException.class,
() -> assignmentService.create(leaderA, false, user2, platformMm, null, "有组织组长碰平台直属红娘", null),
"有组织组长不得分配 org=NULL(平台直属)红娘");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
"拒绝必须落 DATA_SCOPE_DENIED 审计");
// 同理 transfer 的范围校验也覆盖该方向(ZC-MM-006 / T04:transfer 404 化)
MatchmakerAssignment a = assignmentService.create(leaderA, false, user3, mmB, null, "主责", null);
var transferDenied = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.transfer(a.getId(), leaderA, false, platformMm, "转给平台直属红娘"),
"有组织组长不得把分配转给 org=NULL 红娘(404 化拒绝)");
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, transferDenied.getCode(),
"transfer 范围违规必须 404 化(防探测,T04)");
}
@Test
@DisplayName("P2-1补充(负向): org=X 组长转分配 org=Y 红娘持有的分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(原持有人范围校验)")
void leaderCannotTransferAssignmentHeldByCrossOrgMatchmaker() {
// org=Y 红娘C 持有一条分配(由平台授权路径创建,绕过 create 的范围校验以构造前置状态)
MatchmakerAssignment held = assignmentService.create(
leaderA, true, user1, mmC, null, "平台授权分配给C", null);
assertEquals(mmC, held.getMatchmakerOperatorId());
long before = countAudits("DATA_SCOPE_DENIED", leaderA);
// org=X 组长A 把 C(org=Y)持有的分配转给同组织成员 B:原持有人范围违规,必须拒绝
// (ZC-MM-006 / T04:transfer 为资源寻址写端点,拒绝 404 化,审计仍为 DATA_SCOPE_DENIED)
var denied = assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> assignmentService.transfer(held.getId(), leaderA, false, mmB, "转回本组织"),
"组长不得处置跨组织红娘持有的分配(原持有人范围校验,404 化)");
assertEquals(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, denied.getCode(),
"transfer 范围违规必须 404 化(防探测,T04)");
assertEquals(before + 1, countAudits("DATA_SCOPE_DENIED", leaderA),
"拒绝必须落 DATA_SCOPE_DENIED 审计");
// 被转记录必须原样保留:仍 active 且持有人仍是 C,未产生新分配
MatchmakerAssignment after = assignmentMapper.findById(held.getId()).orElseThrow();
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE, after.getStatus(),
"被拒绝的转分配不得改变原分配状态");
assertEquals(mmC, after.getMatchmakerOperatorId(),
"被拒绝的转分配不得改变原持有人");
assertTrue(assignmentMapper.selectList(new LambdaQueryWrapper<MatchmakerAssignment>()
.eq(MatchmakerAssignment::getUserId, user1)
.eq(MatchmakerAssignment::getMatchmakerOperatorId, mmB))
.isEmpty(),
"被拒绝的转分配不得给目标红娘留下任何分配记录");
}
// ==================== 分配同事务联动服务单 ====================
@Autowired
private com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper realServiceMapper;
@Test
@DisplayName("联动: 带 serviceId 的分配同事务把服务单置 assigned 并写回 assigned_operator_id")
void createWithServiceIdLinksServiceRow() {
// 真实落库一条 submitted 服务单
var record = new com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord();
record.setUserId(user1);
record.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
record.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
realServiceMapper.insert(record);
assignmentService.create(leaderA, false, user1, mmB, record.getId(), "带服务单分配", null);
var updated = realServiceMapper.selectById(record.getId());
assertEquals(Constants.MATCHMAKER_STATUS_ASSIGNED, updated.getStatus(),
"分配联动必须把服务单置为 assigned");
assertEquals(mmB, updated.getAssignedOperatorId(),
"服务单 assigned_operator_id 必须写为目标红娘");
}
}
@@ -0,0 +1,132 @@
package com.danyang.match.platform.matchmaker;
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
import com.danyang.match.platform.matchmaker.controller.MatchmakerOperatorController;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-002(A1)权限契约守卫(standalone 反射扫描,无需 Spring 上下文)。
*
* <p>锁定两条不变量:
* <ol>
* <li>{@code MatchmakerAssignmentController} 每个端点都有显式 @PreAuthorize;
* 写操作(create/transfer/revoke/end/assignable)必须同时包含
* {@code matchmaker.assign} 或 {@code matchmaker.team.assign};
* 读自己名下(/my)必须是 {@code matchmaker.service}。</li>
* <li>{@code MatchmakerOperatorController} 无 claim 残留(D3:claim 端点/方法已删除)。</li>
* </ol>
*/
class MatchmakerAssignmentPermissionTest {
@Test
@DisplayName("AssignmentController:全部端点有 @PreAuthorize,写操作限定分配权限")
void assignmentControllerPermissionContract() {
List<String> violations = new ArrayList<>();
for (Method method : MatchmakerAssignmentController.class.getDeclaredMethods()) {
if (paths(method).isEmpty()) {
continue; // 非端点方法
}
PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(method, PreAuthorize.class);
if (preAuthorize == null) {
violations.add(method.getName() + ": 缺少 @PreAuthorize");
continue;
}
String expr = preAuthorize.value();
boolean isReadMy = method.getName().equals("my");
boolean isTeamStatistics = method.getName().equals("teamStatistics");
if (isReadMy) {
if (!expr.contains("matchmaker.service")) {
violations.add("my: 读接口必须要求 matchmaker.service,实际=" + expr);
}
} else if (isTeamStatistics) {
// ZC-MM-005/T03:纯组长数据,只要求 team.assign(更强而非更弱)
if (!expr.contains("matchmaker.team.assign")) {
violations.add("teamStatistics: 必须要求 matchmaker.team.assign,实际=" + expr);
}
} else {
boolean hasAssignPermission = expr.contains("matchmaker.assign")
&& expr.contains("matchmaker.team.assign");
if (!hasAssignPermission) {
violations.add(method.getName()
+ ": 写操作必须同时限定 matchmaker.assign / matchmaker.team.assign,实际=" + expr);
}
}
}
assertEquals(List.of(), violations, "AssignmentController 权限契约被破坏");
}
@Test
@DisplayName("守卫自检:必须真的扫到 AssignmentController 端点(防守卫失效)")
void guardMustActuallySeeEndpoints() {
long endpointCount = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods())
.filter(m -> !paths(m).isEmpty())
.count();
assertTrue(endpointCount >= 5,
"扫描到的端点数异常偏少(" + endpointCount + "),守卫可能失效");
}
@Test
@DisplayName("OperatorController:无 claim 残留(端点路径与方法名均不得含 claim)")
void operatorControllerMustNotHaveClaimResidue() {
List<String> residue = new ArrayList<>();
for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) {
if (method.getName().toLowerCase().contains("claim")) {
residue.add("方法名残留: " + method.getName());
}
if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("claim"))) {
residue.add("路径残留: " + method.getName());
}
}
assertEquals(List.of(), residue,
"D3 决策:claim(红娘接单)端点必须彻底删除,归属由 matchmaker_assignments 建模");
}
@Test
@DisplayName("ZC-MM-005/E4:OperatorController 无 my-services 残留(死端点已删除)")
void operatorControllerMustNotHaveMyServicesResidue() {
List<String> residue = new ArrayList<>();
for (Method method : MatchmakerOperatorController.class.getDeclaredMethods()) {
String lower = method.getName().toLowerCase();
if (lower.contains("myservices") || lower.contains("my_services")) {
residue.add("方法名残留: " + method.getName());
}
if (paths(method).stream().anyMatch(p -> p.toLowerCase().contains("my-services"))) {
residue.add("路径残留: " + method.getName());
}
}
assertEquals(List.of(), residue,
"GET /my-services 是死端点(按 C 端 user_id 查 operator 恒空、前后端零调用),"
+ "替代路径为 GET /operator/matchmaker/assignments/my");
}
@Test
@DisplayName("ZC-MM-005/T03:team/statistics 端点必须显式限定 matchmaker.team.assign")
void teamStatisticsMustRequireTeamAssignPermission() {
Method statistics = Arrays.stream(MatchmakerAssignmentController.class.getDeclaredMethods())
.filter(m -> m.getName().equals("teamStatistics"))
.findFirst()
.orElseThrow(() -> new AssertionError("teamStatistics 端点不存在"));
PreAuthorize preAuthorize = AnnotatedElementUtils.findMergedAnnotation(statistics, PreAuthorize.class);
assertNotNull(preAuthorize, "team/statistics 缺少 @PreAuthorize");
assertTrue(preAuthorize.value().contains("matchmaker.team.assign"),
"team/statistics 必须限定 matchmaker.team.assign(纯组长数据),实际="
+ preAuthorize.value());
}
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
? mapping.path() : mapping.value());
}
}
@@ -0,0 +1,203 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-006 / T01 验收:audit_logs 扩展字段(V055)写入链路。
*
* <p>覆盖:
* <ul>
* <li>扩展写入(正向):assign / recommend 审计行落 purpose_code + service_assignment_id;</li>
* <li>兼容性(负向·缺省调用):applyService 走旧签名(purposeCode/assignmentId 缺省)仍可写,
* purpose_code 为 null —— 证明扩展列全可空、存量行为不变;</li>
* <li>拒绝路径(负向):无分配 recommend → AccessDeniedException + DATA_SCOPE_DENIED 审计,
* 且审计行带 actor_role_snapshot(SecurityContext 权限快照)+ request_trace_id(MDC)。</li>
* </ul>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MatchmakerAuditFieldsTest {
@Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private MatchmakerAssignmentService assignmentService;
@Autowired private MatchmakerServiceMapper serviceMapper;
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
@Autowired private OperatorMapper operatorMapper;
@Autowired private UserMapper userMapper;
@Autowired private AuditLogMapper auditLogMapper;
private String matchmakerId;
private String userId;
@BeforeEach
void setUp() {
matchmakerId = createOperator();
userId = createUser();
}
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
MDC.remove("requestId");
}
// ==================== 数据构造(Phase13 范式) ====================
private String createOperator() {
Operator op = new Operator();
op.setName("审计字段测试红娘");
op.setUsername("audit_fields_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
return op.getId();
}
private String createUser() {
User u = new User();
u.setWechatOpenid("audit_fields_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.insert(u);
return u.getId();
}
private String createService(String operatorId) {
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setUserId(userId);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setDescription("审计字段测试服务单");
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
svc.setAssignedOperatorId(operatorId);
serviceMapper.insert(svc);
return svc.getId();
}
private void authenticate(String operatorId, String... permissions) {
Set<String> permSet = Set.of(permissions);
var authorities = Stream.concat(
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new OperatorPrincipal(operatorId, permSet), null, authorities));
}
private AuditLog latestAudit(String action, String resourceId) {
List<AuditLog> rows = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, action)
.eq(AuditLog::getResourceId, resourceId)
.orderByDesc(AuditLog::getCreatedAt));
return rows.isEmpty() ? null : rows.get(0);
}
// ==================== 正向:扩展写入 ====================
@Test
@DisplayName("T01 正向: assignService 审计行落 purpose_code=matchmaker.service.assign + service_assignment_id")
void assignAuditCarriesPurposeCodeAndAssignmentId() {
String serviceId = createService(matchmakerId);
matchmakerServiceService.assignService(serviceId, matchmakerId, matchmakerId, "T01 分配审计");
MatchmakerAssignment assignment = assignmentMapper
.findActiveByOperatorAndUser(matchmakerId, userId).orElseThrow();
AuditLog audit = latestAudit("MATCHMAKER_ASSIGN", serviceId);
assertNotNull(audit, "分配审计行必须存在");
assertEquals("matchmaker.service.assign", audit.getPurposeCode(), "purpose_code 必须落库");
assertEquals(assignment.getId(), audit.getServiceAssignmentId(),
"service_assignment_id 必须关联新建分配");
}
@Test
@DisplayName("T01 正向: recommendPartner 审计行落 purpose_code=matchmaker.service.recommend + 分配 ID")
void recommendAuditCarriesPurposeCodeAndAssignmentId() {
String serviceId = createService(matchmakerId);
MatchmakerAssignment assignment = assignmentService.create(matchmakerId, true,
userId, matchmakerId, serviceId, "T01 前置分配", null);
matchmakerServiceService.recommendPartner(serviceId, matchmakerId,
"audit-target-" + System.nanoTime(), "T01 推荐审计");
AuditLog audit = latestAudit("MATCHMAKER_RECOMMEND", serviceId);
assertNotNull(audit, "推荐审计行必须存在");
assertEquals("matchmaker.service.recommend", audit.getPurposeCode());
assertEquals(assignment.getId(), audit.getServiceAssignmentId());
}
// ==================== 负向·兼容性:缺省调用仍可写 ====================
@Test
@DisplayName("T01 负向(兼容): 旧签名审计调用(applyService)不传 purposeCode/assignmentId 仍成功写入,扩展列为 null")
void legacyAuditCallStillWritableWithNullExtendedFields() {
String serviceId = matchmakerServiceService
.applyService(userId, Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER, "T01 兼容性申请")
.get("serviceId").toString();
AuditLog audit = latestAudit("MATCHMAKER_APPLY", serviceId);
assertNotNull(audit, "缺省调用(旧签名)审计行必须仍可写入");
assertNull(audit.getPurposeCode(), "缺省调用 purpose_code 必须为 null(全可空)");
assertNull(audit.getServiceAssignmentId(), "缺省调用 service_assignment_id 必须为 null");
}
// ==================== 负向:拒绝路径带快照 + 追踪 ID ====================
@Test
@DisplayName("T01 负向: 无分配 recommend → 404 化拒绝(T04)+ DATA_SCOPE_DENIED 审计含权限快照与追踪 ID")
void deniedAuditCarriesActorRoleSnapshotAndTraceId() {
String serviceId = createService(matchmakerId);
// 无分配的红娘(matchmakerId 对 userId 无生效分配)+ SecurityContext 携带权限 → 快照非空
authenticate(matchmakerId, "matchmaker.service");
MDC.put("requestId", "trace01test0001");
BusinessException denied = assertThrows(BusinessException.class,
() -> matchmakerServiceService.recommendPartner(serviceId, matchmakerId,
"audit-target-x", "T01 拒绝路径"),
"无生效分配必须拒绝(ZC-MM-006 / T04 起为 404 化拒绝)");
assertEquals(ApiResponseCode.NOT_FOUND, denied.getCode(),
"写接口范围违规 404 化(T04 防探测)");
AuditLog audit = latestAudit("DATA_SCOPE_DENIED", serviceId);
assertNotNull(audit, "拒绝路径必须落 DATA_SCOPE_DENIED 审计");
assertEquals("matchmaker.service", audit.getActorRoleSnapshot(),
"快照必须来自 SecurityContext 权限(逗号拼接,截断 128)");
assertEquals("trace01test0001", audit.getRequestTraceId(),
"追踪 ID 必须取自 MDC(requestId)");
}
}
@@ -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());
}
}
@@ -0,0 +1,377 @@
package com.danyang.match.platform.matchmaker;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-003(B1 例外通道立项)守卫 —— standalone 源码扫描
* (沿用 {@code MatchmakerFieldPrivacyGuardTest} 范式,无需 Spring 上下文)。
*
* <p><b>ZC-MM-006→003 守卫翻转说明</b>:受控联系授权机制(V056 + ContactAuthorizationPolicy/
* Service,架构师设计 H4/G4)立项后,本守卫从「锁机制不存在」翻转为「锁新契约」:
* <ol>
* <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 {
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/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 原契约,053 阶段逐键核定,本阶段保留不变)。
*/
private static final Set<String> RESPONSE_KEY_WHITELIST = Set.of(
"appointmentId", "registrationId", "activityId", "id", "status", "type",
"serviceType", "referralType", "referralFromOperatorId", "needsUserConfirmation",
"confirmedAt", "userConfirmedAt", "createdByOperatorId", "createdAt",
"registeredAt", "userId");
/** 联系方式字样(出现在响应键名中即触发收敛判定) */
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
"emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
"idnumber", "id_card");
/** 联系/触达类端点词(出现在 mapping 路径中即触发前缀收敛判定) */
private static final List<String> CONTACT_ENDPOINT_TOKENS = List.of(
"contact", "phone", "channel", "offline");
/** 联系类端点仅允许的两个前缀(受控联系授权机制专属) */
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("① 联系回显收敛: 含联系方式字样的响应装配仅允许白名单文件(披露出口 + 既有合法落点)")
void contactEchoConfinedToWhitelistedFiles() 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 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,
"联系方式回显出现白名单外落点(守卫①)。\n违规项:\n - "
+ String.join("\n - ", violations));
}
// ==================== ② 联系类端点前缀收敛 ====================
@Test
@DisplayName("② 端点收敛: 受治理域内 contact/phone/channel/offline 字样端点仅限受控联系授权两前缀")
void contactEndpointsConfinedToAuthorizationPrefixes() throws IOException {
List<String> violations = new ArrayList<>();
// 扫描面 = 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 = stripComments(Files.readString(f, StandardCharsets.UTF_8));
String classPrefix = classMappingPrefix(src);
// 类级前缀 + 方法级路径任一含联系字样 → 完整路径必须落在白名单前缀内
Matcher mapping = Pattern.compile(
"@(Get|Post|Put|Delete|Patch)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
while (mapping.find()) {
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;
}
}
}
}
}
}
assertEquals(List.of(), violations,
"联系类端点出现收敛前缀外落点(守卫②)。\n违规项:\n - "
+ String.join("\n - ", violations));
}
// ==================== ③ 到期语义完整契约 ====================
@Test
@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 body = stripComments(Files.readString(f, StandardCharsets.UTF_8));
if (body.contains("@Scheduled")) {
violations.add(f.getFileName() + " 含 @Scheduled 定时任务"
+ "(禁止引入代替用户决定/代操作的自动化通道)");
}
}
}
}
assertEquals(List.of(), violations,
"既有 B1 契约(转介白名单/无批处理)被破坏(守卫⑤)。\n违规项:\n - "
+ String.join("\n - ", violations));
}
// ==================== 解析实现 ====================
/** 提取类级 @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;
}
}
return "";
}
/** 类级前缀 + 方法路径合成完整路径(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) {
return src.replaceAll("//[^\\n]*", "").replaceAll("/\\*.*?\\*/", "");
}
/** 定位源码根目录(与 SecurityPathCoverageTest 同款双候选)。 */
private static Path resolveSourceRoot() {
for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) {
Path path = Path.of(candidate);
if (Files.isDirectory(path)) {
return path;
}
}
throw new IllegalStateException(
"无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录="
+ Path.of("").toAbsolutePath());
}
}
@@ -0,0 +1,539 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.controller.MatchmakerAssignmentController;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.referral.service.ReferralService;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-005(统一数据范围与字段隐私)验收测试 —— 读侧纯化 + 防探测 404 + 字段白名单 + 组长统计。
*
* <p>覆盖:
* <ul>
* <li>MM-PRIVACY-001(负向×3):非持有人读详情 404+审计;查无 serviceId 响应与前者
* 逐字段一致(防探测);撤销后原持有人再读 404;</li>
* <li>MM-AUTH-001 读侧(正向):持分配红娘读完整详情、无 viewerIsOwner 双态残留;</li>
* <li>MM-AUTH-003 读侧(负向):读他人对象 → 404+审计;</li>
* <li>MM-LIFECYCLE-001 读侧:end/revoke/expired 后 404;</li>
* <li>组长团队统计正例 + 普通红娘 403+审计(负向);</li>
* <li>showOccupation 隐私开关:false 隐藏 occupationCode,true/未设置放行。</li>
* </ul>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MatchmakerDataScope005Test {
@Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private MatchmakerAssignmentService assignmentService;
@Autowired private MatchmakerServiceMapper serviceMapper;
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
@Autowired private OperatorMapper operatorMapper;
@Autowired private UserMapper userMapper;
@Autowired private UserProfileMapper userProfileMapper;
@Autowired private UserPrivacySettingsMapper userPrivacySettingsMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private MatchmakerAssignmentController assignmentController;
@Autowired private ReferralService referralService;
private String leaderA; // 组长,org X
private String mmB; // 红娘,org X
private String mmC; // 红娘,org Y
private String mmD; // 红娘,org X
private String user1;
private String user2;
private String user3;
private static final String ORG_X = "33333333-3333-3333-3333-333333333331";
private static final String ORG_Y = "33333333-3333-3333-3333-333333333332";
@BeforeEach
void setUp() {
leaderA = createOperator("组长A-005", ORG_X);
mmB = createOperator("红娘B-005", ORG_X);
mmC = createOperator("红娘C-005", ORG_Y);
mmD = createOperator("红娘D-005", ORG_X);
user1 = createUserWithProfile("ds-user1");
user2 = createUserWithProfile("ds-user2");
user3 = createUserWithProfile("ds-user3");
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ==================== 数据构造 ====================
private String createOperator(String name, String organizationId) {
Operator op = new Operator();
op.setName(name);
op.setOrganizationId(organizationId);
op.setUsername("ds_" + name + "_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
return op.getId();
}
private String createUserWithProfile(String tag) {
User u = new User();
u.setWechatOpenid("ds_" + tag + "_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.insert(u);
UserProfile profile = new UserProfile();
profile.setUserId(u.getId());
profile.setDisplayName(tag);
profile.setGender("female");
profile.setBirthDate(java.time.LocalDate.of(1995, 6, 15));
profile.setRegionName("丹阳市云阳街道");
profile.setEducationCode("bachelor");
profile.setOccupationCode("teacher");
profile.setMaritalStatus("never_married");
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
userProfileMapper.insert(profile);
return u.getId();
}
private String createService(String userId, String operatorId) {
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setUserId(userId);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setDescription("希望找一位本地稳定的伴侣");
// submitted:createAssignment 会联动把服务单置 assigned(A1 同事务联动语义)
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
svc.setAssignedOperatorId(operatorId);
serviceMapper.insert(svc);
return svc.getId();
}
/** 平台授权路径创建分配(绕过组织范围校验以构造前置状态),返回 assignmentId */
private String createAssignment(String operatorId, String userId, String serviceId) {
MatchmakerAssignment a = assignmentService.create(leaderA, true, userId, operatorId,
serviceId, "005 测试前置分配", null);
return a.getId();
}
private void authenticate(String operatorId, String... permissions) {
Set<String> permSet = Set.of(permissions);
var authorities = Stream.concat(
Stream.of(new SimpleGrantedAuthority("ROLE_OPERATOR")),
permSet.stream().map(SimpleGrantedAuthority::new)).toList();
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(
new OperatorPrincipal(operatorId, permSet), null, authorities));
}
private long countAudits(String action, String operatorId) {
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, action)
.eq(operatorId != null, AuditLog::getOperatorId, operatorId));
}
private long countDeniedAuditsForResource(String operatorId, String resourceId) {
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
.eq(AuditLog::getOperatorId, operatorId)
.eq(AuditLog::getResourceId, resourceId));
}
private BusinessException notFoundCall(String operatorId, String serviceId) {
try {
matchmakerServiceService.getServiceDetail(serviceId, operatorId);
} catch (BusinessException e) {
return e;
}
throw new AssertionError("预期抛出 BusinessException(NOT_FOUND),但正常返回了");
}
// ==================== MM-PRIVACY-001(负向×3) ====================
@Test
@DisplayName("MM-PRIVACY-001a(负向): 非持有人读服务单 → 404 + DATA_SCOPE_DENIED 审计(语义403)")
void mmPrivacy001a_nonHolderReadDetailNotFoundAndAudited() {
String serviceId = createService(user1, mmB);
createAssignment(mmB, user1, serviceId);
long before = countDeniedAuditsForResource(mmC, serviceId);
BusinessException e = notFoundCall(mmC, serviceId);
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "非持有人响应必须是 404(防探测)");
assertEquals("服务不存在", e.getMessage());
assertEquals(before + 1, countDeniedAuditsForResource(mmC, serviceId),
"越权读尝试必须先落 DATA_SCOPE_DENIED 审计(语义 403)");
}
@Test
@DisplayName("MM-PRIVACY-001b(负向): 查无 serviceId 的响应与非持有人 404 逐字段一致(防探测)")
void mmPrivacy001b_notFoundResponseIndistinguishableFromDenied() {
String serviceId = createService(user1, mmB);
createAssignment(mmB, user1, serviceId);
BusinessException denied = notFoundCall(mmC, serviceId); // 非持有人(真实存在)
BusinessException missing = notFoundCall(mmC, "no-such-service-id"); // 查无
// 全局异常处理器把 BusinessException 映射为 ApiResponse.error(code, message),
// code+message 一致 ⇒ 响应体逐字段一致(success/code/message),无法区分"无权"与"不存在"
assertEquals(denied.getCode(), missing.getCode(), "HTTP/业务码必须一致");
assertEquals(denied.getMessage(), missing.getMessage(), "响应 message 必须一致");
assertEquals(ApiResponseCode.NOT_FOUND, missing.getCode());
assertEquals("服务不存在", missing.getMessage());
}
@Test
@DisplayName("MM-PRIVACY-001c(负向): 撤销后原持有人再读详情 → 404(E1:无 assigned_operator_id 回退)")
void mmPrivacy001c_revokedHolderReadDetailNotFound() {
String serviceId = createService(user1, mmB);
String assignmentId = createAssignment(mmB, user1, serviceId);
// 撤销前持有人可读
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
assertNotNull(detail);
assignmentService.revoke(assignmentId, leaderA, false, "005 撤销验证");
// E1 核心断言:撤销后服务单仍残留 assigned_operator_id=mmB,但回退分支已删 → 404
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
"前置确认:服务单 assigned_operator_id 残留(回退分支必须对此免疫)");
long before = countDeniedAuditsForResource(mmB, serviceId);
BusinessException e = notFoundCall(mmB, serviceId);
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId),
"原持有人失权后的读取也必须落 DATA_SCOPE_DENIED 审计");
}
// ==================== MM-AUTH-001 读侧(正向) ====================
@Test
@DisplayName("MM-AUTH-001 读侧: 持分配红娘读详情 200 语义、完整字段、无 viewerIsOwner 双态残留")
void mmAuth001_readSideHolderGetsFullDetailWithoutDualState() {
String serviceId = createService(user1, mmB);
createAssignment(mmB, user1, serviceId);
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
assertNotNull(detail);
assertEquals(user1, detail.get("userId"));
assertEquals("希望找一位本地稳定的伴侣", detail.get("description"));
assertTrue(detail.containsKey("userProfile"), "持有人可见白名单用户资料");
assertTrue(detail.containsKey("followups"), "持有人可见跟进正文");
assertTrue(detail.containsKey("recommendations"), "持有人可见推荐列表");
// E1:双态残留字段必须消失
assertFalse(detail.containsKey("viewerIsOwner"), "viewerIsOwner 双态字段已删除");
assertFalse(detail.containsKey("followupCount"), "脱敏分支的 followupCount 摘要已随 404 消失");
// E3:userProfile 白名单键集合
@SuppressWarnings("unchecked")
Map<String, Object> userProfile = (Map<String, Object>) detail.get("userProfile");
Set<String> whitelist = Set.of("displayName", "gender", "age", "regionName",
"educationCode", "occupationCode", "maritalStatus");
assertTrue(whitelist.containsAll(userProfile.keySet()),
"userProfile 键必须落在白名单内,实际=" + userProfile.keySet());
assertEquals("丹阳市云阳街道", userProfile.get("regionName"));
}
// ==================== MM-AUTH-003 读侧(负向) ====================
@Test
@DisplayName("MM-AUTH-003 读侧(负向): B 读 D 的服务对象详情 → 404 + 审计")
void mmAuth003_readSideCrossHolderAccessDeniedAndAudited() {
String serviceId = createService(user3, mmD);
createAssignment(mmD, user3, serviceId);
long before = countDeniedAuditsForResource(mmB, serviceId);
BusinessException e = notFoundCall(mmB, serviceId);
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
assertEquals(before + 1, countDeniedAuditsForResource(mmB, serviceId));
}
// ==================== MM-LIFECYCLE-001 读侧 ====================
@Test
@DisplayName("MM-LIFECYCLE-001 读侧: end 后 404;expires_at 届满惰性置 expired 后 404")
void mmLifecycle001_readSideTerminalStatesAllNotFound() {
// end 后 404
String serviceId1 = createService(user1, mmB);
String a1 = createAssignment(mmB, user1, serviceId1);
assignmentService.end(a1, leaderA, false, "005 服务完结");
assertEquals(ApiResponseCode.NOT_FOUND,
notFoundCall(mmB, serviceId1).getCode(), "end 后读详情必须 404");
// expires_at 届满(固定时间注入:写入已过期的 expires_at)→ 惰性 expired → 404
String serviceId2 = createService(user2, mmB);
MatchmakerAssignment a2 = assignmentService.create(leaderA, true, user2, mmB,
serviceId2, "短期分配", Instant.now().minusSeconds(3600));
assertEquals(ApiResponseCode.NOT_FOUND,
notFoundCall(mmB, serviceId2).getCode(), "已过期分配读详情必须 404");
assertEquals(Constants.ASSIGNMENT_STATUS_EXPIRED,
assignmentMapper.findById(a2.getId()).orElseThrow().getStatus(),
"惰性过期必须落库");
}
// ==================== E1 边界:active 分配 × completed 服务单 ====================
@Test
@DisplayName("E1 边界: active 分配 + completed 服务单 → 持有人可读详情(200);分配终态(end)后同单再读→404")
void activeAssignmentWithCompletedServiceStillReadableThen404AfterEnd() {
String serviceId = createService(user1, mmB);
String assignmentId = createAssignment(mmB, user1, serviceId);
// 推进服务单到 in_service 并完成。completeService 仅改服务单状态、不结束分配
// (服务单与分配是两条独立生命周期)
MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId);
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
serviceMapper.updateById(svc);
matchmakerServiceService.completeService(serviceId, mmB);
assertEquals(Constants.MATCHMAKER_STATUS_COMPLETED,
serviceMapper.selectById(serviceId).getStatus());
// 分配仍处于 active → mmB 在该用户数据范围内,读 completed 单应放行(200,非 404)。
// 说明:决策"completed 服务单 404"语义实为"分配已终态则 404"(代码 L348 注释"分配已终态"),
// 本用例锁定"分配仍 active"时 completed 单仍属本人数据范围、可正常回看。
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
assertNotNull(detail, "active 分配持有人读 completed 服务单应放行(200),不应 404");
assertEquals(user1, detail.get("userId"));
// 对照:分配 end(终态)后,同一 completed 单再读 → 404(E1:防 assigned_operator_id 回退)
assignmentService.end(assignmentId, leaderA, false, "005 边界:服务完结后结束分配");
assertEquals(ApiResponseCode.NOT_FOUND, notFoundCall(mmB, serviceId).getCode(),
"分配终态后读 completed 服务单详情必须 404");
}
// ==================== 组长团队统计(T03) ====================
@Test
@DisplayName("组长统计: 以组长 org 为边界(跨组织不计入);NULL 组长统计平台直属")
void teamStatisticsScopedByLeaderOrganization() {
createAssignment(mmB, user1, null);
createAssignment(mmD, user3, null);
createAssignment(mmC, user2, null); // org Y,不应计入 org X 组长的统计
Map<String, Object> stats = assignmentService.teamStatistics(leaderA);
assertEquals(ORG_X, stats.get("organizationId"));
assertEquals(2L, stats.get("activeAssignmentCount"), "跨组织分配不得计入");
assertEquals(2L, stats.get("activeMatchmakerCount"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> perMatchmaker = (List<Map<String, Object>>) stats.get("perMatchmakerCounts");
assertEquals(2, perMatchmaker.size());
assertTrue(perMatchmaker.stream().allMatch(row ->
Set.of("operatorId", "operatorName", "activeCount").equals(row.keySet())
|| row.keySet().containsAll(Set.of("operatorId", "operatorName", "activeCount"))),
"分组行键必须为 operatorId/operatorName/activeCount(脱敏,无联系方式),实际=" + perMatchmaker);
// NULL 组长 → 平台直属边界(IS NOT DISTINCT FROM NULL)
String platformLeader = createOperator("平台直属组长-005", null);
String platformMm = createOperator("平台直属红娘-005", null);
String platformUser = createUserWithProfile("ds-platform-user");
assignmentService.create(platformLeader, true, platformUser, platformMm, null,
"平台直属分配", null);
Map<String, Object> platformStats = assignmentService.teamStatistics(platformLeader);
assertNull(platformStats.get("organizationId"));
assertEquals(1L, platformStats.get("activeAssignmentCount"),
"NULL 组长统计平台直属(IS NOT DISTINCT FROM NULL)");
// 该平台直属分配不得计入 orgX 组长的统计
assertEquals(2L, assignmentService.teamStatistics(leaderA).get("activeAssignmentCount"),
"平台直属分配不得计入有组织组长的统计");
}
@Test
@DisplayName("负向: 普通红娘(无 team.assign)调 team/statistics → 403 + AUTHORIZATION_DENIED 审计")
void normalMatchmakerCannotReadTeamStatistics() {
// 必须走 Spring 注入的代理 Bean(@PreAuthorize 方法级安全在代理上生效)
authenticate(mmB, "matchmaker.view", "matchmaker.service");
long before = countAudits("AUTHORIZATION_DENIED", mmB);
assertThrows(AccessDeniedException.class,
() -> assignmentController.teamStatistics(
new OperatorPrincipal(mmB, Set.of("matchmaker.view", "matchmaker.service"))),
"team/statistics 仅组长(team.assign)可读");
assertEquals(before + 1, countAudits("AUTHORIZATION_DENIED", mmB),
"授权拒绝必须落 AUTHORIZATION_DENIED 审计");
}
// ==================== showOccupation 隐私开关(E3) ====================
// ==================== P1-1:推荐列表切判定器(读路径) ====================
private MatchmakerRecommendation createRecommendation(String serviceId, String userId,
String targetUserId, String operatorId) {
MatchmakerRecommendation rec = new MatchmakerRecommendation();
rec.setId(UUID.randomUUID().toString());
rec.setServiceId(serviceId);
rec.setUserId(userId);
rec.setTargetUserId(targetUserId);
rec.setOperatorId(operatorId);
rec.setReason("005 收口测试推荐");
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
recommendationMapper.insert(rec);
return rec;
}
@Test
@DisplayName("P1-1: 推荐列表按生效分配过滤 —— revoke 后不再可见 assigned_operator_id 残留列放行的推荐")
void recommendationsFollowActiveAssignmentsNotResidualColumn() {
String serviceId = createService(user1, mmB);
String assignmentId = createAssignment(mmB, user1, serviceId);
MatchmakerRecommendation rec = createRecommendation(serviceId, user1, user2, mmB);
// 正向:active 分配持有人可见本人分配对象名下服务的推荐
List<Map<String, Object>> visible = matchmakerServiceService.getRecommendationsByOperator(mmB);
assertEquals(1, visible.size(), "active 分配持有人应看到该推荐");
assertEquals(rec.getId(), visible.get(0).get("id"));
// 横向:无分配的红娘不可见
assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmC).isEmpty(),
"无生效分配的红娘不得看到他人对象的推荐");
// 负向:revoke 后服务单残留 assigned_operator_id=mmB(前置确认),推荐列表必须不再含该条
assignmentService.revoke(assignmentId, leaderA, false, "005 P1-1 撤销验证");
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
"前置确认:残留列仍在(回退式过滤必须对此免疫)");
assertTrue(matchmakerServiceService.getRecommendationsByOperator(mmB).isEmpty(),
"撤销后推荐列表必须不再包含已失联对象的推荐");
}
// ==================== P1-2:dashboard 三计数切判定器(读路径) ====================
@Test
@DisplayName("P1-2: dashboard inProgress/today/completed 按生效分配口径 —— end 后全部归零(他人接手的单不再计入)")
void dashboardCountsFollowActiveAssignments() {
String serviceId = createService(user1, mmB);
String assignmentId = createAssignment(mmB, user1, serviceId);
Map<String, Object> d0 = matchmakerServiceService.getDashboard(mmB);
assertEquals(1L, d0.get("todayCount"), "分配创建时服务单 assigned_at=今天 → today=1");
assertEquals(0L, d0.get("inProgressCount"));
assertEquals(0L, d0.get("completedCount"));
// 服务单推进到 in_service → 进行中 +1
MatchmakerServiceRecord svc = serviceMapper.selectById(serviceId);
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
serviceMapper.updateById(svc);
assertEquals(1L, matchmakerServiceService.getDashboard(mmB).get("inProgressCount"));
// 完成 → inProgress 归零、completed +1
matchmakerServiceService.completeService(serviceId, mmB);
Map<String, Object> d1 = matchmakerServiceService.getDashboard(mmB);
assertEquals(0L, d1.get("inProgressCount"));
assertEquals(1L, d1.get("completedCount"), "分配仍生效时历史 completed 计入本人");
// 分配 end(终态)→ 三项计数全部归零(DEC-002 规则7:服务结束失去访问权)
assignmentService.end(assignmentId, leaderA, false, "005 P1-2 结束验证");
Map<String, Object> d2 = matchmakerServiceService.getDashboard(mmB);
assertEquals(0L, d2.get("inProgressCount"));
assertEquals(0L, d2.get("todayCount"), "分配 end 后该用户服务单不再计入本人今日数");
assertEquals(0L, d2.get("completedCount"), "分配 end 后历史 completed 不再计入本人");
}
// ==================== P1-3:转介授权切判定器(写路径) ====================
@Test
@DisplayName("P1-3: 转介授权走判定器 —— 有效分配内正常转介(正向);revoke/无分配 → 404 化拒绝 + DATA_SCOPE_DENIED 审计(负向)")
void referralAuthorizationFollowsActiveAssignments() {
String serviceId = createService(user1, mmB);
String assignmentId = createAssignment(mmB, user1, serviceId);
// 正向:有效分配内红娘可转介
Map<String, Object> ok = referralService.referToActivity(mmB, user1, "act-005-p13", "紧急联系人", "enc");
assertNotNull(ok.get("registrationId"), "有效分配内转介必须成功落库");
// 负向①:无任何分配的用户 → 404 化拒绝(T04 防探测)+ 审计
long before = countDeniedAuditsForResource(mmB, user2);
BusinessException denied1 = assertThrows(BusinessException.class,
() -> referralService.referToActivity(mmB, user2, "act-005-p14", "紧急联系人", "enc"),
"无生效分配的红娘不得对任意用户发起转介");
assertEquals(ApiResponseCode.NOT_FOUND, denied1.getCode(), "拒绝必须 404 化(防探测)");
assertEquals("服务不存在", denied1.getMessage());
assertEquals(before + 1, countDeniedAuditsForResource(mmB, user2),
"越权转介必须先落 DATA_SCOPE_DENIED 审计");
// 负向②:revoke 后原对象转介 → 404 化拒绝 + 审计(残留 assigned_operator_id 不再构成转介凭据)
assignmentService.revoke(assignmentId, leaderA, false, "005 P1-3 撤销验证");
assertEquals(mmB, serviceMapper.selectById(serviceId).getAssignedOperatorId(),
"前置确认:服务单残留 assigned_operator_id");
long beforeRevoked = countDeniedAuditsForResource(mmB, user1);
BusinessException denied2 = assertThrows(BusinessException.class,
() -> referralService.referToServiceStation(mmB, user1, "loc-005-p13",
"counselor_consultation", null, "备注"),
"撤销分配后红娘对原对象发起服务站转介必须拒绝");
assertEquals(ApiResponseCode.NOT_FOUND, denied2.getCode());
assertEquals(beforeRevoked + 1, countDeniedAuditsForResource(mmB, user1));
}
@Test
@DisplayName("E3 showOccupation: false→无 occupationCode;true/未设置→有(缺省全放行)")
void showOccupationPrivacySwitch() {
String serviceId = createService(user1, mmB);
createAssignment(mmB, user1, serviceId);
// 未设置(无隐私记录)→ 放行
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, mmB);
@SuppressWarnings("unchecked")
Map<String, Object> profileDefault = (Map<String, Object>) detail.get("userProfile");
assertTrue(profileDefault.containsKey("occupationCode"), "未设置隐私记录时缺省放行");
// false → 隐藏 occupationCode,其余白名单字段不受影响
UserPrivacySettings privacyOff = new UserPrivacySettings();
privacyOff.setUserId(user1);
privacyOff.setShowOccupation(false);
userPrivacySettingsMapper.insert(privacyOff);
Map<String, Object> detailOff = matchmakerServiceService.getServiceDetail(serviceId, mmB);
@SuppressWarnings("unchecked")
Map<String, Object> profileOff = (Map<String, Object>) detailOff.get("userProfile");
assertFalse(profileOff.containsKey("occupationCode"), "showOccupation=false 必须隐藏职业");
assertTrue(profileOff.containsKey("displayName") && profileOff.containsKey("educationCode"),
"其他白名单字段不受影响");
// true → 放行
privacyOff.setShowOccupation(true);
userPrivacySettingsMapper.updateById(privacyOff);
Map<String, Object> detailOn = matchmakerServiceService.getServiceDetail(serviceId, mmB);
@SuppressWarnings("unchecked")
Map<String, Object> profileOn = (Map<String, Object>) detailOn.get("userProfile");
assertTrue(profileOn.containsKey("occupationCode"), "showOccupation=true 放行");
}
}
@@ -0,0 +1,162 @@
package com.danyang.match.platform.matchmaker;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-005 / E3+MM-PRIVACY-002 字段级隐私守卫(standalone 源码扫描,无需 Spring 上下文)。
*
* <p>锁定两条不变量:
* <ol>
* <li>红娘侧 userProfile 装配(MatchmakerServiceService#assembleMatchmakerUserProfile)
* 的输出键集合 ⊆ §5.2 白名单 {displayName, gender, age, regionName, educationCode,
* occupationCode, maritalStatus};方法体内不得出现 phone/mobile/wechat/idNumber/
* birthDate/address/id_card 等禁放字段的 getter 调用(birthDate 仅允许作为
* calculateAge 的输入存在,绝不能成为输出键);</li>
* <li>MM-PRIVACY-002:matchmaker / referral 运营端 Controller 不存在
* export / download / batch 类端点(锁定"不存在"——导出能力引入时守卫即红)。</li>
* </ol>
*/
class MatchmakerFieldPrivacyGuardTest {
private static final Path SERVICE_SOURCE =
Paths.get("backend/src/main/java/com/danyang/match/platform/matchmaker/service/MatchmakerServiceService.java");
private static final Path CONTROLLER_DIRS = Paths.get("backend/src/main/java/com/danyang/match/platform");
/** §5.2 红娘侧用户资料白名单(与 assembleMatchmakerUserProfile 一一对照) */
private static final Set<String> WHITELIST = Set.of(
"displayName", "gender", "age", "regionName", "educationCode", "occupationCode", "maritalStatus");
/** §5.2 禁放字段(作为输出键或 getter 调用出现即违规) */
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
"phone", "mobile", "wechat", "idNumber", "birthDate", "address", "id_card");
private static final List<String> FORBIDDEN_GETTERS = List.of(
"getPhone", "getMobile", "getWechat", "getIdNumber", "getAddress", "getIdCard");
// ==================== E3:userProfile 白名单 ====================
@Test
@DisplayName("E3 守卫:userProfile 装配键 ⊆ §5.2 白名单,禁放字段不得出现")
void userProfileAssemblyKeysAreWhitelisted() throws IOException {
String body = extractMethodBody("assembleMatchmakerUserProfile");
assertTrue(body != null && !body.isBlank(), "未能从源码提取 assembleMatchmakerUserProfile 方法体");
// 输出键白名单
Set<String> keys = new LinkedHashSet<>();
Matcher put = Pattern.compile("userProfile\\.put\\(\"([^\"]+)\"").matcher(body);
while (put.find()) {
keys.add(put.group(1));
}
assertFalse(keys.isEmpty(), "装配方法必须显式输出字段(防止装配被搬空后守卫永真)");
List<String> violations = new ArrayList<>();
for (String key : keys) {
if (!WHITELIST.contains(key)) {
violations.add("非白名单输出键: " + key);
}
String lower = key.toLowerCase();
for (String token : FORBIDDEN_KEY_TOKENS) {
if (lower.contains(token.toLowerCase())) {
violations.add("输出键含禁放字段 token: " + key + "(含 " + token + ")");
}
}
}
// 禁放字段的 getter 调用(birthDate 仅允许经 calculateAge 派生,无独立 getter 白名单问题)
for (String getter : FORBIDDEN_GETTERS) {
if (body.contains(getter + "(")) {
violations.add("装配方法调用了禁放字段 getter: " + getter);
}
}
assertEquals(List.of(), violations,
"userProfile 字段级隐私契约被破坏(§5.2):\n - " + String.join("\n - ", violations)
+ "\n实际输出键=" + keys);
}
@Test
@DisplayName("守卫自检:装配方法必须包含 showOccupation 开关逻辑(防装配被改回无隐私开关)")
void assemblyMustKeepOccupationPrivacySwitch() throws IOException {
String body = extractMethodBody("assembleMatchmakerUserProfile");
assertTrue(body.contains("showOccupation"),
"装配方法必须读取隐私开关 showOccupation(E3)");
assertTrue(body.contains("Boolean.FALSE.equals"),
"缺省=全放行的语义必须保留(仅显式 false 隐藏)");
}
// ==================== MM-PRIVACY-002:无导出端点 ====================
@Test
@DisplayName("MM-PRIVACY-002: matchmaker/referral Controller 无 export/download/batch 端点")
void noExportDownloadBatchEndpoints() throws IOException {
List<String> violations = new ArrayList<>();
List<Path> controllerDirs = List.of(
CONTROLLER_DIRS.resolve("matchmaker/controller"),
CONTROLLER_DIRS.resolve("referral/controller"));
for (Path dir : controllerDirs) {
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
try (Stream<Path> files = Files.list(dir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
String src = Files.readString(f, StandardCharsets.UTF_8);
Matcher mapping = Pattern.compile(
"@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
while (mapping.find()) {
String args = mapping.group(2).toLowerCase();
for (String banned : List.of("export", "download", "batch")) {
if (args.contains(banned)) {
violations.add(f.getFileName() + " → " + mapping.group(0)
.replaceAll("\\s+", " ") + " 含禁用能力: " + banned);
}
}
}
}
}
}
assertEquals(List.of(), violations,
"红娘/转介域引入导出/批量能力必须先过隐私评审(§5.2 + MM-PRIVACY-002)");
}
// ==================== 解析实现 ====================
/** 按花括号配对提取指定方法的方法体(去注释后匹配)。 */
private String extractMethodBody(String methodName) throws IOException {
String src = Files.readString(SERVICE_SOURCE, StandardCharsets.UTF_8)
.replaceAll("//[^\\n]*", "")
.replaceAll("/\\*.*?\\*/", "");
int sig = src.indexOf(methodName);
if (sig < 0) {
return null;
}
int open = src.indexOf('{', sig);
if (open < 0) {
return null;
}
int depth = 0;
for (int i = open; i < src.length(); i++) {
char ch = src.charAt(i);
if (ch == '{') depth++;
else if (ch == '}') {
depth--;
if (depth == 0) {
return src.substring(open + 1, i);
}
}
}
return null;
}
}
@@ -54,6 +54,7 @@ class MatchmakerReferralPhase13Test {
@Autowired private com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper;
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
@Autowired private com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper matchmakerAssignmentMapper;
@Autowired private MatchmakerService matchmakerService;
@Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private ReferralService referralService;
@@ -73,6 +74,19 @@ class MatchmakerReferralPhase13Test {
target = createUser("mm-target");
// 一条归属 MATCHMAKER_OWNER 的红娘服务
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
// ZC-MM-005(E1):归属判定切纯判定器(无 assigned_operator_id 回退)后,
// 服务层归属断言只认 matchmaker_assignments —— 前置补一条生效分配
com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment assignment =
new com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment();
assignment.setUserId(user);
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
assignment.setServiceId(serviceId);
assignment.setAssignedBy(MATCHMAKER_OWNER);
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
assignment.setAssignmentReason("Phase13 前置分配");
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
assignment.setAssignedAt(java.time.Instant.now());
matchmakerAssignmentMapper.insert(assignment);
}
/**
@@ -289,24 +303,29 @@ class MatchmakerReferralPhase13Test {
@DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)")
void otherMatchmakerCannotReferUser() {
// MATCHMAKER_OTHER 与该用户之间没有任何服务关系
assertThrows(IllegalStateException.class,
// ZC-MM-005 收口(P1-3):转介授权切换为 ServiceAssignmentPolicy 判定器;
// ZC-MM-006 / T04:转介为资源寻址写端点,拒绝 404 化(先 DATA_SCOPE_DENIED 审计,
// 再抛 404「服务不存在」,与"查无"逐字段一致,防探测)
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> referralService.referToActivity(
MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"),
"非该用户服务负责人的红娘不得发起转介");
"非该用户服务负责人的红娘不得发起转介(404 化拒绝)");
assertThrows(IllegalStateException.class,
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> referralService.referToServiceStation(
MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null),
"非该用户服务负责人的红娘不得发起服务站转介");
"非该用户服务负责人的红娘不得发起服务站转介(404 化拒绝)");
}
@Test
@DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)")
void otherMatchmakerCannotRecommend() {
assertThrows(IllegalStateException.class,
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy;
// ZC-MM-006 / T04:写接口拒绝 404 化(DATA_SCOPE_DENIED 审计 + BusinessException 404)
assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> matchmakerServiceService.recommendPartner(
serviceId, MATCHMAKER_OTHER, target, "越权推荐"),
"非服务负责人不得添加推荐");
"非服务负责人不得添加推荐(404 化拒绝)");
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
"越权推荐失败后不得留下任何推荐记录");
@@ -337,21 +356,15 @@ class MatchmakerReferralPhase13Test {
// ==================== 计划项 3:红娘只读摘要,不读敏感正文 ====================
@Test
@DisplayName("计划项3/D4: 非负责人的 operator 读详情只得到脱敏视图(无 userProfile / 无 followup 正文)")
void nonOwnerSeesSanitizedDetail() {
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER);
assertNotNull(detail);
assertEquals(Boolean.FALSE, detail.get("viewerIsOwner"),
"非负责人必须被标记为非 owner");
assertFalse(detail.containsKey("userProfile"),
"INV-013 同类口径:非负责人不得读取服务对象的用户资料");
assertFalse(detail.containsKey("followups"),
"非负责人不得读取跟进正文");
assertFalse(detail.containsKey("userId"),
"非负责人不需要知道申请人 userId");
assertEquals(0, ((Number) detail.getOrDefault("followupCount", 0)).intValue(),
"非负责人只能拿到跟进条数摘要");
@DisplayName("计划项3/D4+ZC-MM-005 E2: 非持有人读详情 → 防探测 404(与「查无」一致,无脱敏双态)")
void nonOwnerReadsDetailIsRejectedAsNotFound() {
// ZC-MM-005(E1+E2):002 的"非负责人脱敏详情"双态已删除;
// 非持有人读任意 serviceId 得到与"查无"逐字段一致的 404,防枚举探测。
// 读侧不再走 403(写接口维持 403,见 assertServiceAccess 口径)。
org.junit.jupiter.api.Assertions.assertThrows(
com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER),
"非持有人读详情必须被拒(防探测 404)");
}
@Test
@@ -360,7 +373,8 @@ class MatchmakerReferralPhase13Test {
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER);
assertNotNull(detail);
assertEquals(Boolean.TRUE, detail.get("viewerIsOwner"));
// ZC-MM-005(E1):viewerIsOwner 双态字段已删除,持有人语义由"能拿到完整视图"表达
assertFalse(detail.containsKey("viewerIsOwner"), "双态字段已随读侧纯化删除");
assertTrue(detail.containsKey("userId"), "负责人应能看到申请人 userId");
assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录");
}
@@ -0,0 +1,181 @@
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.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-006 / T03 验收:SensitiveAccessLog 接入红娘服务对象详情。
*
* <p>语义(AGENTS.md #8:敏感数据默认拒绝,查看动作本身必须可溯源):
* <ul>
* <li>持有人读 GET /operator/matchmaker/services/{id}(getServiceDetail)→
* sensitive_access_logs 落 success 行(dataType=matchmaker_service_detail);</li>
* <li>拒绝路径维持 audit_logs DATA_SCOPE_DENIED(判定器写入),<b>不重复写</b>
* SensitiveAccessLog —— 双记录互斥;</li>
* <li>/my 与 /team/statistics 不接入敏感访问日志(无服务对象敏感明细)。</li>
* </ul>
*
* <p>覆盖:正例 success 行;负例(非持有人)无 SensitiveAccessLog 行且 audit_logs 有
* DATA_SCOPE_DENIED;连读 N 次落 N 行(幂等逐次记录,不聚合去重)。
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MatchmakerSensitiveAccessAuditTest {
@Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private MatchmakerAssignmentService assignmentService;
@Autowired private MatchmakerServiceMapper serviceMapper;
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
@Autowired private OperatorMapper operatorMapper;
@Autowired private UserMapper userMapper;
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
@Autowired private AuditLogMapper auditLogMapper;
private String holderId; // 分配持有人(红娘 B)
private String outsiderId; // 无分配红娘 C
private String userId;
@BeforeEach
void setUp() {
holderId = createOperator("敏感审计持有人");
outsiderId = createOperator("敏感审计外人");
userId = createUser();
}
// ==================== 数据构造(Phase13 范式) ====================
private String createOperator(String name) {
Operator op = new Operator();
op.setName(name);
op.setUsername("sa_" + name + "_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
return op.getId();
}
private String createUser() {
User u = new User();
u.setWechatOpenid("sa_user_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.insert(u);
return u.getId();
}
private String createServiceWithAssignment() {
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setUserId(userId);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setDescription("敏感访问审计测试服务单");
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
svc.setAssignedOperatorId(holderId);
serviceMapper.insert(svc);
assignmentService.create(holderId, true, userId, holderId, svc.getId(),
"T03 前置分配", null);
return svc.getId();
}
private List<SensitiveAccessLog> accessRows(String operatorId, String serviceId) {
return sensitiveAccessLogMapper.selectList(new LambdaQueryWrapper<SensitiveAccessLog>()
.eq(SensitiveAccessLog::getOperatorId, operatorId)
.eq(SensitiveAccessLog::getDataType, Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL)
.eq(SensitiveAccessLog::getAccessTarget, serviceId));
}
// ==================== 正例:持有人读详情落 success 行 ====================
@Test
@DisplayName("T03 正例: 持有人读服务详情 → sensitive_access_logs 落 success 行(dataType/target/reason 齐全)")
void holderReadDetailWritesSensitiveAccessSuccessRow() {
String serviceId = createServiceWithAssignment();
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, holderId);
assertNotNull(detail);
List<SensitiveAccessLog> rows = accessRows(holderId, serviceId);
assertEquals(1, rows.size(), "持有人读详情必须落恰好 1 行敏感访问日志");
SensitiveAccessLog row = rows.get(0);
assertEquals(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL, row.getDataType());
assertEquals(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS, row.getAccessResult());
assertEquals(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL, row.getReason());
assertEquals(userId, row.getUserId());
assertEquals(serviceId, row.getAccessTarget());
}
// ==================== 负例:非持有人读 → 双记录互斥 ====================
@Test
@DisplayName("T03 负例: 非持有人读详情 404 → 无 SensitiveAccessLog 行,audit_logs 有 DATA_SCOPE_DENIED")
void outsiderReadDetailWritesNoSensitiveAccessRowButDeniedAudit() {
String serviceId = createServiceWithAssignment();
BusinessException e = assertThrows(BusinessException.class,
() -> matchmakerServiceService.getServiceDetail(serviceId, outsiderId),
"非持有人读详情必须 404(防探测口径)");
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
// 双记录互斥:拒绝路径只写 audit_logs,不写 sensitive_access_logs
assertTrue(accessRows(outsiderId, serviceId).isEmpty(),
"拒绝路径不得写 SensitiveAccessLog(与 DATA_SCOPE_DENIED 互斥)");
Long deniedAudits = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, "DATA_SCOPE_DENIED")
.eq(AuditLog::getOperatorId, outsiderId)
.eq(AuditLog::getResourceId, serviceId));
assertEquals(1L, deniedAudits, "拒绝路径必须落 DATA_SCOPE_DENIED 审计");
}
// ==================== 幂等逐次记录:连读 N 次落 N 行 ====================
@Test
@DisplayName("T03 幂等: 持有人连读 3 次 → 落 3 行(逐次记录,不聚合去重)")
void repeatedReadsWriteRowPerAccess() {
String serviceId = createServiceWithAssignment();
matchmakerServiceService.getServiceDetail(serviceId, holderId);
matchmakerServiceService.getServiceDetail(serviceId, holderId);
matchmakerServiceService.getServiceDetail(serviceId, holderId);
assertEquals(3, accessRows(holderId, serviceId).size(),
"每次查看必须逐次记录(可溯源,不允许聚合去重)");
}
// ==================== 边界:查无 serviceId 不写敏感日志 ====================
@Test
@DisplayName("T03 边界: 查无 serviceId → 404,不落任何敏感访问行")
void missingServiceWritesNoSensitiveAccessRow() {
assertThrows(BusinessException.class,
() -> matchmakerServiceService.getServiceDetail("sa-no-such-service", holderId));
assertTrue(accessRows(holderId, "sa-no-such-service").isEmpty(),
"查无资源不构成敏感访问,不得落行");
}
}
@@ -185,4 +185,29 @@ class MatchmakerServiceTest {
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_OPERATOR_ASSISTED), whitelist.toString())
);
}
@Test
@DisplayName("ZC-MM-002 守卫:assignment 状态常量必须落在 chk_mm_assignment_status 白名单内")
void assignmentStatusConstantsAreWithinProductionWhitelist() {
Set<String> whitelist = resolveCheckValues("chk_mm_assignment_status");
assertAll(
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_ACTIVE), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_ENDED), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_REVOKED), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_TRANSFERRED), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_STATUS_EXPIRED), whitelist.toString())
);
}
@Test
@DisplayName("ZC-MM-002 守卫:assignment scope 常量必须落在 chk_mm_assignment_scope 白名单内")
void assignmentScopeConstantsAreWithinProductionWhitelist() {
Set<String> whitelist = resolveCheckValues("chk_mm_assignment_scope");
assertAll(
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_SCOPE_PRIMARY), whitelist.toString()),
() -> assertTrue(whitelist.contains(Constants.ASSIGNMENT_SCOPE_COLLABORATING), whitelist.toString())
);
}
}
@@ -0,0 +1,284 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.referral.service.ReferralService;
import com.danyang.match.platform.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.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-006 / T04 验收:写接口 404 统一(防探测)。
*
* <p>语义:带资源寻址的写端点(transfer / revoke / end / recommend / followup / complete /
* 转介)范围校验失败时,先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」——
* 响应与"资源不存在"(读详情 404)逐字段一致,调用方无法区分"无权"与"不存在",
* 杜绝按资源 ID 枚举探测写端点。create / assign 类无资源寻址端点维持 403(不在本测试面)。
*
* <p>覆盖:负向×5(跨组织 transfer / 无分配 recommend / followup / complete / 非负责人转介)
* + 正向×2(持有人 recommend / followup 正常)+ 五个拒绝响应与读详情 404 逐字段一致。
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MatchmakerWriteDenyUniformityTest {
@Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private MatchmakerAssignmentService assignmentService;
@Autowired private ReferralService referralService;
@Autowired private MatchmakerServiceMapper serviceMapper;
@Autowired private MatchmakerAssignmentMapper assignmentMapper;
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
@Autowired private MatchmakerFollowupMapper followupMapper;
@Autowired private OperatorMapper operatorMapper;
@Autowired private UserMapper userMapper;
@Autowired private AuditLogMapper auditLogMapper;
private String leaderA; // 组长,org X
private String mmB; // 红娘,org X(持有人)
private String mmC; // 红娘,org Y(跨组织)
private String userId;
private static final String ORG_X = "44444444-4444-4444-4444-444444444441";
private static final String ORG_Y = "44444444-4444-4444-4444-444444444442";
@BeforeEach
void setUp() {
leaderA = createOperator("组长A-006", ORG_X);
mmB = createOperator("红娘B-006", ORG_X);
mmC = createOperator("红娘C-006", ORG_Y);
userId = createUser();
}
// ==================== 数据构造(Phase13 范式) ====================
private String createOperator(String name, String organizationId) {
Operator op = new Operator();
op.setName(name);
op.setOrganizationId(organizationId);
op.setUsername("wu_" + name + "_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
return op.getId();
}
private String createUser() {
User u = new User();
u.setWechatOpenid("wu_user_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.insert(u);
return u.getId();
}
private String createService(String operatorId) {
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setUserId(userId);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setDescription("写接口 404 统一测试服务单");
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
svc.setAssignedOperatorId(operatorId);
serviceMapper.insert(svc);
return svc.getId();
}
/** 平台授权路径创建分配(绕过组织范围校验以构造前置状态) */
private String createAssignment(String operatorId, String serviceId) {
MatchmakerAssignment a = assignmentService.create(leaderA, true, userId, operatorId,
serviceId, "006 测试前置分配", null);
return a.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));
}
/** 读详情 404 基准("查无"路径)—— 五个写拒绝必须与其逐字段一致 */
private BusinessException readDetailNotFoundBaseline() {
try {
matchmakerServiceService.getServiceDetail("wu-no-such-service", mmB);
} catch (BusinessException e) {
return e;
}
throw new AssertionError("预期读详情 404 基准异常");
}
// ==================== 负向×5:写拒绝 404 化 ====================
@Test
@DisplayName("T04 负向①: 跨组织 transfer → 404「服务不存在」+ DATA_SCOPE_DENIED 审计")
void crossOrgTransferDeniedAsNotFound() {
String serviceId = createService(mmB);
String assignmentId = createAssignment(mmB, serviceId);
long before = deniedAudits(leaderA, assignmentId);
BusinessException e = assertThrows(BusinessException.class,
() -> assignmentService.transfer(assignmentId, leaderA, false, mmC, "跨组织转分配"),
"跨组织 transfer 必须拒绝");
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
assertEquals("服务不存在", e.getMessage());
assertEquals(before + 1, deniedAudits(leaderA, assignmentId), "必须先落 DATA_SCOPE_DENIED 审计");
// 被转记录原样保留
assertEquals(Constants.ASSIGNMENT_STATUS_ACTIVE,
assignmentMapper.findById(assignmentId).orElseThrow().getStatus(),
"被拒绝的转分配不得改变原分配状态");
}
@Test
@DisplayName("T04 负向②: 无分配 recommend → 404「服务不存在」+ 审计")
void recommendWithoutAssignmentDeniedAsNotFound() {
String serviceId = createService(mmB);
createAssignment(mmB, serviceId);
long before = deniedAudits(mmC, serviceId);
BusinessException e = assertThrows(BusinessException.class,
() -> matchmakerServiceService.recommendPartner(serviceId, mmC, "wu-target", "无权推荐"));
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
assertEquals("服务不存在", e.getMessage());
assertEquals(before + 1, deniedAudits(mmC, serviceId));
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
"拒绝的推荐不得落库");
}
@Test
@DisplayName("T04 负向③: 无分配 followup → 404「服务不存在」+ 审计")
void followupWithoutAssignmentDeniedAsNotFound() {
String serviceId = createService(mmB);
createAssignment(mmB, serviceId);
long before = deniedAudits(mmC, serviceId);
BusinessException e = assertThrows(BusinessException.class,
() -> matchmakerServiceService.addFollowup(serviceId, mmC, "note", "越权跟进", null));
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
assertEquals("服务不存在", e.getMessage());
assertEquals(before + 1, deniedAudits(mmC, serviceId));
assertTrue(followupMapper.findByServiceId(serviceId).isEmpty(),
"拒绝的跟进不得落库");
}
@Test
@DisplayName("T04 负向④: 无分配 complete → 404「服务不存在」+ 审计")
void completeWithoutAssignmentDeniedAsNotFound() {
String serviceId = createService(mmB);
createAssignment(mmB, serviceId);
long before = deniedAudits(mmC, serviceId);
BusinessException e = assertThrows(BusinessException.class,
() -> matchmakerServiceService.completeService(serviceId, mmC));
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode());
assertEquals("服务不存在", e.getMessage());
assertEquals(before + 1, deniedAudits(mmC, serviceId));
assertNotEquals(Constants.MATCHMAKER_STATUS_COMPLETED,
serviceMapper.selectById(serviceId).getStatus(),
"拒绝的完成动作不得改变服务单状态");
}
@Test
@DisplayName("T04 负向⑤: 非负责人转介 → 404「服务不存在」+ 审计(含裸异常消灭回归)")
void referralWithoutAssignmentDeniedAsNotFound() {
String serviceId = createService(mmB);
createAssignment(mmB, serviceId);
long before = deniedAudits(mmC, userId);
BusinessException e1 = assertThrows(BusinessException.class,
() -> referralService.referToActivity(mmC, userId, "act-006-t04", "紧急联系人", "enc"));
assertEquals(ApiResponseCode.NOT_FOUND, e1.getCode());
assertEquals("服务不存在", e1.getMessage());
BusinessException e2 = assertThrows(BusinessException.class,
() -> referralService.referToServiceStation(mmC, userId, "loc-006-t04",
"counselor_consultation", null, null));
assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode());
assertEquals(before + 2, deniedAudits(mmC, userId), "两次越权转介各落一条审计");
}
// ==================== 正向×2:持有人正常 ====================
@Test
@DisplayName("T04 正向①: 持有人 recommend 正常(200 语义)")
void holderRecommendSucceeds() {
String serviceId = createService(mmB);
createAssignment(mmB, serviceId);
Map<String, Object> result = matchmakerServiceService.recommendPartner(
serviceId, mmB, "wu-target-ok", "T04 持有人推荐");
assertNotNull(result.get("recommendationId"));
}
@Test
@DisplayName("T04 正向②: 持有人 followup 正常(200 语义)")
void holderFollowupSucceeds() {
String serviceId = createService(mmB);
createAssignment(mmB, serviceId);
MatchmakerFollowup followup = matchmakerServiceService.addFollowup(
serviceId, mmB, "note", "T04 持有人跟进", null);
assertNotNull(followup.getId());
}
// ==================== 逐字段一致:五个写拒绝 ≡ 读详情 404 ====================
@Test
@DisplayName("T04 统一性: 五个写拒绝的 code+message 与读详情 404(查无基准)逐字段一致")
void writeDeniesIndistinguishableFromReadNotFound() {
String serviceId = createService(mmB);
String assignmentId = createAssignment(mmB, serviceId);
BusinessException baseline = readDetailNotFoundBaseline();
BusinessException transferDenied = assertThrows(BusinessException.class,
() -> assignmentService.transfer(assignmentId, leaderA, false, mmC, "跨组织"));
BusinessException recommendDenied = assertThrows(BusinessException.class,
() -> matchmakerServiceService.recommendPartner(serviceId, mmC, "wu-t", "r"));
BusinessException followupDenied = assertThrows(BusinessException.class,
() -> matchmakerServiceService.addFollowup(serviceId, mmC, "note", "f", null));
BusinessException completeDenied = assertThrows(BusinessException.class,
() -> matchmakerServiceService.completeService(serviceId, mmC));
BusinessException referralDenied = assertThrows(BusinessException.class,
() -> referralService.referToActivity(mmC, userId, "act-006-u", "联系人", "enc"));
List<BusinessException> denies = List.of(transferDenied, recommendDenied,
followupDenied, completeDenied, referralDenied);
for (BusinessException denied : denies) {
assertEquals(baseline.getCode(), denied.getCode(),
"业务码必须与读详情 404 一致(逐字段防探测)");
assertEquals(baseline.getMessage(), denied.getMessage(),
"message 必须与读详情 404 一致(逐字段防探测)");
}
assertEquals(ApiResponseCode.NOT_FOUND, baseline.getCode());
assertEquals("服务不存在", baseline.getMessage());
}
}
@@ -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;
@@ -23,6 +25,7 @@ import com.danyang.match.platform.user.controller.UserController;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
@@ -40,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,
@@ -97,6 +102,117 @@ class OperatorEndpointPermissionCoverageTest {
"扫描到的运营端接口数量异常偏少(" + operatorEndpointCount + "),守卫可能已失效");
}
// ==================== ZC-MM-006 / T05 守卫补强 ====================
/**
* T05-a:类级 @RequestMapping 前缀合成完整路径后再判 /operator/** 归属。
*
* <p>防护场景:运营端控制器普遍用类级 @RequestMapping("/operator/xxx") + 方法级
* @GetMapping("/yyy") —— 单看方法路径不含 /operator 前缀。若后续把路径从类级挪到
* 方法级(或新增控制器时前缀写错),本用例通过「类前缀 + 方法路径」合成完整路径逐条
* 判定,保证归属判定不因 mapping 写法迁移而漂移。
*/
@Test
void classLevelPrefixMustComposeIntoFullOperatorPath() {
int composedCount = 0;
List<String> violations = new ArrayList<>();
for (Class<?> controller : CONTROLLERS) {
List<String> classPaths = paths(controller);
if (classPaths.isEmpty() || classPaths.stream().noneMatch(this::isOperatorPath)) {
continue;
}
for (Method method : controller.getDeclaredMethods()) {
List<String> methodPaths = paths(method);
if (methodPaths.isEmpty()) {
continue; // 非端点
}
for (String methodPath : methodPaths) {
String fullPath = composePath(classPaths.get(0), methodPath);
composedCount++;
if (!isOperatorPath(fullPath)) {
violations.add(controller.getSimpleName() + " → " + fullPath);
}
}
}
}
org.junit.jupiter.api.Assertions.assertTrue(composedCount >= 10,
"合成路径样本过少(" + composedCount + "),守卫可能已失效");
assertEquals(List.of(), violations,
"类级 /operator 前缀 + 方法路径合成的完整路径必须仍判为运营端归属");
}
/**
* T05-b:裸 @PreAuthorize 检查 —— 缺类级 @PreAuthorize 的控制器,其运营端接口
* 必须逐方法显式授权(未全覆盖 → 红)。
*
* <p>防护场景:带类级 @PreAuthorize 的控制器(如 MatchmakerOperatorController)允许
* 个别方法不重复标注;但若有人日后删掉类级注解,这些"裸"方法即失去权限粒度 ——
* 本用例在"类级已缺失"的状态下强制逐方法覆盖,删注解动作立即变红。
*/
@Test
void controllerWithoutClassLevelPreAuthorizeMustCoverEveryOperatorEndpoint() {
List<String> violations = new ArrayList<>();
for (Class<?> controller : CONTROLLERS) {
boolean classLevel = AnnotatedElementUtils.hasAnnotation(controller, PreAuthorize.class);
for (Method method : controller.getDeclaredMethods()) {
List<String> methodPaths = paths(method);
if (methodPaths.isEmpty()) {
continue; // 非端点
}
boolean operatorFacing = paths(controller).stream().anyMatch(this::isOperatorPath)
|| methodPaths.stream().anyMatch(this::isOperatorPath)
|| Arrays.stream(method.getParameterTypes()).anyMatch(OperatorPrincipal.class::equals);
if (!operatorFacing) {
continue;
}
if (!classLevel && !AnnotatedElementUtils.hasAnnotation(method, PreAuthorize.class)) {
violations.add(controller.getSimpleName() + "." + method.getName());
}
}
}
assertEquals(List.of(), violations,
"缺类级 @PreAuthorize 的控制器,其每个运营端接口必须自带方法级 @PreAuthorize");
}
/**
* T05-c:matchmaker / referral 包禁止 @PatchMapping。
*
* <p>背景(ZC-MM-003/004 治理口径):红娘域全部状态迁移走显式语义端点
* (accept/pass/confirm/transfer/revoke/end 等),每个动作独立审计与状态机校验。
* PATCH 部分更新语义会绕过「动作 → 状态机 + 审计」链路,出现即视为架构违规。
*/
@Test
void matchmakerAndReferralPackagesMustNotUsePatchMapping() {
List<String> violations = new ArrayList<>();
for (Class<?> controller : CONTROLLERS) {
String pkg = controller.getPackageName();
if (!pkg.contains(".matchmaker.") && !pkg.contains(".referral.")) {
continue;
}
if (AnnotatedElementUtils.findMergedAnnotation(controller, PatchMapping.class) != null) {
violations.add(controller.getSimpleName() + " (class-level)");
}
for (Method method : controller.getDeclaredMethods()) {
if (AnnotatedElementUtils.findMergedAnnotation(method, PatchMapping.class) != null) {
violations.add(controller.getSimpleName() + "." + method.getName());
}
}
}
assertEquals(List.of(), violations,
"matchmaker/referral 包禁止 @PatchMapping(部分更新绕过状态机+审计链路)");
}
/** 类级前缀 + 方法路径合成完整路径(Spring mapping 拼接语义)。 */
private String composePath(String classPath, String methodPath) {
if (methodPath == null || methodPath.isEmpty()) {
return classPath;
}
if (methodPath.startsWith("/")) {
return classPath + methodPath;
}
return classPath + "/" + methodPath;
}
private List<String> paths(java.lang.reflect.AnnotatedElement element) {
RequestMapping mapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
return mapping == null ? List.of() : Arrays.asList(mapping.path().length > 0
@@ -0,0 +1,198 @@
package com.danyang.match.platform.security;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.auth.token.TokenBlacklistService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.entity.Permission;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.permission.mapper.PermissionMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ZC-MM-006 / T02 验收:JWT 权限回收(实时查库路线,不建撤销名单)。
*
* <p>语义:operator token 每请求实时查库 —— ① operators.status 非 active(含账号删除)
* → 401、不建 principal;② 权限由 PermissionMapper join role_permissions/operator_roles
* 实时装载,DB 撤销权限后已签发 token 立即失去对应能力(403 + AUTHORIZATION_DENIED 审计)。
* token claims 中的 permissions 仅为登录时刻快照,不是授权事实源(只写不读)。
*
* <p>覆盖:
* <ul>
* <li>正例:DB 授权(role_permissions)→ token 调 dashboard 200;</li>
* <li>负向①:DB UPDATE status='disabled' → 同一 token → 401;</li>
* <li>负向②:DB 删 role_permissions 行 → 同一 token 调 recommend → 403
* + AUTHORIZATION_DENIED 审计(含 actor_role_snapshot 权限快照);</li>
* <li>负向③(回归):用户 token 打 operator 路径 → 403(USER fallback 语义不回归)。</li>
* </ul>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
@AutoConfigureMockMvc
class OperatorPermissionRevocationTest {
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenService jwtTokenService;
@Autowired private OperatorMapper operatorMapper;
@Autowired private PermissionMapper permissionMapper;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private AuditLogMapper auditLogMapper;
/** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */
@MockBean private TokenBlacklistService tokenBlacklistService;
private String operatorId;
private String operatorToken;
private String servicePermissionId;
@BeforeEach
void setUp() {
// 真实 DB 授权链:operator → operator_roles → role → role_permissions → permissions
operatorId = createOperator();
String roleId = createRole("rev_role_" + System.nanoTime());
String viewPermissionId = createPermission("matchmaker.view");
servicePermissionId = createPermission("matchmaker.service");
grant(roleId, viewPermissionId);
grant(roleId, servicePermissionId);
bindOperatorRole(operatorId, roleId);
operatorToken = jwtTokenService.generateOperatorToken(operatorId,
Map.of("permissions", java.util.List.of("matchmaker.view", "matchmaker.service")));
}
@AfterEach
void clearSecurityContext() {
org.springframework.security.core.context.SecurityContextHolder.clearContext();
}
// ==================== 数据构造 ====================
private String createOperator() {
Operator op = new Operator();
op.setName("权限回收测试运营");
op.setUsername("revoke_" + System.nanoTime());
op.setPasswordHash("test-hash");
op.setStatus("active");
operatorMapper.insert(op);
return op.getId();
}
private String createRole(String code) {
String roleId = UUID.randomUUID().toString();
jdbcTemplate.update("INSERT INTO roles (id, code, name, system_role) VALUES (?, ?, ?, ?)",
roleId, code, "权限回收测试角色", false);
return roleId;
}
private String createPermission(String code) {
Permission p = new Permission();
p.setCode(code);
p.setName("权限回收测试权限 " + code);
p.setModule("matchmaker");
permissionMapper.insert(p);
return p.getId();
}
private void grant(String roleId, String permissionId) {
jdbcTemplate.update(
"INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)",
roleId, permissionId);
}
private void bindOperatorRole(String operatorId, String roleId) {
jdbcTemplate.update(
"INSERT INTO operator_roles (operator_id, role_id) VALUES (?, ?)",
operatorId, roleId);
}
// ==================== 正例 ====================
@Test
@DisplayName("T02 正例: DB 授权运营持 token 调 dashboard → 200")
void dbAuthorizedOperatorCanCallDashboard() throws Exception {
mockMvc.perform(get("/operator/matchmaker/dashboard")
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isOk());
}
// ==================== 负向①:禁用账号即时 401 ====================
@Test
@DisplayName("T02 负向①: DB UPDATE status='disabled' 后同一 token → 401(实时查库,零缓存)")
void disabledOperatorTokenImmediatelyUnauthorized() throws Exception {
// 前置:禁用前可正常调用
mockMvc.perform(get("/operator/matchmaker/dashboard")
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isOk());
Operator op = operatorMapper.findById(operatorId).orElseThrow();
op.setStatus("disabled");
operatorMapper.updateById(op);
// 同一 token(未过期、无撤销名单)→ 过滤器实时查库拒绝
mockMvc.perform(get("/operator/matchmaker/dashboard")
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isUnauthorized());
}
// ==================== 负向②:DB 撤权即时 403 + 审计 ====================
@Test
@DisplayName("T02 负向②: DB 撤销 role_permissions 后同一 token 调 recommend → 403 + AUTHORIZATION_DENIED 审计(含快照)")
void revokedPermissionImmediatelyForbiddenWithAudit() throws Exception {
// 撤销 matchmaker.service 授权(matchmaker.view 保留 → 类级 @PreAuthorize 仍过、方法级拒绝)
jdbcTemplate.update("DELETE FROM role_permissions WHERE permission_id = ?", servicePermissionId);
long deniedCount = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, "AUTHORIZATION_DENIED"));
assertTrue(deniedCount <= 1, "前置确认:本测试事务内拒绝审计至多前置 1 条");
mockMvc.perform(post("/operator/matchmaker/services/{id}/recommend", "rev-no-such-svc")
.header("Authorization", "Bearer " + operatorToken)
.contentType("application/json")
.content("{\"targetUserId\":\"rev-target\",\"reason\":\"T02 撤权验证\"}"))
.andExpect(status().isForbidden());
AuditLog denied = auditLogMapper.selectList(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, "AUTHORIZATION_DENIED")
.eq(AuditLog::getOperatorId, operatorId)
.orderByDesc(AuditLog::getCreatedAt))
.stream().findFirst().orElseThrow(() ->
new AssertionError("权限回收后的调用必须落 AUTHORIZATION_DENIED 审计"));
// actor_role_snapshot:来自实时查库装载的权限集合(matchmaker.view 仍在)
assertEquals("matchmaker.view", denied.getActorRoleSnapshot(),
"拒绝审计必须携带落库时刻的权限快照(实时查库口径,不含已撤销的 matchmaker.service)");
}
// ==================== 负向③:用户 token 打 operator 路径(回归) ====================
@Test
@DisplayName("T02 负向③: 用户 token 打 operator 路径 → 403(USER fallback 语义不回归)")
void userTokenCannotReachOperatorPath() throws Exception {
String userToken = jwtTokenService.generateAccessToken(
"rev-user-" + UUID.randomUUID(), Map.of());
mockMvc.perform(get("/operator/matchmaker/dashboard")
.header("Authorization", "Bearer " + userToken))
.andExpect(status().isForbidden());
}
}
@@ -1,6 +1,8 @@
package com.danyang.match.platform.shared.filter;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.permission.service.PermissionService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
@@ -27,9 +29,19 @@ class JwtAuthFilterTest {
@Mock private JwtTokenService jwtTokenService;
@Mock private PermissionService permissionService;
/** ZC-MM-006 / T02:运营分支新增实时查库(operators.status),单测同步打桩 */
@Mock private OperatorMapper operatorMapper;
@Mock private Claims claims;
@InjectMocks private JwtAuthFilter filter;
private Operator activeOperator(String id) {
Operator op = new Operator();
op.setId(id);
op.setName("单测运营");
op.setStatus("active");
return op;
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
@@ -42,6 +54,7 @@ class JwtAuthFilterTest {
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("operator-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("operator_access");
when(operatorMapper.findById("operator-1")).thenReturn(java.util.Optional.of(activeOperator("operator-1")));
when(permissionService.getPermissions("operator-1"))
.thenReturn(List.of("content.edit", "content.publish"));
@@ -109,6 +122,7 @@ class JwtAuthFilterTest {
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("operator-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("operator_access");
when(operatorMapper.findById("operator-1")).thenReturn(java.util.Optional.of(activeOperator("operator-1")));
when(permissionService.getPermissions("operator-1")).thenReturn(List.of("content.edit"));
filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain());
+123 -1
View File
@@ -108,7 +108,10 @@ CREATE TABLE audit_logs (
id VARCHAR(36) PRIMARY KEY, operator_id VARCHAR(36), organization_id VARCHAR(36), action VARCHAR(128),
resource_type VARCHAR(64), resource_id VARCHAR(128), before_data CLOB, after_data CLOB, ip VARCHAR(64),
user_agent VARCHAR(500), created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
-- V055(ZC-MM-006 / T01):扩展审计字段,全可空,与生产对齐
purpose_code VARCHAR(64), service_assignment_id VARCHAR(36),
actor_role_snapshot VARCHAR(128), request_trace_id VARCHAR(64)
);
CREATE TABLE notifications (
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), type VARCHAR(32), title VARCHAR(200), content VARCHAR(2000),
@@ -492,6 +495,125 @@ CREATE TABLE matchmaker_followups (
deleted_at TIMESTAMP WITH TIME ZONE
);
-- V053: matchmaker_assignments 服务对象分配表(ZC-MM-002 / A1)
-- 生产为部分唯一索引 uq_mm_assignment_active_primary WHERE status='active' AND scope='primary';
-- H2 不支持部分索引,用「生成列 + 唯一索引」等价替代:
-- active_primary_key 仅在 (active, primary) 时取 user_id,否则为 NULL,
-- 标准唯一索引对多行 NULL 互不冲突 → 终态历史行可共存,语义与生产一致。
-- 注:H2 无 operators 表(见 Phase 17 注),去掉生产 DDL 的外键;CHECK 白名单保留。
CREATE TABLE matchmaker_assignments (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
matchmaker_operator_id VARCHAR(36) NOT NULL,
service_id VARCHAR(36),
assigned_by VARCHAR(36) NOT NULL,
assignment_scope VARCHAR(32) NOT NULL DEFAULT 'primary',
assignment_reason VARCHAR(500) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
assigned_at TIMESTAMP WITH TIME ZONE,
expires_at TIMESTAMP WITH TIME ZONE,
ended_at TIMESTAMP WITH TIME ZONE,
end_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_mm_assignment_status CHECK (status IN (
'active', 'ended', 'revoked', 'transferred', 'expired'
)),
CONSTRAINT chk_mm_assignment_scope CHECK (assignment_scope IN ('primary', 'collaborating')),
active_primary_key VARCHAR(36) GENERATED ALWAYS AS (
CASE WHEN status = 'active' AND assignment_scope = 'primary' THEN user_id END
)
);
CREATE UNIQUE INDEX uq_mm_assignment_active_primary_h2 ON matchmaker_assignments(active_primary_key);
CREATE INDEX idx_mm_assignment_operator_h2 ON matchmaker_assignments(matchmaker_operator_id, status);
CREATE INDEX idx_mm_assignment_user_h2 ON matchmaker_assignments(user_id, status);
CREATE INDEX idx_mm_assignment_service_h2 ON matchmaker_assignments(service_id);
-- V054 配套:operators 运营人员表(ZC-MM-002 A1 测试需要:组长/红娘按 organization_id
-- 划定团队边界;此前 operators 一直在 EntityMigrationParityTest 可选白名单里 = 零覆盖)。
-- 注意:与生产 V001 对齐但去掉 REFERENCES organizations(id) 外键(organizations 本表已存在,
-- 保留外键亦可;为与既有「去外键」惯例一致,不建外键)。
CREATE TABLE operators (
id VARCHAR(36) PRIMARY KEY,
organization_id VARCHAR(36),
name VARCHAR(100) NOT NULL,
phone_enc CLOB,
phone_hash VARCHAR(128),
username VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'active',
last_login_at TIMESTAMP WITH TIME ZONE,
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
);
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 绕过。
-- 与生产 V001 对齐、按惯例去外键。
CREATE TABLE roles (
id VARCHAR(36) PRIMARY KEY, code VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL,
description VARCHAR(500), system_role BOOLEAN NOT NULL DEFAULT FALSE,
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
);
CREATE UNIQUE INDEX uk_role_code_h2 ON roles(code);
CREATE TABLE permissions (
id VARCHAR(36) PRIMARY KEY, code VARCHAR(128) NOT NULL, name VARCHAR(200) NOT NULL,
module VARCHAR(64) NOT NULL, description 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
);
CREATE UNIQUE INDEX uk_permission_code_h2 ON permissions(code);
CREATE TABLE role_permissions (
role_id VARCHAR(36) NOT NULL, permission_id VARCHAR(36) NOT NULL,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE operator_roles (
operator_id VARCHAR(36) NOT NULL, role_id VARCHAR(36) NOT NULL,
PRIMARY KEY (operator_id, role_id)
);
-- Phase 17:举报与风控三表(与 V012__add_risk_and_report_tables.sql 生产 DDL 对齐)。
-- 此前完全缺失 → ReportService / RiskReferralService 整条真实 DB 路径零覆盖,
-- 且 ReportServiceTest 只能写 assertTrue(true) 假绿。已补入并移出 TEST_SCHEMA_OPTIONAL_TABLES 白名单。
+18 -1
View File
@@ -61,6 +61,22 @@
|--------|------|------|
| 申诉是否V1.1首发 | 否(Feature Flag 预留) | ✅ 已完成 |
### 红娘角色权限(ZC-MM-001~007,2026-10-10 回写)
> 正式决策与开发规则见《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》V1.1;
> 治理登记以 `docs/governance/decision-register.md`「红娘角色权限专项」与 `open-questions.md` OQ-MATCHMAKER-003~008 为准。
| 决策项 | 确认值 | 状态 |
|--------|--------|------|
| DEC-MATCHMAKER-001 红娘角色定位与权限边界 | 受授权、受范围限制的公益服务角色 | ✅ 已完成(LOCKED;落地 2d60f78 审计 → 861c78e/3caf5f8/5e484d0/1be0d98 全链路) |
| DEC-MATCHMAKER-002 / A1 服务对象分配 | 平台授权人员或红娘组长分配;普通红娘仅访问本人有效分配对象;禁止认领 | ✅ 已完成(LOCKED;V053/V054 + ServiceAssignmentPolicy,861c78e) |
| DEC-MATCHMAKER-003 / B1 联系用户渠道 | 默认平台内渠道;电话/线下不默认开放 | ✅ 已完成(LOCKED;守卫锁定 5e484d0;开放前置确认见 OQ-MATCHMAKER-003【LEGAL_VERIFY】) |
| DEC-MATCHMAKER-004 不得代替用户决定 | Like/Pass/活动意向/开启聊天等用户本人操作 | ✅ 已完成(LOCKED;5e484d0 锁定) |
| DEC-MATCHMAKER-005 数据最小必要 | RBAC + 数据范围 + 字段隐私 + 服务授权 | ✅ 已完成(LOCKED;3caf5f8 + 1be0d98) |
| DEC-MATCHMAKER-006 全程留痕 | 分配/受限查看/越权拒绝等审计 + 第八节字段 | ✅ 已完成(LOCKED;V055 + 1be0d98) |
| 受控联系(电话/线下)授权机制 | **已立项实现**(DEC-MATCHMAKER-003 B1 例外通道,V056,提交 d3735e3,QA PASS 0 P0/P1):申请(须持有效分配)→ 审批(双岗 DEFAULT+CONFIG)→ **用户单独同意**(PIPL 单独同意动作:独立端点、无预同意参数、守卫三端锁定)→ 限时生效(默认 7 天)→ 撤销/过期全链路上线;唯一披露出口专用 disclose 端点(enabled fail-safe + 404 化 deny + user_phone_disclosure 敏感审计 + phone 不落日志) | ⏳ 剩余决策:双岗收敛(OQ-MATCHMAKER-009)+ 5 项 LEGAL_VERIFY 终审(OQ-MATCHMAKER-003);参数均 DEFAULT+CONFIG,终审后改配置生效 |
| C 端死代码退役 3 项 | MatchmakerServiceService.getCandidates / cancelService / getRecommendationsForUser(grep 全仓零调用已核,2026-10-10) | ⏳ 待产品确认:是否批准专项治理批次执行(与 ZC-MM-006 G6 评估清单合并为一个退役 PR,不混入功能提交) |
---
## 已确认决策列表(不得再放 Pending)
@@ -70,6 +86,7 @@
| 类别 | 确认项 |
|------|--------|
| 品牌 | 善缘丹阳品牌正式启用 |
| 红娘权限 | DEC-MATCHMAKER-001~006 全部 LOCKED(A1 服务对象分配 / B1 平台内联系 / 不得代用户决定 / 数据最小必要 / 全程留痕;详见 decision-register「红娘角色权限专项」与 open-questions OQ-MATCHMAKER-003~008) |
| 首页 | Banner运营 + 8个核心入口 + 固定顺序 |
| 数据保留 | **注销后用户数据保留不得低于 36 个月**(2026-09-19 产品确认;到期后经人工审核匿名化清除;保留期配置化 `account.data-retention-months`,默认 36;已落地 V049 `users.closed_at` + `DataRetentionService` + `account.retention.purge` 权限。隐私政策 §3.3 与 PIA §8 原文「30 天内删除」已同步修订) |
| 服务站 | 万善公园婚姻登记处为固定阵地 |
@@ -129,5 +146,5 @@
---
*最后更新: 2026-09-20 — V1.0.1 配置与规则纠偏:消解「推荐阈值 60 已确认 / 待定」矛盾(60 为技术默认,OQ-MATCH-001);照片上限 6 由「已确认」降级为「技术默认 / 未确认」(OQ-PROFILE-001);活体核验补 CONFIG/TBD 并接 IdentityPolicy 真实判定点(OQ-IDENTITY-001);拆分出 docs/governance/(config-register / open-questions / decision-register)。*
*最后更新: 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 已确认 / 待定」矛盾等)。*
@@ -0,0 +1,254 @@
# 红娘角色权限差距审计(ZC-MM-001)
> **审计基准**:《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》V1.1(DEC-MATCHMAKER-001~006 全部 LOCKED,A1/B1 已确认)
> **审计日期**:2026-10-09
> **审计执行**:架构师(高见远)· software-danyang-mm 团队 · 只读走查,未修改任何业务代码
> **审计方式**:backend matchmaker/referral/audit/security/auth 模块、Flyway V001~V052、admin-web 红娘相关页面、现有测试与治理文档。所有结论均给出文件路径+行号或 SQL/代码证据。
---
## 0. 审计范围与方法
- 后端:`backend/src/main/java/com/danyang/match/platform/` 下 `matchmaker/`(controller 2、service 2、entity 3、mapper 3)、`referral/`(controller 1、service 1)、`audit/`、`shared/security/`、`auth/`、`counseling/`(对照 INV-012)、`review/`(对照敏感访问日志范式)。
- 数据库:`backend/src/main/resources/db/migration/` V001~V052 全量中 matchmaker 相关 8 个迁移(V001/V002/V009/V019/V021/V037/V038/V044/V052)。
- 前端:`admin-web/src/modules/matchmaker/MatchmakerManagementView.vue`、`modules/referral/ReferralManagementView.vue`、`router/index.ts`、`layouts/MainLayout.vue`。
- 测试:`backend/src/test` 中 matchmaker/referral/权限相关 6 个测试类。
- 治理文档:`docs/governance/decision-register.md`、`open-questions.md`、`docs/PENDING_DECISIONS.md`。
- 全局检索:`matchmaker_assignments`、`ServiceAssignment`、`matchmaker_leader`、`ContactAuthorization` 等关键词。
状态标注:【已实现】/【部分实现】/【缺失】/【无法确定】。
---
## 1. 现状总览
**一句话结论**:当前仓库的"红娘"只是一个挂在 `matchmaker_services` 服务单上的 `assigned_operator_id` 工单负责人概念——没有独立的服务对象分配关系、没有红娘组长角色、没有统一的数据范围/字段级隐私策略层;工单维度上已做过多轮越权修补(推荐/转介/详情归属校验),但**认领、跟进、完结等写接口仍缺归属校验,未分配队列与全局统计对全体红娘可见,审计与权限回收存在系统性缺口**。A1 核心未建模,B1 现状上"侥幸安全"(接口本就不返回联系方式)而非"设计安全"。
### 决策要求 vs 现状对照表
| # | 决策要求 | 现状 | 状态 | 关键证据 |
|---|---------|------|------|---------|
| 1 | DEC-002/A1:服务对象由平台授权人员或红娘组长分配,普通红娘仅访问本人有效分配对象 | 分配仅体现在 `matchmaker_services.assigned_operator_id`(服务单指派),无独立分配实体、无 assigned_by/理由/有效期/结束语义;`/pending` 全局队列对任何 `matchmaker.view` 持有者可见 | **缺失(核心)** | V009 L5-20;`MatchmakerOperatorController` L32-36;`MatchmakerServiceMapper.findSubmitted()` |
| 2 | DEC-002 规则2:禁止普通红娘认领未分配/他人对象 | `POST /operator/matchmaker/services/{id}/claim` 任何 `matchmaker.service` 持有者可对任何 assigned 服务接单,无归属校验 | **缺失** | `MatchmakerOperatorController` L49-56;`MatchmakerServiceService.claimService` L109-122 |
| 3 | DEC-002 规则3/6:分配、转分配、撤销、结束留痕;状态机覆盖 | 仅有 assign/complete/cancel(cancel 无 HTTP 入口,死代码);无转分配、无撤销、无结束原因;分配人(assigned_by)不记录 | **缺失** | `MatchmakerServiceService` L87-104、L229-237;V009 L11-14 |
| 4 | DEC-003/B1:默认平台内联系,不暴露完整手机号/微信号/详细地址 | 红娘侧 DTO/VO 与前端页面均无 phone/wechat/address/idNumber 字段;但也**不存在**任何联系授权机制(无表、无策略类) | **部分实现(结果安全、机制缺失)** | `MatchmakerServiceService.getServiceDetail` L329-348;`ReferralService` 全文;全局检索 ContactAuthorization 无结果 |
| 5 | DEC-004:红娘不得代替用户 Like/Pass/确认开启聊天/接受活动意向 | 设计与实现均贯彻"推荐≠Like":推荐仅建 `pending_view` 记录,Like/Pass 由用户本人端点(UserPrincipal+`findByIdAndUser` 归属校验)触发;转介确认须用户本人 | **已实现** | `MatchmakerService` L97-142、L213-258;`ReferralService.confirmReferral` L139-184;测试 `MatchmakerReferralPhase13Test` L132-296 |
| 6 | DEC-005:最小必要 + 数据范围 + 字段隐私 + 有效分配 | 无 Policy 层;数据范围校验散落且口径不一:recommend/转介有归属校验,claim/followup/complete 无;`matchmaker.assign` 与 `matchmaker.service` 语义即"工单指派/工单执行",不是"分配服务对象" | **缺失** | `MatchmakerServiceService` L142-151(有)vs L109-122、L186-203、L208-224(无) |
| 7 | DEC-006:全程留痕(分配、敏感查看、越权拒绝等) | 有 audit 基础设施且授权拒绝有全局监听;但 claim 不审计、敏感详情查看不记 SensitiveAccessLog、模块内归属拒绝不走 DataScopeGuard、分配审计不记 assigned_by/理由 | **部分实现** | `AuthorizationAuditListener` L25-49;`DataScopeGuard` 使用方仅 activity/counseling/service_appointment;`MatchmakerServiceService.saveAuditLog` L395-403(before/after 恒 null) |
| 8 | 功能权限矩阵:红娘无全站检索/导出/敏感字段 | matchmaker 角色仅授 3 权限(view/service/assign),无 user.view、无 data.export;全仓未发现导出端点 | **已实现(现状合规)** | V052 L24-28;grep `data.export` 使用方为 0 |
| 9 | 红娘组长角色 | 全仓(迁移+代码+前端)无 matchmaker_leader/team_lead | **缺失** | grep 无结果 |
| 10 | 测试基线 MM-AUTH/CONTACT/ACTION/PRIVACY/AUDIT | 仅有"红娘不代替用户操作"(MM-ACTION 方向)与状态白名单守卫测试;无数据范围/联系方式/审计/生命周期场景测试 | **缺失** | `MatchmakerReferralPhase13Test`、`MatchmakerServiceTest`(仅常量白名单校验) |
---
## 2. 分项审计
### 2.1 当前红娘角色及权限实现
**角色与权限定义**【部分实现】:
- `matchmaker` 角色定义于 V001:`('00000000-0000-0000-0000-000000000005', 'matchmaker', '公益红娘', '提供人工婚恋服务', TRUE)`(V001 L145)。
- 三条红娘权限定义于 V001 L161-163:`matchmaker.view`(020)、`matchmaker.assign`(021)、`matchmaker.service`(022)。
- **角色-权限授权缺口曾真实存在**:V052 迁移注释自证——"创建红娘运营账号后发现该角色在 role_permissions 中零条授权,登录后台 403"(V052 L4-12);V052 仅补授 3 条权限(L24-28)。
- 其余角色授权同样残缺:V044 注释承认"permissions 共 48 条,role_permissions 里 super_admin 只有 18 条;matchmaker 角色为 0 条"(V044 L5-11),V044 只回补了 super_admin,**platform_admin/organization_admin 等角色的红娘权限授予状态无法从迁移中确认**【无法确定,需查实际库】。注意 `PermissionMatrixTest` L57-59 期望 platform_admin 拥有 matchmaker.view/assign/service,但迁移中未见对应 INSERT【测试期望与 DB 授权可能漂移】。
- **红娘组长角色不存在**:迁移与代码 grep `leader|team_lead` 均无结果。
**后端接口面**【部分实现】:
- 运营端 `MatchmakerOperatorController`(`/operator/matchmaker`,类级 `@PreAuthorize("hasAuthority('matchmaker.view')")` L19):dashboard(L25)、pending(L32)、assign(L38,`matchmaker.assign`)、claim(L49,`matchmaker.service`)、my-services(L58)、recommendations(L69)、services/{id} 详情(L75)、recommend(L87)、followup(L99)、complete(L112)。
- 用户端 `MatchmakerController`(`/matchmaker`):提交申请(L48-58)、我的服务(L60)、推荐列表(L67)、viewed/accept/pass(L73-96)。
- 转介 `ReferralController`(`/referrals`):service-station(L33)/activity(L53) 均 `matchmaker.service`;confirm(L70)/my-history(L85) 用户本人;operator/history(L99) 按 `matchmaker.service`。
**关键发现:`matchmaker.assign` 的语义**——它是"把服务工单指派给某红娘"(`assignService(serviceId, operatorId, organizationId)`,`MatchmakerServiceService` L87-104),即**工单分派**而非决策文档意义上的"服务对象分配"(对用户建立服务关系)。当前系统里"服务对象"就是服务单上的 `user_id`,分配关系与工单合一,无法表达"同一用户多次/多任务分配、分配历史、到期、撤销"。
**已知实现缺陷(非权限但影响验收)**:
- `countInProgressByOperator` 统计 `status='processing'`(`MatchmakerServiceMapper` L28-30),而 DB CHECK 白名单只有 `in_service`(V009 L30-32),工作台 inProgress 恒为 0。
- `cancelService`(`MatchmakerServiceService` L229-237)无任何 Controller 入口,死代码。
### 2.2 服务对象分配建模
**结论:未建模**【缺失】。
- 全仓(backend main + admin-web src + 全部迁移)grep `matchmaker_assignments|service_assignment|ServiceAssignment` 无结果。
- 唯一的"分配"载体是 `matchmaker_services.assigned_operator_id`(V009 L12),字段只有 `assigned_operator_id / assigned_at / organization_id`,**没有** assigned_by、assignment_reason、assignment_scope、expires_at、ended_at、end_reason、status(分配关系状态与服务单状态混用)。
- `/operator/matchmaker/my-services` 返回该红娘名下全部服务含历史(`MatchmakerServiceMapper.findByOperatorId` L36-38,`MatchmakerServiceService.getMyServices` L68-82),即"我的服务对象 = 工单负责人",非独立分配关系。
- **认领入口存在**:`POST /services/{id}/claim` 仅需 `matchmaker.service`(`MatchmakerOperatorController` L49-56),`claimService` 只校验状态=assigned,不校验该单是否分配给本人,也不把 assigned_operator_id 改成自己(`MatchmakerServiceService` L109-122)——任何红娘可以把任何红娘的 assigned 单推进到 in_service。直接违反 DEC-002 规则 2(不能自行认领)。
- **转分配/撤销/结束**:无 transfer/revoke 接口;`cancelService` 无入口;`completeService` 无归属校验(L208-224)。
### 2.3 列表/搜索/详情/导出数据范围一致性
| 接口 | 数据范围 | 判定 |
|------|---------|------|
| `GET /pending`(L32-36 → `findSubmitted` Mapper L17-18) | **全局**:任何 `matchmaker.view` 持有者可见全库 submitted 申请,含申请人 userId + 需求描述(Service L245-258) | ❌ 违反 DEC-002 规则1(不能浏览未分配对象库)、DEC-005 |
| `GET /dashboard`(L25-30 → L260-276) | pendingCount/todayCount/completedCount 为**全局统计**,inProgress 按本人 | ❌ 信息泄露(全局量级)+ 状态口径 bug |
| `GET /my-services`(L58-63 → `findByOperatorId`) | 按本人过滤 | ✅ |
| `GET /recommendations`(L69-73 → `findByOperatorId`) | 按本人过滤 | ✅ |
| `GET /services/{id}` 详情(L75-85 → `getServiceDetail` L301-351) | 负责人→完整(含 userProfile 8 字段 + followup 正文);非负责人→**仍返回**状态、assignedOperatorId、以及**该服务的推荐列表(含他人服务的 targetUserId 与推荐理由)**(L317-327 对所有调用者装配) | ⚠️ 部分修复:比 Phase 13 D4 前好,但非负责人仍可探测任意 serviceId 的存在性与他人推荐内容(违反"响应不得泄露目标用户是否存在");推荐理由是红娘对候选对象的自由文本,可能含隐私 |
| `POST /services/{id}/recommend`(L87-97 → L142-181) | 归属校验(L148-151)+ 不能推给申请人(L153-155) | ✅(但推荐对象 targetUserId 由红娘手输,无候选来源,见 2.4) |
| `POST /followup`(L99-110 → L186-203) | **无归属校验**:任何 `matchmaker.service` 持有者可对任意 serviceId 写跟进正文 | ❌ 违反 DEC-005;跟进正文为自由文本敏感写面 |
| `POST /complete`(L112-118 → L208-224) | **无归属校验** | ❌ |
| `POST /claim`(L49-56 → L109-122) | **无归属校验** | ❌(见 2.2) |
| 转介 `POST /referrals/*` | `assertMatchmakerServesUser`(`ReferralService` L45-52 → `findActiveByOperatorAndUser` Mapper L48-54)要求存在本人名下、该用户的进行中服务 | ✅(Phase 13 D5 修复,注释自证修复前可代任意用户创建报名/预约) |
| 导出 | 全仓未发现任何导出端点(grep `data.export|export` 在 main 源码无结果) | ✅ 现状无导出面 |
按 userId/查询参数越权形态:matchmaker 角色无 `user.view`(V052 仅 3 权限),无法触达用户管理接口,全站用户检索面关闭;但上述服务单维度参数越权真实存在。
### 2.4 联系方式暴露(B1)
**现状结果层面安全**【已实现(被动)】:
- 红娘侧所有返回 DTO 均为手工 Map 装配:服务详情 userProfile 仅 displayName/gender/age/regionName/educationCode/occupationCode/maritalStatus(`MatchmakerServiceService` L333-343),无手机号/微信号/详细地址/身份证。
- 前端 `MatchmakerManagementView.vue` 表格列仅 userId/serviceType/description/status/createdAt(L7-25)与推荐 userId/targetUserId/reason/status(L29-39);`ReferralManagementView.vue` 无联系方式字段。grep phone/mobile/wechat/身份证/地址 在两个 view 中均无命中。
- 转介活动时的 `emergencyContactPhoneEnc` 是**红娘录入**的紧急联系人信息(`ReferralController` L62、`ReferralService` L111-112),非读取用户联系方式。
- 无导出接口可绕道获取联系方式(2.3)。
**机制层面缺失**【缺失】:
- 全仓无 ContactAuthorization/contact_authorization/线下联系 相关表、实体、策略(grep 无结果)——DEC-003 要求的"电话/线下联系须专项授权"完全没有机制承载,目前是"没有功能所以没有漏洞"。若后续有人补电话/线下功能,无任何护栏。
- 平台内联系渠道:现有通知仅是单向 notification(`notificationService.send/sendAsync`),无红娘↔用户平台内会话通道;B1 的"平台内服务申请/回应"仅覆盖转介确认流。
**功能缺口**:推荐弹窗要求红娘手输 targetUserId(前端 L62"输入被推荐对象的用户ID")——红娘既无全站检索权(正确),也没有任何"从用户已提供的择偶需求/公开池中获取候选"的受控途径,主流程实际不可用【部分实现】。
### 2.5 红娘代替用户操作(DEC-004)
**未发现代替用户决策的接口**【已实现】:
- 红娘推荐只建 `MatchmakerRecommendation(status=pending_view)`,明确注释"这不是替用户 Like!""红娘点创建 Match → 不允许!"(`MatchmakerService` L97-105),并且有归属校验(L117-121)。
- Like/Pass 仅由用户本人端点触发:`POST /matchmaker/recommendations/{id}/accept|pass`(`MatchmakerController` L81-96)→ `acceptRecommendation` 用 `findByIdAndUser(recommendationId, userId)` 归属校验(`MatchmakerService` L213-222),Pass 同理(L246-255)。
- 红娘无法以用户身份调用:用户端点走 `UserPrincipal`,运营端走 `OperatorPrincipal`(两套 principal 体系,`shared/security/principal/`),且测试锁定"红娘无法用别人的用户身份接受推荐"(`MatchmakerReferralPhase13Test` L158-171)。
- 转介确认(活动/预约)须用户本人 `confirmReferral`,校验 `userId.equals(appt.getUserId())`(`ReferralService` L145-146、L166-167),非本人确认被拒并有测试(`MatchmakerReferralPhase13Test` L255-269)。
- 无修改用户择偶条件、同意公开资料的红娘入口(matchmaker 角色无 user 相关权限)。
- `matchmaker.assign` 的实际用途 = 指派工单给红娘,**不是**"代替分配对象做操作";其风险在于指派时 `organizationId` 参数恒传 null(Controller L46 传 `null`),且前端弹窗是硬编码假数据(见 2.8)。
⚠️ 唯一灰色点:转介会**代替用户创建** `activity_registration(status=pending)` / `service_appointment(status=submitted)` 记录(`ReferralService` L74、L114),但带 `source=matchmaker_referral` 标记且必须用户本人确认后生效(`needsUserConfirmation=true`),语义上是"代填草稿、用户确认",与 DEC-004 底线可兼容,但建议在 ZC-MM-004 复核其与"代填关键个人信息"边界的表述。
### 2.6 审计日志覆盖(DEC-006)
**基础设施**【已实现】:
- `audit_logs` 表 + `AuditLog` 实体(operatorId/organizationId/action/resourceType/resourceId/beforeData/afterData/ip/userAgent)。
- `sensitive_access_logs` + `SensitiveAccessLog`(operatorId/userId/dataType/reason/accessTarget/accessResult/ip)。
- `AuthorizationAuditListener`(L25-49):`@PreAuthorize` 拒绝自动写 `AUTHORIZATION_DENIED`(含 IP/UA)→ **@PreAuthorize 层的越权拒绝已覆盖**。
- `DataScopeGuard.deny`(L21-36):写 `DATA_SCOPE_DENIED` 后抛 403。
**matchmaker 模块实际覆盖**【部分实现,缺口明确】:
| 操作 | 审计现状 |
|------|---------|
| 分配 assign | ✅ `MATCHMAKER_ASSIGN`(L102),但 beforeData/afterData 恒 null(saveAuditLog L395-403),**不记录分配人以外信息(谁指派=principal.id 即 assignOperatorId=被指派红娘,见 Controller L44-46 的参数命名混乱:body.operatorId 是"被分配红娘",操作者是 principal,审计里存的是前者)** |
| 认领 claim | ❌ 无审计(L109-122 无 saveAuditLog) |
| 推荐 recommend | ✅ `MATCHMAKER_RECOMMEND`(L174) |
| 跟进 followup | ✅ `MATCHMAKER_FOLLOWUP`(L201) |
| 完成 complete | ✅ `MATCHMAKER_COMPLETE` 但 operatorId 传 null(L222)——审计丢失操作者 |
| 取消 cancel | ❌ 死代码且无审计 |
| 查看敏感详情 getServiceDetail | ❌ 不写 SensitiveAccessLog、不调 recordSensitiveAccess(对照:ReviewService L470-477 访问本地资格佐证时写 SensitiveAccessLog;AuditLogService.recordSensitiveAccess L74-88 已有现成方法) |
| 模块内归属拒绝(recommend/followup 的 IllegalStateException、ReferralService 的 IllegalStateException) | ❌ 不走 DataScopeGuard,不产生 DATA_SCOPE_DENIED;仅裸抛异常 → **服务层越权拒绝不落审计**,违反 MM-AUDIT-002 |
| 缺失字段 | 审计无 purpose_code/service_assignment_id/actor_role_snapshot/request_trace_id(DEC-006 第八节字段要求),AuditLog 实体不含这些列 |
### 2.7 可复用模块
| 组件 | 位置 | 与 A1/B1 的关系 |
|------|------|----------------|
| **CounselingService 的 INV-012 判定链路** | `counseling/service/CounselingService.java` L181-200(assign 用条件更新 assignIfPending 防并发)、assertAssigned 模式(L205/232/242 各写接口前置)、`GET /counseling/services/operator/assigned`(Controller L141-145 → assignedTo) | **与 A1 最接近**:"只有被分配人可见/可操作" 的完整范式,含权限矩阵注释(L38-48 定义 6 种角色的列表/脱敏/全文视图)。红娘"本人有效分配对象"可直接套此模式 |
| **DataScopeGuard** | `shared/security/DataScopeGuard.java` L21-36 | 拒绝+审计一体,activity/counseling/service_appointment 已用;红娘模块的归属拒绝应统一接入 |
| **AuthorizationAuditListener** | `shared/security/AuthorizationAuditListener.java` | @PreAuthorize 拒绝自动审计,MM-AUDIT-002 的 403 层已覆盖 |
| **AuditLogService.recordSensitiveAccess + SensitiveAccessLog** | `audit/service/AuditLogService.java` L74-88;使用范例 `review/service/ReviewService.java` L470-477 | 受限字段/资料查看审计现成方法(matchmaker 详情接口应调用) |
| **MatchmakerServiceMapper.findActiveByOperatorAndUser** | `matchmaker/mapper/MatchmakerServiceMapper.java` L48-54 | 现成的"本人名下+该用户+有效状态"判定 SQL,是未来 ServiceAssignmentPolicy 判定的雏形(状态集含 submitted 待收紧) |
| **OperatorAuthService 权限装载** | `auth/service/OperatorAuthService.java` L98-108(权限集合仅登录时装载进 JWT claims) | 权限事实来源;同时是权限回收的**风险点**(见 P1-7) |
| 用户端归属校验范式 | `MatchmakerRecommendationMapper.findByIdAndUser` + `MatchmakerService.acceptRecommendation` L213-222 | "资源归属=当前主体"校验样板 |
### 2.8 前端现状
- `MatchmakerManagementView.vue`:服务请求队列 tab 对所有 `matchmaker.view` 用户展示全库 pending(userId+需求说明,L7-25);**分配弹窗红娘下拉是硬编码假数据** `operator_wang/operator_chen`(L47-50)——分配功能前端不可真实使用;推荐弹窗需手输 targetUserId(L62)。
- 路由/菜单:`router/index.ts` L40-41、`MainLayout.vue` L177-178 权限码已与后端对齐(matchmaker.view / matchmaker.service,V052 注释所述错配已修复)。
- `ReferralManagementView.vue` L35 已改走 `/referrals/operator/history`(契约缺口已修)。
### 2.9 治理文档现状
- `docs/governance/decision-register.md`:grep `DEC-MATCHMAKER|matchmaker` 无结果【缺失】。
- `docs/governance/open-questions.md`:无 matchmaker/电话/线下联系条目【缺失】。
- `docs/PENDING_DECISIONS.md`:无 A1/B1/matchmaker 条目【缺失】。
- `docs/audits/` 目录不存在(本报告即首次建立)。
### 2.10 现有测试
| 测试 | 覆盖 | 缺口 |
|------|------|------|
| `MatchmakerReferralPhase13Test` | MM-ACTION 方向较完整:推荐不产生 Like/Match(L132-141)、只有用户能 Like(L143)、红娘不能代用户接受(L158-171)、转介归属校验(L288-296)、用户本人确认(L225-269) | 不含数据范围/联系方式/审计断言 |
| `MatchmakerServiceTest` | 仅校验常量与 DB CHECK 白名单一致(L94-177),**纯守卫测试,无权限断言** | — |
| `PermissionMatrixTest` | 静态 Map 自断言(L25-224 + L229 起),**不连接 DB/迁移文件**,且期望值(matchmaker 有 user.view=true,L119)与 V052 实际授权(无 user.view)漂移 | 矩阵不校验真实授权,是"看起来有保护"的假阳性 |
| `OperatorEndpointPermissionCoverageTest` | 机械守卫:确保运营端 Controller 方法有 @PreAuthorize(L10、L43) | 只查注解存在性,不校验权限码正确性 |
| SecurityTests / V11InvariantsTest | 通用授权拒绝、INV 守卫 | 无 MM-AUTH/CONTACT/PRIVACY/AUDIT/LIFECYCLE 专属场景 |
**测试基线覆盖判定**:MM-ACTION ≈ 部分覆盖;MM-AUTH/MM-CONTACT/MM-PRIVACY/MM-AUDIT/MM-LIFECYCLE ≈ 全部缺失。
---
## 3. 可复用模块清单
(按建议采用优先级)
1. **CounselingService INV-012 范式**(`CounselingService` L181-200 条件更新分配、assertAssigned 写接口前置、Controller L141-145 assigned 列表)——A1"本人有效分配对象"的直接模板。
2. **DataScopeGuard**(`shared/security/DataScopeGuard.java`)——归属拒绝统一走 deny()=审计+403。
3. **AuthorizationAuditListener**——已有,无需新建。
4. **AuditLogService.recordSensitiveAccess + SensitiveAccessLog**(ReviewService L470-477 为使用范例)——受限字段查看审计。
5. **MatchmakerServiceMapper.findActiveByOperatorAndUser**——分配有效性判定 SQL 雏形。
6. **OperatorAuthService/JwtAuthFilter 权限装载链**——若做权限回收需改造此链路(JWT claims 快照问题)。
7. 无 MatchmakerPolicy/PrivacyPolicy/ContactAuthorizationPolicy 任何现存实现——需新建。
---
## 4. 差距与风险清单(按优先级)
### P0(违反 LOCKED 决策,阻断 DoD)
| # | 差距 | 违反 | 影响 | 证据 |
|---|------|------|------|------|
| P0-1 | 服务对象分配未建模:无 matchmaker_assignments/独立分配实体,分配=工单指派且无 assigned_by/理由/有效期/结束语义/分配历史 | DEC-002 规则6/7、5.1 概念模型 | A1 无法落地;分配不可追溯、不可回收、不可过期 | V009 L5-20;全仓 grep 无 assignment 实体 |
| P0-2 | 普通红娘认领入口开放且无归属校验:claim 任意 assigned 单;followup/complete 对任意 serviceId 可写/可完结 | DEC-002 规则1/2;DEC-005;MM-AUTH-004 | 任何红娘可干预他人服务对象、写入跟进内容、变更服务状态 | MatchmakerOperatorController L49-56、L99-118;MatchmakerServiceService L109-122、L186-203、L208-224 |
| P0-3 | 未分配申请队列+全局统计对全体红娘可见(含申请人 userId 与诉求描述) | DEC-002 规则1;DEC-005;MM-AUTH-002/PRIVACY-001 精神 | 普通红娘可浏览"待分配用户库"挑选对象,正面违反 A1 | MatchmakerOperatorController L32-36;MatchmakerServiceService L245-258、L260-276;Mapper findSubmitted L17-18 |
| P0-4 | 无红娘组长角色与团队管理范围概念,assign 权限无范围约束(谁能 assign 只有权限码、无组织/团队边界;organizationId 恒传 null) | DEC-002 规则4/5/8 | 分配权无法收敛到"平台授权人员或组长";跨团队数据范围无依据 | MatchmakerOperatorController L38-47(organizationId=null);全仓无 leader 角色 |
| P0-5 | 服务层归属/范围拒绝不产生审计(不走 DataScopeGuard),claim 无审计、complete 审计丢操作者、分配审计字段残缺 | DEC-006;MM-AUDIT-001/002 | 分配与越权链条不可追溯 | MatchmakerServiceService L102/L109-122/L222/L395-403;DataScopeGuard 使用方不含 matchmaker |
### P1(安全机制缺口,DoD 必补)
| # | 差距 | 违反 | 影响 | 证据 |
|---|------|------|------|------|
| P1-6 | 非负责人可读任意服务单脱敏详情+他人推荐列表(targetUserId、推荐理由),泄露存在性与他人服务内容 | DEC-005(响应不得泄露目标用户是否存在);MM-PRIVACY-001 | 横向探测面 | MatchmakerServiceService L301-351(L317-327 对非 owner 装配 recommendations) |
| P1-7 | 运营权限是登录时 JWT claims 快照(JwtAuthFilter L90-99 直接取 claims),停用/调岗/权限回收后旧 token 有效期内仍放行 | DEC-005/006(权限及时回收);MM-LIFECYCLE-001 | 权限回收延迟=token 剩余有效期 | OperatorAuthService L98-108;JwtAuthFilter L90-99 |
| P1-8 | 受限资料查看无 SensitiveAccessLog(服务详情 userProfile 属受限资料但零记录) | DEC-006;MM-AUDIT 方向 | 敏感读取不可溯源 | MatchmakerServiceService L329-348;对照 ReviewService L470-477 |
| P1-9 | 无统一 Policy 层(MatchmakerPolicy/DataScopePolicy/PrivacyPolicy),范围校验散落且口径不一 | DEC-005 判定链要求 | 后续每加一个接口都可能漏校验(现状已证明) | §2.3 表格 |
| P1-10 | B1 联系授权机制完全缺失(无表无策略无审批流),当前安全是"功能不存在"而非"有护栏" | DEC-003 规则7 | 一旦有人新增电话/线下功能即裸奔;DoD "无授权电话/线下联系被拒绝" 无承载 | grep ContactAuthorization 无结果 |
| P1-11 | 测试基线缺口:MM-AUTH/CONTACT/PRIVACY/AUDIT/LIFECYCLE 无场景测试;PermissionMatrixTest 与真实授权漂移且自引用 | 决策文档第十节 | 回归无保护网 | §2.10 |
### P2(质量/体验/治理)
| # | 差距 | 影响 | 证据 |
|---|------|------|------|
| P2-12 | 分配弹窗硬编码假红娘(operator_wang/chen),分配功能前端不可用 | 主流程断裂 | MatchmakerManagementView.vue L47-50 |
| P2-13 | 推荐对象需手输 userId,无受控候选来源,推荐主流程实际不可用 | 业务不可用(但不可用方向是安全的) | 同上 L62 |
| P2-14 | dashboard inProgress 统计用 'processing' 与 DB 白名单 'in_service' 不符,恒为 0 | 数据失真 | MatchmakerServiceMapper L28-30 vs V009 L30-32 |
| P2-15 | cancelService 死代码;matchmaker.assign 的 organizationId 参数恒 null | 代码卫生 | MatchmakerServiceService L229-237;Controller L46 |
| P2-16 | 治理文档无 DEC-MATCHMAKER/A1/B1 条目;docs/audits 目录此前不存在 | 治理不同步(ZC-MM-007 范围) | §2.9 |
| P2-17 | PermissionMatrixTest 期望 platform_admin/reviewer 等角色矩阵,但迁移未定义这些角色的 matchmaker 授权,实际库状态无法确定 | 授权事实不清 | V052 仅授 role 005;V044 仅补 super_admin |
---
## 5. 仍需后续确认的实现细节(对应决策文档第十一节,仓库现状)
| 待确认项 | 仓库现状 |
|---------|---------|
| 电话/线下联系例外批准岗位 | 无任何授权机制/审批流/岗位模型(grep 无结果);需产品确认后从零建 |
| 例外授权是否需用户单独同意及记录方式 | 无载体;`activity_registration`/`service_appointment` 已有 `user_confirmed_at`+`source=matchmaker_referral` 的用户确认范式(ReferralService L151-152)可作参考 |
| 临时授权有效期限 | 无 expires_at/有效期字段于任何红娘相关表(matchmaker_services 也没有);`matchmaker_recommendations` 无过期机制 |
| 审计日志/服务摘要保留周期 | 迁移中未见 audit_logs 的保留/清理策略;account 模块有 DataRetentionService(`account/service/DataRetentionService.java`)可参考但未覆盖审计日志【保留周期无法确定】 |
| 红娘组长可分配的组织/团队边界 | `organizations` 表与 `matchmaker_services.organization_id` 存在(V009 L11),operator 与组织的关联关系存在于 operator 体系,但"团队/红娘组"概念不存在,边界配置无从谈起 |
| platform_admin/organization_admin 的 matchmaker 权限实际授权 | 迁移不可证【无法确定,需查演示库】 |
| 转介代建 pending 报名/预约与"代填关键信息"边界 | 见 §2.5 灰色点,建议 ZC-MM-004 复核时与产品确认表述 |
---
## 6. 对 ZC-MM-002~007 实施顺序的建议(仅顺序与依赖)
1. **ZC-MM-002(A1 分配)先行且作为一切的地基**:必须先落 `matchmaker_assignments` 独立分配关系(含 assigned_by/reason/status/expires/ended)+ 红娘组长角色 + 分配状态机。理由:P0-1~P0-4 全部依赖此建模;后续所有数据范围判定都以"有效分配"为事实源。
2. **ZC-MM-005(统一数据范围与字段隐私)紧跟 002**:把 claim/followup/complete/pending/dashboard/详情统一接入范围判定(复用 CounselingService INV-012 + DataScopeGuard)。依赖 002 的分配事实源;不先做会继续在旧工单模型上打补丁。
3. **ZC-MM-003(B1 联系规则)与 004(禁止代操作复核)可并行**:003 当前主要是"补护栏+固化现状"(关闭暴露面+建立联系授权的拒绝默认),004 主要是复核与测试锁定(现状已基本合规)。两者都不依赖 002 的表结构,但 003 的"平台内服务通知渠道"若要做成会话类功能,建议在 005 稳定后做。
4. **ZC-MM-006(审计与权限回收)在 002/005 之后收口**:分配/转交审计、SensitiveAccessLog 接入、模块内拒绝走 DataScopeGuard 都依赖 002 的新实体与 005 的判定点;JWT 权限回收(P1-7)涉及 auth 链路改造,放在业务语义稳定之后风险最小。
5. **ZC-MM-007(治理文档)最后**:登记 DEC-MATCHMAKER-001~006 与 A1/B1、open-questions(第十一节五项)、本报告归档于 docs/audits。
6. **贯穿性建议**:每阶段同步补 MM-AUTH/CONTACT/ACTION/PRIVACY/AUDIT/LIFECYCLE 测试基线(当前几乎为零),并把 PermissionMatrixTest 从静态自引用改为对迁移/真实授权的校验——否则 P1-11 的假阳性会掩盖回归。
---
*(审计完毕。本报告由架构师产出、主理人汇编落盘。后续实施需 Owner 授权后按 ZC-MM-002~007 分阶段进行。)*
+7 -2
View File
@@ -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 字面化。*
+26 -1
View File
@@ -25,6 +25,31 @@
| 活动规则 | 需审核 / 男女名额独立配置 / 互选最大 3 人 / 截止后 48 小时 | LOCKED | activity 模块 |
| 关系状态 | searching / getting_to_know / paused / matched_successfully / service_exited | LOCKED | 关系状态枚举 |
| 推荐重置 | 每日 00:00 Asia/Shanghai | LOCKED | 时区统一 Asia/Shanghai |
| 红娘权限 | DEC-MATCHMAKER-001 红娘角色定位与权限边界(受授权、受范围限制的公益服务角色,非管理员/裁决者/风控) | LOCKED | 见下方「红娘角色权限专项」 |
| 红娘权限 | DEC-MATCHMAKER-002 / A1 服务对象由平台授权人员或红娘组长分配,普通红娘仅访问本人有效分配对象 | LOCKED | 见下方「红娘角色权限专项」 |
| 红娘权限 | DEC-MATCHMAKER-003 / B1 红娘联系用户默认平台内渠道,不默认开放电话/线下/完整联系方式 | LOCKED | 见下方「红娘角色权限专项」 |
| 红娘权限 | DEC-MATCHMAKER-004 红娘不得代替用户执行交友决策(Like/Pass/活动意向/开启聊天/修改择偶条件/同意公开资料) | LOCKED | 见下方「红娘角色权限专项」 |
| 红娘权限 | DEC-MATCHMAKER-005 红娘数据访问最小必要(角色权限∧有效分配∧范围∧字段隐私∧服务目的,五条件同时满足) | LOCKED | 见下方「红娘角色权限专项」 |
| 红娘权限 | DEC-MATCHMAKER-006 红娘操作全程留痕(分配/受限查看/授权/越权拒绝等审计事件 + 第八节字段要求) | LOCKED | 见下方「红娘角色权限专项」 |
---
### 红娘角色权限专项(ZC-MM-001~007,2026-10-10 登记)
> 决策基准:《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》V1.1(A1/B1 均标记 LOCKED)。
> 实施全链路:审计先行 → A1 建模 → 数据范围/字段隐私 → B1/代操作护栏 → 审计与权限回收 → 本登记。
| 决策 | 实施摘要 | 落地提交 | 验证 |
|------|---------|---------|------|
| DEC-MATCHMAKER-001~006 差距审计 | 全仓只读走查(角色/分配建模/数据范围/联系方式/代操作/审计/测试/治理 9 项),产出 P0×5 / P1×6 / P2×6 差距清单 | 2d60f78(ZC-MM-001 审计报告) | — |
| DEC-MATCHMAKER-002 / A1 | `matchmaker_assignments` 五态状态机(V053 部分唯一索引防多主责 + 存量回填)、`matchmaker_leader` 角色 + `matchmaker.team.assign`(V054)、`ServiceAssignmentPolicy` 唯一判定器、claim 端点删除、/pending 权限收敛 | 861c78e(ZC-MM-002) | 定向 38/38、UI 24/24、小程序真编译通过 |
| DEC-MATCHMAKER-005 / §5.2 | 读侧纯判定器(删除 assigned_operator_id 回退)、非持有人 404 防探测、字段白名单 7 键 + showOccupation 接入、组长团队脱敏统计 | 3caf5f8(ZC-MM-005) | mvn verify 884/884、UI 契约 24/24、后端契约 198 |
| 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 处判红→还原**(守卫有效性自证)。
---
@@ -40,4 +65,4 @@
---
*最后更新: 2026-09-20 — 登记 15 项已确认决策;照片 6 / 阈值 60 / 活体 / Banner / 报送口径 明确划为非确认项,转入 open-questions。*
*最后更新: 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 项已确认决策。*
+74 -1
View File
@@ -64,6 +64,79 @@
- **当前实现**:`match.effective` 为 `PROVISIONAL` 保守近似(`min(有真人消息会话, Match 总数)`),
文档已标「禁止对外」。建议口径(Mutual Match + Two-Way Chat)未实现;升 `OFFICIAL` 前需产品裁决。
## OQ-MATCHMAKER-002 · 组长团队统计 perMatchmakerCounts 的 operatorName 是否脱敏?
- **状态**:RESOLVED(ZC-MM-005 收口 P2-4,team-lead 裁定 2026-10-10)
- **裁定**:红娘姓名(operatorName)**保留、不脱敏**。依据:
1. 对照《决策》§5.2 字段分类:红娘姓名属「本人/团队范围内的工作人员标识」,**非用户 PII**
(§5.2 禁放的是 phone/mobile/wechat/idNumber 等用户敏感数据);
2. 功能权限矩阵「查看脱敏服务统计:组长=团队范围」+ DEC-MATCHMAKER-002 红娘组长职责
(团队内进度与工作量管理)—— 组长统计本团队工作量属管理职责;
3. 组长已可经 `GET /assignable-matchmakers` 拿到同批红娘姓名,此字段**非新增泄露面**
(管理范围 ≠ 用户敏感数据)。
- **代码落点**:`MatchmakerAssignmentService#teamStatistics` 方法注释已写明该决策依据;
本登记防止后人误判「统计含姓名=越权」。产品如日后要求组长统计姓名脱敏(如仅显示昵称/工号),
需回到本项重新裁决并同步改 `countActiveGroupByOperator` 查询与前端渲染。
## OQ-MATCHMAKER-003 · 受控联系(电话/线下)开放前的产品/法务确认项【LEGAL_VERIFY】
- **状态**:**IN_PROGRESS**(2026-10-11 立项实现:V056 + ContactAuthorizationPolicy/Service + 双控制器 + 前端三端,提交 d3735e3,QA PASS 0 P0/P1;双守卫翻转完成——`MatchmakerContactGuardTest` 改写为新机制契约、`MatchmakerActionGuardTest` 保留)
- **背景**:DEC-MATCHMAKER-003 / B1 LOCKED——默认平台内渠道联系,电话/线下联系须专项授权。B1 例外通道已按「申请(须持有效分配)→ 审批(双岗)→ **用户单独同意**(PIPL 单独同意动作,独立端点、无预同意参数)→ 限时生效 → 撤销/过期」全链路上线。
- **关闭前必须逐项终审(①②标 LEGAL_VERIFY,需产品 + 法务共同裁决)**:
1. 电话/线下联系例外由**哪个岗位批准**【LEGAL_VERIFY】——当前默认 `matchmaker.team.assign,matchmaker.assign` 双岗并列(待收敛,见 OQ-MATCHMAKER-009);
2. 例外授权是否需要**用户单独同意**、如何记录【LEGAL_VERIFY】——当前实现已满足"单独端点 + consent 留痕 + 无捆绑默认勾选",待法务确认该形式合规;
3. 临时授权的**有效期限**——当前默认 `default-validity-days=7`(DEFAULT+CONFIG);
4. 联系授权记录/联系结果摘要的**保留周期**——当前默认 `retention-days=365`(DEFAULT+CONFIG);
5. 授权审批与用户同意的**审计字段与查询面**——当前已实现(purpose_code=matchmaker.contact.* 全链路 + service_assignment_id + request_trace_id + user_phone_disclosure 敏感审计),待确认是否满足合规查询要求。
- **以上默认值均非 LOCKED**:全部经 `matchmaker.contact.*` canonical key 配置化(config-register),产品/法务终审后改配置即可,无需改代码。
- **机械锁(已翻转)**:`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「平台直属」规则
- **状态**:TBD(技术默认安全值,宁窄勿宽,待产品确认)
- **当前技术默认**:`matchmaker.team.assign` 的范围校验按 `operators.organization_id` 相等判定,**NULL 视为「平台直属」**:平台直属组长(org=NULL)只能分配/管理平台直属红娘;有组织组长不能分配 org=NULL 的红娘;org=NULL 的普通红娘只能由平台授权人员(`matchmaker.assign`)分配。判定实现用 IS NOT DISTINCT FROM / Objects.equals 语义(NULL 与 NULL 相等,不落 SQL `=` 的 NULL 陷阱)。
- **说明**:demo 账号 wanghongniang 即 org=NULL 情况。若产品要求「平台直属红娘由平台授权人员统一管理、组长不得触碰」或相反(NULL 归某默认组织),需改范围校验并回归 ZC-MM-002 测试基线。
- **关联**:DEC-MATCHMAKER-002 规则 4/8;提交 861c78e(ZC-MM-002 D2 决策)。
## OQ-MATCHMAKER-005 · JWT 权限实时查库零缓存的压测复议条件
- **状态**:TBD(技术默认:零缓存;非性能问题不复议)
- **当前实现**:`JwtAuthFilter` 运营分支每请求查 `operators.status` + 查库权限(claims 权限快照降级为只写不读),停用/调岗/权限变更**即时生效**(ZC-MM-006,提交 1be0d98)。未加缓存的依据:运营端为内部后台、流量低,且《决策》§七明确「不得依赖长期缓存继续放行」。
- **复议条件**:仅当运营端压测显示鉴权查询成为瓶颈时复议;届时优先考虑**超短 TTL 缓存(秒级)+ 复效事件主动失效**,并在本项登记缓存窗口期与 MM-LIFECYCLE-001 的回归影响。禁止以技术默认值冒充产品决策。
## OQ-MATCHMAKER-006 · 写接口 404 化防探测的语义边界
- **状态**:RESOLVED(2026-10-10,ZC-MM-006 G4 评估表裁定,此处留痕)
- **裁定**:凡**以资源 ID 寻址**且调用者具备权限码的写端点(assignments transfer/revoke/end、services recommend/followup/complete、referrals 转介创建),「不存在」与「范围违规」统一 404(先落 DATA_SCOPE_DENIED 审计再 404,与读详情同范式);**无资源寻址的创建类端点**(POST /assignments 创建、POST /services/{id}/assign)维持 403——它们不构成存在性枚举面,且需要明确的权限反馈。
- **留痕原因**:后续新增写端点时按此边界执行;如产品要求创建类也 404 化(牺牲前端错误提示精度换一致性),需回到本项重裁。
- **关联**:DEC-MATCHMAKER-005「越权响应不得泄露目标用户是否存在」;提交 1be0d98;`MatchmakerWriteDenyUniformityTest` 锁定。
## OQ-MATCHMAKER-007 · purpose_code 取值 matchmaker.service.view 已定义未使用(口径漂移)
- **状态**:TBD(QA P2 移交,2026-10-10)
- **现状**:V055/审计写入点的 purpose_code 取值表中定义了 `matchmaker.service.view`,但实际**无写入点**——受限资料查看走 `sensitive_access_logs`(dataType=matchmaker_service_detail,不含 purpose_code),拒绝路径 purpose_code 传 null(audit_logs 已有 DATA_SCOPE_DENIED 语义)。
- **待产品/架构确认**:二选一——(a) 为服务详情成功查看的 audit_logs 补一条带 purpose_code=matchmaker.service.view 的写入(与 SensitiveAccessLog 双写,有噪音成本);(b) 从取值表删除该定义(登记即口径收敛)。当前行为与 ZC-MM-006 G2 设计一致(拒绝不双写),不构成缺陷,仅口径漂移登记。
## OQ-MATCHMAKER-008 · JwtAuthFilter 运营域与用户域账号状态口径差
- **状态**:TBD(记录性登记,两域状态字段固有差异)
- **现状**:JWT 权限实时回收(ZC-MM-006)后,运营域仅认 `operators.status='active'`,用户域账号状态口径为 `users.account_status='normal'`(含 suspended/banned/closed 等多态)。两域字段模型不同,状态枚举不一一对应。
- **说明**:不构成越权(两域独立判定、各自从严),但若未来要求「统一账号生命周期模型」(如运营也需 suspended 中间态),需产品出统一口径后重构。当前按各自域内语义执行即安全默认。
## OQ-THIRD-PARTY-* · 第三方开放 / 平台化遗留(7 项)
- **状态**:TBD(范围扩张,需产品决策,本轮未擅自实施)
@@ -73,4 +146,4 @@
---
*最后更新: 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
View File
@@ -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": "丹阳公益婚恋",
+1 -1
View File
@@ -26,7 +26,7 @@ module.exports = {
// 部署前必须替换为真实域名,并到小程序后台「开发管理 → 开发设置 → 服务器域名」配置 request 合法域名。
// 注意:本文件不得再出现散落的硬编码地址,统一改这里即可。
API_BASE_URL: isDev
? 'http://localhost:8085/api/v1'
? 'http://127.0.0.1:8085/api/v1'
: 'https://api.danyang-match.example.com/api/v1', // TODO(部署): 替换为真实生产域名
IS_DEV: isDev,
};
+1 -1
View File
@@ -28,7 +28,7 @@ const PROD_API_BASE_URL: string =
|| 'https://api.danyang-match.example.com/api/v1'; // TODO(部署): 替换为真实生产域名
export const API_BASE_URL: string = isDev()
? 'http://localhost:8085/api/v1'
? 'http://127.0.0.1:8085/api/v1'
: PROD_API_BASE_URL;
export const IS_DEV: boolean = isDev();
@@ -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; }
@@ -1,10 +1,16 @@
/**
* 红娘工作台主页
*
*
* ZC-MM-002(A1 / DEC-MATCHMAKER-002 锁定决策):
* - 普通红娘禁止认领(原 POST /services/{id}/claim 端点已删除),本页不再有任何接单逻辑;
* - 列表数据源从 GET /operator/matchmaker/pending(现仅分配权持有者可调,普通红娘 403)
* 改为 GET /operator/matchmaker/assignments/my(仅返回本人生效中的服务对象分配)。
* 响应为 assignment 字段(userId/assignmentReason/status/assignedAt/serviceId 等),
* 无服务类型/诉求描述字段,故按实际字段精简展示,不前端拼空值。
*
* 展示待处理服务、工作台数据概览
*/
const api = require('../../utils/api');
const { serviceTypeLabel } = require('../../constants/profileLabels');
Page({
data: {
@@ -13,9 +19,20 @@ Page({
inProgressCount: 0,
todayCount: 0,
completedCount: 0,
pendingServices: [],
myAssignments: [],
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() {
@@ -23,12 +40,12 @@ Page({
// 获取操作员信息
const operatorName = wx.getStorageSync('operatorName') || '红娘';
this.setData({ operatorName });
// 设置当前日期
const now = new Date();
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`;
this.setData({ currentDate: dateStr });
this.loadDashboard();
},
@@ -46,10 +63,10 @@ Page({
return;
}
// 加载工作台数据
const [dashRes, pendingRes] = await Promise.all([
// 加载工作台数据 + 本人服务对象分配(ZC-MM-002:不再调用 /pending,普通红娘无该权限)
const [dashRes, assignmentRes] = await Promise.all([
api.get('/operator/matchmaker/dashboard', {}, { Authorization: `Bearer ${token}` }),
api.get('/operator/matchmaker/pending', {}, { Authorization: `Bearer ${token}` }),
api.get('/operator/matchmaker/assignments/my', {}, { Authorization: `Bearer ${token}` }),
]);
if (dashRes.success) {
@@ -61,10 +78,20 @@ Page({
});
}
if (pendingRes.success) {
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({
pendingServices: pendingRes.data || [],
myAssignments: assignments,
assignmentCount: assignments.length,
contactTargets: targets,
contactTargetIndex: 0,
});
this.loadContactAuthorizations();
}
} catch (err) {
console.error('加载工作台数据失败:', err);
@@ -75,30 +102,121 @@ Page({
onViewService(e) {
const serviceId = e.currentTarget.dataset.id;
if (!serviceId) {
// 纯用户级分配(无关联服务单)无处可跳,不拼空参数
return;
}
wx.navigateTo({ url: `/pages/matchmaker-service-detail/matchmaker-service-detail?id=${serviceId}` });
},
onClaimService(e) {
const serviceId = e.currentTarget.dataset.id;
wx.showLoading({ title: '接单中...' });
const token = wx.getStorageSync('operatorToken');
// 注意:api.post 签名为 (url, data, headers, idempotencyKey),Authorization 必须放第 3 参(headers),
// 此前误放到第 4 参(idempotencyKey),导致实际请求不携带任何鉴权头、接单必 401。
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, { Authorization: `Bearer ${token}` })
.then(res => {
wx.hideLoading();
if (res.success) {
wx.showToast({ title: '接单成功', icon: 'success' });
this.loadDashboard();
} else {
wx.showToast({ title: res.message || '接单失败', icon: 'none' });
// ==================== 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' });
}
})
.catch(err => {
wx.hideLoading();
wx.showToast({ title: '网络错误', icon: 'none' });
});
},
});
},
// 分配状态 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
assignmentStatusLabel(code) {
const labels = {
active: '生效中',
ended: '已结束',
revoked: '已撤销',
transferred: '已转分配',
expired: '已过期',
};
return labels[code] || code || '';
},
// 分配范围 → 中文
assignmentScopeLabel(code) {
const labels = { primary: '主责', collaborating: '协同' };
return labels[code] || code || '';
},
formatTime(timeStr) {
@@ -115,11 +233,6 @@ Page({
return m + '月' + d + '日';
},
// 红娘服务类型 → 中文(避免枚举码直接上屏,见 profileLabels 铁律)
serviceTypeLabel(code) {
return serviceTypeLabel(code);
},
onLogout() {
wx.showModal({
title: '退出登录',
@@ -1,4 +1,4 @@
<!-- 红娘工作台 -->
<!-- 红娘工作台(ZC-MM-002:普通红娘仅见本人有效分配,无接单) -->
<view class="page">
<!-- 欢迎区 -->
<view class="header">
@@ -17,7 +17,7 @@
<view class="stat-card" hover-class="stat-hover">
<view class="stat-icon pending">📋</view>
<view class="stat-value">{{pendingCount}}</view>
<view class="stat-label">待处理</view>
<view class="stat-label">待分配</view>
</view>
<view class="stat-card" hover-class="stat-hover">
<view class="stat-icon progress">⏳</view>
@@ -36,39 +36,90 @@
</view>
</view>
<!-- 待处理列表 -->
<!-- 我的服务对象分配 -->
<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>
<text class="section-title">我的服务对象分配</text>
</view>
<text class="section-count" wx:if="{{pendingCount > 0}}">共{{pendingCount}}件</text>
<text class="section-count" wx:if="{{assignmentCount > 0}}">共{{assignmentCount}}人</text>
</view>
<view class="service-list" wx:if="{{pendingServices.length > 0}}">
<view
wx:for="{{pendingServices}}"
wx:key="serviceId"
<view class="service-list" wx:if="{{myAssignments.length > 0}}">
<view
wx:for="{{myAssignments}}"
wx:key="id"
class="service-card"
data-id="{{item.serviceId}}"
bindtap="onViewService"
>
<view class="service-left">
<view class="service-header">
<view class="service-tag">{{serviceTypeLabel(item.serviceType)}}</view>
<view class="service-time">{{formatTime(item.createdAt)}}</view>
<view class="service-tag">{{assignmentScopeLabel(item.assignmentScope)}}</view>
<view class="service-tag">{{assignmentStatusLabel(item.status)}}</view>
<view class="service-time">{{formatTime(item.assignedAt)}}</view>
</view>
<view class="service-desc">{{item.description}}</view>
<view class="service-desc">{{item.assignmentReason}}</view>
</view>
<button class="btn-claim" catchtap="onClaimService" data-id="{{item.serviceId}}" hover-class="btn-hover">接单</button>
</view>
</view>
<view class="empty-state" wx:else>
<view class="empty-illustration">📭</view>
<view class="empty-text">暂无待处理服务</view>
<view class="empty-hint">用户申请的服务将在这里展示</view>
<view class="empty-text">暂无服务对象分配</view>
<view class="empty-hint">分配给你的服务对象将在这里展示</view>
</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>
@@ -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; }
+14
View File
@@ -45,6 +45,14 @@ const backendTestRoot = resolve(root, 'backend', 'src', 'test', 'java')
// 若 @RequestBody 被再度收窄为 Map<String,String>,数组值反序列化失败即 400
// (现象:「小程序提交资料后,管理员点通过直接报错」)。
// ReviewControllerRequestBodyContractTest
// 第 15~18 项(2026-10-11,ZC-MM-006):
// MatchmakerAuditFieldsTest —— audit_logs 扩展审计字段(V055)写入/兼容/拒绝快照
// OperatorPermissionRevocationTest —— JWT 权限回收实时查库(禁用 401 / 撤权 403+审计)
// MatchmakerSensitiveAccessAuditTest —— 服务详情敏感访问日志(success 行 / 双记录互斥)
// MatchmakerWriteDenyUniformityTest —— 写接口 404 统一防探测(5 拒绝 ≡ 读详情 404)
// 第 19~20 项(ZC-MM-003 B1 立项):
// MatchmakerContactAuthorizationTest —— 受控联系授权生命周期/状态机/404 化拒绝/审计
// MatchmakerContactGuardTest —— 守卫翻转:联系回显收敛/端点前缀/到期语义/consent 独立性
// 新增/删减请同步修改本数组(下限随之变化)。
const CURATED = [
'AuthServiceRefreshTtlTest',
@@ -61,6 +69,12 @@ const CURATED = [
'PhotoUploadValidationTest',
'OperatorAuthServiceTest',
'ReviewControllerRequestBodyContractTest',
'MatchmakerAuditFieldsTest',
'OperatorPermissionRevocationTest',
'MatchmakerSensitiveAccessAuditTest',
'MatchmakerWriteDenyUniformityTest',
'MatchmakerContactAuthorizationTest',
'MatchmakerContactGuardTest',
]
const MIN_GUARDS = Number(process.env.BACKEND_GUARDS_MIN ?? CURATED.length)
+2 -2
View File
@@ -37,8 +37,8 @@ const requiredMenuEntries = [
['/activities', 'activity.view'],
['/activities/checkin', 'activity.checkin'],
['/activities/interests', 'activity.review_registration'],
['/referral', 'referral.manage'],
['/matchmaker', 'matchmaker.service.view'],
['/referral', 'matchmaker.service'],
['/matchmaker', 'matchmaker.view'],
['/counseling', 'counseling.service.view'],
['/banner', 'banner.view'],
['/content', 'content.view'],
+2 -1
View File
@@ -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',