Author SHA1 Message Date
WorkBuddy 377429995c release: freeze V1.2 demo acceptance 2026-10-25
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Waiting to run
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 / UI Contracts (source-structure gates) (push) Waiting to run
CI/CD Pipeline / E2E (live stack) (push) Blocked by required conditions
2026-10-25 14:29:29 +08:00
WorkBuddy f6744d3f91 fix: restore matchmaker workbench permissions 2026-10-25 09:30:13 +08:00
WorkBuddy 692d71696a test: record v1.2 permission scope acceptance gaps 2026-10-25 09:20:30 +08:00
WorkBuddy ec9252bd3d feat: align v12 operator role permissions 2026-10-25 09:15:12 +08:00
WorkBuddy dd291868fb fix: define dashboard metric status 2026-10-25 09:07:09 +08:00
WorkBuddy b38056f1d0 fix admin dashboard permission scope display 2026-10-25 09:06:15 +08:00
WorkBuddy 34b9ea1169 docs: record dashboard detail scope gap 2026-10-25 08:56:36 +08:00
WorkBuddy 88f4176e60 fix: 收口红娘工作台权限与组织范围 2026-10-25 08:50:18 +08:00
WorkBuddy a3bd1e8a95 docs: freeze v1.2 permission baseline 2026-10-25 08:36:39 +08:00
WorkBuddy d51c4c6a76 chore: ignore local temporary workspaces
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
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) 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
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
WorkBuddy ca0db57f24 chore(release): 冻结 v1.1.0 版本
- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0
- 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md):
  - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast)
  - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto)
  - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天)
  - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入
  - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置
- 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证(均实际执行)
- node scripts/miniapp-compile-check.mjs → WXSS 88 个 + WXML 86 个,0 失败
- 负向注入:把 `.tap-gap > * + *` 改回去 → 预检 FAIL(exit 1),报错与用户原始报错逐字一致
  `app.wxss(364:12): error at token '*'`;已还原并复测通过
