26 Commits
Author SHA1 Message Date
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
WorkBuddy 4edb7619bc fix(admin-web,db): 红娘菜单权限码对齐后端真实码 + 红娘角色授权
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 / Migration Validation (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 / E2E (live stack) (push) Canceled after 0s
前端菜单/路由守卫用 referral.manage、matchmaker.service.view,但后端 permissions
表不存在这两个码(referral 控制器要 matchmaker.service、红娘控制器要
matchmaker.view/service/assign)。visibleNavigation 据此把「公益红娘」「红娘服务」
两个菜单对**所有角色(含 super_admin)隐藏**。

- admin-web: MainLayout.vue(菜单 + 角色名判定)、router/index.ts(路由 meta)、
  stores/auth.ts(DEV 预览角色)三处对齐为后端真实 authority 码。
- db: 新增 V052 迁移,将 matchmaker.view/service/assign 授予 matchmaker 角色
  (005),修复「角色定义!=可用」同族缺陷;幂等 ON CONFLICT DO NOTHING。
- 重建 danyang-admin-web 镜像并重新部署;运行时验证 wanghongniang 登录返回
  [matchmaker.assign, matchmaker.service, matchmaker.view],两菜单可见。
2026-10-09 19:33:40 +08:00
WorkBuddy dfb74f3386 fix(backend): 修复审核决策请求体契约不一致导致的 400
审核操作统一发送 {comment, reasonCodes[]}(数组),但 ReviewController 的
approve / need-evidence / approve-local-eligibility / reject-local-eligibility /
evidences/{id}/access 将 @RequestBody 声明为 Map<String,String>,数组值无法反序列
化成 String,Jackson 抛 HttpMessageNotReadableException,经 GlobalExceptionHandler
映射为 400「请求体格式错误」。

- 5 处 @RequestBody Map<String,String> 改为 Map<String,Object>,comment/purpose
  读取加 (String) 强转(与原本正确的 needEdit/reject 一致)。
- 新增回归守卫 ReviewControllerRequestBodyContractTest(standalone MockMvc +
  GlobalExceptionHandler + 自定义 @AuthenticationPrincipal 解析器),注入前端真实
  载荷 {comment, reasonCodes:[]} 断言 200;直接 new 控制器测不到 HTTP/绑定层缺陷。
- 接入后端契约门禁 scripts/run-backend-contracts.mjs(CURATED 13 -> 14)。
- 负向注入已自证判别力:临时改回 Map<String,String> 精确判红 400,恢复后 5/5 绿。
2026-10-09 19:33:16 +08:00
WorkBuddy 9fc3385d6c feat(ci): 新增后端契约门禁 scripts/run-backend-contracts.mjs 并接入 CI
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 / Migration Validation (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 / E2E (live stack) (push) Canceled after 0s
与 scripts/run-ui-contracts.mjs 同级:固化一组关键后端回归/契约测试
(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底 + 核心不变量),
先校验测试源文件存在性下限(防改名/删除后门禁假绿),再用 mvn test -Dtest
真跑并解析 surefire 汇总。在 backend job 的 mvn verify 之后执行,复用已
编译产物与依赖缓存,开销极小。同步更新 CODE_REVIEW_STANDARDS.md 门禁说明。
2026-09-30 16:21:25 +08:00
WorkBuddy c1c75edc55 fix(backend): 修复 v1.1.0 引入的 4 项后端测试失败
上一轮 v1.1.0 修复从未真正运行过后端测试,本次接入后端契约门禁前
全量 mvn verify 暴露 4 处缺陷:

1. PhotoService.confirmDirectUpload:countActiveProfilePhotos 返回 long,
   窄化赋值给 int sortOrder 导致整个后端编译失败(全量测试 0 跑)。
2. OperatorAuthController 直接引用 JwtTokenService 计算 Cookie max-age 并在
   logout 解析 JWT,违反 PrincipalInjectionContractTest 契约(控制器不得
   解析/引用 JWT 工具)。改为由 OperatorCookieWriter 持有 TTL、logout 从
   SecurityContext 的 OperatorPrincipal 取 operatorId。
3-4. AuthServiceRefreshTtlTest 与 JwtAuthFilterTest 的 UnnecessaryStubbing:
   移除未被调用的 getExpirationMs() / validateToken("bad-cookie") 桩。

全量 mvn verify:809 测试 0 失败 0 错误(3 跳过)。
2026-09-30 16:21:16 +08:00
WorkBuddy 47cccc9ca5 test: 补 v1.1.0 修复的回归测试
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 / Migration Validation (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 / E2E (live stack) (push) Canceled after 0s
- RealIdentityVerificationProvider: 伪造证件校验位拦截、配置缺失 fail-fast、第三方 fail-closed
- AuthService: 钉死刷新 Token Redis TTL 用 getRefreshExpirationMs(14 天)而非 access*2(4 小时)
- PhotoService: 直传落库 + 归属/存在性护栏(修复「传了白传」)
- JwtAuthFilter: 管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 + 头优先于 Cookie
- miniapp profileLabels: 枚举码→中文映射不退化(serviceTypeLabel / labelOf)
2026-09-30 14:05:48 +08:00
WorkBuddy 5c97c57c76 Merge branch 'main' of http://120.27.207.134:13000/admin/danyang-match
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 / Migration Validation (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 / E2E (live stack) (push) Canceled after 0s
# Conflicts:
#	README.md
2026-09-30 13:44:13 +08:00
WorkBuddy ca0db57f24 chore(release): 冻结 v1.1.0 版本
- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0
- 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md):
  - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast)
  - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto)
  - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天)
  - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入
  - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置
- 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖)

未验证项(打标签 ≠ 生产发布):
- 后端编译 / 单测未在本环境执行(Maven 慢、Docker DNS 受限)
- 管理端 登录→刷新→登出 端到端、小程序真机走查待人工验收
- 生产部署需注入 WECHAT_APP_ID / WECHAT_APP_SECRET、VERIFICATION_API_URL / VERIFICATION_API_KEY 与小程序生产域名
2026-09-30 12:10:00 +08:00
admin 8335ef780c Initial commit 2026-09-30 12:03:52 +08:00
WorkBuddy 1ad9793ea2 重构底部导航图标:照参考图重做形状与配色
- 5 个 Tab 图标形状改为参考图语言:房子(首页)/爱心(找对象)/对话气泡(消息)/日历加号(活动)/人物(我的)
- 配色改为参考图方案:默认态藏青蓝 #47528C、选中态玫红 #F4005A(与导航栏品牌色同系)
- 同步重生成 1x/2x/3x 共 30 张透明 RGBA PNG(Edge CDP 栅格化)
- app.json tabBar.color/selectedColor 对齐新配色
- 守卫 tabbar-icons.mjs 增加两态颜色与 app.json 一致性断言
- 静态预览复刻 tabBar 配色同步更新;DESIGN.md 同步
2026-09-23 16:30:32 +08:00
WorkBuddy 45e84722e1 fix(miniapp): 修复底部 tabBar 图标全部消失(开发者工具误删 app.json 专属图标)
根因:微信开发者工具(>=1.05.2112301)的 ignoreDevUnusedFiles 会按 WXML/JS 依赖分析过滤
"无依赖"文件。tabBar 图标仅在 app.json 中声明,不属于任何页面 WXML/JS 依赖,被误判为无用
文件从开发包剥离,导致 5 个图标全部加载失败(渲染为破图占位)。

修复:
- 在 project.config.json 与 miniapp/project.config.json 的 setting 中显式关闭
  ignoreDevUnusedFiles / ignoreUploadUnusedFiles(false),阻止图标被过滤。
- 同步关闭本地 gitignore 的 project.private.config.json(DevTools 运行时实际读取项)。
- 预览通道 scripts/preview-miniapp.mjs:补全 /assets 绝对路径→相对路径重写,并手动复刻
  原生 tabBar(原生 tabBar 不在任何页面 WXML 中,静态预览需自行复刻)。
- 回归守卫 ui-tests/tabbar-icons.mjs 扩充为 5 段,覆盖根因(配置开关)+ 预览复刻。
- miniapp/assets/tabbar/DESIGN.md 增加根因警示,防止后续被误开开关。

验证:UI 契约 23/23 全绿(tabbar-icons.mjs 位于 [19/23]);CDP 渲染实测 127 张图 0 破图,
5 个 tabBar 图标 naturalW=81 全部加载,选中色 rgb(233,107,107)、未选 rgb(153,153,153),
与 app.json selectedColor/color 一致。
2026-09-23 15:57:04 +08:00
WorkBuddy f4e00b4d95 feat(miniapp): 首页 4×2 入口宫格图标改版为参考图 2 的渐变砖 + 白色图形
参考图 2 的视觉语言是「高饱和渐变圆角砖 + 白色描画图形 + 主标题/副标题双层文字」,
原实现是「浅色底砖 + 彩色线性 PNG + 单行标题」,两者差异较大,本轮按参考图重做。

- app.wxss:新增 8 个 --gradient-entry-* 渐变 token 与 --mini-entry-shadow。
  白图形的判据取**非文本图形 3:1**(WCAG 1.4.11);渐变是斜向的、图形又跨在两色之间,
  故以「较亮端」为准逐色实算(3.00~3.02:1,较暗端 3.54~4.08:1),实算值写在 token 注释里。
- index.wxss:.entry-icon 由 72rpx 浅色砖改为 96rpx 渐变砖(radius 28rpx + 落影);
  图形改用 data-uri SVG 背景层(几何图形统一 24×24 viewBox),砖面走 --gradient-entry-*;
  新增 .entry-sub(12px muted 副标题);删除已无用的 .entry-icon-image。
  小屏 @media(≤340px) 同步收紧,副标题不降到 micro —— micro 在本项目是「仅角标/装饰」档位,
  这行字承载语义,不属于装饰。
- index.wxml:宫格项去掉 <image>,图标改为纯 CSS 砖;补 aria-label 承接图形语义。
- index.ts:tone 名与色相语义对齐(rose/orange/violet/blue/purple/pink/green/amber),
  并补 subtitle;entry 不再持有 iconAsset(图标不再是图片资源)。
- preview-miniapp.mjs:首页 mock 同步 tone/subtitle,预览才能看到真实砖面与副标题。

渲染态实测(无头浏览器 + CDP,非真机渲染):
- 8 个 .entry-item 均渲染出 label + subtitle;.entry-icon 计算样式 48×48px / radius 14px /
  background-image 含 svg 且为两层(图形 26px + 渐变铺满);label 14px/600;sub 12px #7D839B。
- 证据截图:artifacts/ui-redesign/miniapp/home-entries-page.png 与 home-entries-grid-zoom.png。

门禁:UI 契约 23/23;语种审计 0 命中;tsc --noEmit 通过;wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。

未改(需产品确认):第 8 项业务名仍是「善缘服务站」(参考图 2 写作「万善缘地」),
副标题自拟为「就在身边」;渐变整体比参考图略深,以保证白图形对比度达标。
2026-09-23 15:22:23 +08:00
WorkBuddy e740cbf403 docs(phase26): 上线清单 ⛔ 结论更正为仅第 2/5 项阻塞(第 4/6 项已闭环)+ 覆盖率数字更新
commit 44f75ac 已闭环推荐恒空(第 6 项)与资料/择偶条件保存失败(第 4 项),
但清单头部 ⛔ 结论仍写着「第 2/4/5/6 项为硬阻塞」,与当前代码事实不符
——会直接把已修项误判为阻塞,或反之让人以为清单已过时而不读。
本次更正为「第 2、5 项仍为硬阻塞」,并把已闭环项与仍阻塞项分列,避免再次过时。

同时把自动化覆盖率数字更新为当前真实值:
后端 757 → 795、UI 契约 22/22 → 23/23(新增 ui-tests/miniapp-profile-contracts.mjs)。
2026-09-23 14:46:03 +08:00
WorkBuddy 44f75acebe fix(uat): 闭环 UAT 4 项硬阻塞(推荐恒空/保存即失败/越权端点/文档不一致)+ 小程序字段契约
UAT 走查(docs/UAT-WALKTHROUGH-2026-09-23.md)发现 4 项硬阻塞,本次闭环其中 3 项
(#2 微信登录、#5 照片直传不在本轮范围),并顺带修掉走查中新发现的跨端字段契约缺陷。

后端
- #6 推荐恒空:新增 MatchScoringService 作为唯一评分事实源(复用 UserPreference 既有
  10 维权重,零新造常量);RecommendationEngineService.calculateScore/generateReasons 由
  桩(return 50.0)改为委托;MatchEligibilityService 3 处桩全部去雷。此前 50 < 阈值 60
  → GET /recommendations 恒返回空数组。
- #4 保存即失败:updateProfile / updateBasicProfile / savePreference 改为
  「无则插入、有则更新」。注意 UserPreference 实体 @TableId=id 与库主键 user_id 不一致
  (V037 后补的可空列),故按 user_id 定位更新而非 updateById(后者会静默不更新)。
  gender / birth_date 为 NOT NULL 且向导不采集,缺失时从已通过实名认证的 user_identities
  补齐,仍缺则抛 400 业务拒绝,不再让 DataIntegrityViolationException 渲染成 500。
  GET /me/profile 的 preferences 补回 5 个此前未返回字段(regionPriority / maritalPriority /
  minEducationCode / educationPriority / childrenPriority)。
