27 Commits
Author SHA1 Message Date
WorkBuddy d51c4c6a76 chore: ignore local temporary workspaces
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-20 14:20:23 +08:00
WorkBuddy 69ebf33e18 release: freeze demo v1.1.0 2026-10-19
CI/CD Pipeline / Backend Build & Test (push) Waiting to run
CI/CD Pipeline / Admin Web Build (push) Waiting to run
CI/CD Pipeline / Mini Program Compile Check (push) Waiting to run
CI/CD Pipeline / Migration Validation (push) Blocked by required conditions
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-19 20:36:22 +08:00
WorkBuddy 8b505d9133 feat: disable user messaging and retain公益 service guidance
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (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 / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (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
2026-10-15 13:38:17 +08:00
WorkBuddy 3c2b967274 release: freeze 2026-10-15 validated release baseline
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (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 / 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
2026-10-15 09:47:31 +08:00
WorkBuddy 827c23db1d fix(audit): 拒绝审计改 REQUIRES_NEW 独立事务提交(运行时实测抓到拒绝审计被业务回滚吞掉的系统性缺陷)
- 根因:DataScopeGuard.deny/denyAsNotFound/denyUser 与 AuthorizationAuditListener 的审计 insert 与业务同事务——拒绝抛异常必然回滚,审计(已执行 Updates:1)随之消失 → 生产全量 DATA_SCOPE_DENIED 零落库;H2 测试类 @Transactional(测试事务内可见未提交行)结构性假绿
- 运行时取证:outsider consent → deny insert DEBUG 日志 Updates:1 → psql count=0(回滚吞掉)
- 修复:新增 AuditDenyWriter(@Transactional REQUIRES_NEW)——deny/denyAsNotFound/denyUser 与 AuthorizationAuditListener 全部改走独立提交;成功路径审计保持与业务同事务(业务回滚审计一并消失,语义正确)
- 测试判别性加固:新增 denyAuditSurvivesBusinessRollback(@Transactional NOT_SUPPORTED 挂起测试事务跑真事务,旧实现必红);两处 operator deny 断言前 clearCache(REQUIRES_NEW 走独立会话不清外层 MyBatis 一级缓存——调试实证行已落库而过滤查询命中陈旧缓存)
- 负向注入:AuditDenyWriter 退化同事务 → 判别用例精准判红 → 还原复绿
- 验证:定向 46/46(授权 14+disabled 2+守卫 5+迁移 13+监听 1+矩阵 1+预约 3+路径 2+覆盖 5)
2026-10-12 11:09:56 +08:00
WorkBuddy ef61d51f61 fix(audit,matchmaker): V057 audit_logs 增加 subject_user_id 主体维度(运行时实测抓到 C 端审计 FK 硬缺陷)
- 根因:audit_logs.operator_id FK→operators,C 端用户(consent/decline/越权)触发的审计把 userId 写 operator_id → FK violation:consent 成功路径 500 回滚(阻塞主链路)、deny 路径写失败被吞(C 端越权零审计,违反 DEC-006);H2 无 FK → 集成测试假绿(测试库缺约束类假绿第二例)
- V057:audit_logs + subject_user_id UUID FK users(可空)+ idx_audit_logs_subject_user;主体语义=operator_id 与 subject_user_id 恰有一者非空
- AuditLog 实体 +subjectUserId;DataScopeGuard.denyUser(subject 维度 deny,先审计后 403);AuditLogService.recordUserAction 备用入口
- ContactAuthorizationService:consent/decline 改 saveUserAudit(subject 维度);requireOwnedAuthorization 改 denyUser;operator 侧 6 处不变
- 测试:生命周期断言主体分流(consent 行 subject_user_id/operator_id=null/无 assignment_id);非本人 consent 负例补「越权 deny 审计必须落 subject 维度」断言;LATEST_MIGRATION 56→57
- 验证:定向 33/33(授权 13+disabled 2+守卫 5+迁移 13);负向注入 denyUser 写回 operator 维度 → 恰好新断言判红 → 还原复绿
- 发现渠道:本地栈运行时实测(mock 登录新用户 consent 403 → deny 审计 FK 失败日志溯源)
2026-10-12 10:15:46 +08:00
WorkBuddy 02bc01f9c9 fix(matchmaker): 受控联系审计 afterData 改 JSON 序列化(运行时实测抓到 PG jsonb 500)
- 根因:ContactAuthorizationService 7 处 saveAudit afterData 传 key=value 裸串,PG audit_logs.after_data 为 jsonb 严格解析 → request/disclose 全链路 500;H2 测试库该列 CLOB 不校验 → 集成测试假绿
- 修复:新增 jsonOf 辅助(Jackson writeValueAsString,成对 kv),7 调用点全部改写;purpose/reason 用户输入由 Jackson 转义防 JSON 注入
- 守卫加固:MatchmakerContactAuthorizationTest 审计断言补「afterData 必须可 JSON 解析且为对象」(readTree 真解析)——运行时事故进入自动化基线
- 同型排查:全仓 recordSensitiveAccess/saveAuditLog 其他调用点 afterData 干净,污染面仅本文件
- 验证:定向 20/20(授权 13+disabled 2+守卫 5);负向注入还原裸串 → 恰好 JSON 解析守卫判红(错误信息与生产一致 Unrecognized token 'channel')→ 还原复绿
- 发现渠道:本地栈运行时实测(V056 上线后首次真 PG 验证),非测试环境
2026-10-12 09:56:26 +08:00
WorkBuddy 16c82739db docs(governance): 受控联系产品裁决落账——OQ-003/009/010 全部 RESOLVED
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
- open-questions.md: OQ-MATCHMAKER-003 RESOLVED(5 项 LEGAL_VERIFY 全闭环:①组长主审+管理员兜底 ②用户单独同意已实现 ③有效期 7 天维持 ④保留期 365 天维持 ⑤审计面满足+NULL=NULL 维持;建议放量前转法务复核留痕);OQ-009 RESOLVED(分层审批:主审岗=approver-authorities 首段走组织范围,兜底岗固定 matchmaker.assign 全量,落地证据=4 条分层用例+注入自证+主理人 Docker 独立实跑 20/20);OQ-010 RESOLVED(MatchmakerContactDisabledTest 行为基线,顺带修复 request 未查 enabled 真实缺口)
- config-register.md: approver-authorities 转「已裁决/分层语义」(键名默认值不变);enabled/request 读点更新
- PENDING_DECISIONS.md: 受控联系行改「产品裁决全部落定」,剩余决策清零
2026-10-11 16:01:13 +08:00
WorkBuddy f73a116a10 feat(matchmaker): 受控联系审批分层落地(OQ-MATCHMAKER-009 裁决:组长主审+管理员兜底)+ enabled fail-safe 行为基线
- ContactAuthorizationService approve/reject 分层语义:matchmaker.assign 平台管理员=兜底岗(全量审批、不做组织校验);approver-authorities 首段(默认 matchmaker.team.assign)=主审岗(assertTeamScope 组织范围,hasGlobalAssign 固定 false 防权限面双通道,NULL=NULL 平台直属可审);两者皆无 403 deny 不变
- 真实缺口修复(OQ-010 引出):request 入口原未查 matchmaker.contact.enabled,补 denyAsNotFound fail-safe(与 disclose 口径一致:最先、404 化、防探测)
- 测试:MatchmakerContactAuthorizationTest 10→13(管理员跨组织兜底/组长他组织 403/组长审平台直属 NULL=NULL/双岗兜底全量,均 HTTP+审计双断言);新增 MatchmakerContactDisabledTest(enabled=false 时 disclose/request 404 化、零 SensitiveAccessLog、零 contact 成功审计、fail-safe 先于 purpose 校验)
- 负向注入:移除兜底分支 → 恰好 2 例分层用例判红(主审路径保持绿,判别力精准)→ 还原复绿
- 验证:主理人 Docker 独立实跑 20/20(授权 13+disabled 2+守卫 5);全量 mvn verify 900/900(3 skip);后端契约 234/CURATED 20;UI 24/24
- 裁决依据:OQ-MATCHMAKER-009(组长主审+管理员兜底)、LEGAL_VERIFY③ 7 天/④ 365 天/⑤ NULL=NULL 维持(登记于 governance)
2026-10-11 15:57:56 +08:00
WorkBuddy 21275ef770 docs(governance): 受控联系立项治理登记(OQ-003 转 IN_PROGRESS / OQ-009~010 新增 / DEC-MATCHMAKER-003-B1 落账)
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
- config-register.md: matchmaker.contact.* 五 canonical key 登记(enabled fail-safe 缺省 false / approver-authorities 双岗并列 / consent-deadline-hours 72 / default-validity-days 7 / retention-days 365,均 DEFAULT+CONFIG 非 LOCKED 待产品+法务终审)
- open-questions.md: OQ-MATCHMAKER-003 DEFERRED→IN_PROGRESS(V056 立项实现,双守卫翻转,5 项 LEGAL_VERIFY 逐项标注当前实现态与默认配置键);新增 OQ-MATCHMAKER-009(审批双岗并列待收敛,OPEN)与 OQ-MATCHMAKER-010(enabled=false 行为用例待补,QA P2-2)
- PENDING_DECISIONS.md: 受控联系行更新为已立项实现(DEC-MATCHMAKER-003-B1,V056,提交 d3735e3);剩余决策=双岗收敛+LEGAL_VERIFY 终审
- decision-register.md: DEC-MATCHMAKER-003-B1(2026-10-11 LOCKED→已实现)登记,验证数字 mvn verify 895/895、UI 24/24、后端契约 228/CURATED 20、真编译 87 WXML、负向注入 5+2
2026-10-11 13:11:52 +08:00
WorkBuddy d3735e3afa feat(matchmaker,contactauth): 受控联系授权机制立项落地(OQ-MATCHMAKER-003 / DEC-MATCHMAKER-003 B1 例外通道)
后端:
- V056 matchmaker_contact_authorizations 表:channel CHECK(phone/offline_meeting/full_contact)、六态状态机(pending_approval→pending_consent→active→expired/revoked, →rejected)、部分唯一索引 uq_mm_contact_auth_active 防同红娘-同用户-同渠道多 ACTIVE、双态惰性收敛(active 过期 + pending_consent 超同意窗口)
- ContactAuthorizationConfig 五 canonical key(matchmaker.contact.*):enabled 代码缺省 false fail-safe(yml 显式 true)/approver-authorities 双岗并列/consent-deadline-hours 72/default-validity-days 7/retention-days 365,全部 DEFAULT+CONFIG 待产品+法务终审
- ContactAuthorizationPolicy.assertContactAllowed(404 化 deny 先审计后 404) + canContact 徽标查询
- ContactAuthorizationService:request(须有效分配+purpose 必填)/approve(权限码动态判定∈配置+组长组织范围)/consent(用户本人,仅 pending_consent)/decline/revoke(reason 必填)/disclose(唯一披露出口:enabled fail-safe→assertActiveAssignment→assertContactAllowed→仅返回 phone/disclosedAt/authorizationId 三键,每次落 SensitiveAccessLog user_phone_disclosure,phone 不落应用日志)
- 双控制器:运营端 /operator/matchmaker/contact-authorizations + C 端 /contact-authorizations(UserPrincipal 归属校验,独立端点无预同意参数),不碰 SecurityConfig

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

测试:
- MatchmakerAssignmentA1Test 14 用例(MM-AUTH-001~004、MM-AUDIT-001、
  MM-LIFECYCLE-001、组长跨组织拒绝、NULL=NULL 平台直属边界、transfer 原持有人
  范围负向、/pending 403+审计),负向注入自证有效
- MatchmakerAssignmentPermissionTest 3 用例(端点权限契约 + claim 残留扫描)
- MatchmakerServiceTest 补 assignment 状态/scope 白名单守卫;迁移基线 LATEST=54
- 验证:定向 38/38 全绿;UI 契约 24/24;小程序 wcc/wcsc 真编译 0 失败
2026-10-09 22:46:32 +08:00
WorkBuddy 6187ec69df test(ui-guards): 导航契约守卫基线对齐后端真实权限码
- admin-role-navigation-contracts.mjs L40-41 原断言 referral.manage /
  matchmaker.service.view,但后端 permissions 表不存在这两个幽灵码——
  v1.1.1 权限码对齐(4edb761)时漏更新守卫基线,导致该守卫自 v1.1.1 起恒红
- 修正为后端真实 authority 码:/referral→matchmaker.service、
  /matchmaker→matchmaker.view(守卫内容修正,非删守卫,MIN_GUARDS 不变)
- 实测 UI 契约 24/24 全绿
2026-10-09 22:46:32 +08:00
WorkBuddy 2d60f7899f docs(audit): ZC-MM-001 红娘角色权限差距审计报告
- 基准:《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》(DEC-MATCHMAKER-001~006
  全部 LOCKED,A1/B1 已确认)
- 审计范围:backend matchmaker/referral/audit/security/auth、Flyway V001~V052、
  admin-web 红娘页面、现有测试、治理文档;全部结论带文件:行号证据
- 核心结论:A1 分配关系未建模(分配=工单指派)、claim/followup/complete 无归属校验、
  /pending 全局队列对全体红娘可见、无红娘组长角色、模块内越权拒绝不落审计
- P0×5 / P1×6 / P2×6 差距清单 + 可复用模块(CounselingService INV-012 范式、
  DataScopeGuard、recordSensitiveAccess)+ ZC-MM-002~007 实施顺序建议
- 首次建立 docs/audits/ 目录
2026-10-09 22:46:32 +08:00
WorkBuddy f76bda1efa fix(miniapp): 开发态 API base URL 由 localhost 改为 127.0.0.1
- 规避模拟器 localhost→::1 的 IPv6 解析歧义导致的 ERR_CONNECTION_REFUSED
  (onLaunch 全量启动拉取 /app/config 失败后不自动重试,热重载不重跑)
- 生产 base 不变;仅 env.ts/env.js 的 isDev 分支
2026-10-09 22:46:31 +08:00
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
admin 8335ef780c Initial commit 2026-09-30 12:03:52 +08:00
383 changed files with 33884 additions and 853 deletions
+7
View File
@@ -25,6 +25,13 @@ jobs:
working-directory: backend
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
if: always()
uses: actions/upload-artifact@v4
+3
View File
@@ -61,6 +61,9 @@ tests/fixtures/*.json
.worktrees/
.zcode/
.workbuddy/
.mimosa/
.trae/
.wb_tmp_bcrypt/
project.private.config.json
/nul
/infrastructure/nul
+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/>
+6 -2
View File
@@ -32,8 +32,12 @@ server {
}
# API proxy to backend
# 用 resolver + 变量 proxy_pass:避免 nginx 启动时 backend DNS 还没就绪就 [emerg] 退出
# Docker Compose 默认网络的内置 DNS resolver 是 127.0.0.11
resolver 127.0.0.11 valid=30s ipv6=off;
set $backend_upstream "http://backend:8080";
location /api/ {
proxy_pass http://backend:8080;
proxy_pass $backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -42,7 +46,7 @@ server {
# WebSocket proxy for chat
location /ws/ {
proxy_pass http://backend:8080;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
+2 -2
View File
@@ -20,8 +20,8 @@
packages:
- '.'
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
esbuild: true
vue-demi: true
onlyBuiltDependencies:
- esbuild
- vue-demi
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 672 KiB

+366 -155
View File
@@ -1,26 +1,52 @@
<template>
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
<header class="scene-header" aria-label="爱在丹阳公益服务品牌信息">
<!-- 背景装饰层:粉渐变兜底 + 丹阳剪影底图 + 爱心点缀 -->
<div class="header-bg" aria-hidden="true"></div>
<div class="header-hearts" aria-hidden="true">
<span>♡</span><span>♡</span><span>♡</span><span>♡</span>
</div>
<div class="scene-brand">
<img :src="BRAND_LOGO" alt="善缘丹阳" />
<div class="brand-logo-wrap">
<img :src="BRAND_LOGO" alt="爱在丹阳" />
<span class="brand-dot" aria-hidden="true"></span>
</div>
<div class="scene-brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
<div class="brand-name-row">
<strong>爱在丹阳</strong>
<em class="brand-badge">公益运营</em>
</div>
<span class="brand-tagline">公益婚恋服务平台</span>
<span class="brand-ornament" aria-hidden="true">❖ &nbsp;●&nbsp; ❖&nbsp; ●&nbsp; ❖</span>
</div>
<div class="scene-title">
<h1>管理端(运营后台)</h1>
<p>用专业的服务,帮助更多人遇见对的人</p>
<p class="title-sub">
<span>公益婚恋服务平台</span>
<span class="sep">·</span>
<span>用爱连接美好姻缘</span>
<span class="sep">·</span>
<span>让更多人在丹阳遇见幸福</span>
</p>
<p class="title-meta">
<span class="meta-chip" title="当前权限范围内今日新增、分配、跟进或完成的红娘服务单,按北京时间去重">今日服务 {{ todayStats?.total ?? '—' }} 单</span>
<span class="meta-chip" title="当前权限范围内尚未完成的红娘服务单">待处理 {{ todayStats?.pending ?? '—' }} 件</span>
</p>
</div>
</div>
<div class="scene-slogan" aria-hidden="true">
<span>善缘相牵</span>
<strong>爱在丹阳</strong>
<span class="slogan-top">爱在丹阳</span>
<strong class="slogan-main">原来是你</strong>
<em class="slogan-en">Love&nbsp;In&nbsp;Danyang</em>
</div>
<div class="scene-benefits" aria-label="平台服务原则">
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
<span><i class="benefit-icon"><el-icon><StarFilled /></el-icon></i><b>公益为本</b><small>不以营利为目的</small></span>
<span><i class="benefit-icon"><el-icon><Lock /></el-icon></i><b>真实可靠</b><small>严格审核·多重保障</small></span>
<span><i class="benefit-icon"><el-icon><LocationFilled /></el-icon></i><b>本地服务</b><small>深耕丹阳·服务家乡</small></span>
<span><i class="benefit-icon"><el-icon><Opportunity /></el-icon></i><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
</div>
</header>
@@ -29,7 +55,7 @@
<div class="brand" :class="{ compact: isCollapse }">
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
<div v-if="!isCollapse" class="brand-copy">
<strong>善缘丹阳</strong>
<strong>爱在丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
</div>
@@ -121,14 +147,16 @@
</el-container>
<footer class="scene-footer">
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
<strong>爱在丹阳</strong><span>公益婚恋服务平台</span>
<small>真实 · 真诚 · 公益 · 幸福</small>
</footer>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { get } from '@/api/client'
import { summarizeMatchmakerServices } from '@/services/dashboard'
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
@@ -174,8 +202,8 @@ const navigation: NavigationItem[] = [
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection },
{ label: '公益红娘', path: '/referral', permissions: ['matchmaker.service'], icon: Connection },
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.view'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
@@ -199,6 +227,25 @@ const isCollapse = ref(false)
const globalSearch = ref('')
const notificationCount = ref(0)
const todayStats = ref<{ total: number; pending: number } | null>(null)
watch(() => route.fullPath, async () => {
todayStats.value = null
const perms = authStore.permissions
if (authStore.token === 'ui-preview-only') return
try {
if (perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')) {
const response: any = await get('/operator/matchmaker/management/services')
if (response.success && Array.isArray(response.data)) todayStats.value = summarizeMatchmakerServices(response.data)
} else if (perms.includes('matchmaker.view')) {
const response: any = await get('/operator/matchmaker/dashboard')
if (response.success) todayStats.value = { total: response.data.todayCount, pending: response.data.pendingCount + response.data.inProgressCount }
}
} catch {
todayStats.value = null
}
}, { immediate: true })
const activeMenu = computed(() => {
if (route.path === '/users/detail') return '/users'
return route.path
@@ -217,7 +264,7 @@ const currentRoleName = computed(() => {
if (perms.includes('counseling.service.view')) return '公益咨询人员'
if (perms.includes('service_appointment.view')) return '服务站运营人员'
if (perms.includes('review.view')) return '资料审核人员'
if (perms.includes('matchmaker.service.view')) return '公益红娘'
if (perms.includes('matchmaker.view')) return '公益红娘'
return '运营人员'
})
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
@@ -258,137 +305,307 @@ function handleLogout() {
<style scoped>
.admin-scene {
min-width: 1100px;
min-height: 100vh;
height: 100vh;
display: flex;
flex-direction: column;
padding: 18px 34px 14px;
overflow: hidden;
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
/* 先给纯色兜底,避免图未加载完成时闪白 */
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
background-image: url(v-bind('BRAND_BACKDROP'));
/* 外层:纯色粉渐变兜底,再叠加背景图。
原实现把 background 和 background-image 拆两行在某些浏览器会覆盖 —— 这里统一写一条,
并显式声明 position/size/repeat/attachment,确保 cover 全区域。 */
background:
linear-gradient(180deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 40%, var(--admin-scene-bg-end) 100%),
url(v-bind('BRAND_BACKDROP')) center / cover fixed no-repeat;
}
.scene-header {
position: relative;
overflow: hidden;
display: grid;
max-width: var(--admin-shell-max-width);
height: 188px;
margin: 0 auto;
grid-template-columns: minmax(640px, 1fr) auto;
grid-template-rows: 112px 76px;
/* 100vw 撑满浏览器:用负 margin 抵消 admin-scene 的 padding */
width: 100vw;
max-width: none;
height: 105px;
margin: -18px -34px 12px;
padding: 0 34px;
grid-template-columns: auto 1fr auto;
grid-template-rows: 1fr 36px;
grid-template-areas:
"brand title slogan"
"benefits benefits benefits";
align-items: center;
column-gap: var(--space-8);
column-gap: 20px;
border-radius: 0;
}
/* ===== 背景装饰层 ===== */
.header-bg {
position: absolute;
inset: 0;
background:
linear-gradient(135deg, var(--admin-scene-bg-start) 0%, var(--admin-scene-bg-mid) 55%, var(--admin-scene-bg-end) 100%),
url(v-bind('BRAND_BACKDROP')) center / cover no-repeat;
z-index: 0;
}
/* 左上角柔光晕 */
.header-bg::before {
content: '';
position: absolute;
top: -60px;
left: -40px;
width: 300px;
height: 180px;
background: radial-gradient(ellipse at center, var(--admin-scene-glow) 0%, transparent 70%);
pointer-events: none;
}
/* 右下角柔光晕 */
.header-bg::after {
content: '';
position: absolute;
right: 40px;
bottom: -30px;
width: 280px;
height: 140px;
background: radial-gradient(ellipse at center, var(--admin-scene-glow-soft) 0%, transparent 70%);
pointer-events: none;
}
/* 爱心点缀 */
.header-hearts {
position: absolute;
top: 18px;
right: 34px;
display: flex;
gap: 14px;
font-size: 16px;
color: var(--admin-coral-border);
z-index: 1;
pointer-events: none;
}
.header-hearts span {
animation: heartbeat 3s ease-in-out infinite;
}
.header-hearts span:nth-child(2) { animation-delay: .4s; }
.header-hearts span:nth-child(3) { animation-delay: .8s; }
.header-hearts span:nth-child(4) { animation-delay: 1.2s; }
@keyframes heartbeat {
0%, 100% { transform: scale(1); opacity: .5; }
50% { transform: scale(1.25); opacity: 1; }
}
/* ===== 所有 header 内容浮在背景层之上 ===== */
.scene-brand,
.scene-slogan,
.scene-benefits {
position: relative;
z-index: 2;
}
/* ===== 品牌区 ===== */
.scene-brand,
.scene-benefits,
.scene-benefits span,
.scene-brand-copy {
display: flex;
align-items: center;
}
.scene-brand > img {
width: 64px;
height: 64px;
object-fit: contain;
.scene-brand {
grid-area: brand;
gap: 10px;
}
.scene-slogan {
/* Logo + 装饰圆点 */
.brand-logo-wrap {
position: relative;
width: 48px;
height: 48px;
flex: none;
display: grid;
place-items: center;
}
.brand-logo-wrap img {
width: 42px;
height: 42px;
object-fit: contain;
border-radius: 50%;
clip-path: circle(50%);
filter: drop-shadow(0 2px 6px rgba(244,95,104,0.25));
}
.brand-dot {
position: absolute;
top: -2px;
right: -2px;
width: 10px;
height: 10px;
background: var(--gradient-brand-solid);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(200, 74, 83, 0.45);
}
/* 品牌名 + 徽章 — tagline/ornament 在 105px 高度里塞不下,隐藏 */
.scene-brand-copy {
min-width: 0;
flex-direction: row;
align-items: center;
color: var(--admin-coral-strong);
gap: 8px;
}
.brand-name-row {
display: flex;
padding-right: var(--space-6);
align-items: flex-end;
align-items: center;
gap: 8px;
}
.brand-name-row strong {
font-size: 24px;
font-weight: 700;
line-height: 1;
letter-spacing: 2px;
background: var(--gradient-brand-text);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: none;
}
.brand-badge {
display: inline-flex;
align-items: center;
padding: 1px 8px;
font-size: 12px;
font-style: normal;
font-weight: 500;
letter-spacing: 1px;
color: var(--color-text-inverse);
background: var(--gradient-brand-solid);
border-radius: 10px;
box-shadow: 0 2px 6px rgba(200, 74, 83, 0.3);
}
.brand-tagline,
.brand-ornament {
display: none;
}
/* 标题区 */
.scene-title {
min-width: 0;
margin-left: 20px;
padding-left: 16px;
border-left: 2px solid var(--admin-scene-divider);
}
.scene-title h1 {
font-size: 18px;
font-weight: 700;
line-height: 1.05;
letter-spacing: 1px;
color: var(--color-text-primary);
}
.title-sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
margin-top: 2px;
font-size: 12px;
color: var(--color-text-secondary);
letter-spacing: .2px;
}
.title-sub .sep {
margin: 0 5px;
color: var(--admin-coral-solid);
font-weight: 700;
}
.title-meta {
display: flex;
gap: 6px;
margin-top: 3px;
font-size: 12px;
}
.meta-chip {
display: inline-flex;
align-items: center;
padding: 0 8px;
height: 18px;
color: var(--admin-coral-strong);
background: var(--admin-scene-surface);
border: 1px solid var(--admin-coral-border);
border-radius: 9px;
font-weight: 500;
line-height: 18px;
}
/* ===== 书法 slogan ===== */
.scene-slogan {
grid-area: slogan;
display: flex;
padding-right: 0;
align-items: center;
flex-direction: column;
justify-content: center;
color: var(--admin-coral-strong);
font-family: var(--font-family-brand);
line-height: 1.05;
transform: rotate(-7deg);
line-height: 1;
text-align: center;
gap: 0;
}
.scene-slogan span {
font-size: var(--font-size-lg);
}
.scene-slogan strong {
margin-top: var(--space-1);
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-medium);
}
.scene-brand-copy {
min-width: 172px;
margin-left: 10px;
align-items: flex-start;
flex-direction: column;
.slogan-top {
font-size: 12px;
letter-spacing: 6px;
color: var(--admin-coral-strong);
opacity: .8;
}
.scene-brand-copy strong {
font-size: var(--font-size-4xl);
line-height: 1.15;
letter-spacing: 2px;
}
.scene-brand-copy span {
margin-top: 5px;
font-size: var(--font-size-xs);
.slogan-main {
font-size: 26px;
font-weight: 500;
letter-spacing: 4px;
background: var(--gradient-brand-text);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.slogan-en {
display: none;
}
.scene-title {
min-width: 370px;
margin-left: 28px;
padding-left: 28px;
border-left: 1px solid var(--admin-coral-border);
}
.scene-title h1 {
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
line-height: 1.1;
letter-spacing: 1px;
}
.scene-title p {
margin-top: 9px;
color: var(--color-text-secondary);
}
/* ===== 公益原则行 ===== */
.scene-benefits {
grid-column: 1 / -1;
align-self: start;
justify-content: flex-start;
gap: clamp(38px, 5vw, 92px);
padding: var(--space-1) 0 0 50px;
grid-area: benefits;
align-self: center;
justify-content: center;
flex-wrap: nowrap;
gap: clamp(10px, 1.6vw, 28px);
padding: 0;
overflow: hidden;
}
.scene-benefits span {
display: grid;
grid-template-columns: 36px auto;
column-gap: var(--space-2);
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
color: var(--admin-coral-strong);
}
.scene-benefits .el-icon {
grid-row: 1 / 3;
font-size: var(--font-size-4xl);
}
.scene-benefits b {
font-size: var(--font-size-sm);
}
.scene-benefits small {
margin-top: 2px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
flex: none;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 14px 4px 8px;
white-space: nowrap;
background: var(--admin-scene-surface);
border: 1px solid var(--admin-coral-border);
border-radius: 18px;
color: var(--admin-coral-strong);
backdrop-filter: blur(6px);
box-shadow: 0 1px 4px rgba(200, 74, 83, 0.06);
}
.scene-benefits b {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
line-height: 1.1;
}
.scene-benefits small {
color: var(--color-text-tertiary);
font-size: 12px;
font-size: 12px;
white-space: nowrap;
line-height: 1.1;
}
.admin-shell {
width: min(var(--admin-shell-max-width), 100%);
height: calc(100vh - 238px);
min-height: 560px;
flex: 1;
min-height: 0;
margin: 0 auto;
overflow: hidden;
background: var(--admin-shell-bg);
@@ -427,6 +644,8 @@ function handleLogout() {
height: 42px;
flex: none;
object-fit: contain;
border-radius: 50%;
clip-path: circle(50%);
}
.brand-copy {
@@ -629,10 +848,15 @@ function handleLogout() {
}
.admin-content {
height: 100%;
padding: 0;
overflow: auto;
background: var(--color-bg-page);
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge 旧版 */
/* 暖色调背景,与整体品牌粉调一致;冷灰蓝 --color-bg-page 仅用于通用组件 */
background: var(--admin-scene-bg-end);
}
.admin-content::-webkit-scrollbar { display: none; } /* Chrome/Safari/Edge */
.scene-footer {
display: flex;
@@ -656,62 +880,49 @@ function handleLogout() {
letter-spacing: 4px;
}
@media (max-width: 1450px), (max-height: 820px) {
@media (max-width: 1280px), (max-height: 780px) {
.admin-scene {
padding: 12px 24px 10px;
}
.scene-header {
display: grid;
height: 120px;
grid-template-columns: minmax(520px, 1fr) auto;
grid-template-rows: 66px 54px;
column-gap: 18px;
height: 160px;
grid-template-columns: minmax(480px, 1fr) auto;
grid-template-rows: 92px 64px;
padding-top: 14px;
}
.scene-slogan {
padding-right: var(--space-3);
}
.brand-logo-wrap { width: 64px; height: 64px; }
.brand-logo-wrap img { width: 56px; height: 56px; }
.brand-dot { display: none; }
.scene-slogan span {
font-size: var(--font-size-base);
}
.scene-slogan strong {
font-size: var(--font-size-2xl);
}
.scene-brand > img {
width: 52px;
height: 52px;
}
.scene-brand-copy strong {
font-size: var(--font-size-2xl);
}
.brand-name-row strong { font-size: 30px; letter-spacing: 3px; }
.brand-badge { display: none; }
.brand-tagline { font-size: 12px; letter-spacing: 2px; }
.brand-ornament { display: none; }
.scene-title {
min-width: 310px;
margin-left: 18px;
padding-left: 18px;
min-width: 280px;
margin-left: 20px;
padding-left: 20px;
}
.scene-title h1 { font-size: 22px; letter-spacing: 1px; }
.title-sub { font-size: 12px; }
.title-meta { display: none; }
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
.scene-title h1 {
font-size: var(--font-size-3xl);
}
.slogan-top { font-size: 13px; letter-spacing: 6px; }
.slogan-main { font-size: 34px; letter-spacing: 4px; }
.slogan-en { display: none; }
.scene-benefits {
padding: 0 0 0 28px;
gap: clamp(22px, 4vw, 54px);
}
.benefit-icon { width: 34px; height: 34px; }
.benefit-icon .el-icon { font-size: 16px; }
.scene-benefits { gap: clamp(10px, 2vw, 28px); }
.scene-benefits b { font-size: 12px; }
.scene-benefits small { display: none; font-size: 12px; }
.admin-shell {
height: calc(100vh - 170px);
}
.scene-footer {
height: 28px;
height: calc(100vh - 200px);
}
}
@@ -49,9 +49,9 @@
<el-col :span="12">
<el-card shadow="never" class="inner-card">
<template #header><span>手工签到(特殊情形)</span></template>
<el-input v-model="manualUserId" placeholder="输入用户 ID" />
<el-input v-model="manualUserNumber" placeholder="输入13位用户编号" />
<div class="btn-row">
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
<el-button type="primary" :loading="saving" :disabled="!manualUserNumber"
@click="manual">手工签到</el-button>
</div>
<div class="form-hint">
@@ -65,8 +65,8 @@
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
<template #header><span>最近一次签到</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item>
<el-descriptions-item label="用户编号">{{ lastResult.userDisplayNumber }}</el-descriptions-item>
<el-descriptions-item label="活动编号">{{ lastResult.activityDisplayId }}</el-descriptions-item>
<el-descriptions-item label="本次是否为重复签到">
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
@@ -96,7 +96,7 @@ import { get, post } from '@/api/client'
const activities = ref<any[]>([])
const activityId = ref('')
const token = ref('')
const manualUserId = ref('')
const manualUserNumber = ref('')
const stats = ref<any>(null)
const lastResult = ref<any>(null)
const scanning = ref(false)
@@ -151,15 +151,15 @@ async function scan() {
}
async function manual() {
if (!activityId.value || !manualUserId.value.trim()) return
if (!activityId.value || !manualUserNumber.value.trim()) return
saving.value = true
try {
const res: any = await post(
`/activities/operator/${activityId.value}/checkin/manual`,
{ userId: manualUserId.value.trim() },
{ userId: manualUserNumber.value.trim() },
authHeader())
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
manualUserId.value = ''
lastResult.value = { ...res.data, userDisplayNumber: res.data?.userDisplayNumber || manualUserNumber.value.trim(), activityDisplayId: activities.value.find((item: any) => item.id === activityId.value)?.displayId }
manualUserNumber.value = ''
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
await loadStats()
} finally {
@@ -4,13 +4,13 @@
<template #header>
<div class="card-header">
<span>活动互选管理</span>
<el-input v-model="searchActivityId" placeholder="活动ID搜索" clearable style="width:260px" />
<el-input v-model="searchActivityNumber" placeholder="活动编号搜索" clearable style="width:260px" />
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="activityId" label="活动ID" width="160" />
<el-table-column prop="activityDisplayId" label="活动编号" width="160" />
<el-table-column prop="activityTitle" label="活动名称" min-width="180" />
<el-table-column label="双向互选对数" width="140">
<template #default="{row}">{{ row.mutualPairs }} 对</template>
@@ -31,8 +31,8 @@
<div v-if="currentActivity">
<h4>{{ currentActivity.title }} — 双向互选列表</h4>
<el-table :data="mutualList" border size="small" style="margin-top:16px">
<el-table-column prop="sourceUserId" label="表达者ID" min-width="180" />
<el-table-column prop="targetUserId" label="被表达者ID" min-width="180" />
<el-table-column prop="sourceUserDisplayNumber" label="表达者编号" min-width="180" />
<el-table-column prop="targetUserDisplayNumber" label="被表达者编号" min-width="180" />
<el-table-column prop="confirmedAt" label="确认时间" width="180" />
</el-table>
<el-empty v-if="!mutualList.length" description="暂无双向互选记录" />
@@ -46,7 +46,7 @@ import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
const searchActivityId = ref('')
const searchActivityNumber = ref('')
const loading = ref(false)
const tableData = ref<any[]>([])
const drawerVisible = ref(false)
@@ -59,8 +59,8 @@ async function loadData() {
const token = getAccessToken()
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
const rows = res.data || []
tableData.value = searchActivityId.value
? rows.filter((row: any) => row.activityId.includes(searchActivityId.value.trim()))
tableData.value = searchActivityNumber.value
? rows.filter((row: any) => (row.activityDisplayId || '').includes(searchActivityNumber.value.trim()))
: rows
} catch (e) {
console.error(e)
@@ -9,6 +9,7 @@
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="displayId" label="活动编号" min-width="180" />
<el-table-column prop="title" label="活动名称" min-width="180" />
<el-table-column prop="activityType" label="活动类型" width="130">
<template #default="{ row }">
@@ -128,7 +129,6 @@
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
<h4>组织关系</h4>
<el-table :data="orgs" border size="small">
<el-table-column prop="organizationId" label="组织ID" min-width="160" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">{{ roleText(row.role) }}</template>
+2 -2
View File
@@ -16,14 +16,14 @@
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="operatorId" label="操作人ID" width="140" />
<el-table-column prop="operatorDisplayId" label="操作人编号" min-width="180" />
<el-table-column prop="action" label="操作" width="160">
<template #default="{row}">{{ actionText(row.action) }}</template>
</el-table-column>
<el-table-column prop="resourceType" label="资源类型" width="120">
<template #default="{row}">{{ row.resourceType }}</template>
</el-table-column>
<el-table-column prop="resourceId" label="资源ID" width="140" show-overflow-tooltip />
<el-table-column prop="resourceDisplayId" label="业务编号" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.resourceDisplayId || '未公开编号(已脱敏)' }}</template></el-table-column>
<el-table-column prop="ip" label="IP地址" width="140" />
<el-table-column prop="createdAt" label="时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
@@ -179,7 +179,7 @@ function isExternalLink(value: string) {
function previewTargetText(banner: any) {
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
if (!banner.targetValue) return dest
return `${dest} · ${banner.targetValue}`
return `${dest} · 已配置站内目标`
}
function assignForm(source: any) {
form.value = {
@@ -88,7 +88,7 @@
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
<el-form label-width="80px">
<el-form-item label="咨询师">
<el-input v-model="assignForm.counselorId" placeholder="请输入咨询师工号 / ID" />
<el-input v-model="assignForm.counselorNumber" placeholder="请输入13位工作人员编号" />
</el-form-item>
</el-form>
<template #footer>
@@ -143,7 +143,7 @@ const assignedServices = ref<any[]>([])
const assignDialogVisible = ref(false)
const followupDialogVisible = ref(false)
const currentId = ref('')
const assignForm = ref({ counselorId: '' })
const assignForm = ref({ counselorNumber: '' })
const followupForm = ref({ type: 'phone_call', content: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = {
@@ -211,19 +211,19 @@ function onTabChange(name: string | number) {
function openAssign(row: any) {
currentId.value = row.id
assignForm.value.counselorId = ''
assignForm.value.counselorNumber = ''
assignDialogVisible.value = true
}
async function doAssign() {
if (!assignForm.value.counselorId) {
ElMessage.warning('请填写咨询师 ID')
if (!assignForm.value.counselorNumber) {
ElMessage.warning('请填写咨询师编号')
return
}
submitting.value = true
try {
await post(`/counseling/services/operator/${currentId.value}/assign`,
{ counselorId: assignForm.value.counselorId })
{ counselorId: assignForm.value.counselorNumber })
ElMessage.success('分配成功')
assignDialogVisible.value = false
loadQueue()
@@ -2,10 +2,24 @@
<div class="matchmaker-management">
<el-card>
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
<el-tabs v-model="activeTab">
<el-tab-pane label="服务请求队列" name="services">
<el-tabs v-model="activeTab" @tab-change="refresh">
<el-tab-pane v-if="canAssign" label="服务进展与跟进" name="management">
<el-table :data="managedServices" stripe v-loading="loading">
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="150" />
<el-table-column prop="operatorDisplayId" label="红娘编号" min-width="150" />
<el-table-column label="服务状态" width="120"><template #default="{ row }">{{ statusLabel(row.status) }}</template></el-table-column>
<el-table-column prop="latestFollowupContent" label="最新跟进" min-width="230" show-overflow-tooltip />
<el-table-column label="操作" width="110"><template #default="{ row }">
<el-button link type="primary" @click="openServiceDetail(row.serviceId, true)">查看记录</el-button>
</template></el-table-column>
</el-table>
</el-tab-pane>
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
<el-table :data="services" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
</el-table-column>
<el-table-column prop="serviceType" label="服务类型" width="160">
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
</el-table-column>
@@ -19,7 +33,8 @@
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
<el-button v-if="row.status!=='submitted'" size="small" @click="openServiceDetail(row.serviceId || row.id)">查看服务</el-button>
</template>
</el-table-column>
</el-table>
@@ -27,8 +42,8 @@
<el-tab-pane label="推荐列表" name="recommendations">
<el-table :data="recommendations" stripe v-loading="loading">
<el-table-column prop="userId" label="用户" width="110" />
<el-table-column prop="targetUserId" label="推荐对象" width="110" />
<el-table-column prop="userDisplayNumber" label="用户" width="110" />
<el-table-column prop="targetUserDisplayNumber" label="推荐对象" width="110" />
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="130">
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
@@ -38,16 +53,80 @@
</el-table-column>
</el-table>
</el-tab-pane>
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
<el-tab-pane label="服务对象分配" name="assignments">
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
<el-table-column prop="userDisplayNumber" label="用户编号" width="190">
<template #default="{ row }">{{ row.userDisplayNumber || '实名后生成' }}</template>
</el-table-column>
<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 v-if="row.serviceId" size="small" @click="openServiceDetail(row.serviceId)">查看服务</el-button>
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
<el-tab-pane label="联系授权审批" name="contactAuths">
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
<el-table-column prop="operatorDisplayId" label="红娘编号" width="120" />
<el-table-column prop="userDisplayNumber" label="用户编号" width="120" />
<el-table-column prop="channel" label="渠道" width="110">
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
</el-table-column>
<el-table-column prop="purpose" label="授权目的" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }"><el-tag :type="contactStatusTag(row.status)">{{ contactStatusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="createdAt" label="申请时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="expiresAt" label="窗口/有效期至" width="170">
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<template v-if="row.status === 'pending_approval' && canApprove">
<el-button size="small" type="primary" @click="doContactApprove(row)">批准</el-button>
<el-button size="small" type="danger" @click="doContactReject(row)">拒绝</el-button>
</template>
<el-button v-if="['active','pending_approval','pending_consent'].includes(row.status)"
size="small" @click="doContactRevoke(row)">撤销</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 分配弹窗 -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
<el-form label-width="80px">
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
<el-option label="王红娘" value="operator_wang" />
<el-option label="陈红娘" value="operator_chen" />
</el-select></el-form-item>
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
<el-form label-width="90px">
<el-form-item label="红娘">
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select>
</el-form-item>
<el-form-item label="分配理由" required>
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button>
@@ -55,11 +134,42 @@
</template>
</el-dialog>
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
<el-form label-width="90px">
<el-form-item label="新红娘">
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select>
</el-form-item>
<el-form-item label="转分配理由" required>
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
</template>
</el-dialog>
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
<el-form label-width="90px">
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="terminateDialogVisible=false">取消</el-button>
<el-button type="danger" @click="doTerminate">确认</el-button>
</template>
</el-dialog>
<!-- 创建推荐弹窗 -->
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
<el-form label-width="100px">
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" disabled /></el-form-item>
<el-form-item label="推荐对象ID"><el-input v-model="recForm.targetUserId" placeholder="输入被推荐对象的用户ID" /></el-form-item>
<el-form-item label="推荐给用户"><el-input v-model="recForm.userDisplayNumber" disabled /></el-form-item>
<el-form-item label="推荐对象编号"><el-input v-model="recForm.targetUserDisplayNumber" placeholder="输入13位用户编号" /></el-form-item>
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
</el-form>
<template #footer>
@@ -67,33 +177,108 @@
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
</template>
</el-dialog>
<el-dialog v-model="serviceDetailVisible" title="服务详情" width="620px">
<el-descriptions v-if="serviceDetail" :column="2" border>
<el-descriptions-item label="服务编号">{{ serviceDetail.serviceDisplayId }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(serviceDetail.status) }}</el-descriptions-item>
<el-descriptions-item label="服务类型">{{ serviceTypeLabel(serviceDetail.serviceType) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(serviceDetail.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="服务需求" :span="2">{{ serviceDetail.description || '未填写' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">服务历史</el-divider>
<el-empty v-if="!serviceDetail?.followups?.length" description="暂无服务记录" />
<el-timeline v-else>
<el-timeline-item v-for="item in serviceDetail.followups" :key="item.id" :timestamp="formatDateTime(item.createdAt)">
{{ item.content }}
</el-timeline-item>
</el-timeline>
<template v-if="serviceDetail && !managementDetail">
<el-divider />
<el-button type="primary" @click="openFollowup">添加跟进记录</el-button>
<el-button
v-if="['completed', 'cancelled'].indexOf(serviceDetail.status) < 0"
type="success"
plain
@click="completeCurrentService"
>完成服务</el-button>
</template>
</el-dialog>
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="520px">
<el-form label-width="90px">
<el-form-item label="跟进方式">
<el-select v-model="followupForm.followupType" style="width: 100%">
<el-option label="电话沟通" value="phone_call" />
<el-option label="微信沟通" value="wechat" />
<el-option label="面对面" value="face_to_face" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="跟进内容" required>
<el-input v-model="followupForm.content" type="textarea" :rows="4" placeholder="记录本次服务进展" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="followupDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitFollowup">保存记录</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onActivated } from 'vue'
import { useRoute } from 'vue-router'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const activeTab = ref('services')
const authStore = useAuthStore()
const route = useRoute()
const managementDetail = ref(false)
const managedServices = ref<any[]>([])
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
const canAssign = computed(() => {
const perms: string[] = authStore.permissions || []
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
})
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
const loading = ref(false)
const services = ref<any[]>([])
const recommendations = ref<any[]>([])
const assignments = ref<any[]>([])
const assignmentsLoading = ref(false)
const assignableMatchmakers = ref<any[]>([])
const assignDialogVisible = ref(false)
const transferDialogVisible = ref(false)
const terminateDialogVisible = ref(false)
const recDialogVisible = ref(false)
const currentServiceId = ref('')
const assignForm = ref({ operatorId: '' })
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
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({ userDisplayNumber: '', targetUserDisplayNumber: '', reason: '' })
const serviceDetailVisible = ref(false)
const serviceDetail = ref<any>(null)
const followupDialogVisible = ref(false)
const followupForm = ref({ followupType: 'phone_call', content: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = {
recommendation: '推荐对象', referral_counseling: '转介咨询',
referral_activity: '转介活动', referral_station: '转介服务站'
}
const STATUS_LABEL: Record<string, string> = {
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
processing: '处理中', completed: '已完成', cancelled: '已取消'
}
const STATUS_TAG: Record<string, string> = {
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
processing: '', completed: 'success', cancelled: 'danger'
}
const REC_STATUS_LABEL: Record<string, string> = {
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
@@ -103,18 +288,128 @@ const REC_STATUS_TAG: Record<string, string> = {
pending_view: 'warning', viewed: 'info', liked: 'primary',
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
}
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
}
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
}
// ===== ZC-MM-003 / B1:受控联系授权(枚举码不上屏,中文映射) =====
const CONTACT_CHANNEL_LABEL: Record<string, string> = {
phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式'
}
const CONTACT_STATUS_LABEL: Record<string, string> = {
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
rejected: '已拒绝', expired: '已过期', revoked: '已撤销'
}
const CONTACT_STATUS_TAG: Record<string, string> = {
pending_approval: 'warning', pending_consent: 'warning', active: 'success',
rejected: 'danger', expired: 'info', revoked: 'info'
}
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
async function openServiceDetail(serviceId: string, management = false) {
managementDetail.value = management
if (!serviceId) return
try {
const res: any = await get(`/operator/matchmaker/${management ? 'management/' : ''}services/${serviceId}`)
serviceDetail.value = res?.data || null
serviceDetailVisible.value = true
} catch { serviceDetail.value = null }
}
function openFollowup() {
followupForm.value = { followupType: 'phone_call', content: '' }
followupDialogVisible.value = true
}
async function submitFollowup() {
if (!followupForm.value.content.trim() || !serviceDetail.value?.serviceId) {
ElMessage.warning('请填写跟进内容')
return
}
try {
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/followup`, followupForm.value)
ElMessage.success('跟进记录已保存')
followupDialogVisible.value = false
await openServiceDetail(serviceDetail.value.serviceId)
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '保存跟进记录失败')
}
}
async function completeCurrentService() {
if (!serviceDetail.value?.serviceId) return
try {
await ElMessageBox.confirm('确认将该红娘服务标记为已完成吗?', '完成服务', { type: 'warning' })
await post(`/operator/matchmaker/services/${serviceDetail.value.serviceId}/complete`, {})
ElMessage.success('服务已完成')
await openServiceDetail(serviceDetail.value.serviceId)
await loadAssignments()
} catch (e: any) {
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.response?.data?.message || '完成服务失败')
}
}
// ===== ZC-MM-003 / B1:受控联系授权 =====
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
const canApprove = computed(() => {
const perms: string[] = authStore.permissions || []
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
})
const contactAuths = ref<any[]>([])
const contactAuthsLoading = ref(false)
async function loadContactAuths() {
contactAuthsLoading.value = true
try {
const endpoint = canApprove.value
? '/operator/matchmaker/contact-authorizations/pending'
: '/operator/matchmaker/contact-authorizations/my'
const res: any = await get(endpoint)
contactAuths.value = res?.data?.list || []
} finally { contactAuthsLoading.value = false }
}
async function doContactApprove(row: any) {
try {
await post(`/operator/matchmaker/contact-authorizations/${row.id}/approve`, {})
ElMessage.success('已批准,等待用户单独确认'); loadContactAuths()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function doContactReject(row: any) {
try {
await post(`/operator/matchmaker/contact-authorizations/${row.id}/reject`, {})
ElMessage.success('已拒绝'); loadContactAuths()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function doContactRevoke(row: any) {
try {
const { value } = await ElMessageBox.prompt('撤销理由(必填)', '撤销联系授权', {
confirmButtonText: '确认撤销', cancelButtonText: '取消',
inputValidator: (v: string) => (v && v.trim() ? true : '撤销理由必填'),
})
await post(`/operator/matchmaker/contact-authorizations/${row.id}/revoke`, { reason: value.trim() })
ElMessage.success('已撤销'); loadContactAuths()
} catch { /* 用户取消 */ }
}
async function loadServices() {
loading.value = true
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
}
async function loadRecommendations() {
@@ -123,36 +418,109 @@ async function loadRecommendations() {
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
}
async function loadAssignments() {
assignmentsLoading.value = true
// ZC-MM-002:本人名下生效中的服务对象分配
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
}
async function loadAssignableMatchmakers() {
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
}
async function refresh() {
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
if (activeTab.value === 'management') {
loading.value = true
try { const res: any = await get('/operator/matchmaker/management/services'); managedServices.value = res?.data || [] }
finally { loading.value = false }
}
else if (activeTab.value === 'services') await loadServices()
else if (activeTab.value === 'assignments') await loadAssignments()
else if (activeTab.value === 'contactAuths') await loadContactAuths()
else await loadRecommendations()
}
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
async function openAssign(row: any) {
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
currentServiceId.value = row.serviceId || row.id
currentUserId.value = row.userId
assignForm.value = { matchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
assignDialogVisible.value = true
}
async function doAssign() {
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
await post('/operator/matchmaker/assignments', {
userId: currentUserId.value,
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
serviceId: currentServiceId.value,
reason: assignForm.value.reason.trim()
})
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function openTransfer(row: any) {
currentAssignmentId.value = row.id
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
transferDialogVisible.value = true
}
async function doTransfer() {
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
reason: transferForm.value.reason.trim()
})
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openTerminate(row: any, action: 'revoke' | 'end') {
currentAssignmentId.value = row.id
terminateAction.value = action
terminateForm.value = { reason: '' }
terminateDialogVisible.value = true
}
async function doTerminate() {
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
}
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
reason: terminateForm.value.reason.trim()
})
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openRecommendation(row: any) {
currentServiceId.value = row.id
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
recForm.value = { userDisplayNumber: row.userDisplayNumber || '实名后生成', targetUserDisplayNumber: '', reason: '' }
recDialogVisible.value = true
}
async function doCreateRecommendation() {
if (!recForm.value.targetUserId || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
if (!recForm.value.targetUserDisplayNumber || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
targetUserId: recForm.value.targetUserId, reason: recForm.value.reason
targetUserId: recForm.value.targetUserDisplayNumber, reason: recForm.value.reason
})
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
onMounted(refresh)
onActivated(async () => {
if (canAssign.value && typeof route.query.serviceId === 'string') {
activeTab.value = 'management'
await openServiceDetail(route.query.serviceId, true)
}
await refresh()
})
</script>
<style scoped>
@@ -10,6 +10,7 @@
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="displayId" label="人员编号" width="150" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="organizationId" label="所属机构" width="160">
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
@@ -6,7 +6,7 @@
<el-table-column prop="type" label="类型" width="120">
<template #default="{ row }"><el-tag>{{ typeLabel(row.type) }}</el-tag></template>
</el-table-column>
<el-table-column prop="id" label="关联ID" width="120" />
<el-table-column prop="referralDisplayId" label="转介编号" min-width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='confirmed'?'success':'warning'">{{ row.status }}</el-tag></template>
</el-table-column>
+5 -3
View File
@@ -20,7 +20,8 @@
<el-tabs v-model="activeTab">
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="displayId" label="任务编号" min-width="180" />
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="reviewType" label="审核类型" min-width="130">
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
</el-table-column>
@@ -42,7 +43,8 @@
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="displayId" label="任务编号" min-width="180" />
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="reviewType" label="审核类型" min-width="130">
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
</el-table-column>
@@ -65,7 +67,7 @@
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
<template v-else-if="currentTask">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item>
<el-descriptions-item label="用户编号">{{ currentTask.userDisplayNumber }}</el-descriptions-item>
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
@@ -25,7 +25,8 @@
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="reasonText" label="举报原因" width="140" />
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" />
<el-table-column prop="displayId" label="举报编号" min-width="180" />
<el-table-column prop="reportedUserDisplayNumber" label="被举报人编号" width="140" />
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
@@ -11,8 +11,8 @@
</el-radio-group>
</div></template>
<el-table :data="appointments" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="displayId" label="预约编号" min-width="180" />
<el-table-column prop="userDisplayNumber" label="用户编号" min-width="180" />
<el-table-column prop="serviceType" label="服务类型" width="150" />
<el-table-column prop="source" label="来源" width="140">
<template #default="{ row }"><el-tag size="small">{{ sourceLabel(row.source) }}</el-tag></template>
@@ -21,7 +21,7 @@
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="preferredDate" label="预约日期" width="110" />
<el-table-column prop="assignedOperatorId" label="分配人员" width="100" />
<el-table-column prop="assignedOperatorDisplayId" label="分配人员" width="100" />
<el-table-column prop="createdAt" label="创建时间" width="160">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
@@ -74,7 +74,7 @@
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
</el-descriptions>
<el-table :data="members" stripe v-loading="memberLoading">
<el-table-column prop="userId" label="用户ID" width="200" show-overflow-tooltip />
<el-table-column prop="userDisplayNumber" label="用户编号" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
@@ -19,7 +19,7 @@
<section v-else-if="user" class="surface-card detail-card">
<div class="identity-summary">
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.displayNumber || '实名后生成' }}</span></div>
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
</div>
<el-tabs v-model="activeTab" class="detail-tabs">
@@ -42,7 +42,9 @@
</el-alert>
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
<el-table-column prop="id" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="displayNumber" label="用户编号" min-width="190" show-overflow-tooltip>
<template #default="{ row }">{{ row.displayNumber || '实名后生成' }}</template>
</el-table-column>
<el-table-column prop="displayName" label="显示名称" min-width="110">
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
</el-table-column>
+2 -2
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-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: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
{ 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.view'] } },
],
},
+52 -10
View File
@@ -18,6 +18,20 @@ interface ApiEnvelope<T> {
const REQUEST_TIMEOUT = 8000
export function summarizeMatchmakerServices(rows: JsonRecord[]) {
const day = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' })
const today = day.format(new Date())
return {
pending: rows.filter(row => ['submitted', 'assigned', 'in_service', 'waiting_user'].includes(String(row.status))).length,
completed: rows.filter(row => row.status === 'completed').length,
total: rows.filter(row => ['createdAt', 'assignedAt', 'completedAt', 'latestFollowupAt'].some(key => {
if (!row[key]) return false
const date = new Date(String(row[key]))
return !Number.isNaN(date.getTime()) && day.format(date) === today
})).length,
}
}
const numberOf = (source: JsonRecord | undefined, key: string) => {
const value = source?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : 0
@@ -45,9 +59,10 @@ const emptyMetric = (
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
return {
followups: [],
metrics: [
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/matchmaker'),
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
@@ -65,8 +80,11 @@ export function createEmptyDashboard(notice = '当前账号暂无可展示的工
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
const canViewAnalytics = permissions.includes('analytics.view')
const canViewActivities = permissions.includes('activity.view')
const canViewMatchmaker = permissions.includes('matchmaker.view')
const canManageMatchmaker = permissions.includes('matchmaker.assign') || permissions.includes('matchmaker.team.assign')
const canViewReviews = permissions.includes('review.view')
const [coreResult, serviceResult, activityResult] = await Promise.allSettled([
const [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
canViewAnalytics
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
@@ -76,14 +94,33 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
canViewActivities
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
canViewMatchmaker
? client.get('/operator/matchmaker/dashboard', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
canViewReviews ? client.get('/operator/reviews/stats', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
canManageMatchmaker ? client.get('/operator/matchmaker/management/services', { timeout: REQUEST_TIMEOUT }) : Promise.resolve(null),
])
const core = fulfilledData<JsonRecord>(coreResult)
const service = fulfilledData<JsonRecord>(serviceResult)
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
const matchmaker = fulfilledData<JsonRecord>(matchmakerResult)
const reviews = fulfilledData<JsonRecord>(reviewResult)
const managedServices = fulfilledData<JsonRecord[]>(managementResult)
const managedStats = summarizeMatchmakerServices(managedServices || [])
const matchmakerAvailable = canManageMatchmaker ? Boolean(managedServices) : Boolean(matchmaker)
const matchmakerPending = canManageMatchmaker
? managedStats.pending
: numberOf(matchmaker || undefined, 'pendingCount') + numberOf(matchmaker || undefined, 'inProgressCount')
const matchmakerCompleted = canManageMatchmaker
? managedStats.completed
: numberOf(matchmaker || undefined, 'completedCount')
const followups = (managedServices || []).filter(row => row.latestFollowupContent)
.sort((a, b) => String(b.latestFollowupAt).localeCompare(String(a.latestFollowupAt)))
.slice(0, 5).map(row => ({ serviceId: String(row.serviceId), userDisplayNumber: String(row.userDisplayNumber || '未生成编号'),
content: String(row.latestFollowupContent), createdAt: String(row.latestFollowupAt || '') }))
const analyticsAvailable = Boolean(core || service)
const eligibility = recordOf(service || undefined, 'localEligibility')
const counseling = recordOf(service || undefined, 'counseling')
const appointments = recordOf(service || undefined, 'serviceAppointments')
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
@@ -92,12 +129,13 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
const metrics: DashboardMetric[] = [
{
key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'),
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review',
key: 'pending_reviews', label: '待审核资料', value: numberOf(reviews || undefined, 'pending') + numberOf(reviews || undefined, 'processing'),
trendText: reviews ? '待领取与审核中资料' : '暂无授权数据', tone: 'rose', available: Boolean(reviews), route: '/review',
},
{
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
key: 'matchmaker_todos', label: '红娘服务待办',
value: matchmakerPending,
trendText: matchmakerAvailable ? (canManageMatchmaker ? '管理范围内待完成服务' : '本人服务对象待跟进') : '暂无授权数据', tone: 'amber', available: matchmakerAvailable, route: '/matchmaker',
},
{
key: 'counseling_todos', label: '咨询服务待办',
@@ -120,9 +158,10 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
]
const todos = ([
{ key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
{ key: 'review', title: '资料审核', description: '待领取与审核中资料', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
{ key: 'matchmaker', title: '红娘服务', description: metrics[1].trendText, count: metrics[1].value, actionText: '查看服务', route: '/matchmaker', tone: 'amber', available: metrics[1].available },
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
] as DashboardTodo[]).filter((item) => item.available)
@@ -142,6 +181,7 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
{ key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
{ key: 'matchmaker_completed', label: '红娘服务完成', value: matchmakerCompleted, compareText: canManageMatchmaker ? '管理范围内已完成服务' : '本人已完成服务', tone: 'amber', available: matchmakerAvailable },
] as DashboardOverviewItem[]).filter((item) => item.available)
const effectiveness = ([
@@ -152,12 +192,14 @@ export async function loadDashboard(permissions: string[]): Promise<DashboardMod
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
] as DashboardEffectivenessItem[]).filter((item) => item.available)
const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length
const notice = !analyticsAvailable && !activityRows
const rejectedCount = [coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
const anyData = analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
const notice = !anyData
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
return {
followups,
metrics,
todos,
activities,
+5 -3
View File
@@ -13,9 +13,9 @@ interface OperatorInfo {
const previewRolePermissions: Record<string, string[]> = {
reviewer: ['review.view', 'user.view'],
counselor: ['counseling.service.view', 'referral.manage'],
counselor: ['counseling.service.view', 'matchmaker.service'],
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', () => {
@@ -31,6 +31,8 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || [])
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
const hasPermission = (code: string) => permissions.value.includes(code)
function applyAuthData(data: any) {
token.value = data.accessToken || ''
@@ -87,5 +89,5 @@ export const useAuthStore = defineStore('auth', () => {
clearAccessToken()
}
return { token, operator, isLoggedIn, permissions, login, logout, initializeSession }
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
})
+10
View File
@@ -37,8 +37,18 @@
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
--admin-coral-border: rgba(244, 95, 104, 0.22);
--admin-scene-bg-start: #FFEDEE;
--admin-scene-bg-mid: #FFF6F4;
--admin-scene-bg-end: #FBF5F4;
--admin-scene-glow: rgba(244, 95, 104, 0.28);
--admin-scene-glow-soft: rgba(255, 150, 170, 0.22);
--admin-scene-surface: rgba(255, 255, 255, 0.65);
--admin-scene-border: rgba(244, 95, 104, 0.18);
--admin-scene-divider: rgba(244, 95, 104, 0.30);
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
/* 文字用渐变(background-clip:text):比 solid 更鲜亮,用于「善缘丹阳」等大字号品牌标题 */
--gradient-brand-text: linear-gradient(135deg, #FF7D6E 0%, #E8475D 60%, #B8434A 100%);
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
+1
View File
@@ -51,6 +51,7 @@ export interface DashboardEffectivenessItem {
}
export interface DashboardModel {
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
metrics: DashboardMetric[]
todos: DashboardTodo[]
activities: DashboardActivity[]
+30 -2
View File
@@ -106,6 +106,17 @@
</article>
</section>
<article v-if="dashboard.followups.length" class="dashboard-panel">
<header class="panel-header"><div><h2>最新红娘跟进记录</h2></div></header>
<div class="todo-list">
<button v-for="item in dashboard.followups" :key="item.serviceId" type="button" class="todo-row"
@click="goTo(`/matchmaker?serviceId=${encodeURIComponent(item.serviceId)}`)">
<span class="todo-copy"><strong>{{ item.userDisplayNumber }}</strong><small>{{ item.content }}</small></span>
<em>{{ formatDateTime(item.createdAt) }} · 查看服务</em>
</button>
</div>
</article>
<section class="dashboard-grid dashboard-grid-bottom">
<article class="dashboard-panel service-overview-panel">
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
@@ -136,7 +147,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onActivated, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
@@ -277,15 +288,19 @@ async function retryLoad() {
}
}
onMounted(retryLoad)
onActivated(retryLoad)
</script>
<style scoped>
.dashboard-page {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 100%;
max-width: var(--admin-content-max-width);
margin: 0 auto;
padding: var(--space-5) 26px 28px;
box-sizing: border-box;
}
.dashboard-welcome {
@@ -478,6 +493,19 @@ onMounted(retryLoad)
margin-top: 14px;
}
.dashboard-grid-top {
flex: 0 0 auto;
}
.dashboard-grid-bottom {
flex: 1;
min-height: 0;
}
.dashboard-grid-bottom .dashboard-panel {
height: 100%;
}
.dashboard-panel {
min-width: 0;
padding: 14px var(--space-4);
+5 -3
View File
@@ -1,12 +1,12 @@
<template>
<main class="login-shell">
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
<section class="brand-panel" aria-label="爱在丹阳公益婚恋服务平台">
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
<div class="brand-overlay">
<div class="brand-title">
<img :src="BRAND_LOGO" alt="" />
<div>
<strong>善缘丹阳</strong>
<strong>爱在丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
</div>
@@ -55,7 +55,7 @@
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
</div>
</div>
<footer>善缘丹阳 · 公益婚恋服务平台</footer>
<footer>爱在丹阳 · 公益婚恋服务平台</footer>
</section>
</main>
</template>
@@ -135,6 +135,8 @@ async function handleLogin() {
.brand-title img {
width: 54px;
height: 54px;
border-radius: 50%;
clip-path: circle(50%);
}
.brand-title div {
+1 -1
View File
@@ -13,7 +13,7 @@
<groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId>
<version>1.1.0</version>
<version>1.2.0</version>
<name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
@@ -12,6 +12,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,6 +48,7 @@ public class AppealService {
private final AppealMapper appealMapper;
private final RiskActionMapper riskActionMapper;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper;
private final NotificationService notificationService;
@@ -281,6 +284,11 @@ public class AppealService {
log.info("申诉批准,已解除处置: appealId={}, actionId={}", appeal.getId(), appeal.getRelatedActionId());
}
// 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把账号恢复为 normal。
// 否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
// users.account_status=banned/suspended 无人回滚。
restoreAccountStatusIfNoActiveRestriction(appeal.getUserId());
// 通知用户
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
"申诉审核通过",
@@ -371,6 +379,38 @@ public class AppealService {
return "limited";
}
/**
* 解除处置后同步账号状态:若该用户已无其他生效中的封禁 / 暂停,把 users.account_status 恢复为 normal。
*
* <p>否则会出现「申诉已通过、账号仍被锁死」的死锁 —— ReportService 处置时写入的
* users.account_status=banned/suspended 无人回滚,登录门禁 / 推荐 / 匹配资格仍按旧状态拒绝用户。
*
* <p>仅回滚由处置写入的 banned / suspended,避免误改 closed(注销)等其他业务状态。
*/
private void restoreAccountStatusIfNoActiveRestriction(String userId) {
boolean stillRestricted = riskActionMapper.findActiveByUserId(userId).stream()
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType())
|| Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(a.getActionType()));
if (stillRestricted) {
return;
}
User user = userMapper.selectById(userId);
if (user == null) {
return;
}
String oldStatus = user.getAccountStatus();
if (!Constants.ACCOUNT_STATUS_BANNED.equals(oldStatus)
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(oldStatus)) {
return;
}
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串会被 PG 拒绝。
saveAuditLog(null, "ACCOUNT_STATUS_RESTORE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}",
"{\"accountStatus\":\"" + Constants.ACCOUNT_STATUS_NORMAL + "\"}");
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
@@ -378,6 +418,10 @@ public class AppealService {
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
// 此前忽略 beforeData/afterData → 账号状态恢复等变更审计丢失前后值;补齐后需注意
// audit_logs 的 before_data/after_data 为 JSONB,调用方必须传合法 JSON 文本。
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLogMapper.insert(auditLog);
}
}
@@ -19,4 +19,20 @@ public class AuditLog extends BaseEntity {
private String afterData;
private String ip;
private String userAgent;
// ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ====================
/** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */
private String purposeCode;
/** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */
private String serviceAssignmentId;
/** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */
private String actorRoleSnapshot;
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
private String requestTraceId;
// ==================== V057 / 运行时实测修复:C 端用户主体维度 ====================
/** 用户主体 ID(C 端触发的审计写此列且 operator_id=null;两者恰有一者非空)。 */
private String subjectUserId;
}
@@ -0,0 +1,33 @@
package com.danyang.match.platform.audit.service;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 拒绝类审计的独立事务写入器(运行时实测修复)。
*
* <p>背景:拒绝审计(DATA_SCOPE_DENIED / AUTHORIZATION_DENIED)原先与业务方法同一事务——
* 拒绝时抛异常必然触发业务事务回滚,审计 insert(已执行 Updates:1)随之被回滚吞掉:
* 生产全量 deny 审计零落库。H2 集成测试因测试类 @Transactional(测试事务内可见未提交行)
* 结构性假绿。运行时实测(outsider consent → deny 行 0 条)抓到。
*
* <p>语义:REQUIRES_NEW 独立事务提交——外层业务事务回滚与否均不影响拒绝审计落库;
* 内层失败仍由调用方 catch 记日志后照常拒绝(审计失败不阻断拒绝响应)。
* 成功路径审计(SENSITIVE_ACCESS / 业务动作)保持与业务同事务(业务回滚审计一并消失,语义正确),
* 不走本写入器。
*/
@Component
@RequiredArgsConstructor
public class AuditDenyWriter {
private final AuditLogMapper auditLogMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertIndependent(AuditLog auditLog) {
auditLogMapper.insert(auditLog);
}
}
@@ -52,6 +52,11 @@ public class AuditLogService {
m.put("ip", log.getIp());
m.put("userAgent", log.getUserAgent());
m.put("createdAt", log.getCreatedAt());
// ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容)
m.put("purposeCode", log.getPurposeCode());
m.put("serviceAssignmentId", log.getServiceAssignmentId());
m.put("actorRoleSnapshot", log.getActorRoleSnapshot());
m.put("requestTraceId", log.getRequestTraceId());
return m;
}).collect(java.util.stream.Collectors.toList());
@@ -73,11 +78,48 @@ public class AuditLogService {
*/
@Transactional
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
recordSensitiveAccess(operatorId, resourceType, resourceId, null, null);
}
/**
* ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。
* 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。
*/
@Transactional
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId,
String purposeCode, String serviceAssignmentId) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction("SENSITIVE_ACCESS");
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
HttpServletRequest req = attrs.getRequest();
auditLog.setIp(req.getRemoteAddr());
String ua = req.getHeader("User-Agent");
auditLog.setUserAgent(ua != null ? (ua.length() > 512 ? ua.substring(0, 512) : ua) : null);
}
auditLogMapper.insert(auditLog);
}
/**
* V057 / 运行时实测修复:C 端用户主体的审计入口。
* operator 主体照旧走 recordSensitiveAccess/saveAuditLog(operator_id 维度);
* user 主体(consent/decline 等用户动作)写 subject_user_id 且 operator_id=null——
* 否则 userId 撞 audit_logs_operator_id_fkey(→operators) 必 FK violation(H2 无 FK 假绿)。
*/
@Transactional
public void recordUserAction(String userId, String action, String resourceType, String resourceId,
String purposeCode, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setSubjectUserId(userId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLog.setAfterData(afterData);
auditLog.setPurposeCode(purposeCode);
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
HttpServletRequest req = attrs.getRequest();
auditLog.setIp(req.getRemoteAddr());
@@ -2,13 +2,14 @@ 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.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
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 org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -23,7 +24,6 @@ public class OperatorAuthController {
private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter;
private final JwtTokenService jwtTokenService;
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
@@ -34,10 +34,11 @@ public class OperatorAuthController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
}
Map<String, Object> result = operatorAuthService.login(username, password);
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌
// 同时下发 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"),
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
(String) result.get("accessToken"), (String) result.get("refreshToken"));
return ApiResponse.ok(result);
}
@@ -61,25 +62,22 @@ public class OperatorAuthController {
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"),
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
(String) result.get("accessToken"), (String) result.get("refreshToken"));
return ApiResponse.ok(result);
}
/**
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
* token 取自 Authorization 头,缺省时回退到 access Cookie。
* operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
* 仅用于黑名单登记。
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) {
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response,
@AuthenticationPrincipal OperatorPrincipal principal) {
String token = readBearer(request);
if (token != null) {
try {
String operatorId = jwtTokenService.getUserIdFromToken(token);
operatorAuthService.logout(operatorId, token);
} catch (Exception ignored) {
// token 已失效也无妨,仍清除 Cookie
}
if (token != null && principal != null) {
operatorAuthService.logout(principal.id(), token);
}
cookieWriter.clearCookies(response);
return ApiResponse.ok();
@@ -27,6 +27,20 @@ public class OperatorCookieWriter {
@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());
@@ -29,6 +29,10 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
@Value("${wechat.app-id:dev-app-id}")
private String appId;
/** 演示环境可固定到预置用户;生产真实微信登录不使用此 Provider。 */
@Value("${wechat.mock.openid:}")
private String configuredOpenid;
// 模拟 openid 映射
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
@@ -36,8 +40,21 @@ public class MockWechatLoginProvider implements WechatLoginProvider {
public Map<String, String> getCodeSession(String code) {
log.debug("[Mock] 微信登录 code: {}", code);
// 演示直通:H5 演示端在无微信环境下用 `demo_openid_xx` 作为 code 登录预置演示用户。
// 生产环境 provider=real(见 application-production.yml),不会进入本 Provider。
if (code != null && code.startsWith("demo_openid_")) {
return Map.of(
"openid", code,
"unionid", "mock_unionid_" + code,
"session_key", "mock_session_key",
"appid", appId
);
}
// 模拟:相同 code 返回相同 openid
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
String openid = configuredOpenid != null && !configuredOpenid.isBlank()
? configuredOpenid
: codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID().toString().substring(0, 8));
return Map.of(
"openid", openid,
@@ -101,6 +101,9 @@ public class OperatorAuthService {
.toList();
// 签发 Token
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
Map<String, Object> claims = new HashMap<>();
claims.put("operatorId", operator.getId());
claims.put("organizationId", operator.getOrganizationId());
@@ -121,6 +124,7 @@ public class OperatorAuthService {
result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken);
result.put("operatorId", operator.getId());
result.put("operatorDisplayId", operator.getDisplayId());
result.put("name", operator.getName());
result.put("username", operator.getUsername());
result.put("organizationId", operator.getOrganizationId());
@@ -154,6 +158,7 @@ public class OperatorAuthService {
Map<String, Object> result = new HashMap<>();
result.put("operatorId", operator.getId());
result.put("operatorDisplayId", operator.getDisplayId());
result.put("name", operator.getName());
result.put("username", operator.getUsername());
result.put("username", operator.getUsername());
@@ -201,6 +206,7 @@ public class OperatorAuthService {
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
result.put("operatorId", operatorInfo.get("operatorId"));
result.put("operatorDisplayId", operatorInfo.get("operatorDisplayId"));
result.put("name", operatorInfo.get("name"));
result.put("username", operatorInfo.get("username"));
result.put("organizationId", operatorInfo.get("organizationId"));
@@ -0,0 +1,44 @@
package com.danyang.match.platform.chat.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 产品当前不提供用户之间的即时聊天。
*
* <p>在 HTTP 和 WebSocket 握手进入 Controller/Handler 前统一拒绝,
* 防止只隐藏小程序入口却留下可调用的通信通道。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ChatDisabledFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String context = request.getContextPath();
if (context != null && !context.isEmpty() && path.startsWith(context)) {
path = path.substring(context.length());
}
if (path.equals("/conversations") || path.startsWith("/conversations/")
|| path.equals("/ws/chat") || path.startsWith("/ws/chat/")) {
response.setStatus(HttpServletResponse.SC_GONE);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"success\":false,\"code\":\"CHAT_DISABLED\","
+ "\"message\":\"平台未提供用户之间的即时聊天功能\",\"data\":null}");
return;
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,55 @@
package com.danyang.match.platform.contactauth.controller;
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 受控联系授权控制器(C 端用户,ZC-MM-003 / B1 立项)。
*
* <p>端点(基路径 {@code /api/v1/contact-authorizations},USER 角色兜底):
* <ul>
* <li>GET {@code /my} —— 我的授权列表 + 待同意队列;</li>
* <li>POST {@code /{id}/consent} —— 用户<b>单独明示同意</b>(独立端点,
* 非任何其他操作的捆绑步骤、无预勾选/预同意参数);</li>
* <li>POST {@code /{id}/decline} —— 用户拒绝。</li>
* </ul>
*
* <p>归属校验(userId.equals)在 Service 层 {@code requireOwnedAuthorization} 承担;
* 不碰 SecurityConfig(路径落入 {@code /** → hasRole("USER")} 兜底规则)。
*/
@RestController
@RequestMapping("/contact-authorizations")
@RequiredArgsConstructor
public class ContactAuthorizationUserController {
private final ContactAuthorizationService contactAuthorizationService;
/** 我的授权列表 + 待同意队列。 */
@GetMapping("/my")
public ApiResponse<Map<String, Object>> my(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(contactAuthorizationService.myAuthorizationsForUser(principal.id()));
}
/** 单独明示同意(独立端点,无预同意参数)。 */
@PostMapping("/{id}/consent")
public ApiResponse<Object> consent(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(contactAuthorizationService.consent(id, principal.id()));
}
/** 拒绝授权请求。 */
@PostMapping("/{id}/decline")
public ApiResponse<Object> decline(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(contactAuthorizationService.decline(id, principal.id()));
}
}
@@ -1,7 +1,5 @@
package com.danyang.match.platform.match.controller;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.match.service.RelationService.LikeResult;
import com.danyang.match.platform.shared.constant.Constants;
@@ -11,7 +9,6 @@ import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -22,18 +19,16 @@ import org.springframework.web.bind.annotation.*;
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
*
* 核心规则:INV-005 未 Mutual 不可聊天
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId }
* 响应返回关系状态和 Match 结果,不创建用户会话。
*
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
*/
@Slf4j
@RestController
@RequestMapping("/likes")
@RequiredArgsConstructor
public class LikeController {
private final RelationService relationService;
private final ChatService chatService;
@Idempotent
@PostMapping
@@ -44,7 +39,7 @@ public class LikeController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
LikeResult result = relationService.like(principal.id(), req.targetUserId);
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId)));
return ApiResponse.ok(LikeResponse.from(result));
}
/**
@@ -62,20 +57,8 @@ public class LikeController {
/**
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
*
* - getOrCreateConversation 幂等,不会重复建会话
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
*/
private String ensureConversation(LikeResult result, String userId, String targetUserId) {
if (!result.mutual || result.match == null) return null;
try {
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
return conv.getId();
} catch (RuntimeException e) {
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
userId, targetUserId, result.match.getId(), e.getMessage());
return null;
}
}
// 当前产品不提供用户间会话;Match 成功后仅展示结果和公益服务入口。
// ==================== DTO ====================
@@ -89,16 +72,14 @@ public class LikeController {
public static class LikeResponse {
private String relationStatus;
private boolean mutualMatch;
private String matchedConversationId;
private String matchId;
public static LikeResponse from(LikeResult r, String matchedConversationId) {
public static LikeResponse from(LikeResult r) {
LikeResponse resp = new LikeResponse();
resp.relationStatus = r.mutual
? Constants.RELATION_STATUS_MUTUAL_MATCH
: Constants.RELATION_STATUS_LIKED;
resp.mutualMatch = r.mutual;
resp.matchedConversationId = matchedConversationId;
resp.matchId = r.match != null ? r.match.getId() : null;
return resp;
}
@@ -51,7 +51,9 @@ public class MatchController {
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
List<Match> matches = matchMapper.findActiveByUser(principal.id());
List<MatchResponse> items = matches.stream().map(MatchResponse::from).collect(Collectors.toList());
List<MatchResponse> items = matches.stream()
.map(match -> MatchResponse.from(match, principal.id()))
.collect(Collectors.toList());
// 与全平台列表接口统一信封 { items, nextCursor };当前一次性返回全部,nextCursor 恒为 null
return ApiResponse.ok(CursorPage.all(items));
}
@@ -67,7 +69,7 @@ public class MatchController {
if (!m.getUserAId().equals(principal.id()) && !m.getUserBId().equals(principal.id())) {
return ApiResponse.forbidden();
}
return ApiResponse.ok(MatchResponse.from(m));
return ApiResponse.ok(MatchResponse.from(m, principal.id()));
}
@Idempotent
@@ -166,13 +168,13 @@ public class MatchController {
private java.time.Instant lastActivityAt;
private Integer episode;
public static MatchResponse from(Match m) {
public static MatchResponse from(Match m, String currentUserId) {
MatchResponse r = new MatchResponse();
r.id = m.getId();
r.source = m.getSource();
r.sourceId = m.getSourceId();
r.status = m.getStatus();
r.peerUserId = m.getUserAId(); // 客户端需要自己判断对方
r.peerUserId = currentUserId.equals(m.getUserAId()) ? m.getUserBId() : m.getUserAId();
r.matchedAt = m.getMatchedAt();
r.lastActivityAt = m.getLastActivityAt();
r.episode = m.getEpisode();
@@ -310,7 +310,11 @@ public class RecommendationEngineService {
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setScore(java.math.BigDecimal.valueOf(score));
record.setLevel(toLevel(score));
record.setReasons(String.join(";", reasons));
// recommendation_records.reasons 为 jsonb 列:必须写入合法 JSON。
// 此前写入 "a;b;c" 纯文本,仅在推荐结果非空(真正触发写入)时才会
// 触发 PostgreSQL「invalid input syntax for type json」,导致整个
// /home 聚合接口 500。改为序列化为 JSON 数组。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -342,6 +346,16 @@ public class RecommendationEngineService {
}
}
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/**
* 内部评分结果
*/
@@ -9,6 +9,7 @@ import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.UserProfileService;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
public class RecommendationService {
private static final Logger log = LoggerFactory.getLogger(RecommendationService.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final MatchEligibilityService eligibilityService;
private final UserProfileMapper userProfileMapper;
@@ -61,7 +63,9 @@ public class RecommendationService {
record.setTargetUserId(targetUserId);
record.setSource(Constants.RECOMMENDATION_SOURCE_DAILY);
record.setLevel(level);
record.setReasons(new java.util.ArrayList<>(reasons).toString());
// recommendation_records.reasons 为 jsonb 列,必须写入合法 JSON
// (原 new ArrayList<>(reasons).toString() 产出 [a, b] 亦非合法 JSON)。
record.setReasons(writeReasonsJson(reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
@@ -70,6 +74,16 @@ public class RecommendationService {
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
}
/** 推荐理由序列化为 JSON 数组字符串,写入 recommendation_records.reasons (jsonb)。 */
private String writeReasonsJson(List<String> reasons) {
try {
return objectMapper.writeValueAsString(reasons == null ? List.of() : reasons);
} catch (Exception e) {
log.warn("序列化推荐理由失败", e);
return "[]";
}
}
/**
* 记录用户对推荐的响应(Like/Pass/Block)
*/
@@ -0,0 +1,67 @@
package com.danyang.match.platform.matchmaker.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 受控联系授权配置(ZC-MM-003 / B1 立项,对照 {@code ProfileProperties} 范式)。
*
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):
* 五个 canonical key 统一在此管理,禁止散落在 Controller/Service/前端各自写死;
* 治理登记见 T06 config-register(OQ-MATCHMAKER-003)。
*
* <p><b>fail-safe 语义</b>:{@code enabled} 的 Java 字段缺省为 <b>false</b> ——
* 若配置键完全缺失(如新环境漏配),机制整体关闭(fail-safe 关闭优于意外开放)。
* 生产 {@code application.yml} 显式声明 {@code matchmaker.contact.enabled: true};
* "默认开启" 指的是配置注册表中的生效默认值,而非代码缺省。
*/
@Data
@Component
@ConfigurationProperties(prefix = "matchmaker.contact")
public class ContactAuthorizationConfig {
/**
* 机制总开关。
* Java 缺省 false(fail-safe:配置缺失 → 机制关闭);生产 yml 显式置 true。
* disclose(H4 唯一披露出口)前强校验,关闭时任何披露请求 404 化拒绝。
*/
private boolean enabled = false;
/**
* 审批岗权限码(逗号分隔,<b>分层语义</b>,用户裁决①:组长主审 + 管理员兜底)。
* 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。
*
* <p>分层规则(ContactAuthorizationService.assertApproverAuthority):
* <ol>
* <li>列表<b>首段 = 主审岗</b>(默认 {@code matchmaker.team.assign},组长)——
* 仅可审批本组织红娘的申请(NULL=NULL 平台直属,assertTeamScope 复用);</li>
* <li><b>兜底岗键固定 {@code matchmaker.assign}</b>(平台管理员)——
* 可审批全部申请且不做组织校验;兜底键不受本列表调整影响(权限面不放大);</li>
* <li>两者皆无 → 403 拒绝。</li>
* </ol>
*
* <p>审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize
* 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。
*/
private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign";
/**
* 用户同意窗口(小时):pending_consent 超窗未同意 → 惰性 expired。
* 默认 72h(治理登记 OQ-MATCHMAKER-003 技术默认,待产品确认)。
*/
private int consentDeadlineHours = 72;
/**
* 授权默认有效期(天):用户 consent 起算,expires_at = consent + N 天。
* 默认 7 天;到期由双态惰性收敛置 expired。
*/
private int defaultValidityDays = 7;
/**
* 授权审计数据保留期(天):受控联系相关审计/敏感访问记录的保留下限口径。
* 默认 365 天;当前以 /my 响应元数据形式下发(retentionDays 字段)供前端展示,
* 治理登记 T06 open-questions 同步说明。
*/
private int retentionDays = 365;
}
@@ -0,0 +1,160 @@
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 com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
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;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
/**
* 创建分配。
* 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) {
var user = userMapper.findById(a.getUserId()).orElse(null);
var profile = userProfileMapper.findByUserId(a.getUserId()).orElse(null);
return Map.ofEntries(
Map.entry("id", a.getId()),
Map.entry("userId", a.getUserId()),
Map.entry("userDisplayNumber", user == null || user.getDisplayNumber() == null ? "" : user.getDisplayNumber()),
Map.entry("userDisplayName", profile == null || profile.getDisplayName() == null ? "" : profile.getDisplayName()),
Map.entry("userGender", profile == null || profile.getGender() == null ? "" : profile.getGender()),
Map.entry("userAge", profile == null || profile.getBirthDate() == null ? "" : String.valueOf(java.time.Period.between(profile.getBirthDate(), java.time.LocalDate.now()).getYears())),
Map.entry("userRegionName", profile == null || profile.getRegionName() == null ? "" : profile.getRegionName()),
Map.entry("matchmakerOperatorId", a.getMatchmakerOperatorId()),
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
Map.entry("assignmentScope", a.getAssignmentScope()),
Map.entry("status", a.getStatus()),
Map.entry("assignmentReason", a.getAssignmentReason()),
Map.entry("assignedAt", a.getAssignedAt() == null ? "" : a.getAssignedAt().toString()),
Map.entry("expiresAt", a.getExpiresAt() == null ? "" : a.getExpiresAt().toString()),
Map.entry("endedAt", a.getEndedAt() == null ? "" : a.getEndedAt().toString()),
Map.entry("endReason", a.getEndReason() == null ? "" : a.getEndReason())
);
}
}
@@ -0,0 +1,106 @@
package com.danyang.match.platform.matchmaker.controller;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 受控联系授权控制器(运营端,ZC-MM-003 / B1 立项)。
*
* <p>端点(基路径 {@code /api/v1/operator/matchmaker/contact-authorizations}):
* <ul>
* <li>POST {@code ""} —— 红娘发起授权申请(matchmaker.service,purpose 必填);</li>
* <li>POST {@code /{id}/approve} / {@code /{id}/reject} —— 审批岗批准/拒绝
* (审批岗权限码在 Service 层动态判定 ∈ approver-authorities,组长另有组织范围校验);</li>
* <li>GET {@code /my} —— 红娘本人授权列表;</li>
* <li>POST {@code /{id}/revoke} —— 申请人或审批人撤销(reason 必填);</li>
* <li>GET {@code /disclose} —— H4 唯一披露出口(userId/purpose 必填,
* 每次落 SensitiveAccessLog;phone 不落任何应用日志)。</li>
* </ul>
*
* <p>类级 {@code matchmaker.view} 覆盖列表/详情读;写动作方法级收敛;
* 审批岗动态权限与 G4 404 化拒绝、approve 403 边界均由
* {@code ContactAuthorizationService} 承担(T06 open-questions OQ-MATCHMAKER-009)。
*/
@RestController
@RequestMapping("/operator/matchmaker/contact-authorizations")
@PreAuthorize("hasAuthority('matchmaker.view')")
@RequiredArgsConstructor
public class MatchmakerContactAuthorizationController {
private final ContactAuthorizationService contactAuthorizationService;
/** 红娘发起授权申请(须持分配;purpose 必填)。 */
@PostMapping
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<MatchmakerContactAuthorization> request(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(contactAuthorizationService.request(
principal.id(),
body.get("userId"),
body.get("channel"),
body.get("purpose")));
}
/** 审批岗批准(权限动态判定 + 组长组织范围校验)。 */
@PostMapping("/{id}/approve")
public ApiResponse<MatchmakerContactAuthorization> approve(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(contactAuthorizationService.approve(
id, principal.id(), principal.permissions()));
}
/** 审批岗拒绝。 */
@PostMapping("/{id}/reject")
public ApiResponse<MatchmakerContactAuthorization> reject(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(contactAuthorizationService.reject(
id, principal.id(), principal.permissions()));
}
/** 红娘本人授权列表(含保留期元数据)。 */
@GetMapping("/my")
public ApiResponse<Map<String, Object>> my(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(contactAuthorizationService.myAuthorizations(principal.id()));
}
/** 审批岗待处理队列;服务层按平台管理员/组长组织范围过滤。 */
@GetMapping("/pending")
public ApiResponse<Map<String, Object>> pending(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(contactAuthorizationService.pendingApprovals(
principal.id(), principal.permissions()));
}
/** 申请人或审批人撤销(reason 必填)。 */
@PostMapping("/{id}/revoke")
public ApiResponse<MatchmakerContactAuthorization> revoke(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(contactAuthorizationService.revoke(
id, principal.id(), body.get("reason")));
}
/** H4 唯一披露出口:userId/purpose 必填,channel 缺省 phone。 */
@GetMapping("/disclose")
public ApiResponse<Map<String, Object>> disclose(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestParam String userId,
@RequestParam String purpose,
@RequestParam(required = false) String channel) {
return ApiResponse.ok(contactAuthorizationService.disclose(
principal.id(), userId, channel, purpose));
}
}
@@ -54,7 +54,8 @@ public class MatchmakerController {
String serviceType = (req.serviceType == null || req.serviceType.isBlank())
? "recommendation" : req.serviceType;
String description = req.description != null ? req.description : req.requirement;
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description));
return ApiResponse.ok(matchmakerService.submitServiceRequest(principal.id(), serviceType, description,
req.targetUserId, req.authorizationAccepted));
}
@GetMapping("/services")
@@ -64,6 +65,23 @@ public class MatchmakerController {
return ApiResponse.ok(matchmakerService.myServices(principal.id(), limit));
}
@Idempotent
@PostMapping("/services/{id}/confirm")
public ApiResponse<MatchmakerServiceRecord> confirmService(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(matchmakerService.confirmService(id, principal.id()));
}
@Idempotent
@PostMapping("/services/{id}/withdraw")
public ApiResponse<Void> withdraw(@PathVariable String id, @RequestBody(required = false) WithdrawRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
matchmakerService.withdrawService(id, principal.id(), req == null ? null : req.reason);
return ApiResponse.ok();
}
@GetMapping("/recommendations")
public ApiResponse<List<MatchmakerRecommendation>> pendingRecs(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
@@ -103,5 +121,10 @@ public class MatchmakerController {
private String description;
/** 兼容字段:小程序约定的需求描述(requirement -> description) */
private String requirement;
private String targetUserId;
private Boolean authorizationAccepted;
}
@Data
public static class WithdrawRequest { private String reason; }
}
@@ -13,6 +13,11 @@ import java.util.Map;
/**
* 红娘工作台控制器(Operator调用)
*
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
* 服务单状态由分配动作同事务联动(submitted → assigned)。
*/
@RestController
@RequestMapping("/operator/matchmaker")
@@ -21,6 +26,20 @@ import java.util.Map;
public class MatchmakerOperatorController {
private final MatchmakerServiceService matchmakerServiceService;
private final com.danyang.match.platform.matchmaker.service.MatchmakerManagementService managementService;
@GetMapping("/management/services")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> managementServices(@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(managementService.list(principal));
}
@GetMapping("/management/services/{id}")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<Map<String, Object>> managementDetail(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(managementService.detail(principal, id));
}
@GetMapping("/dashboard")
public ApiResponse<Map<String, Object>> getDashboard(
@@ -29,42 +48,57 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId));
}
/**
* 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者
* (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列,
* 普通红娘(仅 matchmaker.view/service)403。
*
* <p>组长视角说明(ZC-MM-005 / E4,治理登记 007):matchmaker_services 表无组织归属字段
* (organization_id 恒 null 的已知限制),因此待分配队列无法按组长组织过滤,
* 权衡后对分配权持有者全局可读 —— 待分配服务单尚无负责人,队列不含敏感分配关系。
* 组织归属字段的补齐登记至 ZC-MM-007。
*/
@GetMapping("/pending")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> getPending(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(matchmakerServiceService.getPendingServices());
}
/**
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
*/
@PostMapping("/services/{id}/assign")
@PreAuthorize("hasAuthority('matchmaker.assign')")
public ApiResponse<?> assignService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = principal.id();
String assignOperatorId = body.get("operatorId");
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null));
String assignerOperatorId = principal.id();
String targetOperatorId = body.get("operatorId");
String reason = body.getOrDefault("reason", "红娘服务单分配");
return ApiResponse.ok(matchmakerServiceService.assignService(
id, assignerOperatorId, targetOperatorId, reason));
}
@PostMapping("/services/{id}/claim")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<?> claimService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
String operatorId = principal.id();
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
}
@GetMapping("/my-services")
public ApiResponse<?> getMyServices(
@AuthenticationPrincipal OperatorPrincipal principal) {
String operatorId = principal.id();
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
}
// GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
// 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
// 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
// GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
// getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
/**
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
* 红娘名下服务的全部推荐列表(运营端工作台用)。
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
*
* <p>口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments}
* 中本人<b>生效中的分配</b>(active 且未过有效期)→ 该分配对象(user)名下
* 服务单上的推荐,经 matchmaker_services 桥接;既不按
* matchmaker_services.assigned_operator_id 残留列过滤,也<b>不</b>按
* recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status
* 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见
* (DEC-002 规则7:撤销即失权)。
*/
@GetMapping("/recommendations")
public ApiResponse<List<Map<String, Object>>> getRecommendations(
@@ -72,16 +106,19 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
}
/**
* 服务详情(ZC-MM-005 / E2 防探测 404)。
*
* <p>语义变更:非持有人与查无 serviceId 抛出完全相同的
* BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致
* (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。
* 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。
*/
@GetMapping("/services/{id}")
public ApiResponse<Map<String, Object>> getServiceDetail(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
if (detail == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
}
return ApiResponse.ok(detail);
return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
}
@PostMapping("/services/{id}/recommend")
@@ -114,7 +151,8 @@ public class MatchmakerOperatorController {
public ApiResponse<?> completeService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(matchmakerServiceService.completeService(id));
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy(服务层完成)
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
}
}
@@ -0,0 +1,62 @@
package com.danyang.match.platform.matchmaker.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 服务对象分配(ZC-MM-002 / A1)
* 对应 DB: matchmaker_assignments(V053)
*
* <p>建模说明:
* <ul>
* <li>{@code matchmakerOperatorId}:持有该用户服务对象的红娘(operators 主体,
* 沿用 matchmaker_services.assigned_operator_id 惯例)。</li>
* <li>{@code assignedBy}:做出本次分配的操作者(组长 / 平台授权人员)。</li>
* <li>状态机(D5):active → transferred / revoked / ended;
* expires_at 届满由 {@code ServiceAssignmentPolicy} 惰性置 expired(无定时任务);
* 终态一律拒绝再转换。</li>
* <li>scope:primary(主责,同用户仅一条生效中,DB 部分唯一索引兜底)/
* collaborating(协同)。</li>
* </ul>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_assignments")
public class MatchmakerAssignment extends BaseEntity {
/** 被服务用户 */
private String userId;
/** 持有服务对象的红娘(operators.id) */
private String matchmakerOperatorId;
/** 关联的服务单(可空:允许脱离服务单的用户级分配) */
private String serviceId;
/** 做出本次分配的操作者(operators.id) */
private String assignedBy;
/** 分配范围:primary / collaborating(与 V053 chk_mm_assignment_scope 白名单一致) */
private String assignmentScope;
/** 分配理由(必填) */
private String assignmentReason;
/** 状态:active/ended/revoked/transferred/expired(与 V053 chk_mm_assignment_status 白名单一致) */
private String status;
private Instant assignedAt;
/** 有效期届满时间;null 表示长期有效 */
private Instant expiresAt;
/** 终态时间(ended/revoked/transferred/expired 时落库) */
private Instant endedAt;
/** 终态理由(revoked/ended 必填;transferred 记录转分配理由;expired 为固定文案) */
private String endReason;
}
@@ -0,0 +1,42 @@
package com.danyang.match.platform.matchmaker.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 受控联系授权(ZC-MM-003 / B1 例外通道,架构师设计 H4)。
*
* <p>状态机(六态):pending_approval → pending_consent → active / rejected;
* pending_consent / active 双态惰性收敛为 expired(有效期或同意窗口届满);
* active → revoked(申请人或审批人撤销)。V056 建表。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_contact_authorizations")
public class MatchmakerContactAuthorization extends BaseEntity {
/** 发起申请的红娘 operator id */
private String operatorId;
/** 被联系用户 id */
private String userId;
/** 联系渠道:phone / offline_meeting / full_contact */
private String channel;
/** 授权目的(必填,用户同意页明示) */
private String purpose;
/** 状态:pending_approval / pending_consent / active / rejected / expired / revoked */
private String status;
/** 审批岗 operator id */
private String approvedBy;
private Instant approvedAt;
/** 用户单独同意时间(独立端点,无预同意参数) */
private Instant userConsentedAt;
/** 生效截止:pending 期为同意窗口上界,consent 后重算为 consent + validity days */
private Instant expiresAt;
private Instant revokedAt;
private String revokedBy;
private String revokeReason;
}
@@ -25,6 +25,10 @@ public class MatchmakerServiceRecord extends BaseEntity {
private String userId;
private String serviceType;
private String description;
private String targetUserId;
private java.time.Instant authorizationAt;
private java.time.Instant withdrawnAt;
private String withdrawReason;
private String status;
private String organizationId;
private String assignedOperatorId;
@@ -0,0 +1,100 @@
package com.danyang.match.platform.matchmaker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 服务对象分配 Mapper(ZC-MM-002 / A1)
*
* <p>「生效中」的统一口径:status = 'active' 且(expires_at IS NULL OR expires_at &gt; NOW())。
* 惰性过期由 {@link #expireOverdue} 负责(读前触发,无定时任务),
* 这里的 expires 条件是双保险:即使惰性过期尚未执行,已届满的行也不会被当作生效中。
*/
@Mapper
public interface MatchmakerAssignmentMapper extends BaseMapper<MatchmakerAssignment> {
@Select("SELECT * FROM matchmaker_assignments WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findById(@Param("id") String id);
/** 某红娘名下全部生效中的分配(GET /my、GET /assignable 的工作台口径) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findActiveByOperator(@Param("operatorId") String operatorId);
/** 某红娘对某用户是否持有生效中的分配(数据范围判定核心查询) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND user_id = #{userId} AND status = 'active' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
/** 某用户当前生效中的主责分配(创建/转分配前的唯一性预检) */
@Select("SELECT * FROM matchmaker_assignments WHERE user_id = #{userId} " +
"AND status = 'active' AND assignment_scope = 'primary' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findActivePrimaryByUser(@Param("userId") String userId);
/** 某服务单上生效中的分配(服务单维度的归属判定) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByService(@Param("serviceId") String serviceId);
/** 某服务单的全部分配历史(追溯用) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findByService(@Param("serviceId") String serviceId);
/**
* 惰性过期:把 expires_at 已届满的 active 行置为 expired 并落库。
* 由 {@code ServiceAssignmentPolicy} 在读取路径前触发,无定时任务。
*
* @return 本次被置为 expired 的行数
*/
@Update("UPDATE matchmaker_assignments SET status = 'expired', " +
"ended_at = NOW(), end_reason = '有效期届满自动失效', updated_at = NOW() " +
"WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() " +
"AND deleted_at IS NULL")
int expireOverdue();
// ==================== ZC-MM-005 / E4:组长团队统计(纯脱敏计数) ====================
// 组织边界:organization_id IS NOT DISTINCT FROM #{organizationId} ——
// NULL=NULL 平台直属规则在 SQL 层等价复用(有组织组长统计本组织;NULL 组长统计平台直属)。
/** 某组织范围内生效中的分配总数 */
@Select("SELECT COUNT(*) FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
long countActiveByOrganization(@Param("organizationId") String organizationId);
/** 某组织范围内各红娘的生效分配数(operatorId/name/activeCount;别名加引号保持驼峰键名) */
@Select("SELECT o.id AS \"operatorId\", o.name AS \"operatorName\", COUNT(*) AS \"activeCount\" " +
"FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId} " +
"GROUP BY o.id, o.name ORDER BY \"activeCount\" DESC")
List<Map<String, Object>> countActiveGroupByOperator(@Param("organizationId") String organizationId);
/** 某组织范围内持有生效分配的红娘数(去重) */
@Select("SELECT COUNT(DISTINCT ma.matchmaker_operator_id) FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
long countActiveOperators(@Param("organizationId") String organizationId);
}
@@ -0,0 +1,65 @@
package com.danyang.match.platform.matchmaker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Optional;
/**
* 受控联系授权 Mapper(ZC-MM-003 / B1 立项)。
*
* <p>双态惰性收敛(对照 005 expireOverdue 范式,读前触发、无定时任务):
* ① active 且 expires_at 届满 → expired(授权有效期到);
* ② pending_consent 且同意窗口(expires_at 在 request/approve 期承载窗口上界)届满 → expired。
*/
@Mapper
public interface MatchmakerContactAuthorizationMapper extends BaseMapper<MatchmakerContactAuthorization> {
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerContactAuthorization> findById(@Param("id") String id);
/** 某红娘的全部授权记录(工作台列表,倒序) */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerContactAuthorization> findByOperator(@Param("operatorId") String operatorId);
/** 审批队列:仅返回待审批申请,组织范围由服务层按审批权限过滤。 */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE status = 'pending_approval' " +
"AND deleted_at IS NULL ORDER BY created_at ASC")
List<MatchmakerContactAuthorization> findPendingApproval();
/** 用户待同意队列(pending_consent,C 端同意页数据源) */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
"AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " +
"ORDER BY created_at DESC")
List<MatchmakerContactAuthorization> findPendingConsentByUser(@Param("userId") String userId);
/** 某红娘对某用户某渠道当前生效中的授权 */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
"AND user_id = #{userId} AND channel = #{channel} AND status = 'active' " +
"AND expires_at > NOW() AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerContactAuthorization> findActiveByOperatorUserChannel(@Param("operatorId") String operatorId,
@Param("userId") String userId,
@Param("channel") String channel);
/** 某用户的全部授权记录(C 端我的授权列表) */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerContactAuthorization> findByUser(@Param("userId") String userId);
/**
* 双态惰性收敛:
* ① active 过期 → expired;
* ② pending_consent 超同意窗口 → expired(expires_at 即窗口上界)。
*
* @return 本次被收敛的行数
*/
@Update("UPDATE matchmaker_contact_authorizations SET status = 'expired', updated_at = NOW() " +
"WHERE status IN ('active', 'pending_consent') AND expires_at < NOW() AND deleted_at IS NULL")
int expireOverdue();
}
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
int markViewed(@Param("id") String id);
/**
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
* 避免运营端越权看到他人推荐。
* 某红娘推荐列表(运营端工作台用)。
*
* <p>ZC-MM-005 收口(P1-1):范围事实源由 matchmaker_services.assigned_operator_id
* 残留列切换为 matchmaker_assignments 生效中分配(active 且未过有效期)。
* 语义 = 「本人当前有效分配对象(user)名下服务单上的推荐」—— 分配 revoke/end 后
* 残留列不再构成可见凭据(DEC-002 规则7:撤销即失权)。
*/
@Select("SELECT r.* FROM matchmaker_recommendations r " +
"JOIN matchmaker_services s ON s.id = r.service_id " +
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
"JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"ORDER BY r.created_at DESC")
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
}
@@ -5,6 +5,7 @@ import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Optional;
@@ -12,6 +13,23 @@ import java.util.Optional;
@Mapper
public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRecord> {
@Select("<script>SELECT s.id AS \"serviceId\", s.status, s.service_type AS \"serviceType\", " +
"s.description, s.target_user_id AS \"targetUserId\", s.authorization_at AS \"authorizationAt\", " +
"s.created_at AS \"createdAt\", s.assigned_at AS \"assignedAt\", s.completed_at AS \"completedAt\", " +
"u.display_number AS \"userDisplayNumber\", o.display_id AS \"operatorDisplayId\", " +
"tu.display_number AS \"targetUserDisplayNumber\", " +
"(SELECT f.content FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupContent\", " +
"(SELECT f.created_at FROM matchmaker_followups f WHERE f.service_id=s.id AND f.deleted_at IS NULL " +
"ORDER BY f.created_at DESC LIMIT 1) AS \"latestFollowupAt\" " +
"FROM matchmaker_services s LEFT JOIN users u ON u.id=s.user_id " +
"LEFT JOIN users tu ON tu.id=s.target_user_id " +
"LEFT JOIN operators o ON o.id=s.assigned_operator_id AND o.deleted_at IS NULL " +
"WHERE s.deleted_at IS NULL <if test='!global'>AND o.id IS NOT NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}</if> ORDER BY s.created_at DESC</script>")
List<java.util.Map<String, Object>> findManagementServices(@Param("global") boolean global,
@Param("organizationId") String organizationId);
@Select("SELECT * FROM matchmaker_services WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerServiceRecord> findById(@Param("id") String id);
@@ -26,35 +44,64 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
long countSubmitted();
/** 某红娘进行中的服务数(工作台统计) */
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND status = 'processing' AND deleted_at IS NULL")
/**
* 某红娘进行中的服务数(工作台统计)。
* ZC-MM-005 收口(P1-2):范围事实源由 assigned_operator_id 残留列切换为
* matchmaker_assignments 生效中分配(active 且未过有效期)——「本人名下进行中」
* = 本人 active 分配关联 user 的服务单中 status='in_service'。
* 分配 end/revoke 后,该用户的服务单不再计入本人(DEC-002 规则7:服务结束失去访问权)。
* 状态口径沿用 ZC-MM-002(D4)修正:'in_service'('processing' 不在 V009 白名单)。
*/
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE s.status = 'in_service' AND s.deleted_at IS NULL")
long countInProgressByOperator(@Param("operatorId") String operatorId);
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-005 收口 / P1-2:按生效分配 join) */
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE CAST(s.assigned_at AS DATE) = CURRENT_DATE AND s.deleted_at IS NULL")
long countTodayByOperator(@Param("operatorId") String operatorId);
/**
* 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-005 收口 / P1-2)。
* 口径说明(与 E1「服务结束失去访问权」一致):按当前生效分配统计 ——
* 分配 end/revoke 后,该用户的历史 completed 服务单<b>不再计入</b>本人完成数;
* 仅当分配仍生效时,其名下 completed 单才计入。
*/
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE s.status = 'completed' AND s.deleted_at IS NULL")
long countCompletedByOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id=#{userId} AND target_user_id=#{targetUserId} " +
"AND service_type='recommendation_assistance' AND status NOT IN ('completed','cancelled') AND deleted_at IS NULL")
long countActiveAssistance(@Param("userId") String userId, @Param("targetUserId") String targetUserId);
@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);
@Update("UPDATE matchmaker_services SET status='cancelled', withdrawn_at=NOW(), withdraw_reason=#{reason}, updated_at=NOW() " +
"WHERE id=#{serviceId} AND user_id=#{userId} AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
int withdraw(@Param("serviceId") String serviceId, @Param("userId") String userId, @Param("reason") String reason);
/**
* 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);
/** 用户确认红娘服务方案:仅允许本人将 waiting_user 恢复为 in_service。 */
@Update("UPDATE matchmaker_services SET status = 'in_service', updated_at = NOW() " +
"WHERE id = #{serviceId} AND user_id = #{userId} AND status = 'waiting_user' AND deleted_at IS NULL")
int confirmWaitingForUser(@Param("serviceId") String serviceId, @Param("userId") String userId);
/**
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。
* 状态口径(submitted/assigned/in_service/waiting_user)。
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
*/
@@ -0,0 +1,55 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* 受控联系授权判定器(ZC-MM-003 / B1 立项,架构师设计 G4)。
*
* <p>红娘对某用户某渠道能否披露联系方式,唯一事实源是
* {@code matchmaker_contact_authorizations} 中「生效中的授权」
* (status=active 且未过期,读前双态惰性收敛)。
*
* <p>判定无效时走 {@link DataScopeGuard#denyAsNotFound}:先落 DATA_SCOPE_DENIED 审计,
* 再抛 404「服务不存在」(G4 防探测范式,与分配判定器同款)。
*/
@Component
@RequiredArgsConstructor
public class ContactAuthorizationPolicy {
private final MatchmakerContactAuthorizationMapper authorizationMapper;
private final DataScopeGuard dataScopeGuard;
/**
* 断言生效中的授权;无效时先审计后 404。
*
* @return 生效中的授权记录(永不返回 null)
*/
public MatchmakerContactAuthorization assertContactAllowed(String operatorId, String userId,
String channel, String purpose) {
Optional<MatchmakerContactAuthorization> found = canContact(operatorId, userId, channel);
if (found.isPresent()) {
return found.get();
}
dataScopeGuard.denyAsNotFound(operatorId, "matchmaker_contact_authorization", userId,
"服务不存在", null, null);
return null; // denyAsNotFound 必然抛出,此行不可达
}
/**
* 徽标查询(不审计):双态惰性收敛后查生效授权。
* 供 UI「可联系」徽标等高频读场景,避免每徽标一次拒绝审计。
*/
public Optional<MatchmakerContactAuthorization> canContact(String operatorId, String userId,
String channel) {
authorizationMapper.expireOverdue();
return authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId,
channel == null ? Constants.CONTACT_CHANNEL_PHONE : channel);
}
}
@@ -0,0 +1,516 @@
package com.danyang.match.platform.matchmaker.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
import com.danyang.match.platform.matchmaker.config.ContactAuthorizationConfig;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.permission.entity.Operator;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 受控联系授权服务(ZC-MM-003 / B1 立项,架构师设计 H4)。
*
* <p>六操作状态机(全矩阵):
* <pre>
* request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
* approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
* {@link #assertApproverAuthority})pending_approval → pending_consent;
* 违规 403(无资源枚举面维持 403)
* reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
* consent : C 端用户本人(归属校验,独立端点、无预同意参数)
* pending_consent → active(expires_at = consent + default-validity-days)
* decline : C 端用户本人 pending_consent → rejected
* revoke : 申请人或审批人,reason 必填 → revoked
* disclose : H4 唯一披露出口 —— enabled(fail-safe) → 持分配 → 生效授权;
* 返回 {phone, disclosedAt, authorizationId};每次落 SensitiveAccessLog;
* phone 不落任何应用日志
* </pre>
*
* <p>拒绝口径(G4 防探测):disclose 及授权有效性判定一律 404 化 deny
* (先 DATA_SCOPE_DENIED 审计再 404「服务不存在」);approve/管理类违规维持 403。
* 状态机非法转换一律 CONFLICT。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ContactAuthorizationService {
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure";
/**
* 兜底审批岗权限键(用户裁决① 固定):持有者可审批全部申请且不做组织校验。
* 主审岗权限键仍由 approver-authorities 列表首段配置(默认 matchmaker.team.assign)。
*/
private static final String FALLBACK_APPROVER_AUTHORITY = "matchmaker.assign";
private final MatchmakerContactAuthorizationMapper authorizationMapper;
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
private final MatchmakerAssignmentService matchmakerAssignmentService;
private final ContactAuthorizationPolicy contactAuthorizationPolicy;
private final ContactAuthorizationConfig config;
private final DataScopeGuard dataScopeGuard;
private final AuditLogMapper auditLogMapper;
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final UserMapper userMapper;
private final OperatorMapper operatorMapper;
/** afterData JSON 序列化(静态初始化,不参与构造注入,避免测试桩连带改动)。 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// ==================== ① request:红娘发起申请 ====================
@Transactional
public MatchmakerContactAuthorization request(String operatorId, String userId,
String channel, String purpose) {
// fail-safe 最先(与 disclose 同口径):机制关闭(配置缺失/显式关闭)→ 404 化拒绝。
// ZC-MM-003 裁决落地 T-C:request 原实现漏查 enabled(真实缺口,本轮补齐)。
if (!config.isEnabled()) {
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
"服务不存在", null, null);
}
if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
}
requireValidChannel(channel);
// 未持分配申请 → 403 + DATA_SCOPE_DENIED 审计(申请凭据是分配而非授权本身)
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignment(
operatorId, userId, RESOURCE_TYPE);
// 同 (operator,user,channel) 已有生效授权或待处理申请 → CONFLICT(防重复申请洪泛)
if (authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId, channel).isPresent()) {
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在生效中的授权");
}
boolean hasPending = authorizationMapper.findByOperator(operatorId).stream()
.anyMatch(a -> a.getUserId().equals(userId) && a.getChannel().equals(channel)
&& (Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(a.getStatus())
|| Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(a.getStatus())));
if (hasPending) {
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在待处理的授权申请");
}
MatchmakerContactAuthorization auth = new MatchmakerContactAuthorization();
auth.setOperatorId(operatorId);
auth.setUserId(userId);
auth.setChannel(channel);
auth.setPurpose(purpose.trim());
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL);
// pending 期 expires_at 承载同意窗口上界(consent 后重算为有效期)
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
authorizationMapper.insert(auth);
log.info("受控联系授权申请: authId={}, operatorId={}, userId={}, channel={}",
auth.getId(), operatorId, userId, channel);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(),
assignment != null ? assignment.getId() : null,
"matchmaker.contact.request", jsonOf("channel", channel, "purpose", auth.getPurpose()));
return auth;
}
// ==================== ② approve / ③ reject:审批岗(分层) ====================
/**
* 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
*
* <p>分层规则:
* <ol>
* <li>持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请,
* <b>不做组织校验</b>(见 {@link #assertOrgScopeForApprover});</li>
* <li>否则持主审岗权限键(approver-authorities 列表首段,默认
* {@code matchmaker.team.assign},组长)→ 主审,须通过
* {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);</li>
* <li>两者皆无 → 403 deny(不变)。</li>
* </ol>
*
* <p>主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign}
* —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。
*/
private void assertApproverAuthority(String approverOperatorId, String authId,
Set<String> principalPermissions) {
boolean authorized = isFallbackApprover(principalPermissions)
|| isPrimaryApprover(principalPermissions);
if (!authorized) {
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
"非审批岗,禁止审批受控联系授权");
}
}
/** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */
private boolean isFallbackApprover(Set<String> principalPermissions) {
return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY);
}
/** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */
private boolean isPrimaryApprover(Set<String> principalPermissions) {
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
return principalPermissions != null
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
}
/**
* 分层组织范围校验(用户裁决①):兜底岗(持 matchmaker.assign)跳过组织校验;
* 主审岗(组长)固定走 {@code assertTeamScope}(hasGlobalAssign=false ——
* 是否兜底由本层判定,不再向底层传组织豁免,防止权限面双通道扩大)。
*/
private void assertOrgScopeForApprover(String approverOperatorId, String authId,
Set<String> principalPermissions, String targetOperatorId) {
if (isFallbackApprover(principalPermissions)) {
return;
}
matchmakerAssignmentService.assertTeamScope(approverOperatorId, false,
targetOperatorId, authId);
}
@Transactional
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
Set<String> principalPermissions) {
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
MatchmakerContactAuthorization auth = requireAuthorization(authId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许审批");
}
// 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
auth.getOperatorId());
auth.setApprovedBy(approverOperatorId);
auth.setApprovedAt(Instant.now());
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT);
// 刷新同意窗口上界(给用户完整窗口)
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
authorizationMapper.updateById(auth);
log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
jsonOf("result", "approved", "channel", auth.getChannel()));
return auth;
}
@Transactional
public MatchmakerContactAuthorization reject(String authId, String approverOperatorId,
Set<String> principalPermissions) {
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
MatchmakerContactAuthorization auth = requireAuthorization(authId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许拒绝");
}
// 分层组织范围校验(用户裁决①,与 approve 同口径)
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
auth.getOperatorId());
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
authorizationMapper.updateById(auth);
log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
jsonOf("result", "rejected", "channel", auth.getChannel()));
return auth;
}
// ==================== ④ consent / ⑤ decline:C 端用户本人 ====================
@Transactional
public MatchmakerContactAuthorization consent(String authId, String userId) {
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许同意");
}
auth.setUserConsentedAt(Instant.now());
auth.setExpiresAt(Instant.now().plusSeconds(config.getDefaultValidityDays() * 86400L));
auth.setStatus(Constants.CONTACT_AUTH_STATUS_ACTIVE);
authorizationMapper.updateById(auth);
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
"matchmaker.contact.consent", jsonOf("channel", auth.getChannel()));
return auth;
}
@Transactional
public MatchmakerContactAuthorization decline(String authId, String userId) {
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许拒绝");
}
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
authorizationMapper.updateById(auth);
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
saveUserAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
"matchmaker.contact.decline", jsonOf("channel", auth.getChannel()));
return auth;
}
// ==================== ⑥ revoke:申请人或审批人 ====================
@Transactional
public MatchmakerContactAuthorization revoke(String authId, String operatorId, String reason) {
if (reason == null || reason.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "撤销理由必填");
}
MatchmakerContactAuthorization auth = requireAuthorization(authId);
boolean isApplicant = operatorId.equals(auth.getOperatorId());
boolean isApprover = operatorId.equals(auth.getApprovedBy());
if (!isApplicant && !isApprover) {
dataScopeGuard.deny(operatorId, RESOURCE_TYPE, authId,
"仅申请人或审批人可撤销授权");
}
if (!Constants.CONTACT_AUTH_STATUS_ACTIVE.equals(auth.getStatus())
&& !Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())
&& !Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许撤销");
}
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REVOKED);
auth.setRevokedAt(Instant.now());
auth.setRevokedBy(operatorId);
auth.setRevokeReason(reason.trim());
authorizationMapper.updateById(auth);
log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.revoke",
jsonOf("reason", auth.getRevokeReason()));
return auth;
}
// ==================== ⑦ disclose:H4 唯一披露出口 ====================
/**
* 披露用户电话(H4 唯一出口)。
* 链路:enabled fail-safe → 持分配(404 化)→ 生效授权(404 化)→ 返回三键;
* 每次落 SensitiveAccessLog(user_phone_disclosure);<b>phone 不落任何应用日志</b>。
*/
@Transactional
public Map<String, Object> disclose(String operatorId, String userId, String channel,
String purpose) {
// fail-safe:机制关闭(配置缺失/显式关闭)→ 404 化拒绝
if (!config.isEnabled()) {
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
"服务不存在", null, null);
}
if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "披露目的必填");
}
// 持分配校验(404 化,G4)
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
operatorId, userId, RESOURCE_TYPE, userId);
// 生效授权校验(404 化;未同意/已拒绝/已过期/已撤销全部落同一响应)
MatchmakerContactAuthorization auth = contactAuthorizationPolicy.assertContactAllowed(
operatorId, userId, channel, purpose);
User user = userMapper.selectById(userId);
String phone = user != null ? user.getPhone() : null;
SensitiveAccessLog accessLog = new SensitiveAccessLog();
accessLog.setOperatorId(operatorId);
accessLog.setUserId(userId);
accessLog.setDataType(SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE);
accessLog.setReason(purpose.trim());
accessLog.setAccessTarget(auth.getId());
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
sensitiveAccessLogMapper.insert(accessLog);
// 审计不含 phone(afterData 只记 channel;log.info 不打 phone)
log.info("受控联系披露: authId={}, operatorId={}, userId={}, channel={}",
auth.getId(), operatorId, userId, auth.getChannel());
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(),
assignment != null ? assignment.getId() : null,
"matchmaker.contact.disclose", jsonOf("channel", auth.getChannel()));
Map<String, Object> result = new HashMap<>();
result.put("phone", phone);
result.put("disclosedAt", Instant.now());
result.put("authorizationId", auth.getId());
return result;
}
// ==================== 查询 ====================
/** 红娘工作台:本人全部授权(含保留期元数据 retentionDays)。 */
public Map<String, Object> myAuthorizations(String operatorId) {
List<Map<String, Object>> items = authorizationMapper.findByOperator(operatorId).stream()
.map(this::toView).collect(Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("list", items);
result.put("retentionDays", config.getRetentionDays());
return result;
}
/** 审批队列:平台管理员全量可见,组长仅可见本组织申请。 */
public Map<String, Object> pendingApprovals(String approverOperatorId, Set<String> permissions) {
assertApproverAuthority(approverOperatorId, "pending", permissions);
List<MatchmakerContactAuthorization> rows = authorizationMapper.findPendingApproval();
if (!isFallbackApprover(permissions)) {
String approverOrg = operatorMapper.findById(approverOperatorId)
.map(Operator::getOrganizationId).orElse(null);
rows = rows.stream().filter(row -> operatorMapper.findById(row.getOperatorId())
.map(Operator::getOrganizationId)
.map(org -> java.util.Objects.equals(org, approverOrg)).orElse(false))
.collect(Collectors.toList());
}
Map<String, Object> result = new HashMap<>();
result.put("list", rows.stream().map(this::toView).collect(Collectors.toList()));
result.put("retentionDays", config.getRetentionDays());
return result;
}
/** C 端:我的授权列表 + 待同意队列。 */
public Map<String, Object> myAuthorizationsForUser(String userId) {
List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream()
.map(this::toView).collect(Collectors.toList());
List<Map<String, Object>> pendingConsent = authorizationMapper
.findPendingConsentByUser(userId).stream()
.map(this::toView).collect(Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("list", items);
result.put("pendingConsent", pendingConsent);
return result;
}
/** 视图投影:不含任何联系方式字段(授权表本身无 phone 列)。 */
private Map<String, Object> toView(MatchmakerContactAuthorization auth) {
Map<String, Object> item = new HashMap<>();
item.put("id", auth.getId());
item.put("operatorId", auth.getOperatorId());
item.put("userId", auth.getUserId());
item.put("channel", auth.getChannel());
item.put("purpose", auth.getPurpose());
item.put("status", auth.getStatus());
item.put("createdAt", auth.getCreatedAt());
item.put("approvedAt", auth.getApprovedAt());
item.put("userConsentedAt", auth.getUserConsentedAt());
item.put("expiresAt", auth.getExpiresAt());
item.put("revokeReason", auth.getRevokeReason());
return item;
}
// ==================== 内部实现 ====================
private void requireValidChannel(String channel) {
boolean valid = Constants.CONTACT_CHANNEL_PHONE.equals(channel)
|| Constants.CONTACT_CHANNEL_OFFLINE_MEETING.equals(channel)
|| Constants.CONTACT_CHANNEL_FULL_CONTACT.equals(channel);
if (!valid) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "不支持的授权渠道: " + channel);
}
}
private MatchmakerContactAuthorization requireAuthorization(String authId) {
return authorizationMapper.findById(authId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "授权记录不存在"));
}
/** C 端归属校验:仅授权记录的本人用户可 consent/decline,违规 403。 */
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
MatchmakerContactAuthorization auth = requireAuthorization(authId);
if (!auth.getUserId().equals(userId)) {
// V057:C 端主体走 denyUser(subject_user_id 维度)——userId 写 operator_id 维度
// 必撞 audit_logs_operator_id_fkey(→operators),H2 无 FK 假绿、生产零审计
dataScopeGuard.denyUser(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求");
}
return auth;
}
/** 审计用 service_assignment_id:当前 (红娘,用户) 生效中的分配 id,无则 null。 */
private String activeAssignmentIdOf(MatchmakerContactAuthorization auth) {
Optional<MatchmakerAssignment> assignment =
serviceAssignmentPolicy.findActiveAssignment(auth.getOperatorId(), auth.getUserId());
return assignment.map(MatchmakerAssignment::getId).orElse(null);
}
/**
* 审计写入:purpose_code(matchmaker.contact.*)+ service_assignment_id +
* request_trace_id(MDC requestId)。afterData 一律不含 phone。
*/
private void saveAudit(String operatorId, String action, String authId,
String serviceAssignmentId, String purposeCode, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(RESOURCE_TYPE);
auditLog.setResourceId(authId);
auditLog.setAfterData(afterData);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
auditLog.setRequestTraceId(MDC.get("requestId"));
auditLogMapper.insert(auditLog);
}
/**
* V057 / 运行时实测修复:C 端用户主体的审计写入(consent/decline)。
* subject_user_id 维度(operator_id=null)——userId 写 operator_id 必撞
* audit_logs_operator_id_fkey(→operators):H2 无 FK 假绿,生产 FK violation 500。
* service_assignment_id 不适用(用户动作不经分配),purpose_code 照旧落。
*/
private void saveUserAudit(String userId, String action, String authId,
String purposeCode, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setSubjectUserId(userId);
auditLog.setAction(action);
auditLog.setResourceType(RESOURCE_TYPE);
auditLog.setResourceId(authId);
auditLog.setAfterData(afterData);
auditLog.setPurposeCode(purposeCode);
auditLog.setRequestTraceId(MDC.get("requestId"));
auditLogMapper.insert(auditLog);
}
/**
* afterData 必须是合法 JSON(PG audit_logs.after_data 为 jsonb 严格解析;
* H2 测试库 CLOB 不校验——裸 key=value 拼接在测试侧假绿、生产侧 500)。
* kv 成对传入,Jackson 负责用户输入(purpose/reason)的 JSON 转义,防 JSON 注入。
*/
private String jsonOf(Object... kvPairs) {
if (kvPairs.length % 2 != 0) {
throw new IllegalStateException("jsonOf requires key-value pairs");
}
Map<String, Object> data = new LinkedHashMap<>();
for (int i = 0; i < kvPairs.length; i += 2) {
data.put(String.valueOf(kvPairs[i]), kvPairs[i + 1]);
}
try {
return OBJECT_MAPPER.writeValueAsString(data);
} catch (JsonProcessingException e) {
throw new IllegalStateException("audit afterData JSON serialization failed", e);
}
}
}
@@ -0,0 +1,428 @@
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 com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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 static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
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 -> {
// 历史服务已完成/取消时,旧分配只是残留状态,不应阻塞用户再次申请服务。
// 仍在进行中的服务继续按唯一主责约束返回 409。
MatchmakerServiceRecord linked = existing.getServiceId() == null
? null : serviceMapper.selectById(existing.getServiceId());
if (linked != null && (Constants.MATCHMAKER_STATUS_COMPLETED.equals(linked.getStatus())
|| Constants.MATCHMAKER_STATUS_CANCELLED.equals(linked.getStatus()))) {
existing.setStatus(Constants.ASSIGNMENT_STATUS_ENDED);
existing.setEndedAt(Instant.now());
existing.setEndReason("关联服务已完成或取消,自动结束旧分配");
assignmentMapper.updateById(existing);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_END, existing.getId(),
auditSummary(existing.getMatchmakerOperatorId(), existing.getAssignmentScope(), existing.getEndReason()));
return;
}
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配,请先转分配或撤销");
});
if (serviceId != null && !serviceId.isBlank()) {
linkServiceRow(serviceId, matchmakerOperatorId);
}
MatchmakerAssignment assignment = new MatchmakerAssignment();
assignment.setUserId(userId);
assignment.setMatchmakerOperatorId(matchmakerOperatorId);
assignment.setServiceId(serviceId);
assignment.setAssignedBy(assignerOperatorId);
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
assignment.setAssignmentReason(reason);
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
assignment.setAssignedAt(Instant.now());
assignment.setExpiresAt(expiresAt);
try {
assignmentMapper.insert(assignment);
} catch (DuplicateKeyException e) {
// 并发竞态兜底:uq_mm_assignment_active_primary 部分唯一索引拒绝第二条 active+primary
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配(并发创建被唯一索引拒绝)");
}
log.info("服务对象分配创建: assignmentId={}, userId={}, matchmakerOperatorId={}, by={}",
assignment.getId(), userId, matchmakerOperatorId, assignerOperatorId);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_CREATE, assignment.getId(),
auditSummary(matchmakerOperatorId, assignment.getAssignmentScope(), reason));
return assignment;
}
/**
* 转分配(active → transferred + 同事务新建 active)。
* 旧记录置 transferred + end_reason,新记录 assigned_by = 当前操作者。
*/
@Transactional
public MatchmakerAssignment transfer(String assignmentId, String assignerOperatorId,
boolean hasGlobalAssign, String newMatchmakerOperatorId,
String reason) {
requireReason(reason, "转分配理由必填");
MatchmakerAssignment old = requireAssignment(assignmentId);
assertActiveForTransition(old, "转分配");
// 防御纵深(QA P2-1):不仅新目标红娘要在操作者范围内,
// 被转记录的**原持有人**也必须在范围内(与 terminate 路径同口径;
// 否则 org=X 组长可把 org=Y 红娘持有的分配"顺手"转走)。
// ZC-MM-006 / T04:transfer 为资源寻址写端点,范围违规 404 化(防探测)。
assertAssignScope(assignerOperatorId, hasGlobalAssign, old.getMatchmakerOperatorId(),
"组长只能处置同组织红娘持有的分配(NULL=NULL 平台直属)", assignmentId, true);
requireActiveOperator(newMatchmakerOperatorId, "目标红娘不存在");
assertAssignScope(assignerOperatorId, hasGlobalAssign, newMatchmakerOperatorId,
"组长只能转分配给同组织的红娘(NULL=NULL 平台直属)", assignmentId, true);
// 1) 旧记录置 transferred
old.setStatus(Constants.ASSIGNMENT_STATUS_TRANSFERRED);
old.setEndReason(reason);
old.setEndedAt(Instant.now());
assignmentMapper.updateById(old);
// 2) 同事务新建 active 分配
MatchmakerAssignment created = new MatchmakerAssignment();
created.setUserId(old.getUserId());
created.setMatchmakerOperatorId(newMatchmakerOperatorId);
created.setServiceId(old.getServiceId());
created.setAssignedBy(assignerOperatorId);
created.setAssignmentScope(old.getAssignmentScope());
created.setAssignmentReason(reason);
created.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
created.setAssignedAt(Instant.now());
created.setExpiresAt(old.getExpiresAt());
try {
assignmentMapper.insert(created);
} catch (DuplicateKeyException e) {
// 事务内旧记录已非 active,正常不应触发;仍兜底映射 CONFLICT(整个事务回滚)
throw new BusinessException(ApiResponseCode.CONFLICT,
"该用户已存在生效中的主责分配(并发转分配被唯一索引拒绝)");
}
log.info("服务对象转分配: oldId={}, newId={}, fromUser={}, toOperator={}, by={}",
old.getId(), created.getId(), old.getUserId(), newMatchmakerOperatorId, assignerOperatorId);
saveAudit(assignerOperatorId, Constants.AUDIT_ACTION_ASSIGNMENT_TRANSFER, assignmentId,
auditSummary(newMatchmakerOperatorId, created.getAssignmentScope(), reason));
return created;
}
/** 撤销分配(active → revoked,end_reason 必填)。 */
@Transactional
public MatchmakerAssignment revoke(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason) {
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
Constants.ASSIGNMENT_STATUS_REVOKED, Constants.AUDIT_ACTION_ASSIGNMENT_REVOKE, "撤销");
}
/** 结束分配(active → ended,end_reason 必填)。 */
@Transactional
public MatchmakerAssignment end(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason) {
return terminate(assignmentId, operatorId, hasGlobalAssign, reason,
Constants.ASSIGNMENT_STATUS_ENDED, Constants.AUDIT_ACTION_ASSIGNMENT_END, "结束");
}
/** 我(当前红娘)名下生效中的服务对象分配。读前惰性过期。 */
public List<MatchmakerAssignment> listMy(String operatorId) {
return serviceAssignmentPolicy.findActiveByOperator(operatorId);
}
/**
* 可分配红娘列表(创建分配弹窗数据源)。
* operators.status='active';组长按组织过滤、平台授权人员(matchmaker.assign)返回全部。
* 返回 id/name/organizationId,<b>不含联系方式</b>(字段级隐私,ZC-MM-005 范围)。
*/
public List<Map<String, Object>> listAssignableMatchmakers(String assignerOperatorId,
boolean hasGlobalAssign) {
String assignerOrg = null;
if (!hasGlobalAssign) {
Operator assigner = operatorMapper.findById(assignerOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
assignerOrg = assigner.getOrganizationId();
}
String orgFilter = assignerOrg;
return operatorMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Operator>()
.eq(Operator::getStatus, "active")
.isNull(Operator::getDeletedAt))
.stream()
.filter(op -> hasGlobalAssign || Objects.equals(orgFilter, op.getOrganizationId()))
.map(op -> {
Map<String, Object> item = new HashMap<>();
item.put("id", op.getId());
item.put("name", op.getName());
item.put("organizationId", op.getOrganizationId());
return item;
})
.collect(Collectors.toList());
}
/**
* 组长团队统计(ZC-MM-005 / E4 新增):以组长本人 organization_id 为边界
* (NULL 组长统计平台直属,SQL 侧 IS NOT DISTINCT FROM 复用 NULL=NULL 规则)。
* 返回纯脱敏计数,不含任何用户身份或联系方式:
* <ul>
* <li>activeAssignmentCount —— 组织内生效中的分配总数;</li>
* <li>perMatchmakerCounts —— 各红娘的生效分配数(operatorId/name/activeCount);</li>
* <li>activeMatchmakerCount —— 持有生效分配的红娘数(去重)。</li>
* </ul>
*
* <p><b>字段脱敏决策(P2-4,对照《决策》§5.2 字段分类 + 功能权限矩阵)</b>:
* perMatchmakerCounts 中的 <b>operatorName(红娘姓名)保留、不做脱敏</b>。依据:
* <ol>
* <li>红娘姓名属于「本人/团队范围内的工作人员标识」,<b>不是用户 PII</b>
* (§5.2 隐私清单的禁放字段是 phone/wechat/idNumber 等用户敏感数据);</li>
* <li>组长统计本团队工作量属其管理职责(DEC-MATCHMAKER-002 红娘组长:
* 团队内进度与工作量管理),「查看脱敏服务统计:组长=团队范围」是既有授权口径;</li>
* <li>组长已可经 GET /assignable-matchmakers 拿到同批红娘姓名 —— 此字段
* 非新增泄露面,管理范围 ≠ 用户敏感数据。</li>
* </ol>
* 决策依据同时登记于 docs/governance/open-questions.md(OQ-MATCHMAKER-002),
* 避免后人误判"统计含姓名=越权"。
*/
public Map<String, Object> teamStatistics(String leaderOperatorId) {
Operator leader = operatorMapper.findById(leaderOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
String orgId = leader.getOrganizationId();
Map<String, Object> result = new HashMap<>();
result.put("organizationId", orgId);
result.put("activeAssignmentCount", assignmentMapper.countActiveByOrganization(orgId));
result.put("perMatchmakerCounts", assignmentMapper.countActiveGroupByOperator(orgId));
result.put("activeMatchmakerCount", assignmentMapper.countActiveOperators(orgId));
return result;
}
// ==================== 内部实现 ====================
/** revoke / end 共用的终态迁移(仅允许 active → 终态)。 */
private MatchmakerAssignment terminate(String assignmentId, String operatorId,
boolean hasGlobalAssign, String reason,
String targetStatus, String auditAction,
String actionLabel) {
requireReason(reason, actionLabel + "理由必填");
MatchmakerAssignment assignment = requireAssignment(assignmentId);
assertActiveForTransition(assignment, actionLabel);
// 范围校验:操作者必须有权处置该红娘的分配(assign 不限组织 / team.assign 同组织)
// ZC-MM-006 / T04:revoke/end 为资源寻址写端点,范围违规 404 化(防探测)
assertAssignScope(operatorId, hasGlobalAssign, assignment.getMatchmakerOperatorId(),
"组长只能处置同组织红娘的分配(NULL=NULL 平台直属)", assignmentId, true);
assignment.setStatus(targetStatus);
assignment.setEndReason(reason);
assignment.setEndedAt(Instant.now());
assignmentMapper.updateById(assignment);
log.info("服务对象分配{}: assignmentId={}, operatorId={}, by={}",
actionLabel, assignmentId, assignment.getMatchmakerOperatorId(), operatorId);
saveAudit(operatorId, auditAction, assignmentId,
auditSummary(assignment.getMatchmakerOperatorId(), assignment.getAssignmentScope(), reason));
return assignment;
}
/**
* 受控联系审批的组织范围校验(ZC-MM-003 / B1 立项)。
* {@link #assertAssignScope} 的公开入口:审批岗(team.assign 组长)只能审批
* 同组织红娘的授权申请(NULL=NULL 平台直属);matchmaker.assign 持有者不限组织。
* 违规走 DataScopeGuard.deny(403,approve 无资源枚举面维持 403)。
*
* @param approverOperatorId 审批人
* @param hasGlobalAssign 审批人是否持有 matchmaker.assign(true=不限组织)
* @param targetOperatorId 被审批授权的红娘
* @param auditResourceId 审计 resourceId(授权记录 id)
*/
public void assertTeamScope(String approverOperatorId, boolean hasGlobalAssign,
String targetOperatorId, String auditResourceId) {
assertAssignScope(approverOperatorId, hasGlobalAssign, targetOperatorId,
"组长只能审批同组织红娘的授权申请(NULL=NULL 平台直属)", auditResourceId, false);
}
/**
* 组织范围判定(D2):hasGlobalAssign(matchmaker.assign)不限组织;
* team.assign 时校验操作者与目标红娘 organization_id 一致。
* <b>NULL=NULL 视为平台直属</b>:平台直属组长只能分配平台直属红娘;
* 有组织组长不能分配 org=NULL 红娘。违规统一先落 DATA_SCOPE_DENIED 审计。
*
* <p>拒绝口径(ZC-MM-006 / T04):
* <ul>
* <li>{@code notFoundOnDeny=false}(create/assign 类,无资源寻址)→ 403 AccessDeniedException;</li>
* <li>{@code notFoundOnDeny=true}(transfer/revoke/end,资源寻址写端点)→ 404 化
* {@link DataScopeGuard#denyAsNotFound}(响应与"资源不存在"逐字段一致,防探测)。</li>
* </ul>
*/
private void assertAssignScope(String assignerOperatorId, boolean hasGlobalAssign,
String targetOperatorId, String denyMessage,
String auditResourceId, boolean notFoundOnDeny) {
if (hasGlobalAssign) {
return; // 平台授权人员(matchmaker.assign),不限组织
}
Operator assigner = operatorMapper.findById(assignerOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"));
Operator target = operatorMapper.findById(targetOperatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "目标红娘不存在"));
// Objects.equals 的 NULL=NULL → true 语义即「平台直属对平台直属」规则
if (Objects.equals(assigner.getOrganizationId(), target.getOrganizationId())) {
return;
}
if (notFoundOnDeny) {
dataScopeGuard.denyAsNotFound(assignerOperatorId, RESOURCE_TYPE, auditResourceId,
"服务不存在", null, null);
} else {
dataScopeGuard.deny(assignerOperatorId, RESOURCE_TYPE, auditResourceId, denyMessage);
}
}
/** 服务单联动(同事务):仅 submitted 状态允许关联分配并置为 assigned。 */
private void linkServiceRow(String serviceId, String matchmakerOperatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联服务单不存在");
}
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单当前状态不允许关联分配");
}
service.setAssignedOperatorId(matchmakerOperatorId);
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
service.setAssignedAt(Instant.now());
serviceMapper.updateById(service);
}
private MatchmakerAssignment requireAssignment(String assignmentId) {
return assignmentMapper.findById(assignmentId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "分配记录不存在"));
}
private Operator requireActiveOperator(String operatorId, String notFoundMessage) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, notFoundMessage));
if (!"active".equals(operator.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "目标红娘账号非 active 状态");
}
return operator;
}
/** 终态一律拒绝再转换(D5 状态机矩阵)。 */
private void assertActiveForTransition(MatchmakerAssignment assignment, String actionLabel) {
if (!Constants.ASSIGNMENT_STATUS_ACTIVE.equals(assignment.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"分配已处于终态(" + assignment.getStatus() + "),不允许" + actionLabel);
}
}
private void requireReason(String reason, String message) {
if (reason == null || reason.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, message);
}
}
/** afterData 摘要:只记 operatorId/scope/reason(D5 审计口径,不落敏感数据)。 */
private String auditSummary(String matchmakerOperatorId, String scope, String reason) {
Map<String, String> summary = Map.of(
"matchmakerOperatorId", matchmakerOperatorId == null ? "" : matchmakerOperatorId,
"scope", scope == null ? "" : scope,
"reason", reason == null ? "" : reason);
try {
return OBJECT_MAPPER.writeValueAsString(summary);
} catch (JsonProcessingException e) {
throw new IllegalStateException("audit afterData JSON serialization failed", e);
}
}
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);
}
}
@@ -0,0 +1,53 @@
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.mapper.MatchmakerFollowupMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** 管理岗位只读服务进展;不复用普通红娘的本人分配权限,也不授予代操作权限。 */
@Service
@RequiredArgsConstructor
public class MatchmakerManagementService {
private final MatchmakerServiceMapper serviceMapper;
private final MatchmakerFollowupMapper followupMapper;
private final OperatorMapper operatorMapper;
private final AuditLogMapper auditLogMapper;
public List<Map<String, Object>> list(OperatorPrincipal principal) {
boolean global = principal.hasPermission("matchmaker.assign");
if (!global && !principal.hasPermission("matchmaker.team.assign")) {
throw new AccessDeniedException("无红娘服务管理权限");
}
String org = global ? null : operatorMapper.findById(principal.id())
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "操作者不存在"))
.getOrganizationId();
return serviceMapper.findManagementServices(global, org);
}
public Map<String, Object> detail(OperatorPrincipal principal, String serviceId) {
Map<String, Object> summary = list(principal).stream()
.filter(row -> serviceId.equals(String.valueOf(row.get("serviceId"))))
.findFirst().orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在"));
var detail = new HashMap<>(summary);
detail.put("followups", followupMapper.findByServiceId(serviceId));
AuditLog audit = new AuditLog();
audit.setOperatorId(principal.id());
audit.setAction("MATCHMAKER_MANAGEMENT_VIEW");
audit.setResourceType("matchmaker_service");
audit.setResourceId(serviceId);
audit.setPurposeCode("matchmaker.management.view");
auditLogMapper.insert(audit);
return detail;
}
}
@@ -48,6 +48,8 @@ public class MatchmakerService {
private final RelationService relationService;
private final MatchMapper matchMapper;
private final NotificationService notificationService;
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper followupMapper;
private final com.danyang.match.platform.shared.id.PublicIdMapper publicIdMapper;
// ==================== 用户端:提交红娘服务请求 ====================
@@ -62,22 +64,57 @@ public class MatchmakerService {
*/
@Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description) {
return submitServiceRequest(userId, serviceType, description, null, null);
}
@Transactional
public MatchmakerServiceRecord submitServiceRequest(String userId, String serviceType, String description,
String targetUserId, Boolean authorizationAccepted) {
boolean assistance = "recommendation_assistance".equals(serviceType);
if (assistance) {
if (targetUserId == null || targetUserId.isBlank()) throw new IllegalArgumentException("请选择需要协助的对象");
if (!Boolean.TRUE.equals(authorizationAccepted)) throw new IllegalArgumentException("请先确认授权红娘查看必要公开资料");
if (userId.equals(targetUserId)) throw new IllegalArgumentException("不能申请协助了解自己");
if (!relationService.canInteract(userId, targetUserId)) throw new IllegalStateException("该对象当前不可申请红娘协助");
if (serviceMapper.countActiveAssistance(userId, targetUserId) > 0) throw new IllegalStateException("已提交过该对象的红娘协助申请");
}
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType(serviceType);
svc.setDescription(description);
svc.setTargetUserId(targetUserId);
if (assistance) svc.setAuthorizationAt(Instant.now());
svc.setStatus("submitted");
serviceMapper.insert(svc);
log.info("Matchmaker service submitted: userId={}, type={}", userId, serviceType);
return svc;
}
@Transactional
public void withdrawService(String serviceId, String userId, String reason) {
if (serviceMapper.withdraw(serviceId, userId, reason) != 1) throw new IllegalStateException("服务不存在或当前状态不可撤回");
}
/** 查询我的红娘服务历史 */
public List<MatchmakerServiceRecord> myServices(String userId, int limit) {
return serviceMapper.findByUser(userId, Math.min(limit, 50));
}
/** 用户本人确认红娘方案,服务恢复为进行中。 */
@Transactional
public MatchmakerServiceRecord confirmService(String serviceId, String userId) {
if (serviceId == null || userId == null) {
throw new IllegalArgumentException("服务和用户不能为空");
}
int updated = serviceMapper.confirmWaitingForUser(serviceId, userId);
if (updated != 1) {
throw new IllegalStateException("服务不存在或当前状态无需确认");
}
return serviceMapper.findById(serviceId)
.orElseThrow(() -> new IllegalStateException("服务不存在"));
}
// ==================== 操作人端:分配 + 推荐 + 转介 ====================
/**
@@ -157,9 +194,7 @@ public class MatchmakerService {
throw new IllegalStateException("您不是此服务的负责人");
}
// referral 记录写进 MatchmakerServiceRecord.description 的扩展 JSON
// + 单独一条 Followup 记录(DB V009 已有 matchmaker_followups)
// 这里用 Map 简单记录,不引入新实体
// 转介建议复用跟进记录持久化,独立 ZF 编号不替用户创建申请。
ReferralRecord record = new ReferralRecord(
UUID.randomUUID().toString(),
serviceId,
@@ -171,11 +206,22 @@ public class MatchmakerService {
Instant.now()
);
var followup = new com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup();
followup.setId(record.id());
followup.setServiceId(serviceId);
followup.setOperatorId(operatorId);
followup.setFollowupType("system");
followup.setContent("转介建议:" + referralLabel(referralType) + (note == null ? "" : "," + note));
followup.setCreatedAt(record.createdAt());
followupMapper.insert(followup);
String referralNumber = publicIdMapper.nextReferral(record.createdAt());
if (publicIdMapper.saveReferral(record.id(), referralNumber) != 1) throw new IllegalStateException("转介编号写入失败");
// 通知用户被转介了
notificationService.send(userId, "matchmaker_referral",
"红娘为你转介了一个" + referralLabel(referralType),
"公益红娘为你转介了" + referralLabel(referralType) +
"(" + (referralTarget != null ? referralTarget : "") + "),你可以在" +
"(转介编号:" + referralNumber + "),你可以在" +
serviceLabel(referralType) + "页面申请。",
"matchmaker_service", serviceId);
@@ -1,9 +1,11 @@
package com.danyang.match.platform.matchmaker.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
import com.danyang.match.platform.matchmaker.dto.MatchmakerRecommendationDTO;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
@@ -11,7 +13,9 @@ import com.danyang.match.platform.matchmaker.mapper.MatchmakerFollowupMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
@@ -27,6 +31,31 @@ import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 红娘服务服务层。
*
* <p>ZC-MM-002(A1)变更说明:
* <ul>
* <li>claimService(红娘接单)已删除(D3:不保留组长认领),服务对象归属统一由
* matchmaker_assignments 建模;</li>
* <li>写接口(recommend/followup/complete)归属判定切换到
* {@link ServiceAssignmentPolicy}(D4),拒绝统一走 DataScopeGuard(审计 + 403);
* 存量兼容:assignment 表上线前的服务单仍按 assigned_operator_id 判定(005 阶段收敛);</li>
* <li>assignService 同事务联动「创建分配 + 服务单置 assigned」。</li>
* </ul>
*
* <p>ZC-MM-005(E1 双轨切纯判定器)变更说明:
* <ul>
* <li><b>删除 assigned_operator_id 回退分支</b>:002 的 "assignment 优先 +
* assigned_operator_id 回退" 有真漏洞 —— 分配 revoke/end 后服务单残留
* assigned_operator_id,回退分支会放行已失去访问权的红娘(违反 DEC-002 规则7)。
* V053 已回填全部存量进行中服务单,数据一致性已建立;终态服务单不回填是有意设计
* (服务结束即失去访问权)。{@link ServiceAssignmentPolicy} 成为唯一事实源;</li>
* <li>读详情(getServiceDetail)改用 E2 防探测 404:非持有人响应与"不存在"逐字段一致,
* 删除 viewerIsOwner 双态与"非负责人脱敏详情"整段;</li>
* <li>行为变化:红娘读自己历史 completed 服务单详情将 404(符合 DEC-002 决策)。</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class MatchmakerServiceService {
@@ -37,8 +66,12 @@ public class MatchmakerServiceService {
private final MatchmakerRecommendationMapper recommendationMapper;
private final MatchmakerFollowupMapper followupMapper;
private final UserProfileMapper userProfileMapper;
private final UserPrivacySettingsMapper userPrivacySettingsMapper;
private final NotificationService notificationService;
private final AuditLogMapper auditLogMapper;
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
private final MatchmakerAssignmentService matchmakerAssignmentService;
/**
* 用户申请红娘服务
@@ -82,43 +115,46 @@ public class MatchmakerServiceService {
}
/**
* 分配服务给红娘
* 分配服务给红娘(ZC-MM-002 / D4 联动版)。
*
* <p>同事务完成:① 创建 matchmaker_assignments 主责分配(含组织范围校验、
* 唯一性约束、CREATE 审计,由 {@link MatchmakerAssignmentService#create} 承担);
* ② 服务单 submitted → assigned、写回 assigned_operator_id。
* 任何一步失败整体回滚。
*
* @param serviceId 服务单 ID
* @param assignerOperatorId 当前操作者(分配人,principal)
* @param targetOperatorId 目标红娘
* @param reason 分配理由(默认文案由 Controller 传入)
*/
@Transactional
public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) {
public MatchmakerServiceRecord assignService(String serviceId, String assignerOperatorId,
String targetOperatorId, String reason) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
}
service.setAssignedOperatorId(operatorId);
service.setOrganizationId(organizationId);
service.setStatus(Constants.MATCHMAKER_STATUS_ASSIGNED);
service.setAssignedAt(Instant.now());
serviceMapper.updateById(service);
log.info("分配红娘服务: serviceId={}, operatorId={}", serviceId, operatorId);
saveAuditLog(operatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null);
return service;
}
/**
* 红娘接单
*/
@Transactional
public MatchmakerServiceRecord claimService(String operatorId, String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许接单");
if (targetOperatorId == null || targetOperatorId.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "目标红娘必填");
}
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
serviceMapper.updateById(service);
// ① 创建分配(matchmaker.assign 入口为平台授权,hasGlobalAssign=true)
MatchmakerAssignment createdAssignment = matchmakerAssignmentService.create(assignerOperatorId, true,
service.getUserId(), targetOperatorId, serviceId, reason, null);
log.info("红娘接单: operatorId={}, serviceId={}", operatorId, serviceId);
return service;
// ② 服务单联动(create 内已对 submitted 服务单置 assigned;此处再校验一次确保联动生效)
MatchmakerServiceRecord after = serviceMapper.selectById(serviceId);
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(after.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "服务单分配联动失败");
}
log.info("分配红娘服务: serviceId={}, targetOperatorId={}, by={}",
serviceId, targetOperatorId, assignerOperatorId);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(assignerOperatorId, "MATCHMAKER_ASSIGN", "matchmaker_service", serviceId, null, null,
"matchmaker.service.assign", createdAssignment.getId());
return after;
}
/**
@@ -132,10 +168,10 @@ public class MatchmakerServiceService {
/**
* 红娘推荐候选人给用户
*
* Phase 13 复验修复(D3):补 assigned_operator 归属校验。
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator
* 都能对**任意** serviceId 创建推荐(越权)。与
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。
* Phase 13 复验修复(D3):补归属校验。
* ZC-MM-002(D4):归属判定切换到 {@link ServiceAssignmentPolicy}(matchmaker_assignments
* 是服务对象归属的唯一事实源,无任何 assigned_operator_id 回退);拒绝统一走
* DataScopeGuard(DATA_SCOPE_DENIED 审计 + 403),不再抛裸 IllegalStateException。
*
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
*/
@@ -145,10 +181,8 @@ public class MatchmakerServiceService {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
if (!operatorId.equals(service.getAssignedOperatorId())) {
throw new IllegalStateException("您不是此服务的负责人");
}
// D4:归属校验 —— 只有该服务对象的负责人(分配持有人)才能推荐
MatchmakerAssignment assignment = assertServiceAccess(operatorId, service);
// 不能推荐给自己(服务申请人)
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
throw new IllegalArgumentException("不能推荐用户给自己");
@@ -171,7 +205,9 @@ public class MatchmakerServiceService {
"matchmaker_recommendation", rec.getId().toString());
log.info("红娘推荐: serviceId={}, operatorId={}, targetUserId={}", serviceId, operatorId, targetUserId);
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(operatorId, "MATCHMAKER_RECOMMEND", "matchmaker_service", serviceId, null, null,
"matchmaker.service.recommend", assignment != null ? assignment.getId() : null);
Map<String, Object> result = new HashMap<>();
result.put("recommendationId", rec.getId());
@@ -182,11 +218,18 @@ public class MatchmakerServiceService {
/**
* 红娘添加跟进记录
*
* ZC-MM-002(D4):补归属校验(此前无任何校验,任何持 matchmaker.service 权限的
* operator 可对任意 serviceId 写跟进 → 越权写)。判定口径同 recommendPartner。
*/
@Transactional
public MatchmakerFollowup addFollowup(String serviceId, String operatorId,
String followupType, String content,
String nextFollowupAt) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
MatchmakerAssignment followupAssignment = assertServiceAccess(operatorId, service);
MatchmakerFollowup followup = new MatchmakerFollowup();
followup.setServiceId(serviceId);
followup.setOperatorId(operatorId);
@@ -198,17 +241,23 @@ public class MatchmakerServiceService {
followupMapper.insert(followup);
log.info("红娘跟进: serviceId={}, operatorId={}, type={}", serviceId, operatorId, followupType);
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(operatorId, "MATCHMAKER_FOLLOWUP", "matchmaker_service", serviceId, null, null,
"matchmaker.service.followup", followupAssignment != null ? followupAssignment.getId() : null);
return followup;
}
/**
* 完成服务
*
* ZC-MM-002(D4):补归属校验(判定口径同 recommendPartner);
* operatorId 由 Controller 传入 principal,不信任请求参数。
*/
@Transactional
public MatchmakerServiceRecord completeService(String serviceId) {
public MatchmakerServiceRecord completeService(String serviceId, String operatorId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
MatchmakerAssignment completeAssignment = assertServiceAccess(operatorId, service);
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
@@ -219,7 +268,9 @@ public class MatchmakerServiceService {
serviceMapper.updateById(service);
log.info("红娘服务完成: serviceId={}", serviceId);
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null);
// ZC-MM-006 / T01:审计补 purpose_code + service_assignment_id
saveAuditLog(null, "MATCHMAKER_COMPLETE", "matchmaker_service", serviceId, null, null,
"matchmaker.service.complete", completeAssignment != null ? completeAssignment.getId() : null);
return service;
}
@@ -257,6 +308,24 @@ public class MatchmakerServiceService {
.collect(java.util.stream.Collectors.toList());
}
/**
* 获取红娘工作台数据。
*
* ZC-MM-002(D4):today / completed 改为本人口径(该红娘名下的服务),
* pendingCount 保留全局口径(待分配队列是分配权持有者的全局视图);
* inProgress 统计的状态口径由错误的 'processing' 修正为 'in_service'
* ('processing' 不在 V009 chk_matchmaker_status 白名单,恒为 0)。
*
* <p>ZC-MM-005 收口(P1-2):inProgress / today / completed 三项计数统一切换为
* <b>matchmaker_assignments 生效中分配</b>口径(SQL 侧 join assignments,见
* MatchmakerServiceMapper 计数查询)。口径说明:
* <ul>
* <li>三项均只统计「本人当前生效分配对象名下」的服务单;</li>
* <li>分配 end / revoke 后(含他人接手后原红娘),该用户的服务单
* <b>不再计入</b>原红娘的 dashboard 计数(DEC-002 规则7:服务结束失去访问权);</li>
* <li>completed 计数同理:历史 completed 单仅在分配仍生效时计入本人。</li>
* </ul>
*/
public Map<String, Object> getDashboard(String operatorId) {
long pendingCount = serviceMapper.countSubmitted();
long inProgressCount = serviceMapper.countInProgressByOperator(operatorId);
@@ -264,57 +333,69 @@ public class MatchmakerServiceService {
Map<String, Object> dashboard = new HashMap<>();
dashboard.put("pendingCount", pendingCount);
dashboard.put("inProgressCount", inProgressCount);
long todayCount = serviceMapper.selectCount(
new LambdaQueryWrapper<MatchmakerServiceRecord>()
.eq(MatchmakerServiceRecord::getStatus, "submitted"));
// 本人口径:今天新流转到该红娘名下(按 assigned_at)的服务数
long todayCount = serviceMapper.countTodayByOperator(operatorId);
dashboard.put("todayCount", todayCount);
long completedCount = serviceMapper.selectCount(
new LambdaQueryWrapper<MatchmakerServiceRecord>()
.eq(MatchmakerServiceRecord::getStatus, "completed"));
// 本人口径:该红娘名下已完成的服务数
long completedCount = serviceMapper.countCompletedByOperator(operatorId);
dashboard.put("completedCount", completedCount);
return dashboard;
}
/**
* 获取我的服务列表(红娘工作台)
*/
public List<MatchmakerServiceRecord> getOperatorServices(String operatorId) {
return serviceMapper.findByOperatorId(operatorId);
}
/**
* 获取服务详情
* 获取服务详情(ZC-MM-005 / E1+E2 重构)。
*
* Phase 13 复验修复(D4):改为按调用者授权范围返回。
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取
* **任意** serviceId 的详情,其中包含该用户的完整资料
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
* <p>此前(002 双轨)语义:负责人 → 完整详情;其他 operator → 脱敏详情(含 viewerIsOwner
* 双态)。该设计有两个问题:① assigned_operator_id 回退分支在分配 revoke/end 后仍放行
* (服务单残留该列);② 脱敏详情响应可被用于按 serviceId 枚举探测资源存在性。
*
* 现语义:
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
* (不含 userProfile;followups 正文也不返回,仅给条数)
* <p>现语义(判定器唯一事实源,无回退):
* <ul>
* <li>查无此 serviceId → 抛 BusinessException(NOT_FOUND, "服务不存在");</li>
* <li>非持有人(无生效中的分配)→ {@link ServiceAssignmentPolicy}
* #assertActiveAssignmentOrNotFound:先落 DATA_SCOPE_DENIED 审计,
* 再抛与"查无"完全相同的 NOT_FOUND 异常 —— 两种失败响应逐字段一致,防探测;</li>
* <li>持有人 → 完整详情(申请人 userId / 诉求描述 / 白名单 userProfile / 跟进正文 /
* 推荐列表),不再有 viewerIsOwner 双态字段。</li>
* </ul>
*
* @param serviceId 服务 ID
* <p>行为变化:红娘读自己历史 completed 服务单详情将 404(分配已终态,符合 DEC-002)。
*
* @param serviceId 服务 ID
* @param requesterId 调用者 operator ID
*/
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) return null;
if (service == null) {
// 与"非持有人"路径抛出完全相同的异常 → 响应体逐字段一致(防探测,E2)
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
}
boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId());
// E1:判定器唯一事实源;E2:非持有人防探测 404(先审计后 NOT_FOUND)
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
requesterId, service.getUserId(), "matchmaker_service", serviceId);
// ZC-MM-006 / T03:持有人读取服务对象详情 = 敏感数据访问,必须落 sensitive_access_logs
// (AGENTS.md #8:查看动作本身必须可溯源)。拒绝路径走判定器的 DATA_SCOPE_DENIED 审计,
// 双记录互斥 —— 非持有人不会走到本行(不重复写 SensitiveAccessLog)。
SensitiveAccessLog accessLog = new SensitiveAccessLog();
accessLog.setOperatorId(requesterId);
accessLog.setUserId(service.getUserId());
accessLog.setDataType(Constants.SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL);
accessLog.setReason(Constants.SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL);
accessLog.setAccessTarget(serviceId);
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
sensitiveAccessLogMapper.insert(accessLog);
Map<String, Object> detail = new HashMap<>();
detail.put("serviceId", service.getId());
detail.put("serviceType", service.getServiceType());
detail.put("status", service.getStatus());
detail.put("assignedOperatorId", service.getAssignedOperatorId());
detail.put("createdAt", service.getCreatedAt());
detail.put("assignedAt", service.getAssignedAt());
detail.put("completedAt", service.getCompletedAt());
detail.put("viewerIsOwner", isOwner);
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见)
// 推荐列表(服务进展摘要)
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
detail.put("recommendations", recs.stream().map(r -> {
Map<String, Object> map = new HashMap<>();
@@ -326,35 +407,78 @@ public class MatchmakerServiceService {
return map;
}).collect(Collectors.toList()));
if (isOwner) {
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文
detail.put("userId", service.getUserId());
detail.put("description", service.getDescription());
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
Map<String, Object> userProfile = new HashMap<>();
userProfile.put("displayName", profile.getDisplayName());
userProfile.put("gender", profile.getGender());
userProfile.put("age", calculateAge(profile.getBirthDate()));
userProfile.put("regionName", profile.getRegionName());
userProfile.put("educationCode", profile.getEducationCode());
userProfile.put("occupationCode", profile.getOccupationCode());
userProfile.put("maritalStatus", profile.getMaritalStatus());
detail.put("userProfile", userProfile);
});
detail.put("followups", followupMapper.findByServiceId(serviceId));
} else {
// 非负责人:只给跟进条数作为进度摘要,不返回正文
detail.put("followupCount", followupMapper.findByServiceId(serviceId).size());
}
// 仅持有人可见:申请人 userId / 诉求描述 / 白名单用户资料 / 跟进正文
detail.put("userId", service.getUserId());
detail.put("description", service.getDescription());
UserPrivacySettings privacy = userPrivacySettingsMapper.findByUserId(service.getUserId())
.orElse(null);
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile ->
detail.put("userProfile", assembleMatchmakerUserProfile(profile, privacy)));
detail.put("followups", followupMapper.findByServiceId(serviceId));
return detail;
}
/**
* 红娘侧用户资料装配(ZC-MM-005 / E3 字段白名单显式化)。
*
* <p>逐字段对照《安全、隐私、合规与风控体系设计》§5.2 字段级隐私清单:
* <ul>
* <li>displayName —— 基础展示字段,允许</li>
* <li>gender —— 基础展示字段,允许</li>
* <li>age —— 由 birthDate 计算的派生值(<b>不输出原始 birthDate</b>)</li>
* <li>regionName —— 地区展示名(非精确坐标),允许</li>
* <li>educationCode —— 枚举码,允许</li>
* <li>occupationCode —— 枚举码,<b>受隐私开关 showOccupation 控制</b>:
* 用户明确关闭(false)时不输出;隐私记录缺失或开关未设置(null)按缺省放行</li>
* <li>maritalStatus —— 枚举码,允许</li>
* </ul>
* §5.2 禁放字段(phone/mobile/wechat/idNumber/birthDate/address/id_card 等)
* 一律不进入装配;守卫见 MatchmakerFieldPrivacyGuardTest(源码级白名单扫描)。
*
* @param profile 用户资料(可空字段自然输出 null,由前端按缺失处理)
* @param privacy 用户隐私设置;null 表示用户未设置过(缺省=全放行)
*/
private Map<String, Object> assembleMatchmakerUserProfile(UserProfile profile,
UserPrivacySettings privacy) {
// 缺省=全放行:仅在用户显式关闭(showOccupation == false)时隐藏职业
boolean showOccupation = privacy == null || !Boolean.FALSE.equals(privacy.getShowOccupation());
Map<String, Object> userProfile = new HashMap<>();
userProfile.put("displayName", profile.getDisplayName());
userProfile.put("gender", profile.getGender());
// 派生年龄:不输出原始 birthDate(§5.2 禁放)
userProfile.put("age", calculateAge(profile.getBirthDate()));
userProfile.put("regionName", profile.getRegionName());
userProfile.put("educationCode", profile.getEducationCode());
if (showOccupation) {
userProfile.put("occupationCode", profile.getOccupationCode());
}
userProfile.put("maritalStatus", profile.getMaritalStatus());
return userProfile;
}
private int calculateAge(java.time.LocalDate birthDate) {
if (birthDate == null) return 0;
return java.time.Period.between(birthDate, java.time.LocalDate.now()).getYears();
}
/**
* 服务归属校验(ZC-MM-005 / E1 纯判定器版;ZC-MM-006 / T04 写接口 404 化)。
*
* <p><b>无任何回退</b>:matchmaker_assignments 是服务对象归属的唯一事实源。
* 002 的 assigned_operator_id 回退分支已删除 —— 分配 revoke/end 后服务单残留的
* assigned_operator_id 不再构成访问凭据(DEC-002 规则7:撤销即失权)。
* 写接口(recommend/followup/complete,资源寻址端点)口径:判定无效 →
* {@link ServiceAssignmentPolicy#assertActiveAssignmentOrNotFound}:
* 先落 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」—— 与读详情口径逐字段一致,防探测。
*
* @return 生效中的分配记录(供审计写入 service_assignment_id;deny 必然抛出,不会返回 null)
*/
private MatchmakerAssignment assertServiceAccess(String operatorId, MatchmakerServiceRecord service) {
return serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(operatorId, service.getUserId(),
"matchmaker_service", service.getId());
}
/**
* 获取红娘为该用户推荐的人员列表
@@ -394,11 +518,25 @@ public class MatchmakerServiceService {
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写(负向用例覆盖)
saveAuditLog(operatorId, action, resourceType, resourceId, beforeData, afterData, null, null);
}
/**
* ZC-MM-006 / T01 扩展版审计写入:附加 purpose_code 与 service_assignment_id。
*/
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData,
String purposeCode, String serviceAssignmentId) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLog.setBeforeData(beforeData);
auditLog.setAfterData(afterData);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
auditLogMapper.insert(auditLog);
}
}
@@ -0,0 +1,111 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/**
* 服务对象分配判定器(ZC-MM-002 / A1,架构师设计 D6;ZC-MM-005 / E1-E2 增强)。
*
* <p>红娘对某个用户能否读/写,唯一事实源是 matchmaker_assignments 里
* 「生效中的分配」(active 且未过有效期)。所有读路径前先 {@link #expireOverdue}
* 惰性过期(无定时任务,D5 决策),保证过期分配立即失效。
*
* <p>判定无效时的两种处置:
* <ul>
* <li>{@link #assertActiveAssignment}:写接口口径 —— 落 DATA_SCOPE_DENIED 审计 +
* 抛 AccessDeniedException(403);</li>
* <li>{@link #assertActiveAssignmentOrNotFound}(ZC-MM-005 / E2):读详情口径 ——
* 先落 DATA_SCOPE_DENIED 审计(语义上仍是 403 越权),再抛
* BusinessException(NOT_FOUND)(响应 404,与"资源不存在"逐字段一致),
* 防止枚举 serviceId 探测资源存在性。</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ServiceAssignmentPolicy {
private final MatchmakerAssignmentMapper assignmentMapper;
private final DataScopeGuard dataScopeGuard;
/**
* 惰性过期:把 expires_at 已届满的 active 分配置为 expired 并落库。
* 读路径入口统一调用;无定时任务(D5 决策)。
*/
public int expireOverdue() {
return assignmentMapper.expireOverdue();
}
/** 查询某红娘对某用户的生效中分配;不存在返回 empty。 */
public Optional<MatchmakerAssignment> findActiveAssignment(String operatorId, String userId) {
expireOverdue();
return assignmentMapper.findActiveByOperatorAndUser(operatorId, userId);
}
/** 某红娘当前是否可访问某用户(存在生效中的分配)。 */
public boolean canAccessUser(String operatorId, String userId) {
return findActiveAssignment(operatorId, userId).isPresent();
}
/**
* 断言某红娘对某用户持有生效中的分配;无效时:
* 落 DATA_SCOPE_DENIED 审计 + 抛 AccessDeniedException(403)。
*
* @param resourceType 审计 resourceType(如 matchmaker_service / matchmaker_user)
* @return 生效中的分配记录(永不返回 null)
*/
public MatchmakerAssignment assertActiveAssignment(String operatorId, String userId,
String resourceType) {
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
if (found.isPresent()) {
return found.get();
}
dataScopeGuard.deny(operatorId, resourceType, userId,
"未持有该服务对象的分配关系,禁止访问");
return null; // deny 必然抛出,此行不可达
}
/**
* 读详情口径的断言(ZC-MM-005 / E2 防探测 404)。
*
* <p>无效时先写 DATA_SCOPE_DENIED 审计(字段口径与 {@link DataScopeGuard#deny} 对齐,
* 语义上这是一次越权尝试),再抛 {@code BusinessException(NOT_FOUND, "服务不存在")}。
* 全局异常处理器将其映射为 404 响应体 {@code {success:false, code:NOT_FOUND,
* message:"服务不存在"}} —— 与查询一个真正不存在的 serviceId 的响应逐字段一致,
* 调用方无法区分"无权"与"不存在",杜绝按 serviceId 枚举探测。
*
* <p>适用面(ZC-MM-006 / T04 扩展):读详情 + <b>带资源寻址的写端点</b>
* (recommend / followup / complete / 转介)范围校验失败统一 404 化(防探测);
* create / assign 类无资源寻址端点维持 {@link #assertActiveAssignment} 的 403 口径。
*
* @param resourceType 审计 resourceType(如 matchmaker_service)
* @param resourceId 审计 resourceId(被探测的 serviceId)
* @return 生效中的分配记录(永不返回 null)
*/
public MatchmakerAssignment assertActiveAssignmentOrNotFound(String operatorId, String userId,
String resourceType,
String resourceId) {
Optional<MatchmakerAssignment> found = findActiveAssignment(operatorId, userId);
if (found.isPresent()) {
return found.get();
}
// ZC-MM-006 / T04:防探测 404 化 deny —— 审计与 404 抛出统一收敛到
// {@link DataScopeGuard#denyAsNotFound}(审计含权限快照 + 请求追踪 ID)。
// 该方法同时服务读详情与(本阶段起)写接口范围校验,拒绝口径全程一致。
dataScopeGuard.denyAsNotFound(operatorId, resourceType, resourceId, "服务不存在", null, null);
return null; // denyAsNotFound 必然抛出,此行不可达
}
/** 某红娘名下全部生效中的分配(GET /my 口径),读前惰性过期。 */
public List<MatchmakerAssignment> findActiveByOperator(String operatorId) {
expireOverdue();
return assignmentMapper.findActiveByOperator(operatorId);
}
}
@@ -44,6 +44,7 @@ public class OperatorManagementService {
List<Map<String, Object>> items = operators.stream().map(op -> {
Map<String, Object> m = new HashMap<>();
m.put("id", op.getId());
m.put("displayId", op.getDisplayId());
m.put("name", op.getName());
m.put("username", op.getUsername());
m.put("status", op.getStatus());
@@ -62,6 +63,7 @@ public class OperatorManagementService {
@Transactional
public Operator createOperator(String name, String username, String password, String organizationId) {
Operator operator = new Operator();
operator.setId(UUID.randomUUID().toString());
operator.setName(name);
operator.setUsername(username);
operator.setPasswordHash(passwordEncoder.encode(password));
@@ -54,6 +54,7 @@ public class OperatorService {
});
Operator operator = new Operator();
operator.setId(java.util.UUID.randomUUID().toString());
operator.setName(name);
operator.setUsername(username);
operator.setPasswordHash(passwordEncoder.encode(password));
@@ -15,6 +15,8 @@ public class Operator extends BaseEntity {
private String phoneEnc;
private String phoneHash;
private String username;
/** 对外展示编号,内部 id 仍为 UUID。 */
private String displayId;
private String passwordHash;
private String status;
private java.time.Instant lastLoginAt;
@@ -208,7 +208,9 @@ public class PhotoService {
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
}
int sortOrder = photoMapper.countActiveProfilePhotos(userId) + 1;
// countActiveProfilePhotos 返回 long,落库排序用 int(与 savePhoto 形参一致);
// 照片数量不会触及 int 上界,此处安全窄化。
int sortOrder = (int) (photoMapper.countActiveProfilePhotos(userId) + 1);
return savePhoto(userId, fileKey, photoType, sortOrder);
}
@@ -1,13 +1,15 @@
package com.danyang.match.platform.profile.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.danyang.match.platform.shared.typehandler.JsonNodeTypeHandler;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("user_preferences")
@TableName(value = "user_preferences", autoResultMap = true)
public class UserPreference extends BaseEntity {
private String userId;
@@ -17,8 +19,10 @@ public class UserPreference extends BaseEntity {
private Integer minHeightCm;
private Integer maxHeightCm;
private String heightPriority;
@TableField(typeHandler = JsonNodeTypeHandler.class)
private com.fasterxml.jackson.databind.JsonNode regionScope;
private String regionPriority;
@TableField(typeHandler = JsonNodeTypeHandler.class)
private com.fasterxml.jackson.databind.JsonNode maritalStatuses;
private String maritalPriority;
private String minEducationCode;
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.ResultMap;
import java.util.Optional;
@@ -20,5 +21,6 @@ public interface UserPreferenceMapper extends BaseMapper<UserPreference> {
* <p>{@code deleted_at IS NULL}:V037 已为 user_preferences 补上软删列。
*/
@Select("SELECT * FROM user_preferences WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
@ResultMap("mybatis-plus_UserPreference")
Optional<UserPreference> findByUserId(String userId);
}
@@ -30,7 +30,7 @@ public class ReferralService {
private final ActivityRegistrationMapper activityRegistrationMapper;
private final OperatorMapper operatorMapper;
private final NotificationService notificationService;
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
private final com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy serviceAssignmentPolicy;
/**
* Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
@@ -39,16 +39,24 @@ public class ReferralService {
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。
*
* 修复后:先断言存在进行中的 matchmaker_service(assigned_operator_id=红娘, user_id=目标用户),
* 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
* 修复后:先断言存在生效中的分配关系,否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
*
* <p>ZC-MM-005 收口(P1-3):授权事实源切换为
* {@link com.danyang.match.platform.matchmaker.service.ServiceAssignmentPolicy}
* 判定器 —— 与写接口 recommend / followup 同口径(matchmaker_assignments 生效中分配)。
* 分配 revoke / end 后残留列不再构成转介凭据(DEC-002 规则7:撤销即失权)。
*
* <p>ZC-MM-006 / T04(写接口 404 统一·防探测):转介为资源寻址写端点,拒绝路径
* 404 化 {@code denyAsNotFound}(先 DATA_SCOPE_DENIED 审计,再抛 404「服务不存在」,
* 与读详情口径逐字段一致);并消灭裸 IllegalArgumentException ——
* 缺失目标用户也统一映射为同一 404 响应,不泄露"目标是否存在"的任何信号。
*/
private void assertMatchmakerServesUser(String matchmakerId, String userId) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("缺少转介目标用户");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
}
matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId)
.orElseThrow(() -> new IllegalStateException(
"您不是该用户的服务负责人,无法发起转介"));
serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(matchmakerId, userId,
"matchmaker_referral", userId);
}
@Transactional
@@ -219,9 +227,10 @@ public class ReferralService {
private String getOperatorName(String operatorId) {
try {
var operator = operatorMapper.selectById(operatorId);
return operator != null ? operator.getName() : operatorId;
return operator != null ? operator.getName() : "公益红娘";
} catch (Exception e) {
return operatorId;
log.warn("转介操作员名称查询失败", e);
return "公益红娘";
}
}
@@ -251,16 +251,7 @@ public class RelationshipService {
matchMapper.insert(match);
}
// V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理
if (autoCreateConversation) {
try {
chatService.getOrCreateConversation(match.getId(), userId, targetUserId);
log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId);
} catch (Exception e) {
log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}",
match.getId(), e.toString(), e);
}
}
// 当前产品不提供用户间聊天;Match 仅用于结果判定和公益服务引导。
return match;
}
@@ -286,7 +277,6 @@ public class RelationshipService {
result.put("matchReused", true);
result.put("matchId", existingMatch.get().getId());
result.put("status", existingMatch.get().getStatus());
result.put("conversationAutoCreated", true);
log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId());
return result;
}
@@ -299,20 +289,10 @@ public class RelationshipService {
// 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation)
Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false);
// V1.1 Phase 16 修复(Gate:互选确认后双方可沟通):双确认后必须开启会话。
// 此前此处未建会话,仅靠首条消息惰性创建,导致「双方可沟通」的入口不可见。
// 现显式开启,保证互选成功后聊天入口立即可见。
try {
chatService.getOrCreateConversation(match.getId(), match.getUserAId(), match.getUserBId());
} catch (Exception e) {
log.warn("活动互选开启会话失败(Match 已创建): matchId={}, error={}", match.getId(), e.toString(), e);
}
Map<String, Object> result = new HashMap<>();
result.put("matchCreated", true);
result.put("matchId", match.getId());
result.put("priorUnmatch", priorUnmatchExists);
result.put("conversationAutoCreated", true);
log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}",
userId, partnerId, priorUnmatchExists, match.getId());
return result;
@@ -95,9 +95,9 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approve(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, Object> body) {
String operatorId = principal.id();
String comment = body.getOrDefault("comment", "");
String comment = (String) body.getOrDefault("comment", "");
return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment));
}
@@ -140,8 +140,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> needEvidence(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/approve-local-eligibility")
@@ -149,8 +149,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approveLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/reject-local-eligibility")
@@ -158,8 +158,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/evidences/{evidenceId}/access")
@@ -168,9 +168,9 @@ public class ReviewController {
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@PathVariable String evidenceId,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
taskId, evidenceId, principal.id(), body.get("purpose")));
taskId, evidenceId, principal.id(), (String) body.get("purpose")));
}
/**
@@ -45,5 +45,26 @@ public class ReportController {
return ApiResponse.ok(reportService.getMyReports(userId));
}
/**
* 获取我的举报详情(仅举报人本人可见,含处理结果与证据签名 URL)
* GET /api/v1/risk/reports/{reportId}
*/
@GetMapping("/{reportId}")
public ApiResponse<Map<String, Object>> getMyReportDetail(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String reportId) {
return ApiResponse.ok(reportService.getMyReportDetail(principal.id(), reportId));
}
/**
* 获取举报证据直传凭证(客户端直传对象存储后,将 fileKey 随提交举报上报)
* POST /api/v1/risk/reports/evidence/upload-token
*/
@PostMapping("/evidence/upload-token")
public ApiResponse<Map<String, Object>> getEvidenceUploadToken(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(reportService.getEvidenceUploadToken(userId));
}
}
@@ -3,9 +3,12 @@ package com.danyang.match.platform.risk.service;
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.audit.service.AuditLogService;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.risk.dto.ReportItemDTO;
import com.danyang.match.platform.risk.dto.ReportProcessDTO;
import com.danyang.match.platform.risk.dto.ReportSubmitDTO;
@@ -16,9 +19,16 @@ import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.risk.mapper.RiskEventMapper;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import com.amazonaws.HttpMethod;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
@@ -30,9 +40,11 @@ import java.time.Instant;
import java.util.Map;
import java.util.List;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/**
* 举报与风控服务
@@ -43,17 +55,72 @@ import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.Busine
* - 封禁需要人工复核,不能自动永久封禁
*/
@Service
@RequiredArgsConstructor
public class ReportService {
private static final Logger log = LoggerFactory.getLogger(ReportService.class);
/** 补充说明字数上限(与前端 maxlength 对齐)。 */
private static final int MAX_DESCRIPTION_LENGTH = 500;
/** 证据截图上限的配置默认值(未获产品正式确认,按「DEFAULT + CONFIG」可配置,见 docs/governance/config-register)。 */
private static final int DEFAULT_MAX_EVIDENCE = 9;
/** 运营端按状态列表查询的单页上限。 */
private static final int STATUS_LIST_LIMIT = 200;
/** 高风险举报原因:必须补充文字说明。 */
private static final Set<String> HIGH_RISK_REASONS = Set.of(
Constants.REPORT_REASON_FRAUD,
Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION,
Constants.REPORT_REASON_FAKE_MARITAL_STATUS
);
private final ReportMapper reportMapper;
private final RiskEventMapper riskEventMapper;
private final RiskActionMapper riskActionMapper;
private final UserProfileMapper userProfileMapper;
private final UserMapper userMapper;
private final AuditLogMapper auditLogMapper;
private final AuditLogService auditLogService;
private final NotificationService notificationService;
private final ObjectMapper objectMapper;
/** 证据对象的归属/存在性校验走内网客户端。 */
private final AmazonS3 amazonS3;
/** 给客户端签发证据直传与查看签名 URL 的客户端(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
@Value("${object-storage.bucket-private}")
private String privateBucket;
@Value("${report.evidence.max-count:" + DEFAULT_MAX_EVIDENCE + "}")
private int maxEvidenceCount;
@Autowired
public ReportService(
ReportMapper reportMapper,
RiskEventMapper riskEventMapper,
RiskActionMapper riskActionMapper,
UserProfileMapper userProfileMapper,
UserMapper userMapper,
AuditLogMapper auditLogMapper,
AuditLogService auditLogService,
NotificationService notificationService,
ObjectMapper objectMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.reportMapper = reportMapper;
this.riskEventMapper = riskEventMapper;
this.riskActionMapper = riskActionMapper;
this.userProfileMapper = userProfileMapper;
this.userMapper = userMapper;
this.auditLogMapper = auditLogMapper;
this.auditLogService = auditLogService;
this.notificationService = notificationService;
this.objectMapper = objectMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
}
// 风险评分权重
private static final Map<String, Integer> REASON_SCORES = Map.of(
@@ -75,10 +142,30 @@ public class ReportService {
*/
@Transactional
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
// 举报对象校验
if (dto.getReportedUserId() == null || dto.getReportedUserId().isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少举报对象");
}
// 不允许举报自己
if (reporterUserId.equals(dto.getReportedUserId())) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
}
// 举报原因白名单(REASON_SCORES 的 key 即合法枚举)
String reasonType = dto.getReasonType();
if (reasonType == null || !REASON_SCORES.containsKey(reasonType)) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "举报原因不合法");
}
// 补充说明长度 + 高风险必填
String description = dto.getDescription() == null ? "" : dto.getDescription().trim();
if (description.length() > MAX_DESCRIPTION_LENGTH) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"补充说明过长(上限 " + MAX_DESCRIPTION_LENGTH + " 字)");
}
if (HIGH_RISK_REASONS.contains(reasonType) && description.isEmpty()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "该举报原因需要补充说明");
}
// 证据截图:校验归属与存在性(避免任意 fileKey 注入或引用未完成上传的孤儿对象)
List<String> evidenceFileIds = validateEvidence(reporterUserId, dto.getEvidenceFileIds());
// 检查是否已被封禁
List<RiskAction> activeActions = riskActionMapper.findActiveByUserId(reporterUserId);
@@ -91,34 +178,35 @@ public class ReportService {
Report report = new Report();
report.setReporterUserId(reporterUserId);
report.setReportedUserId(dto.getReportedUserId());
report.setReasonType(dto.getReasonType());
report.setDescription(dto.getDescription());
if (dto.getEvidenceFileIds() != null) {
report.setEvidenceFileIds(java.util.List.of(dto.getEvidenceFileIds()).toString());
}
report.setReasonType(reasonType);
report.setDescription(description.isEmpty() ? null : description);
// evidence_file_ids 列(TEXT)存 JSON 数组:必须序列化为合法 JSON,不能用 List.toString()
report.setEvidenceFileIds(writeEvidenceJson(evidenceFileIds));
report.setStatus(Constants.REPORT_STATUS_PENDING);
reportMapper.insert(report);
// 触发风险事件:被举报用户得分增加
int scoreAdd = REASON_SCORES.getOrDefault(dto.getReasonType(), 5);
int scoreAdd = REASON_SCORES.getOrDefault(reasonType, 5);
RiskEvent riskEvent = new RiskEvent();
riskEvent.setUserId(dto.getReportedUserId());
riskEvent.setEventType(Constants.RISK_EVENT_ABUSE_REPORT);
riskEvent.setScore(scoreAdd);
riskEvent.setSource("report");
riskEvent.setSourceId(report.getId());
riskEvent.setNote("举报触发,原因: " + dto.getReasonType());
riskEvent.setNote("举报触发,原因: " + reasonType);
riskEventMapper.insert(riskEvent);
// 高风险举报自动标记为处理中(诈骗/金钱诱导/冒充)
if (List.of(Constants.REPORT_REASON_FRAUD, Constants.REPORT_REASON_MONEY_INDUCEMENT,
Constants.REPORT_REASON_IMPERSONATION).contains(dto.getReasonType())) {
Constants.REPORT_REASON_IMPERSONATION).contains(reasonType)) {
report.setStatus(Constants.REPORT_STATUS_PROCESSING);
reportMapper.updateById(report);
}
// 审计
saveAuditLog(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
// 审计(C 端用户主体:写 subject_user_id 且 operator_id=null)
// 修复:原 saveAuditLog(operatorId=userId) 会让 userId 撞 audit_logs_operator_id_fkey(→operators)
// 触发 FK violation,导致举报提交必然 500、举报→管理端举报中心链路整体不可用。
auditLogService.recordUserAction(reporterUserId, "SUBMIT_REPORT", "report", report.getId(), null, null);
Map<String, Object> result = new HashMap<>();
result.put("reportId", report.getId());
@@ -135,6 +223,45 @@ public class ReportService {
.collect(Collectors.toList());
}
/**
* 用户端举报详情(仅举报人本人可见):返回处理结果与证据签名查看 URL。
* 非本人访问一律按「不存在」处理,避免泄露他人举报存在性。
*/
public Map<String, Object> getMyReportDetail(String userId, String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null || !userId.equals(report.getReporterUserId())) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
return detail;
}
/**
* 签发举报证据直传凭证(客户端直传私有桶,服务端不落库)。
* fileKey 固定以 {@code reports/{userId}/evidence/} 开头,提交举报时按此前缀校验归属。
*/
public Map<String, Object> getEvidenceUploadToken(String userId) {
String fileKey = String.format("reports/%s/evidence/%s.jpg",
userId, UUID.randomUUID().toString().replace("-", ""));
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
token.put("uploadUrl", uploadUrl);
token.put("expiresIn", 3600);
return token;
}
// ==================== 管理端举报处理 ====================
/**
@@ -157,6 +284,13 @@ public class ReportService {
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
// 证据截图:向运营端返回文件标识与短时签名查看 URL(查看动作的敏感访问审计由调用方记录)
List<String> evidenceFileKeys = parseEvidence(report.getEvidenceFileIds());
detail.put("evidenceFileKeys", evidenceFileKeys);
detail.put("evidenceUrls", evidenceFileKeys.stream()
.map(this::signEvidenceUrl)
.collect(Collectors.toList()));
// 被举报人风险记录
String reportedUserId = report.getReportedUserId();
if (reportedUserId != null) {
@@ -215,7 +349,10 @@ public class ReportService {
}
// 通知被举报用户
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote());
sendProcessNotification(report.getReportedUserId(), result, dto.getProcessNote(), reportId);
// 举报链路闭环:处置完成后通知举报人(仅告知结果,不泄露被举报人处置细节)
sendReporterResultNotification(report.getReporterUserId(), result, reportId);
// 高风险举报(婚姻状态欺诈)额外记录风险事件
if (Constants.REPORT_REASON_FAKE_MARITAL_STATUS.equals(report.getReasonType())) {
@@ -257,8 +394,20 @@ public class ReportService {
return m;
}).collect(java.util.stream.Collectors.toList());
}
// For other statuses, return empty or implement full query
return List.of();
// 按状态真实查询(此前固定返回空列表,导致运营端「处理中 / 已处理」页签恒为空)
return reportMapper.findByStatusPaged(status, STATUS_LIST_LIMIT, 0).stream().map(report -> {
Map<String, Object> m = new java.util.HashMap<>();
m.put("id", report.getId());
m.put("reasonType", report.getReasonType());
m.put("reasonText", getReasonText(report.getReasonType()));
m.put("reportedUserId", report.getReportedUserId());
m.put("status", report.getStatus());
m.put("description", report.getDescription());
m.put("createdAt", report.getCreatedAt());
m.put("processResult", report.getProcessResult());
m.put("processNote", report.getProcessNote());
return m;
}).collect(java.util.stream.Collectors.toList());
}
public Map<String, Object> getReportStats() {
@@ -298,6 +447,83 @@ public class ReportService {
// ==================== 私有辅助方法 ====================
/** 校验证据文件标识:数量上限 + 归属前缀(reports/{userId}/)+ 对象确实已上传。 */
private List<String> validateEvidence(String reporterUserId, List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return List.of();
}
if (evidenceFileIds.size() > maxEvidenceCount) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"证据截图最多 " + maxEvidenceCount + " 张");
}
String prefix = "reports/" + reporterUserId + "/";
for (String key : evidenceFileIds) {
if (key == null || !key.startsWith(prefix)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的证据文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, key)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "证据文件尚未上传完成");
}
}
return evidenceFileIds;
}
/** 将证据 fileKey 列表序列化为 JSON 数组,写入 evidence_file_ids(TEXT 列)。 */
private String writeEvidenceJson(List<String> evidenceFileIds) {
if (evidenceFileIds == null || evidenceFileIds.isEmpty()) {
return "[]";
}
try {
return objectMapper.writeValueAsString(evidenceFileIds);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new ServerErrorException("证据序列化失败", e);
}
}
/** 解析 evidence_file_ids 的 JSON 数组;历史脏数据(非 JSON 数组)按空列表处理。 */
private List<String> parseEvidence(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("解析举报证据 JSON 失败,按空列表处理: {}", json);
return List.of();
}
}
/** 为证据文件签发短时(10 分钟)只读 URL,供运营端查看。 */
private String signEvidenceUrl(String fileKey) {
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
return amazonS3Signer.generatePresignedUrl(
new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(HttpMethod.GET)
.withExpiration(expiration)).toString();
}
/** 取用户展示名(无资料时回退 null,前端可回退为「用户」)。 */
private String displayNameOf(String userId) {
if (userId == null) return null;
return userProfileMapper.findByUserId(userId).map(UserProfile::getDisplayName).orElse(null);
}
/** 处置完成后通知举报人(不通知被举报人处置细节,避免二次伤害/踢皮球)。 */
private void sendReporterResultNotification(String reporterUserId, String result, String reportId) {
if (reporterUserId == null) return;
String title = "举报处理结果";
String content = Constants.PROCESS_RESULT_NO_ACTION.equals(result)
? "您提交的举报经核实未发现问题,感谢您的监督。"
: "您提交的举报已处理完成,感谢您对社区安全的贡献。";
try {
// business_id 列为 UUID:用举报 id 作幂等键(不可传非 UUID 字符串)
notificationService.send(reporterUserId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"report", reportId);
} catch (Exception e) {
log.warn("举报结果通知发送失败: reporter={}", reporterUserId, e);
}
}
private void applyRiskAction(String userId, String result, String note, String operatorId) {
RiskAction action = new RiskAction();
action.setUserId(userId);
@@ -329,6 +555,34 @@ public class ReportService {
action.setEffectiveAt(Instant.now());
action.setReasonDetail(note);
riskActionMapper.insert(action);
// 同步账号状态:封禁 / 暂停必须落到 users.account_status,否则处置不真正生效 ——
// 登录门禁(AuthService.checkAccountStatus)、推荐引擎、匹配资格、能力判定
// 全部以 users.account_status 为准,只写 risk_actions 会让封禁沦为"一条记录"。
String newAccountStatus = switch (result) {
case Constants.PROCESS_RESULT_BAN -> Constants.ACCOUNT_STATUS_BANNED;
case Constants.PROCESS_RESULT_TEMPORARY_SUSPEND -> Constants.ACCOUNT_STATUS_SUSPENDED;
default -> null; // warning / feature_limit 不改变账号状态
};
if (newAccountStatus != null) {
syncAccountStatus(userId, newAccountStatus, operatorId);
}
}
/**
* 把处置结果写入 users.account_status 并留痕。
* 注销(closed)态不属风控处置范围,不可被覆盖;相同状态不重复写。
*/
private void syncAccountStatus(String userId, String newStatus, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) return;
String oldStatus = user.getAccountStatus();
if (newStatus.equals(oldStatus) || Constants.ACCOUNT_STATUS_CLOSED.equals(oldStatus)) return;
user.setAccountStatus(newStatus);
userMapper.updateById(user);
// audit_logs.before_data/after_data 为 JSONB:必须写合法 JSON,裸字符串(如 normal)会被 PG 拒绝。
saveAuditLog(operatorId, "ACCOUNT_STATUS_CHANGE", "user", userId,
"{\"accountStatus\":\"" + oldStatus + "\"}", "{\"accountStatus\":\"" + newStatus + "\"}");
}
private String calculateRiskLevel(int score) {
@@ -351,6 +605,8 @@ public class ReportService {
dto.setProcessNote(report.getProcessNote());
// 填充显示名称
dto.setReasonText(getReasonText(report.getReasonType()));
dto.setReporterName(displayNameOf(report.getReporterUserId()));
dto.setReportedName(displayNameOf(report.getReportedUserId()));
return dto;
}
@@ -368,7 +624,7 @@ public class ReportService {
};
}
private void sendProcessNotification(String userId, String result, String note) {
private void sendProcessNotification(String userId, String result, String note, String businessId) {
if (userId == null) return;
String title = "账号处置通知";
String content = switch (result) {
@@ -378,8 +634,9 @@ public class ReportService {
case Constants.PROCESS_RESULT_BAN -> "您的账号已被封禁,如有疑问可提交申诉。";
default -> "您的账号相关处置已完成。";
};
// business_id 列为 UUID:用被处置举报 id 作幂等键(历史 defect:曾传非 UUID 字符串导致处置 500,事务回滚)
notificationService.send(userId, Constants.NOTIFICATION_TYPE_RISK, title, content,
"risk_action", null, "risk_action:" + userId + ":" + System.currentTimeMillis());
"risk_action", businessId);
}
private void saveAuditLog(String operatorId, String action, String resourceType,
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
*/
@Slf4j
@RestControllerAdvice
@org.springframework.core.annotation.Order(0)
public class ApiResponseStatusAdvice implements ResponseBodyAdvice<ApiResponse> {
private static final Map<String, ApiResponseCode> BY_CODE =
@@ -65,7 +65,8 @@ public class SecurityConfig {
.accessDeniedHandler(forbiddenHandler(objectMapper)))
.authorizeHttpRequests(auth -> auth
// 公开接口(order=0 最高优先级)
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login").permitAll()
.requestMatchers("/auth/wechat/login", "/auth/refresh", "/auth/operator/login")
.permitAll()
.requestMatchers("/health/**").permitAll()
.requestMatchers("/version").permitAll()
.requestMatchers("/app/config").permitAll()
@@ -100,7 +101,9 @@ public class SecurityConfig {
"/activities/operator/**",
"/service-appointments/operator/**",
"/service-programs/operator/**",
"/referrals/operator/**")
"/referrals/operator/**",
"/referrals/service-station",
"/referrals/activity")
.hasRole("OPERATOR")
/**
* 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。
@@ -330,9 +330,68 @@ public final class Constants {
public static final String RECOMMENDATION_SOURCE_MATCHMAKER = "matchmaker";
public static final String PROFILE_STATUS_PENDING_REVIEW = "pending_review";
// ========== 服务对象分配(ZC-MM-002 / A1,与 V053 数据库 CHECK 白名单一致)==========
// 状态机:active → transferred / revoked / ended;expires_at 届满由判定器惰性置 expired。
// 终态(ended/revoked/transferred/expired)一律拒绝再转换。
public static final String ASSIGNMENT_STATUS_ACTIVE = "active";
public static final String ASSIGNMENT_STATUS_ENDED = "ended";
public static final String ASSIGNMENT_STATUS_REVOKED = "revoked";
public static final String ASSIGNMENT_STATUS_TRANSFERRED = "transferred";
public static final String ASSIGNMENT_STATUS_EXPIRED = "expired";
// 分配范围:primary(主责,同用户仅一条生效中)/ collaborating(协同)
public static final String ASSIGNMENT_SCOPE_PRIMARY = "primary";
public static final String ASSIGNMENT_SCOPE_COLLABORATING = "collaborating";
// 服务对象分配审计 action(resourceType = matchmaker_assignment)
public static final String AUDIT_ACTION_ASSIGNMENT_CREATE = "MATCHMAKER_ASSIGNMENT_CREATE";
public static final String AUDIT_ACTION_ASSIGNMENT_TRANSFER = "MATCHMAKER_ASSIGNMENT_TRANSFER";
public static final String AUDIT_ACTION_ASSIGNMENT_REVOKE = "MATCHMAKER_ASSIGNMENT_REVOKE";
public static final String AUDIT_ACTION_ASSIGNMENT_END = "MATCHMAKER_ASSIGNMENT_END";
// ========== V1.1 本地资格审核 ==========
/** 本地资格审核任务类型 */
public static final String REVIEW_TYPE_LOCAL_ELIGIBILITY = "local_eligibility";
/** 本地资格佐证数据类型(用于敏感访问审计) */
public static final String SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE = "local_eligibility_evidence";
// ========== ZC-MM-006 / T03 红娘域敏感访问审计 ==========
/** 红娘工作台服务对象详情数据类型(用于敏感访问审计) */
public static final String SENSITIVE_DATA_TYPE_MATCHMAKER_SERVICE_DETAIL = "matchmaker_service_detail";
/** 敏感访问成功结果(与 SensitiveAccessLog.accessResult 白名单 success/denied/failed 对齐) */
public static final String SENSITIVE_ACCESS_RESULT_SUCCESS = "success";
/** 红娘查看服务对象详情的访问理由文案 */
public static final String SENSITIVE_ACCESS_REASON_MATCHMAKER_DETAIL = "matchmaker 工作台查看服务对象详情";
// ========== ZC-MM-003 / B1 立项:受控联系授权(V056) ==========
/** 授权状态:待审批 */
public static final String CONTACT_AUTH_STATUS_PENDING_APPROVAL = "pending_approval";
/** 授权状态:待用户同意 */
public static final String CONTACT_AUTH_STATUS_PENDING_CONSENT = "pending_consent";
/** 授权状态:生效中 */
public static final String CONTACT_AUTH_STATUS_ACTIVE = "active";
/** 授权状态:用户已拒绝 */
public static final String CONTACT_AUTH_STATUS_REJECTED = "rejected";
/** 授权状态:已过期(有效期/同意窗口届满,双态惰性收敛) */
public static final String CONTACT_AUTH_STATUS_EXPIRED = "expired";
/** 授权状态:已撤销 */
public static final String CONTACT_AUTH_STATUS_REVOKED = "revoked";
/** 渠道:电话联系 */
public static final String CONTACT_CHANNEL_PHONE = "phone";
/** 渠道:线下见面 */
public static final String CONTACT_CHANNEL_OFFLINE_MEETING = "offline_meeting";
/** 渠道:完整联系方式 */
public static final String CONTACT_CHANNEL_FULL_CONTACT = "full_contact";
/** 审计动作:红娘发起授权申请 */
public static final String CONTACT_AUDIT_ACTION_REQUEST = "MATCHMAKER_CONTACT_REQUEST";
/** 审计动作:审批岗批准/拒绝申请 */
public static final String CONTACT_AUDIT_ACTION_APPROVE = "MATCHMAKER_CONTACT_APPROVE";
/** 审计动作:用户单独同意 */
public static final String CONTACT_AUDIT_ACTION_CONSENT = "MATCHMAKER_CONTACT_CONSENT";
/** 审计动作:用户拒绝 */
public static final String CONTACT_AUDIT_ACTION_DECLINE = "MATCHMAKER_CONTACT_DECLINE";
/** 审计动作:披露联系方式(H4 唯一披露出口) */
public static final String CONTACT_AUDIT_ACTION_DISCLOSE = "MATCHMAKER_CONTACT_DISCLOSE";
/** 审计动作:撤销授权 */
public static final String CONTACT_AUDIT_ACTION_REVOKE = "MATCHMAKER_CONTACT_REVOKE";
}
@@ -1,6 +1,7 @@
package com.danyang.match.platform.shared.filter;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.permission.service.PermissionService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
@@ -34,14 +35,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
private final PermissionService permissionService;
private final OperatorMapper operatorMapper;
private static final List<String> PERMITTED_PATHS = List.of(
"/api/v1/auth/**",
"/auth/**",
"/api/v1/health/**",
"/health/**",
"/api/v1/version",
"/version",
"/api/v1/operator/auth/**",
"/api/v1/app/**",
"/api/v1/activities"
"/app/**",
"/api/v1/activities",
"/activities"
);
@Override
@@ -49,11 +56,16 @@ public class JwtAuthFilter extends OncePerRequestFilter {
FilterChain chain) throws ServletException, IOException {
String contextPath = request.getContextPath();
String servletPath = request.getServletPath();
if (servletPath == null || servletPath.isEmpty()) {
servletPath = request.getRequestURI();
}
String effectivePath = (contextPath != null && !contextPath.isEmpty())
? contextPath + servletPath : servletPath;
String method = request.getMethod();
if ("OPTIONS".equals(method) || isPermittedPath(effectivePath)) {
if ("OPTIONS".equals(method)
|| isPermittedPath(effectivePath)
|| isPermittedPath(request.getRequestURI())) {
chain.doFilter(request, response);
return;
}
@@ -76,7 +88,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
}
}
if (token == null) {
chain.doFilter(request, response);
sendUnauthorized(response);
return;
}
if (!jwtTokenService.validateToken(token)) {
@@ -87,6 +99,20 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String subject = jwtTokenService.getUserIdFromToken(token);
String tokenType = jwtTokenService.parseToken(token).get("type", String.class);
boolean operatorToken = "operator_access".equals(tokenType);
if (operatorToken) {
// ZC-MM-006 / T02(JWT 权限回收·实时查库路线,不建撤销名单):
// 运营 token 每请求实时查 operators.status —— 账号不存在(含软删除)或非 active
// 一律 401、不建 principal,已签发 token 立即失效;零缓存。
// 注意:此处只接受 "active";"normal" 是用户账号状态(ACCOUNT_STATUS_NORMAL),
// 运营账号禁用/待激活均不得通过过滤器。
var operator = operatorMapper.findById(subject);
if (operator.isEmpty() || !"active".equals(operator.get().getStatus())) {
sendUnauthorized(response);
return;
}
}
// ZC-MM-006 / T02:权限实时查库(PermissionMapper join role_permissions/operator_roles),
// 每请求生效;token claims 中的 permissions 仅作展示快照(只写不读),不是授权事实源。
List<String> permissions = operatorToken
? permissionService.getPermissions(subject)
: List.of();
@@ -120,7 +146,11 @@ public class JwtAuthFilter extends OncePerRequestFilter {
}
private boolean isPermittedPath(String path) {
String normalizedPath = path.startsWith("/api/v1")
? path
: "/api/v1" + (path.startsWith("/") ? path : "/" + path);
return PERMITTED_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
.anyMatch(pattern -> pathMatcher.match(pattern, path)
|| pathMatcher.match(pattern, normalizedPath));
}
}
@@ -0,0 +1,62 @@
package com.danyang.match.platform.shared.id;
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 com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.http.*;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
import java.io.*;
import java.lang.reflect.Type;
import java.util.Map;
/** 仅解析已有写接口的人员/活动编号;业务层仍校验原 UUID 的权限与数据范围。 */
@RestControllerAdvice
@RequiredArgsConstructor
public class PublicIdInputAdvice extends RequestBodyAdviceAdapter {
private final ObjectMapper objectMapper;
private final PublicIdMapper mapper;
private static final Map<String, String> TYPES = Map.of(
"userId", "users", "targetUserId", "users", "reportedUserId", "users",
"counselorId", "operators", "assignedOperatorId", "operators",
"matchmakerOperatorId", "operators", "newMatchmakerOperatorId", "operators",
"activityId", "activities");
@Override
public boolean supports(MethodParameter method, Type targetType, Class<? extends HttpMessageConverter<?>> converter) {
return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converter);
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage message, MethodParameter method, Type targetType,
Class<? extends HttpMessageConverter<?>> converter) throws IOException {
JsonNode body = objectMapper.readTree(message.getBody());
resolve(body);
byte[] bytes = objectMapper.writeValueAsBytes(body);
HttpHeaders headers = new HttpHeaders();
headers.putAll(message.getHeaders());
headers.setContentLength(bytes.length);
return new HttpInputMessage() {
public InputStream getBody() { return new ByteArrayInputStream(bytes); }
public HttpHeaders getHeaders() { return headers; }
};
}
void resolve(JsonNode node) {
if (node == null) return;
if (node.isArray()) { node.forEach(this::resolve); return; }
if (!node.isObject()) return;
ObjectNode object = (ObjectNode) node;
for (var entry : TYPES.entrySet()) {
JsonNode value = object.get(entry.getKey());
if (value == null || !value.isTextual() || !value.asText().matches("[A-Z]{2}[0-9]{11}")) continue;
String id = mapper.resolve(value.asText(), entry.getValue());
if (id == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "编号对应的对象不存在或不可用");
object.put(entry.getKey(), id);
}
object.elements().forEachRemaining(child -> { if (child.isContainerNode()) resolve(child); });
}
}
@@ -0,0 +1,21 @@
package com.danyang.match.platform.shared.id;
import org.apache.ibatis.annotations.*;
import java.util.List;
import java.util.Map;
@Mapper
public interface PublicIdMapper {
@Select({"<script>SELECT id AS \"id\", display_id AS \"displayId\", entity_type AS \"entityType\", referral_display_id AS \"referralDisplayId\" FROM public_display_ids WHERE id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>CAST(#{id} AS text)</foreach></script>"})
List<Map<String, Object>> findAll(@Param("ids") List<String> ids);
@Select("SELECT id FROM public_display_ids WHERE display_id=#{number} AND entity_type=#{entityType}")
String resolve(@Param("number") String number, @Param("entityType") String entityType);
@Select("SELECT public_next_display_id('ZF', (#{createdAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date)")
String nextReferral(@Param("createdAt") java.time.Instant createdAt);
@Update("UPDATE matchmaker_followups SET referral_display_id=#{number} WHERE id=#{id}::uuid AND referral_display_id IS NULL")
int saveReferral(@Param("id") String id, @Param("number") String number);
}
@@ -0,0 +1,59 @@
package com.danyang.match.platform.shared.id;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.regex.Pattern;
/** 只补充原响应已允许返回的对象编号;不加载资料,不改变 UUID 与权限。 */
@Service
@RequiredArgsConstructor
public class PublicIdResponse {
private final PublicIdMapper mapper;
private static final Pattern UUID = Pattern.compile("[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}");
private static boolean reference(String name) {
return "id".equals(name) || name.endsWith("Id");
}
public void enrich(JsonNode data) {
Set<String> ids = new LinkedHashSet<>();
collect(data, ids);
if (ids.isEmpty()) return;
Map<String, Map<String, Object>> numbers = new HashMap<>();
for (var row : mapper.findAll(new ArrayList<>(ids))) {
String id = row.get("id").toString();
if (numbers.putIfAbsent(id, row) != null) throw new IllegalStateException("跨实体 UUID 冲突,不能确定公开编号");
}
apply(data, numbers);
}
private void collect(JsonNode node, Set<String> ids) {
if (node.isObject()) node.fields().forEachRemaining(field -> {
if (reference(field.getKey()) && field.getValue().isTextual() && UUID.matcher(field.getValue().asText()).matches()) ids.add(field.getValue().asText());
else if (field.getValue().isContainerNode()) collect(field.getValue(), ids);
});
else if (node.isArray()) node.forEach(child -> collect(child, ids));
}
private void apply(JsonNode node, Map<String, Map<String, Object>> numbers) {
if (node.isArray()) { node.forEach(child -> apply(child, numbers)); return; }
if (!node.isObject()) return;
var object = (ObjectNode) node;
List<Map.Entry<String, JsonNode>> fields = new ArrayList<>();
object.fields().forEachRemaining(fields::add);
for (var field : fields) {
String name = field.getKey();
JsonNode value = field.getValue();
if (value.isContainerNode()) { apply(value, numbers); continue; }
if (!reference(name) || !value.isTextual() || !UUID.matcher(value.asText()).matches()) continue;
var row = numbers.get(value.asText());
String stem = name.equals("id") ? "" : name.substring(0, name.length()-2);
boolean user = stem.toLowerCase(Locale.ROOT).endsWith("user") || (row != null && "users".equals(row.get("entityType")));
String key = stem.isEmpty() ? "displayId" : stem + (user ? "DisplayNumber" : "DisplayId");
Object number = row == null ? null : row.get("displayId");
if (row != null && row.get("referralDisplayId") != null && "matchmaker_followups".equals(row.get("entityType"))) number = row.get("referralDisplayId");
if (number == null) object.putNull(key); else object.put(key, number.toString());
if (stem.isEmpty() && user) object.put("displayNumber", number == null ? null : number.toString());
if (stem.isEmpty() && row != null && row.get("referralDisplayId") != null) object.put("referralDisplayId", row.get("referralDisplayId").toString());
}
}
}
@@ -0,0 +1,37 @@
package com.danyang.match.platform.shared.id;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.server.*;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
@RequiredArgsConstructor
public class PublicIdResponseAdvice implements ResponseBodyAdvice<Object> {
private final ObjectMapper objectMapper;
private final PublicIdResponse publicIds;
@Override
public boolean supports(MethodParameter method, Class<? extends HttpMessageConverter<?>> converter) {
return MappingJackson2HttpMessageConverter.class.isAssignableFrom(converter);
}
@Override
@SuppressWarnings({"rawtypes", "unchecked"})
public Object beforeBodyWrite(Object body, MethodParameter method, MediaType type,
Class<? extends HttpMessageConverter<?>> converter, ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof ApiResponse api && api.isSuccess() && api.getData() != null) {
var data = objectMapper.valueToTree(api.getData());
publicIds.enrich(data);
api.setData(data);
}
return body;
}
}
@@ -2,6 +2,7 @@ package com.danyang.match.platform.shared.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditDenyWriter;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,6 +22,7 @@ import java.util.UUID;
public class AuthorizationAuditListener {
private final AuditLogMapper auditLogMapper;
private final AuditDenyWriter auditDenyWriter;
@EventListener
public void onAuthorizationDenied(AuthorizationDeniedEvent<?> event) {
@@ -35,6 +37,9 @@ public class AuthorizationAuditListener {
audit.setAction("AUTHORIZATION_DENIED");
audit.setResourceType("authorization");
audit.setResourceId(limit(String.valueOf(event.getObject()), 128));
// ZC-MM-006 / T01:权限快照(permissions 逗号拼接,截断 128)+ 请求追踪 ID
audit.setActorRoleSnapshot(limit(String.join(",", principal.permissions()), 128));
audit.setRequestTraceId(org.slf4j.MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) {
@@ -43,7 +48,8 @@ public class AuthorizationAuditListener {
}
try {
auditLogMapper.insert(audit);
// 拒绝审计独立事务提交:@PreAuthorize 拒绝若发生在业务事务内,同事务写入会被回滚吞掉
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入授权拒绝审计失败: operatorId={}", principal.id(), exception);
}
@@ -2,13 +2,19 @@ package com.danyang.match.platform.shared.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.service.AuditDenyWriter;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.UUID;
import org.slf4j.MDC;
/** Denies out-of-scope operator access and records the attempt before returning 403. */
@Slf4j
@@ -17,21 +23,118 @@ import java.util.UUID;
public class DataScopeGuard {
private final AuditLogMapper auditLogMapper;
private final AuditDenyWriter auditDenyWriter;
public void deny(String operatorId, String resourceType, String resourceId, String message) {
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写
deny(operatorId, resourceType, resourceId, message, null, null);
}
/**
* ZC-MM-006 / T01 扩展版拒绝审计:附加业务目的码与关联分配 ID。
*
* @param purposeCode 业务目的码(如 matchmaker.service.view),可空
* @param serviceAssignmentId 关联的 matchmaker_assignments.id(拒绝路径通常无生效分配 → null)
*/
public void deny(String operatorId, String resourceType, String resourceId, String message,
String purposeCode, String serviceAssignmentId) {
AuditLog audit = new AuditLog();
audit.setId(UUID.randomUUID().toString());
audit.setOperatorId(operatorId);
audit.setAction("DATA_SCOPE_DENIED");
audit.setResourceType(resourceType);
audit.setResourceId(resourceId);
audit.setPurposeCode(purposeCode);
audit.setServiceAssignmentId(serviceAssignmentId);
audit.setActorRoleSnapshot(currentActorRoleSnapshot());
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
auditLogMapper.insert(audit);
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception);
}
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 {
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception);
}
throw new BusinessException(ApiResponseCode.NOT_FOUND, message);
}
/**
* V057 / 运行时实测修复:C 端用户主体的数据范围拒绝。
* 与 {@link #deny} 同「先审计后拒绝」语义,但审计主体写 subject_user_id
* (operator_id 置 null)——userId 写 operator_id 维度必撞
* audit_logs_operator_id_fkey(→operators),H2 无 FK 假绿、生产零审计。
*/
public void denyUser(String userId, String resourceType, String resourceId, String message) {
AuditLog audit = new AuditLog();
audit.setId(UUID.randomUUID().toString());
audit.setSubjectUserId(userId);
audit.setAction("DATA_SCOPE_DENIED");
audit.setResourceType(resourceType);
audit.setResourceId(resourceId);
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
// 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入用户主体数据范围拒绝审计失败: subjectUserId={}, resourceType={}, resourceId={}",
userId, resourceType, resourceId, exception);
}
throw new AccessDeniedException(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);
}
}
@@ -0,0 +1,57 @@
package com.danyang.match.platform.shared.typehandler;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
/** PostgreSQL JSONB 与 Jackson JsonNode 的参数/结果转换。 */
@MappedTypes(JsonNode.class)
@MappedJdbcTypes(JdbcType.OTHER)
public class JsonNodeTypeHandler extends BaseTypeHandler<JsonNode> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType)
throws SQLException {
String json = parameter.toString();
if ("PostgreSQL".equalsIgnoreCase(ps.getConnection().getMetaData().getDatabaseProductName())) {
ps.setObject(i, json, Types.OTHER);
} else {
ps.setString(i, json);
}
}
@Override
public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException {
return parse(rs.getString(columnName));
}
@Override
public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return parse(rs.getString(columnIndex));
}
@Override
public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return parse(cs.getString(columnIndex));
}
private JsonNode parse(String value) throws SQLException {
if (value == null || value.isBlank()) return null;
try {
return MAPPER.readTree(value);
} catch (Exception ex) {
throw new SQLException("JSONB 数据格式无效", ex);
}
}
}
@@ -0,0 +1,21 @@
package com.danyang.match.platform.user;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/** 用户对外展示编号;内部 UUID 仍是唯一主键。 */
public final class UserDisplayNumber {
private static final DateTimeFormatter DATE = DateTimeFormatter.BASIC_ISO_DATE;
private UserDisplayNumber() {}
public static String format(String gender, LocalDate registeredDate, int genderRank) {
if ((!"male".equals(gender) && !"female".equals(gender)) || registeredDate == null || genderRank < 1) {
throw new IllegalArgumentException("性别、注册日期或排名无效");
}
if (genderRank > ("male".equals(gender) ? 500 : 499)) throw new IllegalArgumentException("当日同性序号超过三位编号容量");
int sequence = genderRank * 2 - ("male".equals(gender) ? 1 : 0);
if (sequence > 999) throw new IllegalArgumentException("当日同性交序超过三位编号容量");
return "YH" + DATE.format(registeredDate) + String.format("%03d", sequence);
}
}
@@ -5,6 +5,7 @@ package com.danyang.match.platform.user.dto;
*/
public class UserDetailDTO {
private String id;
private String displayNumber;
private String displayName;
private String gender;
private Integer age;
@@ -34,6 +35,8 @@ public class UserDetailDTO {
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getDisplayNumber() { return displayNumber; }
public void setDisplayNumber(String displayNumber) { this.displayNumber = displayNumber; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getGender() { return gender; }
@@ -5,6 +5,7 @@ package com.danyang.match.platform.user.dto;
*/
public class UserListItemDTO {
private String id;
private String displayNumber;
private String displayName;
private String gender;
private Integer age;
@@ -19,6 +20,8 @@ public class UserListItemDTO {
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getDisplayNumber() { return displayNumber; }
public void setDisplayNumber(String displayNumber) { this.displayNumber = displayNumber; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getGender() { return gender; }
@@ -19,6 +19,8 @@ public class User extends BaseEntity {
@TableField("phone")
private String phone;
@TableField("display_number")
private String displayNumber;
@TableField("account_status")
private String accountStatus;
@@ -55,6 +55,7 @@ public class UserService {
if (keyword != null && !keyword.isBlank()) {
query.and(q -> q.like(User::getId, keyword)
.or().like(User::getDisplayNumber, keyword)
.or().like(User::getWechatOpenid, keyword));
}
if (accountStatus != null && !accountStatus.isBlank()) {
@@ -71,7 +72,7 @@ public class UserService {
}
LambdaQueryWrapper<User> countQuery = new LambdaQueryWrapper<User>();
if (keyword != null && !keyword.isBlank()) countQuery.and(q -> q.like(User::getId, keyword).or().like(User::getWechatOpenid, keyword));
if (keyword != null && !keyword.isBlank()) countQuery.and(q -> q.like(User::getId, keyword).or().like(User::getDisplayNumber, keyword).or().like(User::getWechatOpenid, keyword));
if (accountStatus != null && !accountStatus.isBlank()) countQuery.eq(User::getAccountStatus, accountStatus);
if (profileStatus != null && !profileStatus.isBlank()) {
List<String> userIds = userProfileMapper.selectList(
@@ -155,6 +156,7 @@ public class UserService {
private UserListItemDTO toListItemDTO(User user) {
UserListItemDTO dto = new UserListItemDTO();
dto.setId(user.getId());
dto.setDisplayNumber(user.getDisplayNumber());
dto.setAccountStatus(user.getAccountStatus());
dto.setRegisteredAt(user.getCreatedAt());
dto.setRelationshipStatus(user.getRelationshipStatus());
@@ -189,6 +191,7 @@ public class UserService {
int reportCount, boolean hasSensitiveView) {
UserDetailDTO dto = new UserDetailDTO();
dto.setId(user.getId());
dto.setDisplayNumber(user.getDisplayNumber());
dto.setAccountStatus(user.getAccountStatus());
dto.setRelationshipStatus(user.getRelationshipStatus());
dto.setRegisteredAt(user.getCreatedAt());
@@ -7,10 +7,22 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
import java.time.Instant;
@Mapper
public interface UserIdentityMapper extends BaseMapper<UserIdentity> {
@Select("SELECT pg_advisory_xact_lock(hashtext(#{gender} || ':' || ((#{registeredAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date)::text))")
void lockDisplayNumberSlot(@Param("gender") String gender, @Param("registeredAt") Instant registeredAt);
@Select("SELECT COUNT(*) FROM user_identities i JOIN users u ON u.id = i.user_id "
+ "WHERE i.gender = #{gender} AND i.verification_status = 'verified' "
+ "AND (u.created_at AT TIME ZONE 'Asia/Shanghai')::date = (#{registeredAt}::timestamptz AT TIME ZONE 'Asia/Shanghai')::date "
+ "AND (u.created_at < #{registeredAt} OR (u.created_at = #{registeredAt} AND u.id < #{userId}::uuid))")
int countEarlierSameDayGender(@Param("gender") String gender,
@Param("registeredAt") Instant registeredAt,
@Param("userId") String userId);
@Select("SELECT * FROM user_identities WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
Optional<UserIdentity> findByUserId(@Param("userId") String userId);
@@ -119,6 +119,8 @@ public class IdentityVerificationService {
identity.setVerifiedAt(java.time.Instant.now());
userIdentityMapper.insert(identity);
// V060 在实名记录写入事务中通过数据库原子计数器生成展示编号。
// 11. 更新用户表的认证状态标记
saveAuditLog(userId, null, "IDENTITY_VERIFY", "user", userId, null, null);
log.info("用户实名认证成功: userId={}, age={}, gender={}", userId, age, gender);
@@ -16,6 +16,8 @@ spring:
wechat:
app-id: ${WECHAT_APP_ID:}
app-secret: ${WECHAT_APP_SECRET:}
mock:
openid: ${WECHAT_MOCK_OPENID:}
encryption:
key: ${ENCRYPTION_KEY}
@@ -92,3 +92,13 @@ spring.security:
rate-limit:
max-requests-per-window: 20
window-seconds: 60
# 受控联系授权(ZC-MM-003 / B1 立项,canonical key 见 ContactAuthorizationConfig;治理登记 T06 config-register)
# 注意:代码缺省 enabled=false(fail-safe),生产显式开启;新环境漏配时机制整体关闭而非意外开放。
matchmaker:
contact:
enabled: true
approver-authorities: matchmaker.team.assign,matchmaker.assign
consent-deadline-hours: 72
default-validity-days: 7
retention-days: 365
@@ -2,7 +2,6 @@
-- 丹阳公益婚恋服务平台 — Demo Seed 最终版(单次事务,纯 SQL)
-- ============================================================
BEGIN;
SET session_replication_role = 'replica';
-- 1. 组织
INSERT INTO organizations (id, name, type, contact_name, status) VALUES
@@ -11,27 +10,27 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
(gen_random_uuid(), '丹阳市慈善基金会', 'charity_center', '王秀英', 'active');
-- 2. 工作人员(V1.0原有 + V1.1新增)
INSERT INTO operators (id, organization_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, '陈主任', 'admin', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
INSERT INTO operators (id, organization_id, display_id, name, username, password_hash, status)
SELECT gen_random_uuid(), id, 'HN20261019001', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '红娘小王', 'wanghongniang', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019002', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '审核员小张', 'reviewer01', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019003', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center'
UNION ALL
SELECT gen_random_uuid(), id, '活动管理员赵', 'activitymgr','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019004', '活动管理员赵', 'activitymgr','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='trade_union'
-- V1.1 新增演示操作员
UNION ALL
SELECT gen_random_uuid(), id, '服务站小李', 'stationop', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019005', '服务站小李', 'stationop', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation'
UNION ALL
SELECT gen_random_uuid(), id, '咨询师小周', 'counselor', '$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019006', '咨询师小周', 'counselor', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='charity_center'
UNION ALL
SELECT gen_random_uuid(), id, '内容编辑小吴', 'contenteditor','$2b$10$2OtF1WNZuVJ7bAjIUuO1p.NzRgquJhco/11W2640cvXfkp8xWNATK', 'active'
SELECT gen_random_uuid(), id, 'HN20261019007', '内容编辑小吴', 'contenteditor','$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'
FROM organizations WHERE type='women_federation';
-- 操作员角色绑定(V1.0原有 + V1.1新增)
@@ -211,10 +210,11 @@ SELECT gen_random_uuid(), id, 'photos/demo/'||alias||'/lifestyle1.jpg', 'lifesty
-- ============================================================
-- 4. 审核任务
-- ============================================================
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version)
INSERT INTO review_tasks (id, user_id, review_type, status, assigned_operator_id, submitted_at, started_at, completed_at, final_comment, version, display_id)
SELECT gen_random_uuid(), ud.id, 'initial_profile', 'approved',
(SELECT id FROM operators WHERE username='reviewer01'),
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1
NOW()-INTERVAL '60 days', NOW()-INTERVAL '59 days', NOW()-INTERVAL '58 days', '资料完整', 1,
'RW'||LPAD(ROW_NUMBER() OVER (ORDER BY ud.alias)::text, 11, '0')
FROM user_data ud WHERE ud.alias IN ('f01','f02','f03','m01','m02','m03','m05');
-- ============================================================
@@ -331,17 +331,17 @@ VALUES
WITH women_org AS (SELECT id FROM organizations WHERE type='women_federation' LIMIT 1),
trade_org AS (SELECT id FROM organizations WHERE type='trade_union' LIMIT 1),
mgr AS (SELECT id FROM operators WHERE username='activitymgr' LIMIT 1)
INSERT INTO activities (id, title, description, organizer_org_id, coorganizer_text,
INSERT INTO activities (id, title, display_id, description, organizer_org_id, coorganizer_text,
start_at, end_at, location_name, location_address,
male_limit, female_limit, total_limit, min_age, max_age,
registration_start_at, registration_end_at, need_review, status, created_by)
VALUES
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)',
(gen_random_uuid(), '丹阳公益单身联谊会(夏季场)', 'HD20261019001',
'面向丹阳市区及周边乡镇的单身青年,由妇联、工会联合主办。',
(SELECT id FROM women_org), '丹阳市总工会、丹阳市慈善基金会',
'2026-08-15T14:00:00Z', '2026-08-15T17:30:00Z', '丹阳市文化馆多功能厅', '丹阳市丹北路168号',
15, 15, 30, 22, 40, '2026-07-01T00:00:00Z', '2026-08-10T23:59:59Z', true, 'registration_open', (SELECT id FROM mgr)),
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你',
(gen_random_uuid(), '七夕特别活动·丹阳缘来是你', 'HD20261019002',
'七夕节特别策划,浪漫主题相亲活动。',
(SELECT id FROM trade_org), '丹阳市新时代文明实践中心',
'2026-08-29T15:00:00Z', '2026-08-29T18:00:00Z', '丹阳大剧院报告厅', '丹阳市书院路28号',
@@ -442,7 +442,6 @@ VALUES
('manual_review_needed', '需人工复核', 'quality', true, 7)
ON CONFLICT (code) DO NOTHING;
SET session_replication_role = 'origin';
DROP TABLE IF EXISTS _conv_result;
-- ============================================================
@@ -0,0 +1,487 @@
-- ============================================================
-- 丹阳公益婚恋服务平台 — V1.1 增量演示数据(单事务,纯 SQL)
-- ============================================================
-- 目标:为 V1.1 内容/服务域补齐演示数据,使 H5 演示端以下页面不再空白:
-- home_banners(首页轮播)、content_categories / content_articles(婚恋课堂+反诈)、
-- service_locations(善缘服务站)、service_programs(专项公益服务项目)、
-- counseling_services(婚恋咨询 + 婚前辅导)、user_local_eligibilities(本地资格),
-- 以及首页「今日推荐」所需的双向 Required 兼容候选对象(见第 10 节)。
--
-- 约束(与任务一致):
-- 1. 不改任何 Flyway 迁移 / Java / 前端源码;本文件位于 db/ 而非 db/migration/。
-- 2. 可重复执行(幂等):固定 UUID 字面量 + ON CONFLICT (id) DO NOTHING,
-- 唯一约束冲突处用 WHERE NOT EXISTS 守卫。
-- 3. 不使用 SET session_replication_role;所有 NOT NULL 列显式赋值。
-- 4. 外键用子查询解析既有演示用户 / 操作员 / 组织。
-- ============================================================
BEGIN;
-- ============================================================
-- 1. 首页轮播 home_banners(追加 3 条;现有 3 条保持不动)
-- target_type 仅限内部跳转,禁止外部 URL(AGENTS 第 16 条)
-- created_by 引用既有操作员 contenteditor
-- ============================================================
INSERT INTO home_banners
(id, title, subtitle, button_text, target_type, target_value,
audience_scope, sort_order, start_at, end_at, status, created_by)
VALUES
('a1111111-1111-1111-1111-111111110001'::uuid,
'婚前辅导公益开放报名', '专业婚前辅导,帮助你们更从容地走向未来', '了解婚前辅导',
'premarital', NULL, 'all', 4, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110002'::uuid,
'婚恋反诈 · 守护你的真心', '识别常见情感骗局,远离财产与隐私陷阱', '查看反诈指南',
'article', 'a3333333-3333-3333-3333-333333330004', 'all', 5,
NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor')),
('a1111111-1111-1111-1111-111111110003'::uuid,
'婚恋咨询可以预约啦', '一对一的公益婚恋咨询服务,为你答疑解惑', '预约咨询',
'counseling', NULL, 'all', 6, NOW() - INTERVAL '10 days', NOW() + INTERVAL '180 days', 'active',
(SELECT id FROM operators WHERE username = 'contenteditor'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 2. 婚恋课堂分类 content_categories(4 个)
-- 生产已由 V027 播过同名 code(marriage_class / anti_fraud / premarital_edu / custom_culture),
-- 此处在缺失时补种;用 WHERE NOT EXISTS 守卫保证幂等,不覆盖既有行。
-- ============================================================
INSERT INTO content_categories (id, name, code, description, sort_order, status)
SELECT v.id, v.name, v.code, v.description, v.sort_order, 'active'
FROM (VALUES
('a2222222-2222-2222-2222-222222220001'::uuid, '婚恋课堂', 'marriage_class',
'恋爱沟通、关系经营与择偶知识', 1),
('a2222222-2222-2222-2222-222222220002'::uuid, '婚恋反诈', 'anti_fraud',
'婚恋诈骗识别与防范知识', 2),
('a2222222-2222-2222-2222-222222220003'::uuid, '婚前辅导', 'premarital_edu',
'婚前准备与关系建立指导', 3),
('a2222222-2222-2222-2222-222222220004'::uuid, '婚俗新风', 'custom_culture',
'传统婚俗与文明新风尚', 4)
) AS v(id, name, code, description, sort_order)
WHERE NOT EXISTS (SELECT 1 FROM content_categories c WHERE c.code = v.code);
-- ============================================================
-- 3. 婚恋课堂文章 content_articles(7 篇,含 2 篇反诈主题)
-- status = published 且 publish_at <= NOW()(公开查询 findPublishedAll 的条件)
-- category_id 按 code 子查询解析;author_operator_id 引用 contenteditor
-- ============================================================
INSERT INTO content_articles
(id, category_id, title, summary, content, author_operator_id, status, publish_at, view_count, sort_order)
VALUES
('a3333333-3333-3333-3333-333333330001'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'初次见面聊什么?五个不冷场的话题',
'第一次见面不必紧张,从兴趣、家乡、工作日常聊起,自然又真诚。',
'很多人在第一次见面时最怕冷场。其实不必刻意寻找“金句”,从彼此的兴趣、家乡、工作日常聊起,就能自然展开。'
|| E'\n\n' || '一、聊聊兴趣爱好:最近在看的书、在练的运动、在追的剧,都能引出一段轻松对话。'
|| E'\n' || '二、聊聊家乡与成长:丹阳本地人可以从熟悉的地方小吃、老街区聊起。'
|| E'\n' || '三、聊聊工作日常:关注感受而非收入数字,避免变成互相盘问。'
|| E'\n' || '四、聊聊对未来的期待:让对方看到你的真诚与计划。'
|| E'\n' || '五、聊聊最近的开心事:好情绪能迅速拉近距离。'
|| E'\n\n' || '记住:一次见面能愉快结束,就已经是一次成功的相识。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-01T10:00:00Z', 186, 1),
('a3333333-3333-3333-3333-333333330002'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'如何判断两个人是否合适',
'合适的两个人,不是条件清单全打勾,而是价值观相似、沟通舒服、节奏一致。',
'判断两个人是否合适,可以从三个维度观察。'
|| E'\n\n' || '一、价值观是否相似:对家庭、金钱、事业、生活方式的根本看法越接近,长期相处越省力。'
|| E'\n' || '二、沟通是否舒服:有分歧时能不能把话说完、愿不愿意换位理解,比从不吵架更重要。'
|| E'\n' || '三、节奏是否一致:对关系推进速度、见面频率的期待相差太远,容易积累失望。'
|| E'\n\n' || '合适的感情会让人感到安心,而不是长期紧绷。如果相处总让你自我怀疑,就要认真审视了。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-08-12T10:00:00Z', 154, 2),
('a3333333-3333-3333-3333-333333330003'::uuid,
(SELECT id FROM content_categories WHERE code = 'marriage_class'),
'从喜欢到结婚:健康关系的发展节奏',
'好的感情有其自然节奏:相识、了解、确定关系、见家长、谈婚论嫁,每一步都值得认真对待。',
'从喜欢到结婚,往往要经历几个自然阶段。'
|| E'\n\n' || '相识期:通过平台、活动或朋友认识,先建立基本的信任。'
|| E'\n' || '了解期:在多次见面和沟通中,观察对方真实的生活状态与处事方式。'
|| E'\n' || '确定期:双方对关系的定位达成一致,明确彼此是唯一的选择。'
|| E'\n' || '成长期:一起面对家庭、工作与现实问题,检验是否适合长期共同生活。'
|| E'\n' || '决策期:讨论婚礼、住房、育儿等议题,走向婚姻。'
|| E'\n\n' || '不要因为年龄焦虑而跳过了解期,也不要因为害怕而迟迟不肯确认。健康的节奏,是双方都舒服又都在向前走。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-05T10:00:00Z', 97, 3),
('a3333333-3333-3333-3333-333333330004'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'婚恋反诈指南:识别常见情感骗局',
'刚认识就谈投资、频繁借钱、索取隐私资料,都是危险信号。保护好自己的真心和钱包。',
'婚恋交友中,以下情况需要高度警惕。'
|| E'\n\n' || '一、短期内的经济诉求:以投资理财、博彩平台、工程项目为由,拉你一起“赚钱”。'
|| E'\n' || '二、反复借款:以生病、周转、被冻结为由不断借钱,甚至教你如何贷款。'
|| E'\n' || '三、索取隐私资料:催你脱离平台联系,索要银行卡、验证码、身份证信息。'
|| E'\n' || '四、冒充平台人员:谎称缴纳认证金、保证金、解冻费。'
|| E'\n\n' || '一旦遇到上述情形:立即停止资金操作,保存聊天与转账证据,及时通过平台举报,紧急情况拨打 110。'
|| E'\n\n' || '请记住:正规公益平台不会以任何名义向你收取费用,实名认证和人工审核也不会要求你转账。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-10T10:00:00Z', 260, 1),
('a3333333-3333-3333-3333-333333330005'::uuid,
(SELECT id FROM content_categories WHERE code = 'anti_fraud'),
'「杀猪盘」套路全解析',
'温柔人设、投资话术、逐步加码——「杀猪盘」的每一步都有迹可循。',
'「杀猪盘」是婚恋诈骗中常见的一类,套路通常分四步。'
|| E'\n\n' || '第一步:建立人设。对方往往形象、职业、谈吐都很完美,且对你的生活格外“上心”。'
|| E'\n' || '第二步:迅速升温。短时间内表达强烈的爱与依赖,推动关系快速确定。'
|| E'\n' || '第三步:引入投资。以“内部消息”“稳赚不赔”为诱饵,让你注册陌生平台并小额获利尝甜头。'
|| E'\n' || '第四步:加大投入后收割。诱导你投入更多资金,随后平台无法提现、对方失联。'
|| E'\n\n' || '防范要点:爱情里没有人会急着带你“赚钱”;凡是引导转账、下载陌生 App 的,立即停止并举报。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-20T10:00:00Z', 213, 2),
('a3333333-3333-3333-3333-333333330006'::uuid,
(SELECT id FROM content_categories WHERE code = 'premarital_edu'),
'婚前一定要聊清楚的十个问题',
'在走进婚姻之前,把金钱、家务、父母、育儿等关键问题谈清楚,是对彼此负责。',
'走进婚姻前,建议和对方认真聊清以下问题。'
|| E'\n\n' || '一、金钱观:收入如何管理?大额支出怎么商量?'
|| E'\n' || '二、婚后居住:和父母同住还是独立居住?'
|| E'\n' || '三、家务分工:如何共同承担家庭事务?'
|| E'\n' || '四、生育计划:要不要孩子、何时要、如何教育?'
|| E'\n' || '五、双方父母:如何平衡对各自家庭的照顾?'
|| E'\n' || '六、职业规划:是否支持对方的职业发展?'
|| E'\n' || '七、冲突处理:吵架时如何避免伤害对方?'
|| E'\n' || '八、双方底线:哪些事情无法接受?'
|| E'\n' || '九、家庭应急:突发状况如何共同应对?'
|| E'\n' || '十、对婚姻的期待:各自理想中的婚后生活是什么样的?'
|| E'\n\n' || '这些问题没有标准答案,但提前沟通能减少未来的误解与争执。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-15T10:00:00Z', 88, 1),
('a3333333-3333-3333-3333-333333330007'::uuid,
(SELECT id FROM content_categories WHERE code = 'custom_culture'),
'婚事新办,文明婚俗新风尚',
'低彩礼、简仪式、重情义,让婚礼回归幸福本身。',
'近年来,婚事新办、喜事简办逐渐成为新风尚。'
|| E'\n\n' || '抵制高价彩礼:彩礼不应成为家庭的沉重负担,更不该成为衡量感情的标尺。'
|| E'\n' || '简化仪式流程:把精力放在真诚的相处上,而不是铺张的排场。'
|| E'\n' || '注重情义内涵:一场由亲友见证、温馨朴素的婚礼,同样值得珍藏。'
|| E'\n\n' || '善缘丹阳倡导文明健康的婚恋观,愿每一对新人都能轻装上阵,从容开启新生活。',
(SELECT id FROM operators WHERE username = 'contenteditor'), 'published', '2026-09-18T10:00:00Z', 64, 1)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 4. 善缘服务站 service_locations(追加 2 条;现有 1 条保持不动)
-- ============================================================
INSERT INTO service_locations
(id, name, short_name, address, latitude, longitude,
contact_phone_display, opening_hours_text, service_description,
primary_organization_id, status)
VALUES
('a4444444-4444-4444-4444-444444440001'::uuid,
'丹阳市开发区善缘服务站', '开发区站', '丹阳市开发区凤凰路18号',
31.9210000, 119.6090000, '0511-86666888',
'周一至周五 9:00-17:00,周六 9:00-12:00(法定节假日除外)',
'提供公益红娘服务、婚恋咨询预约、婚前辅导与本地资格核验',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1), 'active'),
('a4444444-4444-4444-4444-444444440002'::uuid,
'丹阳市延陵镇善缘服务站', '延陵镇站', '丹阳市延陵镇人民路36号',
31.8600000, 119.4900000, '0511-86688866',
'周一至周五 9:00-16:30(法定节假日除外)',
'面向延陵镇及周边乡镇提供公益婚恋登记咨询、活动报名与反诈宣传',
(SELECT id FROM organizations WHERE type = 'trade_union' LIMIT 1), 'active')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 5. 专项公益服务项目 service_programs(3 条:2 个已激活 + 1 个草稿)
-- 用户端公开列表只展示 status = active
-- ============================================================
INSERT INTO service_programs
(id, name, description, organizer_org_id, start_at, end_at, status, created_by)
VALUES
('a5555555-5555-5555-5555-555555550001'::uuid,
'公益红娘一对一服务计划',
'由公益红娘为长期未遇合适对象的单身人士提供一对一牵线与情感陪跑服务。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() - INTERVAL '30 days', NOW() + INTERVAL '150 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550002'::uuid,
'婚恋反诈宣传进社区',
'联合社区开展婚恋反诈宣讲,帮助居民识别情感诈骗与网络交友陷阱。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
NOW() - INTERVAL '15 days', NOW() + INTERVAL '120 days', 'active',
(SELECT id FROM operators WHERE username = 'admin')),
('a5555555-5555-5555-5555-555555550003'::uuid,
'婚前辅导公益课堂(筹备中)',
'面向即将步入婚姻的情侣,开展婚前沟通、家庭经营等公益课程。',
(SELECT id FROM organizations WHERE type = 'women_federation' LIMIT 1),
NOW() + INTERVAL '20 days', NOW() + INTERVAL '200 days', 'draft',
(SELECT id FROM operators WHERE username = 'admin'))
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 6. 婚恋咨询 counseling_services(2 条)
-- service_type / status 取值对齐生产 CHECK 约束
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
preferred_service_mode, scheduled_start_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
'relationship_guidance', 'user_self',
'离异后重新开始,希望了解如何建立健康的新关系。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed', 'offline', NOW() - INTERVAL '20 days', NOW() - INTERVAL '19 days'),
('a6666666-6666-6666-6666-666666660002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m02'),
'emotional_support', 'user_self',
'工作压力较大,希望有人倾听并给出情绪调节建议。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'assigned', 'online', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 7. 婚前辅导 counseling_services(service_type = premarital_guidance,2 条)
-- 婚前辅导复用 counseling_services 表,无独立表
-- ============================================================
INSERT INTO counseling_services
(id, user_id, service_type, request_source, user_request_summary,
organization_id, assigned_operator_id, status,
companion_user_id, scheduled_start_at, user_confirmed_at, completed_at)
VALUES
('a6666666-6666-6666-6666-666666660003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
'premarital_guidance', 'user_self',
'计划明年结婚,希望接受婚前辅导,了解婚后相处与家庭经营。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'completed',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
NOW() - INTERVAL '12 days', NOW() - INTERVAL '12 days', NOW() - INTERVAL '11 days'),
('a6666666-6666-6666-6666-666666660004'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m05'),
'premarital_guidance', 'user_self',
'希望和未婚妻一起参加婚前辅导课堂,为婚后生活做准备。',
(SELECT id FROM organizations WHERE type = 'charity_center' LIMIT 1),
(SELECT id FROM operators WHERE username = 'counselor'),
'scheduled',
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f05'),
NOW() + INTERVAL '5 days', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 8. 本地资格 user_local_eligibilities(3 条)
-- eligibility_type / verification_status 对齐生产 CHECK 约束
-- 生产有 UNIQUE(user_id),用固定 id + ON CONFLICT (id) DO NOTHING 保证幂等
-- ============================================================
INSERT INTO user_local_eligibilities
(id, user_id, eligibility_type, declared_region_code, verification_status, verified_at, verified_by)
VALUES
('a7777777-7777-7777-7777-777777770001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f01'),
'household_registration', '321181', 'verified',
NOW() - INTERVAL '40 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01'),
'employment', '321181', 'verified',
NOW() - INTERVAL '35 days', (SELECT id FROM operators WHERE username = 'reviewer01')),
('a7777777-7777-7777-7777-777777770003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03'),
'residence', '321100', 'pending_review', NULL, NULL)
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 9. 本地资格佐证 local_eligibility_evidences(2 条)
-- ============================================================
INSERT INTO local_eligibility_evidences
(id, local_eligibility_id, file_key, evidence_type, status)
VALUES
('a8888888-8888-8888-8888-888888880001'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_f03')),
'eligibility/demo/f03/residence_proof.jpg', 'residence_proof', 'reviewing'),
('a8888888-8888-8888-8888-888888880002'::uuid,
(SELECT id FROM user_local_eligibilities
WHERE user_id = (SELECT id FROM users WHERE wechat_openid = 'demo_openid_m01')),
'eligibility/demo/m01/employment_proof.jpg', 'employment_proof', 'approved')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 10. 首页「今日推荐」候选对象(新增 3 位男性演示用户 m07/m08/m09)
-- ------------------------------------------------------------
-- 根因:演示登录账号「晓燕」(demo_openid_f02) 为离异,其 required 婚姻状况为
-- ["never_married"](要求对方未婚);而既有演示男性 m01~m06 的 required 婚姻
-- 状况同样是 ["never_married"],对「晓燕」的 divorced 不满足 → 双向 Required
-- 互斥,RecommendationEngineService 对 f02 恒返回空,「今日推荐」恒空白。
--
-- 这 3 位候选的设计(同时满足 f02 的 required 与自身的 required 反向):
-- * 本人资料:未婚 (never_married)、年龄落在 f02 的 28-40、地区落在
-- ["321181","321100"]、身高落在 f02 偏好的 155-172、学历 >= college;
-- * 自身择偶条件:marital_statuses = ["never_married","divorced"](接受离异),
-- age 范围覆盖 f02 的 33 岁、region 覆盖 321181,故反向 Required 亦满足。
-- * 账号满足 RecommendationEngineService.isParticipantEligible:
-- account_status=normal / relationship_status=searching / recommendation_enabled=true /
-- visible_in_recommendations=true(本地资格未开启强制,无需核验)。
-- ============================================================
INSERT INTO users (id, wechat_openid, account_status, relationship_status, recommendation_enabled, created_at)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 'demo_openid_m07', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000008'::uuid, 'demo_openid_m08', 'normal', 'searching', true, NOW() - INTERVAL '20 days'),
('a9900000-0000-0000-0000-000000000009'::uuid, 'demo_openid_m09', 'normal', 'searching', true, NOW() - INTERVAL '20 days')
ON CONFLICT DO NOTHING;
INSERT INTO user_profiles (user_id, display_name, gender, birth_date, height_cm,
region_code, region_name, marital_status, children_status,
education_code, occupation_code, smoking_status, drinking_status,
long_term_danyang, future_danyang, relationship_goal, profile_completion, profile_status)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, '陈志远', 'male', '1991-03-18', 172,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'engineer', 'never', 'sometimes',
'是', '是', '结婚', 93, 'approved'),
('a9900000-0000-0000-0000-000000000008'::uuid, '吴凯', 'male', '1994-07-09', 170,
'321181', '丹阳', 'never_married', 'none', 'bachelor', 'teacher', 'never', 'sometimes',
'是', '是', '结婚', 91, 'approved'),
('a9900000-0000-0000-0000-000000000009'::uuid, '郑海', 'male', '1989-11-22', 168,
'321100', '镇江', 'never_married', 'none', 'master', 'architect', 'never', 'sometimes',
'是', '是', '结婚', 94, 'approved')
ON CONFLICT (user_id) DO NOTHING;
-- 择偶条件:marital 接受 never_married + divorced(关键差异点),其余维度与 f02 互容
INSERT INTO user_preferences
(user_id, min_age, max_age, age_priority, min_height_cm, max_height_cm, height_priority,
region_scope, region_priority, marital_statuses, marital_priority, min_education_code, education_priority)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000008'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred'),
('a9900000-0000-0000-0000-000000000009'::uuid, 28, 42, 'required', 155, 180, 'preferred',
'["321181","321100"]'::jsonb, 'required', '["never_married","divorced"]'::jsonb, 'required', NULL, 'preferred')
ON CONFLICT (user_id) DO NOTHING;
-- 隐私:必须允许出现在推荐里,否则 isParticipantEligible 直接排除
INSERT INTO user_privacy_settings
(user_id, visible_in_recommendations, visible_in_search, show_occupation,
show_lifestyle_photos, receive_activity_recommendations, accept_matchmaker_recommendations)
VALUES
('a9900000-0000-0000-0000-000000000007'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000008'::uuid, true, true, true, true, true, true),
('a9900000-0000-0000-0000-000000000009'::uuid, true, true, true, true, true, true)
ON CONFLICT (user_id) DO NOTHING;
-- 实名认证(mock,与既有演示用户一致,用于演示端「已实名」标识)
INSERT INTO user_identities
(id, user_id, real_name_enc, id_number_enc, id_number_hash, gender, birth_date, verification_status, provider, verified_at)
VALUES
('a9922222-2222-2222-2222-222222220007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid,
encode('demo_enc_m07'::bytea, 'escape'), encode('demo_id_m07'::bytea, 'escape'), 'demo_sha256_m07',
'male', '1991-03-18', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid,
encode('demo_enc_m08'::bytea, 'escape'), encode('demo_id_m08'::bytea, 'escape'), 'demo_sha256_m08',
'male', '1994-07-09', 'verified', 'mock', NOW() - INTERVAL '1 day'),
('a9922222-2222-2222-2222-222222220009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid,
encode('demo_enc_m09'::bytea, 'escape'), encode('demo_id_m09'::bytea, 'escape'), 'demo_sha256_m09',
'male', '1989-11-22', 'verified', 'mock', NOW() - INTERVAL '1 day')
ON CONFLICT DO NOTHING;
-- 照片(头像 + 生活照,与既有演示用户一致)
INSERT INTO user_photos (id, user_id, file_key, photo_type, sort_order, review_status)
VALUES
('a9933333-3333-3333-3333-333333330007'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330017'::uuid, 'a9900000-0000-0000-0000-000000000007'::uuid, 'photos/demo/m07/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330008'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330018'::uuid, 'a9900000-0000-0000-0000-000000000008'::uuid, 'photos/demo/m08/lifestyle1.jpg', 'lifestyle', 1, 'approved'),
('a9933333-3333-3333-3333-333333330009'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/avatar.jpg', 'avatar', 0, 'approved'),
('a9933333-3333-3333-3333-333333330019'::uuid, 'a9900000-0000-0000-0000-000000000009'::uuid, 'photos/demo/m09/lifestyle1.jpg', 'lifestyle', 1, 'approved')
ON CONFLICT DO NOTHING;
-- ============================================================
-- 11. 红娘工作台「我的服务对象分配」(matchmaker_assignments)
-- ------------------------------------------------------------
-- 根因:V053 的存量回填发生在演示 seed 之前,故演示服务单的分配记录在
-- matchmaker_assignments 中为空 → GET /operator/matchmaker/assignments/my
-- 返回 [],红娘端工作台既无服务对象,也无法发起「联系授权申请 / 转介」。
-- 这里为既有演示服务单补建分配记录,与 matchmaker_services.assigned_operator_id
-- 保持一致(均指向演示红娘 wanghongniang)。
-- ============================================================
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)
VALUES
-- 晓燕(f02) → 红娘小王,服务单 FW20261018001(in_service),生效中
('a9944444-4444-4444-4444-444444440001'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f02'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018001'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'),
-- 丧偶用户(f06) → 红娘小王,服务单 FW20261018002(waiting_user),生效中
('a9944444-4444-4444-4444-444444440002'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f06'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018002'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:红娘小王主责跟进', 'active',
NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days', NOW() - INTERVAL '25 days'),
-- 已完结服务(f04) → 分配已结束(终态,便于演示历史分配)
('a9944444-4444-4444-4444-444444440003'::uuid,
(SELECT id FROM users WHERE wechat_openid = 'demo_openid_f04'),
(SELECT id FROM operators WHERE username = 'wanghongniang'),
(SELECT id FROM matchmaker_services WHERE display_id = 'FW20261018003'),
(SELECT id FROM operators WHERE username = 'admin'),
'primary', '演示:服务已完结', 'ended',
NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days')
ON CONFLICT (id) DO NOTHING;
-- ============================================================
-- 12. 服务站运营人员数据范围(service_location_operators)
-- ------------------------------------------------------------
-- 根因:该表为空 → 管理端「服务预约」(GET /service-appointments/operator/list)
-- 对服务站运营人员 stationop 恒返回空,红娘转介产生的预约(source =
-- matchmaker_referral)在管理端不可见,红娘端↔管理端的转介联动无法演示。
-- 为演示服务站运营人员 stationop 显式授予 3 个演示服务站的运营范围
-- (组织关系不自动授予数据权限,必须显式站点分配,见 AGENTS 第 24 条 / INV-018)。
-- ============================================================
INSERT INTO service_location_operators (service_location_id, operator_id, created_by, created_at)
VALUES
((SELECT id FROM service_locations WHERE name = '万善公园婚姻登记处善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市开发区善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW()),
((SELECT id FROM service_locations WHERE name = '丹阳市延陵镇善缘服务站'),
(SELECT id FROM operators WHERE username = 'stationop'),
(SELECT id FROM operators WHERE username = 'admin'), NOW())
ON CONFLICT (service_location_id, operator_id) DO NOTHING;
-- ============================================================
-- 验证
-- ============================================================
DO $$
DECLARE
b_cnt INTEGER; cc_cnt INTEGER; ca_cnt INTEGER; sl_cnt INTEGER;
sp_cnt INTEGER; cs_cnt INTEGER; pm_cnt INTEGER; le_cnt INTEGER; ev_cnt INTEGER;
rec_cnt INTEGER; rec_pref INTEGER; asg_cnt INTEGER; slo_cnt INTEGER;
BEGIN
SELECT COUNT(*) INTO b_cnt FROM home_banners WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cc_cnt FROM content_categories WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ca_cnt FROM content_articles WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sl_cnt FROM service_locations WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO sp_cnt FROM service_programs WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO cs_cnt FROM counseling_services WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO pm_cnt FROM counseling_services
WHERE service_type = 'premarital_guidance' AND deleted_at IS NULL;
SELECT COUNT(*) INTO le_cnt FROM user_local_eligibilities WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO ev_cnt FROM local_eligibility_evidences WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO rec_cnt FROM users
WHERE wechat_openid IN ('demo_openid_m07','demo_openid_m08','demo_openid_m09')
AND deleted_at IS NULL;
SELECT COUNT(*) INTO rec_pref FROM user_preferences p
WHERE p.user_id IN ('a9900000-0000-0000-0000-000000000007'::uuid,
'a9900000-0000-0000-0000-000000000008'::uuid,
'a9900000-0000-0000-0000-000000000009'::uuid);
SELECT COUNT(*) INTO asg_cnt FROM matchmaker_assignments WHERE deleted_at IS NULL;
SELECT COUNT(*) INTO slo_cnt FROM service_location_operators;
RAISE NOTICE '========================================';
RAISE NOTICE 'V1.1 增量演示数据填充完成!';
RAISE NOTICE '========================================';
RAISE NOTICE ' Banner: % 条 | 内容分类: % 个 | 文章: % 篇', b_cnt, cc_cnt, ca_cnt;
RAISE NOTICE ' 服务站: % 个 | 专项服务项目: % 个', sl_cnt, sp_cnt;
RAISE NOTICE ' 咨询服务: % 条(其中婚前辅导 % 条)', cs_cnt, pm_cnt;
RAISE NOTICE ' 本地资格: % 条 | 资格佐证: % 条', le_cnt, ev_cnt;
RAISE NOTICE ' 今日推荐候选(m07/m08/m09): % 位 | 择偶条件: % 条', rec_cnt, rec_pref;
RAISE NOTICE ' 红娘服务对象分配: % 条', asg_cnt;
RAISE NOTICE ' 服务站运营人员范围(slo): % 条', slo_cnt;
RAISE NOTICE '========================================';
END $$;
COMMIT;
@@ -14,13 +14,13 @@ INSERT INTO organizations (id, name, type, contact_name, status) VALUES
INSERT INTO operators (id, organization_id, name, username, password_hash, status) VALUES
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('f54eedf5-a281-4289-a63d-57de1ea83fbc', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '陈主任', 'admin', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('a98c0d97-d272-4577-887e-5c05e27db19b', 'c74863d4-be10-4efa-b22c-f3f84e2e4e59', '红娘小王', 'wanghongniang', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active'),
('e9312b05-c263-4901-8145-0b38e4d81c45', 'bcd9e5b6-3b66-454c-96d7-3fade6866135', '审核员小张', 'reviewer01', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active'),
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHdBfKYuPIiCTB6MJJDEaK', 'active');
('b4cdeb91-bbce-4587-be58-22ff55ef89da', 'a35f0043-be09-4bdf-ad5d-05a72ff19c2a', '活动管理员赵', 'activitymgr', '$2a$10$rKkmfph0xE1zQgqbBteLhO34H64Qt7ZxNDevd/P00inoKhA6mPvTG', 'active');
INSERT INTO operator_roles (operator_id, role_id) VALUES ('f54eedf5-a281-4289-a63d-57de1ea83fbc', '00000000-0000-0000-0000-000000000001');
@@ -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;

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