- node scripts/run-ui-contracts.mjs → 20 / 20 PASS
2026-09-23 08:25:11 +08:00
648 changed files with 49730 additions and 4352 deletions
+37
View File
@@ -0,0 +1,37 @@
# CODEOWNERS — 善缘丹阳公益婚恋平台 审查角色分配(草案)
#
# 作用:开 PR 时 GitHub 自动向对应路径的 owner 请求审查;配合分支保护与
# docs/governance/CODE_REVIEW_STANDARDS.md 的 T0/T1/T2 分级与双审规则使用。
# 语法:https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
#
# 当前为一人团队,默认全部归技术负责人 @g1307092382。
# 待扩员时,把各端拆给对应熟悉者即可;T2 高危改动按审查标准须 2 人复核(其中 1 人为该端 owner)。
# 说明:CODEOWNERS 只负责「请求谁审」,不强制「必须不同人」;双审由审查流程纪律保证。
# 排序:子路径必须放在父路径之后(GitHub 取最后一个匹配的规则)。
# —— 默认兜底 ——
* @g1307092382
# —— 后端(Spring Boot / Java)——
/backend/ @g1307092382
# 数据库迁移(Flyway):schema 变更属 T2,须重点复核(放在 /backend/ 之后)
/backend/src/main/resources/db/migration/ @g1307092382
# —— 微信小程序(原生 WXML/WXSS/TS)——
/miniapp/ @g1307092382
# —— 管理端(Vue3 / TS / pnpm)——
/admin-web/ @g1307092382
# —— 测试与契约守卫(跨端契约,需熟悉各端)——
/tests/ @g1307092382
/ui-tests/ @g1307092382
/scripts/run-ui-contracts.mjs @g1307092382
# —— 基础设施 / 部署(Compose / 镜像 / 密钥)——
/infrastructure/ @g1307092382
# —— 治理与审查机制文档(变更须经技术负责人确认)——
/docs/governance/ @g1307092382
/.github/ @g1307092382
+69
View File
@@ -0,0 +1,69 @@
<!--
提 PR 前请先读 docs/governance/CODE_REVIEW_STANDARDS.md。
本模板是合并准入的硬性门槛:逐项打勾 = 已实际验证(不是"应该没问题")。
证据优先:描述里的"✅已完成"不是证据,请贴命令+输出/代码 diff/实测结论。
-->
## 变更摘要
<!-- 改了什么、为什么、影响面 -->
## 风险分级(见审查标准 §2)
- [ ] T0 琐碎(纯文案/文档/格式化/资源重命名)
- [ ] T1 常规(单端功能/Bug/UI/单 API)
- [ ] T2 高危(鉴权/支付/隐私/跨端契约/DB schema/并发幂等/已知阻塞项)— 需 2 人复核
## 验证方式(必填,证据优先)
<!-- 贴实际跑过的命令与关键输出;不要只写"已自测通过" -->
- 本地测试/契约守卫命令与结果:
- 运行时实测(如涉及渲染/接口,贴 mock/路由/关键响应):
- 负向注入(新增或修改测试时必填:故意改坏后测试是否转红):
## 技术栈审查清单(按本 PR 涉及项打勾)
### 后端 Java / Spring Boot
- [ ] 业务拒绝用 `BusinessException`+`ApiResponseCode`(HTTP 400≤code<500),无裸 `RuntimeException`
- [ ] 测试对业务拒绝断言 `400 ≤ status < 500`(非仅 `!= 200`)
- [ ] 核心不变量(INV-014/015、Required/Mutual/Block 等)未被绕过
- [ ] 主键/唯一键/CHECK 约束与 Java 枚举一致,无静默 no-op 写
- [ ] 无新增死表/死配置(`system_configs`/`match_rule_configs` 类)
### 小程序 miniapp
- [ ] `app.json`/`pages.json` 路径与磁盘文件一致、无前导 `/`
- [ ] `ignoreDevUnusedFiles`/`ignoreUploadUnusedFiles` = false(资源不被吞)
- [ ] 跨端字段名与后端规范名一致(tsc 不保护 JS 层字段名)
- [ ] WXSS 无通配符/属性选择器/嵌套注释;`<image>` 显式高度
- [ ] 胶囊安全区让出 `--nav-right`
### 管理端 admin-web
- [ ] 渲染相关状态走 `ref`/`reactive`,已补渲染态实测
- [ ] 颜色/字号收敛到 `src/theme/` token,EP 主色阶补全 `-light-N`/`-dark-2`/`-rgb`
- [ ] 表单校验类型安全、出错保留输入
- [ ] 触摸目标 ≥44px,尊重 `prefers-reduced-motion`
### 数据库迁移 SQL
- [ ] `V<序号>__<描述>.sql` 命名,版本连续唯一
- [ ] 向后兼容,NOT NULL 新列有默认值/迁移
- [ ] CHECK 约束与枚举一致,破坏性变更有回滚方案
### 测试 / 契约守卫
- [ ] 无空壳 `skip`/`pass` 测试
- [ ] 已做负向注入证明判别力
- [ ] `scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 23) 同步(增删守卫必改)
### 脚本 / 基础设施
- [ ] 脚本真实依赖已在 CI 对应 job 安装(非仅本地能跑)
- [ ] 密钥仅运行期、不落盘;本地栈固定 `-p danyang`
## 判据纪律自查(见审查标准 §5,对应改动必查)
- [ ] 非空数据验证(无空数据假绿,如推荐/列表返回空也能过)
- [ ] 无直连绕过(关键路径至少一个经 HTTP 层断言)
- [ ] 约束相关改动在带约束环境验证(无测试库缺约束假绿)
- [ ] 小程序改动不走 `Mini Program Compile Check` 的"仅 tsc"假绿,已真编译/查资源
- [ ] 合并后回读 `git rev-parse HEAD` + 轮询 CI 确认本次 head 真 success
## 已知阻塞 / 决策关联
- [ ] 本 PR 是否触碰上线阻塞项(微信真实登录 / 照片直传落库)?若是:✅本次解决 / ⚠️未解决已登记 known-risks
- [ ] 是否与 `decision-register` 的 LOCKED 决策冲突?若是:已回决策层确认(附结论)
- [ ] 是否产生新债务?若是:已登记 `known-risks.md`(编号 KR-xx)
## 关联
<!-- 关联 Issue / 决策 / 风险编号 -->
+7
View File
@@ -25,6 +25,13 @@ jobs:
working-directory: backend
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
@@ -0,0 +1,32 @@
# Task 1 完成报告:角色权限与范围基线
## 变更
- 新增 `docs/v12/V1.2-角色权限与前端入口矩阵.md`。
- 冻结 14 个正式角色的数据范围、工作台入口、允许操作和禁止操作。
- 明确普通红娘为 SELF、红娘组长为 TEAM、平台/超级管理员为 ALL。
- 明确五类工作台业务卡片的查看及审核、待办分配、确认、完成权限,同时保留红娘不得代用户 Like/Pass、不得查看身份证原始佐证和咨询敏感备注等边界。
- 说明 V070/V071 是历史收敛迁移,不能覆盖 2026-10-25 最新裁决,后续通过幂等迁移恢复正式最小权限。
- 修改 `docs/governance/decision-register.md`,登记 V070/V071 冲突裁决。
- 新增 `backend/src/test/java/com/danyang/match/platform/deployment/PermissionBaselineContractTest.java`,锁定角色、范围、操作、敏感权限和历史迁移裁决。
## 测试
命令:
```text
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=PermissionBaselineContractTest test
```
结果:
```text
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
```
## 证据边界与风险
- 本任务未修改 V070/V071 历史迁移,也未直接修改数据库;活跃 PostgreSQL 角色权限/版本查询留给活栈验收任务。
- 矩阵锁定的是产品权限与数据范围契约,实际恢复权限需要后续 Task 5 的幂等迁移并通过干净数据库和活栈验证。
- 未运行全量 Maven、前端构建或 E2E;这些属于后续任务门禁。
@@ -0,0 +1,23 @@
# Task 2 完成报告
## 结果
- 工作台六项指标先按各自 `detailPermission` 判断;无权限仅返回 `available=false` 与权限标识,不执行统计查询或返回真实值。
- 授权指标继续返回真实统计值,空数据保持 `0`。
- `matchmaker_leader` 未绑定组织时返回 `CROSS_ORGANIZATION_DENIED`。
- 团队范围使用严格 `organization_id = ?`,不再把 NULL 组织视为同一团队。
- 统计统一排除没有已发布资料 revision 的用户。
## 验证
执行:
```text
mvn -B -f backend/pom.xml -s backend/maven-settings.xml -Dtest=MatchmakerWorkbenchAuthorizationTest,MatchmakerWorkbenchScopeTest test
```
结果:3 个测试通过,0 失败,0 错误。
## 范围
本次仅涉及工作台服务及新增授权/范围单元测试;未修改迁移、控制器或其他任务文件。
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+1 -1
View File
@@ -1 +1 @@
1.1.0-RC.1
1.1.0
+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";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "danyang-match-admin",
"version": "1.0.0-rc.2",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
+3
View File
@@ -19,6 +19,9 @@
# 后者生成 Vue 版本桥接),允许它们执行。
packages:
- '.'
allowBuilds:
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

+43 -5
View File
@@ -1,14 +1,18 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
})
// 防止 401 时并发触发多次刷新
let refreshing = false
client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('accessToken')
router.push('/login')
async (err) => {
const status = err.response?.status
const original = err.config
const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
refreshing = true
try {
const resp: any = await client.post('/auth/operator/refresh', {})
if (resp?.success) {
const newToken = resp.data.accessToken
setAccessToken(newToken)
refreshing = false
;(original as any).__retried = true
const headers = (original.headers || {}) as Record<string, string>
headers.Authorization = `Bearer ${newToken}`
original.headers = headers as any
return client(original)
}
} catch {
// 刷新失败:清除并跳登录
} finally {
refreshing = false
}
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
return Promise.reject(err)
}
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
if (status === 401) {
if (!isAuthEndpoint) {
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
}
} else if (err.response?.data?.message) {
ElMessage.error(err.response.data.message)
} else {
+377 -156
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,15 @@
</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 { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
@@ -174,8 +201,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 },
@@ -190,6 +217,7 @@ const navigation: NavigationItem[] = [
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
{ label: '红娘团队管理', path: '/matchmaker-team', permissions: ['matchmaker.role.manage'], icon: Connection },
]
const route = useRoute()
@@ -199,6 +227,23 @@ 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('workbench.view')) {
const response: any = await get('/operator/workbench/dashboard')
const value = response.data?.metrics?.matchmakerTodos?.value
if (response.success && typeof value === 'number') todayStats.value = { total: value, pending: value }
}
} catch {
todayStats.value = null
}
}, { immediate: true })
const activeMenu = computed(() => {
if (route.path === '/users/detail') return '/users'
return route.path
@@ -209,6 +254,13 @@ const permissions = computed(() => authStore.permissions || [])
const currentRoleName = computed(() => {
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
const role = operator.value?.roles?.[0]
const roleNames: Record<string, string> = {
super_admin: '超级管理员', platform_admin: '平台管理员', matchmaker_leader: '红娘组长', matchmaker: '公益红娘',
reviewer: '资料审核人员', counselor: '公益咨询人员', counseling_manager: '咨询管理员', service_station_operator: '服务站运营人员',
content_editor: '内容编辑员', risk_operator: '风控运营人员',
}
if (role && roleNames[role]) return roleNames[role]
const perms = permissions.value
if (perms.includes('permission.manage')) return '平台管理员'
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
@@ -217,10 +269,15 @@ 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.team.assign')) return '红娘组长'
if (perms.includes('matchmaker.view')) return '公益红娘'
return '运营人员'
})
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
const visibleNavigation = computed(() => navigation.filter((item) => {
const adminRole = operator.value?.roles?.some((role) => ['super_admin', 'platform_admin'].includes(role))
if ((item.path === '/referral' || item.path === '/matchmaker') && (permissions.value.includes('permission.manage') || adminRole)) return false
return item.permissions.every(hasPerm)
}))
function hasPerm(permission: string) {
return permissions.value.includes(permission)
@@ -258,137 +315,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 +654,8 @@ function handleLogout() {
height: 42px;
flex: none;
object-fit: contain;
border-radius: 50%;
clip-path: circle(50%);
}
.brand-copy {
@@ -629,10 +858,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 +890,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 ? '是(已幂等,未重复记录)' : '否' }}
@@ -80,6 +80,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/**
* U32 活动签到工作台(Phase 15)
*
@@ -95,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)
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
}
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
const formatTime = (v: any) => {
if (!v) return '-'
@@ -150,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="暂无双向互选记录" />
@@ -42,10 +42,11 @@
</template>
<script setup lang="ts">
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)
@@ -55,11 +56,11 @@ const mutualList = ref<any[]>([])
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
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)
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
drawerVisible.value = true
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
mutualList.value = mutualRes.data || []
} catch (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>
@@ -168,6 +168,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/**
* 活动管理(V1.1 Phase 14)
*
@@ -259,7 +260,7 @@ function emptyForm() {
}
function authHeader() {
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
return { Authorization: `Bearer ${getAccessToken()}` }
}
async function loadData() {
@@ -213,6 +213,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client'
import { token } from '@/theme/tokens'
@@ -302,7 +303,7 @@ async function loadFunnel() {
try {
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = localStorage.getItem('accessToken')
const authToken = getAccessToken()
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
@@ -328,7 +329,7 @@ async function loadFunnel() {
async function loadDailySummary() {
try {
const authToken = localStorage.getItem('accessToken')
const authToken = getAccessToken()
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
dailySummary.value = res.data || []
} catch (e) { console.error(e) }
@@ -84,6 +84,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
@@ -109,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
loadStats()
@@ -119,7 +120,7 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
@@ -138,7 +139,7 @@ async function openDetail(id: string) {
decision.value = 'approve'
decisionNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data
} catch (e) { console.error(e) }
@@ -148,7 +149,7 @@ async function handleProcess() {
if (!decision.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post(`/operator/appeals/${currentId.value}/process`,
{ decision: decision.value, decisionNote: decisionNote.value },
{ Authorization: `Bearer ${token}` })
+4 -3
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>
@@ -38,6 +38,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/audit/logs', {
resourceType: resourceFilter.value || undefined,
page: page.value,
@@ -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>
@@ -0,0 +1,110 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="header-row">
<span>红娘团队管理</span>
<div class="header-actions">
<el-select v-model="status" clearable placeholder="全部状态" style="width:120px" @change="load">
<el-option label="正常" value="normal" /><el-option label="停用" value="suspended" />
</el-select>
<el-input v-model="keyword" clearable placeholder="姓名或用户名" style="width:180px" @keyup.enter="load" />
<el-button type="primary" @click="openCreate">新增红娘</el-button>
</div>
</div>
</template>
<el-alert v-if="error" type="error" :closable="false" :title="error" />
<el-table v-loading="loading" :data="items" border stripe empty-text="暂无红娘团队成员">
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="角色" width="130"><template #default="{ row }">{{ roleName(row.roles?.[0]) }}</template></el-table-column>
<el-table-column label="数据范围" width="110"><template #default="{ row }">{{ scopeName(row.scope) }}</template></el-table-column>
<el-table-column label="所属组织" min-width="180"><template #default="{ row }">{{ orgMap[row.organizationId] || '未绑定' }}</template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'normal' ? 'success' : 'danger'">{{ row.status === 'normal' ? '正常' : '停用' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" @click="resetPassword(row)">重置密码</el-button>
<el-button size="small" :type="row.status === 'normal' ? 'warning' : 'success'" @click="toggleStatus(row)">{{ row.status === 'normal' ? '停用' : '启用' }}</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination v-if="total" v-model:current-page="page" style="margin-top:16px;text-align:right" layout="total, prev, pager, next" :page-size="pageSize" :total="total" @current-change="load" />
</el-card>
<el-dialog v-model="dialogVisible" :title="editing ? '编辑红娘团队成员' : '新增红娘团队成员'" width="520px">
<el-form label-width="92px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="角色"><el-radio-group v-model="form.roleCode"><el-radio value="matchmaker">普通红娘</el-radio><el-radio value="matchmaker_leader">红娘组长</el-radio></el-radio-group></el-form-item>
<el-form-item label="所属组织" required><el-select v-model="form.organizationId" clearable style="width:100%"><el-option v-for="org in organizations" :key="org.id" :label="org.name" :value="org.id" /></el-select></el-form-item>
<el-form-item v-if="!editing" label="初始密码"><el-input v-model="form.password" type="password" show-password /></el-form-item>
<el-alert type="info" :closable="false" :title="`数据范围:${scopeName(form.roleCode === 'matchmaker_leader' ? 'TEAM' : 'SELF')}`" />
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { get, post, put } from '@/api/client'
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const items = ref<any[]>([])
const organizations = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const keyword = ref('')
const status = ref('')
const dialogVisible = ref(false)
const editing = ref<any | null>(null)
const form = ref({ name: '', roleCode: 'matchmaker', organizationId: '', password: '' })
const orgMap = computed(() => Object.fromEntries(organizations.value.map((org) => [org.id, org.name])))
function roleName(role: string) { return role === 'matchmaker_leader' ? '红娘组长' : '普通红娘' }
function scopeName(scope: string) { return scope === 'TEAM' ? '团队范围' : scope === 'ALL' ? '全部范围' : '本人范围' }
async function load() {
loading.value = true; error.value = ''
try {
const res: any = await get('/operator/matchmaker-team/members', { page: page.value - 1, pageSize, keyword: keyword.value || undefined, status: status.value || undefined })
items.value = res.data?.items || []; total.value = res.data?.total || 0
} catch (e: any) { error.value = e?.response?.data?.message || '红娘团队数据加载失败' } finally { loading.value = false }
}
async function loadOrganizations() {
const res: any = await get('/operator/operators/organizations')
organizations.value = res.data || []
}
function openCreate() { editing.value = null; form.value = { name: '', roleCode: 'matchmaker', organizationId: '', password: '' }; dialogVisible.value = true }
function openEdit(row: any) { editing.value = row; form.value = { name: row.name, roleCode: row.roles?.[0] || 'matchmaker', organizationId: row.organizationId || '', password: '' }; dialogVisible.value = true }
async function save() {
if (!form.value.name.trim()) return ElMessage.warning('请填写红娘姓名')
if (form.value.roleCode === 'matchmaker_leader' && !form.value.organizationId) return ElMessage.warning('红娘组长必须选择所属组织')
if (!editing.value && form.value.password.length < 8) return ElMessage.warning('初始密码至少8位')
saving.value = true
try {
const payload: any = { name: form.value.name, roleCode: form.value.roleCode, organizationId: form.value.organizationId || null }
if (!editing.value) Object.assign(payload, { password: form.value.password })
const res: any = editing.value ? await put(`/operator/matchmaker-team/members/${editing.value.id}`, payload) : await post('/operator/matchmaker-team/members', payload)
if (!res.success) throw new Error(res.message || '保存失败')
dialogVisible.value = false; ElMessage.success('保存成功'); await load()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || e.message || '保存失败') } finally { saving.value = false }
}
async function toggleStatus(row: any) {
await put(`/operator/matchmaker-team/members/${row.id}/${row.status === 'normal' ? 'disable' : 'enable'}`)
ElMessage.success('状态已更新'); await load()
}
async function resetPassword(row: any) {
const value = await ElMessageBox.prompt('请输入新密码(至少8位)', '重置密码', { inputType: 'password', inputValidator: (v) => v.length >= 8 || '密码至少8位' })
await put(`/operator/matchmaker-team/members/${row.id}/reset-password`, { newPassword: value.value })
ElMessage.success('密码已重置')
}
onMounted(async () => { await loadOrganizations(); await load() })
</script>
<style scoped>
.header-row,.header-actions{display:flex;align-items:center}.header-row{justify-content:space-between}.header-actions{gap:8px}
</style>
@@ -10,7 +10,16 @@
<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 label="角色" min-width="190">
<template #default="{row}">
<el-tag v-for="role in row.roles || []" :key="role" size="small" style="margin-right:4px">{{ roleName(role) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="数据范围" width="110">
<template #default="{row}">{{ scopeName(row.scope) }}</template>
</el-table-column>
<el-table-column prop="organizationId" label="所属机构" width="160">
<template #default="{row}">{{ orgMap[row.organizationId] || '-' }}</template>
</el-table-column>
@@ -25,8 +34,9 @@
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<el-table-column label="操作" width="270">
<template #default="{row}">
<el-button size="small" @click="openEdit(row)">角色权限</el-button>
<el-button size="small" @click="resetPwd(row.id)">重置密码</el-button>
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
@@ -40,15 +50,23 @@
</el-card>
<!-- 新增对话框 -->
<el-dialog v-model="formVisible" title="新增Operator" width="480px">
<el-dialog v-model="formVisible" title="新增工作人员" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="所属机构">
<el-select v-model="form.organizationId" style="width:100%">
<el-select v-model="form.organizationId" clearable style="width:100%">
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-checkbox-group v-model="form.roleCodes">
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-alert v-if="selectedPermissions.length" type="info" :closable="false" title="将继承以下权限">
{{ selectedPermissions.join('、') }}
</el-alert>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password />
</el-form-item>
@@ -58,11 +76,35 @@
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="调整角色与数据范围" width="560px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="账号">{{ editForm.name }}({{ editForm.username }})</el-form-item>
<el-form-item label="所属机构">
<el-select v-model="editForm.organizationId" clearable style="width:100%">
<el-option v-for="o in orgList" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-checkbox-group v-model="editForm.roleCodes">
<el-checkbox v-for="role in roleTemplates" :key="role.code" :label="role.code">{{ role.name }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-alert v-if="editPermissions.length" type="info" :closable="false" title="将继承以下权限">
{{ editPermissions.join('、') }}
</el-alert>
</el-form>
<template #footer>
<el-button @click="editVisible=false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
const loading = ref(false)
@@ -72,24 +114,35 @@ const page = ref(0)
const pageSize = ref(20)
const formVisible = ref(false)
const saving = ref(false)
const form = ref({ username: '', name: '', organizationId: '', password: '' })
const form = ref({ username: '', name: '', organizationId: '', password: '', roleCodes: [] as string[] })
const editVisible = ref(false)
const editForm = ref({ id: '', username: '', name: '', organizationId: '', roleCodes: [] as string[] })
const roleTemplates = ref<any[]>([])
const orgList = ref<any[]>([])
const orgMap = ref<Record<string, string>>({})
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
tableData.value = res.data?.list || []
tableData.value = res.data?.items || res.data?.list || []
total.value = res.data?.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function loadRoles() {
try {
const token = getAccessToken()
const res: any = await get('/operator/operators/roles', {}, { Authorization: `Bearer ${token}` })
roleTemplates.value = res.data || []
} catch (e) { console.error(e) }
}
async function loadOrgs() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
orgList.value = res.data || []
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
@@ -97,7 +150,7 @@ async function loadOrgs() {
}
function openCreate() {
form.value = { username: '', name: '', organizationId: '', password: '' }
form.value = { username: '', name: '', organizationId: '', password: '', roleCodes: ['matchmaker'] }
formVisible.value = true
}
@@ -105,7 +158,9 @@ async function save() {
if (!form.value.username || !form.value.password) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
if (!form.value.roleCodes.length) return
if (form.value.roleCodes.includes('matchmaker_leader') && !form.value.organizationId) return
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
formVisible.value = false
loadData()
@@ -113,9 +168,32 @@ async function save() {
finally { saving.value = false }
}
function openEdit(row: any) {
editForm.value = { id: row.id, username: row.username, name: row.name, organizationId: row.organizationId || '', roleCodes: [...(row.roles || [])] }
editVisible.value = true
}
async function saveEdit() {
if (!editForm.value.roleCodes.length) return
if (editForm.value.roleCodes.includes('matchmaker_leader') && !editForm.value.organizationId) return
saving.value = true
try {
const token = getAccessToken()
await put(`/operator/operators/${editForm.value.id}/roles`, { roleCodes: editForm.value.roleCodes, organizationId: editForm.value.organizationId || null }, { Authorization: `Bearer ${token}` })
editVisible.value = false
await loadData()
} catch (e) { console.error(e) }
finally { saving.value = false }
}
function roleName(code: string) { return roleTemplates.value.find(r => r.code === code)?.name || code }
function scopeName(scope: string) { return scope === 'ALL' ? '全平台' : scope === 'TEAM' ? '本组织' : '本人' }
const selectedPermissions = computed(() => [...new Set(form.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
const editPermissions = computed(() => [...new Set(editForm.value.roleCodes.flatMap(code => roleTemplates.value.find(r => r.code === code)?.permissions || []))])
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
loadData()
@@ -126,7 +204,7 @@ async function resetPwd(id: string) {
const pwd = prompt('请输入新密码(至少8位)')
if (!pwd || pwd.length < 8) return
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
} catch (e) { console.error(e) }
}
@@ -134,7 +212,7 @@ async function resetPwd(id: string) {
function handlePageChange(p: number) { page.value = p - 1; loadData() }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
onMounted(() => { loadData(); loadOrgs() })
onMounted(() => { loadData(); loadOrgs(); loadRoles() })
</script>
<style scoped>
@@ -16,7 +16,7 @@
<el-table-column prop="contactName" label="联系人" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
<el-tag :type="row.status==='normal'?'success':'danger'">{{ row.status==='normal'?'正常':'已停用' }}</el-tag>
<el-tag :type="row.status==='active'?'success':'danger'">{{ row.status==='active'?'正常':'已停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
@@ -25,8 +25,8 @@
<el-table-column label="操作" width="180">
<template #default="{row}">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" :type="row.status==='normal'?'warning':'success'"
@click="toggleStatus(row)">{{ row.status==='normal'?'停用':'启用' }}</el-button>
<el-button size="small" :type="row.status==='active'?'warning':'success'"
@click="toggleStatus(row)">{{ row.status==='active'?'停用':'启用' }}</el-button>
</template>
</el-table-column>
</el-table>
@@ -40,10 +40,11 @@
</el-form-item>
<el-form-item label="机构类型">
<el-select v-model="form.type" style="width:100%">
<el-option label="妇联" value="fulian" />
<el-option label="工会" value="gonghui" />
<el-option label="公益服务中心" value="public_service" />
<el-option label="志愿者团队" value="volunteer" />
<el-option label="妇联" value="women_federation" />
<el-option label="工会" value="trade_union" />
<el-option label="公益服务中心" value="charity_center" />
<el-option label="志愿者团队" value="volunteer_team" />
<el-option label="民政合作单位" value="civil_affairs_partner" />
<el-option label="其他合作机构" value="other" />
</el-select>
</el-form-item>
@@ -60,6 +61,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
@@ -71,14 +73,14 @@ const form = ref({ name: '', type: '', contactName: '' })
const saving = ref(false)
const ORG_TYPE_TEXT: Record<string, string> = {
fulian: '妇联', gonghui: '工会', public_service: '公益服务中心',
volunteer: '志愿者团队', other: '其他合作机构'
women_federation: '妇联', trade_union: '工会', charity_center: '公益服务中心',
volunteer_team: '志愿者团队', civil_affairs_partner: '民政合作单位', other: '其他合作机构'
}
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
} catch (e) { console.error(e) }
@@ -87,7 +89,7 @@ async function loadData() {
function openCreate() {
editId.value = ''
form.value = { name: '', type: 'fulian', contactName: '' }
form.value = { name: '', type: 'women_federation', contactName: '' }
formVisible.value = true
}
@@ -101,7 +103,7 @@ async function save() {
if (!form.value.name) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
if (editId.value) {
await put(`/operator/organizations/${editId.value}`, form.value, {
Authorization: `Bearer ${token}`
@@ -119,8 +121,8 @@ async function save() {
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const action = row.status === 'normal' ? 'disable' : 'enable'
const token = getAccessToken()
const action = row.status === 'active' ? 'disable' : 'enable'
await put(`/operator/organizations/${row.id}/${action}`, {}, {
Authorization: `Bearer ${token}`
})
@@ -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}">
@@ -98,6 +99,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
@@ -124,7 +126,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
loadStats()
@@ -134,7 +136,7 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
@@ -152,7 +154,7 @@ async function openDetail(reportId: string) {
processResult.value = 'no_action'
processNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data
} catch (e) { console.error(e) }
@@ -162,7 +164,7 @@ async function handleProcess() {
if (!processResult.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post(`/operator/risk/reports/${currentReportId.value}/process`,
{ processResult: processResult.value, processNote: processNote.value },
{ Authorization: `Bearer ${token}` })
@@ -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>
@@ -0,0 +1,27 @@
<template>
<div class="page-container page-shell">
<header class="page-heading"><div><h1>后台建档</h1><p>建立草稿档案,手机号核验和资料审核仍需独立完成。</p></div></header>
<section class="surface-card form-card">
<el-form label-position="top" @submit.prevent="submit">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="所属组织"><el-input v-model="form.organization" /></el-form-item>
<el-form-item label="组织联系电话"><el-input v-model="form.organizationPhone" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
<el-button type="primary" :loading="loading" @click="submit">创建草稿</el-button>
</el-form>
</section>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { post } from '@/api/client'
const loading = ref(false)
const form = reactive({ name: '', organization: '', organizationPhone: '', phone: '' })
async function submit() {
loading.value = true
try { await post('/operator/users', form); ElMessage.success('草稿档案已创建'); Object.assign(form, { name: '', organization: '', organizationPhone: '', phone: '' }) }
finally { loading.value = false }
}
</script>
<style scoped>.page-container{padding:28px}.form-card{max-width:620px;padding:24px}</style>
@@ -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">
@@ -0,0 +1,22 @@
<template>
<div class="page-container page-shell">
<header class="page-heading"><div><h1>CSV 批量建档</h1><p>仅支持 UTF-8 CSV;预览不会写入用户表。</p></div></header>
<section class="surface-card form-card">
<input type="file" accept=".csv,text/csv" @change="onFile" />
<el-button type="primary" :loading="loading" :disabled="!fileText" @click="preview">预览</el-button>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-table v-if="result" :data="result.rows" class="preview-table"><el-table-column prop="rowNumber" label="行号" width="80" /><el-table-column prop="status" label="状态" /><el-table-column prop="error" label="错误" /></el-table>
<el-button v-if="result && result.validRows" type="success" :loading="committing" @click="commit">提交 {{ result.validRows }} 行</el-button>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { post } from '@/api/client'
const fileText = ref(''); const result = ref<any>(null); const error = ref(''); const loading = ref(false); const committing = ref(false)
function onFile(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { fileText.value = String(reader.result || '') }; reader.readAsText(file, 'UTF-8') }
async function preview() { loading.value = true; error.value = ''; try { const response: any = await post('/operator/users/import/preview', fileText.value, { 'Content-Type': 'text/csv' }); result.value = response.data } catch (e: any) { error.value = e.response?.data?.message || '预览失败' } finally { loading.value = false } }
async function commit() { if (!result.value?.batchId) return; committing.value = true; try { await post('/operator/users/import/commit', { batchId: result.value.batchId, idempotencyKey: crypto.randomUUID() }); ElMessage.success('导入提交成功'); result.value = null } finally { committing.value = false } }
</script>
<style scoped>.page-container{padding:28px}.form-card{padding:24px;display:grid;gap:16px}.preview-table{margin-top:12px}</style>
@@ -6,7 +6,7 @@
<h1>用户管理</h1>
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
</div>
<el-tag type="info" effect="plain">共 {{ total }} 人</el-tag>
<div class="heading-actions"><el-button v-if="canCreate" type="primary" @click="router.push('/users/create')">后台建档</el-button><el-button v-if="canCreate" @click="router.push('/users/import')">CSV导入</el-button><el-tag type="info" effect="plain">共 {{ total }} 人</el-tag></div>
</header>
<section class="surface-card filter-panel" aria-label="用户筛选">
@@ -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>
@@ -99,12 +101,15 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { get } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const canCreate = computed(() => authStore.permissions.includes('user.create'))
const loading = ref(false)
const errorMessage = ref('')
const keyword = ref('')
+13 -3
View File
@@ -25,9 +25,12 @@ const routes: RouteRecordRaw[] = [
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
{ path: 'users', name: 'UserManagement', component: () => import('@/modules/user/UserManagementView.vue'), meta: { title: '用户管理', permissions: ['user.view'] } },
{ path: 'users/create', name: 'UserCreate', component: () => import('@/modules/user/UserCreateView.vue'), meta: { title: '后台建档', permissions: ['user.create'] } },
{ path: 'users/import', name: 'UserImport', component: () => import('@/modules/user/UserImportView.vue'), meta: { title: '批量建档', permissions: ['user.create'] } },
{ path: 'users/detail', name: 'UserDetail', component: () => import('@/modules/user/UserDetailView.vue'), meta: { title: '用户详情', permissions: ['user.view', 'user.sensitive.view'] } },
{ path: 'organizations', name: 'OrganizationManagement', component: () => import('@/modules/organization/OrganizationView.vue'), meta: { title: '组织管理', permissions: ['organization.manage'] } },
{ path: 'operators', name: 'OperatorManagement', component: () => import('@/modules/operator/OperatorManagementView.vue'), meta: { title: '工作人员管理', permissions: ['permission.manage'] } },
{ path: 'matchmaker-team', name: 'MatchmakerTeamManagement', component: () => import('@/modules/operator/MatchmakerTeamManagementView.vue'), meta: { title: '红娘团队管理', permissions: ['matchmaker.role.manage'] } },
{ path: 'analytics', name: 'Analytics', component: () => import('@/modules/analytics/AnalyticsView.vue'), meta: { title: '数据统计', permissions: ['analytics.view'] } },
{ path: 'audit', name: 'AuditLog', component: () => import('@/modules/audit/AuditLogView.vue'), meta: { title: '操作日志', permissions: ['user.view'] } },
// V1.1 新增模块
@@ -37,8 +40,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'] } },
],
},
@@ -48,16 +51,23 @@ const routes: RouteRecordRaw[] = [
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
const requiredPerms = (to.meta.permissions as string[]) || []
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
await authStore.initializeSession()
// 1. 登录检查
if (to.path !== '/login' && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
// 2. 权限检查
if ((to.path === '/referral' || to.path === '/matchmaker') && authStore.permissions.includes('permission.manage')) {
ElMessage.info('管理员请在工作台查看红娘业务统计')
return { path: '/dashboard' }
}
if (requiredPerms.length > 0 && !hasPermission(requiredPerms)) {
ElMessage.warning('您没有访问该页面的权限')
return { path: '/dashboard' }
+79 -28
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
@@ -40,18 +54,18 @@ const emptyMetric = (
key: string,
label: string,
tone: DashboardMetric['tone'],
route: string,
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, route })
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, status: 'forbidden', route: '' })
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
return {
followups: [],
metrics: [
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
emptyMetric('activity_registrations', '活动报名', 'red', '/activities/interests'),
emptyMetric('pending_reviews', '待审核资料', 'rose'),
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber'),
emptyMetric('counseling_todos', '咨询服务待办', 'blue'),
emptyMetric('station_appointments', '服务站预约', 'green'),
emptyMetric('effective_matches', '有效匹配', 'purple'),
emptyMetric('activity_registrations', '活动报名', 'red'),
],
todos: [],
activities: [],
@@ -65,8 +79,12 @@ 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 [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult] = await Promise.allSettled([
client.get('/operator/workbench/dashboard', { timeout: REQUEST_TIMEOUT }),
canViewAnalytics
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
@@ -76,53 +94,83 @@ 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 workbench = fulfilledData<JsonRecord>(workbenchResult)
const workbenchMetrics = recordOf(workbench || undefined, 'metrics')
const workbenchValue = (key: string) => numberOf(recordOf(workbenchMetrics, key), 'value')
const hasWorkbenchMetric = (key: string) => Object.prototype.hasOwnProperty.call(workbenchMetrics, key)
const workbenchAvailable = (key: string) => Boolean(recordOf(workbenchMetrics, key).available)
const metricStatus = (key: string): DashboardMetric['status'] => {
if (hasWorkbenchMetric(key)) return workbenchAvailable(key) ? 'ready' : 'forbidden'
return 'error'
}
const metricAvailable = (key: string) => metricStatus(key) === 'ready'
const metricRoute = (status: DashboardMetric['status'], route: string) => status === 'ready' ? route : ''
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 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')
const registrationSources = recordOf(service || undefined, 'registrationSources')
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
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: workbenchValue('pendingReviews'),
trendText: workbenchAvailable('pendingReviews') ? '当前数据范围内待审核资料' : '暂无授权数据', tone: 'rose', available: metricAvailable('pendingReviews'), status: metricStatus('pendingReviews'), route: metricRoute(metricStatus('pendingReviews'), '/review'),
},
{
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
key: 'matchmaker_todos', label: '红娘服务待办',
value: workbenchValue('matchmakerTodos'),
trendText: workbenchAvailable('matchmakerTodos') ? `${workbench?.scopeLabel || '当前范围'}待完成服务` : '暂无授权数据', tone: 'amber', available: metricAvailable('matchmakerTodos'), status: metricStatus('matchmakerTodos'), route: metricRoute(metricStatus('matchmakerTodos'), permissions.includes('permission.manage') ? '' : '/matchmaker'),
},
{
key: 'counseling_todos', label: '咨询服务待办',
value: numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'),
trendText: service ? '待受理与已分配' : '暂无授权数据', tone: 'blue', available: Boolean(service), route: '/counseling',
value: workbenchValue('counselingTodos'),
trendText: workbenchAvailable('counselingTodos') ? `${workbench?.scopeLabel || '当前范围'}咨询待办` : '暂无授权数据', tone: 'blue', available: metricAvailable('counselingTodos'), status: metricStatus('counselingTodos'), route: metricRoute(metricStatus('counselingTodos'), '/counseling'),
},
{
key: 'station_appointments', label: '服务站预约',
value: numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'),
trendText: service ? '待确认与已确认' : '暂无授权数据', tone: 'green', available: Boolean(service), route: '/service-appointment',
value: workbenchValue('serviceStationAppointments'),
trendText: workbenchAvailable('serviceStationAppointments') ? `${workbench?.scopeLabel || '当前范围'}服务站预约` : '暂无授权数据', tone: 'green', available: metricAvailable('serviceStationAppointments'), status: metricStatus('serviceStationAppointments'), route: metricRoute(metricStatus('serviceStationAppointments'), '/service-appointment'),
},
{
key: 'effective_matches', label: '有效匹配', value: numberOf(effectiveConnections, 'activeMatches'),
trendText: service ? '当前活跃匹配' : '暂无授权数据', tone: 'purple', available: Boolean(service), route: '/analytics',
key: 'effective_matches', label: '有效匹配', value: workbenchValue('effectiveMatches'),
trendText: workbenchAvailable('effectiveMatches') ? `${workbench?.scopeLabel || '当前范围'}有效匹配` : '暂无授权数据', tone: 'purple', available: metricAvailable('effectiveMatches'), status: metricStatus('effectiveMatches'), route: metricRoute(metricStatus('effectiveMatches'), '/analytics'),
},
{
key: 'activity_registrations', label: '活动报名', value: numberOf(registrationSources, 'totalRegistrations'),
trendText: service ? '累计有效报名' : '暂无授权数据', tone: 'red', available: Boolean(service), route: '/activities/interests',
key: 'activity_registrations', label: '活动报名', value: workbenchValue('activityRegistrations'),
trendText: workbenchAvailable('activityRegistrations') ? `${workbench?.scopeLabel || '当前范围'}活动报名` : '暂无授权数据', tone: 'red', available: metricAvailable('activityRegistrations'), status: metricStatus('activityRegistrations'), route: metricRoute(metricStatus('activityRegistrations'), '/activities/interests'),
},
// 保留 forbidden 卡片以明确展示“无权限”;其数值只在 available 时渲染,route 由 metricRoute 置空。
]
const metricByKey = (key: string) => metrics.find(metric => metric.key === key)
const todos = ([
{ 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: 'review', title: '资料审核', description: '待领取与审核中资料', count: metricByKey('pending_reviews')?.value || 0, actionText: '立即审核', route: '/review', tone: 'rose', available: metricByKey('pending_reviews')?.available || false },
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metricByKey('counseling_todos')?.value || 0, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metricByKey('counseling_todos')?.available || false },
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metricByKey('station_appointments')?.value || 0, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metricByKey('station_appointments')?.available || false },
{ key: 'matchmaker', title: '红娘服务', description: metricByKey('matchmaker_todos')?.trendText || '暂无授权数据', count: metricByKey('matchmaker_todos')?.value || 0, actionText: permissions.includes('permission.manage') ? '统计' : '查看服务', route: permissions.includes('permission.manage') ? '' : '/matchmaker', tone: 'amber', available: metricByKey('matchmaker_todos')?.available || false },
{ 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 +190,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 +201,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 = [workbenchResult, coreResult, serviceResult, activityResult, matchmakerResult, reviewResult, managementResult].filter((item) => item.status === 'rejected').length
const anyData = Boolean(workbench) || analyticsAvailable || Boolean(activityRows) || matchmakerAvailable || Boolean(reviews)
const notice = !anyData
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
return {
followups,
metrics,
todos,
activities,
+59 -24
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { post } from '@/api/client'
import { setAccessToken, clearAccessToken } from '@/utils/token'
interface OperatorInfo {
operatorId: string
@@ -8,53 +9,87 @@ interface OperatorInfo {
username: string
permissions: string[]
organizationId: string | null
roles: string[]
}
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', () => {
const previewParams = import.meta.env.DEV ? new URLSearchParams(window.location.search) : null
const previewRole = previewParams?.get('preview') || ''
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null, roles: [previewRole] }
: null
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
const operator = ref<OperatorInfo | null>(
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
)
const isLoggedIn = computed(() => !!token.value)
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
const operator = ref<OperatorInfo | null>(previewOperator)
let sessionInitialized = false
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || [])
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
const hasPermission = (code: string) => permissions.value.includes(code)
function applyAuthData(data: any) {
token.value = data.accessToken || ''
operator.value = {
operatorId: data.operatorId,
name: data.name,
username: data.username,
permissions: data.permissions || [],
organizationId: data.organizationId ?? null,
roles: Array.isArray(data.roles) ? data.roles : (Array.isArray(data.roleCodes) ? data.roleCodes : [data.roleCode || data.role].filter(Boolean)),
}
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
setAccessToken(token.value)
}
async function login(username: string, password: string) {
const res: any = await post('/auth/operator/login', { username, password })
if (res.success) {
token.value = res.data.accessToken
operator.value = {
operatorId: res.data.operatorId,
name: res.data.name,
username: res.data.username,
permissions: res.data.permissions || [],
organizationId: res.data.organizationId ?? null,
}
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value))
applyAuthData(res.data)
return res.data
}
throw new Error(res.message || '登录失败')
}
function logout() {
token.value = ''
operator.value = null
localStorage.removeItem('accessToken')
localStorage.removeItem('operator')
/**
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
*/
async function initializeSession(): Promise<boolean> {
if (sessionInitialized) return isLoggedIn.value
sessionInitialized = true
if (token.value) return true
try {
const res: any = await post('/auth/operator/refresh', {})
if (res.success) {
applyAuthData(res.data)
return true
}
} catch {
// 未登录或 refresh 失效,交由路由守卫跳转登录页
}
return false
}
return { token, operator, isLoggedIn, permissions, login, logout }
function logout() {
// 尽力通知后端注销并清除 Cookie(best-effort)
try {
post('/auth/operator/logout', {}).catch(() => {})
} catch {
// ignore
}
token.value = ''
operator.value = null
clearAccessToken()
}
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
})
+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%);
+3
View File
@@ -1,4 +1,5 @@
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
export type DashboardMetricStatus = 'ready' | 'forbidden' | 'error'
export interface DashboardMetric {
key: string
@@ -7,6 +8,7 @@ export interface DashboardMetric {
trendText: string
tone: DashboardTone
available: boolean
status: DashboardMetricStatus
route: string
}
@@ -51,6 +53,7 @@ export interface DashboardEffectivenessItem {
}
export interface DashboardModel {
followups: { serviceId: string; userDisplayNumber: string; content: string; createdAt: string }[]
metrics: DashboardMetric[]
todos: DashboardTodo[]
activities: DashboardActivity[]
+19
View File
@@ -0,0 +1,19 @@
// 内存态访问令牌。
//
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
// Authorization 头承载。两者并存,互不冲突。
let currentToken = ''
export function getAccessToken(): string {
return currentToken
}
export function setAccessToken(token: string): void {
currentToken = token || ''
}
export function clearAccessToken(): void {
currentToken = ''
}
+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 {
+2
View File
@@ -18,6 +18,8 @@ export default defineConfig(({ command }) => ({
server: {
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
port: 13520,
// 允许同一局域网的浏览器访问开发页面。
host: '0.0.0.0',
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
strictPort: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,9 +13,9 @@
<groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId>
<version>1.0.0-RC.1</version>
<version>1.2.0</version>
<name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
<properties>
<java.version>21</java.version>
@@ -0,0 +1,24 @@
package com.danyang.match.platform.account.controller;
import com.danyang.match.platform.account.service.ServiceConsentService;
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;
@RestController
@RequestMapping("/account/service-consent")
@RequiredArgsConstructor
public class ServiceConsentController {
private final ServiceConsentService service;
@GetMapping public ApiResponse<?> get(@AuthenticationPrincipal UserPrincipal p) { return ApiResponse.ok(service.getConsentState(p.id())); }
@PostMapping public ApiResponse<?> agree(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, Object> body, jakarta.servlet.http.HttpServletRequest req) {
@SuppressWarnings("unchecked") Map<String, String> versions = (Map<String, String>) body.get("documentVersions");
return ApiResponse.ok(service.agree(p.id(), versions, req.getRemoteAddr(), req.getHeader("User-Agent")));
}
@PostMapping("/withdraw") public ApiResponse<?> withdraw(@AuthenticationPrincipal UserPrincipal p, @RequestBody Map<String, String> body, jakarta.servlet.http.HttpServletRequest req) {
return ApiResponse.ok(service.withdraw(p.id(), body.get("consentType"), req.getRemoteAddr(), req.getHeader("User-Agent")));
}
}
@@ -0,0 +1,21 @@
package com.danyang.match.platform.account.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("users")
public class UserAccountLifecycle extends BaseEntity {
@TableField("phone_verification_status")
private String phoneVerificationStatus;
@TableField("account_creation_status")
private String accountCreationStatus;
@TableField("credential_delivery_status")
private String credentialDeliveryStatus;
@TableField("service_eligibility_status")
private String serviceEligibilityStatus;
}
@@ -0,0 +1,23 @@
package com.danyang.match.platform.account.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;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("user_consent_states")
public class UserConsentState extends BaseEntity {
private String userId;
private String consentType;
private String documentVersion;
private String status;
private Instant agreedAt;
private Instant withdrawnAt;
private String actorUserId;
private String ipAddress;
private String userAgent;
}
@@ -0,0 +1,22 @@
package com.danyang.match.platform.account.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;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("user_credentials")
public class UserCredential extends BaseEntity {
private String userId;
private String passwordHash;
private Integer credentialVersion;
private Boolean mustChangePassword;
private Instant issuedAt;
private Instant firstUsedAt;
private Instant lastChangedAt;
private Instant revokedAt;
}
@@ -0,0 +1,18 @@
package com.danyang.match.platform.account.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
@Data
@TableName("user_service_states")
public class UserServiceState {
@TableId private String userId;
private Boolean serviceActive;
private String serviceBlockReason;
private Instant evaluatedAt;
private Instant createdAt;
private Instant updatedAt;
private Instant deletedAt;
}
@@ -0,0 +1,20 @@
package com.danyang.match.platform.account.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.account.entity.UserConsentState;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Optional;
@Mapper
public interface UserConsentStateMapper extends BaseMapper<UserConsentState> {
@Select("SELECT COUNT(*) FROM user_consent_states WHERE user_id = #{userId} AND status = 'agreed' AND deleted_at IS NULL")
int countAgreed(String userId);
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND deleted_at IS NULL ORDER BY consent_type")
List<UserConsentState> findByUserId(String userId);
@Select("SELECT * FROM user_consent_states WHERE user_id = #{userId} AND consent_type = #{consentType} AND deleted_at IS NULL LIMIT 1")
Optional<UserConsentState> findByUserAndType(String userId, String consentType);
}
@@ -0,0 +1,13 @@
package com.danyang.match.platform.account.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.account.entity.UserServiceState;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
@Mapper
public interface UserServiceStateMapper extends BaseMapper<UserServiceState> {
@Select("SELECT * FROM user_service_states WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
Optional<UserServiceState> findByUserId(String userId);
}
@@ -0,0 +1,106 @@
package com.danyang.match.platform.account.service;
import com.danyang.match.platform.account.entity.UserConsentState;
import com.danyang.match.platform.account.entity.UserServiceState;
import com.danyang.match.platform.account.mapper.UserConsentStateMapper;
import com.danyang.match.platform.account.mapper.UserServiceStateMapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
@Service
@RequiredArgsConstructor
public class ServiceConsentService {
public static final String SERVICE_TERMS = "service_terms";
public static final String PRIVACY_SAFETY = "privacy_safety";
private final UserConsentStateMapper consentMapper;
private final UserServiceStateMapper serviceStateMapper;
private final UserMapper userMapper;
private final UserProfileMapper profileMapper;
private final AuditLogMapper auditLogMapper;
@Value("${account.service-consent.service-terms-version:v1}") private String serviceTermsVersion = "v1";
@Value("${account.service-consent.privacy-safety-version:v1}") private String privacySafetyVersion = "v1";
public List<Map<String, Object>> getConsentState(String userId) {
Map<String, UserConsentState> current = new HashMap<>();
consentMapper.findByUserId(userId).forEach(c -> current.put(c.getConsentType(), c));
return List.of(document(SERVICE_TERMS, serviceTermsVersion, current.get(SERVICE_TERMS)),
document(PRIVACY_SAFETY, privacySafetyVersion, current.get(PRIVACY_SAFETY)));
}
@Transactional
public Map<String, Object> agree(String userId, Map<String, String> versions, String ip, String ua) {
if (versions == null || !versions.containsKey(SERVICE_TERMS) || !versions.containsKey(PRIVACY_SAFETY))
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "两份服务授权必须分别确认");
agreeOne(userId, SERVICE_TERMS, serviceTermsVersion, versions.get(SERVICE_TERMS), ip, ua);
agreeOne(userId, PRIVACY_SAFETY, privacySafetyVersion, versions.get(PRIVACY_SAFETY), ip, ua);
boolean active = evaluate(userId);
audit(userId, "SERVICE_CONSENT_AGREE", "service_consent", ip, ua);
return Map.of("serviceActive", active, "consents", getConsentState(userId));
}
@Transactional
public Map<String, Object> withdraw(String userId, String type, String ip, String ua) {
if (!required().contains(type)) throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的授权类型");
UserConsentState state = consentMapper.findByUserAndType(userId, type)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该授权记录"));
state.setStatus("withdrawn"); state.setWithdrawnAt(Instant.now()); state.setActorUserId(userId);
state.setIpAddress(ip); state.setUserAgent(ua); consentMapper.updateById(state); evaluate(userId);
audit(userId, "SERVICE_CONSENT_WITHDRAW", type, ip, ua);
return Map.of("withdrawn", true, "consentType", type, "serviceActive", false);
}
public void requireActive(String userId) {
User user = userMapper.findById(userId).orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
UserProfile profile = profileMapper.findByUserId(userId).orElse(null);
if (!Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus()) || !"verified".equals(user.getPhoneVerificationStatus()))
throw blocked("账号或手机号尚未满足服务条件");
if (profile == null || !Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|| profile.getPublishedProfileRevision() == null
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
throw blocked("资料尚未审核通过");
if (consentMapper.countAgreed(userId) != required().size()) throw blocked("请先确认两份服务授权");
if (!"eligible".equals(user.getServiceEligibilityStatus())) throw blocked("当前账号不具备服务资格");
}
public boolean isActive(String userId) { try { requireActive(userId); return true; } catch (RuntimeException e) { return false; } }
private void agreeOne(String userId, String type, String expected, String actual, String ip, String ua) {
if (!Objects.equals(expected, actual)) throw new BusinessException(ApiResponseCode.CONFLICT, "授权文档版本已更新,请刷新后重新确认");
UserConsentState s = consentMapper.findByUserAndType(userId, type).orElseGet(UserConsentState::new);
s.setUserId(userId); s.setConsentType(type); s.setDocumentVersion(expected); s.setStatus("agreed"); s.setAgreedAt(Instant.now());
s.setWithdrawnAt(null); s.setActorUserId(userId); s.setIpAddress(ip); s.setUserAgent(ua);
if (s.getId() == null) { s.setId(UUID.randomUUID().toString()); consentMapper.insert(s); } else consentMapper.updateById(s);
}
private boolean evaluate(String userId) {
UserServiceState s = serviceStateMapper.findByUserId(userId).orElseGet(UserServiceState::new);
s.setUserId(userId); boolean active = consentMapper.countAgreed(userId) == required().size();
s.setServiceActive(active); s.setServiceBlockReason(active ? null : "consent_required"); s.setEvaluatedAt(Instant.now());
if (s.getCreatedAt() == null) serviceStateMapper.insert(s); else serviceStateMapper.updateById(s);
return active;
}
private void audit(String userId, String action, String resourceId, String ip, String ua) {
AuditLog log = new AuditLog(); log.setSubjectUserId(userId); log.setAction(action); log.setResourceType("service_consent");
log.setResourceId(resourceId); log.setIp(ip); log.setUserAgent(ua); auditLogMapper.insert(log);
}
private Set<String> required() { return Set.of(SERVICE_TERMS, PRIVACY_SAFETY); }
private BusinessException blocked(String message) { return new BusinessException(ApiResponseCode.FORBIDDEN, message); }
private Map<String, Object> document(String type, String version, UserConsentState state) {
return Map.of("consentType", type, "documentVersion", version, "status", state == null ? "required" : state.getStatus(), "agreed", state != null && "agreed".equals(state.getStatus()));
}
}
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
@@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* 活动反馈与互选控制器
@@ -30,8 +31,11 @@ import java.util.Map;
public class ActivityInterestController {
private final ActivityInterestService interestService;
private final RelationshipService relationshipService;
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
private final ActivityMutualConsentService consentService;
private final ActivityMapper activityMapper;
@org.springframework.beans.factory.annotation.Autowired
private ServiceConsentService serviceConsentService;
@GetMapping("/operator/activity-interest-summary")
@PreAuthorize("hasAuthority('activity.view')")
@@ -62,6 +66,7 @@ public class ActivityInterestController {
@PathVariable String id,
@RequestBody ActivityFeedbackDTO body) {
String userId = principal.id();
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
Map<String, Object> result = interestService.submitFeedback(
userId,
id,
@@ -82,6 +87,7 @@ public class ActivityInterestController {
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String id) {
String userId = principal.id();
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
return ApiResponse.ok(interestService.getMutualMatches(userId, id));
}
@@ -95,6 +101,7 @@ public class ActivityInterestController {
@PathVariable String id,
@PathVariable String partnerId) {
String userId = principal.id();
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
boolean mutual = interestService.hasMutualInterest(userId, partnerId, id);
Map<String, Object> result = new HashMap<>();
result.put("hasMutual", mutual);
@@ -105,7 +112,12 @@ public class ActivityInterestController {
/**
* 确认开启线上沟通(V1.1 Phase 08)
* POST /api/v1/activities/{id}/interests/confirm
* 双方确认后创建 Match Episode,不自动创建 Conversation
*
* <p><b>2026-09-23 UAT 走查修复(INV-014/015)</b>:此前本端点仅校验「双向互选」便直调
* {@code RelationshipService.confirmActivityInterest} —— 那是「建 Match」的底层原语
* (不做任何同意校验),于是**单方调用即可建 Match/会话**,绕过「双方各自显式确认」
* 的二阶段同意。现改为与 {@code /activities/{id}/mutual/*} 走**同一条**二阶段链路:
* 本端点只登记「我方确认」,只有双方都 accepted 时才建 Match/会话。
*/
@Idempotent
@PostMapping("/{id}/interests/confirm")
@@ -114,19 +126,23 @@ public class ActivityInterestController {
@PathVariable String id,
@RequestBody Map<String, String> body) {
String userId = principal.id();
if (serviceConsentService != null) serviceConsentService.requireActive(userId);
String partnerId = body.get("partnerId");
if (partnerId == null || partnerId.isBlank()) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"partnerId不能为空");
}
// 验证互选状态
// 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
"您与该用户未形成活动双向互选");
}
return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId));
// 二阶段同意:先幂等确保同意记录存在,再仅提交「我方确认」。
// 单方调用不再可能创建 Match(INV-014/015)。
consentService.createMutualConsent(id, userId, partnerId);
return ApiResponse.ok(consentService.confirmConnection(userId, id, partnerId));
}
/**
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 活动互选服务
@@ -69,7 +70,7 @@ public class ActivityInterestService {
// 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
}
@@ -32,6 +32,7 @@ import java.util.*;
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.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -407,7 +408,7 @@ public class ActivityService {
@Transactional
public ActivityRegistration cancelRegistration(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 签到服务
@@ -64,7 +65,7 @@ public class CheckinService {
*/
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
@Slf4j
@Service
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
@Transactional
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
// 回读权威状态(可能受并发另一方提交影响)
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("互选记录已不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
@@ -120,7 +122,7 @@ public class ActivityMutualConsentService {
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
} else {
@@ -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());
@@ -9,6 +9,14 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import com.danyang.match.platform.user.dto.PhoneVerificationRequest;
import com.danyang.match.platform.user.dto.UserRegistrationRequest;
import com.danyang.match.platform.user.service.PhoneVerificationService;
import com.danyang.match.platform.user.service.UserRegistrationService;
import com.danyang.match.platform.auth.service.PasswordAuthService;
import com.danyang.match.platform.user.dto.PasswordLoginRequest;
import com.danyang.match.platform.user.dto.InitialPasswordChangeRequest;
import java.util.Map;
@@ -18,6 +26,43 @@ import java.util.Map;
public class AuthController {
private final AuthService authService;
private final PhoneVerificationService phoneVerificationService;
private final UserRegistrationService userRegistrationService;
private final PasswordAuthService passwordAuthService;
@PostMapping("/password/login")
public ApiResponse<Map<String, Object>> passwordLogin(@Valid @RequestBody PasswordLoginRequest request) {
return ApiResponse.ok(passwordAuthService.login(request.phone(), request.password()));
}
@PostMapping("/password/change-initial")
@PreAuthorize("hasRole('USER')")
public ApiResponse<Void> changeInitialPassword(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody InitialPasswordChangeRequest request) {
passwordAuthService.changeInitialPassword(principal.id(), request.oldPassword(), request.newPassword());
return ApiResponse.ok();
}
@PostMapping("/register")
public ApiResponse<Map<String, Object>> register(@Valid @RequestBody UserRegistrationRequest request) {
return ApiResponse.ok(userRegistrationService.register(request));
}
@PostMapping("/phone/send-code")
public ApiResponse<Void> sendPhoneCode(@RequestBody Map<String, String> body) {
String phone = body.get("phone");
if (phone == null || !phone.matches("^1\\d{10}$")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号格式不正确");
}
phoneVerificationService.sendCode(phone);
return ApiResponse.ok();
}
@PostMapping("/phone/verify")
public ApiResponse<Map<String, String>> verifyPhone(@Valid @RequestBody PhoneVerificationRequest request) {
return ApiResponse.ok(Map.of("verificationTransaction",
phoneVerificationService.verify(request.phone(), request.code())));
}
/**
* 微信登录
@@ -44,10 +89,11 @@ public class AuthController {
public ApiResponse<Void> bindPhone(@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String phone = body.get("phone");
if (phone == null || phone.isBlank()) {
String transaction = body.get("verificationTransaction");
if (phone == null || phone.isBlank() || transaction == null || transaction.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
}
authService.bindPhone(principal.id(), phone);
authService.bindPhone(principal.id(), phone, transaction);
return ApiResponse.ok();
}
@@ -1,9 +1,15 @@
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.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;
@@ -17,14 +23,80 @@ import java.util.Map;
public class OperatorAuthController {
private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter;
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) {
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String username = body.get("username");
String password = body.get("password");
if (username == null || username.isBlank() || password == null || password.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
}
return ApiResponse.ok(operatorAuthService.login(username, password));
Map<String, Object> result = operatorAuthService.login(username, password);
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌。
// Cookie max-age 由 OperatorCookieWriter 按 JWT 有效期计算,控制器不引用 JWT 工具
// (控制器禁止解析/引用 JWT 工具,见 PrincipalInjectionContractTest 契约)。
cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"));
return ApiResponse.ok(result);
}
/**
* 刷新 Operator Token。
*
* <p>refresh token 优先取请求体(兼容旧前端/跨端口开发代理),缺省时回退到 HttpOnly Cookie
* {@code dy_op_rt}(生产同源场景,前端不持有 refresh token,抗 XSS)。
* 响应同时下发新的 access / refresh Cookie,实现刷新即轮换。
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refresh(@RequestBody(required = false) Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String refreshToken = (body != null) ? body.get("refreshToken") : null;
if (refreshToken == null || refreshToken.isBlank()) {
refreshToken = readCookie(request, OperatorCookieWriter.REFRESH_COOKIE);
}
if (refreshToken == null || refreshToken.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少 Refresh Token");
}
String operatorId = (body != null) ? body.get("operatorId") : null;
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"));
return ApiResponse.ok(result);
}
/**
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
* operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
* 仅用于黑名单登记。
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response,
@AuthenticationPrincipal OperatorPrincipal principal) {
String token = readBearer(request);
if (token != null && principal != null) {
operatorAuthService.logout(principal.id(), token);
}
cookieWriter.clearCookies(response);
return ApiResponse.ok();
}
private String readCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies == null) return null;
for (Cookie c : cookies) {
if (name.equals(c.getName())) return c.getValue();
}
return null;
}
private String readBearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
}
return readCookie(request, OperatorCookieWriter.ACCESS_COOKIE);
}
}
@@ -0,0 +1,64 @@
package com.danyang.match.platform.auth.cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
/**
* 管理端鉴权 Cookie 写入工具。
*
* <p>登录 / 刷新成功时下发 HttpOnly + SameSite=Lax 的 access / refresh Cookie;登出时清除。
* Cookie 为 HttpOnly,JS 无法读取,从根本上消除 XSS 读取令牌并外泄的风险(此前令牌存于
* 前端 localStorage,XSS 可读写并伪造权限)。
*
* <p>{@code Secure} 标志由 {@code app.auth-cookie-secure} 控制:生产(https)为 true;
* dev / docker(http)为 false,否则浏览器拒绝写入。
*
* <p>路径固定为 {@code /api/v1}(即后端 servlet context-path),确保只随同源 API 请求携带。
*/
@Component
public class OperatorCookieWriter {
public static final String ACCESS_COOKIE = "dy_op_at";
public static final String REFRESH_COOKIE = "dy_op_rt";
@Value("${app.auth-cookie-secure:true}")
private boolean secure;
// Cookie 生命周期与 JWT 自身有效期对齐(access 2h / refresh 14d),由配置驱动、不写死。
// 由本组件持有而非控制器持有:控制器不得直接引用 JwtTokenService
// (PrincipalInjectionContractTest 契约禁止控制器解析/引用 JWT 工具)。
@Value("${jwt.access-token-expiration-ms:7200000}")
private long accessExpirationMs;
@Value("${jwt.refresh-token-expiration-ms:1209600000}")
private long refreshExpirationMs;
/** 登录/刷新下发 Cookie,max-age 自动取 JWT 有效期(秒)。 */
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken) {
writeLoginCookies(response, accessToken, refreshToken, accessExpirationMs / 1000, refreshExpirationMs / 1000);
}
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
long accessMaxAgeSec, long refreshMaxAgeSec) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, refreshToken, refreshMaxAgeSec).toString());
}
public void clearCookies(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, "", 0).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, "", 0).toString());
}
private ResponseCookie build(String name, String value, long maxAgeSec) {
return ResponseCookie.from(name, value)
.httpOnly(true)
.secure(secure)
.sameSite("Lax")
.path("/api/v1")
.maxAge(maxAgeSec)
.build();
}
}
@@ -0,0 +1,26 @@
package com.danyang.match.platform.auth.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Component
@Profile("!test & !demo")
public class ConfiguredSmsVerificationProvider implements SmsVerificationProvider {
private final String provider;
public ConfiguredSmsVerificationProvider(
@Value("${sms.provider:}") String provider) {
this.provider = provider;
}
@Override
public void sendCode(String phone, String code) {
if (provider == null || provider.isBlank()) {
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务未配置");
}
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "短信服务适配器尚未配置完成");
}
}
@@ -0,0 +1,22 @@
package com.danyang.match.platform.auth.provider;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Profile({"test", "demo"})
public class MockSmsVerificationProvider implements SmsVerificationProvider {
private final Map<String, String> sentCodes = new ConcurrentHashMap<>();
@Override
public void sendCode(String phone, String code) {
sentCodes.put(phone, code);
}
public String lastCode(String phone) {
return sentCodes.get(phone);
}
}
@@ -13,10 +13,15 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Mock 微信登录 Provider(开发/测试环境使用)
* Mock 微信登录 Provider(开发/测试环境使用)。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
*/
@Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
@@ -24,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<>();
@@ -31,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,
@@ -0,0 +1,100 @@
package com.danyang.match.platform.auth.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 真实微信登录 Provider(生产环境使用)。
*
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>安全要点:
* <ul>
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
* <li>绝不把 appSecret / session_key 回传给前端。</li>
* </ul>
*/
@Component
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
public class RealWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
@Value("${wechat.app-id:}")
private String appId;
@Value("${wechat.app-secret:}")
private String appSecret;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, String> getCodeSession(String code) {
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
}
if (code == null || code.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
}
String url = CODE2SESSION_URL
+ "?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
+ "&grant_type=authorization_code";
try {
String resp = restTemplate.getForObject(url, String.class);
JsonNode node = objectMapper.readTree(resp);
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
}
String openid = node.has("openid") ? node.get("openid").asText() : null;
if (openid == null || openid.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
Map<String, String> result = new HashMap<>();
result.put("openid", openid);
result.put("unionid", unionid);
result.put("session_key", sessionKey);
result.put("appid", appId);
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Wechat] code2session 调用异常", e);
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
}
}
@Override
public boolean isMock() {
return false;
}
}
@@ -0,0 +1,5 @@
package com.danyang.match.platform.auth.provider;
public interface SmsVerificationProvider {
void sendCode(String phone, String code);
}
@@ -18,6 +18,7 @@ import com.danyang.match.platform.review.entity.ReviewTask;
import com.danyang.match.platform.review.mapper.ReviewTaskMapper;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.user.service.PhoneVerificationService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -30,6 +31,7 @@ import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -46,6 +48,7 @@ public class AuthService {
private final CapabilityService capabilityService;
private final UserProfileMapper userProfileMapper;
private final ReviewTaskMapper reviewTaskMapper;
private final PhoneVerificationService phoneVerificationService;
@Value("${jwt.access-token-expiration-ms:7200000}")
private long accessTokenExpireMs;
@@ -90,7 +93,9 @@ public class AuthService {
// 将 refreshToken 存入 Redis(用于撤销)
String refreshTokenKey = "auth:refresh_token:" + user.getId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
// 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
// 记录审计日志
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
@@ -108,10 +113,10 @@ public class AuthService {
* 绑定手机号
*/
@Transactional
public void bindPhone(String userId, String phoneCode) {
// TODO: Phase 03 对接真实短信验证码
public void bindPhone(String userId, String phoneCode, String verificationTransaction) {
phoneVerificationService.consumeTransaction(verificationTransaction, phoneCode);
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
// 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
@@ -121,6 +126,7 @@ public class AuthService {
});
user.setPhone(phoneCode);
user.setPhoneVerificationStatus("verified");
userMapper.updateById(user);
saveAuditLog(userId, null, "PHONE_BIND", "user", userId, null, null);
@@ -172,9 +178,9 @@ public class AuthService {
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
// 更新 Redis
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccessToken);
@@ -200,7 +206,7 @@ public class AuthService {
*/
public Map<String, Object> getMeStatus(String userId) {
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
Map<String, Object> status = new HashMap<>();
status.put("userId", user.getId());
@@ -25,6 +25,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -100,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());
@@ -120,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());
@@ -144,7 +149,7 @@ public class OperatorAuthService {
*/
public Map<String, Object> getMe(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
List<String> permissionCodes = permissions.stream()
@@ -153,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());
@@ -176,6 +182,11 @@ public class OperatorAuthService {
throw new UnauthorizedException("Refresh Token 无效");
}
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
if (operatorId == null || operatorId.isBlank()) {
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
}
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效");
@@ -193,6 +204,13 @@ public class OperatorAuthService {
result.put("accessToken", accessToken);
result.put("refreshToken", newRefreshToken);
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"));
result.put("permissions", operatorInfo.get("permissions"));
return result;
}
@@ -0,0 +1,77 @@
package com.danyang.match.platform.auth.service;
import com.danyang.match.platform.auth.token.JwtTokenService;
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.exception.GlobalExceptionHandler.UnauthorizedException;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserCredentialMapper;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.account.entity.UserCredential;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class PasswordAuthService {
private final UserMapper userMapper;
private final UserCredentialMapper credentialMapper;
private final PasswordEncoder passwordEncoder;
private final JwtTokenService jwtTokenService;
private final StringRedisTemplate redisTemplate;
@Transactional
public Map<String, Object> login(String phone, String password) {
User user = userMapper.findByPhone(phone)
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
if (!"verified".equals(user.getPhoneVerificationStatus())
|| !Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())) {
throw new UnauthorizedException("账号尚未完成手机号核验或已停用");
}
UserCredential credential = credentialMapper.findByUserId(user.getId())
.orElseThrow(() -> new UnauthorizedException("手机号或密码错误"));
if (credential.getRevokedAt() != null || !passwordEncoder.matches(password, credential.getPasswordHash())) {
throw new UnauthorizedException("手机号或密码错误");
}
credential.setFirstUsedAt(credential.getFirstUsedAt() == null ? Instant.now() : credential.getFirstUsedAt());
credentialMapper.updateById(credential);
String access = jwtTokenService.generateAccessToken(user.getId(), null);
String refresh = jwtTokenService.generateRefreshToken(user.getId());
redisTemplate.opsForValue().set("auth:refresh_token:" + user.getId(), refresh,
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
Map<String, Object> result = new HashMap<>();
result.put("accessToken", access);
result.put("refreshToken", refresh);
result.put("userId", user.getId());
result.put("mustChangePassword", Boolean.TRUE.equals(credential.getMustChangePassword()));
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
return result;
}
@Transactional
public void changeInitialPassword(String userId, String oldPassword, String newPassword) {
if (newPassword == null || newPassword.length() < 8) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "新密码至少 8 位");
}
UserCredential credential = credentialMapper.findByUserId(userId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "凭据不存在"));
if (!Boolean.TRUE.equals(credential.getMustChangePassword())
|| !passwordEncoder.matches(oldPassword, credential.getPasswordHash())) {
throw new UnauthorizedException("初始密码不正确或已完成修改");
}
credential.setPasswordHash(passwordEncoder.encode(newPassword));
credential.setCredentialVersion((credential.getCredentialVersion() == null ? 0 : credential.getCredentialVersion()) + 1);
credential.setMustChangePassword(false);
credential.setLastChangedAt(Instant.now());
credentialMapper.updateById(credential);
}
}
@@ -215,9 +215,21 @@ public class JwtTokenService {
}
/**
* 获取 Token 过期时间
* 获取 Access Token 过期时间(毫秒)
*/
public long getExpirationMs() {
return accessExpirationMs;
}
/**
* 获取 Refresh Token 过期时间(毫秒)。
*
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
*/
public long getRefreshExpirationMs() {
return refreshExpirationMs;
}
}
@@ -1,6 +1,7 @@
package com.danyang.match.platform.capability.service;
import com.danyang.match.platform.home.dto.HomeDTO;
import com.danyang.match.platform.account.service.ServiceConsentService;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
@@ -32,6 +33,7 @@ public class CapabilityService {
private final UserProfileMapper userProfileMapper;
private final UserIdentityMapper userIdentityMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final ServiceConsentService serviceConsentService;
/**
* 获取用户的能力开关集合
@@ -45,7 +47,8 @@ public class CapabilityService {
boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
&& isIdentityVerified(userId)
&& isProfileApproved(user);
&& isProfileApproved(user)
&& serviceConsentService.isActive(userId);
HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
caps.setCanReceiveRecommendations(baseEligible
@@ -77,10 +80,11 @@ public class CapabilityService {
/** 资料是否审核通过 */
public boolean isProfileApproved(User user) {
if (user == null) return false;
String status = userProfileMapper.findByUserId(user.getId())
.map(UserProfile::getProfileStatus).orElse(null);
return Constants.PROFILE_STATUS_APPROVED.equals(status)
|| Constants.PROFILE_STATUS_COMPLETED.equals(status);
return userProfileMapper.findByUserId(user.getId()).map(profile ->
Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
&& (profile.getPublishedProfileRevision() == null
|| java.util.Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision()))
).orElse(false);
}
/** 是否在认真寻找中 */
@@ -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);
}
}
@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* Conversation 端点
@@ -35,6 +36,8 @@ public class ConversationController {
private final ChatService chatService;
private final MatchMapper matchMapper;
@org.springframework.beans.factory.annotation.Autowired
private ServiceConsentService serviceConsentService;
/**
* 从 Match 创建/获取 Conversation
@@ -47,6 +50,7 @@ public class ConversationController {
public ApiResponse<ConversationDTO> fromMatch(@PathVariable String matchId,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
Match match = matchMapper.findById(matchId).orElse(null);
if (match == null) return ApiResponse.notFound();
// 权限:必须是 Match 参与者
@@ -68,6 +72,7 @@ public class ConversationController {
@RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int offset) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
// 游标契约(小程序):cursor + pageSize;偏移契约:offset + limit。两者兼容。
int off = cursor != null && !cursor.isBlank() ? parseIntOrDefault(cursor, 0) : Math.max(offset, 0);
int size = Math.min(Math.max(pageSize != null ? pageSize : limit, 1), MAX_PAGE_SIZE);
@@ -92,6 +97,7 @@ public class ConversationController {
public ApiResponse<ConversationDTO> detail(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
// 对象级鉴权:非会话成员一律 404 —— 否则可凭 conversationId 读到对方昵称、最后一条消息
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
ConversationDTO dto = chatService.getConversation(principal.id(), id);
@@ -107,6 +113,7 @@ public class ConversationController {
public ApiResponse<Map<String, Object>> unread(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
// 对象级鉴权:非成员不得读取他人会话未读
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
return ApiResponse.ok(Map.of("unreadCount", chatService.getUnreadCount(principal.id(), id)));
@@ -14,6 +14,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* Message 端点(HTTP REST + 幂等)
@@ -36,12 +37,15 @@ public class MessageController {
private static final int MAX_PAGE_SIZE = 100;
private final ChatService chatService;
@org.springframework.beans.factory.annotation.Autowired
private ServiceConsentService serviceConsentService;
@PostMapping("/{id}/messages")
public ApiResponse<MessageResponse> send(@PathVariable String id,
@RequestBody SendRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
// 幂等键:优先 clientMessageId,兼容小程序早期约定的 messageId
String idempotentKey = req.getClientMessageId() != null ? req.getClientMessageId() : req.getMessageId();
Message msg = chatService.sendText(id, principal.id(), req.getContent(), idempotentKey);
@@ -59,6 +63,7 @@ public class MessageController {
@RequestParam(defaultValue = "0") int offset,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
// 对象级鉴权:非会话成员一律 404(不区分"不存在"与"无权",避免探测他人会话)
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
int size = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
@@ -84,6 +89,7 @@ public class MessageController {
@RequestBody Map<String, String> body,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
// 对象级鉴权:非成员不得改写他人会话的已读状态
if (!chatService.isMember(id, principal.id())) return ApiResponse.notFound();
String lastReadId = body.get("lastReadMessageId");
@@ -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()));
}
}
@@ -18,6 +18,7 @@ import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* 婚恋咨询 / 婚前辅导 端点(Phase 13A)
@@ -56,6 +57,8 @@ public class CounselingController {
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$");
private final CounselingService counselingService;
@org.springframework.beans.factory.annotation.Autowired
private ServiceConsentService serviceConsentService;
// ==================== 用户端 ====================
@@ -64,6 +67,7 @@ public class CounselingController {
public ApiResponse<CounselingServiceRecord> apply(@RequestBody ApplyRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
return ApiResponse.ok(counselingService.apply(
principal.id(), // 修复:userId 来自认证主体,不取请求体
req.serviceType,
@@ -79,6 +83,7 @@ public class CounselingController {
public ApiResponse<List<CounselingServiceRecord>> myServices(@RequestParam(defaultValue = "20") int limit,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
List<CounselingServiceRecord> list = counselingService.myServices(principal.id(), limit);
// 列表场景剥离正文与 followups(INV-013)
list.forEach(CounselingService::sanitize);
@@ -98,6 +103,7 @@ public class CounselingController {
public ApiResponse<CounselingServiceRecord> detail(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
// 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛
// `invalid input syntax for type uuid` → 500 且泄漏数据库异常。
if (!UUID_PATTERN.matcher(id).matches()) {
@@ -114,6 +120,7 @@ public class CounselingController {
@RequestBody(required = false) CancelRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
return ApiResponse.ok(counselingService.cancel(id, principal.id(),
req != null ? req.reason : null));
}
@@ -124,6 +131,7 @@ public class CounselingController {
public ApiResponse<CounselingServiceRecord> confirmCompanion(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
return ApiResponse.ok(counselingService.confirmCompanion(id, principal.id()));
}
@@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* 找对象控制器
@@ -21,6 +22,7 @@ public class DiscoverController {
private final DiscoverService discoverService;
private final UserProfileMapper userProfileMapper;
private final ServiceConsentService serviceConsentService;
/**
* 搜索用户(临时筛选)
@@ -38,6 +40,7 @@ public class DiscoverController {
@RequestParam(defaultValue = "20") int pageSize) {
String userId = principal.id();
serviceConsentService.requireActive(userId);
Map<String, Object> myProfile = getMyProfile(userId);
String myGender = (String) myProfile.get("gender");
@@ -61,6 +64,7 @@ public class DiscoverController {
public ApiResponse<Map<String, Object>> getProfile(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String userId) {
serviceConsentService.requireActive(principal.id());
Map<String, Object> profile = discoverService.getPublicProfile(principal.id(), userId);
if (profile == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
@@ -40,6 +40,8 @@ public class DiscoverService {
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
wrapper.ne(UserProfile::getGender, myGender)
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
.isNotNull(UserProfile::getPublishedProfileRevision)
.apply("profile_revision = published_profile_revision")
.isNull(UserProfile::getDeletedAt);
// INV-004 搜索不可见:排除自己 + 双向拉黑者。
@@ -117,6 +119,11 @@ public class DiscoverService {
}
return userProfileMapper.findByUserId(userId)
.map(profile -> {
if (!Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus())
|| profile.getPublishedProfileRevision() == null
|| !Objects.equals(profile.getProfileRevision(), profile.getPublishedProfileRevision())) {
return null;
}
Map<String, Object> map = new HashMap<>();
map.put("userId", profile.getUserId());
map.put("displayName", profile.getDisplayName());
@@ -135,7 +142,7 @@ public class DiscoverService {
map.put("longTermDanyang", profile.getLongTermDanyang());
map.put("futureDanyang", profile.getFutureDanyang());
map.put("verified", true);
map.put("reviewed", Constants.PROFILE_STATUS_APPROVED.equals(profile.getProfileStatus()));
map.put("reviewed", true);
return map;
})
.orElse(null);
@@ -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,9 +9,9 @@ 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.*;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* Like 端点(canonical 路径,2026-09-14 版)
@@ -22,29 +20,29 @@ 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;
private final ServiceConsentService serviceConsentService;
@Idempotent
@PostMapping
public ApiResponse<LikeResponse> like(@RequestBody LikeRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
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));
}
/**
@@ -55,6 +53,7 @@ public class LikeController {
public ApiResponse<Void> unlike(@PathVariable String targetUserId,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
relationService.unlike(principal.id(), targetUserId);
return ApiResponse.ok();
}
@@ -62,20 +61,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 +76,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;
}
@@ -22,6 +22,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* Match 端点(canonical 路径,2026-09-14 版)
@@ -46,12 +47,16 @@ public class MatchController {
private final RelationService relationService;
private final RelationshipStatusService relationshipStatusService;
private final ChatService chatService;
private final ServiceConsentService serviceConsentService;
@GetMapping
public ApiResponse<CursorPage<MatchResponse>> list(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
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));
}
@@ -60,6 +65,7 @@ public class MatchController {
public ApiResponse<MatchResponse> get(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
Optional<Match> opt = matchMapper.findById(id);
if (opt.isEmpty()) return ApiResponse.notFound();
Match m = opt.get();
@@ -67,7 +73,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
@@ -75,6 +81,7 @@ public class MatchController {
public ApiResponse<Void> unmatch(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
Optional<Match> opt = matchMapper.findById(id);
if (opt.isEmpty()) return ApiResponse.notFound();
Match m = opt.get();
@@ -166,13 +173,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();
@@ -9,6 +9,7 @@ import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* Pass 端点(canonical 路径,补齐 2026-09-14 Like/Block/Match 模块)
@@ -28,12 +29,14 @@ import org.springframework.web.bind.annotation.*;
public class PassController {
private final RelationService relationService;
private final ServiceConsentService serviceConsentService;
@Idempotent
@PostMapping
public ApiResponse<Void> pass(@RequestBody PassRequest req,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
serviceConsentService.requireActive(principal.id());
if (req == null || req.targetUserId == null || req.targetUserId.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
}
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import com.danyang.match.platform.account.service.ServiceConsentService;
/**
* 推荐控制器
@@ -21,6 +22,7 @@ import java.util.Map;
public class RecommendationController {
private final RecommendationEngineService engineService;
private final ServiceConsentService serviceConsentService;
/**
* 获取今日推荐
@@ -30,6 +32,7 @@ public class RecommendationController {
public ApiResponse<List<RecommendationCandidateDTO>> getRecommendations(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
serviceConsentService.requireActive(userId);
return ApiResponse.ok(engineService.getDailyRecommendations(userId));
}
@@ -45,6 +48,7 @@ public class RecommendationController {
@PathVariable String targetUserId,
@RequestBody Map<String, String> body) {
String userId = principal.id();
serviceConsentService.requireActive(userId);
String action = body.get("action");
if (action == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "action不能为空");
@@ -62,6 +66,7 @@ public class RecommendationController {
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String targetUserId) {
String userId = principal.id();
serviceConsentService.requireActive(userId);
boolean eligible = engineService.isStillEligible(userId, targetUserId);
return ApiResponse.ok(Map.of("eligible", eligible));
}
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper;
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
private final MatchScoringService matchScoringService;
/**
* 获取每日推荐列表
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
return null;
}
// 2. 双向 Required 检查
// 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
if (targetPref == null) {
return null; // 目标用户未设置择偶条件,无法双向验证
}
// A → B Required 检查
if (!checkBidirectionalRequired(myPref, targetPref)) {
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
if (myProfile == null || targetProfile == null) {
return null; // 资料缺失则无法核验 Required,不假定满足
}
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
return null;
}
@@ -154,6 +159,9 @@ public class MatchEligibilityService {
if (targetProfile == null) return false;
String ps = targetProfile.getProfileStatus();
if (!Constants.PROFILE_STATUS_APPROVED.equals(ps) && !Constants.PROFILE_STATUS_COMPLETED.equals(ps)) return false;
if (Constants.PROFILE_STATUS_APPROVED.equals(ps)
&& targetProfile.getPublishedProfileRevision() != null
&& !java.util.Objects.equals(targetProfile.getProfileRevision(), targetProfile.getPublishedProfileRevision())) return false;
// V1.1: 本地资格核验(可配置是否强制)
if (matchConfig.isLocalEligibilityRequired()) {
@@ -175,36 +183,18 @@ public class MatchEligibilityService {
return "verified".equals(el.getVerificationStatus());
}
/**
* 双向 Required 检查
* Required 是 Hard Constraint,不可自动放宽
*/
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
return true;
}
// checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
// (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
/**
* 方向性评分(A对B的适配度)
* 基于 Preferred 维度计算 0-100 分
*/
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
double score = 0;
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() +
pref.getMaritalWeight() + pref.getChildrenWeight() +
pref.getEducationWeight() + pref.getHeightWeight() +
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
pref.getLifestyleWeight() + pref.getInterestWeight();
// TODO: 实现各维度评分逻辑
// 年龄匹配:在范围内得满分,超出范围按比例扣分
// 地区匹配:同地区得满分,不同地区按比例扣分
// ...
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
return (int)(Math.round(score / totalWeight * 100));
// 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
return userProfileMapper.findByUserId(toUserId)
.map(p -> matchScoringService.scoreDirectionally(pref, p))
.orElse(0.0);
}
/**
@@ -212,20 +202,21 @@ public class MatchEligibilityService {
*/
private List<String> generateReasons(String userId, String targetUserId,
UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>();
// TODO: 根据实际资料生成真实推荐理由
reasons.add("年龄符合双方择偶要求");
reasons.add("都长期生活在丹阳");
reasons.add("婚姻计划接近");
return reasons;
// 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
return userProfileMapper.findByUserId(targetUserId)
.map(p -> matchScoringService.reasons(myPref, p))
.orElseGet(ArrayList::new);
}
/**
* 获取已Pass的用户ID集合(冷却期内)
*/
private Set<String> getPassedUserIds(String userId, Instant since) {
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id
return Collections.emptySet();
// 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
.filter(r -> "passed".equals(r.getAction()))
.map(RecommendationRecord::getTargetUserId)
.collect(Collectors.toSet());
}
/**
@@ -240,14 +231,28 @@ public class MatchEligibilityService {
}
/**
* 构建候选池(简化版)
* TODO: Phase 06 后续接入数据库查询可用用户
* 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
* (2026-09-23:替换原 `return List.of()` 空实现。)
*/
private List<String> buildCandidatePool(String userId, UserPreference myPref,
Set<String> excludedIds, Set<String> alreadyRecommended) {
// TODO: 从数据库查询符合条件的用户
// 当前返回空列表,等待后续阶段完善
return List.of();
// 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
// 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProfile> wrapper =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
wrapper.isNotNull(UserProfile::getGender)
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
.isNull(UserProfile::getDeletedAt);
if (me != null && me.getGender() != null && !me.getGender().isBlank()) {
wrapper.ne(UserProfile::getGender, me.getGender());
}
return userProfileMapper.selectList(wrapper).stream()
.map(UserProfile::getUserId)
.filter(id -> !id.equals(userId))
.filter(id -> !excludedIds.contains(id))
.filter(id -> !alreadyRecommended.contains(id))
.collect(Collectors.toList());
}
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
@@ -0,0 +1,284 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 匹配评分 —— **全仓唯一事实源**。
*
* <p>背景:`RecommendationEngineService.calculateScore` 与
* `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
* 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
* `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
* 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
*
* <p>本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
*
* <p><b>规则(不新造常量)</b>:直接使用 {@link UserPreference} 上<b>已存在</b>的
* 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
* 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
* 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
*
* <p>返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
*/
@Service
public class MatchScoringService {
/** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
private static final List<String> EDUCATION_ORDER = List.of(
"primary", "junior_high", "senior_high", "vocational", "college", "bachelor", "master", "doctor");
/**
* 方向性评分:seeker 的择偶条件对 candidate 资料的适配度(0–100)。
*
* @param pref seeker 的择偶条件(可为 null → 无约束)
* @param candidate 候选人资料
*/
public double scoreDirectionally(UserPreference pref, UserProfile candidate) {
if (candidate == null) return 0;
if (pref == null) return 100;
double acc = 0; // 加权得分
double denom = 0; // 已设置条件的权重合计
// —— 年龄 ——(设置了 min/maxAge 才构成约束)
if (pref.getMinAge() != null && pref.getMaxAge() != null) {
double w = w(pref.getAgeWeight());
denom += w;
acc += w * rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge());
}
// —— 身高 ——
if (pref.getMinHeightCm() != null && pref.getMaxHeightCm() != null) {
double w = w(pref.getHeightWeight());
denom += w;
Integer h = candidate.getHeightCm();
acc += w * (h == null ? 0 : rangeRatio(h, pref.getMinHeightCm(), pref.getMaxHeightCm()));
}
// —— 地区(regionScope 为地区码数组)——
if (hasJsonArray(pref.getRegionScope())) {
double w = w(pref.getRegionWeight());
denom += w;
acc += w * (inJsonArray(pref.getRegionScope(), candidate.getRegionCode()) ? 1 : 0);
}
// —— 婚姻状况(maritalStatuses 数组)——
if (hasJsonArray(pref.getMaritalStatuses())) {
double w = w(pref.getMaritalWeight());
denom += w;
acc += w * (inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus()) ? 1 : 0);
}
// —— 子女(childrenAcceptance:any/accept/reject,兼容中文)——
if (notBlank(pref.getChildrenAcceptance())) {
double w = w(pref.getChildrenWeight());
denom += w;
acc += w * (childrenAcceptable(pref.getChildrenAcceptance(), candidate.getChildrenStatus()) ? 1 : 0);
}
// —— 学历(minEducationCode 为下限)——
if (notBlank(pref.getMinEducationCode())) {
double w = w(pref.getEducationWeight());
denom += w;
acc += w * (educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode()) ? 1 : 0);
}
// —— 婚恋规划(marriagePlanPreference 与 candidate.marriagePlan 一致)——
if (notBlank(pref.getMarriagePlanPreference())) {
double w = w(pref.getMarriagePlanWeight());
denom += w;
acc += w * (sameValue(pref.getMarriagePlanPreference(), candidate.getMarriagePlan()) ? 1 : 0);
}
// —— 定居意愿(settlementPreference 对照 candidate 长期/未来丹阳意向)——
if (notBlank(pref.getSettlementPreference())) {
double w = w(pref.getSettlementWeight());
denom += w;
boolean ok = sameValue(pref.getSettlementPreference(), candidate.getLongTermDanyang())
|| sameValue(pref.getSettlementPreference(), candidate.getFutureDanyang());
acc += w * (ok ? 1 : 0);
}
// —— 生活方式(吸烟 / 饮酒,取已设置者的均值)——
double lifeW = w(pref.getLifestyleWeight());
int lifeDims = 0;
double lifeHit = 0;
if (notBlank(pref.getSmokingPreference())) {
lifeDims++;
if (sameValue(pref.getSmokingPreference(), candidate.getSmokingStatus())) lifeHit++;
}
if (notBlank(pref.getDrinkingPreference())) {
lifeDims++;
if (sameValue(pref.getDrinkingPreference(), candidate.getDrinkingStatus())) lifeHit++;
}
if (lifeDims > 0) {
denom += lifeW;
acc += lifeW * (lifeHit / lifeDims);
}
// interestWeight:当前无对应择偶条件字段,不参与计分(保留权重以备扩展)。
if (denom == 0) return 100; // 用户未设置任何条件 → 无约束
return clamp(acc / denom * 100.0);
}
/**
* 生成中文推荐理由(基于**实际达成**的维度,不再是无条件套话)。
* 未达成任何具体维度时回退为「资料符合基本要求」。
*/
public List<String> reasons(UserPreference pref, UserProfile candidate) {
List<String> out = new ArrayList<>();
if (pref == null || candidate == null) return out;
if (pref.getMinAge() != null && pref.getMaxAge() != null
&& rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge()) > 0) {
out.add("年龄在您的要求范围内");
}
if (hasJsonArray(pref.getRegionScope()) && inJsonArray(pref.getRegionScope(), candidate.getRegionCode())) {
out.add("同在您期望的地区");
}
if (hasJsonArray(pref.getMaritalStatuses())
&& inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus())) {
out.add("婚姻状况符合您的期望");
}
if (notBlank(pref.getMinEducationCode())
&& educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode())) {
out.add("学历符合您的要求");
}
if (notBlank(pref.getAgePriority()) && "required".equals(pref.getAgePriority())) {
out.add("年龄为您设定的必要条件");
}
if (out.isEmpty()) out.add("资料符合基本要求");
return out;
}
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——唯一事实源。
*
* <p>任一方把某维度设为 `required`,则对方该维度<b>必须</b>落在要求内,否则整条候选被否。
* 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 Required 维度)。
*
* @return true 表示双向 Required 全部满足
*/
public boolean bidirectionalRequiredSatisfied(UserPreference myPref, UserProfile myProfile,
UserPreference targetPref, UserProfile targetProfile) {
if (myPref == null || myProfile == null || targetPref == null || targetProfile == null) return false;
// 年龄
if (isRequired(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
Integer candAge = age(targetProfile.getBirthDate());
if (candAge == null || candAge < myPref.getMinAge() || candAge > myPref.getMaxAge()) return false;
}
if (isRequired(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
Integer myAge = age(myProfile.getBirthDate());
if (myAge == null || myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) return false;
}
// 身高
if (isRequired(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
Integer h = targetProfile.getHeightCm();
if (h == null || h < myPref.getMinHeightCm() || h > myPref.getMaxHeightCm()) return false;
}
if (isRequired(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
Integer h = myProfile.getHeightCm();
if (h == null || h < targetPref.getMinHeightCm() || h > targetPref.getMaxHeightCm()) return false;
}
// 地区(Required 用 isJsonArray:空数组=自相矛盾的硬约束 → 拒绝;不可放宽为「无约束」)
if (isRequired(myPref.getRegionPriority()) && isJsonArray(myPref.getRegionScope())
&& !inJsonArray(myPref.getRegionScope(), targetProfile.getRegionCode())) return false;
if (isRequired(targetPref.getRegionPriority()) && isJsonArray(targetPref.getRegionScope())
&& !inJsonArray(targetPref.getRegionScope(), myProfile.getRegionCode())) return false;
// 婚姻状况
if (isRequired(myPref.getMaritalPriority()) && isJsonArray(myPref.getMaritalStatuses())
&& !inJsonArray(myPref.getMaritalStatuses(), targetProfile.getMaritalStatus())) return false;
if (isRequired(targetPref.getMaritalPriority()) && isJsonArray(targetPref.getMaritalStatuses())
&& !inJsonArray(targetPref.getMaritalStatuses(), myProfile.getMaritalStatus())) return false;
return true;
}
private static boolean isRequired(String priority) { return "required".equals(priority); }
// ==================== 私有工具 ====================
private static double w(Double v) { return v == null ? 0 : Math.max(0, v); }
private static boolean notBlank(String s) { return s != null && !s.isBlank(); }
private static Integer age(LocalDate birthDate) {
if (birthDate == null) return null;
return Period.between(birthDate, LocalDate.now()).getYears();
}
/** 在 [min,max] 内 → 1;否则按越界距离线性衰减(跨度越大衰减越慢),最低 0。 */
private static double rangeRatio(Integer value, int min, int max) {
if (value == null) return 0;
if (min > max) { int t = min; min = max; max = t; }
if (value >= min && value <= max) return 1.0;
int span = Math.max(1, max - min);
int distance = value < min ? (min - value) : (value - max);
return Math.max(0.0, 1.0 - (double) distance / span);
}
/** 评分用:仅当数组**非空**才算「用户表达了该条件」。空数组 = 未设置,不计入分母。 */
private static boolean hasJsonArray(JsonNode node) {
return node != null && node.isArray() && node.size() > 0;
}
/**
* Required 校验用:只要节点是数组即「用户声明了该硬约束」。
* 空数组是自相矛盾的声明(要求必填却无任何可接受值)→ 交由调用方判为不满足,
* 与「Required 不可自动放宽」(INV-001)一致;不可退化成「无约束」。
*/
private static boolean isJsonArray(JsonNode node) {
return node != null && node.isArray();
}
private static boolean inJsonArray(JsonNode node, String value) {
if (node == null || !node.isArray() || value == null) return false;
for (int i = 0; i < node.size(); i++) {
if (value.equals(node.get(i).asText())) return true;
}
return false;
}
/** 子女接受度:any/不限 → 恒满足;accept/可接受 → 有孩与否皆可;reject/不接受 → 仅无孩。 */
private static boolean childrenAcceptable(String acceptance, String childrenStatus) {
String a = acceptance == null ? "" : acceptance.trim();
boolean candidateHasChildren = "yes".equals(childrenStatus) || "has".equals(childrenStatus)
|| "有".equals(childrenStatus);
return switch (a) {
case "any", "不限" -> true;
case "accept", "可接受" -> true; // 接受带孩(且同样接受无孩)
case "reject", "不接受" -> !candidateHasChildren;
default -> true;
};
}
private static boolean educationAtLeast(String candidateCode, String minCode) {
int ci = EDUCATION_ORDER.indexOf(candidateCode);
int mi = EDUCATION_ORDER.indexOf(minCode);
if (mi < 0) return true; // 下限码不在白名单 → 不做约束
if (ci < 0) return false; // 候选人学历缺失/未知 → 无法核验,不假定满足
return ci >= mi;
}
/** 宽松相等:去空白、忽略大小写;两边都非空才比较。 */
private static boolean sameValue(String a, String b) {
if (!notBlank(a) || !notBlank(b)) return false;
return a.trim().equalsIgnoreCase(b.trim());
}
private static double clamp(double v) {
if (v < 0) return 0;
if (v > 100) return 100;
return v;
}
/** 便于单测/文档:常量键说明。 */
public static Map<String, String> weightKeys() {
return Map.of(
"age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
"children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
"marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
"settlement", "settlementWeight", "interest", "interestWeight");
}
}
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.TimeUnit;
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
private final UserMapper userMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final LocalEligibilityMapper localEligibilityMapper;
/** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
private final MatchScoringService matchScoringService;
/**
* 获取每日推荐列表
@@ -126,6 +127,8 @@ public class RecommendationEngineService {
// 检查审核状态
if (!Constants.PROFILE_STATUS_APPROVED.equals(target.getProfileStatus())) return false;
if (target.getPublishedProfileRevision() != null
&& !java.util.Objects.equals(target.getProfileRevision(), target.getPublishedProfileRevision())) return false;
// 检查是否已被当前用户Block
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
@@ -241,86 +244,16 @@ public class RecommendationEngineService {
return userProfileMapper.findByUserId(userId).orElse(null);
}
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——委托
* {@link MatchScoringService#bidirectionalRequiredSatisfied} 唯一事实源。
*
* <p>此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;2026-09-23 收敛为单点。
* 参数对映:本方法的 {@code candidate} 即事实源签名中的 {@code targetProfile}。
*/
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) {
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
// 1. 年龄 Required 检查
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
int candidateAge = calculateAge(candidate.getBirthDate());
if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) {
return false;
}
}
if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
int myAge = calculateAge(myProfile.getBirthDate());
if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) {
return false;
}
}
// 2. 身高 Required 检查
if ("required".equals(myPref.getHeightPriority())
&& myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
if (candidate.getHeightCm() == null
|| candidate.getHeightCm() < myPref.getMinHeightCm()
|| candidate.getHeightCm() > myPref.getMaxHeightCm()) {
return false;
}
}
if ("required".equals(targetPref.getHeightPriority())
&& targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
if (myProfile.getHeightCm() == null
|| myProfile.getHeightCm() < targetPref.getMinHeightCm()
|| myProfile.getHeightCm() > targetPref.getMaxHeightCm()) {
return false;
}
}
// 3. 地区 Required 检查
if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < myPref.getRegionScope().size(); i++) {
if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < targetPref.getRegionScope().size(); i++) {
if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
// 4. 婚姻状况 Required 检查
if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) {
if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) {
if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
return true;
return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
}
private boolean isParticipantEligible(String userId) {
@@ -341,23 +274,19 @@ public class RecommendationEngineService {
return true;
}
/**
* 方向性评分:委托 {@link MatchScoringService}(唯一事实源)。
*
* <p>此前为桩 `return 50.0`,而阈值默认 60 → `mutualScore < 60` 恒成立,
* 导致 `GET /recommendations` **恒返回空数组**(2026-09-23 UAT 走查实测)。
*/
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
// TODO: Phase 07 后续完善评分逻辑
return 50.0; // V1 简化:默认中间分
return matchScoringService.scoreDirectionally(pref, target);
}
private List<String> generateReasons(String userId, UserProfile candidate,
UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>();
// TODO: 基于真实资料生成推荐理由
reasons.add("年龄符合双方择偶要求");
reasons.add("都希望长期在丹阳生活");
return reasons;
}
private int calculateAge(LocalDate birthDate) {
if (birthDate == null) return 0;
return Period.between(birthDate, LocalDate.now()).getYears();
return matchScoringService.reasons(myPref, candidate);
}
private Set<String> getPassedUserIds(String userId, Instant since) {
@@ -383,7 +312,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 追溯历史推荐依据的规则版本)
@@ -415,6 +348,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)
*/

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