- #8 越权端点:POST /activities/{id}/interests/confirm 不再直调「建 Match 原语」
  RelationshipService.confirmActivityInterest,改为与 /activities/{id}/mutual/* 同一条
  二阶段同意链路(createMutualConsent + confirmConnection),单方调用不再可能建 Match
  (INV-014 / INV-015)。
- #10 文档不一致:MatchmakerController 类注释更正(operator 端点实际在 /operator/matchmaker)。
- 存量 500 家族:RuntimeExceptionThrowGuardTest 的正则只认 throw new RuntimeException(,
  而 17 处裸抛写作 orElseThrow(() -> new RuntimeException(...)),整体绕过守卫(CI 全绿却
  仍有 500 家族缺陷)→ 17 处全量改为 GlobalExceptionHandler.BusinessException;
  守卫扩为「整段代码 + 该形态」,并加自检断言防止守卫退化成恒真空断言。

小程序(跨端字段名 / 取值契约)
- 字段名对齐后端规范名:hasChildren→childrenStatus、occupationCategory→occupationCode、
  workRegion→workRegionCode、ageMin/ageImportance→minAge/agePriority、
  educationAccepted/maritalStatusAccepted→minEducationCode/maritalStatuses。
  此前字段名对不上,后端读不到 → 字段被静默丢弃。
- 取值修 4 类违反 DB CHECK(写入直接 500)的项:has→yes;high_school→senior_high、
  phd→doctor(chk_profile_edu);occasional→sometimes(chk_profile_smoking/drinking)。
- 预填改读服务端真实字段:此前读 profile.hasChildren / occupationCategory / workRegion,
  而服务端返回的是 childrenStatus / occupationCode / workRegionCode → 表单永远回填不上。
- profile-confirm 完成度改为直接采用服务端 profileCompletion(AGENTS 第 9 条:DB 是
  Source of Truth),废弃原先 22 条「读服务端不存在字段」的本地猜测式断言。
- 择偶条件页新增「展示形态 ↔ 服务端规范形态」适配器(toApiPayload / fromApiPayload)。

测试与门禁
- 新增 MatchScoringServiceTest、RecommendationEngineServiceBehaviorTest、
  ProfileSaveUpsertBehaviorTest、ActivityInterestConfirmConsentBehaviorTest 与
  ui-tests/miniapp-profile-contracts.mjs(桩 stores,真跑三个页面的事件处理与 onNext,
  断言提交负载用规范字段名与枚举码)。
- MIN_GUARDS 21→23(并修正上一轮漏改:该值当时仍为 21,与守卫数不符,使「数量下限」
  保护形同虚设)。
- 负向注入实测:把 #6 评分改回桩 50.0、#4 改回恒 insert、#8 改回绕开二阶段、并注入
  orElseThrow 形态裸异常 → 4 个守卫全部变红(含精确定位到 RecommendationEngineService.java:282)。
- 后端:795 tests / 0 failures / 3 skipped;UI 守卫 23/23;语种审计 0 命中;tsc 通过;
  wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。

仍未修(需产品/数据决策,见报告 8.3)
- regionScope 需 GB/T 2260 单值区划码,而小程序选项含「镇江其他辖区」这类非单值项 →
  不臆造区划码(该约束默认 preferred 软约束,不误过滤);
- occupationPreference 后端无对应列;settlementPreference 后端无取值字典。
2026-09-23 14:33:43 +08:00
WorkBuddy 0a100548a9 docs(phase26): 上线前准备 — 13 项手动验证清单 + 冒烟脚本骨架 + STATUS 文档矛盾清理
- 新增 docs/PHASE26-GO-LIVE-CHECKLIST.md:上线前 13 项人工验证清单
  (部署栈存活 / 微信登录 / 运营登录越权隔离 / 资料择偶条件 / 照片 SeaweedFS
  直传 / 推荐匹配 / 聊天 / 活动 / 咨询 / 红娘转介 / 内容 / 管理端口径 / 合规备份),
  每项含验证方式、通过标准、负责方、关联决策与配置旋钮;自动化子集见脚本。
- 加固 infrastructure/scripts/smoke-test.sh:健康检查 + 匿名鉴权守卫(401) +
  安全接口;带 OPERATOR_TOKEN/USER_TOKEN 时展开需登录链路,未提供明确打印
  「需人工验证」而非假绿(沿用项目「断言≠证据/不假绿」纪律)。修掉 curl -f
  在 4xx 退出码会把 000 拼到状态码后的 bug。
- STATUS.md:修正「V1.1 待实现模块」表把本地资格声明与审核误标 ⏳ 待开发
  的矛盾(实际 Phase 04/05 已于 2026-09-12 复验关闭,LocalEligibilityService
  已实现),与全文一致。
2026-09-23 11:57:07 +08:00
WorkBuddy c0298c27a5 fix(miniapp): 小程序界面全量中文化,消除英文/非中文枚举码上屏
- 新增 miniapp/constants/profileLabels.js 作为「枚举码→中文简体」唯一字典
  (婚姻/子女/学历/职业/吸烟饮酒/择偶子女/红娘状态),覆盖表单值与种子
  实际值两套并集,保证详情页无论后端返回哪种码都显示中文
- 修复「择偶条件-子女」下拉英文选项(any/accept/reject→不限/可接受/不接受),
  改用 value/label 数组 + range-key="label"
- 修复 recommend-card / user-card / profile-detail / profile-confirm 直接渲染
  后端枚举码(educationCode/maritalStatus/occupationCode…)的问题,改由
  observers 派生中文 label 上屏
- 清理残留英文文案:Required 条件→必须符合的条件、喜欢TA/向Ta→喜欢对方/向对方、
  U51 服务预约→服务预约、U64 公益服务记录→公益服务记录
- 修复 7 个英文窗口标题与 3 个英文占位页正文
- matchmaker 历史服务状态消除原始码兜底,统一走字典
- 加固 scripts/audit-miniapp-language.mjs(A 整行先剥离 mustache、B 排除
  bind:action 处理器、F 窗口标题、JPG/PNG 入白名单)
- 新增 ui-tests/miniapp-language-contracts.mjs 语种守卫(复用审计脚本,断言 0 命中),
  UI 契约门禁 MIN_GUARDS 21→22;已做负向注入验证判别力
2026-09-23 11:19:17 +08:00
WorkBuddy 1fc34f1bcd fix(miniapp): 修复导航页与微信胶囊重合,并把首页两个栏目卡缩到 1/3
问题 1:五个导航页顶部与右上角胶囊按钮(··· ⊙)重合
- 根因:自定义导航用 env(safe-area-inset-top) 取的是设备物理安全区,
  在 Android 上常为 0,完全不反映胶囊位置;且右对齐元素从未为胶囊让出宽度。
- 新增 miniapp/utils/nav-metrics.js 作为唯一事实源:以
  wx.getMenuButtonBoundingClientRect() 实测胶囊矩形,反推并导出四个 CSS 变量
  --nav-status / --nav-h / --nav-right / --nav-top,并提供无接口时的兜底值。
- nav-bar 组件改为自行获取导航度量(attached + observer 双触发),
  标题按胶囊垂直居中并收窄 max-width;外部传参降级为兜底。
- index / discover / messages / activity / mine 五页根结点注入同一组变量,
  WXSS 里的 env(safe-area-inset-top)、硬编码 88rpx 与右对齐 right:0 全量替换。

问题 2:首页「善缘公益服务」「善缘安全提醒」两个栏目过宽
- 根因:封面 <image> 只声明 width 未声明 height,小程序默认按 240px 计算,
  实际把卡片撑到约 480rpx 高。
- 按「整体等比缩到 1/3」重构:两栏改为 mini-card 小卡,宽度
  calc((100% - 2*gap)/3)、高度写死 168rpx,封面 height:100%;
  公益服务一行三卡横向排布,安全提醒为「标题小卡 + 右侧文案条」,
  长文案收进详情页,安全提示原文保留。

守卫与验证
- 新增 ui-tests/miniapp-capsule-safe-contracts.mjs:
  A 层多机型断言导航行与胶囊垂直居中、右对齐让位、navTop 与兜底值;
  B 层纯函数审计五个自带导航行页面(页面清单由文件系统推导,失配直接报红);
  C 层断言首页小卡宽为 1/3 且显式声明高度,并钉住旧 service-/safety- 选择器回归。
  审计前剥离注释,避免守卫被自身示例文本误伤。
- 新增 scripts/audit-image-height.mjs:按属性名精确比对,审计全仓
  「有 width 无 height」的 <image>,当前 30 个类 0 命中。
- 负向注入 N1~N7(含 nav-safe 未接入、旧选择器回归、极小屏幕、无胶囊兜底等)
  全部被守卫咬住并 exit 1,还原后复跑通过。
- 真编译(wcc/wcsc):WXSS 88 / WXML 86,失败 0;
  tsc --noEmit 通过;UI 契约门控 21/21 PASS(新增第 21 个守卫并抬高 MIN_GUARDS)。
- 渲染探针实测:单卡 213rpx x 168rpx;五页右对齐元素右缘 423~528px,
  均在胶囊右缘 365px 之外。

未纳入本次提交:miniapp/project.config.json(微信开发者工具自动改写,
仅追加若干布尔开关并删掉文件末换行)与生成的预览页 artifacts preview.html
(约 7900 行生成产物),以免淹没代码评审视线。
2026-09-23 09:55:40 +08:00
WorkBuddy 2c325136ab fix(miniapp): 修复 WXSS 通配符导致编译失败,并把 WXSS 校验升级为真编译
问题
- v2 设计收敛在 app.wxss 新增 `.tap-gap > * + *`。WXSS 不支持通配符 `*`,
  wcc/wcsc 编译期直接报 `error at token '*'`,整个小程序编译不过。
- 更值得警惕的是当时 19 个 UI 契约守卫全绿:静态守卫刻意只扫
  `miniapp/components/`(page 的 wxss 允许标签选择器,套用会误报),
  app.wxss 落在扫描范围外,且判据里根本没有通配符这一项。

修复
- 移除 `.tap-gap > * + *`。复检 v2 新增的 .tap-min / .tap-gap / .focusable / .pressable
  四个工具类全仓零引用(等价能力已各自写在组件样式内,如 button/index.wxss 直接用
  `min-height: var(--tap-min)`),且 `.focusable:focus` / `.pressable:active` 在 view 上
  不生效(小程序须用 hover-class),属"写了等于没写",故整块移除,只保留 token。
- 修掉同块注释里的嵌套 `/* */`:内层结束标记会提前终止外层注释,其后的文字会被
  当成真实样式解析。该约束已写进注释,防止后人再踩。

守卫加固
- scripts/lib/wxss-selectors.mjs
  * 新增 findUniversalSelectors(通配符判据,排除 `*=` 属性匹配符与注释内星号)
  * 新增 findNestedComments(注释嵌套判据)
  * 修 stripComments:原实现用等长空格替换,把注释内的换行也吃掉,导致报错行号
    整体偏移(实测偏差 68 行);现保留换行,并在定位时跳过选择器前的注释占位空白
- ui-tests/wxss-component-selector-contracts.mjs
  * 第 1 级:miniapp/components/(原判据,保留)
  * 第 2 级(新增):全量 miniapp/**/*.wxss,覆盖 app.wxss 与 pages
  * 第 3 级(新增):注释嵌套
  * 三级各自带正/反自检样例

新增
- scripts/miniapp-compile-check.mjs:直接调用微信开发者工具内置的 wcc/wcsc 做真编译预检
  (wcsc 单独调用不解析 @import 相对路径,脚本会先内联再编译)。
  CI runner 无该工具时输出 SKIPPED 并退出 0;刻意不挂进 run-ui-contracts —— 一个在 CI 里
  什么都不做的守卫被计入"全绿",正是本项目反复强调的假绿。CI 拦截由上面的静态守卫承担。

验证(均实际执行)
- node scripts/miniapp-compile-check.mjs → WXSS 88 个 + WXML 86 个,0 失败
- 负向注入:把 `.tap-gap > * + *` 改回去 → 预检 FAIL(exit 1),报错与用户原始报错逐字一致
  `app.wxss(364:12): error at token '*'`;已还原并复测通过
- node scripts/run-ui-contracts.mjs → 20 / 20 PASS
2026-09-23 08:25:11 +08:00
235 changed files with 16820 additions and 3680 deletions
+37
View File
@@ -0,0 +1,37 @@
# CODEOWNERS — 善缘丹阳公益婚恋平台 审查角色分配(草案)
#
# 作用:开 PR 时 GitHub 自动向对应路径的 owner 请求审查;配合分支保护与
# docs/governance/CODE_REVIEW_STANDARDS.md 的 T0/T1/T2 分级与双审规则使用。
# 语法:https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
#
# 当前为一人团队,默认全部归技术负责人 @g1307092382。
# 待扩员时,把各端拆给对应熟悉者即可;T2 高危改动按审查标准须 2 人复核(其中 1 人为该端 owner)。
# 说明:CODEOWNERS 只负责「请求谁审」,不强制「必须不同人」;双审由审查流程纪律保证。
# 排序:子路径必须放在父路径之后(GitHub 取最后一个匹配的规则)。
# —— 默认兜底 ——
* @g1307092382
# —— 后端(Spring Boot / Java)——
/backend/ @g1307092382
# 数据库迁移(Flyway):schema 变更属 T2,须重点复核(放在 /backend/ 之后)
/backend/src/main/resources/db/migration/ @g1307092382
# —— 微信小程序(原生 WXML/WXSS/TS)——
/miniapp/ @g1307092382
# —— 管理端(Vue3 / TS / pnpm)——
/admin-web/ @g1307092382
# —— 测试与契约守卫(跨端契约,需熟悉各端)——
/tests/ @g1307092382
/ui-tests/ @g1307092382
/scripts/run-ui-contracts.mjs @g1307092382
# —— 基础设施 / 部署(Compose / 镜像 / 密钥)——
/infrastructure/ @g1307092382
# —— 治理与审查机制文档(变更须经技术负责人确认)——
/docs/governance/ @g1307092382
/.github/ @g1307092382
+69
View File
@@ -0,0 +1,69 @@
<!--
提 PR 前请先读 docs/governance/CODE_REVIEW_STANDARDS.md。
本模板是合并准入的硬性门槛:逐项打勾 = 已实际验证(不是"应该没问题")。
证据优先:描述里的"✅已完成"不是证据,请贴命令+输出/代码 diff/实测结论。
-->
## 变更摘要
<!-- 改了什么、为什么、影响面 -->
## 风险分级(见审查标准 §2)
- [ ] T0 琐碎(纯文案/文档/格式化/资源重命名)
- [ ] T1 常规(单端功能/Bug/UI/单 API)
- [ ] T2 高危(鉴权/支付/隐私/跨端契约/DB schema/并发幂等/已知阻塞项)— 需 2 人复核
## 验证方式(必填,证据优先)
<!-- 贴实际跑过的命令与关键输出;不要只写"已自测通过" -->
- 本地测试/契约守卫命令与结果:
- 运行时实测(如涉及渲染/接口,贴 mock/路由/关键响应):
- 负向注入(新增或修改测试时必填:故意改坏后测试是否转红):
## 技术栈审查清单(按本 PR 涉及项打勾)
### 后端 Java / Spring Boot
- [ ] 业务拒绝用 `BusinessException`+`ApiResponseCode`(HTTP 400≤code<500),无裸 `RuntimeException`
- [ ] 测试对业务拒绝断言 `400 ≤ status < 500`(非仅 `!= 200`)
- [ ] 核心不变量(INV-014/015、Required/Mutual/Block 等)未被绕过
- [ ] 主键/唯一键/CHECK 约束与 Java 枚举一致,无静默 no-op 写
- [ ] 无新增死表/死配置(`system_configs`/`match_rule_configs` 类)
### 小程序 miniapp
- [ ] `app.json`/`pages.json` 路径与磁盘文件一致、无前导 `/`
- [ ] `ignoreDevUnusedFiles`/`ignoreUploadUnusedFiles` = false(资源不被吞)
- [ ] 跨端字段名与后端规范名一致(tsc 不保护 JS 层字段名)
- [ ] WXSS 无通配符/属性选择器/嵌套注释;`<image>` 显式高度
- [ ] 胶囊安全区让出 `--nav-right`
### 管理端 admin-web
- [ ] 渲染相关状态走 `ref`/`reactive`,已补渲染态实测
- [ ] 颜色/字号收敛到 `src/theme/` token,EP 主色阶补全 `-light-N`/`-dark-2`/`-rgb`
- [ ] 表单校验类型安全、出错保留输入
- [ ] 触摸目标 ≥44px,尊重 `prefers-reduced-motion`
### 数据库迁移 SQL
- [ ] `V<序号>__<描述>.sql` 命名,版本连续唯一
- [ ] 向后兼容,NOT NULL 新列有默认值/迁移
- [ ] CHECK 约束与枚举一致,破坏性变更有回滚方案
### 测试 / 契约守卫
- [ ] 无空壳 `skip`/`pass` 测试
- [ ] 已做负向注入证明判别力
- [ ] `scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 23) 同步(增删守卫必改)
### 脚本 / 基础设施
- [ ] 脚本真实依赖已在 CI 对应 job 安装(非仅本地能跑)
- [ ] 密钥仅运行期、不落盘;本地栈固定 `-p danyang`
## 判据纪律自查(见审查标准 §5,对应改动必查)
- [ ] 非空数据验证(无空数据假绿,如推荐/列表返回空也能过)
- [ ] 无直连绕过(关键路径至少一个经 HTTP 层断言)
- [ ] 约束相关改动在带约束环境验证(无测试库缺约束假绿)
- [ ] 小程序改动不走 `Mini Program Compile Check` 的"仅 tsc"假绿,已真编译/查资源
- [ ] 合并后回读 `git rev-parse HEAD` + 轮询 CI 确认本次 head 真 success
## 已知阻塞 / 决策关联
- [ ] 本 PR 是否触碰上线阻塞项(微信真实登录 / 照片直传落库)?若是:✅本次解决 / ⚠️未解决已登记 known-risks
- [ ] 是否与 `decision-register` 的 LOCKED 决策冲突?若是:已回决策层确认(附结论)
- [ ] 是否产生新债务?若是:已登记 `known-risks.md`(编号 KR-xx)
## 关联
<!-- 关联 Issue / 决策 / 风险编号 -->
+7
View File
@@ -25,6 +25,13 @@ jobs:
working-directory: backend working-directory: backend
run: mvn verify -B run: mvn verify -B
# 后端契约门禁(与 scripts/run-ui-contracts.mjs 同级):
# 固化关键后端回归/契约测试(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底
# + 核心不变量),先校验这些测试源文件存在性下限(防改名/删除后门禁假绿),
# 再用 `mvn test -Dtest=<列表>` 真跑。复用本 job 已编译产物与依赖缓存,开销极小。
- name: 后端契约门禁(curated + 下限校验)
run: node scripts/run-backend-contracts.mjs
- name: Upload Test Results - name: Upload Test Results
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+1 -1
View File
@@ -1 +1 @@
1.1.0-RC.1 1.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "danyang-match-admin", "name": "danyang-match-admin",
"version": "1.0.0-rc.2", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+3
View File
@@ -19,6 +19,9 @@
# 后者生成 Vue 版本桥接),允许它们执行。 # 后者生成 Vue 版本桥接),允许它们执行。
packages: packages:
- '.' - '.'
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
- vue-demi - vue-demi
+43 -5
View File
@@ -1,14 +1,18 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '@/router' import router from '@/router'
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
const client = axios.create({ const client = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
timeout: 10000, timeout: 10000,
}) })
// 防止 401 时并发触发多次刷新
let refreshing = false
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use( client.interceptors.response.use(
(res) => res.data, (res) => res.data,
(err) => { async (err) => {
if (err.response?.status === 401) { const status = err.response?.status
localStorage.removeItem('accessToken') const original = err.config
router.push('/login') const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
refreshing = true
try {
const resp: any = await client.post('/auth/operator/refresh', {})
if (resp?.success) {
const newToken = resp.data.accessToken
setAccessToken(newToken)
refreshing = false
;(original as any).__retried = true
const headers = (original.headers || {}) as Record<string, string>
headers.Authorization = `Bearer ${newToken}`
original.headers = headers as any
return client(original)
}
} catch {
// 刷新失败:清除并跳登录
} finally {
refreshing = false
}
clearAccessToken()
ElMessage.error('登录已过期,请重新登录') ElMessage.error('登录已过期,请重新登录')
router.push('/login')
return Promise.reject(err)
}
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
if (status === 401) {
if (!isAuthEndpoint) {
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
}
} else if (err.response?.data?.message) { } else if (err.response?.data?.message) {
ElMessage.error(err.response.data.message) ElMessage.error(err.response.data.message)
} else { } else {
+3 -3
View File
@@ -174,8 +174,8 @@ const navigation: NavigationItem[] = [
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User }, { label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked }, { label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled }, { label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection }, { label: '公益红娘', path: '/referral', permissions: ['matchmaker.service'], icon: Connection },
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection }, { label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.view'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound }, { label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar }, { label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar }, { label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
@@ -217,7 +217,7 @@ const currentRoleName = computed(() => {
if (perms.includes('counseling.service.view')) return '公益咨询人员' if (perms.includes('counseling.service.view')) return '公益咨询人员'
if (perms.includes('service_appointment.view')) return '服务站运营人员' if (perms.includes('service_appointment.view')) return '服务站运营人员'
if (perms.includes('review.view')) return '资料审核人员' if (perms.includes('review.view')) return '资料审核人员'
if (perms.includes('matchmaker.service.view')) return '公益红娘' if (perms.includes('matchmaker.view')) return '公益红娘'
return '运营人员' return '运营人员'
}) })
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm))) const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
@@ -80,6 +80,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/** /**
* U32 活动签到工作台(Phase 15) * U32 活动签到工作台(Phase 15)
* *
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
} }
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类' const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` }) const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
const formatTime = (v: any) => { const formatTime = (v: any) => {
if (!v) return '-' if (!v) return '-'
@@ -42,6 +42,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get } from '@/api/client' import { get } from '@/api/client'
@@ -55,7 +56,7 @@ const mutualList = ref<any[]>([])
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
const rows = res.data || [] const rows = res.data || []
tableData.value = searchActivityId.value tableData.value = searchActivityId.value
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
drawerVisible.value = true drawerVisible.value = true
currentActivity.value = tableData.value.find(row => row.activityId === activityId) currentActivity.value = tableData.value.find(row => row.activityId === activityId)
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` }) const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
mutualList.value = mutualRes.data || [] mutualList.value = mutualRes.data || []
} catch (e) { } catch (e) {
@@ -168,6 +168,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/** /**
* 活动管理(V1.1 Phase 14) * 活动管理(V1.1 Phase 14)
* *
@@ -259,7 +260,7 @@ function emptyForm() {
} }
function authHeader() { function authHeader() {
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` } return { Authorization: `Bearer ${getAccessToken()}` }
} }
async function loadData() { async function loadData() {
@@ -213,6 +213,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client' import { get } from '@/api/client'
import { token } from '@/theme/tokens' import { token } from '@/theme/tokens'
@@ -302,7 +303,7 @@ async function loadFunnel() {
try { try {
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手** // 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。 // `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = localStorage.getItem('accessToken') const authToken = getAccessToken()
if (dateRange.value[0] && dateRange.value[1]) { if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([ const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', { get('/operator/analytics/user-funnel', {
@@ -328,7 +329,7 @@ async function loadFunnel() {
async function loadDailySummary() { async function loadDailySummary() {
try { try {
const authToken = localStorage.getItem('accessToken') const authToken = getAccessToken()
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` }) const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
dailySummary.value = res.data || [] dailySummary.value = res.data || []
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -84,6 +84,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens' import { token as tokenColor } from '@/theme/tokens'
@@ -109,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || [] tableData.value = res.data || []
loadStats() loadStats()
@@ -119,7 +120,7 @@ async function loadData() {
async function loadStats() { async function loadStats() {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {} const data = res.data || {}
stats.value = [ stats.value = [
@@ -138,7 +139,7 @@ async function openDetail(id: string) {
decision.value = 'approve' decision.value = 'approve'
decisionNote.value = '' decisionNote.value = ''
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` }) const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data detail.value = res.data
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -148,7 +149,7 @@ async function handleProcess() {
if (!decision.value) return if (!decision.value) return
processing.value = true processing.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await post(`/operator/appeals/${currentId.value}/process`, await post(`/operator/appeals/${currentId.value}/process`,
{ decision: decision.value, decisionNote: decisionNote.value }, { decision: decision.value, decisionNote: decisionNote.value },
{ Authorization: `Bearer ${token}` }) { Authorization: `Bearer ${token}` })
+2 -1
View File
@@ -38,6 +38,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get } from '@/api/client' import { get } from '@/api/client'
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/audit/logs', { const res: any = await get('/operator/audit/logs', {
resourceType: resourceFilter.value || undefined, resourceType: resourceFilter.value || undefined,
page: page.value, page: page.value,
@@ -3,7 +3,8 @@
<el-card> <el-card>
<template #header><div class="card-header"><span>公益红娘管理</span></div></template> <template #header><div class="card-header"><span>公益红娘管理</span></div></template>
<el-tabs v-model="activeTab"> <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 :data="services" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" /> <el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="160"> <el-table-column prop="serviceType" label="服务类型" width="160">
@@ -19,7 +20,7 @@
<el-table-column label="操作" width="200"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <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==='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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -38,16 +39,47 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-tab-pane> </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>
</el-tabs> </el-tabs>
</el-card> </el-card>
<!-- 分配弹窗 --> <!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px"> <el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
<el-form label-width="80px"> <el-form label-width="90px">
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘"> <el-form-item label="红娘">
<el-option label="王红娘" value="operator_wang" /> <el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
<el-option label="陈红娘" value="operator_chen" /> <el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select></el-form-item> </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> </el-form>
<template #footer> <template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button> <el-button @click="assignDialogVisible=false">取消</el-button>
@@ -55,6 +87,37 @@
</template> </template>
</el-dialog> </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-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
<el-form label-width="100px"> <el-form label-width="100px">
@@ -71,18 +134,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus' import { ElMessage } 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 loading = ref(false)
const services = ref<any[]>([]) const services = ref<any[]>([])
const recommendations = ref<any[]>([]) const recommendations = ref<any[]>([])
const assignments = ref<any[]>([])
const assignmentsLoading = ref(false)
const assignableMatchmakers = ref<any[]>([])
const assignDialogVisible = ref(false) const assignDialogVisible = ref(false)
const recDialogVisible = ref(false) const transferDialogVisible = ref(false)
const terminateDialogVisible = ref(false)
const currentServiceId = ref('') 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 recForm = ref({ userId: '', targetUserId: '', reason: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = { const SERVICE_TYPE_LABEL: Record<string, string> = {
@@ -90,10 +170,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
referral_activity: '转介活动', referral_station: '转介服务站' referral_activity: '转介活动', referral_station: '转介服务站'
} }
const STATUS_LABEL: Record<string, string> = { 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> = { 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> = { const REC_STATUS_LABEL: Record<string, string> = {
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢', pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
@@ -103,18 +185,27 @@ const REC_STATUS_TAG: Record<string, string> = {
pending_view: 'warning', viewed: 'info', liked: 'primary', pending_view: 'warning', viewed: 'info', liked: 'primary',
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info' 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'
}
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t } function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
function statusLabel(s: string) { return STATUS_LABEL[s] || s } function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' } function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s } function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' } 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 formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function loadServices() { async function loadServices() {
loading.value = true loading.value = true
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。 // 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 } try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
} }
async function loadRecommendations() { async function loadRecommendations() {
@@ -123,19 +214,80 @@ async function loadRecommendations() {
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。 // 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false } 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() { 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 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() { 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 { 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() ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') } } 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) { function openRecommendation(row: any) {
currentServiceId.value = row.id currentServiceId.value = row.id
recForm.value = { userId: row.userId, targetUserId: '', reason: '' } recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
@@ -62,6 +62,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client' import { get, post, put } from '@/api/client'
@@ -79,7 +80,7 @@ const orgMap = ref<Record<string, string>>({})
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
tableData.value = res.data?.list || [] tableData.value = res.data?.list || []
total.value = res.data?.total || 0 total.value = res.data?.total || 0
@@ -89,7 +90,7 @@ async function loadData() {
async function loadOrgs() { async function loadOrgs() {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
orgList.value = res.data || [] orgList.value = res.data || []
orgList.value.forEach(o => { orgMap.value[o.id] = o.name }) orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
@@ -105,7 +106,7 @@ async function save() {
if (!form.value.username || !form.value.password) return if (!form.value.username || !form.value.password) return
saving.value = true saving.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` }) await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
formVisible.value = false formVisible.value = false
loadData() loadData()
@@ -115,7 +116,7 @@ async function save() {
async function toggleStatus(row: any) { async function toggleStatus(row: any) {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable' const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` }) await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
loadData() loadData()
@@ -126,7 +127,7 @@ async function resetPwd(id: string) {
const pwd = prompt('请输入新密码(至少8位)') const pwd = prompt('请输入新密码(至少8位)')
if (!pwd || pwd.length < 8) return if (!pwd || pwd.length < 8) return
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` }) await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
@@ -60,6 +60,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client' import { get, post, put } from '@/api/client'
@@ -78,7 +79,7 @@ const ORG_TYPE_TEXT: Record<string, string> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || [] tableData.value = res.data || []
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -101,7 +102,7 @@ async function save() {
if (!form.value.name) return if (!form.value.name) return
saving.value = true saving.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
if (editId.value) { if (editId.value) {
await put(`/operator/organizations/${editId.value}`, form.value, { await put(`/operator/organizations/${editId.value}`, form.value, {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
@@ -119,7 +120,7 @@ async function save() {
async function toggleStatus(row: any) { async function toggleStatus(row: any) {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable' const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/organizations/${row.id}/${action}`, {}, { await put(`/operator/organizations/${row.id}/${action}`, {}, {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
@@ -98,6 +98,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens' import { token as tokenColor } from '@/theme/tokens'
@@ -124,7 +125,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` }) const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || [] tableData.value = res.data || []
loadStats() loadStats()
@@ -134,7 +135,7 @@ async function loadData() {
async function loadStats() { async function loadStats() {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {} const data = res.data || {}
stats.value = [ stats.value = [
@@ -152,7 +153,7 @@ async function openDetail(reportId: string) {
processResult.value = 'no_action' processResult.value = 'no_action'
processNote.value = '' processNote.value = ''
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` }) const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data detail.value = res.data
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -162,7 +163,7 @@ async function handleProcess() {
if (!processResult.value) return if (!processResult.value) return
processing.value = true processing.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await post(`/operator/risk/reports/${currentReportId.value}/process`, await post(`/operator/risk/reports/${currentReportId.value}/process`,
{ processResult: processResult.value, processNote: processNote.value }, { processResult: processResult.value, processNote: processNote.value },
{ Authorization: `Bearer ${token}` }) { Authorization: `Bearer ${token}` })
+6 -3
View File
@@ -37,8 +37,8 @@ const routes: RouteRecordRaw[] = [
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } }, { path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } }, { path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } }, { path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } }, { path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['matchmaker.service'] } },
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } }, { path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.view'] } },
], ],
}, },
@@ -48,10 +48,13 @@ const routes: RouteRecordRaw[] = [
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。 // 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
router.beforeEach((to) => { router.beforeEach(async (to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const requiredPerms = (to.meta.permissions as string[]) || [] const requiredPerms = (to.meta.permissions as string[]) || []
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
await authStore.initializeSession()
// 1. 登录检查 // 1. 登录检查
if (to.path !== '/login' && !authStore.isLoggedIn) { if (to.path !== '/login' && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } } return { path: '/login', query: { redirect: to.fullPath } }
+56 -23
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { post } from '@/api/client' import { post } from '@/api/client'
import { setAccessToken, clearAccessToken } from '@/utils/token'
interface OperatorInfo { interface OperatorInfo {
operatorId: string operatorId: string
@@ -12,9 +13,9 @@ interface OperatorInfo {
const previewRolePermissions: Record<string, string[]> = { const previewRolePermissions: Record<string, string[]> = {
reviewer: ['review.view', 'user.view'], reviewer: ['review.view', 'user.view'],
counselor: ['counseling.service.view', 'referral.manage'], counselor: ['counseling.service.view', 'matchmaker.service'],
station: ['service_location.view', 'service_appointment.view'], station: ['service_location.view', 'service_appointment.view'],
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'referral.manage'], operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'matchmaker.view', 'matchmaker.service'],
} }
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
@@ -23,38 +24,70 @@ export const useAuthStore = defineStore('auth', () => {
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole] const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null } ? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
: null : null
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
const operator = ref<OperatorInfo | null>(
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
)
const isLoggedIn = computed(() => !!token.value) const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
const operator = ref<OperatorInfo | null>(previewOperator)
let sessionInitialized = false
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || []) 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 || ''
operator.value = {
operatorId: data.operatorId,
name: data.name,
username: data.username,
permissions: data.permissions || [],
organizationId: data.organizationId ?? null,
}
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
setAccessToken(token.value)
}
async function login(username: string, password: string) { async function login(username: string, password: string) {
const res: any = await post('/auth/operator/login', { username, password }) const res: any = await post('/auth/operator/login', { username, password })
if (res.success) { if (res.success) {
token.value = res.data.accessToken applyAuthData(res.data)
operator.value = {
operatorId: res.data.operatorId,
name: res.data.name,
username: res.data.username,
permissions: res.data.permissions || [],
organizationId: res.data.organizationId ?? null,
}
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value))
return res.data return res.data
} }
throw new Error(res.message || '登录失败') throw new Error(res.message || '登录失败')
} }
function logout() { /**
token.value = '' * 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
operator.value = null * 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
localStorage.removeItem('accessToken') * 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
localStorage.removeItem('operator') */
async function initializeSession(): Promise<boolean> {
if (sessionInitialized) return isLoggedIn.value
sessionInitialized = true
if (token.value) return true
try {
const res: any = await post('/auth/operator/refresh', {})
if (res.success) {
applyAuthData(res.data)
return true
}
} catch {
// 未登录或 refresh 失效,交由路由守卫跳转登录页
}
return false
} }
return { token, operator, isLoggedIn, permissions, login, logout } function logout() {
// 尽力通知后端注销并清除 Cookie(best-effort)
try {
post('/auth/operator/logout', {}).catch(() => {})
} catch {
// ignore
}
token.value = ''
operator.value = null
clearAccessToken()
}
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
}) })
+19
View File
@@ -0,0 +1,19 @@
// 内存态访问令牌。
//
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
// Authorization 头承载。两者并存,互不冲突。
let currentToken = ''
export function getAccessToken(): string {
return currentToken
}
export function setAccessToken(token: string): void {
currentToken = token || ''
}
export function clearAccessToken(): void {
currentToken = ''
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,9 +13,9 @@
<groupId>com.danyang.match</groupId> <groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId> <artifactId>danyang-match-platform</artifactId>
<version>1.0.0-RC.1</version> <version>1.2.0</version>
<name>Danyang Match Platform</name> <name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description> <description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.relationship.service.RelationshipService; import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -30,7 +30,8 @@ import java.util.Map;
public class ActivityInterestController { public class ActivityInterestController {
private final ActivityInterestService interestService; private final ActivityInterestService interestService;
private final RelationshipService relationshipService; /** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
private final ActivityMutualConsentService consentService;
private final ActivityMapper activityMapper; private final ActivityMapper activityMapper;
@GetMapping("/operator/activity-interest-summary") @GetMapping("/operator/activity-interest-summary")
@@ -105,7 +106,12 @@ public class ActivityInterestController {
/** /**
* 确认开启线上沟通(V1.1 Phase 08) * 确认开启线上沟通(V1.1 Phase 08)
* POST /api/v1/activities/{id}/interests/confirm * POST /api/v1/activities/{id}/interests/confirm
* 双方确认后创建 Match Episode,不自动创建 Conversation *
* <p><b>2026-09-23 UAT 走查修复(INV-014/015)</b>:此前本端点仅校验「双向互选」便直调
* {@code RelationshipService.confirmActivityInterest} —— 那是「建 Match」的底层原语
* (不做任何同意校验),于是**单方调用即可建 Match/会话**,绕过「双方各自显式确认」
* 的二阶段同意。现改为与 {@code /activities/{id}/mutual/*} 走**同一条**二阶段链路:
* 本端点只登记「我方确认」,只有双方都 accepted 时才建 Match/会话。
*/ */
@Idempotent @Idempotent
@PostMapping("/{id}/interests/confirm") @PostMapping("/{id}/interests/confirm")
@@ -120,13 +126,16 @@ public class ActivityInterestController {
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"partnerId不能为空"); "partnerId不能为空");
} }
// 验证互选状态 // 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
if (!interestService.hasMutualInterest(userId, partnerId, id)) { if (!interestService.hasMutualInterest(userId, partnerId, id)) {
return ApiResponse.error( return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT, com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
"您与该用户未形成活动双向互选"); "您与该用户未形成活动双向互选");
} }
return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId)); // 二阶段同意:先幂等确保同意记录存在,再仅提交「我方确认」。
// 单方调用不再可能创建 Match(INV-014/015)。
consentService.createMutualConsent(id, userId, partnerId);
return ApiResponse.ok(consentService.confirmConnection(userId, id, partnerId));
} }
/** /**
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* 活动互选服务 * 活动互选服务
@@ -69,7 +70,7 @@ public class ActivityInterestService {
// 2. 验证用户已报名且通过审核 // 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您未报名该活动")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈"); throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
} }
@@ -32,6 +32,7 @@ import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -407,7 +408,7 @@ public class ActivityService {
@Transactional @Transactional
public ActivityRegistration cancelRegistration(String userId, String activityId) { public ActivityRegistration cancelRegistration(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("没有找到报名记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) && if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { !Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException; import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* 签到服务 * 签到服务
@@ -64,7 +65,7 @@ public class CheckinService {
*/ */
public Map<String, Object> generateCheckinToken(String userId, String activityId) { public Map<String, Object> generateCheckinToken(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您没有报名该活动")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到"); throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
@Slf4j @Slf4j
@Service @Service
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
@Transactional @Transactional
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) { public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId) ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted, // 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。 // 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
// 回读权威状态(可能受并发另一方提交影响) // 回读权威状态(可能受并发另一方提交影响)
consent = consentMapper.findByActivityPair(activityId, userId, partnerId) consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("互选记录已不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()); boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()); boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
@@ -120,7 +122,7 @@ public class ActivityMutualConsentService {
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) { public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId) ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) { if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED); consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
} else { } else {
@@ -19,4 +19,15 @@ public class AuditLog extends BaseEntity {
private String afterData; private String afterData;
private String ip; private String ip;
private String userAgent; 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("ip", log.getIp());
m.put("userAgent", log.getUserAgent()); m.put("userAgent", log.getUserAgent());
m.put("createdAt", log.getCreatedAt()); 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; return m;
}).collect(java.util.stream.Collectors.toList()); }).collect(java.util.stream.Collectors.toList());
@@ -73,11 +78,23 @@ public class AuditLogService {
*/ */
@Transactional @Transactional
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) { 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 auditLog = new AuditLog();
auditLog.setOperatorId(operatorId); auditLog.setOperatorId(operatorId);
auditLog.setAction("SENSITIVE_ACCESS"); auditLog.setAction("SENSITIVE_ACCESS");
auditLog.setResourceType(resourceType); auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId); auditLog.setResourceId(resourceId);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) { if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
HttpServletRequest req = attrs.getRequest(); HttpServletRequest req = attrs.getRequest();
auditLog.setIp(req.getRemoteAddr()); auditLog.setIp(req.getRemoteAddr());
@@ -1,9 +1,15 @@
package com.danyang.match.platform.auth.controller; package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
import com.danyang.match.platform.auth.service.OperatorAuthService; import com.danyang.match.platform.auth.service.OperatorAuthService;
import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -17,14 +23,80 @@ import java.util.Map;
public class OperatorAuthController { public class OperatorAuthController {
private final OperatorAuthService operatorAuthService; private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) { public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String username = body.get("username"); String username = body.get("username");
String password = body.get("password"); String password = body.get("password");
if (username == null || username.isBlank() || password == null || password.isBlank()) { if (username == null || username.isBlank() || password == null || password.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空"); return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
} }
return ApiResponse.ok(operatorAuthService.login(username, password)); Map<String, Object> result = operatorAuthService.login(username, password);
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌。
// Cookie max-age 由 OperatorCookieWriter 按 JWT 有效期计算,控制器不引用 JWT 工具
// (控制器禁止解析/引用 JWT 工具,见 PrincipalInjectionContractTest 契约)。
cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"));
return ApiResponse.ok(result);
}
/**
* 刷新 Operator Token。
*
* <p>refresh token 优先取请求体(兼容旧前端/跨端口开发代理),缺省时回退到 HttpOnly Cookie
* {@code dy_op_rt}(生产同源场景,前端不持有 refresh token,抗 XSS)。
* 响应同时下发新的 access / refresh Cookie,实现刷新即轮换。
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refresh(@RequestBody(required = false) Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String refreshToken = (body != null) ? body.get("refreshToken") : null;
if (refreshToken == null || refreshToken.isBlank()) {
refreshToken = readCookie(request, OperatorCookieWriter.REFRESH_COOKIE);
}
if (refreshToken == null || refreshToken.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少 Refresh Token");
}
String operatorId = (body != null) ? body.get("operatorId") : null;
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"));
return ApiResponse.ok(result);
}
/**
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
* operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
* 仅用于黑名单登记。
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response,
@AuthenticationPrincipal OperatorPrincipal principal) {
String token = readBearer(request);
if (token != null && principal != null) {
operatorAuthService.logout(principal.id(), token);
}
cookieWriter.clearCookies(response);
return ApiResponse.ok();
}
private String readCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
for (Cookie c : cookies) {
if (name.equals(c.getName())) return c.getValue();
}
return null;
}
private String readBearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return readCookie(request, OperatorCookieWriter.ACCESS_COOKIE);
} }
} }
@@ -0,0 +1,64 @@
package com.danyang.match.platform.auth.cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
/**
* 管理端鉴权 Cookie 写入工具。
*
* <p>登录 / 刷新成功时下发 HttpOnly + SameSite=Lax 的 access / refresh Cookie;登出时清除。
* Cookie 为 HttpOnly,JS 无法读取,从根本上消除 XSS 读取令牌并外泄的风险(此前令牌存于
* 前端 localStorage,XSS 可读写并伪造权限)。
*
* <p>{@code Secure} 标志由 {@code app.auth-cookie-secure} 控制:生产(https)为 true;
* dev / docker(http)为 false,否则浏览器拒绝写入。
*
* <p>路径固定为 {@code /api/v1}(即后端 servlet context-path),确保只随同源 API 请求携带。
*/
@Component
public class OperatorCookieWriter {
public static final String ACCESS_COOKIE = "dy_op_at";
public static final String REFRESH_COOKIE = "dy_op_rt";
@Value("${app.auth-cookie-secure:true}")
private boolean secure;
// Cookie 生命周期与 JWT 自身有效期对齐(access 2h / refresh 14d),由配置驱动、不写死。
// 由本组件持有而非控制器持有:控制器不得直接引用 JwtTokenService
// (PrincipalInjectionContractTest 契约禁止控制器解析/引用 JWT 工具)。
@Value("${jwt.access-token-expiration-ms:7200000}")
private long accessExpirationMs;
@Value("${jwt.refresh-token-expiration-ms:1209600000}")
private long refreshExpirationMs;
/** 登录/刷新下发 Cookie,max-age 自动取 JWT 有效期(秒)。 */
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken) {
writeLoginCookies(response, accessToken, refreshToken, accessExpirationMs / 1000, refreshExpirationMs / 1000);
}
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
long accessMaxAgeSec, long refreshMaxAgeSec) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, refreshToken, refreshMaxAgeSec).toString());
}
public void clearCookies(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, "", 0).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, "", 0).toString());
}
private ResponseCookie build(String name, String value, long maxAgeSec) {
return ResponseCookie.from(name, value)
.httpOnly(true)
.secure(secure)
.sameSite("Lax")
.path("/api/v1")
.maxAge(maxAgeSec)
.build();
}
}
@@ -13,10 +13,15 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Mock 微信登录 Provider(开发/测试环境使用) * Mock 微信登录 Provider(开发/测试环境使用)。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
*/ */
@Component @Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true) @ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider { public class MockWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class); private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
@@ -0,0 +1,100 @@
package com.danyang.match.platform.auth.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 真实微信登录 Provider(生产环境使用)。
*
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>安全要点:
* <ul>
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
* <li>绝不把 appSecret / session_key 回传给前端。</li>
* </ul>
*/
@Component
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
public class RealWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
@Value("${wechat.app-id:}")
private String appId;
@Value("${wechat.app-secret:}")
private String appSecret;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, String> getCodeSession(String code) {
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
}
if (code == null || code.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
}
String url = CODE2SESSION_URL
+ "?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
+ "&grant_type=authorization_code";
try {
String resp = restTemplate.getForObject(url, String.class);
JsonNode node = objectMapper.readTree(resp);
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
}
String openid = node.has("openid") ? node.get("openid").asText() : null;
if (openid == null || openid.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
Map<String, String> result = new HashMap<>();
result.put("openid", openid);
result.put("unionid", unionid);
result.put("session_key", sessionKey);
result.put("appid", appId);
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Wechat] code2session 调用异常", e);
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
}
}
@Override
public boolean isMock() {
return false;
}
}
@@ -30,6 +30,7 @@ import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -90,7 +91,9 @@ public class AuthService {
// 将 refreshToken 存入 Redis(用于撤销) // 将 refreshToken 存入 Redis(用于撤销)
String refreshTokenKey = "auth:refresh_token:" + user.getId(); String refreshTokenKey = "auth:refresh_token:" + user.getId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2)); // 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
// 记录审计日志 // 记录审计日志
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null); saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
@@ -111,7 +114,7 @@ public class AuthService {
public void bindPhone(String userId, String phoneCode) { public void bindPhone(String userId, String phoneCode) {
// TODO: Phase 03 对接真实短信验证码 // TODO: Phase 03 对接真实短信验证码
User user = userMapper.findById(userId) User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
// 检查手机号是否已被其他用户占用 // 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> { userMapper.findByPhone(phoneCode).ifPresent(existing -> {
@@ -172,9 +175,9 @@ public class AuthService {
String newAccessToken = jwtTokenService.generateAccessToken(userId, null); String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
String newRefreshToken = jwtTokenService.generateRefreshToken(userId); String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
// 更新 Redis // 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken, redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2)); Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccessToken); result.put("accessToken", newAccessToken);
@@ -200,7 +203,7 @@ public class AuthService {
*/ */
public Map<String, Object> getMeStatus(String userId) { public Map<String, Object> getMeStatus(String userId) {
User user = userMapper.findById(userId) User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
Map<String, Object> status = new HashMap<>(); Map<String, Object> status = new HashMap<>();
status.put("userId", user.getId()); status.put("userId", user.getId());
@@ -25,6 +25,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -100,6 +101,9 @@ public class OperatorAuthService {
.toList(); .toList();
// 签发 Token // 签发 Token
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
claims.put("operatorId", operator.getId()); claims.put("operatorId", operator.getId());
claims.put("organizationId", operator.getOrganizationId()); claims.put("organizationId", operator.getOrganizationId());
@@ -144,7 +148,7 @@ public class OperatorAuthService {
*/ */
public Map<String, Object> getMe(String operatorId) { public Map<String, Object> getMe(String operatorId) {
Operator operator = operatorMapper.findById(operatorId) Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId); List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
List<String> permissionCodes = permissions.stream() List<String> permissionCodes = permissions.stream()
@@ -176,6 +180,11 @@ public class OperatorAuthService {
throw new UnauthorizedException("Refresh Token 无效"); throw new UnauthorizedException("Refresh Token 无效");
} }
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
if (operatorId == null || operatorId.isBlank()) {
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
}
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId); String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) { if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效"); throw new UnauthorizedException("Refresh Token 无效");
@@ -193,6 +202,12 @@ public class OperatorAuthService {
result.put("accessToken", accessToken); result.put("accessToken", accessToken);
result.put("refreshToken", newRefreshToken); result.put("refreshToken", newRefreshToken);
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000); result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
result.put("operatorId", operatorInfo.get("operatorId"));
result.put("name", operatorInfo.get("name"));
result.put("username", operatorInfo.get("username"));
result.put("organizationId", operatorInfo.get("organizationId"));
result.put("permissions", operatorInfo.get("permissions"));
return result; return result;
} }
@@ -215,9 +215,21 @@ public class JwtTokenService {
} }
/** /**
* 获取 Token 过期时间 * 获取 Access Token 过期时间(毫秒)
*/ */
public long getExpirationMs() { public long getExpirationMs() {
return accessExpirationMs; return accessExpirationMs;
} }
/**
* 获取 Refresh Token 过期时间(毫秒)。
*
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
*/
public long getRefreshExpirationMs() {
return refreshExpirationMs;
}
} }
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper; private final LocalEligibilityMapper localEligibilityMapper;
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
private final MatchScoringService matchScoringService;
/** /**
* 获取每日推荐列表 * 获取每日推荐列表
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
return null; return null;
} }
// 2. 双向 Required 检查 // 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null); UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
if (targetPref == null) { if (targetPref == null) {
return null; // 目标用户未设置择偶条件,无法双向验证 return null; // 目标用户未设置择偶条件,无法双向验证
} }
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
// A → B Required 检查 UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
if (!checkBidirectionalRequired(myPref, targetPref)) { if (myProfile == null || targetProfile == null) {
return null; // 资料缺失则无法核验 Required,不假定满足
}
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
return null; return null;
} }
@@ -175,36 +180,18 @@ public class MatchEligibilityService {
return "verified".equals(el.getVerificationStatus()); return "verified".equals(el.getVerificationStatus());
} }
/** // checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
* 双向 Required 检查 // (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
* Required 是 Hard Constraint,不可自动放宽
*/
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
return true;
}
/** /**
* 方向性评分(A对B的适配度) * 方向性评分(A对B的适配度)
* 基于 Preferred 维度计算 0-100 分 * 基于 Preferred 维度计算 0-100 分
*/ */
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) { private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
double score = 0; // 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() + return userProfileMapper.findByUserId(toUserId)
pref.getMaritalWeight() + pref.getChildrenWeight() + .map(p -> matchScoringService.scoreDirectionally(pref, p))
pref.getEducationWeight() + pref.getHeightWeight() + .orElse(0.0);
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
pref.getLifestyleWeight() + pref.getInterestWeight();
// TODO: 实现各维度评分逻辑
// 年龄匹配:在范围内得满分,超出范围按比例扣分
// 地区匹配:同地区得满分,不同地区按比例扣分
// ...
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
return (int)(Math.round(score / totalWeight * 100));
} }
/** /**
@@ -212,20 +199,21 @@ public class MatchEligibilityService {
*/ */
private List<String> generateReasons(String userId, String targetUserId, private List<String> generateReasons(String userId, String targetUserId,
UserPreference myPref, UserPreference targetPref) { UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>(); // 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
// TODO: 根据实际资料生成真实推荐理由 return userProfileMapper.findByUserId(targetUserId)
reasons.add("年龄符合双方择偶要求"); .map(p -> matchScoringService.reasons(myPref, p))
reasons.add("都长期生活在丹阳"); .orElseGet(ArrayList::new);
reasons.add("婚姻计划接近");
return reasons;
} }
/** /**
* 获取已Pass的用户ID集合(冷却期内) * 获取已Pass的用户ID集合(冷却期内)
*/ */
private Set<String> getPassedUserIds(String userId, Instant since) { private Set<String> getPassedUserIds(String userId, Instant since) {
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id // 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
return Collections.emptySet(); return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
.filter(r -> "passed".equals(r.getAction()))
.map(RecommendationRecord::getTargetUserId)
.collect(Collectors.toSet());
} }
/** /**
@@ -240,14 +228,28 @@ public class MatchEligibilityService {
} }
/** /**
* 构建候选池(简化版) * 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
* TODO: Phase 06 后续接入数据库查询可用用户 * (2026-09-23:替换原 `return List.of()` 空实现。)
*/ */
private List<String> buildCandidatePool(String userId, UserPreference myPref, private List<String> buildCandidatePool(String userId, UserPreference myPref,
Set<String> excludedIds, Set<String> alreadyRecommended) { Set<String> excludedIds, Set<String> alreadyRecommended) {
// TODO: 从数据库查询符合条件的用户 // 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
// 当前返回空列表,等待后续阶段完善 // 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
return List.of(); UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProfile> wrapper =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
wrapper.isNotNull(UserProfile::getGender)
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
.isNull(UserProfile::getDeletedAt);
if (me != null && me.getGender() != null && !me.getGender().isBlank()) {
wrapper.ne(UserProfile::getGender, me.getGender());
}
return userProfileMapper.selectList(wrapper).stream()
.map(UserProfile::getUserId)
.filter(id -> !id.equals(userId))
.filter(id -> !excludedIds.contains(id))
.filter(id -> !alreadyRecommended.contains(id))
.collect(Collectors.toList());
} }
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) { private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
@@ -0,0 +1,284 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 匹配评分 —— **全仓唯一事实源**。
*
* <p>背景:`RecommendationEngineService.calculateScore` 与
* `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
* 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
* `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
* 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
*
* <p>本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
*
* <p><b>规则(不新造常量)</b>:直接使用 {@link UserPreference} 上<b>已存在</b>的
* 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
* 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
* 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
*
* <p>返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
*/
@Service
public class MatchScoringService {
/** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
private static final List<String> EDUCATION_ORDER = List.of(
"primary", "junior_high", "senior_high", "vocational", "college", "bachelor", "master", "doctor");
/**
* 方向性评分:seeker 的择偶条件对 candidate 资料的适配度(0–100)。
*
* @param pref seeker 的择偶条件(可为 null → 无约束)
* @param candidate 候选人资料
*/
public double scoreDirectionally(UserPreference pref, UserProfile candidate) {
if (candidate == null) return 0;
if (pref == null) return 100;
double acc = 0; // 加权得分
double denom = 0; // 已设置条件的权重合计
// —— 年龄 ——(设置了 min/maxAge 才构成约束)
if (pref.getMinAge() != null && pref.getMaxAge() != null) {
double w = w(pref.getAgeWeight());
denom += w;
acc += w * rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge());
}
// —— 身高 ——
if (pref.getMinHeightCm() != null && pref.getMaxHeightCm() != null) {
double w = w(pref.getHeightWeight());
denom += w;
Integer h = candidate.getHeightCm();
acc += w * (h == null ? 0 : rangeRatio(h, pref.getMinHeightCm(), pref.getMaxHeightCm()));
}
// —— 地区(regionScope 为地区码数组)——
if (hasJsonArray(pref.getRegionScope())) {
double w = w(pref.getRegionWeight());
denom += w;
acc += w * (inJsonArray(pref.getRegionScope(), candidate.getRegionCode()) ? 1 : 0);
}
// —— 婚姻状况(maritalStatuses 数组)——
if (hasJsonArray(pref.getMaritalStatuses())) {
double w = w(pref.getMaritalWeight());
denom += w;
acc += w * (inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus()) ? 1 : 0);
}
// —— 子女(childrenAcceptance:any/accept/reject,兼容中文)——
if (notBlank(pref.getChildrenAcceptance())) {
double w = w(pref.getChildrenWeight());
denom += w;
acc += w * (childrenAcceptable(pref.getChildrenAcceptance(), candidate.getChildrenStatus()) ? 1 : 0);
}
// —— 学历(minEducationCode 为下限)——
if (notBlank(pref.getMinEducationCode())) {
double w = w(pref.getEducationWeight());
denom += w;
acc += w * (educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode()) ? 1 : 0);
}
// —— 婚恋规划(marriagePlanPreference 与 candidate.marriagePlan 一致)——
if (notBlank(pref.getMarriagePlanPreference())) {
double w = w(pref.getMarriagePlanWeight());
denom += w;
acc += w * (sameValue(pref.getMarriagePlanPreference(), candidate.getMarriagePlan()) ? 1 : 0);
}
// —— 定居意愿(settlementPreference 对照 candidate 长期/未来丹阳意向)——
if (notBlank(pref.getSettlementPreference())) {
double w = w(pref.getSettlementWeight());
denom += w;
boolean ok = sameValue(pref.getSettlementPreference(), candidate.getLongTermDanyang())
|| sameValue(pref.getSettlementPreference(), candidate.getFutureDanyang());
acc += w * (ok ? 1 : 0);
}
// —— 生活方式(吸烟 / 饮酒,取已设置者的均值)——
double lifeW = w(pref.getLifestyleWeight());
int lifeDims = 0;
double lifeHit = 0;
if (notBlank(pref.getSmokingPreference())) {
lifeDims++;
if (sameValue(pref.getSmokingPreference(), candidate.getSmokingStatus())) lifeHit++;
}
if (notBlank(pref.getDrinkingPreference())) {
lifeDims++;
if (sameValue(pref.getDrinkingPreference(), candidate.getDrinkingStatus())) lifeHit++;
}
if (lifeDims > 0) {
denom += lifeW;
acc += lifeW * (lifeHit / lifeDims);
}
// interestWeight:当前无对应择偶条件字段,不参与计分(保留权重以备扩展)。
if (denom == 0) return 100; // 用户未设置任何条件 → 无约束
return clamp(acc / denom * 100.0);
}
/**
* 生成中文推荐理由(基于**实际达成**的维度,不再是无条件套话)。
* 未达成任何具体维度时回退为「资料符合基本要求」。
*/
public List<String> reasons(UserPreference pref, UserProfile candidate) {
List<String> out = new ArrayList<>();
if (pref == null || candidate == null) return out;
if (pref.getMinAge() != null && pref.getMaxAge() != null
&& rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge()) > 0) {
out.add("年龄在您的要求范围内");
}
if (hasJsonArray(pref.getRegionScope()) && inJsonArray(pref.getRegionScope(), candidate.getRegionCode())) {
out.add("同在您期望的地区");
}
if (hasJsonArray(pref.getMaritalStatuses())
&& inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus())) {
out.add("婚姻状况符合您的期望");
}
if (notBlank(pref.getMinEducationCode())
&& educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode())) {
out.add("学历符合您的要求");
}
if (notBlank(pref.getAgePriority()) && "required".equals(pref.getAgePriority())) {
out.add("年龄为您设定的必要条件");
}
if (out.isEmpty()) out.add("资料符合基本要求");
return out;
}
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——唯一事实源。
*
* <p>任一方把某维度设为 `required`,则对方该维度<b>必须</b>落在要求内,否则整条候选被否。
* 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 Required 维度)。
*
* @return true 表示双向 Required 全部满足
*/
public boolean bidirectionalRequiredSatisfied(UserPreference myPref, UserProfile myProfile,
UserPreference targetPref, UserProfile targetProfile) {
if (myPref == null || myProfile == null || targetPref == null || targetProfile == null) return false;
// 年龄
if (isRequired(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
Integer candAge = age(targetProfile.getBirthDate());
if (candAge == null || candAge < myPref.getMinAge() || candAge > myPref.getMaxAge()) return false;
}
if (isRequired(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
Integer myAge = age(myProfile.getBirthDate());
if (myAge == null || myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) return false;
}
// 身高
if (isRequired(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
Integer h = targetProfile.getHeightCm();
if (h == null || h < myPref.getMinHeightCm() || h > myPref.getMaxHeightCm()) return false;
}
if (isRequired(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
Integer h = myProfile.getHeightCm();
if (h == null || h < targetPref.getMinHeightCm() || h > targetPref.getMaxHeightCm()) return false;
}
// 地区(Required 用 isJsonArray:空数组=自相矛盾的硬约束 → 拒绝;不可放宽为「无约束」)
if (isRequired(myPref.getRegionPriority()) && isJsonArray(myPref.getRegionScope())
&& !inJsonArray(myPref.getRegionScope(), targetProfile.getRegionCode())) return false;
if (isRequired(targetPref.getRegionPriority()) && isJsonArray(targetPref.getRegionScope())
&& !inJsonArray(targetPref.getRegionScope(), myProfile.getRegionCode())) return false;
// 婚姻状况
if (isRequired(myPref.getMaritalPriority()) && isJsonArray(myPref.getMaritalStatuses())
&& !inJsonArray(myPref.getMaritalStatuses(), targetProfile.getMaritalStatus())) return false;
if (isRequired(targetPref.getMaritalPriority()) && isJsonArray(targetPref.getMaritalStatuses())
&& !inJsonArray(targetPref.getMaritalStatuses(), myProfile.getMaritalStatus())) return false;
return true;
}
private static boolean isRequired(String priority) { return "required".equals(priority); }
// ==================== 私有工具 ====================
private static double w(Double v) { return v == null ? 0 : Math.max(0, v); }
private static boolean notBlank(String s) { return s != null && !s.isBlank(); }
private static Integer age(LocalDate birthDate) {
if (birthDate == null) return null;
return Period.between(birthDate, LocalDate.now()).getYears();
}
/** 在 [min,max] 内 → 1;否则按越界距离线性衰减(跨度越大衰减越慢),最低 0。 */
private static double rangeRatio(Integer value, int min, int max) {
if (value == null) return 0;
if (min > max) { int t = min; min = max; max = t; }
if (value >= min && value <= max) return 1.0;
int span = Math.max(1, max - min);
int distance = value < min ? (min - value) : (value - max);
return Math.max(0.0, 1.0 - (double) distance / span);
}
/** 评分用:仅当数组**非空**才算「用户表达了该条件」。空数组 = 未设置,不计入分母。 */
private static boolean hasJsonArray(JsonNode node) {
return node != null && node.isArray() && node.size() > 0;
}
/**
* Required 校验用:只要节点是数组即「用户声明了该硬约束」。
* 空数组是自相矛盾的声明(要求必填却无任何可接受值)→ 交由调用方判为不满足,
* 与「Required 不可自动放宽」(INV-001)一致;不可退化成「无约束」。
*/
private static boolean isJsonArray(JsonNode node) {
return node != null && node.isArray();
}
private static boolean inJsonArray(JsonNode node, String value) {
if (node == null || !node.isArray() || value == null) return false;
for (int i = 0; i < node.size(); i++) {
if (value.equals(node.get(i).asText())) return true;
}
return false;
}
/** 子女接受度:any/不限 → 恒满足;accept/可接受 → 有孩与否皆可;reject/不接受 → 仅无孩。 */
private static boolean childrenAcceptable(String acceptance, String childrenStatus) {
String a = acceptance == null ? "" : acceptance.trim();
boolean candidateHasChildren = "yes".equals(childrenStatus) || "has".equals(childrenStatus)
|| "有".equals(childrenStatus);
return switch (a) {
case "any", "不限" -> true;
case "accept", "可接受" -> true; // 接受带孩(且同样接受无孩)
case "reject", "不接受" -> !candidateHasChildren;
default -> true;
};
}
private static boolean educationAtLeast(String candidateCode, String minCode) {
int ci = EDUCATION_ORDER.indexOf(candidateCode);
int mi = EDUCATION_ORDER.indexOf(minCode);
if (mi < 0) return true; // 下限码不在白名单 → 不做约束
if (ci < 0) return false; // 候选人学历缺失/未知 → 无法核验,不假定满足
return ci >= mi;
}
/** 宽松相等:去空白、忽略大小写;两边都非空才比较。 */
private static boolean sameValue(String a, String b) {
if (!notBlank(a) || !notBlank(b)) return false;
return a.trim().equalsIgnoreCase(b.trim());
}
private static double clamp(double v) {
if (v < 0) return 0;
if (v > 100) return 100;
return v;
}
/** 便于单测/文档:常量键说明。 */
public static Map<String, String> weightKeys() {
return Map.of(
"age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
"children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
"marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
"settlement", "settlementWeight", "interest", "interestWeight");
}
}
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.Period;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final UserPrivacySettingsMapper privacySettingsMapper; private final UserPrivacySettingsMapper privacySettingsMapper;
private final LocalEligibilityMapper localEligibilityMapper; private final LocalEligibilityMapper localEligibilityMapper;
/** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
private final MatchScoringService matchScoringService;
/** /**
* 获取每日推荐列表 * 获取每日推荐列表
@@ -241,86 +242,16 @@ public class RecommendationEngineService {
return userProfileMapper.findByUserId(userId).orElse(null); return userProfileMapper.findByUserId(userId).orElse(null);
} }
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——委托
* {@link MatchScoringService#bidirectionalRequiredSatisfied} 唯一事实源。
*
* <p>此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;2026-09-23 收敛为单点。
* 参数对映:本方法的 {@code candidate} 即事实源签名中的 {@code targetProfile}。
*/
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate, boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) { UserPreference targetPref, UserProfile myProfile) {
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false; return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
// 1. 年龄 Required 检查
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
int candidateAge = calculateAge(candidate.getBirthDate());
if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) {
return false;
}
}
if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
int myAge = calculateAge(myProfile.getBirthDate());
if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) {
return false;
}
}
// 2. 身高 Required 检查
if ("required".equals(myPref.getHeightPriority())
&& myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
if (candidate.getHeightCm() == null
|| candidate.getHeightCm() < myPref.getMinHeightCm()
|| candidate.getHeightCm() > myPref.getMaxHeightCm()) {
return false;
}
}
if ("required".equals(targetPref.getHeightPriority())
&& targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
if (myProfile.getHeightCm() == null
|| myProfile.getHeightCm() < targetPref.getMinHeightCm()
|| myProfile.getHeightCm() > targetPref.getMaxHeightCm()) {
return false;
}
}
// 3. 地区 Required 检查
if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < myPref.getRegionScope().size(); i++) {
if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < targetPref.getRegionScope().size(); i++) {
if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
// 4. 婚姻状况 Required 检查
if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) {
if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) {
if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
return true;
} }
private boolean isParticipantEligible(String userId) { private boolean isParticipantEligible(String userId) {
@@ -341,23 +272,19 @@ public class RecommendationEngineService {
return true; return true;
} }
/**
* 方向性评分:委托 {@link MatchScoringService}(唯一事实源)。
*
* <p>此前为桩 `return 50.0`,而阈值默认 60 → `mutualScore < 60` 恒成立,
* 导致 `GET /recommendations` **恒返回空数组**(2026-09-23 UAT 走查实测)。
*/
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) { private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
// TODO: Phase 07 后续完善评分逻辑 return matchScoringService.scoreDirectionally(pref, target);
return 50.0; // V1 简化:默认中间分
} }
private List<String> generateReasons(String userId, UserProfile candidate, private List<String> generateReasons(String userId, UserProfile candidate,
UserPreference myPref, UserPreference targetPref) { UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>(); return matchScoringService.reasons(myPref, candidate);
// TODO: 基于真实资料生成推荐理由
reasons.add("年龄符合双方择偶要求");
reasons.add("都希望长期在丹阳生活");
return reasons;
}
private int calculateAge(LocalDate birthDate) {
if (birthDate == null) return 0;
return Period.between(birthDate, LocalDate.now()).getYears();
} }
private Set<String> getPassedUserIds(String userId, Instant since) { private Set<String> getPassedUserIds(String userId, Instant since) {
@@ -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())
);
}
}
@@ -16,7 +16,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 红娘服务端点 * 红娘服务端点(用户端)
* *
* 用户端: * 用户端:
* POST /api/v1/matchmaker/services — 提交红娘服务请求 * POST /api/v1/matchmaker/services — 提交红娘服务请求
@@ -26,10 +26,15 @@ import java.util.Map;
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target) * POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
* POST /api/v1/matchmaker/recommendations/{id}/pass * POST /api/v1/matchmaker/recommendations/{id}/pass
* *
* 操作人端(Matchmaker/Operator role): * 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
* PUT /api/v1/matchmaker/services/{id}/assign * <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象 * {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务 * {@code POST /services/{id}/complete})。
*
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
*/ */
@RestController @RestController
@RequestMapping("/matchmaker") @RequestMapping("/matchmaker")
@@ -13,6 +13,11 @@ import java.util.Map;
/** /**
* 红娘工作台控制器(Operator调用) * 红娘工作台控制器(Operator调用)
*
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
* 服务单状态由分配动作同事务联动(submitted → assigned)。
*/ */
@RestController @RestController
@RequestMapping("/operator/matchmaker") @RequestMapping("/operator/matchmaker")
@@ -29,42 +34,57 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId)); 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") @GetMapping("/pending")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> getPending( public ApiResponse<List<Map<String, Object>>> getPending(
@AuthenticationPrincipal OperatorPrincipal principal) { @AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(matchmakerServiceService.getPendingServices()); return ApiResponse.ok(matchmakerServiceService.getPendingServices());
} }
/**
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
*/
@PostMapping("/services/{id}/assign") @PostMapping("/services/{id}/assign")
@PreAuthorize("hasAuthority('matchmaker.assign')") @PreAuthorize("hasAuthority('matchmaker.assign')")
public ApiResponse<?> assignService( public ApiResponse<?> assignService(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id, @PathVariable String id,
@RequestBody Map<String, String> body) { @RequestBody Map<String, String> body) {
String operatorId = principal.id(); String assignerOperatorId = principal.id();
String assignOperatorId = body.get("operatorId"); String targetOperatorId = body.get("operatorId");
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null)); String reason = body.getOrDefault("reason", "红娘服务单分配");
return ApiResponse.ok(matchmakerServiceService.assignService(
id, assignerOperatorId, targetOperatorId, reason));
} }
@PostMapping("/services/{id}/claim") // GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
@PreAuthorize("hasAuthority('matchmaker.service')") // 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
public ApiResponse<?> claimService( // 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
@AuthenticationPrincipal OperatorPrincipal principal, // GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
@PathVariable String id) { // getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
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));
}
/** /**
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。 * 红娘名下服务的全部推荐列表(运营端工作台用)。
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。 * 补此前 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") @GetMapping("/recommendations")
public ApiResponse<List<Map<String, Object>>> getRecommendations( public ApiResponse<List<Map<String, Object>>> getRecommendations(
@@ -72,16 +92,19 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id())); 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}") @GetMapping("/services/{id}")
public ApiResponse<Map<String, Object>> getServiceDetail( public ApiResponse<Map<String, Object>> getServiceDetail(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) { @PathVariable String id) {
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情 return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
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);
} }
@PostMapping("/services/{id}/recommend") @PostMapping("/services/{id}/recommend")
@@ -114,7 +137,8 @@ public class MatchmakerOperatorController {
public ApiResponse<?> completeService( public ApiResponse<?> completeService(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) { @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,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);
}
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
int markViewed(@Param("id") String id); 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 " + @Select("SELECT r.* FROM matchmaker_recommendations r " +
"JOIN matchmaker_services s ON s.id = r.service_id " + "JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " +
"WHERE s.assigned_operator_id = #{operatorId} 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") "ORDER BY r.created_at DESC")
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId); 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") @Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
long countSubmitted(); 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); 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}") @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); 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):该用户仍处于非终态的红娘服务数。 * 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。 * 状态口径(submitted/assigned/in_service/waiting_user)。
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} —— * ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。 * 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
*/ */
@@ -0,0 +1,389 @@
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;
}
/**
* 组织范围判定(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; 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.AuditLog;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; 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.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.MatchmakerFollowup;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation; import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord; 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.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper; import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService; 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.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor; 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.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; 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 @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MatchmakerServiceService { public class MatchmakerServiceService {
@@ -37,8 +66,12 @@ public class MatchmakerServiceService {
private final MatchmakerRecommendationMapper recommendationMapper; private final MatchmakerRecommendationMapper recommendationMapper;
private final MatchmakerFollowupMapper followupMapper; private final MatchmakerFollowupMapper followupMapper;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final UserPrivacySettingsMapper userPrivacySettingsMapper;
private final NotificationService notificationService; private final NotificationService notificationService;
private final AuditLogMapper auditLogMapper; 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 @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); MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) { if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
} }
if (targetOperatorId == null || targetOperatorId.isBlank()) {
service.setAssignedOperatorId(operatorId); throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "目标红娘必填");
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, "当前状态不允许接单");
} }
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE); // ① 创建分配(matchmaker.assign 入口为平台授权,hasGlobalAssign=true)
serviceMapper.updateById(service); MatchmakerAssignment createdAssignment = matchmakerAssignmentService.create(assignerOperatorId, true,
service.getUserId(), targetOperatorId, serviceId, reason, null);
log.info("红娘接单: operatorId={}, serviceId={}", operatorId, serviceId); // ② 服务单联动(create 内已对 submitted 服务单置 assigned;此处再校验一次确保联动生效)
return service; 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 归属校验。 * Phase 13 复验修复(D3):补归属校验。
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator * ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments
* 都能对**任意** serviceId 创建推荐(越权)。与 * 是服务对象归属的唯一事实源,无任何 assigned_operator_id 回退);拒绝统一走
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。 * DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),不再抛裸 IllegalStateException。
* *
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。 * AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
*/ */
@@ -145,10 +181,8 @@ public class MatchmakerServiceService {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐 // D4:归属校验 —— 只有该服务对象的负责人(分配持有人)才能推荐
if (!operatorId.equals(service.getAssignedOperatorId())) { MatchmakerAssignment assignment = assertServiceAccess(operatorId, service);
throw new IllegalStateException("您不是此服务的负责人");
}
// 不能推荐给自己(服务申请人) // 不能推荐给自己(服务申请人)
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) { if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
throw new IllegalArgumentException("不能推荐用户给自己"); throw new IllegalArgumentException("不能推荐用户给自己");
@@ -171,7 +205,9 @@ public class MatchmakerServiceService {
"matchmaker_recommendation", rec.getId().toString()); "matchmaker_recommendation", rec.getId().toString());
log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId); 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<>(); Map<String, Object> result = new HashMap<>();
result.put("recommendationId", rec.getId()); result.put("recommendationId", rec.getId());
@@ -182,11 +218,18 @@ public class MatchmakerServiceService {
/** /**
* 红娘添加跟进记录 * 红娘添加跟进记录
*
* ZC-MM-002(D4):补归属校验(此前无任何校验,任何持 matchmaker.service 权限的
* operator 可对任意 serviceId 写跟进 → 越权写)。判定口径同 recommendPartner。
*/ */
@Transactional @Transactional
public MatchmakerFollowup addFollowup(String serviceId, String operatorId, public MatchmakerFollowup addFollowup(String serviceId, String operatorId,
String followupType, String content, String followupType, String content,
String nextFollowupAt) { 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(); MatchmakerFollowup followup = new MatchmakerFollowup();
followup.setServiceId(serviceId); followup.setServiceId(serviceId);
followup.setOperatorId(operatorId); followup.setOperatorId(operatorId);
@@ -198,17 +241,23 @@ public class MatchmakerServiceService {
followupMapper.insert(followup); followupMapper.insert(followup);
log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType); 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; return followup;
} }
/** /**
* 完成服务 * 完成服务
*
* ZC-MM-002(D4):补归属校验(判定口径同 recommendPartner);
* operatorId 由 Controller 传入 principal,不信任请求参数。
*/ */
@Transactional @Transactional
public MatchmakerServiceRecord completeService(String serviceId) { public MatchmakerServiceRecord completeService(String serviceId, String operatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"); if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
MatchmakerAssignment completeAssignment = assertServiceAccess(operatorId, service);
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) && if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) { !Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
@@ -219,7 +268,9 @@ public class MatchmakerServiceService {
serviceMapper.updateById(service); serviceMapper.updateById(service);
log.info("红娘服务完成: serviceId={}", serviceId); 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; return service;
} }
@@ -257,6 +308,24 @@ public class MatchmakerServiceService {
.collect(java.util.stream.Collectors.toList()); .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) { public Map<String, Object> getDashboard(String operatorId) {
long pendingCount = serviceMapper.countSubmitted(); long pendingCount = serviceMapper.countSubmitted();
long inProgressCount = serviceMapper.countInProgressByOperator(operatorId); long inProgressCount = serviceMapper.countInProgressByOperator(operatorId);
@@ -264,57 +333,69 @@ public class MatchmakerServiceService {
Map<String, Object> dashboard = new HashMap<>(); Map<String, Object> dashboard = new HashMap<>();
dashboard.put("pendingCount", pendingCount); dashboard.put("pendingCount", pendingCount);
dashboard.put("inProgressCount", inProgressCount); dashboard.put("inProgressCount", inProgressCount);
long todayCount = serviceMapper.selectCount( // 本人口径:今天新流转到该红娘名下(按 assigned_at)的服务数
new LambdaQueryWrapper<MatchmakerServiceRecord>() long todayCount = serviceMapper.countTodayByOperator(operatorId);
.eq(MatchmakerServiceRecord::getStatus, "submitted"));
dashboard.put("todayCount", todayCount); dashboard.put("todayCount", todayCount);
long completedCount = serviceMapper.selectCount( // 本人口径:该红娘名下已完成的服务数
new LambdaQueryWrapper<MatchmakerServiceRecord>() long completedCount = serviceMapper.countCompletedByOperator(operatorId);
.eq(MatchmakerServiceRecord::getStatus, "completed"));
dashboard.put("completedCount", completedCount); dashboard.put("completedCount", completedCount);
return dashboard; return dashboard;
} }
/** /**
* 获取我的服务列表(红娘工作台) * 获取服务详情(ZC-MM-005 / E1+E2 重构)。
*/
public List<MatchmakerServiceRecord> getOperatorServices(String operatorId) {
return serviceMapper.findByOperatorId(operatorId);
}
/**
* 获取服务详情
* *
* Phase 13 复验修复(D4):改为按调用者授权范围返回。 * <p>此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取 * 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行
* **任意** serviceId 的详情,其中包含该用户的完整资料 * (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
* *
* 现语义: * <p>现语义(判定器唯一事实源,无回退):
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups) * <ul>
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情 * <li>查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");</li>
* (不含 userProfile;followups 正文也不返回,仅给条数) * <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 * @param requesterId 调用者 operator ID
*/ */
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) { public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId); 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<>(); Map<String, Object> detail = new HashMap<>();
detail.put("serviceId", service.getId()); detail.put("serviceId", service.getId());
detail.put("serviceType", service.getServiceType()); detail.put("serviceType", service.getServiceType());
detail.put("status", service.getStatus()); detail.put("status", service.getStatus());
detail.put("assignedOperatorId", service.getAssignedOperatorId());
detail.put("createdAt", service.getCreatedAt()); detail.put("createdAt", service.getCreatedAt());
detail.put("assignedAt", service.getAssignedAt()); detail.put("assignedAt", service.getAssignedAt());
detail.put("completedAt", service.getCompletedAt()); detail.put("completedAt", service.getCompletedAt());
detail.put("viewerIsOwner", isOwner);
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见) // 推荐列表(服务进展摘要)
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId); List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
detail.put("recommendations", recs.stream().map(r -> { detail.put("recommendations", recs.stream().map(r -> {
Map<String, Object> map = new HashMap<>(); Map<String, Object> map = new HashMap<>();
@@ -326,35 +407,78 @@ public class MatchmakerServiceService {
return map; return map;
}).collect(Collectors.toList())); }).collect(Collectors.toList()));
if (isOwner) { // 仅持有人可见:申请人 userId / 诉求描述 / 白名单用户资料 / 跟进正文
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文 detail.put("userId", service.getUserId());
detail.put("userId", service.getUserId()); detail.put("description", service.getDescription());
detail.put("description", service.getDescription()); UserPrivacySettings privacy = userPrivacySettingsMapper.findByUserId(service.getUserId())
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> { .orElse(null);
Map<String, Object> userProfile = new HashMap<>(); userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile ->
userProfile.put("displayName", profile.getDisplayName()); detail.put("userProfile", assembleMatchmakerUserProfile(profile, privacy)));
userProfile.put("gender", profile.getGender()); detail.put("followups", followupMapper.findByServiceId(serviceId));
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());
}
return detail; 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) { private int calculateAge(java.time.LocalDate birthDate) {
if (birthDate == null) return 0; if (birthDate == null) return 0;
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears(); 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, private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) { 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 auditLog = new AuditLog();
auditLog.setOperatorId(operatorId); auditLog.setOperatorId(operatorId);
auditLog.setAction(action); auditLog.setAction(action);
auditLog.setResourceType(resourceType); auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId); auditLog.setResourceId(resourceId);
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
auditLogMapper.insert(auditLog); 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);
}
}
@@ -232,6 +232,25 @@ public class MeController {
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType)); return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
} }
/**
* 确认直传照片落库(REST §29 补)。
* 客户端先 {@code POST /me/photos/upload-token} 拿预签名 URL 直传对象存储,
* 上传成功后再调本端点把 fileKey 落成 user_photos 记录;否则照片不展示、签名访问也过不了归属校验。
* POST /api/v1/me/photos/confirm
*/
@PostMapping("/photos/confirm")
public ApiResponse<Map<String, Object>> confirmPhoto(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String fileKey = body.get("fileKey");
String photoType = body.getOrDefault("photoType", "lifestyle");
UserPhoto photo = photoService.confirmDirectUpload(principal.id(), fileKey, photoType);
Map<String, Object> result = new LinkedHashMap<>();
result.put("photoId", photo.getId());
result.put("photoType", photo.getPhotoType());
return ApiResponse.ok(result);
}
/** /**
* 删除本人照片(REST §32)。 * 删除本人照片(REST §32)。
* DELETE /api/v1/me/photos/{photoId} * DELETE /api/v1/me/photos/{photoId}
@@ -20,6 +20,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* Operator 管理服务 * Operator 管理服务
@@ -85,7 +86,7 @@ public class OperatorService {
@Transactional @Transactional
public void disableOperator(String operatorId) { public void disableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId) Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED); operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
operator.setUpdatedAt(Instant.now()); operator.setUpdatedAt(Instant.now());
@@ -101,7 +102,7 @@ public class OperatorService {
@Transactional @Transactional
public void enableOperator(String operatorId) { public void enableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId) Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL); operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
operator.setUpdatedAt(Instant.now()); operator.setUpdatedAt(Instant.now());
@@ -187,6 +187,33 @@ public class PhotoService {
return photo; return photo;
} }
/**
* 确认直传照片落库(客户端直传对象存储成功后调用)。
*
* <p>直传路径({@code /me/photos/upload-token} 签发预签名 PUT URL)对象先进入私有桶,
* 但此前没有任何端点把 {@code fileKey} 写成 {@code user_photos} 记录,导致「传了白传」:
* 列表不展示、签名访问的归属校验也过不了。本方法补齐落库,并设两道护栏:
* <ol>
* <li>归属校验:{@code fileKey} 必须以 {@code photos/{userId}/} 开头(由本用户直传凭证生成),
* 禁止客户端传入任意 fileKey 把别人的对象据为己有或写入非法 key;</li>
* <li>存在性校验:对象必须已存在于私有桶(客户端确实完成了 PUT),否则拒绝落库,
* 避免只拿了凭证却没上传成功就生成孤儿记录。</li>
* </ol>
*/
@Transactional
public UserPhoto confirmDirectUpload(String userId, String fileKey, String photoType) {
if (fileKey == null || !fileKey.startsWith("photos/" + userId + "/")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
}
// countActiveProfilePhotos 返回 long,落库排序用 int(与 savePhoto 形参一致);
// 照片数量不会触及 int 上界,此处安全窄化。
int sortOrder = (int) (photoMapper.countActiveProfilePhotos(userId) + 1);
return savePhoto(userId, fileKey, photoType, sortOrder);
}
/** /**
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录 * 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
*/ */
@@ -1,11 +1,13 @@
package com.danyang.match.platform.profile.service; package com.danyang.match.platform.profile.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.profile.entity.UserPreference; import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -25,37 +27,55 @@ public class UserPreferenceService {
} }
/** /**
* 保存择偶条件 * 保存择偶条件(无则插入、有则更新)。
*
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
* 再次保存即触发主键冲突(实测 {@code PUT /me/preferences} → 409
* {@code duplicate key value violates unique constraint "user_preferences_pkey"})。
*
* <p><b>为何不用 {@code updateById}</b>:{@code UserPreference} 继承 {@code BaseEntity},
* 其 {@code @TableId} 为 {@code id},而库中真正的主键是 {@code user_id}
* ({@code id} 是 V037 后补的可空列 + 唯一索引,V037 之后新插入的行 {@code id} 为 NULL)。
* 用 {@code updateById} 会退化成 {@code WHERE id IS NULL} 静默不更新,
* 因此按 {@code user_id} 定位。
*/ */
@Transactional @Transactional
public UserPreference savePreference(String userId, Map<String, Object> data) { public UserPreference savePreference(String userId, Map<String, Object> data) {
UserPreference pref = userPreferenceMapper.findByUserId(userId) UserPreference pref = userPreferenceMapper.findByUserId(userId).orElse(null);
.orElseGet(() -> { boolean exists = pref != null;
UserPreference p = new UserPreference(); if (!exists) {
p.setUserId(userId); pref = new UserPreference();
return p; pref.setUserId(userId);
}); }
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge")); if (data != null) {
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge")); if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority")); if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm")); if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm")); if (data.containsKey("minHeightCm")) pref.setMinHeightCm((Integer) data.get("minHeightCm"));
if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority")); if (data.containsKey("maxHeightCm")) pref.setMaxHeightCm((Integer) data.get("maxHeightCm"));
if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope"))); if (data.containsKey("heightPriority")) pref.setHeightPriority((String) data.get("heightPriority"));
if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority")); if (data.containsKey("regionScope")) pref.setRegionScope(toJsonNode(data.get("regionScope")));
if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses"))); if (data.containsKey("regionPriority")) pref.setRegionPriority((String) data.get("regionPriority"));
if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority")); if (data.containsKey("maritalStatuses")) pref.setMaritalStatuses(toJsonNode(data.get("maritalStatuses")));
if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode")); if (data.containsKey("maritalPriority")) pref.setMaritalPriority((String) data.get("maritalPriority"));
if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority")); if (data.containsKey("minEducationCode")) pref.setMinEducationCode((String) data.get("minEducationCode"));
if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance")); if (data.containsKey("educationPriority")) pref.setEducationPriority((String) data.get("educationPriority"));
if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority")); if (data.containsKey("childrenAcceptance")) pref.setChildrenAcceptance((String) data.get("childrenAcceptance"));
if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference")); if (data.containsKey("childrenPriority")) pref.setChildrenPriority((String) data.get("childrenPriority"));
if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference")); if (data.containsKey("smokingPreference")) pref.setSmokingPreference((String) data.get("smokingPreference"));
if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference")); if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference")); if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
}
userPreferenceMapper.insert(pref);; if (exists) {
pref.setUpdatedAt(Instant.now());
userPreferenceMapper.update(pref,
new LambdaUpdateWrapper<UserPreference>().eq(UserPreference::getUserId, userId));
} else {
userPreferenceMapper.insert(pref);
}
return pref; return pref;
} }
@@ -11,6 +11,10 @@ import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper; import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.verification.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -27,6 +31,7 @@ public class UserProfileService {
private final UserPreferenceMapper userPreferenceMapper; private final UserPreferenceMapper userPreferenceMapper;
private final UserPrivacySettingsMapper privacySettingsMapper; private final UserPrivacySettingsMapper privacySettingsMapper;
private final UserRelationMapper relationMapper; private final UserRelationMapper relationMapper;
private final UserIdentityMapper userIdentityMapper;
/** /**
* 获取自己完整资料(含隐私设置) * 获取自己完整资料(含隐私设置)
@@ -60,8 +65,13 @@ public class UserProfileService {
prefMap.put("maxHeightCm", pref.getMaxHeightCm()); prefMap.put("maxHeightCm", pref.getMaxHeightCm());
prefMap.put("heightPriority", pref.getHeightPriority()); prefMap.put("heightPriority", pref.getHeightPriority());
prefMap.put("regionScope", pref.getRegionScope()); prefMap.put("regionScope", pref.getRegionScope());
prefMap.put("regionPriority", pref.getRegionPriority());
prefMap.put("maritalStatuses", pref.getMaritalStatuses()); prefMap.put("maritalStatuses", pref.getMaritalStatuses());
prefMap.put("maritalPriority", pref.getMaritalPriority());
prefMap.put("minEducationCode", pref.getMinEducationCode());
prefMap.put("educationPriority", pref.getEducationPriority());
prefMap.put("childrenAcceptance", pref.getChildrenAcceptance()); prefMap.put("childrenAcceptance", pref.getChildrenAcceptance());
prefMap.put("childrenPriority", pref.getChildrenPriority());
prefMap.put("smokingPreference", pref.getSmokingPreference()); prefMap.put("smokingPreference", pref.getSmokingPreference());
prefMap.put("drinkingPreference", pref.getDrinkingPreference()); prefMap.put("drinkingPreference", pref.getDrinkingPreference());
prefMap.put("settlementPreference", pref.getSettlementPreference()); prefMap.put("settlementPreference", pref.getSettlementPreference());
@@ -84,56 +94,185 @@ public class UserProfileService {
} }
/** /**
* 更新基础资料 * 更新基础资料(资料完善流程分节写)。
*
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
* 已有资料再次保存 → 主键冲突 → 500。改为「无则 insert / 有则 updateById」。
* 另补 {@code gender}/{@code birth_date} 的 NOT NULL 兜底(见 {@link #ensureIdentityFields})。
*/ */
@Transactional @Transactional
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) { public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId) UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
.orElseGet(() -> { boolean exists = profile != null;
UserProfile p = new UserProfile(); if (!exists) {
p.setUserId(userId); profile = new UserProfile();
p.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS); profile.setUserId(userId);
return p; profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
}); }
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
applyProfileFields(profile, fields);
ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile)); profile.setProfileCompletion(calculateCompletion(profile));
userProfileMapper.insert(profile); persistProfile(profile, exists);
return profile; return profile;
} }
/** /**
* 更新个人资料 * 更新个人资料(资料完善向导的最终提交,置为 completed)。
*
* <p>2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
*/ */
@Transactional @Transactional
public void updateProfile(String userId, Map<String, Object> fields) { public void updateProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(new UserProfile()); UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
profile.setUserId(userId); boolean exists = profile != null;
if (!exists) {
// 基础字段映射 profile = new UserProfile();
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName")); profile.setUserId(userId);
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm")); }
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
applyProfileFields(profile, fields);
ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile)); profile.setProfileCompletion(calculateCompletion(profile));
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED); profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
userProfileMapper.insert(profile); persistProfile(profile, exists);
}
/**
* 落库:无则插入、有则按主键 user_id 更新。
*
* <p>{@code UserProfile} 的 {@code @TableId = userId},与库主键 {@code user_profiles.user_id}
* 一致,故 {@code updateById} 安全(对比 {@code UserPreference} 实体 id 与库主键不一致的情形)。
*/
private void persistProfile(UserProfile profile, boolean exists) {
if (exists) {
userProfileMapper.updateById(profile);
} else {
userProfileMapper.insert(profile);
}
}
/**
* 把请求字段映射到资料实体。
*
* <p>同时接受「规范字段名」与小程序的既有字段名,二者等价:
* <ul>
* <li>{@code childrenStatus}(none/yes)↔ {@code hasChildren}(布尔)</li>
* <li>{@code occupationCode} ↔ {@code occupationCategory}</li>
* <li>{@code workRegionCode} ↔ {@code workRegion}</li>
* </ul>
* 这使「REST 规范 / 管理端」与「小程序完善资料向导」共用同一写接口,
* 避免因命名不一致导致字段被静默丢弃(UAT 走查发现向导的子女/职业/工作地三项
* 此前根本写不进库)。
*/
private void applyProfileFields(UserProfile profile, Map<String, Object> fields) {
if (fields == null) return;
if (fields.containsKey("displayName")) profile.setDisplayName(asString(fields.get("displayName")));
if (fields.containsKey("gender")) profile.setGender(asString(fields.get("gender")));
if (fields.containsKey("birthDate")) profile.setBirthDate(asDate(fields.get("birthDate")));
if (fields.containsKey("heightCm")) profile.setHeightCm(asInteger(fields.get("heightCm")));
if (fields.containsKey("regionCode")) profile.setRegionCode(asString(fields.get("regionCode")));
if (fields.containsKey("regionName")) profile.setRegionName(asString(fields.get("regionName")));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus(asString(fields.get("maritalStatus")));
// 子女:规范名 childrenStatus(none/yes)优先,兼容向导的 hasChildren(布尔)
if (fields.containsKey("childrenStatus")) {
profile.setChildrenStatus(asString(fields.get("childrenStatus")));
} else if (fields.containsKey("hasChildren")) {
Boolean has = asBoolean(fields.get("hasChildren"));
if (has != null) profile.setChildrenStatus(has ? "yes" : "none");
}
if (fields.containsKey("educationCode")) profile.setEducationCode(asString(fields.get("educationCode")));
// 职业:规范名 occupationCode 优先,兼容向导的 occupationCategory
if (fields.containsKey("occupationCode")) {
profile.setOccupationCode(asString(fields.get("occupationCode")));
} else if (fields.containsKey("occupationCategory")) {
profile.setOccupationCode(asString(fields.get("occupationCategory")));
}
// 工作地:规范名 workRegionCode 优先,兼容向导的 workRegion
if (fields.containsKey("workRegionCode")) {
profile.setWorkRegionCode(asString(fields.get("workRegionCode")));
} else if (fields.containsKey("workRegion")) {
profile.setWorkRegionCode(asString(fields.get("workRegion")));
}
if (fields.containsKey("smokingStatus")) profile.setSmokingStatus(asString(fields.get("smokingStatus")));
if (fields.containsKey("drinkingStatus")) profile.setDrinkingStatus(asString(fields.get("drinkingStatus")));
if (fields.containsKey("longTermDanyang")) profile.setLongTermDanyang(asString(fields.get("longTermDanyang")));
if (fields.containsKey("futureDanyang")) profile.setFutureDanyang(asString(fields.get("futureDanyang")));
if (fields.containsKey("relationshipGoal")) profile.setRelationshipGoal(asString(fields.get("relationshipGoal")));
if (fields.containsKey("marriagePlan")) profile.setMarriagePlan(asString(fields.get("marriagePlan")));
if (fields.containsKey("selfIntro")) profile.setSelfIntro(asString(fields.get("selfIntro")));
}
/**
* {@code user_profiles.gender} 与 {@code birth_date} 在库中为 NOT NULL,而资料完善向导
* 不采集这两项(性别是「已认证」的只读信息)——权威来源是实名认证
* ({@code user_identities}),产品流程亦为「先实名、后完善资料」。
*
* <p>因此:请求未提供时从已通过实名认证的记录补齐;仍缺失则给出明确的
* 4xx 业务拒绝(而非让数据库抛 NOT NULL 约束错渲染成 500)。
*/
private void ensureIdentityFields(UserProfile profile) {
boolean needGender = profile.getGender() == null || profile.getGender().isBlank();
boolean needBirth = profile.getBirthDate() == null;
if (!needGender && !needBirth) return;
UserIdentity identity = userIdentityMapper.findByUserId(profile.getUserId()).orElse(null);
if (identity != null && Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
if (needGender && identity.getGender() != null && !identity.getGender().isBlank()) {
profile.setGender(identity.getGender());
needGender = false;
}
if (needBirth && identity.getBirthDate() != null) {
profile.setBirthDate(identity.getBirthDate());
needBirth = false;
}
}
if (needGender || needBirth) {
throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.BAD_REQUEST,
"请先完成实名认证后再完善资料");
}
}
private static String asString(Object v) {
return v == null ? null : String.valueOf(v);
}
private static Integer asInteger(Object v) {
if (v == null) return null;
if (v instanceof Number n) return n.intValue();
try {
return Integer.valueOf(String.valueOf(v).trim());
} catch (NumberFormatException e) {
return null;
}
}
private static Boolean asBoolean(Object v) {
if (v == null) return null;
if (v instanceof Boolean b) return b;
String s = String.valueOf(v).trim();
if ("true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "has".equalsIgnoreCase(s)) return true;
if ("false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) || "none".equalsIgnoreCase(s)) return false;
return null;
}
private static LocalDate asDate(Object v) {
if (v == null) return null;
if (v instanceof LocalDate d) return d;
String s = String.valueOf(v).trim();
if (s.isEmpty()) return null;
try {
return LocalDate.parse(s);
} catch (RuntimeException e) {
return null;
}
} }
/** /**
@@ -30,7 +30,7 @@ public class ReferralService {
private final ActivityRegistrationMapper activityRegistrationMapper; private final ActivityRegistrationMapper activityRegistrationMapper;
private final OperatorMapper operatorMapper; private final OperatorMapper operatorMapper;
private final NotificationService notificationService; 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):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。 * Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
@@ -39,16 +39,24 @@ public class ReferralService {
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站, * 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。 * 并顺带为该用户创建一条 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) { private void assertMatchmakerServesUser(String matchmakerId, String userId) {
if (userId == null || userId.isBlank()) { if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("缺少转介目标用户"); throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
} }
matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId) serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(matchmakerId, userId,
.orElseThrow(() -> new IllegalStateException( "matchmaker_referral", userId);
"您不是该用户的服务负责人,无法发起转介"));
} }
@Transactional @Transactional
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* 关系服务 * 关系服务
@@ -104,7 +105,7 @@ public class RelationshipService {
@Transactional @Transactional
public void cancelLike(String userId, String targetUserId) { public void cancelLike(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED) UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
.orElseThrow(() -> new RuntimeException("没有找到喜欢关系")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到喜欢关系"));
relationMapper.deleteById(relation.getId()); relationMapper.deleteById(relation.getId());
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId); log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
@@ -184,7 +185,7 @@ public class RelationshipService {
@Transactional @Transactional
public void unblock(String userId, String targetUserId) { public void unblock(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED) UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
.orElseThrow(() -> new RuntimeException("没有拉黑过该用户")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有拉黑过该用户"));
relationMapper.deleteById(relation.getId()); relationMapper.deleteById(relation.getId());
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId); log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
@@ -323,7 +324,7 @@ public class RelationshipService {
@Transactional @Transactional
public void unmatch(String userId, String targetUserId) { public void unmatch(String userId, String targetUserId) {
Match match = matchMapper.findActiveMatch(userId, targetUserId) Match match = matchMapper.findActiveMatch(userId, targetUserId)
.orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到有效的匹配关系"));
match.setStatus(Constants.MATCH_STATUS_UNMATCHED); match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
match.setUnmatchedAt(Instant.now()); match.setUnmatchedAt(Instant.now());
@@ -95,9 +95,9 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approve( public ApiResponse<ReviewResultDTO> approve(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
String operatorId = principal.id(); String operatorId = principal.id();
String comment = body.getOrDefault("comment", ""); String comment = (String) body.getOrDefault("comment", "");
return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment)); return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment));
} }
@@ -140,8 +140,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> needEvidence( public ApiResponse<ReviewResultDTO> needEvidence(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment"))); return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), (String) body.get("comment")));
} }
@PostMapping("/{taskId}/approve-local-eligibility") @PostMapping("/{taskId}/approve-local-eligibility")
@@ -149,8 +149,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approveLocalEligibility( public ApiResponse<ReviewResultDTO> approveLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment"))); return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
} }
@PostMapping("/{taskId}/reject-local-eligibility") @PostMapping("/{taskId}/reject-local-eligibility")
@@ -158,8 +158,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> rejectLocalEligibility( public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment"))); return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
} }
@PostMapping("/{taskId}/evidences/{evidenceId}/access") @PostMapping("/{taskId}/evidences/{evidenceId}/access")
@@ -168,9 +168,9 @@ public class ReviewController {
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId, @PathVariable String taskId,
@PathVariable String evidenceId, @PathVariable String evidenceId,
@RequestBody Map<String, String> body) { @RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence( return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
taskId, evidenceId, principal.id(), body.get("purpose"))); taskId, evidenceId, principal.id(), (String) body.get("purpose")));
} }
/** /**
@@ -32,6 +32,7 @@ import java.time.Instant;
import java.util.*; import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -280,7 +281,7 @@ public class ReviewService {
public ReviewTask resubmit(String userId) { public ReviewTask resubmit(String userId) {
// 找到最新的需修改任务 // 找到最新的需修改任务
ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId) ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId)
.orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到可重新提交的审核任务"));
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) { if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
@@ -330,9 +330,36 @@ public final class Constants {
public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker"; public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker";
public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review"; 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 本地资格审核 ========== // ========== V1.1 本地资格审核 ==========
/** 本地资格审核任务类型 */ /** 本地资格审核任务类型 */
public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility"; public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility";
/** 本地资格佐证数据类型(用于敏感访问审计) */ /** 本地资格佐证数据类型(用于敏感访问审计) */
public static final String SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE = "local_eligibility_evidence"; 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 工作台查看服务对象详情";
} }
@@ -1,12 +1,14 @@
package com.danyang.match.platform.shared.filter; package com.danyang.match.platform.shared.filter;
import com.danyang.match.platform.auth.token.JwtTokenService; 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.permission.service.PermissionService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.shared.security.principal.PlatformPrincipal; import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -33,6 +35,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService; private final JwtTokenService jwtTokenService;
private final PermissionService permissionService; private final PermissionService permissionService;
private final OperatorMapper operatorMapper;
private static final List<String> PERMITTED_PATHS = List.of( private static final List<String> PERMITTED_PATHS = List.of(
"/api/v1/auth/**", "/api/v1/auth/**",
@@ -58,12 +61,26 @@ public class JwtAuthFilter extends OncePerRequestFilter {
} }
String authHeader = request.getHeader("Authorization"); String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) { String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
} else {
// 浏览器同源请求:管理端登录后由 HttpOnly Cookie(dy_op_at)携带 access token。
// 作为 Authorization 头的兜底(抗 XSS 读取;小程序/用户端仍走 Authorization 头,不受影响)。
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("dy_op_at".equals(c.getName())) {
token = c.getValue();
break;
}
}
}
}
if (token == null) {
chain.doFilter(request, response); chain.doFilter(request, response);
return; return;
} }
String token = authHeader.substring(7);
if (!jwtTokenService.validateToken(token)) { if (!jwtTokenService.validateToken(token)) {
sendUnauthorized(response); sendUnauthorized(response);
return; return;
@@ -72,6 +89,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String subject = jwtTokenService.getUserIdFromToken(token); String subject = jwtTokenService.getUserIdFromToken(token);
String tokenType = jwtTokenService.parseToken(token).get("type", String.class); String tokenType = jwtTokenService.parseToken(token).get("type", String.class);
boolean operatorToken = "operator_access".equals(tokenType); 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 List<String> permissions = operatorToken
? permissionService.getPermissions(subject) ? permissionService.getPermissions(subject)
: List.of(); : List.of();
@@ -35,6 +35,9 @@ public class AuthorizationAuditListener {
audit.setAction("AUTHORIZATION_DENIED"); audit.setAction("AUTHORIZATION_DENIED");
audit.setResourceType("authorization"); audit.setResourceType("authorization");
audit.setResourceId(limit(String.valueOf(event.getObject()), 128)); 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()); audit.setCreatedAt(Instant.now());
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { 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.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException; 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 org.springframework.stereotype.Component;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
import org.slf4j.MDC;
/** Denies out-of-scope operator access and records the attempt before returning 403. */ /** Denies out-of-scope operator access and records the attempt before returning 403. */
@Slf4j @Slf4j
@@ -19,12 +24,28 @@ public class DataScopeGuard {
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
public void deny(String operatorId, String resourceType, String resourceId, String message) { 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(); AuditLog audit = new AuditLog();
audit.setId(UUID.randomUUID().toString()); audit.setId(UUID.randomUUID().toString());
audit.setOperatorId(operatorId); audit.setOperatorId(operatorId);
audit.setAction("DATA_SCOPE_DENIED"); audit.setAction("DATA_SCOPE_DENIED");
audit.setResourceType(resourceType); audit.setResourceType(resourceType);
audit.setResourceId(resourceId); audit.setResourceId(resourceId);
audit.setPurposeCode(purposeCode);
audit.setServiceAssignmentId(serviceAssignmentId);
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now()); audit.setCreatedAt(Instant.now());
try { try {
auditLogMapper.insert(audit); auditLogMapper.insert(audit);
@@ -34,4 +55,57 @@ public class DataScopeGuard {
} }
throw new AccessDeniedException(message); 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);
}
} }
@@ -12,10 +12,15 @@ import java.util.UUID;
/** /**
* Mock 实名认证 Provider * Mock 实名认证 Provider
* 开发/测试环境使用,生产环境替换为正式 Provider * 开发/测试环境使用,生产环境替换为正式 Provider。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code identity.verification.provider},
* 由 {@link RealIdentityVerificationProvider} 承接 {@code real} 取值;本类保留
* {@code matchIfMissing = true},未显式配置时本地默认仍是 Mock。生产必须在
* {@code application-production.yml} 显式置 {@code identity.verification.provider=real}。
*/ */
@Component @Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true) @ConditionalOnProperty(name = "identity.verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockIdentityVerificationProvider implements IdentityVerificationProvider { public class MockIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class); private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class);
@@ -0,0 +1,190 @@
package com.danyang.match.platform.verification.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 真实实名认证 Provider(生产环境使用)。
*
* <p>由配置 {@code identity.verification.provider=real} 激活;未显式配置时默认是 Mock
* (见 {@link MockIdentityVerificationProvider}),因此生产环境必须在
* {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>核验策略(fail-closed,绝不静默放行):
* <ul>
* <li>本地强校验:身份证号 GB 11643-1999 校验位(mod 11-2)。校验位不符直接判定未通过,
* 在发起任何外部调用前拦截明显伪造的证件号。</li>
* <li>第三方核验:必须配置 {@code identity.verification.real.api-url} 与
* {@code identity.verification.real.api-key}(环境变量 {@code VERIFICATION_API_URL} /
* {@code VERIFICATION_API_KEY})。未配置即 fail-fast —— 真实核验无法凭空完成,
* 绝不能回退成「放行」。</li>
* <li>任何无法判定为明确的「核验通过」的响应,一律按「未通过」处理(fail-closed)。</li>
* <li>绝不把身份证号原文作为日志明文输出;仅记录脱敏值。</li>
* </ul>
*
* <p>对接说明:第三方请求/响应契约需在接入具体厂商(如公安实名、运营商三要素)时最终确认。
* 当前实现以「明确的 success/match/verified/passed 布尔为 true」判定通过,其余一律视为未通过。
*/
@Component
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "real")
public class RealIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(RealIdentityVerificationProvider.class);
/** 身份证号校验位权重(GB 11643-1999,前 17 位) */
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
/** 校验位映射:索引 = 加权和 mod 11;'X' 表示余数 2 */
private static final String CHECK_CODES = "10X98765432";
@Value("${identity.verification.real.api-url:}")
private String apiUrl;
@Value("${identity.verification.real.api-key:}")
private String apiKey;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, Object> verify(String realName, String idNumber) {
if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意证件
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "实名核验未配置第三方核验服务");
}
if (realName == null || realName.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "实名认证失败:缺少姓名");
}
// 1. 本地校验位强校验:伪造证件号在外部调用前即拒绝
if (!isValidChecksum(idNumber)) {
log.warn("[Identity] 身份证校验位不合法: idNumber={}", maskIdNumber(idNumber));
return buildResult(false, idNumber, realName, null, null, UUID.randomUUID().toString());
}
LocalDate birthDate = parseBirthDateFromIdNumber(idNumber);
String gender = parseGenderFromIdNumber(idNumber);
// 2. 第三方核验(fail-closed)
boolean matched = callThirdParty(realName, idNumber);
return buildResult(matched, idNumber, realName, birthDate.toString(), gender, UUID.randomUUID().toString());
}
/**
* 调用第三方实名核验。仅当响应中明确出现「通过」语义的布尔字段为 true 时才返回 true;
* 其余(缺字段、字段为 false、解析失败、网络异常)一律返回 false(fail-closed)。
*/
private boolean callThirdParty(String realName, String idNumber) {
try {
Map<String, Object> body = new HashMap<>();
body.put("realName", realName);
body.put("idNumber", idNumber);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> resp = restTemplate.postForEntity(apiUrl, entity, String.class);
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
log.warn("[Identity] 第三方核验返回非成功状态: status={}", resp.getStatusCode());
return false;
}
JsonNode node = objectMapper.readTree(resp.getBody());
return isExplicitlyVerified(node);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Identity] 第三方核验调用异常", e);
return false;
}
}
/** 仅在响应中明确的「通过」布尔字段为 true 时判定通过。 */
private boolean isExplicitlyVerified(JsonNode node) {
for (String field : new String[]{"success", "match", "verified", "passed", "isValid"}) {
JsonNode v = node.get(field);
if (v != null && v.isBoolean() && v.asBoolean()) {
return true;
}
}
// 兼容 { "code": 0, "message": "ok" } 形态
JsonNode code = node.get("code");
if (code != null && code.isNumber() && code.asInt() == 0) {
return true;
}
return false;
}
private Map<String, Object> buildResult(boolean verified, String idNumber, String realName,
String birthDate, String gender, String requestId) {
Map<String, Object> result = new HashMap<>();
result.put("verified", verified);
result.put("realName", realName);
result.put("idNumberHash", idNumber);
result.put("birthDate", birthDate);
result.put("gender", gender);
result.put("requestId", requestId);
return result;
}
@Override
public boolean isMock() {
return false;
}
/** GB 11643-1999 校验位(mod 11-2)。 */
private boolean isValidChecksum(String idNumber) {
if (idNumber == null || idNumber.length() != 18) {
return false;
}
if (!idNumber.substring(0, 17).matches("\\d{17}")) {
return false;
}
char last = idNumber.charAt(17);
if (!(Character.isDigit(last) || last == 'X' || last == 'x')) {
return false;
}
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += (idNumber.charAt(i) - '0') * WEIGHTS[i];
}
char expected = CHECK_CODES.charAt(sum % 11);
return Character.toUpperCase(last) == expected;
}
private LocalDate parseBirthDateFromIdNumber(String idNumber) {
try {
int year = Integer.parseInt(idNumber.substring(6, 10));
int month = Integer.parseInt(idNumber.substring(10, 12));
int day = Integer.parseInt(idNumber.substring(12, 14));
return LocalDate.of(year, month, day);
} catch (Exception e) {
return LocalDate.now().minusYears(25);
}
}
private String parseGenderFromIdNumber(String idNumber) {
int genderDigit = Integer.parseInt(idNumber.substring(16, 17));
return (genderDigit % 2 == 1) ? "male" : "female";
}
private String maskIdNumber(String idNumber) {
if (idNumber == null || idNumber.length() < 10) return "****";
return idNumber.substring(0, 6) + "**********" + idNumber.substring(idNumber.length() - 4);
}
}
@@ -24,6 +24,7 @@ import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException; import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -136,7 +137,7 @@ public class IdentityVerificationService {
*/ */
public Map<String, Object> getIdentityStatus(String userId) { public Map<String, Object> getIdentityStatus(String userId) {
User user = userMapper.findById(userId) User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
return userIdentityMapper.findByUserId(userId) return userIdentityMapper.findByUserId(userId)
.map(identity -> { .map(identity -> {
@@ -212,7 +213,7 @@ public class IdentityVerificationService {
@Transactional @Transactional
public void cancelIdentity(String userId) { public void cancelIdentity(String userId) {
UserIdentity identity = userIdentityMapper.findByUserId(userId) UserIdentity identity = userIdentityMapper.findByUserId(userId)
.orElseThrow(() -> new RuntimeException("未找到实名认证记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到实名认证记录"));
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) { if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销"); throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
@@ -38,10 +38,17 @@ object-storage:
region: us-east-1 region: us-east-1
signature-version: s3v4 signature-version: s3v4
verification: # 实名核验:docker 本地/CI 默认走 Mock(VERIFICATION_PROVIDER=real 时启用真实实现,需同时配置 api-url/api-key)
provider: ${VERIFICATION_PROVIDER:mock} identity:
api-url: ${VERIFICATION_API_URL:} verification:
api-key: ${VERIFICATION_API_KEY:} provider: ${VERIFICATION_PROVIDER:mock}
real:
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:docker 内部为 http,关闭 Secure 标志
app:
auth-cookie-secure: false
match: match:
rule-version: 1 rule-version: 1
@@ -14,3 +14,27 @@ spring:
user: user:
name: disabled name: disabled
password: ${SPRING_SECURITY_FALLBACK_PASSWORD} password: ${SPRING_SECURITY_FALLBACK_PASSWORD}
# 微信登录:生产必须显式启用真实实现(wechat.login.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealWechatLoginProvider 不会激活、
# MockWechatLoginProvider 又因 havingValue=mock 不匹配 → 无 WechatLoginProvider Bean,启动即失败(fail-fast,不悄悄放行任意 code)。
wechat:
login:
provider: real
app-id: ${WECHAT_APP_ID}
app-secret: ${WECHAT_APP_SECRET}
# 实名核验:生产必须显式启用真实实现(identity.verification.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealIdentityVerificationProvider 不会激活、
# MockIdentityVerificationProvider 又因 havingValue=mock 不匹配 → 无 IdentityVerificationProvider Bean,启动即失败(fail-fast,不悄悄放行任意证件)。
# 真实核验还需配置第三方核验服务地址与密钥(缺失则核验请求 fail-fast)。
identity:
verification:
provider: real
real:
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:生产走 https,开启 Secure 标志(HttpOnly + SameSite=Lax 始终生效)
app:
auth-cookie-secure: true
@@ -0,0 +1,28 @@
-- ============================================================
-- V052: 补齐 matchmaker(公益红娘)角色权限
--
-- 发现方式:2026-10-09 搭建本地演示栈、创建红娘运营账号 wanghongniang 并授予
-- matchmaker 角色后发现,该角色在 role_permissions 中零条授权。
-- 用该账号登录后台,点「红娘服务」菜单加载 GET /operator/matchmaker/dashboard
-- 直接 403;同时前端菜单权限码(referral.manage / matchmaker.service.view)与后端
-- 真实 authority 码(matchmaker.service / matchmaker.view)不匹配,导致这两个菜单
-- 对所有角色(含 super_admin)被 visibleNavigation 过滤隐藏。
--
-- 根因:与 V043/V044 同族 —— 角色定义了、权限定义了,两者之间从未连上。
-- V015 只把全部权限授予 super_admin;matchmaker 等运营角色从未被授予任何权限。
--
-- 授权范围(最小权限原则,与后端 @PreAuthorize 严格对应):
-- * matchmaker.view —— 查看红娘服务(GET /operator/matchmaker/*)
-- * matchmaker.service —— 认领/推荐/跟进/完成红娘服务 + 转介管理(referral 控制器)
-- * matchmaker.assign —— 分配红娘服务
-- 后端 MatchmakerOperatorController / ReferralController 的 @PreAuthorize 仅要求这三条,
-- 故仅授予这三条,不扩权。
--
-- 幂等:ON CONFLICT (role_id, permission_id) DO NOTHING。
-- ============================================================
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000000-0000-0000-0000-000000000005', id
FROM permissions
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.assign')
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -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,92 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.service.ActivityInterestService;
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 活动互选确认的**行为型**回归守卫(INV-014 / INV-015)。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)发现遗留端点
* {@code POST /activities/{id}/interests/confirm} 仅校验「双向互选」就直调
* {@code RelationshipService.confirmActivityInterest} —— 后者是**不做任何同意校验**的
* 「建 Match」底层原语(见其实现:查无 active Match 即 createMatch)——
* 于是**单方调用即可创建 Match 并开启会话**,绕过「双方各自显式确认」的二阶段同意。
*
* <p>修复后本端点与 {@code /activities/{id}/mutual/*} 共用
* {@link ActivityMutualConsentService}:本端点只登记「我方确认」,
* 只有双方都 accepted 才建 Match。本测试把这一接线钉死 ——
* 若有人把直连 {@code confirmActivityInterest} 的捷径改回来,这里会红。
*/
class ActivityInterestConfirmConsentBehaviorTest {
private ActivityInterestService interestService;
private ActivityMutualConsentService consentService;
private ActivityInterestController controller;
private static final String ACTIVITY = "act-1";
private static final String ME = "u-a";
private static final String PARTNER = "u-b";
@BeforeEach
void setUp() {
interestService = mock(ActivityInterestService.class);
consentService = mock(ActivityMutualConsentService.class);
controller = new ActivityInterestController(interestService, consentService, mock(ActivityMapper.class));
}
@Test
@DisplayName("INV-014/015:单方调用 confirm 只登记『我方确认』,不得直接建 Match")
void legacyConfirmOnlyRecordsMyOwnConsent() {
when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(true);
// 二阶段同意服务回「对方尚未确认」→ 不得建 Match
when(consentService.confirmConnection(ME, ACTIVITY, PARTNER))
.thenReturn(Map.of("myStatus", "accepted", "partnerStatus", "pending",
"bothConfirmed", false, "matchCreated", false));
ApiResponse<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER));
assertTrue(resp.isSuccess(), "调用本身应成功(我方确认已登记)");
assertEquals(Boolean.FALSE, resp.getData().get("matchCreated"),
"对方未确认时不得创建 Match —— 单方不可开启沟通");
assertEquals(Boolean.FALSE, resp.getData().get("bothConfirmed"));
// 关键接线:必经二阶段同意服务(幂等建档 + 提交我方确认)
verify(consentService).createMutualConsent(ACTIVITY, ME, PARTNER);
verify(consentService).confirmConnection(ME, ACTIVITY, PARTNER);
}
@Test
@DisplayName("判别力:无双向互选时直接拒绝,且完全不触碰同意服务")
void noMutualInterestIsRejectedWithoutTouchingConsent() {
when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(false);
ApiResponse<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER));
assertFalse(resp.isSuccess(), "无双向互选不得进入确认环节");
verifyNoInteractions(consentService);
}
@Test
@DisplayName("partnerId 为空 → 400 语义拒绝,且不触碰同意服务")
void blankPartnerIdIsRejected() {
ApiResponse<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", " "));
assertFalse(resp.isSuccess());
verifyNoInteractions(consentService);
verify(interestService, never()).hasMutualInterest(anyString(), anyString(), anyString());
}
}
@@ -0,0 +1,98 @@
package com.danyang.match.platform.auth.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
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 org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:刷新 Token 在 Redis 中登记的 TTL 必须与 refresh JWT 自身有效期一致
* ({@code JwtTokenService#getRefreshExpirationMs},默认 14 天),而不是
* {@code getExpirationMs() * 2}(4 小时)。
*
* <p>此前 AuthService 用 {@code getExpirationMs() * 2} 写入,导致 4h 后 Redis 记录消失、
* 刷新即失效,用户被迫频繁重新登录。本测试钉死两处写入点(登录 / 刷新)。
*/
@ExtendWith(MockitoExtension.class)
class AuthServiceRefreshTtlTest {
@Mock private UserMapper userMapper;
@Mock private WechatLoginProvider wechatLoginProvider;
@Mock private JwtTokenService jwtTokenService;
@Mock private StringRedisTemplate redisTemplate;
@Mock private com.danyang.match.platform.audit.mapper.AuditLogMapper auditLogMapper;
@Mock private com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper localEligibilityMapper;
@Mock private com.danyang.match.platform.capability.service.CapabilityService capabilityService;
@Mock private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
@Mock private com.danyang.match.platform.review.mapper.ReviewTaskMapper reviewTaskMapper;
@Mock private ValueOperations<String, String> valueOps;
@InjectMocks private AuthService authService;
private static final long ACCESS_MS = 7_200_000L; // 2h
private static final long REFRESH_MS = 1_209_600_000L; // 14d
/** 捕获 refresh token 在 Redis 中的登记 TTL。 */
private Duration captureRefreshTtl() {
ArgumentCaptor<Duration> cap = ArgumentCaptor.forClass(Duration.class);
verify(valueOps).set(eq("auth:refresh_token:u1"), anyString(), cap.capture());
return cap.getValue();
}
@Test
void wechatLoginStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(wechatLoginProvider.getCodeSession("code")).thenReturn(Map.of("openid", "o1"));
User user = new User();
user.setId("u1");
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
user.setPhone(null);
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
when(auditLogMapper.insert(any())).thenReturn(1);
when(userMapper.updateById(any())).thenReturn(1);
authService.wechatLogin("code");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "登录:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
@Test
void refreshTokenStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(jwtTokenService.validateRefreshToken("rt")).thenReturn(true);
when(jwtTokenService.getUserIdFromRefreshToken("rt")).thenReturn("u1");
when(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt");
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh");
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
authService.refreshToken("u1", "rt");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "刷新:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
}
@@ -32,7 +32,11 @@ class MigrationValidationTest {
// V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一 // V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一
// —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled), // —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled),
// 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭) // 写入 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 四列(全可空、存量不回填、幂等)
private static final int LATEST_MIGRATION = 55;
private static final String MIGRATION_DIR = private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/"; "backend/src/main/resources/db/migration/";
@@ -44,7 +44,8 @@ class RecommendationEligibilityPhase06Test {
void setUp() { void setUp() {
config = new MatchRuleConfigProperties(); config = new MatchRuleConfigProperties();
service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper, service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper,
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper); config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper,
new MatchScoringService());
} }
@Test @Test
@@ -0,0 +1,118 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
/**
* 评分唯一事实源的行为型测试。
*
* <p>存在意义:修复「推荐恒空」(2026-09-23 UAT 走查)之前的桩 `calculateScore()=50`
* 与阈值 60 使「满足条件的候选」也被丢弃,而既有测试只验阈值数学、从不验真实评分能达标 →
* 缺陷长期漏网。本测试直接断言**满足条件的候选评分 ≥ 阈值**。
*/
class MatchScoringServiceTest {
private final MatchScoringService svc = new MatchScoringService();
private final ObjectMapper mapper = new ObjectMapper();
private static final int THRESHOLD = 60; // 与 match.default-score-threshold 默认值一致
private UserProfile profile(int ageYears, Integer heightCm, String region, String marital) {
UserProfile p = new UserProfile();
p.setBirthDate(LocalDate.now().minusYears(ageYears));
p.setHeightCm(heightCm);
p.setRegionCode(region);
p.setMaritalStatus(marital);
return p;
}
@Test
@DisplayName("修复回归:完全符合条件的候选,评分必须 ≥ 阈值(不再恒 50)")
void matchingCandidateScoresAtLeastThreshold() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
pref.setMinHeightCm(160);
pref.setMaxHeightCm(175);
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
UserProfile candidate = profile(30, 168, "321181", "never_married");
double score = svc.scoreDirectionally(pref, candidate);
assertTrue(score >= THRESHOLD,
"满足全部条件的候选必须过阈值,实际:" + score);
}
@Test
@DisplayName("反向:条件明显不符的候选,评分低于阈值(证明有判别力)")
void nonMatchingCandidateBelowThreshold() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
pref.setMinHeightCm(160);
pref.setMaxHeightCm(175);
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
// 年龄 48(远超)、身高 150(不足)、地区 320500、离异 —— 四个维度全不符
UserProfile candidate = profile(48, 150, "320500", "divorced");
double score = svc.scoreDirectionally(pref, candidate);
assertTrue(score < THRESHOLD, "全不符的候选必须被过滤,实际:" + score);
}
@Test
@DisplayName("未设置任何条件 → 视为无约束,返回 100")
void noCriteriaMeansNoConstraint() {
assertEquals(100.0, svc.scoreDirectionally(new UserPreference(), profile(30, 170, "321181", "never_married")),
0.001);
}
@Test
@DisplayName("候选人身高缺失 → 身高维度计 0(不假定满足),且不阻断其他维度")
void missingCandidateHeightCountsZero() {
UserPreference pref = new UserPreference();
pref.setMinHeightCm(170);
pref.setMaxHeightCm(180);
UserProfile candidate = profile(30, null, "321181", "never_married");
double score = svc.scoreDirectionally(pref, candidate);
assertEquals(0.0, score, 0.001, "唯一条件是身高而资料缺失 → 0 分");
}
@Test
@DisplayName("INV-001:双向 Required 任一不满足即否")
void bidirectionalRequiredEnforced() {
UserPreference myPref = new UserPreference();
myPref.setAgePriority("required");
myPref.setMinAge(25);
myPref.setMaxAge(30);
UserPreference targetPref = new UserPreference(); // 无 Required
UserProfile me = profile(30, 175, "321181", "never_married");
UserProfile inRange = profile(28, 170, "321181", "never_married");
UserProfile outOfRange = profile(35, 170, "321181", "never_married");
assertTrue(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, inRange));
assertFalse(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, outOfRange));
}
@Test
@DisplayName("推荐理由基于实际达成维度,且非空")
void reasonsReflectMatchedDimensions() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
var reasons = svc.reasons(pref, profile(30, 170, "321181", "never_married"));
assertFalse(reasons.isEmpty());
assertTrue(reasons.stream().anyMatch(r -> r.contains("年龄")), "应包含年龄维度理由:" + reasons);
}
}
@@ -0,0 +1,196 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
import com.danyang.match.platform.match.dto.RecommendationCandidateDTO;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
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.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 推荐链路的<b>行为型</b>回归守卫。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)在本机真实栈实测
* {@code GET /recommendations} 恒返回 {@code {"data":[]}}。根因是评分桩
* {@code calculateScore()=50} 而阈值默认 60({@code 50 < 60} 恒真)→ 所有候选被丢弃。
* 该缺陷逃过了 757 个后端用例 + 65 个 E2E + 22 个 UI 守卫,因为
* <b>没有任何用例断言「推荐非空」</b>(E2E 零推荐用例)。
*
* <p>本测试在<b>引擎层</b>(候选池查询 → 双向 Required → 评分 → 阈值过滤 → 排序 → 落库)
* 断言「存在合格候选时必须产出非空推荐」,并配一条反向用例证明其有判别力。
* 这是此类「恒空」缺陷的直接防线 —— 桩式/纯配置断言抓不到它。
*/
class RecommendationEngineServiceBehaviorTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String ME = "u-me";
private static final String CANDIDATE = "u-cand";
private UserProfileMapper userProfileMapper;
private UserPreferenceMapper preferenceMapper;
private RecommendationRecordMapper recommendationRecordMapper;
private MatchRuleConfigProperties matchConfig;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private UserRelationMapper relationMapper;
private UserMapper userMapper;
private UserPrivacySettingsMapper privacySettingsMapper;
private LocalEligibilityMapper localEligibilityMapper;
private RecommendationEngineService engine;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
userProfileMapper = mock(UserProfileMapper.class);
preferenceMapper = mock(UserPreferenceMapper.class);
recommendationRecordMapper = mock(RecommendationRecordMapper.class);
matchConfig = mock(MatchRuleConfigProperties.class);
redisTemplate = mock(StringRedisTemplate.class);
valueOps = mock(ValueOperations.class);
relationMapper = mock(UserRelationMapper.class);
userMapper = mock(UserMapper.class);
privacySettingsMapper = mock(UserPrivacySettingsMapper.class);
localEligibilityMapper = mock(LocalEligibilityMapper.class);
// 用**真实**评分实现(纯函数、无依赖)→ 端到端证明「真实评分能让推荐非空」。
// 若用 mock 掉评分,本测试就退化成「只验接线」,无法守住「评分恒 50」这类回归。
MatchScoringService scoring = new MatchScoringService();
engine = new RecommendationEngineService(
userProfileMapper, preferenceMapper, recommendationRecordMapper,
matchConfig, redisTemplate, relationMapper, userMapper,
privacySettingsMapper, localEligibilityMapper, scoring);
// —— 匹配规则配置(与 application-dev.yml 默认一致)——
when(matchConfig.getDefaultDailyLimit()).thenReturn(6);
when(matchConfig.getDefaultScoreThreshold()).thenReturn(60);
when(matchConfig.getDefaultCooldownDays()).thenReturn(180);
when(matchConfig.getRuleVersion()).thenReturn(1);
when(matchConfig.isLocalEligibilityRequired()).thenReturn(false);
// —— Redis:缓存未命中 ——
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.get(anyString())).thenReturn(null);
// —— 双方账号均正常、可被推荐、无隐私限制 ——
when(userMapper.findById(ME)).thenReturn(Optional.of(user(ME)));
when(userMapper.findById(CANDIDATE)).thenReturn(Optional.of(user(CANDIDATE)));
when(privacySettingsMapper.findByUserId(anyString())).thenReturn(Optional.empty());
// —— 资料 ——
when(userProfileMapper.findByUserId(ME)).thenReturn(Optional.of(myProfile()));
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(candidateProfile()));
when(userProfileMapper.selectList(any())).thenReturn(List.of(candidateProfile()));
// —— 择偶条件:自己设了条件;候选人未设(方向 B→A 不受约束)——
when(preferenceMapper.findByUserId(ME)).thenReturn(Optional.of(myPreference()));
when(preferenceMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(new UserPreference()));
// —— 无历史推荐 / 无 Pass / 无拉黑 ——
when(recommendationRecordMapper.findRecentByUserId(anyString(), any())).thenReturn(List.of());
when(relationMapper.findBlockedByUser(anyString())).thenReturn(List.of());
when(relationMapper.findByRelation(anyString(), anyString(), anyString())).thenReturn(Optional.empty());
}
@Test
@DisplayName("回归守卫:存在合格候选 → 推荐必须非空(此前评分桩 50<60 导致恒空)")
void recommendationsAreNotEmptyWhenEligibleCandidateExists() {
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
assertFalse(result.isEmpty(),
"存在满足条件的候选人时,推荐不得为空 —— 这正是 UAT 实测暴露的缺陷");
assertEquals(CANDIDATE, result.get(0).getUserId());
assertNotNull(result.get(0).getRecommendationReasons());
assertFalse(result.get(0).getRecommendationReasons().isEmpty(), "应带中文推荐理由");
assertEquals("high", result.get(0).getRecommendationLevel(),
"完全匹配的候选应落在 high 档(评分 100)");
}
@Test
@DisplayName("判别力:候选各维度均不符 → 被阈值过滤,推荐为空")
void ineligibleCandidateIsFilteredOut() {
// 候选人年龄 48 / 身高 150 / 地区 320500 / 离异 —— 与择偶条件全不符
UserProfile bad = candidateProfile();
bad.setBirthDate(LocalDate.now().minusYears(48));
bad.setHeightCm(150);
bad.setRegionCode("320500");
bad.setMaritalStatus("divorced");
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(bad));
when(userProfileMapper.selectList(any())).thenReturn(List.of(bad));
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
assertTrue(result.isEmpty(), "全维度不符的候选必须被阈值过滤(证明评分确有判别力)");
}
// ==================== fixtures ====================
private User user(String id) {
User u = new User();
u.setId(id);
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
u.setRecommendationEnabled(true);
return u;
}
private UserProfile myProfile() {
UserProfile p = new UserProfile();
p.setUserId(ME);
p.setGender("male");
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
p.setBirthDate(LocalDate.now().minusYears(33));
p.setHeightCm(175);
p.setRegionCode("321181");
p.setMaritalStatus("never_married");
return p;
}
private UserProfile candidateProfile() {
UserProfile p = new UserProfile();
p.setUserId(CANDIDATE);
p.setGender("female");
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
p.setBirthDate(LocalDate.now().minusYears(30));
p.setHeightCm(168);
p.setRegionCode("321181");
p.setMaritalStatus("never_married");
return p;
}
/** 四个维度均设条件,且默认权重(age .15 / height .05 / region .20 / marital .10)。 */
private UserPreference myPreference() {
UserPreference p = new UserPreference();
p.setMinAge(25);
p.setMaxAge(35);
p.setMinHeightCm(160);
p.setMaxHeightCm(175);
p.setRegionScope(MAPPER.valueToTree(new String[]{"321181"}));
p.setMaritalStatuses(MAPPER.valueToTree(new String[]{"never_married"}));
return p;
}
}
@@ -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,257 @@
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 联系规则护栏)+ ZC-MM-004 佐证 —— standalone 源码扫描守卫
* (沿用 {@code MatchmakerFieldPrivacyGuardTest} 范式,无需 Spring 上下文)。
*
* <p>锁定四条不变量(架构师设计 T01):
* <ol>
* <li><b>MM-CONTACT-002</b>:ReferralService 全部响应 put 键 ⊆ 白名单(逐键对照实际代码
* 核定,见 {@link #RESPONSE_KEY_WHITELIST}),且键名不得含 emergencyContact / phone /
* mobile / wechat / address / idNumber 等联系方式字样 —— 锁定「红娘侧零联系方式回显」;
* emergencyContact* 是纯入口 write-only(写入 activity_registration 的紧急联系人字段),
* 连红娘本人都无读回面。</li>
* <li><b>MM-CONTACT-003</b>:matchmaker / referral Controller 无 contact / call / phone /
* channel / offline 类端点;全仓无 ContactAuthorization 类、无
* matchmaker_contact_authorizations 建表迁移 —— 锁定「不存在联系授权机制」。
* 契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新,守卫即红。</li>
* <li><b>MM-CONTACT-004</b>:无联系授权机制即无授权到期场景 —— 与 MM-CONTACT-003 同源断言
* 承载(若未来引入 ContactAuthorization / matchmaker_contact_authorizations,
* 003/004 两条守卫同时判红,迫使开发者补「到期 + 审计」语义并过 007 评审)。</li>
* <li><b>ZC-MM-004 佐证</b>:matchmaker / referral 包无 @Scheduled —— 全仓无批处理
* 代操作面(禁止代替用户决定的自动化通道不存在)。</li>
* </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 List<Path> CONTROLLER_DIRS = List.of(
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller"),
SRC_ROOT.resolve("com/danyang/match/platform/referral/controller"));
private static final List<Path> PACKAGE_DIRS = List.of(
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker"),
SRC_ROOT.resolve("com/danyang/match/platform/referral"));
/**
* ReferralService 响应键白名单(ZC-MM-003 / T01:以实际代码逐键核对后核定):
* <ul>
* <li>referToServiceStation → appointmentId / status / referralType /
* referralFromOperatorId / needsUserConfirmation</li>
* <li>referToActivity → registrationId / activityId / status / referralType /
* referralFromOperatorId / needsUserConfirmation</li>
* <li>confirmReferral → id / status / confirmedAt</li>
* <li>getReferralHistory → type / id / serviceType|activityId / status /
* createdByOperatorId / userConfirmedAt / createdAt|registeredAt</li>
* <li>getReferralHistoryByOperator → 同上 + userId(operator 查看本人发起的
* 转介记录需要 userId 标识服务对象;userId 是内部标识符,非联系方式,
* 不落入 B1 禁放范畴)</li>
* </ul>
*/
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");
/** 联系方式字样(出现在响应键名中即违规;emergencyContact* 仅允许作为入口 write-only 存在) */
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
"emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
"idnumber", "id_card");
/** MM-CONTACT-003:联系/触达类禁用端点词 */
private static final List<String> FORBIDDEN_ENDPOINT_TOKENS = List.of(
"contact", "call", "phone", "channel", "offline");
// ==================== MM-CONTACT-002:红娘侧零联系方式回显 ====================
@Test
@DisplayName("MM-CONTACT-002: ReferralService 全部响应 put 键 ⊆ 白名单,无任何联系方式回显")
void referralResponseKeysAreWhitelistedWithNoContactEcho() throws IOException {
String src = stripComments(Files.readString(REFERRAL_SERVICE, StandardCharsets.UTF_8));
Set<String> keys = new LinkedHashSet<>();
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(src);
while (put.find()) {
keys.add(put.group(1));
}
// 防止"装配被搬空后守卫永真":核心语义键必须仍在
assertTrue(keys.contains("needsUserConfirmation"),
"must-confirm 语义键必须存在(守卫非永真自检)");
assertTrue(keys.contains("referralFromOperatorId"),
"来源追溯键必须存在(守卫非永真自检)");
assertTrue(keys.contains("status"), "状态键必须存在(守卫非永真自检)");
List<String> violations = new ArrayList<>();
for (String key : keys) {
if (!RESPONSE_KEY_WHITELIST.contains(key)) {
violations.add("非白名单响应键: " + key);
}
String lower = key.toLowerCase();
for (String token : FORBIDDEN_KEY_TOKENS) {
if (lower.contains(token)) {
violations.add("响应键含联系方式 token: " + key + "(含 " + token + ")");
}
}
}
assertEquals(List.of(), violations,
"B1 联系规则契约被破坏(MM-CONTACT-002):响应键出现白名单外键或联系方式字样。\n"
+ "实际输出键=" + keys + "\n违规项:\n - " + String.join("\n - ", violations));
}
// ==================== MM-CONTACT-003:无联系端点 / 无授权机制 ====================
@Test
@DisplayName("MM-CONTACT-003: matchmaker/referral Controller 无 contact 类端点;全仓无 ContactAuthorization 机制")
void noContactEndpointsAndNoContactAuthorizationMechanism() throws IOException {
List<String> violations = new ArrayList<>();
// a) 端点扫描:matchmaker / referral Controller 无联系/触达类路径
for (Path dir : CONTROLLER_DIRS) {
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
try (Stream<Path> files = Files.list(dir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
String src = Files.readString(f, StandardCharsets.UTF_8);
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 : FORBIDDEN_ENDPOINT_TOKENS) {
if (args.contains(banned)) {
violations.add(f.getFileName() + " → " + mapping.group(0)
.replaceAll("\\s+", " ") + " 含联系类端点: " + banned);
}
}
}
}
}
}
// b) 机制扫描:全仓 main 源码无 ContactAuthorization 类;迁移目录无建表
violations.addAll(scanForContactAuthorizationMechanism());
assertEquals(List.of(), violations,
"B1 联系规则契约被破坏(MM-CONTACT-003):出现联系类端点或联系授权机制。\n"
+ "契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新。\n违规项:\n - "
+ String.join("\n - ", violations));
}
// ==================== MM-CONTACT-004:无机制即无到期场景 ====================
@Test
@DisplayName("MM-CONTACT-004: 无联系授权机制 ⇒ 不存在授权到期场景(同源断言承载)")
void noContactAuthorizationMechanismImpliesNoExpiryScenario() throws IOException {
/*
* ZC-MM-003(B1)「临时授权到期」场景的守卫:授权机制本身不存在(MM-CONTACT-003),
* 因此不存在"到期后权限回收"场景。本用例与 MM-CONTACT-003 共享同一底层断言 ——
* 若未来引入 ContactAuthorizationPolicy / matchmaker_contact_authorizations 表,
* 两条守卫同时判红,迫使实现者补齐「授权期限 + 到期回收 + 审计」语义,
* 并同步 007 治理登记(安全默认 = 不开放完整联系方式 / 未授权线下联系)。
*/
List<String> violations = scanForContactAuthorizationMechanism();
assertEquals(List.of(), violations,
"MM-CONTACT-004:联系授权机制出现 ⇒ 必须同步实现到期回收与审计,并过 007 评审。\n违规项:\n - "
+ String.join("\n - ", violations));
}
// ==================== ZC-MM-004 佐证:无批处理代操作面 ====================
@Test
@DisplayName("ZC-MM-004 佐证: matchmaker/referral 包无 @Scheduled(无批处理代操作通道)")
void noScheduledTasksInMatchmakerAndReferralPackages() throws IOException {
List<String> violations = new ArrayList<>();
for (Path dir : PACKAGE_DIRS) {
assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir);
try (Stream<Path> files = Files.walk(dir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
if (src.contains("@Scheduled")) {
violations.add(f.getFileName() + " 含 @Scheduled 定时任务"
+ "(ZC-MM-004:禁止引入代替用户决定/代操作的自动化通道)");
}
}
}
}
assertEquals(List.of(), violations,
"红娘/转介域出现定时任务必须先过「禁止代用户决定」评审(ZC-MM-004)");
}
// ==================== 解析实现 ====================
/** 全仓 main 源码 + 迁移脚本扫描 ContactAuthorization 机制(003/004 共享)。 */
private List<String> scanForContactAuthorizationMechanism() throws IOException {
List<String> violations = new ArrayList<>();
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
String name = f.getFileName().toString();
if (name.contains("ContactAuthorization")) {
violations.add("出现联系授权类(机制被引入): " + name);
continue;
}
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
if (src.contains("ContactAuthorization")) {
violations.add("源码引用联系授权机制: " + f.getFileName());
}
}
}
for (Path migrationDir : migrationDirCandidates()) {
if (!Files.isDirectory(migrationDir)) {
continue;
}
try (Stream<Path> files = Files.list(migrationDir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".sql")).toList()) {
String src = Files.readString(f, StandardCharsets.UTF_8).toLowerCase();
if (src.contains("matchmaker_contact_authorizations")) {
violations.add("出现 matchmaker_contact_authorizations 建表迁移: " + f.getFileName()
+ "(安全默认=不开放;仅当产品确认开放联系功能时随 006 评审引入)");
}
}
}
}
return violations;
}
/** 迁移脚本目录(兼容仓库根与单模块两种布局)。 */
private List<Path> migrationDirCandidates() {
Path parent = SRC_ROOT.getParent();
return List.of(
parent != null ? parent.resolve("resources/db/migration") : Path.of("backend/src/main/resources/db/migration"),
Path.of("backend/src/main/resources/db/migration"),
Path.of("src/main/resources/db/migration"));
}
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 com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper;
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper; @Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
@Autowired private MatchmakerRecommendationMapper recommendationMapper; @Autowired private MatchmakerRecommendationMapper recommendationMapper;
@Autowired private com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper matchmakerAssignmentMapper;
@Autowired private MatchmakerService matchmakerService; @Autowired private MatchmakerService matchmakerService;
@Autowired private MatchmakerServiceService matchmakerServiceService; @Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private ReferralService referralService; @Autowired private ReferralService referralService;
@@ -73,6 +74,19 @@ class MatchmakerReferralPhase13Test {
target = createUser("mm-target"); target = createUser("mm-target");
// 一条归属 MATCHMAKER_OWNER 的红娘服务 // 一条归属 MATCHMAKER_OWNER 的红娘服务
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED); 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: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)") @DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)")
void otherMatchmakerCannotReferUser() { void otherMatchmakerCannotReferUser() {
// MATCHMAKER_OTHER 与该用户之间没有任何服务关系 // 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( () -> referralService.referToActivity(
MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"), MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"),
"非该用户服务负责人的红娘不得发起转介"); "非该用户服务负责人的红娘不得发起转介(404 化拒绝)");
assertThrows(IllegalStateException.class, assertThrows(com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
() -> referralService.referToServiceStation( () -> referralService.referToServiceStation(
MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null), MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null),
"非该用户服务负责人的红娘不得发起服务站转介"); "非该用户服务负责人的红娘不得发起服务站转介(404 化拒绝)");
} }
@Test @Test
@DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)") @DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)")
void otherMatchmakerCannotRecommend() { 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( () -> matchmakerServiceService.recommendPartner(
serviceId, MATCHMAKER_OTHER, target, "越权推荐"), serviceId, MATCHMAKER_OTHER, target, "越权推荐"),
"非服务负责人不得添加推荐"); "非服务负责人不得添加推荐(404 化拒绝)");
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(), assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
"越权推荐失败后不得留下任何推荐记录"); "越权推荐失败后不得留下任何推荐记录");
@@ -337,21 +356,15 @@ class MatchmakerReferralPhase13Test {
// ==================== 计划项 3:红娘只读摘要,不读敏感正文 ==================== // ==================== 计划项 3:红娘只读摘要,不读敏感正文 ====================
@Test @Test
@DisplayName("计划项3/D4: 非负责人的 operator 读详情只得到脱敏视图(无 userProfile / 无 followup 正文)") @DisplayName("计划项3/D4+ZC-MM-005 E2: 非持有人读详情 → 防探测 404(与「查无」一致,无脱敏双态)")
void nonOwnerSeesSanitizedDetail() { void nonOwnerReadsDetailIsRejectedAsNotFound() {
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER); // ZC-MM-005(E1+E2):002 的"非负责人脱敏详情"双态已删除;
// 非持有人读任意 serviceId 得到与"查无"逐字段一致的 404,防枚举探测。
assertNotNull(detail); // 读侧不再走 403(写接口维持 403,见 assertServiceAccess 口径)。
assertEquals(Boolean.FALSE, detail.get("viewerIsOwner"), org.junit.jupiter.api.Assertions.assertThrows(
"非负责人必须被标记为非 owner"); com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException.class,
assertFalse(detail.containsKey("userProfile"), () -> matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER),
"INV-013 同类口径:非负责人不得读取服务对象的用户资料"); "非持有人读详情必须被拒(防探测 404)");
assertFalse(detail.containsKey("followups"),
"非负责人不得读取跟进正文");
assertFalse(detail.containsKey("userId"),
"非负责人不需要知道申请人 userId");
assertEquals(0, ((Number) detail.getOrDefault("followupCount", 0)).intValue(),
"非负责人只能拿到跟进条数摘要");
} }
@Test @Test
@@ -360,7 +373,8 @@ class MatchmakerReferralPhase13Test {
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER); Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER);
assertNotNull(detail); 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("userId"), "负责人应能看到申请人 userId");
assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录"); 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()) () -> 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());
}
}
@@ -0,0 +1,69 @@
package com.danyang.match.platform.photo;
import com.amazonaws.services.s3.AmazonS3;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.photo.service.PhotoService;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:照片直传落库(此前「传了白传」——upload-token 之后没有任何端点写 user_photos)。
* <ul>
* <li>非法的 fileKey(不归属本人)必须被归属护栏拒绝;</li>
* <li>对象尚未上传完成(存储不存在)必须被存在性护栏拒绝;</li>
* <li>合法且已上传的直传必须真正落库(写入 user_photos 记录)。</li>
* </ul>
*/
class PhotoServiceConfirmUploadTest {
private final UserPhotoMapper photoMapper = mock(UserPhotoMapper.class);
private final AmazonS3 amazonS3 = mock(AmazonS3.class);
private final FeatureFlagConfig featureFlags = mock(FeatureFlagConfig.class);
private final ProfilePolicy profilePolicy = mock(ProfilePolicy.class);
private final UserMapper userMapper = mock(UserMapper.class);
private final AmazonS3 amazonS3Signer = mock(AmazonS3.class);
private final PhotoService photoService =
new PhotoService(photoMapper, amazonS3, featureFlags, profilePolicy, userMapper, amazonS3Signer);
@Test
void illegalFileKeyRejected() {
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "evil/key.jpg", "avatar"));
assertEquals("非法的文件标识", ex.getMessage());
}
@Test
void missingObjectRejected() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/x.jpg"))).thenReturn(false);
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "photos/u1/x.jpg", "lifestyle"));
assertEquals("文件尚未上传完成", ex.getMessage());
}
@Test
void validUploadPersistsPhoto() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/p.jpg"))).thenReturn(true);
when(photoMapper.countActiveProfilePhotos("u1")).thenReturn(0L);
when(profilePolicy.getMaxPhotoCount()).thenReturn(6);
when(photoMapper.insert(any())).thenReturn(1);
UserPhoto photo = photoService.confirmDirectUpload("u1", "photos/u1/p.jpg", "lifestyle");
assertNotNull(photo, "直传必须真实落库,不得白传");
assertEquals("u1", photo.getUserId());
assertEquals("photos/u1/p.jpg", photo.getFileKey());
assertEquals("lifestyle", photo.getPhotoType());
assertEquals(Constants.REVIEW_STATUS_PENDING, photo.getReviewStatus());
verify(photoMapper).insert(any(UserPhoto.class));
}
}

Some files were not shown because too many files have changed in this diff Show More