Author SHA1 Message Date
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
WorkBuddy 631e3000d3 管理端:设计系统 v2 收敛 + 修复漏斗卡恒为 0 的响应式缺陷
## 一、设计系统收敛

- theme/index.css 重写为 token 底座:品牌三级、语义五组、文字四级、
  边框/背景/圆角/阴影/动效/热区/排版刻度
- 修复 Element Plus 派生色阶残留:--el-color-*-light-N 是 SCSS 编译期静态生成,
  只覆盖基色不会带动它们 —— 数据统计页实测 133 处元素仍在用 EP 默认调色板
  (其中主色蓝 8 处)。已按 mix(#fff,base,N*10%) / mix(#000,base,20%) 补全全部档位
- 新增 src/theme/tokens.ts:模板里 :style 需拼 8 位 hex(color + '15'),用不了 var(),
  故以 TS 常量镜像色值;守卫会双向校验它与 index.css 同名取值一致
- 24 个 .vue 收敛:硬编码色值 97 处/67 种 → 0;越界字号 88 处(最低 9px)→ 0;
  非法字重 650×6 → 0;触摸目标 17 处 <44px → 全部 ≥44px
- 对比度:text-tertiary 3.54:1、warning 2.33:1、coral 作文字 3.14:1 → 全部 ≥4.5:1
- 新增守卫 ui-tests/admin-design-tokens.mjs(含 WCAG 对比度实算)

## 二、修复既有功能性缺陷:数据统计页漏斗卡与安全卡恒显示 0

- 现象:同一张卡片里,上方漏斗数字全是 0,下方汇总表却是 12846/9217/8462/3812,
  界面自相矛盾
- 根因:funnelStages / matchStages / safetyCards 是普通数组(const x = [...]),
  loadFunnel() 直接改元素属性,模板读的却是同一个数组 —— 非响应式写入不触发重渲染。
  同页凡走 ref 的值(汇总表、V11 区块)全部正常,规律清晰
- 修法(按根因):三个集合改为 computed() 从既有 ref 派生,卡片与汇总表因此同源,
  结构上不可能再出现「同一份数据的两处展示对不上」;另把 loadFunnel() 内遮蔽
  颜色助手的局部变量 const token 改名 authToken
- 该缺陷无 TS/lint/构建报错,前 18 个守卫全绿也抓不到 —— 只有真跑起来看渲染结果才暴露
- 新增守卫 ui-tests/admin-reactivity-contracts.mjs:
  ①非响应式状态(普通声明 + 被当状态写 + 出现在模板里;未被写过的静态常量清单合法)
  ②数据统计页同源契约 ③颜色助手遮蔽

## 验证

- UI 契约门禁 20/20 PASS(MIN_GUARDS 18→20)
- 负向注入 3 例全部 FAIL(exit 1),还原后复跑 exit 0(证明守卫有判别力)
- vue-tsc --noEmit exit 0
- vite build 成功(17.74s)
- 渲染态实测(Edge 无头 + CDP 读渲染后 DOM,同一 mock、同一路由,只差代码):
  修复前漏斗卡/安全卡全 0、比率全 '-' → 修复后 12846/9217/8462/3812/
  48210/51236/7420/4380/1926/1284,安全卡 17/214/23/9

交付物:artifacts/admin-design-v2-preview.html
2026-09-23 01:06:57 +08:00
WorkBuddy 269adf1fed 小程序端:设计系统收敛与排版层级修复
问题:88 个 wxss 里散落大量硬编码颜色/字号/字重,无法统一调整;
部分正文对比度不达 WCAG AA(4.5:1);组件触摸目标偏小。

改动:
- 硬编码颜色收敛到语义 token(颜色统一走 design token,不再散落字面值)
- 修正全站字号阶梯:正文 ≥12px、角标 ≥11px;字重只用 400/500/600/700
- 组件触摸目标提升到 ≥44px,并补交互反馈
- 首页补氛围层次、强化排版层级

验证(本轮实测):
- UI 契约门禁 18/18 PASS(含 wxml 结构、wxss 组件选择器、token 显式值断言)
- 声明级体检 88/88 通过(全部 wxss 声明合法)

交付物:artifacts/miniapp-design-v2-preview.html
2026-09-23 01:06:46 +08:00
WorkBuddy 825fd41ee4 fix(auth): 修复刷新令牌链路三层断点,并把 121 处裸异常收敛为携带 HTTP 语义的异常
用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」这条路整条是断的,
且断在三个互不相干的地方 —— 每修好一层,下一层才显形:

1) 前端漏传 userId → /auth/refresh 直接 400(上一笔 63c18d8 已修)
2) 后端把 refresh token 交给 validateToken 校验,而那是用 access 密钥验签的,
   refresh token 由 refresh 密钥签发 → 必然失败。这是本次的核心修复。
3) 失败被抛成裸 RuntimeException → HTTP 500,客户端当作服务端故障重试。
4) 换发出来的新 refresh token 与旧 token **逐字节相同** —— JWT 的 iat 只有秒级精度、
   且未设 jti,同一用户同一秒内签发的令牌完全一致,「刷新即轮换、旧令牌作废」
   这条安全属性形同不存在(Redis 比对旧值自然相等)。三处签发补 UUID jti。

第 2 层此前被两层掩盖:前端漏传 userId 使请求先 400 走不到验签;且全仓
没有任何 /auth/refresh 用例。第 4 层则只在「同一秒内签发两次」时才暴露,
是新增 E2E 用例(换发后立刻用旧令牌再刷一次)把它逼出来的。

同时修复审计中发现的同族问题:
- AnalyticsService 用的是 shared.exception.BusinessException,而全局处理器只映射
  嵌套类 GlobalExceptionHandler.BusinessException —— 该异常无任何 handler,直落 500。
  现让旧类继承被映射的类,从结构上杜绝「未映射的业务异常静默 500」。
- 小程序 authStore 读 result.needPhone,后端返回 isNewUser → `!undefined === true`,
  每个用户一登录就被标记成已绑手机号。同族的第三个字段名不一致缺陷。
- 小程序 api.js 的 401 分支会无条件触发 onSessionExpired→refresh;后端把刷新失败
  语义修正为 401 后,这条路径会自我递归。补 /auth/* 守卫。
- 登录页只认 instanceof Error,把服务端文案(如「账号已被封禁」)丢了。

后端异常收敛(121 处 → 0):
- 业务拒绝改抛 BusinessException + ApiResponseCode(404 不存在 / 403 无权与状态不允许 /
  409 冲突重复 / 400 参数非法),文案一字未改,避免测试与前端文案契约漂移
- 真正的服务端故障(加解密、文件头、上传、签名、实名 Provider)改为显式
  ServerErrorException:仍是 500,但意图明确、ERROR 记全栈、不向客户端泄露内部细节
- 用带显式映射表的脚本执行(fail-closed,遇未知文案即中止),不用启发式规则

守卫(先有判别力再谈全绿):
- 新增 JwtTokenServiceRefreshTokenTest:钉住「refresh 用 refresh 密钥验签」与
  「两类令牌不得互相顶替」(含两把密钥被配成同值时的 type 声明兜底),
  以及「同一秒内签发的两个令牌必须逐字节不同」
- 新增 RuntimeExceptionThrowGuardTest:扫源码断言 main 零裸抛(无白名单副本)
- GlobalExceptionHandlerTest 补三条:别名的实例必须按其业务码回 403 而非 500、
  无法识别的字符串码 fail-closed 到 500(不得退化成无信息的 400)、
  以及继承关系守卫(把别名类改回 RuntimeException 会静默恢复该缺陷,只有它能咬住)
- 新增 tests/e2e/auth_refresh_test.py:唯一覆盖 /auth/refresh 的用例,
  断言换发的 accessToken 真能过鉴权、旧令牌轮换失效、无效凭据 401 而非 500
- http_semantics_test.py 补「Service 层拒绝必须是 4xx 而非 5xx」
- miniapp-auth-contracts.mjs 补 isNewUser 映射(双向)与 /auth/* 401 不递归

验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
  JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
  注入前后工作区 SHA256 逐字节一致(零污染)
2026-09-22 21:41:30 +08:00
WorkBuddy 2162612892 fix(service-appointment): 状态冲突/越权改为 4xx,修并发下 500 与弱断言漏判
CI E2E (live stack) 在 main@63c18d8 上失败:test_appointment_concurrent_safety
并发「用户取消」与「运营确认」得到 [200, 500]。根因是 ServiceAppointmentService
四处状态/权限守卫抛裸 RuntimeException,GlobalExceptionHandler 无对应映射,
只能兜底成 HTTP 500 —— 「预约不存在」「状态不允许」「越权」被报成服务端故障。

改动:
- ServiceAppointmentService:四处守卫改用 BusinessException + ApiResponseCode
  (不存在→404 NOT_FOUND,越权→403 FORBIDDEN,状态冲突→409 REDUNDANT_OPERATION)。
- ReferralService.confirmReferral:同一家族的四条守卫同样收敛(预约/报名的
  不存在、越权、非转介类型)。
- tests/e2e/service_appointment_test.py:把 3 处 `!= 200` 的弱断言收紧为 4xx。
  弱断言会把 500 当成「越权被拒」放行 —— 这正是该缺陷长期未被发现的原因,
  此前只有并发用例的 `< 500` 断言咬了它一口。
- ServiceAppointmentOwnerGuardTest:新增「终态不可再确认/取消」「预约不存在」
  用例,并把越权断言从 RuntimeException 收紧到具体业务码(403)。

验证:
- 定向后端测试 35 passed(ServiceAppointment* / MatchmakerReferralPhase13Test)。
- 本机真实栈(重建 backend 镜像):service_appointment_test.py 8/8、全量 E2E 65 passed。
- 负向注入(把守卫改回裸 RuntimeException):2 例精准变红,工作区哈希前后一致。
2026-09-22 19:36:59 +08:00
WorkBuddy 63c18d89c2 fix(miniapp): 修复全站 401 —— 认证链字段名不一致 + 首页 onRetry 缺失
根因(读码 + 本机后端实测坐实):
- 后端登录/刷新返回 accessToken,前端 authStore 却读 result.token →
  accessToken 从未落库 → 每个请求都不带 Authorization → 全站 401(首页无法加载)。
- /auth/refresh 少传 userId:后端以 userId 定位 Redis refresh token,
  缺失即 BAD_REQUEST → HTTP 400(实测 {refreshToken} 体 → 400)。
- 首页 index.wxml 绑定 bind:retry="onRetry"(ui-error-state 触发 retry 事件),
  但 index.ts 无该方法 → 控制台报错、错误页「重新加载」失效。
- app.globalData.token 从未赋值,多页直读它拼 Authorization → Bearer undefined。

修复:
- authStore:登录/刷新改用 result.accessToken;刷新传 this.state.userId。
- authService.refresh(userId):发送 { userId, refreshToken }。
- pages/index:新增 onRetry;onLoad 订阅 authStore(expired → 回登录引导;
  刷新成功且停在错误态 → 自动重载);onUnload 退订。
- utils/api:saveToken/clearToken/getToken 同步 app.globalData.token,兼容旧页面。

守卫补强:
- ui-tests/miniapp-auth-contracts.mjs 原仅覆盖 hydrate,对字段名缺陷零覆盖。
  新增行为断言:登录后 storage.accessToken 必须等于后端 accessToken;
  刷新请求体必须含 userId + refreshToken 并持久化新 accessToken。
  已双向负向注入验证判别力(改回 result.token / 去掉 userId 均 exit 1)。

验证:
- tsc --noEmit -p miniapp/tsconfig.json → exit 0,零错误。
- scripts/run-ui-contracts.mjs → 18/18 PASS。
- 本机 8085 /auth/refresh 三态实测:{} → 400;{refreshToken} → 400;
  {userId,refreshToken} → 500(进入服务校验,非 400)。
- 未验证:开发者工具内实际登录→首页链路(本机无法驱动 devtools,待用户复测)。
2026-09-22 19:08:16 +08:00
WorkBuddy 79ed89c89f Merge seaweedfs-pilot: 对象存储从 MinIO 迁移到 SeaweedFS 4.47,并修复 presigned URL 直传 host 断链
- 对象存储后端由 MinIO 换成 SeaweedFS 4.47(path-style 寻址,filer 预建 public/private 桶)
- 新增 storage-init 容器幂等建桶;三套 compose(dev/staging/prod)相应改造
- 修复 presigned URL:签发改用「对外可达 endpoint」的独立 AmazonS3 客户端,避免客户端直传被 403 断链
- 新增对象存储真实链路探针(判据,非 pytest 用例)

PR #1,GitHub Actions 7/7 全绿(含真实栈 E2E 65 passed)。
2026-09-22 17:23:13 +08:00
WorkBuddy dc0a62d4c5 fix(storage): presigned URL 改用对外可达 host,修复客户端直传断链
SeaweedFS 换存储后 MINIO_ENDPOINT 变为 seaweedfs:8333(容器名),
PhotoService 生成的 presigned URL 把该 host 直接下发给客户端,
客户端不在 docker 网络内无法解析 → 直传/直链全断。

- S3Config:新增 amazonS3Signer Bean,指向 object-storage.public-endpoint
  (缺省回退内网 endpoint,向后兼容)。独立签名端是因为 SigV4 签名覆盖
  host 头,事后改 URL 字符串会让签名失效返 403;独立客户端让签名 host
  与客户端实际命中的 host 天然一致。
- PhotoService:去掉 @RequiredArgsConstructor,改显式 @Qualifier 构造器;
  getUploadToken / getSignedUrl / getSignedUrlForAuthorizedReview 三处
  presigned 生成改用 amazonS3Signer;putObject 服务端上传仍走内网 amazonS3。
- application-docker.yml / application-dev.yml:public-endpoint 读
  MINIO_PUBLIC_ENDPOINT(缺省回退 MINIO_ENDPOINT)。
- 三套 compose:注入 MINIO_PUBLIC_ENDPOINT
  (dev localhost:8333 / staging localhost:8334 / prod 留作单一旋钮)。
- 测试:PhotoServiceTest 新增嵌套 PresignedUrlPublicHostTest,用真实
  AmazonS3 客户端(仅本地签名不联网)断言下发 URL host 为对外地址、
  不含 seaweedfs 容器名;同步补齐两处 5→6 参构造调用。

验证:本机 65 E2E 全绿 + storage_link_probe path-style 全通 +
PhotoServiceTest/PhotoUploadValidationTest/PhotoLimitGuardTest 19 例单测全绿。

顺带:backend/maven-settings.xml 的 Central 兜底由投毒的 repo1.maven.org
改为可达的 repo.maven.apache.org,使本机镜像构建可走 aliyun 主源。
2026-09-22 17:10:40 +08:00
WorkBuddy 797b1c46cf refactor(storage): 对象存储从 MinIO 换成 SeaweedFS(钉 4.47)
动机:MinIO 的 GitHub 仓库已于 2026-02 归档,不再产出安全补丁,已累积多个
CVE 且不修;本项目存身份证 / 实名材料 / 用户照片,无人修 CVE 才是真正驱动因素
(Docker Hub 下架只是表象,quay.io 仍在提供)。

变更:
- 三套 compose(dev / staging / prod)的 minio 服务替换为 seaweedfs 4.47:
  覆盖 entrypoint 渲染 -s3.config 注入 identity(S3 网关默认不鉴权,必须显式开启),
  -volume.max=100 解除第二个 bucket 写入时的 InternalError。
- 新增一次性 storage-init 容器,经 filer 接口预建 public / private 桶
  (SeaweedFS PutObject 到不存在的 bucket 直接失败;全仓库原无任何 createBucket)。
- S3Config 显式开启 path-style:AWS SDK v1 默认 virtual-host 会把 bucket 拼进
  主机名,容器名在 DNS 中不存在必然失败(本机有 http_proxy 时易误判为 502 拒绝)。
- ci.yml:镜像校验与起栈步骤改为 seaweedfs + storage-init。
- 运维脚本 health-check.sh / run-uat.sh 的 MinIO 探针改为 SeaweedFS
  (S3 网关开启鉴权后匿名根请求返回 403,200/403 均算存活)。

注:path-style 与建桶属换存储的必要项;presigned URL 的 host 须对外可达这一
既有缺陷(原 minio:9000 直发客户端不可达)留待下一轮「改缺陷」处理。
2026-09-22 14:26:48 +08:00
WorkBuddy aa1114338e 新增对象存储真实链路探针(判据,非 pytest 用例)
背景:E2E 全套 65 例对对象存储零覆盖,而 generatePresignedUrl 只是本地
签名、不访问存储,因此"签名接口返回 200"无法证明对象真能存进去。

实证(2026-09-22):MinIO 的 /data 下只有 .minio.sys,无任何 bucket;
mc cp 到 private 报 `Bucket 'private' does not exist`。全仓库零建桶代码、
零初始化脚本 —— 真实上传链路在当前配置下必然失败,而所有测试仍全绿。

本探针覆盖 PhotoService 的真实路径并要求字节级一致:服务端直传、
presigned PUT(客户端直传)、presigned GET,并同时验证 path / virtual-host
两种 addressing style。支持 --no-create-bucket 负向模式,用于证明判据
有判别力而非恒绿。

不在 CI 运行:依赖 boto3 + requests,不进 tests/e2e/requirements.txt。
2026-09-22 12:26:37 +08:00
WorkBuddy 7186a6b694 fix(infra): minio 镜像钉回确定版本(依据 CI 实测输出)
上一轮为让 CI 先跑通,minio 临时改用 quay.io 的 latest 滚动 tag,并在 e2e job 里
加了一步打印实际版本。该步骤的实测输出为:

  minio version RELEASE.2025-09-07T16-13-09Z (commit-id=07c3a429bfed…)

即 latest 当前正指向这个版本 —— 与本仓库原先在 Docker Hub 上钉的 tag 完全一致。
据此把三处 compose(本地 / staging / prod)统一钉死为
quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z,恢复「可复现」这一原则:
日后即使 quay 上的 latest 滚动到别的版本,本配置也不受影响。

保留 e2e 中「校验钉版镜像可拉取并打印版本」这一步 —— 镜像拉取失败在 compose up
里会埋进几十行输出,独立一步能在起栈前秒级暴露「上游镜像下架」这类外部依赖失效。

上一轮结果:7 个 job 全部 success,GitHub Actions 首次跑绿。
2026-09-22 11:16:37 +08:00
WorkBuddy 4c98170107 fix(infra): minio 镜像源从 Docker Hub 迁到 quay.io(上游已下架,本地/CI/生产均受影响)
E2E job 日志:
  pull access denied for minio/minio, repository does not exist

问题不在 tag 本身,而在**整个仓库已从 Docker Hub 移除**:MinIO 自 2025-10 起停止
发布容器镜像(改为仅源码分发),其 GitHub 仓库于 2026-02 归档,Docker Hub 上
minio/minio 与 minio/mc 现均返回 404;quay.io/minio/minio 仍在提供镜像。

本机此前是用 quay.io 的镜像重打 tag 顶替才跑通的(见 known-risks KR-01),
但那只是**本地绕过**、没有同步回仓库,于是「本地能起栈、CI 拉不到」。
现在三处 compose(本地 / staging / prod)统一改为 quay.io,
让本地、CI 与生产拉取同一个镜像。

这不只是 CI 问题:prod 与 staging 同样引用了已下架的仓库,是真实的部署阻塞
—— 也就是说现有配置下,新环境的生产部署根本起不来。

已知偏离:使用 latest 而非钉版。本机无法访问 quay 的 tag API 去核实某个具体
RELEASE tag 是否仍存在(quay 上的镜像已冻结,最后发布为 2025-09)。
e2e job 新增一步打印实际拉取到的版本,日后即可据此钉版、恢复可复现性。
长期风险:quay 上的 MinIO 镜像同样可能下架,届时需自建镜像或迁移到其他
S3 兼容存储 —— 「依赖第三方镜像」本身就是一条外部依赖风险。

上一轮修复已见效:backend / admin-web / ui-contracts / miniapp /
test-shell-guard / migration 六个 job 均通过,本轮仅剩 E2E。
2026-09-22 11:04:01 +08:00
WorkBuddy b624c43432 fix(ci): 依据 Actions 真实日志修复三处失败(runner 无 pnpm / 门禁缺依赖 / 生产配置未入库)
上一轮已把 CI 跑起来并读到真实结果:7 个 job 中 Mini Program 与 Test Shell Guard
通过,Admin Web、Backend、UI Contracts 三个失败,migration 与 e2e 因依赖 backend
被跳过。本轮全部依据 Actions 原始日志定位,而不是本地推测 —— 三者都不是业务代码问题。

1. Admin Web Build:runner 已不再预装 pnpm
   日志:Unable to locate executable file: pnpm
   runner 镜像(ubuntu-24.04 / 2.337.0)的 PATH 里没有 pnpm,而 setup-node 的
   cache: 'pnpm' 在 restore 阶段就需要调用 pnpm 可执行文件,于是 job 在第一步失败、
   后续步骤全部 skipped。同批的 miniapp job 没配 cache 所以正常通过,正好反向印证。
   改为先用 corepack 启用并钉住 pnpm@9.15.9,store 缓存改由 actions/cache 手动配置
   (此时 pnpm 才存在于 PATH)。

2. UI Contracts:门禁依赖没装
   日志:Cannot find module '.../miniapp/node_modules/typescript/lib/typescript.js'
   phase04 守卫会用 TypeScript 编译器 API 解析小程序源码,但本 job 此前不装依赖。
   本地能跑通只因为工作区早已装过;CI 的每个 job 都在独立 runner 上,
   miniapp job 安装的依赖不会共享过来。补上 npm install,并修正 workflow 中
   「零 npm 依赖、故无需装依赖」的错误注释 —— 与实际不符的声明本身就是缺陷来源。

3. Backend:断言了一个永远不会入库的文件
   日志:DeploymentTests.testDockerComposeProdExists 期望 true 实际 false
   infrastructure/docker-compose.prod.yml 被 .gitignore 列在
   「Secrets and certificates」段而排除出版本控制,但这是**误分类**:
   该文件不含任何明文密钥,敏感值全部走 ${VAR} 注入、db 密码走 Docker secrets,
   文件头注释本身也写着「敏感信息通过 .env 文件注入,不进 Git」。
   后果是该测试「本地恒绿(文件在工作区)、CI 恒红(checkout 拿不到)」,
   且从本地永远发现不了。现修正 .gitignore 把它纳入版本控制;
   真正的密钥仍由 secrets/、certs/、backups/ 等规则挡住。

修复后,此前因 backend 失败而被跳过的 migration 与 e2e 两个 job 也会真正跑起来。
2026-09-22 10:54:24 +08:00
WorkBuddy c6b48d5d91 fix(ci): 修复三处必致 CI 变红的工程缺陷
排查 Phase 23 收尾项「 GitHub Actions 从未跑绿」时,逐个在本地复刻 CI 的每个 job,
发现三个与业务代码无关、但足以让流水线恒红的缺陷。

1. 依赖锁文件被排除出版本控制(admin-web job 必红)
   .gitignore 原先同时忽略 pnpm-lock.yaml 与 package-lock.json,导致
   CI checkout 后 admin-web 根本拿不到 lockfile。pnpm install --frozen-lockfile
   在缺 lockfile 时不是降级重试、而是直接报错,因此该 job 必然失败;
   miniapp 的 npm install 也因失去锁文件而版本浮动。
   现在按包管理器逐一放行:admin-web 走 pnpm、miniapp 走 npm。
   admin-web/package-lock.json 仍保持忽略 —— 同一目录并存两把锁会让两种包管理器
   各按自己的锁安装,漂移时彼此看不出问题。

2. admin-web/pnpm-workspace.yaml 内容残缺(任何环境下都装不上依赖)
   该文件原先只有两行 allowBuilds(esbuild / vue-demi),既无 packages 声明也未写取值。
   pnpm 一旦检测到工作目录下存在 pnpm-workspace.yaml 就强制进入 workspace 模式,
   随后因读不到 packages 而报 packages field missing or empty。
   本仓库 admin-web 只有一个包,现显式声明单包 workspace,并补出 esbuild 与
   vue-demi 的 build 白名单 —— 二者 postinstall 是必需步骤:
   esbuild 下载平台原生二进制、vue-demi 生成 Vue 版本桥接。

3. CI 使用 runner 预装的 pnpm,版本会漂移
   pnpm 9 与 10 对「是否默认放行依赖 build 脚本」的语义相反,而上述
   allowBuilds / onlyBuiltDependencies 恰好是版本敏感配置项。 runner 镜像升级后
   会出现 install 能过、build 变红、且错误信息不指向真正原因的情况。
   现钉住 pnpm@9.15.9,使 CI 语义不再随 runner 变化。

本轮同时在本地复刻了 CI 的其余 job:
- migration job 的迁移序列校验:51 个迁移文件,版本 1 至 51 连续且唯一,通过。
- test-shell-guard:18 个 Python 文件未发现 skip / pass 空壳,通过。
- miniapp job 的 npm install 加 tsc --noEmit:通过。
2026-09-22 09:40:44 +08:00
WorkBuddy 8bd1ab804a fix(organization): 修复创建机构审计缺操作人;补全机构修复的验证并部署 Docker 测试环境
一、机构审计归属缺陷(本轮活栈验收中新发现)
- 现象:audit_logs 中 CREATE_ORGANIZATION 的 operator_id 7/7 为 NULL,
  而 DISABLE/ENABLE_ORGANIZATION 均正确记录操作人。审计不可归属 = 无效审计。
- 根因:OrganizationController.create 持有 @AuthenticationPrincipal OperatorPrincipal,
  却只把 params 传给 Service,未透传 principal.id() → saveAuditLog(null, ...)。
- 修复:createOrganization(params, operatorId) 签名透传;新增测试断言
  createOrganizationRecordsOperatorInAuditLog 长期守卫。
- 复验:新写入的 7 条 CREATE 审计全部带 operator_id,旧 14 条仍为 NULL(前后对照清晰)。

二、补全 rc.22 的验证缺口(此前记为「已修复待验证」)
- 全量回归 757 / 0 / 0 / 3,BUILD SUCCESS,108 个测试类,6:44(基线 741 + 新增 16)。
- 负向注入:ORG_STATUS_ACTIVE 改回 "normal" → 16 例变 3 失败 + 7 错误
  (守卫三条断言各自命中 + 服务侧 7 例被 H2 CHK_ORG_STATUS 拦下)。
  注入采用「仓库只读挂载 + 容器内副本改写」,工作区哈希前后一致,源码零污染。
- 活栈实测(Docker 全栈):创建/停用/启用均 2xx、非法输入 400、不存在 404、全程零 5xx(15/15)。
- 对已部署栈 pytest tests/e2e → 65 passed。

三、Docker 测试环境部署与工具链固化
- 测试镜像 danyang-match-backend-test 与命名卷 danyang-m2-cache 因 Docker Desktop 重装丢失,
  构建定义此前是临时产物 → 落盘为仓库资产 infrastructure/test-toolchain/Dockerfile。
- 本地栈统一使用 docker compose -p danyang(原项目名 infra 与另一项目撞名,
  其 --remove-orphans 会误删本项目容器;已确认邻项目数据无丢失,见 known-risks KR-09)。

四、UI 门禁脆弱性修复
- ui-document-baseline-contracts.mjs 解析 docker-compose.staging.yml 时报假红
  (「守卫自身失效」);真因是该文件为 CRLF 而守卫正则要求 \n 紧随 container_name 之后
  (prod.yml 是 LF 所以此前能过)。解析改为行尾无关,真实断言未削弱。门禁 18/18。

五、文档
- STATUS.md 新增 rc.23 并将 rc.22 由「待验证」订正为「已验证关闭」。
- 实施计划附 B.1、PROGRESS-ASSESSMENT-2026-09-21.md 同步订正。
- known-risks.md:KR-07 解除(Docker 已恢复)、KR-01/KR-06 按复测订正,
  新增 KR-08(Docker Hub 域名 DNS 被投毒,改用国内镜像源绕行)与 KR-09(compose 项目名撞名)。

验证状态:全量回归 757/0/0/3 全绿;UI 契约门禁 18/18;对已部署栈 E2E 65/65;
负向注入可变红;工作区哈希未被注入过程污染。
未验证:GitHub Actions 首次真绿(仍未推送,见 KR-06)。
2026-09-22 00:46:10 +08:00
WorkBuddy c195133680 fix(organization): 机构三处写操作必崩 500 修复 + type 白名单 + 测试库约束补齐
缺陷本体(实施计划附 B.1,2026-09-19 登记并实测复现,本轮处置):
OrganizationService 的 createOrganization / disableOrganization / enableOrganization
分别写入 Constants.ACCOUNT_STATUS_NORMAL("normal") 与 ACCOUNT_STATUS_SUSPENDED("suspended")。
那是 users.account_status 的取值,而 organizations.status 的 CHECK 白名单
(V001 chk_org_status)只有 ('active','inactive') → 三个写操作全部违约,
生产必崩 PSQLException violates check constraint "chk_org_status" → HTTP 500。
二阶影响:activities.organizer_org_id NOT NULL ⇒ 无机构即无法新建活动,
运营端「建机构 → 建活动 → 报名 → 互选」链路的第一环是断的。

修复:
- Constants 新增 ORG_STATUS_ACTIVE / ORG_STATUS_INACTIVE(独立于 users.account_status,
  附注释说明两者白名单不同,禁止互相复用)
- 三处状态值改为 ORG_STATUS_ACTIVE / ORG_STATUS_INACTIVE
- 新增 OrganizationService.ALLOWED_TYPES + type 白名单前置校验:
  非法值返回 VALIDATION_ERROR(400),不再打到 PostgreSQL 才 500
- 「机构不存在」由裸 RuntimeException(→500 SERVER_ERROR) 改为 NOT_FOUND(404)
- 异常类型统一为 GlobalExceptionHandler.BusinessException —— 独立类
  shared.exception.BusinessException 没有 @ExceptionHandler,抛它仍会退化成 500

根因第三重盲区(缺陷长期隐身的直接原因):
schema-h2.sql 的 organizations 表此前未复刻这两条 CHECK,H2 上写 'normal' 畅通无阻。
已补齐 chk_org_type / chk_org_status 及 NOT NULL / DEFAULT,与生产 DDL 逐值一致。

新增守卫:
- OrganizationConstraintParityTest:生产迁移 DDL ↔ 测试库 schema ↔ 后端白名单 ↔
  Constants 四方比对 + 解析器自检(虚构约束名必须解析失败,防断言静默永真)。
  其中 status DEFAULT 的解析必须先把范围切到 organizations 建表块 —— 全文件搜索会
  误命中 users.account_status(本轮自检脚本实测踩到并修正该缺陷)。
- OrganizationServiceTest:走真实 DB 的 H2 集成测试,9 用例覆盖创建/停用/启用/
  停启往返/六个合法 type/非法 type/名称空白/404 语义,断言落库值而非返回值。

⚠️ 未在本机运行测试:本轮探测确认本机无 Docker、无 JDK,wsl.exe 被安全策略阻止
(与 known-risks.md 的 WSL/Docker 故障一致),mvn -o test 与负向注入均无法执行。
已用等价正则的独立脚本验证守卫判别力与两处 DDL 一致性(PASS,脚本留痕
.workbuddy/_verify_org_guard.py)。
补跑全量回归 + 负向注入前,本项记为「待验证」,不得计入已验收。
2026-09-21 20:34:30 +08:00
WorkBuddy 60c591fc68 V1.0.1 纠偏 DoD 补齐:canonical key 字面化(V051+KEY_ALIASES)+ 照片并发突破实测(锁用户行+锁后计数)+ AGENTS 规则27 + known-risks
按文档 DoD/约束逐条复查后补齐的差距(承接 438b38d 主体纠偏):
- CFG-001 字面化:FeatureFlagConfig.KEY_ALIASES(identity.liveness.enabled / 遗留
  livenessVerification.enabled → 实现字段 livenessEnabled,读兼容)+ Controller PUT/DELETE
  落库归一到 canonical 键 + V051 迁移(删遗留键、写入 identity.liveness.enabled=false);
  MigrationValidationTest 基线 50→51。
- PHOTO-004 真实并发突破:新增 PhotoLimitConcurrencyGuardTest(3 线程 H2 并发冲顶)——
  实测咬住「锁照片行+读时计数」在 H2 MVCC 快照漏读、3/3 全成功突破上限的缺陷;
  并发控制改为锁用户行(UserMapper.lockUserRow FOR UPDATE)+ 锁后新语句 COUNT,
  0 张照片亦有锁可加(补首张并发缺口);UserPhotoMapper 计数方法同步替换。
- FF-LIVENESS-003 + canonical 持久化:FeatureFlagPersistenceGuardTest +4
  (canonical 加载 / 遗留别名读兼容 / PUT 落 canonical 键 / 切换即生效无旧缓存)、
  FeatureFlagControllerTest +3(canonical/legacy/reset)。
- 文档同步:AGENTS.md 规则 27(配置治理纪律);docs/governance/known-risks.md
  (7 项含本机 WSL/Docker 故障 KR-07);config-register / open-questions 更新 canonical 口径;
  STATUS rc.21 行补 ⑥⑦⑧。

全量回归 741/0/0/3(基线 717 + 25 新增 − 1 删死测试,宿主机 JDK21+Maven 3.9.9 实测,
本机 WSL/Docker 引擎故障期间);UI 契约门禁 18/18
2026-09-21 11:13:14 +08:00
WorkBuddy 438b38d121 V1.0.1 配置与规则纠偏:照片上限配置化+强校验 / 活体开关闭环 / 推荐阈值快照+规则版本化 / governance 三表+漂移审计
根治三类工程治理问题(技术默认值≠产品规则、配置存在但未闭环、文档代码漂移):
- PROFILE-001:profile.photo.maxCount(默认6未确认)+ ProfilePolicy + 服务器端 FOR UPDATE
  并发强校验(PHOTO_LIMIT_EXCEEDED,拒绝先于 S3 不留孤儿)+ 前端 mergeAppConfig 嵌套键修复
- CFG-001/IDENTITY-001:IdentityPolicy + GET /verification/liveness-status 真实判定点
  (默认关不阻断,开则 enhancedVerified 变化,消除假开关)+ 删死 seed livenessVerification.enabled
- MATCH-001:阈值60定为技术默认(消解 PENDING 矛盾)+ V050 recommendation_records.threshold_snapshot
  规则版本化 + 删死重复配置类 MatchConfig
- DOC-001:新增 docs/governance/(config-register/open-questions/decision-register/
  hardcoded-rule-audit/config-drift-audit)
- TEST-001:新增守卫 PhotoLimitGuardTest(8)/IdentityLivenessGuardTest(5)/
  RecommendationThresholdSnapshotGuardTest(4),均经负向注入可红

全量回归 733/0/0/3(基线717+17新增-1删死测试);UI 契约门禁 18/18;STATUS 升 rc.21
2026-09-21 02:05:15 +08:00
WorkBuddy b60c1ffb71 docs: 数据保留 36 个月政策定稿 + 09-20 收尾状态 —— STATUS 版本演进表重排至 rc.20,隐私政策 §3.3/PIA §8 修订,UAT V1.1 用例 46 条(9 角色×7 路径),E2E 65/65 本机模拟 CI 登记 2026-09-20 22:30:44 +08:00
WorkBuddy 6e76e2f2ea feat(account): 数据保留流程落地(36 个月下限,V049)—— closed_at + 人工审核/幂等匿名化/审计 + 运营端点 + 守卫 5 用例;测试库补 user_photos,MyBatis null 更新修复 2026-09-20 22:30:29 +08:00
WorkBuddy 76a152bccf docs(status): 批 2/3 收尾完成登记 —— 全量回归 712/0/0/3 实测、权威行升级(批 1~3 done, awaiting user go)、Phase 21 遗留行清偿状态、rc.16 定稿
- 后端基线 700 → 712:MeStatusLocalEligibilityGuardTest(2) + 互选 expires_at 接线守卫(+2, 负向恰 2 红)
  + 随访审计与风险守卫(4) + 红娘注销拦截守卫(4),全部真实落库守卫
- 批 3 仅余:数据保留流程(待产品决策)、E2E 入 CI 的 GitHub Actions 实测绿
- UI 契约门禁 18/18;本机 E2E 65 passed
2026-09-19 19:39:02 +08:00
WorkBuddy 08b9cb2468 fix(p21-2e): 红娘服务拦截接线(blocker 首次生效 + 竞态兜底);2d 确认 Onboarding 本地资格已实现;文档回写
P16 遗留 2e(红娘服务拦截):
- checkCanWithdraw 硬编码 0 → 接 MatchmakerServiceMapper.countActiveByUserId(非终态口径
  submitted/assigned/in_service/waiting_user,与 findActiveByOperatorAndUser 一致),blocker 首次真正生效
- notifyMatchmaker TODO 空转 → terminateActiveMatchmakerServices 竞态兜底(残留非终态置 cancelled);
  不发通知:Phase 18 D2 的 notifications.user_id→users(id) FK 教训
- 守卫 MatchmakerWithdrawBlockerGuardTest 4 用例全绿;首跑暴露测试基建坑:@Transactional Service
  是 CGLIB 代理,ReflectionTestUtils 直调私有方法拿到的是代理实例的 null 字段 → 须先
  AopTestUtils.getTargetObject 解包(已在测试内注释说明)
- 另发现历史遗留:countInProgressByOperator 使用白名单外的 'processing'(已登记)

P04-T10:复核确认 Onboarding 本地资格步骤早已实现(onboarding 消息/动作 + 路由 + /me/status 字段),
JIRA ⏳ → ✅;清除首页语义错位的死状态 isVerified(miniapp tsc EXIT=0)

文档:JIRA 收尾行/计划文档 Task 3.1/3.3 勾选
2026-09-19 19:30:38 +08:00
WorkBuddy 469d7194bd fix(p16/p13a): Task 3.3 三项清偿 —— 互选超时接线、随访审计、随访风险检测(负向验证已证守卫有牙)
P16-T13 互选超时接线:
- 缺陷本体:expires_at 全仓无人写入(配置 48h 在、confirm 的过期过滤在,唯独创建不赋值)
  → (expires_at IS NULL OR expires_at > NOW()) 恒真,「截止后48小时」窗口形同虚设
- 修复:createMutualConsent 新建时写 expires_at = 活动结束时间 + ActivityConfig.mutualDeadlineHours
  (已确认口径「截止后48小时」),活动缺失/无结束时间回退创建时刻
- 守卫:ActivityMutualConsentServiceTest 新增 2 用例;负向验证(摘掉写入)恰 2 红、其余 7 绿
- 原测试 expiredConsentRejectsConfirm 手动补 expires_at 恰好掩盖了创建侧缺陷,如实记录

P13A-T31 随访审计:
- assigned Counselor 读他人咨询正文/随访此前零审计 → getDetailForCounselor 接线
  recordSensitiveAccess(counseling_followup);本人读自己与 manager 摘要视图不审计

P13A-T33 随访风险检测:
- detectCounselingRisk 此前只扫申请摘要 → addFollowup 正文接入同一检测(source=counseling),
  INV-013 加固:不记录正文只记级别(同 apply 模式)
- 守卫:CounselingFollowupAuditAndRiskGuardTest 4 用例(审计落库回查 ×2 + 风险事件落库回查 ×2),全绿
2026-09-19 19:10:25 +08:00
WorkBuddy 28cf25756a test(e2e-recon): E2E 场景对账表(32 项:31 项有真实覆盖、1 项被裁定取代)+ 补 /me/status.localEligibilityStatus 零断言缺口
- 对账表写入收尾计划 Task 2.1 节:首页 11 / 本地资格 11 / 权限 10 逐项映射到
  E2E 用例与后端集成测试;原始清单取自 commit ae1bd2b 占位文件头
- 发现并补齐 1 处缺口:/me/status 的 localEligibilityStatus/localEligibilityType
  此前零断言(实现存在但从未被测)→ 新增 MeStatusLocalEligibilityGuardTest 2 用例
- 首页 #10(counselingEnabled flag 控制入口)登记为被 2026-09-19 产品裁定取代(死配置移除)
- 实测:MeStatusLocalEligibilityGuardTest 2/2 绿(离线容器 3:19)
2026-09-19 18:58:07 +08:00
WorkBuddy e9b99d020b docs(status): 回写批 2/3 实况 —— 权威行升 09-19(E2E 65 passed / 678+ tests)、Flyway V048、rc.16、Phase 21 遗留行修正(隐私设置已关闭;红娘拦截「无表」判定已推翻)、JIRA 登记批 2/3 收尾、收尾计划 Task 3.1 隐私项勾选 2026-09-19 18:50:00 +08:00
WorkBuddy 0aa5a7b4e6 docs(status): Phase 22 遗留三项更新为 2026-09-19 已处置
T01-T05 死开关与 T07 私有桶配置已移除;PUT /feature-flags 已由 V048 做成
真持久化(含重启加载、DELETE 恢复默认、坏值 fail-fast),并如实记录
「多实例内存值不自动刷新」的已知边界。避免与计划文档自相矛盾。
2026-09-19 17:50:49 +08:00
WorkBuddy d605c04707 feat(flags): PUT /operator/feature-flags 真持久化(V048)
此前 updateFlag 只改 FeatureFlagConfig 内存单例的字段:重启即回默认值、
多实例互不一致,表现为「后台显示已关闭,一次发布后功能又回来了」。

- V048 新建 feature_flag_overrides(flag_key 主键;值存字符串、回读按字段
  声明类型解析;updated_by 记审计)。updated_by 必须建 uuid 而非 varchar,
  否则 UUIDStringTypeHandler 以 uuid 发参数会在 PG 抛
  operator does not exist: character varying = uuid(同 V046 的 actor_id)。
- FeatureFlagOverride/Mapper/Service:upsert 走「先 UPDATE、0 行再 INSERT」,
  不用 PG 的 ON CONFLICT(H2 测试库不认,同 IdempotencyMapper 取舍)。
- FeatureFlagConfig.reloadFromPersistence() 挂 @PostConstruct:该时机晚于
  @ConfigurationProperties 绑定,故 DB 覆盖压得住 yml 默认值。
- PUT 先改内存再落库(内存改动失败即 400,不写库);新增 DELETE 恢复默认。
- 覆盖值无法解析时启动 fail-fast,不静默回退默认(否则「配了不生效」)。

守卫 FeatureFlagPersistenceGuardTest 11 例,核心是「另起实例重新加载后改动仍在」
(重启等价路径),并自检 @PostConstruct 未被摘掉。
负向验证:注入不落库 + 摘钩子 -> 恰好 5 例变红,其余不受影响;还原 md5 一致。

全量回归 700 / 0 / 0 / 3(起点 689,净 +11);UI 契约门禁 18/18。
2026-09-19 17:47:16 +08:00
WorkBuddy 018626935a chore(config): 移除 7 项从未被读取的死配置(5 个功能开关 + 2 个咨询附件桶)
产品裁定:确认移除对系统无影响即可移除。移除前的无影响论证(不是「看着没人用就删」):
全仓搜索 5 个 getter,命中的只有 ① 本类定义处 ② getAllFlags() ③ FeatureFlagControllerTest 一处断言
—— 业务代码零调用点;前端 admin-web/src 与 miniapp 对 feature-flag 零调用(仅一个未使用的 storageKey 常量)。

⚠️ 重要边界:FeatureFlagConfig **整体不能删** —— 同类的 isAppealEnabled(AppealService 4 处)、
isLocalEvidenceUploadEnabled(LocalEligibilityService)、getLocalEvidenceMaxFiles /
getLocalEvidenceMaxFileSizeMB / getLocalEvidenceAllowedTypes(PhotoService)都是**真实生效的 gate**。
本次只删那 5 个死的:counselingEnabled / premaritalEnabled / contentEnabled /
serviceLocationEnabled / localEligibilityEnabled,以及从未被读取的
counselingAttachmentsPrivate / counselingAttachmentsBucket(存储实走 object-storage.bucket-private)。

同步清理 application-docker.yml(application-dev.yml 被 .gitignore 排除,已在本地同步清理):
否则会留下「配了但不生效」的新死配置 —— 正是本次要消除的问题本身。
getAllFlags() 键数 21 → 14;FeatureFlagControllerTest 布尔样例由 counselingEnabled 换成 referralEnabled。

docs(plan): Task 3.1 全闭 + Task 3.2 前两项已完;附 B 增第 14 项;基线 685→689

Task 3.1(隐私设置)两层均已关闭:① upsert ② 桩接通 + 键集以小程序为准。
Task 3.2 第三项「PUT /feature-flags 不持久化」**仍未处理** —— 改动涉及运维流程
(改的是运行时内存态,重启即失效),与「删死配置」风险不同,需确认做成真持久化还是标注只读后关端点。

附 B-14:新增迁移必须同步更新 MigrationValidationTest.LATEST_MIGRATION(本轮实测踩到),
该守卫还断言「迁移文件总数 == 基线」即不允许缺号,属有效守卫。

全量回归 689 / 0 failures / 0 errors / 3 skipped;UI 契约门禁 18/18 绿。
2026-09-19 17:12:05 +08:00
WorkBuddy d9c08d85f9 feat(privacy): 键集以小程序为准 —— V047 补列 + 接通 /account/privacy-settings 桩
产品裁定:隐私设置键集**以小程序为准**,用迁移补齐后端缺失列。

V047 新增 allow_activity_invitation(默认 TRUE)与 show_last_online(默认 FALSE)。
默认值**不对称**,与小程序 data 初值严格一致 —— showLastOnline 写成 TRUE 等于默认公开用户在线状态。
既有 6 列保留不动:UserProfileService 的资料详情 DTO 仍在全量返回它们(第 74-79 行),
那是仍在生效的独立读取出口。本迁移只做加法,不做替换。

AccountLifecycleService 的两个 TODO 桩改为委托 PrivacyService 真实读写。
这是**用户真正走的路径**(小程序调 /account/privacy-settings,而 /me/* 与 /profile/* 无人调用),
此前「保存成功」只是回 {updated:true} 而从不落库。

入参校验:已知键类型错误必须显式抛错,未知键宽容忽略 ——
若静默丢弃,用户会看到「保存成功」而设置从未生效,这类缺陷最难排查。
另容错 Boolean 的字符串形式("true"/"false")。

test(privacy): PrivacySettingsUpsertGuardTest 扩至 8 用例,其中 2 个是契约守卫

契约守卫**直接解析小程序源文件**的 PUT payload 与 data 块得出权威键集/默认值,
不抄契约副本(沿用 EntityMigrationParityTest 的纪律)—— 小程序改键即变红。
① 键集:后端返回键必须与小程序 PUT payload 键完全一致(不多不少)
② 默认值:后端默认值必须与小程序 data 初值一致(含 showLastOnline=false 这条不对称项)
③ 桩已接通:经 /account/privacy-settings 写入必须真落库且可读回
④ 已知键传错类型必须报错,不得静默丢弃
其余 4 个为 upsert 行为守卫(连续两次更新不炸且不产生第二行 / 部分更新不重置 / 默认值 / 读取无副作用)。

负向注入:把桩退回「不落库」→ 恰 2 红(③ 与 ④),其余 6 个正确未红。已还原并复跑全绿。

test(deployment): MigrationValidationTest 基线 46 → 47

新增迁移必须同步更新 LATEST_MIGRATION,否则全量回归报「版本号超出当前基线」。
该守卫同时断言「迁移文件总数 == 基线」(不允许缺号),是有效守卫 ——
正确做法是更新常量,不要为了让测试变绿而改断言方式。
2026-09-19 17:11:46 +08:00
WorkBuddy 04b07b5821 fix(privacy): updatePrivacySettings 改 upsert —— 同一用户第二次更新不再撞 user_id 主键
user_privacy_settings 的主键自 V002 起就是 user_id,旧实现无论记录是否存在都无条件 insert,
导致同一用户第二次更新必然 DuplicateKeyException → 500,隐私设置从此再也改不动。
现改为「存在则按 user_id 更新,不存在才插入」。

更新条件刻意用 user_id 而非实体 id:id 是 V037 为「实体↔表对齐」后补的独立列,
不作为寻址依据 —— 否则历史行 id 缺失时更新会静默失效。

已知边界(未擅自处理):findByUserId 带 deleted_at IS NULL,若将来引入软删路径,
已软删的行会让本方法走插入分支并撞主键。当前不存在任何软删隐私设置的路径,故不可达;
若将来新增软删,需同步改为 PG ON CONFLICT (user_id) DO UPDATE。已在代码注释中标注。

test(privacy): PrivacySettingsUpsertGuardTest —— 4 用例,全走真实落库(H2),不用 mock 断言调用次数
① 连续更新两次:第二次不得抛异常 + 库中仍只有 1 行(防退化成重复插入,
   那会让 findByUserId 的 Optional 返回值抛 TooManyResultsException)
② 部分更新不得把未提交字段重置回默认值
③ 首次更新未提交字段取默认 true
④ 无记录时读取返回全 true 且不落库(固化既有契约)
负向注入已验证:退回无条件 insert → 恰 2 红,失败原因即 DuplicateKeyException;
另 2 个只更新一次的用例正确未红(走不到该分支)。已还原并复跑全绿。

docs(plan): Task 3.1 拆两层并记录进展;附 B 增第 13 项;基线 681→685

⚠️ 关键发现(附 B-13):隐私设置有两套实现,前端走的是桩那一套 ——
小程序 pages/privacy-settings 调 /account/privacy-settings(AccountLifecycleService 桩,不落库),
管理端对隐私端点零调用。故本次修复位于「前端尚未触达」的路径:必要但当前用户不可感知。
真正的用户可见故障是「改隐私设置根本不保存」(桩),属 Task 3.1 上层,仍待契约裁定。
本次修复是该上层工作的必要前置 —— 桩一旦接到 PrivacyService 立刻会撞上此缺陷。

方法论:评估「某修复的价值」必须先确认前端实际调用哪条路径 ——
本项目同业务存在 /me/* 与 /account/* 多套并存端点,后端有真实实现 ≠ 用户能走到。
2026-09-19 15:49:54 +08:00
WorkBuddy 0242ea44e8 fix(mapper): 补 5 个「只有声明、无任何 SQL」的方法;新建 Mapper 绑定守卫持续挡住该类缺陷
全仓无 mapper XML、无自定义 ISqlInjector,自定义方法只能靠注解提供 SQL。
这 5 个方法漏写注解,编译期无感知,调用必抛 Invalid bound statement:
  UserPrivacySettingsMapper.findByUserId  —— 隐私设置读取
  UserPreferenceMapper.findByUserId      —— 推荐引擎 / 匹配资格共 6 处调用
  ReviewReasonMapper.findAllEnabled      —— 审核原因字典下拉
  ReviewReasonMapper.findByCategory
  ReviewItemMapper.findByReviewTaskId
长期未被发现的原因:候选池为空(分支走不到)+ 测试库根本没有这 4 张表(双重不可达)。
补的 SQL 一律带 AND deleted_at IS NULL —— V037 之后这些表都有软删列,
只看 V002 建表会漏掉软删过滤,制造「读到已删数据」的新缺陷。

test(invariants): MapperBindingGuardTest —— 三用例
① 绑定守卫:反射遍历 MapperRegistry 全部 Mapper 的声明方法,hasStatement 为假即失败;
   按「实体有无主键」语义豁免复合主键实体的 selectById/updateById/deleteById 等
   (MP 本就不为无主键实体注入,属正确行为;非类名白名单,故新增复合主键表同样适用)。
② 探测自检:虚构 statementId 必须为假,防断言静默永真。
③ 可执行性:插真实数据后真查一遍 —— 绑定存在 ≠ 能跑(本次即借此确认 JsonNode 映射无碍)。
负向注入已验证:移除 ReviewItemMapper 的 @Select → 守卫报恰 1 项缺失,
且可执行性用例抛真实 Invalid bound statement;还原后复跑全绿。

test(db): schema-h2.sql 补 4 张缺失表(user_privacy_settings / user_preferences /
review_reasons / review_items),按 V002 + V037 之后的最终态建模(含 id、deleted_at 与
11 个匹配权重列),并保留 chk_item_type / chk_item_status / chk_pref_priority 约束。

docs(plan): 附 B 新增第 12 项;更正第 11 项中我自己上一轮的错误断言
—— 「表无 id 列导致 insert 必崩」不成立:V037 已补齐 id/deleted_at/权重列。
真实缺陷是 updatePrivacySettings 对已存在记录也走 insert(非 upsert)→ 第二次更新撞主键冲突。
方法论:判断表结构必须遍历全部迁移,只看 CREATE TABLE 会得出与生产相反的结论。
2026-09-19 15:26:35 +08:00
WorkBuddy 9466b2b636 docs(plan): 校正「跑测试命令」两处误判并补全必挂项;登记全量回归实测基线 678/0/0/3
本轮自查中先后写下两条错误结论,均系命令/挂载所致而非代码问题,现如实留痕更正:
① 误判「mvn -o 不可用」—— 实为漏挂命名卷 danyang-m2-cache(镜像内置 /root/.m2 仅 132MB,
   缺 surefire-junit-platform;命名卷 251MB 含 3.1.2);
② 误判「35 failures + 54 errors 是回归」—— 实为只挂 backend/ 而非仓库根,
   致 MigrationValidationTest / AnalyticsPermissionGrantTest / SecurityPathCoverageTest /
   PrincipalInjectionContractTest 等以相对仓库根路径读文件的测试集体 NoSuchFile。
正确命令下全量回归 BUILD SUCCESS:678 / 0 failures / 0 errors / 3 skipped(约 6 分钟)。
附 A 校正表扩至 9 行(含 2 处自身结论更正),Global Constraints 与附 B-9 同步补全两处必挂。
2026-09-19 14:16:19 +08:00
WorkBuddy b834fa86a0 test(invariants): Phase 23 Task 2.2 —— INV-011 显式行为守卫(9 用例 + 两次负向注入验证)
产出 Inv011EvidenceAccessGuardTest:非领取人 / 跨用户佐证 / 非 local_eligibility 任务一律拒绝,
且 successful 与 denied 两条路径都断言 sensitive_access_logs 真实落库(不用 mock 断言)。
另含 3 个结构性守卫(用户侧端点无用户可控 userId 入参、佐证读取入口的双重权限约束、
佐证读取不暴露可伪造 operatorId)与 1 个守卫自检(参数名反射可读,防断言静默永真)。

前置修复:schema-h2.sql 补 sensitive_access_logs 表 —— 该表此前在测试库完全不存在,
而它是「访问留痕」命题的唯一落点,缺失导致该命题根本无法断言(表漂移盲区,与 Phase 12/15/19 同族)。

负向注入两次,均精确命中:
① 停用 requireClaimOwner → 恰 1 红(非领取人用例),其余 8 绿
② 停用 catch 内 denied 审计 → 恰 2 红(非领取人 + 跨用户),第 3 条走 try 外的另一路径故正确未红
两次注入均已还原并复跑全绿(9/9);V11InvariantsTest 12/12 绿;UI 契约门禁 18/18 绿。

文档:Task 2.2 闭环记录 + 附 A 扩至 7 处校正(含对本文档初稿「机构创建 500 归因」的自我更正,
实测为 chk_org_status 违约而非 id 为空)+ 附 B 扩至 11 项新发现
(5 个 Mapper 方法声明了却无实现、隐私设置三方契约冲突 + 表/实体主键错配、
测试镜像 mvn -o 不可用、本机全量回归会卡住)。
2026-09-19 14:02:52 +08:00
WorkBuddy e666ee6f8a feat(e2e): Phase 23 Task 2.1 —— 活动互选 8 场景 + 服务预约 8 场景真实实现(16 个真实用例)
背景:`tests/e2e/activity_mutual_consent_test.py` 与 `service_appointment_test.py` 此前是
**诚实占位**(仅 docstring、0 用例,指针指向 run_v11_tests.py),计划 Task 2.1 要求把
文件头保留的场景清单逐条实现为真实 HTTP 用例。本提交完成该任务。

═══ 新增真实用例 16 个(本机活栈 pytest tests/e2e 49 → 65 passed)═══

activity_mutual_consent_test.py(8,覆盖 INV-014/015/006 + AGENTS.md #19/#20)
  1 建立互选记录不建 Match(INV-014)
  2 单方确认不建 Match(INV-015)
  3 双方确认建 Match Episode 且双方可见
  4 已有 active Match 时再次双确认必须复用(#19)
  5 曾 Unmatch 后重新双确认开启新 Episode:episode +1(#20)
  6 一方拒绝不建 Match,对方随后确认也不建
  7 超时:已过期记录不得被推动成双确认(含「未过期必须生效」对照,防假绿)
  8 单向意向不泄露(INV-006,含「B 确认后必须看到 accepted」反证)

service_appointment_test.py(8)
  1 提交预约;2 幂等键重放不得产生第二条;3 运营确认 submitted→confirmed(用户侧同态可见)
  4 并发「用户取消 × 运营确认」不得 5xx、最终状态必须收敛;5 我的列表只含本人
  6 待确认可取消;7 归属隔离(他人不可读详情/不可取消)
  8 运营侧状态闸门:已完成预约不得再被取消

═══ 两个幂等 seed(E2E 前置数据,本机与 CI 通用)═══

- seed_e2e_activity.sql:organization + 两个 activity。
  必须建的原因是硬约束而非选择:`activity_mutual_connection_consents.activity_id`
  有 **FK 到 activities(id)**(实测约束名 …_activity_id_fkey),而 activities.organizer_org_id
  又 FK 到 organizations(id) 且 NOT NULL —— 全新库里三张表全空,互选无活动可挂。
- seed_e2e_service_station.sql:service_location + **站点-运营关联**。
  关联必需:运营确认受数据范围约束(`assertOperatorScope` → `canAccessByOperator`),
  super_admin **不豁免**,实测 e2e_admin 确认他站预约返回 403。
  E2E 因此改用 seed 中该站点关联的 e2e_station。

两个 seed 由测试首用时幂等执行(docker exec psql),无需改 CI 步骤。

═══ 负向注入(Task 2.1 Step 3)═══

③ 撤销 service_station_operator 的 service_appointment.confirm →
   恰 2 红(test_operator_can_confirm / test_operator_cannot_cancel_completed,403),其余 6 绿;
④ 触发器强制新建互选记录的对方侧 accepted →
   3 红(both_confirm_creates_match_episode / timeout_handled / single_interest_not_leaked),其余 5 绿。
两处均已还原并复跑全绿(65 passed),确认无残留(无 e2e* 触发器、权限计数回到 1)。

**两次失败的注入(记录以免后人重复踩)**:
- 删除站点→运营关联:**无效** —— 测试的 seed fixture 在用例前幂等重放 seed 会自愈该前置条件;
- 改 user_b_status 列默认值:**无效** —— `createMutualConsent` 在 Java 侧显式写 pending,
  DB 默认值根本不参与(侧面证明写入路径的权威性)。

═══ 计划文档校正(审阅产物)═══

按实测推翻 4 处失实断言,登记 7 项新发现(详见计划文档「附 A / 附 B」):
- 推翻:matchmaker_services「无表/需扩权」(实为 V009 已建表且 mapper 齐备);
  门禁 14/14(实为 18/18);「tests/e2e 现为占位」(实为仅 2 个文件);
  E2E 基线 49(实为 65)。
- 新发现:① `POST /operator/organizations` 必然 500(createOrganization 不赋 id +
  IdType.INPUT + 无 DB 默认值;且未校验 chk_org_type)→ 连带「无法新建活动」;
  ② `createMutualConsent` 从不写 expires_at → 互选永不过期(即批 3 P16-T13,配置与
  V041 部分索引空转);③ 用户侧取消预约不校验状态(已确认/已完成也能自取消),
  与运营侧明确拒绝 completed 口径不一致 → 需产品裁定;
  ④ E2E 环境 organizations/activities/service_locations 均为空表(无迁移种子);
  ⑤ 运营数据范围 super_admin 不豁免(预期行为,勿误判);
  ⑥ 本机复跑会遇到 Redis 登录锁定键(auth:operator_login_lock:*);
  ⑦ e2e_* 账号因 ON CONFLICT DO NOTHING 重跑 seed 不覆盖旧口令。

═══ 验证 ═══

- `pytest tests/e2e` → **65 passed**(含新增 16);`node scripts/run-ui-contracts.mjs` → **18/18 PASS**,退出 0;
- 本机环境处置(不属代码改动):把 6 个 e2e_* 账号口令对齐到 CI 口令、清除 Redis 登录锁定键。
- **未验证**:微信开发者工具、CI 上的首次 E2E 实测绿(Task 2.3 Step 3,本机无 Docker Hub 出口)。
2026-09-19 12:28:16 +08:00
WorkBuddy 2ae72aabe7 docs(governance): JIRA Epic 总览表补全状态列(P01~P07 补 ✅ / P23~P26 如实标注);基线计划 Phase 22 合并重复口径澄清块
- Epic 表由 4 列改 5 列:P01~P07(此前漏标)补 ✅;P23 部分关闭、P24 待用户确认、P25 待运营、P26 Smoke 待手动
- UAT/RC/Production 当前执行项行更新:/me/* 已收敛,停在 Phase 24 门前
- 基线计划 Phase 22(Deployment/Ops):合并两次编辑产生的重复口径澄清 blockquote,补回 Files 行
2026-09-19 11:45:27 +08:00
WorkBuddy 0da93008d0 test(miniapp): 行为契约的桩改为忠实模拟 wx 回调包装(异常不外逸,转 App.onError)
原桩直接同步调用 `opt.success(...)`,被测代码在回调内抛错时异常会**外逸到测试进程**,
于是「请求永不 settle」这一真实后果反而测不出来 —— 报出来的是注入的异常本身,
而不是「Promise 永久 pending」。

真实小程序里 `wx.request` 的回调是被 wx 包装的:回调内抛错**不会**打断请求链,
而是被捕获后走 `App.onError`(本轮控制台实测 `[App onError] MiniProgramError`),
后果是 `resolve` 永不执行。桩必须保真这一点,否则判据会在错误的地方变红。

改动:
- `respond()` 捕获回调内异常并记录,不外逸;
- 成功路径新增断言:成功回调内**不得**抛出任何异常(附明确失败信息)。

负向自证(改前已实测):在 `resolve(body.data)` 之前注入一行抛错 →
本契约输出 `Promise 在 1000ms 内未 settle —— 收尾逻辑又跑到了 resolve 之前` 并退出 1;
移除注入后 `git diff --exit-code` 为空(逐字节还原),全量门禁 **18/18 PASS**。
2026-09-19 11:43:13 +08:00
WorkBuddy b67a56479e fix(miniapp): 修 P0 请求层崩溃(一个下划线致所有请求卡死)+ 组件 WXSS 违规选择器,再补 3 项门禁
═══ 缺陷 1(P0,运行期):utils/api.js 成功回调调用不存在的方法 ═══

控制台:
  TypeError: _this._releaseIdempotencyKey is not a function

根因:类中定义的是 `releaseIdempotencyKey()`,成功回调写成了
`this._releaseIdempotencyKey(...)`(**多一个下划线**)。

**为什么一个字符的笔误是 P0**:该调用位于 `wx.request` 的 success 回调内、
且在 `resolve()` **之前**。抛错 → `resolve` 永不执行 → 该 Promise **永不 settle**
→ 所有页面永远停在 loading,同时 App.onError 刷屏。
即「一次成功请求」本应推进流程,实际把前端整成不可用。

修复不只是改名,而是纠正结构:
- **先 `resolve(body.data)`,再执行收尾动作** —— 收尾逻辑不允许挡在响应路径上;
- 释放动作自带 try/catch + console.warn:释放失败最多是"下次操作没拿到新键",
  不得升级为业务失败。
- 语义未变(勿改):成功→释放(下次同域操作是新的一次);失败→刻意不释放
  (重试复用同一把键,后端才能识别重复投递)。

═══ 缺陷 2(编译期警告):组件 wxss 用了标签选择器 ═══

  Some selectors are not allowed in component wxss ... (components/recommend-card/index.wxss:1:1092)

实测违规 1 处(23 个组件样式文件全扫):`.compact-like image` —— 组件 wxss 只允许类选择器。
改为 `.compact-like__icon` 并**同步在 WXML 补上类名**
(只改 wxss 会让样式静默落空,是另一种"假成功")。

═══ 门禁:15 → 18(既有的全是源码内容断言,抓不到上述两类)═══

- 第15 `wxml-structure-contracts`(上一提交新增,本轮 MIN_GUARDS 基线顺带对齐)
- 第16 `miniapp-self-method-contracts`:扫描 `this._x(` / `self._x(` 等私有调用,
  要求同文件有定义(方法简写 / 类字段 / `this._x = 值` / prototype 四种形态)。
  **判据先自证假阳性**:首版正则不允许 `.` 前缀,把 `this._unsub = store.bind(this)`
  形式误报为未定义(3 个页面);修正并加反向自检样例固化了该坑。
- 第17 `wxss-component-selector-contracts`:组件 wxss 禁标签/ID/属性选择器。
  **作用域刻意只限 miniapp/components/**(page 的 wxss 允许标签选择器,套上去必有误报)。
- 第18 `miniapp-api-client-behavior`:**唯一的行为断言** —— 桩掉 `wx.request` 真跑 api.js,
  验证成功 resolve / 失败 retain 键 / 成功 release 键 / 收尾抛错不影响响应路径 4 组。
  含 1s settle 超时,直接编码"永不 settle"这一失败模式。
- 新增 `scripts/lib/wxss-selectors.mjs` + `scripts/check-wxss-component-selectors.mjs`(CLI)。
- `MIN_GUARDS` 15 → 18;执行器措辞改为「源码结构与关键行为断言」。

═══ 验证 ═══

- `node scripts/run-ui-contracts.mjs` → **PASS 18 / 18**,退出 0;
- 负向注入:未定义私有方法 / 标签+ID+属性选择器 / 多余闭合标签 → 均精准变红,
  且**合法样例(`this._x = 值`、纯类选择器、属性内含 `>`)不被误报**;
  行为守卫的负向自证见下一提交/记录;`UI_GUARDS_MIN=99` 拒绝报绿。
- 文档口径同步:AGENTS.md、STATUS.md(4 处当前口径 + 新增 1.1.0-rc.18 行)、ci.yml 注释。
- **未验证**:微信开发者工具真实编译与真机渲染(本机无该环境)。
2026-09-19 11:40:38 +08:00
WorkBuddy d036c9b986 fix(miniapp): 修复 2 个页面 WXML 结构缺陷,并补第 15 项门禁(WXML 结构守卫)
现象:微信开发者工具报
  [ WXML 文件编译错误] ./pages/service-history/service-history.wxml
  get tag end without start, near `</`

真实缺陷(实测 2 处,非 1 处):
- pages/service-history/service-history.wxml 多 1 个 </view>
- pages/service-program/service-program.wxml  多 1 个 </view>
(均为单行压缩写法,随更早提交入库;全仓另外 84 个 WXML 结构自洽)

根因不是这两个文件写错,而是**门禁覆盖盲区**:
既有 14 项 UI 契约全是「源码内容 / 清单断言」,没有任何一条会解析 WXML,
所以「14/14 全绿」与「小程序能不能编译」之间没有因果关系。

修复:
1. 删掉两处多余闭合标签 → 86 个 WXML 结构全部自洽。
2. 新增 scripts/lib/wxml-structure.mjs:按**引号状态机**逐字符扫描标签。
   不能用懒惰正则 —— 属性值里可以合法出现 `>`(`subtitle="{{unreadTotal > 0 ? ...}}"`),
   正则会在引号内的 `>` 处提前收尾,把自闭合标签的 `/` 读掉,报出假阳性。
   (首版实现即踩此坑,曾把合法的 messages.wxml 误判为坏文件;已复核排除。)
3. 新增 scripts/check-wxml-structure.mjs(CLI,退出码 0/1/2)。
4. 新增 ui-tests/wxml-structure-contracts.mjs:全量扫描 + **3 组内置自检样例**
   (正向样例故意在属性值内放 `>`,把「假阳性」这个坑钉进门禁)。
5. scripts/run-ui-contracts.mjs:MIN_GUARDS 14 → 15(守卫被删/改名仍会拒绝报绿)。
6. 文档口径同步:AGENTS.md、STATUS.md(4 处当前口径 + 新增 rc.17 版本行);
   ci.yml ui-contracts job 注释 14 → 15。历史时点行保留原值。

负向验证(三项,全部通过):
- 注入「多余闭合 / 未闭合」→ 退出 1,且只报坏文件、合法的 `>` 属性文件未被误报;
- UI_GUARDS_MIN=99 → 拒绝报绿(数量下限分支可自检);
- CLI 直扫坏目录 → 退出 1。

验证:node scripts/run-ui-contracts.mjs → PASS 15 / 15,0 失败,退出码 0。
未跑:微信开发者工具真实编译(本机无该环境),结论仅由结构判据支撑。
2026-09-19 11:25:05 +08:00
WorkBuddy 5715987049 fix(infra): 修复健康探针的 IPv4/IPv6 解析歧义(nginx 假 unhealthy)
问题:本地完整栈起齐后 nginx 容器报 unhealthy,但宿主 curl
http://localhost:6688/nginx-health 返回 200 —— 探针与实际服务不一致。

根因(实证,非推断):
- nginx 的 `listen 80` 只绑 IPv4:容器内 netstat 显示 0.0.0.0:80,无 [::]:80;
- /etc/hosts 中存在 `::1 localhost`,busybox wget 解析 localhost 时优先取 ::1;
- 容器内实测:wget http://127.0.0.1/nginx-health → ok
              wget http://localhost/nginx-health  → Connection refused。

即探针地址与服务的实际绑定族不一致,属「探针与被探对象脱钩」的假告警,
并会连带拖垮 depends_on: service_healthy 的下游等待。
(backend 之所以此前一直 healthy,是因为 Java 恰好双栈监听 :::8080 —— 属偶然,不应被依赖。)

修复:
- infrastructure/docker-compose.yml:nginx 与 backend 探针改用 127.0.0.1,并各加注释写明理由;
- infrastructure/docker-compose.staging.yml:backend 探针的同类写法一并改(staging 栈未起,
  未经运行验证,仅保持写法同构)。

验证:
- docker compose config 通过;nginx 重建后 State.Health = healthy(FailingStreak=0,探针 ExitCode=0);
- 链路回归:/admin/ 200(877B)、/admin/assets/index-CDxMNwaf.js 200(1,267,697B)、
  /nginx-health 200、/api/v1/health/live 200;/admin 与根路径均 301 → /admin/;
- 活栈 E2E 49 passed;UI 契约门禁 14/14 PASS。
2026-09-18 22:27:26 +08:00
WorkBuddy 6b2ff0f73e docs: 二次修正 features 断言失实(5 处)—— 原判据「*.yml 无 features 块」被证伪
Phase 22 复验报告 D-22-D 的判据「backend/src/main/resources/*.yml 无 features: 块」是错的,
且被上一轮收尾照抄进 STATUS.md(P26-T06 行),属二次传播。

实测事实:
- application.yml(基础)            → 无 features 块
- application-dev.yml(默认激活 profile,自 63cbbff)→ 有,21 键
- application-docker.yml(自 730dddd / Phase 23B)  → 有
硬证据:dev 的 chat-image-enabled: true 覆盖了 FeatureFlagConfig 的 Java 字段默认 false ——
若「仅 Java 字段默认值生效」成立,该值不可能为 true。

仍然成立的部分:T01~T05 模块开关与 T07 私有桶确实全仓零业务调用点(实测 12 个 flag 的
调用点计数全为 0),「死配置」结论正确;失实的只是来源(非 application.yml、非「仅 Java 默认值」)。

新发现 D-22-E:AppController#getConfig(GET /app/config)返回一份硬编码 features map
(imageChatEnabled 恒 true,键名与后端 chatImageEnabled 不一致)→ 与 FeatureFlagConfig 双源,
前端读到的开关可能与实际配置相反(如 appealEnabled 实际默认 false)。不静默改契约,登记为遗留。

修正落点:STATUS.md(P26-T06 行 / Phase 22 段 / 历史声明块)、Phase-22 报告(表格行 + D-22-D +
新增 D-22-E)、JIRA_BACKLOG.md、Phase-V11-Deployment-Completion-Report.md、
治理报告新增第九节(含「同一文件并行 Edit 会随机丢失」的事故记录与纪律)。
2026-09-18 21:58:58 +08:00
WorkBuddy f63e579a8d ci(e2e): 落地「活栈供给方式」决策 — Compose in CI,并修掉 6 处真实缺陷
决策:E2E 活栈在 runner 内用 docker compose 起全栈(postgres + redis + minio + backend),
与本地验证路径完全一致;未采纳「service containers + runner 上 mvn package 后 java -jar」——
那需要为后端另写一套 host 网络配置,等于制造第二条与本地不一致的路径。

本机实测(与 CI 同一序列):中间件三项 healthcheck 全 Healthy(pg_isready / redis-cli ping /
mc ready local)→ backend /api/v1/health/live 返回 200 → seed 幂等可执行(INSERT 0 0)
→ pytest tests/e2e → 49 passed。
(本机限制:Docker Hub 不可达且无 temurin 基础镜像,未复现 --build;改用本地既有镜像为底座、
挂载当日 16:24 构建的 jar,使被验证的代码版本与 CI 一致 —— 否则是拿 09-09 的旧代码验 09-18 的用例。)

修掉的 6 处缺陷:
1. minio/minio:latest 是滚动 tag → pin RELEASE.2025-09-07T16-13-09Z(否则 CI 拉到的镜像
   与本地验证时不同,表现为不可复现的偶发失败)
2. up 未显式列出 minio(靠 depends_on 隐式拉起),而 minio 的 healthcheck 正是 backend
   能否启动的前置条件
3. up --build 把构建与起栈耦合,失败日志混杂 → 拆为 build / up 两步
4. 就绪窗口 5min 偏紧(首次构建后仍需 Spring 启动 + Flyway 46 个迁移)→ 7.5min
5. 缺 down -v 前置清卷:seed 用 ON CONFLICT (username) DO NOTHING,残留卷会让账号口令
   停留在上一轮 → 登录 401,并伪装成业务失败
6. job 无 timeout(GH Actions 默认 360 分钟)→ timeout-minutes: 30

另:pytest -v → pytest tests/e2e -v。原以为从仓库根收集会误收 tests/*_test.py
(其在 import 期依赖 psycopg2 与 E2E_DB_PASSWORD),实测否定:pytest.ini 的
testpaths = tests/e2e 已限定范围,从根收集正是 49 个用例且无 error。显式传参作为兜底保留。

计划 Task 2.3 由「依赖基础设施决策」改为「已决策并落地」,仅余 Step 3(CI 上实测一次绿)。
2026-09-18 21:58:56 +08:00
WorkBuddy 70134dd62e docs: STATUS 历史区收敛 951→783 行,修正两处残留假声明,同步治理报告与收尾计划
治理报告「八、遗留」第 2 项(STATUS 后半历史区过长)至此关闭。

STATUS.md(951 → 783 行,净减 168):
- 折叠 2026-09-09 期快照:Phase 01~11 逐项任务表 → 索引表(指向权威结论位置);旧 Phase 13
  首轮表 → 指针;「V1.0 基线状态 + 历史状态记录」两块 → 一段带历史边界声明的治理痕迹。
- 收敛作废/重复块:两块已作废的 Phase 22 历史声明、已作废的 Phase 23「已完成」声明、
  「整体进度总结」里重复的 Phase 24~26 摘要。
- 修正两处残留假声明(本轮新发现):
  ① `P26-T06 初始化Feature Flags | ✅ (application.yml配置)` → 标「不成立」(依据 Phase 22 复验:
     4 份 application*.yml 无 features 块,15 个 flag 实为 Java 字段默认且业务不读取);
  ② `Phase 23(V1.1专项测试)— 已完成 ✅` → 标「已被 2026-09-18 复验推翻,实为部分关闭」。
- 口径更新:UI 契约测试行 13/13 → 14/14(统一执行器 + 已接入 CI),并注明阶段 4 的历史时点值。
- 三个门禁锚点全部保留并复验:UI REDESIGN ACCEPTED BY USER、Phase 07…已通过用户验收、
  管理后台: http://localhost:13520/。

同步文档:
- 治理报告:4.4 漏网文档改为「已处置」;悬空引用表最后一行为已关闭;第七节补 UI 门禁入 CI、
  执行器负向验证、漏网文档处置三行,并改写 STATUS 收敛行;第八节改为「3 项遗留全部关闭」。
- 收尾计划:批 1 Task 1.1 标完成(Step 1~4 证据齐),并更正「15 个契约文件」为实测 14 个;
  验收口径由逐文件执行改为 `node scripts/run-ui-contracts.mjs`。

验证:`node scripts/run-ui-contracts.mjs` → PASS 14/14、退出码 0(含文档基线契约门禁)。
2026-09-18 21:34:47 +08:00
WorkBuddy b2ef60c04c docs: 废止漏网报告 Phase-V11-Deployment-Completion-Report(原地重写)+ 修订两处引用
治理报告 4.4 登记的「保留待决」项至此关闭(用户确认执行)。

- docs/Phase-V11-Deployment-Completion-Report.md:102 行 → 废止声明,含四要素
  (①原文是什么 ②为何失效:16/16 E2E 与 Feature Flags 15 项均被 Phase 22/23 复验推翻
  ③取代者:STATUS.md / OPS.md / V1_1_CHANGELOG.md ④为何保留而不删除)。
  **未删除** —— 本机有「删文件会级联清空父目录」红线,且删除会制造悬空引用。
- docs/Phase-01-V11-Recovery-Completion-Report-2026-09-09.md:引用行标注「已于 2026-09-18 废止」,
  并指明结论以 STATUS/OPS 为准,使引用有落点而非悬空。
- docs/Phase-13A-V11-Completion-Report.md:废止声明中「决定保留待决」改为「已同步原地重写为
  废止声明」,与实况一致。

原文可从 Git 历史取回:git log --all -- docs/Phase-V11-Deployment-Completion-Report.md
2026-09-18 21:34:33 +08:00
WorkBuddy d707fb1f60 ci(ui): 把 14 个 UI 契约门禁接入 CI,并新增统一执行器
Phase 23 收尾计划 批 1 Task 1.1 —— 此前 ui-tests/*.mjs 只在本地手工逐个执行,没有任何 CI
把关:改动破坏了源码结构契约也不会有任何信号。

- 新增 scripts/run-ui-contracts.mjs:逐个 spawn 子进程运行守卫 → 汇总 → 任一失败即非零退出,
  并打印失败守卫的原始输出便于定位。零 npm 依赖(守卫只用 node 内建模块)。
- 两条防退化设计:
  ① 子进程 cwd 固定为仓库根 —— 守卫里 6 个用相对路径、8 个用 import.meta.dirname 自锚定,
     不固定 cwd 会在 CI 上假红;
  ② 守卫数量下限校验(MIN_GUARDS=14,可用 UI_GUARDS_MIN 覆盖)—— 防止守卫被删除或改名后
     依然报绿(Phase 19 教训:扫描清单本身会漏)。
- ci.yml 新增 ui-contracts job(checkout + setup-node + 跑执行器),与既有 test-shell-guard
  同属「守门」类 job。
- 生产 compose 缺失的既有兜底确认可复用:ui-document-baseline-contracts.mjs 对不在版本控制内的
  infrastructure/docker-compose.prod.yml 已做 existsSync 判定并显式打印跳过原因,CI 上不会假红。

验证:
- 基线 node scripts/run-ui-contracts.mjs → PASS 14/14,退出码 0;
- 负向注入(.workbuddy/tmp 下必然失败的守卫)→ FAIL、退出码 1,失败详情可见;
- 数量下限分支以 UI_GUARDS_MIN=99 自检 → 正确拒绝报绿(退出码 1);
- .github/workflows/ci.yml 经 yaml.safe_load 解析通过,jobs 含 ui-contracts。
2026-09-18 21:30:15 +08:00
WorkBuddy c061a55151 docs: 治理报告去掉 bundle 的写死 hash,改为现场可复核判据
上一提交在 D7 行与第七节写上「list-heads → main = 4e18272」,但该 ref 指向会随每次
提交变化 —— 报告一旦入库就已经过期,读者复核必然看到不一致。改为只给判据不给值:

- D7 行 / 第七节:删除固定 hash,改为「`git bundle list-heads <file>` 里的
  `refs/heads/main` 必须等于 `git rev-parse HEAD`;引用时不要写死 hash」。
- 保留 f96dcb8 这一历史事实(restore-checkpoint 覆盖面不足的依据),它是稳定事实。

理由:这与本报告自身正在治的病同族 —— 把「随时间变化的量」写成常量,等于制造下一个
「文档与实况不符」的缺口。
2026-09-18 21:23:56 +08:00
WorkBuddy 75272a5c21 docs: 修正治理报告 D7(bundle 先虚后实)+ 补 9 项缺口补记与可复核证据
D7 是本轮唯一一处「先报完成、后被证伪」的项,如实修正并留证:

- 原写「✅ 本轮重跑 git bundle create 覆盖至最新提交 | 根目录 *.bundle」,
  实测磁盘上并不存在该文件;且当时唯一的 .workbuddy/tmp/restore-checkpoint-2026-09-18.bundle
  其 main 停在 f96dcb8,早于 21:15 的三个治理提交 —— 等于治理成果当时无 bundle 保护。
- 已补建 .workbuddy/backup/docs-governance-2026-09-18.bundle(23.3MB,--all),
  git bundle verify = complete history,list-heads → refs/heads/main = 4e18272。
- 新增补记:安全网类交付(bundle / tag / 门禁)必须用可验证命令复核
  (list-heads 看 ref 指向的提交号、git tag -l、门禁退出码),不能以「命令已执行」为准;
  与本项目此前「假成功」同族(生产 /admin/ 返回 200 欢迎页、E2E 只断言常量字符串)——
  共性为「验证信号与被验证对象解耦」。
- 第七节补 bundle 可复核证据行;第六节「安全网」行注明其覆盖面不足;
  第八节执行纪律补 bundle 路径约定 .workbuddy/backup/<phase>-recovery-<date>.bundle 与 list-heads 校验步骤。
2026-09-18 21:23:19 +08:00
WorkBuddy 4e1827295d docs: 新增 Phase 23 收尾与 UAT 就绪计划 + 2026-09-18 文档治理报告(方案任务四、五)
- 新建 docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md(superpowers 计划格式),
  五批:批 1 快速收尾(ui-tests 15 个契约接入 CI —— 目前**只在本地手工跑**,CI 无把关)/
  批 2 Phase 23 剩余(E2E 场景真实实现 + INV-011 显式守卫 + CI 串联 E2E【需基础设施决策】+
  Phase 22 Deployment/Ops 口径复验)/ 批 3 代码级遗留清偿 / 批 4 UAT 就绪【需用户协作】/
  批 5 Phase 25-26 前置登记。STATUS.md 与客户需求基线计划均已引用(方案明确要求)。

- 新建 docs/文档治理报告-2026-09-18.md:五任务执行核对;本轮 9 项缺口(D1~D9)逐项处置;
  删除/归档/头注/保留四张清单;悬空引用二分结论(naive 扫描 29 处,其中 **24 处是「已删除对象清单」
  里的合法治理证据**,真实悬空 5 处);**docs/ 目录被 safe-delete 钩子整体移除的事故记录与恢复过程**;
  验证证据;4 项遗留。

验证:UI 契约门禁 **14/14 PASS**;ui-document-baseline-contracts 通过(锚点齐全);
database 文档 59 表 / 46 迁移与迁移文件实测一致。
2026-09-18 21:15:39 +08:00
WorkBuddy ae2405f645 docs: 状态文件同步到 2026-09-18 实况(方案任务三)
- STATUS.md:顶部权威行由 "PHASE 20 COMPLETE"(09-17)更新为 09-18 口径(Phase 01~22 关闭 /
  Phase 23 部分关闭 / 三轮横向加固 / 669 tests / 停在 Phase 24 门前);版本演进表补 rc.11~rc.16;
  **合并三处重复的「V1.1 本地部署完成 (2026-09-09)」区块为一处**(996 行 → 946 行)并加
  「历史记录边界」声明;清「Phase 04~05 ⏳ 待开发」等与实测矛盾项。契约锚点全部保住
  (UI REDESIGN ACCEPTED BY USER / Phase 07 已通过用户验收 / 管理后台: http://localhost:13520/)。
- AGENTS.md + STATUS.md:UI 契约门禁口径 13/13 → **14/14**(历史时点行保留原值并注明,
  不篡改史实)。
- JIRA_BACKLOG.md:Epic 总览表补 P14/P15/P19~P23 的 ✅(P23 标「部分关闭」)。
- 客户需求基线计划:Phase 07 标题去 "UI REOPENED";Phase 10~15/19 补 ✅;Phase 20/21 勾选
  checkbox 并注日期;**Phase 22 如实标「未复验」**(口径澄清:计划的 Deployment/Ops 与 STATUS 的
  「部署运维配置扩展(Feature Flag)」是两份不同工作,后者复验结论不构成本节证据);
  Phase 23 标「部分关闭」,第 3/5 项(E2E 场景实现、CI 串联)**不虚勾**。
- PENDING_DECISIONS.md:历史部署声明 10 项按各 Phase Recovery 报告证据回写处置结果
  (9 项已关闭 / 1 项部分);补登 HTTP 与幂等检修遗留的第三方开放待决策 7 项。
- REMAINING_WORK.md:整体重写 —— 原文称 CHANGELOG 3.3MB(实为 4234 字节)、P1-3 指向从未存在的
  Phase-23-Completion-Report.md;改为「已关闭 / 仍未完成 / 原编号索引 / 验收标准」四段,保留原编号
  以便对照历史引用。
- database 两份:TABLE_INVENTORY 40 表 → **59 表**(带「来源迁移」列);MIGRATION_GUIDE
  V001~V011 → **V001~V046(46 个)**;两份均标注「以 backend/src/main/resources/db/migration/ 为准」。
2026-09-18 21:15:29 +08:00
WorkBuddy 4f37df3284 docs: 废止被取代的 Phase-13A 首轮报告与 V1.0 部署方案(原地重写,不用 git rm)
- docs/Phase-13A-V11-Completion-Report.md:2026-09-08 首轮报告自报「✅ 核心链路已完成」,
  已被 Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md(CLOSED,Gate 全项通过)取代。
  该文件因命名差异(`Phase-13A-` 带 13A、缺数字段)不匹配清理清单的 `Phase-XX-` 模式而漏删。
  **原地重写为废止声明**而非 git rm —— 本机 safe-delete 钩子会把「删文件」放大为「删父目录」
  (2026-09-18 实测:一次 git rm 导致整个 docs/ 被移除,已从 Git 完整恢复)。

- 部署架构、运维监控、备份恢复与升级方案.md:补 V1.1 取代头注(方案任务二点名要求,此前遗漏)。

另登记一份**不删**的:docs/Phase-V11-Deployment-Completion-Report.md(2026-09-09,含已被推翻的
「16/16 E2E 通过」)—— 它不在方案删除目标模式内,且被保留文档
Phase-01-V11-Recovery-Completion-Report-2026-09-09.md 援引,删除会制造悬空引用,保留待决。
2026-09-18 21:15:13 +08:00
WorkBuddy f96dcb8aa6 fix(admin): 管理端入口收敛到 /admin/ 子路径,staging 端口 13521,修复生产 /admin/ 假成功
承接 rc.15 的管理端端口收敛(3000 → 13520),本轮把「入口」也收敛到一处:

- staging admin 发布端口 3001 → 13521(保持「生产 + 1」错开),同步
  tests/uat/run-uat.sh 的默认 UAT_ADMIN_URL。
- 管理端产物改为 /admin/ 子路径部署:vite 构建 base=/admin/(dev 仍为 /,开发与
  排查习惯不变)、router history base 跟随 import.meta.env.BASE_URL、三处容器化
  入口(生产 nginx / 本地整套栈 nginx / admin-web 容器内 nginx)统一为「不剥离
  前缀」转发,形态一致。
- 修复生产 nginx /admin/ 的假成功:原为 alias /usr/share/nginx/html/,而那正是
  nginx:alpine 纯净镜像自带的默认页目录(管理端产物不在该容器内),访问返回
  200 + nginx 欢迎页 —— 浏览器不报错、状态码正常,只是打开的不是管理端,比 404
  更难发现。改为 upstream admin_web + proxy_pass,并以真实两跳容器实测:
  /admin/ → 200 且为管理端标题与 /admin/assets/*、/admin/dashboard → 200(SPA
  兜底)、/admin/brand/logo-mark.png → 200、根路径 → 显式 404(用 location = /
  钉住,不依赖「本配置恰好没有 server 级 root」这一偶然事实)。
- 品牌资源引用统一走新增的 src/brand.ts(public/ 资源不受 vite base 影响,原 5 处
  写死根路径,子路径部署下会裂图);favicon 由仓库内并不存在的 /favicon.svg 改为
  存在的品牌标。
- 补全 tests/uat/run-uat.sh 被截断的尾部:原文件停在 `if [ -`,bash -n 不通过、
  脚本根本无法运行;顺带把此前「定义了却无人消费」的 ADMIN_URL 接到管理端可达性
  实测上,并补结果汇总与非零退出码。
- ui-tests:ui-document-baseline-contracts 新增两类一致性断言(staging 发布端口 ⇄
  run-uat.sh 默认入口、子路径部署的五处约束);admin-reference-contracts 的资产
  断言适配 brand 模块(断言「组件经 brand 模块引用」+「brand 模块指向正式资产」)。
  6 条新增断言全部经负向注入验证(注入对应缺陷 → 守卫变红且信息指向正确对象)。

验证:UI 门控 14/14 绿;vue-tsc EXIT=0;vite build EXIT=0 且产物资源均为
/admin/ 前缀;三份 nginx 配置 nginx -t 通过。

注:docs/STATUS.md 含此前的在途文档改动(非本次工作,且其中引用了已被删除的
plans 文件),故未纳入本提交;本轮的口径更新已写入该文件待一并处理。
2026-09-18 20:51:12 +08:00
WorkBuddy 16538d7af5 chore(docs): V1.0 前端设计文档归档至 docs/archive/design-v1.0;为仍被援引的 V1.0 总纲文档加取代头注
- git mv 4 份 UI 类文档(UIUX 视觉规范/组件库与 Design Token/高保真原型/页面级功能清单)入档,
  附 README 说明取代关系:UI 域唯一基线为 docs/superpowers/specs/2026-09-13-* 规格
- 根目录两份 V1.0 WorkBuddy 提示词/总指令:加「已被 V1.1 整合版取代」头注,保留原位
  (AGENTS_SUMMARY 与 Recovery 报告仍援引其行号)
- REST API 规范:加「部分取代」头注(V1.1 增量以数据库与API增量设计为准;WebSocket 章节仍为协议出处)
- 根 CHANGELOG.md:加指针指向 docs/V1_1_CHANGELOG.md
- V1.0 业务设计 12 份与部署运维方案按用户决定保留原位不动
2026-09-18 20:25:58 +08:00
WorkBuddy c77e6fcd63 chore(docs): 删除已被复验推翻或取代的失效历史文档(65 份)
- V1.0 自报式 Phase-XX-Completion-Report 26 份(README 已声明其不可信)
- V1.1 首轮 Phase-XX-V11-Completion-Report 21 份(已被对应 Recovery 版复验推翻/覆盖)
- V1.0 时代审查/发布报告 13 份(CODE_REVIEW/FIX_PLAN/ISSUE_TRACKER/发布与 Go-NoGo/UAT/渗透测试/P0 指令/REAUDIT/两次评估)
- 自declared被取代的旧计划 4 份(RECOVERY_PLAN_2026-09-09、当前进度校正、09-14 两份 superpowers 计划)
- IDEA.md(一行备忘)

引用核查:代码/脚本/CI/配置零引用;STATUS.md 与 REMAINING_WORK.md 中两处现行引用随后续文档同步修正;
Phase-10 Recovery 报告与 2026-09-13 UI spec 中的历史叙述引用按原文保留。
治理详情见 docs/文档治理报告-2026-09-18.md。
2026-09-18 20:23:43 +08:00
WorkBuddy 3ff0c0641a chore(admin): 生产管理端端口 3000 → 13520;守卫扩至三处并容错 CI
生产 admin 容器发布端口原为 127.0.0.1:3000 —— 3000 是本机最易被其他项目
(videoos / ai-callcenter)抢用的端口,与新定的管理端端口 13520 不一致。
已改为 127.0.0.1:13520:80(仍绑定 loopback,对外访问走 nginx 80/443 + /admin/)。

⚠️ 该实体改动无法进入本次提交:infrastructure/docker-compose.prod.yml 位于
.gitignore 的「Secrets and certificates」段,刻意不进版本控制(含按主机定制的
部署参数)。因此本次提交承载的是「决策记录 + 守卫」,端口改动只在部署机生效。

守卫 ui-tests/ui-document-baseline-contracts.mjs:
- 由「两处一致」扩为「三处一致」:admin-web/vite.config.ts(权威声明)、
  docs/STATUS.md(给人照做的)、docker-compose.prod.yml(生产发布端口)。
- 生产 compose 采取「存在才断言」:CI 全新克隆上该文件不存在,硬 read() 会抛
  ENOENT 让门控假红。已实测——移走文件 → 打印 skip 且 EXIT=0;放回 → 断言恢复。
- 明确排除另外两处管理端入口并写清理由:本地整套栈的管理端经 nginx 6688 暴露
  (6688 是 nginx 入口,不是纯管理端端口);staging 的 3001 是为与生产同机共存
  而按「生产 + 1」错开(5433/6380/8081/3001),且被 tests/uat/run-uat.sh 的
  UAT_ADMIN_URL 默认值依赖 —— 改动它们要同步消费方,不该由本守卫断言等于 vite 端口。

docs/STATUS.md:新增「管理端端口口径」表,记录四种管理端入口的端口与取舍理由,
并显式标注生产 compose 不在版本控制内(否则后人会以为改了没生效)。

验证:ui-tests 门控 14/14;负向验证 prod compose 改回 3000 → 精准报
「docker-compose.prod.yml=3000,vite.config.ts=13520」;文件缺失 → skip 且不假红;
还原后 md5 逐字节一致(71a30653…);docker compose config 解析出
published=13520 且 host_ip=127.0.0.1(loopback 绑定未丢失)。
2026-09-18 19:49:50 +08:00
WorkBuddy 9c26d4b7b2 chore(admin-web): 开发端口 3000 → 13520,并禁止端口静默漂移
本机长期并行运行三个项目(videoos / ai-callcenter / 本项目),3000 属最易被
抢用的端口之一。按用户决定改为 13520。

- admin-web/vite.config.ts:server.port 3000 → 13520
- 同处新增 strictPort: true。vite 默认在端口被占用时静默 +1 退到下一个可用端口,
  结果是「文档写 13520、实际跑在 13521」且全程不报错 —— 与本项目反复处理的
  「声明与兑现失联」同族。加上后端口被占用会直接失败。
- docs/STATUS.md「当前运行状态」同步为 13520。
- ui-tests/ui-document-baseline-contracts.mjs:新增端口一致性守卫。

  端口写在两处(vite.config.ts 是权威,STATUS.md 给人照做),只改一处不会让任何
  测试变红,却会让照文档操作的人打不开页面。守卫断言的是「两处一致」而非写死
  13520 —— 允许改端口,允许的是「一起改」,不是「只改一处」。
  两处解析均用「解析失败即失败」写法,避免正则匹配不到时断言退化成与 undefined
  比较而失去意义(本项目踩过的守卫静默失效坑)。
  用 assert.equal 而非 assert.match:match 失败会把整份 STATUS.md(3000+ 行)
  打进报错,噪声会盖掉真正的不一致信息 —— 负向验证时实测确认。

- .gitignore:忽略 admin-web/vite.config.ts.timestamp-*.mjs。
  vite 在 dev server 被强杀(未优雅退出)时遗留该配置临时文件,本机已多次出现。

未改动(有意):
- CORS 不受影响。CorsConfig 默认拒绝所有跨域是刻意设计,且注释已写明「管理端走
  Vite 代理(同源)」—— dev 端口变化不产生跨域。
- infrastructure/docker-compose.prod.yml 的 127.0.0.1:3000:80 是生产 admin 容器
  的发布端口,与开发服务器无关,未动。
- releases/v1.0.0-RC.* 内的 3000 为历史发布快照,属冻结记录,不改写。

验证
- UI 契约门控 14/14 全绿(含新增守卫);vue-tsc --noEmit EXIT=0。
- 负向验证:把 STATUS.md 改回 3000 → 守卫变红并精确报出
  「管理后台端口不一致:STATUS.md=3000,vite.config.ts=13520」及行号;还原后复绿。
- 真实启停:dev server 实跑 http://localhost:13520/(HTTP 200),旧端口 3000 无监听。
- strictPort 负向验证:端口被占用时第二实例立即以
  「Error: Port 13520 is already in use」退出,未漂移到 13521。
- vite build EXIT=0(19.35s)。

注:vite build 直接执行会失败,栈停在 emptyDir(dist) 且全部位于本机
node-safe-delete-shim(批量删除守卫)内,与本次改动无关;改用全新 outDir 复跑即
成功,据此排除「改动导致构建失败」。该现象此前无记录,已写入技能备查。
2026-09-18 19:36:07 +08:00
548 changed files with 38798 additions and 17503 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 / 决策 / 风险编号 -->
+118 -8
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
@@ -42,8 +49,36 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: admin-web/pnpm-lock.yaml
# 这里不能写 cache: 'pnpm'。
# 2026-09-22 实测:runner 镜像(ubuntu-24.04 / runner 2.337.0)**已不再预装
# pnpm**,而 setup-node 的 cache 步骤在 restore 阶段就需要调用 pnpm 可执行文件,
# 于是直接报 "Unable to locate executable file: pnpm",整个 job 在第一步失败、
# 后续步骤全部 skipped —— 表现为 admin-web job 秒红,且与业务代码无关。
# (同批的 miniapp job 没配 cache 所以活了下来,正好反向印证了这一点。)
# 因此:先让 pnpm 存在于 PATH,缓存改为手动配置。
- name: 启用并锁定 pnpm
# corepack 随 Node 分发,runner 上没有 pnpm 但有 corepack。
# 钉 9.15.9 的理由:pnpm 9 与 10 对「是否默认放行依赖 build 脚本」语义相反,
# 而 pnpm-workspace.yaml 里的 onlyBuiltDependencies 正是版本敏感项
# (esbuild / vue-demi 的 postinstall 若被静默阻断,会出现 install 能过、
# build 变红,且错误信息不指向真正原因)。钉死后不再随 runner 镜像升级而变化。
run: |
corepack enable pnpm
corepack prepare pnpm@9.15.9 --activate
pnpm --version
- name: 解析 pnpm store 路径
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: 缓存 pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('admin-web/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Dependencies
working-directory: admin-web
@@ -143,9 +178,45 @@ jobs:
- name: 禁止 skip/pass 空壳测试
run: python3 scripts/check-no-empty-tests.py tests
ui-contracts:
name: UI Contracts (source-structure gates)
runs-on: ubuntu-latest
# Phase 23 遗留(收尾计划 批 1 Task 1.1):UI 契约门禁此前**只在本地手工执行**,
# 没有 CI 把关 —— 即「改动破坏了源码结构契约也不会被发现」。
# 2026-09-19:14 → 15 → 18 个守卫。
# 第 15 项 WXML 结构守卫(此前 14 绿时小程序仍有两个页面编译失败:
# service-history / service-program 各多一个 </view>);
# 第 16/17 项:`this._x()` 无定义(运行期 TypeError,曾致所有请求 Promise 永不 settle)
# 与组件 WXSS 违规选择器(标签/ID/属性选择器);
# 第 18 项:api.js 请求层**行为**契约(桩 wx.request 真跑,非源码断言)。
# 统一执行器负责逐个跑 + 汇总 + 非零退出,
# 并校验守卫数量下限(防止守卫被删除/改名后仍然报绿)。
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# 2026-09-22 修正:本 job 此前注释称「零 npm 依赖,故无需装依赖」,这个声明是错的。
# 至少 phase04 守卫会 import miniapp/node_modules/typescript/lib/typescript.js
# (用 TS 编译器 API 解析小程序源码)。本地能跑通只因为工作区早已装过 miniapp 依赖;
# CI 的每个 job 都在**独立 runner**上,miniapp job 装的东西不会共享过来,
# 于是这里直接 ERR_MODULE_NOT_FOUND。
# 「依赖声明与实际不符」正是本地绿 / CI 红最典型的一类来源。
- name: 安装 miniapp 依赖(phase04 守卫需要 typescript 编译器 API)
working-directory: miniapp
run: npm install
- name: 运行 UI 契约门禁
run: node scripts/run-ui-contracts.mjs
e2e:
name: E2E (live stack)
runs-on: ubuntu-latest
# 防挂死:GH Actions 默认 360 分钟,起栈失败若无人超时会把流水线拖住数小时。
timeout-minutes: 30
# Phase 23 遗留 D-23-D:计划要求 CI 串联 E2E。此前 E2E 从未真正执行过——
# 无 conftest、fixture 不存在、文件在 import 期就崩。本 job 起真实栈后执行。
needs: [backend, migration]
@@ -176,20 +247,56 @@ jobs:
WECHAT_APP_SECRET=
ENV
- name: 起真实栈(postgres + redis + backend)
# 「活栈供给方式」决策(2026-09-18):**在 runner 内用 Compose 起全栈**。
# 备选是 service containers + 在 runner 上 `mvn package` 后 `java -jar`,但那需要为后端
# 另写一套 host 网络配置,等于制造第二条与本地不一致的路径;Compose 全栈则与本地验证
# 路径完全相同(2026-09-18 本机按同一序列实测:中间件三项 healthcheck 全 Healthy、
# backend /health/live 200、seed 幂等可执行、**pytest tests/e2e 49 passed**)。
- name: 校验 compose 配置(秒级快速失败,早于任何构建)
working-directory: infrastructure
run: docker compose up -d --build postgres redis backend
run: docker compose config --quiet
# 2026-09-22:compose 的对象存储已换成 chrislusf/seaweedfs:4.47 并钉版。
# 单独保留这一步的理由:镜像拉取失败在 compose up 里会埋进几十行输出,
# 这里能在起栈前秒级暴露「上游镜像下架」这类外部依赖失效 ——
# 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库
# (minio/minio 现返回 404;教训是「第三方镜像」本身就是外部依赖风险点)。
- name: 校验 seaweedfs 钉版镜像可拉取并打印版本
run: |
docker pull chrislusf/seaweedfs:4.47
docker run --rm chrislusf/seaweedfs:4.47 version
# 保证库为全新:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷是上一轮残留,
# 账号密码会停留在上一轮的 `E2E_OPERATOR_PASSWORD` 上 → 本轮登录 401(表现为各种业务失败)。
- name: 清理可能的残留栈与数据卷
working-directory: infrastructure
run: docker compose down -v --remove-orphans || true
# 构建与起栈分两步:构建失败时的日志不混杂在 up 输出里,定位更快。
- name: 构建 backend 镜像
working-directory: infrastructure
run: docker compose build backend
- name: 起真实栈(postgres + redis + seaweedfs + 建桶 + backend)
# 显式列出 seaweedfs 与 storage-init:backend 的 depends_on 虽会隐式拉起它们,
# 但隐式依赖不利于阅读,且 seaweedfs 的 healthcheck 是 backend 启动的前置条件。
# storage-init 是**一次性**建桶容器 —— SeaweedFS 不会自动建桶,
# PutObject 到不存在的 bucket 会直接失败,少了它照片链路就是断的
# (E2E 对存储零覆盖,不会替你发现)。
working-directory: infrastructure
run: docker compose up -d postgres redis seaweedfs storage-init backend
- name: 等待 backend 就绪(超时即失败,不静默跳过)
# 窗口 90×5s=7.5min:首次构建后还需 Spring 启动 + Flyway 46 个迁移,5min 偏紧。
run: |
for i in $(seq 1 60); do
for i in $(seq 1 90); do
if curl -sf "$E2E_BASE_URL/health/live" > /dev/null; then
echo "backend READY after ${i} checks"; exit 0
fi
sleep 5
done
echo "::error::backend 未在 5 分钟内就绪"
docker compose -f infrastructure/docker-compose.yml logs backend
echo "::error::backend 未在 7.5 分钟内就绪"
docker compose -f infrastructure/docker-compose.yml logs --tail=200 backend
exit 1
- name: Seed E2E 运营账号(迁移不含 operator 种子,全新库无账号可登录)
@@ -199,7 +306,10 @@ jobs:
-v pwd="$E2E_OPERATOR_PASSWORD" -f - < ../tests/e2e/sql/seed_e2e_operators.sql
- name: Run E2E
run: pytest -v
# 显式给出目录:pytest.ini 的 `testpaths = tests/e2e` 已限定范围,但显式传参可防
# 该配置被改动后悄悄扩大收集范围(仓库根另有 tests/*_test.py 不是 E2E 用例,
# 且它们在 import 期即依赖 psycopg2 与 E2E_DB_PASSWORD,会被收集成 error)。
run: pytest tests/e2e -v
- name: Teardown(无论成败都清理并留日志)
if: always()
+30 -1
View File
@@ -10,11 +10,30 @@ package-lock.json
yarn.lock
pnpm-lock.yaml
# 例外:依赖锁文件必须入库,CI 的可复现安装依赖它们。
#
# 2026-09-22 修复背景:上面四条规则此前把 admin-web/pnpm-lock.yaml 与
# miniapp/package-lock.json 一并挡在版本控制之外,于是 CI checkout 后的环境
# 与本机完全不同:
# - admin-web:`pnpm install --frozen-lockfile` 找不到 lockfile → 直接失败
# (frozen 模式的语义就是「必须存在且不可变更」,缺文件不是警告而是错误);
# - miniapp:`npm install` 无 lockfile 约束,版本随上游浮动,
# `npx tsc --noEmit` 的结果不与任何一次本地验证对齐。
# 二者共同导致「本地绿、CI 红」且二者都不可解释。
#
# 只放行每个包真正使用的那把锁:admin-web 走 pnpm,miniapp 走 npm。
# admin-web/package-lock.json 仍然忽略 —— 同一目录并存两把锁会让两种包管理器
# 各自按自己的锁安装,漂移时彼此看不出问题,属于典型的双锁冲突。
!admin-web/pnpm-lock.yaml
!miniapp/package-lock.json
# Build outputs
backend/target/
admin-web/dist/
miniapp/dist/
miniapp/unpackage/
# vite 在 dev server 被强杀(未优雅退出)时会遗留配置临时文件
admin-web/vite.config.ts.timestamp-*.mjs
# IDE
.idea/
@@ -51,7 +70,17 @@ Thumbs.db
.DS_Store
# Secrets and certificates
docker-compose.prod.yml
#
# 2026-09-22 修正:`docker-compose.prod.yml` 原先被列在本段(当作密钥)而排除出
# 版本控制,这是**误分类** —— 该文件不含任何明文密钥:所有敏感值都通过
# `${VAR:?请设置...}` 注入,db 密码走 Docker secrets(./secrets/db_password.txt),
# 文件头注释本身也写着「敏感信息通过 .env 文件注入,不进 Git」。
#
# 后果:backend 的 `DeploymentTests.testDockerComposeProdExists` 断言该文件存在,
# 于是「本地恒绿(文件在工作区)、CI 恒红(checkout 拿不到)」——
# 一次典型的本地绿/CI 红假绿,且从本地永远发现不了。
# 生产部署配置需要可复现、可评审,应与 staging 版一致纳入版本控制。
# 真正的密钥仍由下面三条挡住。
infrastructure/secrets/
infrastructure/certs/
infrastructure/backups/
+2 -1
View File
@@ -22,7 +22,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因
业务 Phase 唯一执行计划:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
当前 UI 重设计唯一总计划:`docs/superpowers/plans/2026-09-13-reference-driven-dual-client-ui-redesign.md`。首批范围(小程序 11 个核心页面 + 管理端框架/工作台)已于 **2026-09-16 通过用户验收(结论:基本达标)**,此前"视觉否决 / REOPENED"的判定随之关闭,UI 契约门禁 13/13 全绿。管理端桌面端目视复验仍待用户执行。
当前 UI 重设计唯一总计划:`docs/superpowers/plans/2026-09-13-reference-driven-dual-client-ui-redesign.md`。首批范围(小程序 11 个核心页面 + 管理端框架/工作台)已于 **2026-09-16 通过用户验收(结论:基本达标)**,此前"视觉否决 / REOPENED"的判定随之关闭,UI 契约门禁 **18/18** 全绿(2026-09-16 时为 13 项,此后新增端口与 `/admin/` 子路径一致性门禁;2026-09-19 连补 4 项 —— 第 15 项 **WXML 结构守卫**:此前全绿时小程序实际有两个页面 `service-history` / `service-program` 因多一个 `</view>` **编译失败**;第 16 项 **私有方法调用守卫**:`this._x()` 无定义会抛 TypeError,且该调用位于 `resolve` 之前时会让**所有请求**的 Promise 永不 settle;第 17 项 **组件 WXSS 选择器守卫**;第 18 项 **api.js 请求层行为契约**(桩掉 `wx.request` 真跑)。前三项说明既有门禁全是源码内容断言、没有一条会解析 WXML/WXSS 或符号)。管理端桌面端目视复验仍待用户执行。
---
@@ -61,6 +61,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因
24. **Service Station Operator 不可浏览全用户库** — 服务站人员只能处理现场预约和指定任务
25. **主办单位关系不自动授予数据权限** — Organization relationship ≠ 用户数据访问权限,权限由 Role+Permission+DataScope 控制
26. **前端 Capability 控制入口** — 用户端各功能入口可见性由服务端 CapabilityService 返回决定,前端不自行拼状态判断
27. **配置与规则治理(V1.0.1 纠偏基线)** — 技术默认值 ≠ 产品正式确认规则。所有未经产品确认的数量/阈值/时间/比例/权重/容量/开关/冷却期,一律按「`DEFAULT + CONFIG`」管理:可配置化(`@ConfigurationProperties`/Feature Flag)、**不得**作为 `LOCKED` 业务规则写死;每个配置有唯一 canonical key,登记 `docs/governance/config-register.md`,未确认项登记 `docs/governance/open-questions.md`,已确认项才入 `docs/governance/decision-register.md`。涉及并发的上限必须有服务器端强校验 + 并发突破测试。详见 `docs/governance/`(config-register / open-questions / decision-register / hardcoded-rule-audit / config-drift-audit)。
---
+3
View File
@@ -1,3 +1,6 @@
> **📌 指针(2026-09-18 头注)**:V1.1 变更记录见 `docs/V1_1_CHANGELOG.md`;发布级变更另见
> `releases/v1.0.0-RC.1|RC.2/`。本文件不再逐版本维护。
# CHANGELOG
所有重要变更将记录在此文件中。
-1
View File
@@ -1 +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/>
+74 -300
View File
@@ -1,322 +1,96 @@
# 丹阳公益婚恋服务平台 V1.0 — 上线前剩余工作
# 丹阳公益婚恋服务平台 V1.1 — 上线前剩余工作
> 本文件为上线前必须完成的工作清单,按优先级排序。
> 交由 WorkBuddy 执行。
> **最后更新:2026-09-18**(原文为 2026-09-01,内容已严重过期,本轮按实测重写)
>
> ⚠️ **重写说明**:本文件此前被多处引用,但内容与实际严重不符 —— 称 `CHANGELOG.md` 有 **3.3MB**(实际
> **4234 字节 / 73 行**);P1-3 指向**已删除**的 `docs/Phase-23-Completion-Report.md`。2026-09-18 文档治理按实测重写,
> 并保留原编号以便对照历史引用。**逐项处置计划与批次划分**见
> `docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md`。
---
## P0 — 上线前必须完成(阻塞性)
## 一、已关闭项(原文标为阻塞 / 重要,实测已完成)
### P0-1: 产品确认16项技术默认值
**文件**: `docs/PENDING_DECISIONS.md`
**问题**: 以下技术默认值已硬编码,但无产品最终确认:
| 决策项 | 当前技术默认值 | 需要确认 |
| 原编号 | 事项 | 结论(2026-09-18 复核) |
|---|---|---|
| 每日推荐数量 | 6 人 | 产品确认 |
| 推荐最低阈值分数 | 60 分 | 产品确认 |
| Pass 冷却时间 | 180 天 | 产品确认 |
| 照片上限 | 6 张 | 产品确认 |
| 兴趣标签上限 | 8 个 | 产品确认 |
| 自我介绍长度上限 | 500 字符 | 产品确认 |
| Privacy 默认值 | 全部 TRUE | 产品确认 |
| 活动是否需要审核 | TRUE | 产品确认 |
| 活动互选最大人数 | 3 人 | 产品确认 |
| 活动互选截止时间 | 48 小时 | 产品确认 |
| 红娘是否允许电话联系 | FALSE | 产品确认 |
| "正在了解"是否自动暂停推荐 | 用户选择 | 产品确认 |
| RelationshipStatus 最终命名 | searching/getting_to_know/paused/matched_successfully/service_exited | 产品确认 |
| Appeal 是否 V1.0 首发 | 否(Feature Flag 预留) | 产品确认 |
| 每日推荐重置时间 | 00:00 Asia/Shanghai | 产品确认 |
**操作**:
1. 产品负责人逐一确认以上16项
2. 确认后更新 `application.yml` 或对应配置类
3. 从 `docs/PENDING_DECISIONS.md` 移除已确认项
4. 更新 `AGENTS.md` 中的规则说明(如有影响)
| P0-1 | 产品确认 16 项技术默认值 | ✅ **已确认 16 项**(见 `docs/PENDING_DECISIONS.md`「已确认决策列表」);仍有 13 项待确认(另见该文件「第三方开放遗留」7 项) |
| P0-2 | 隐私政策最终版本确认 | ✅ `docs/legal/privacy-policy.md` 已存在(2996 字节)。上线前仍需确认是否经法务终审签署 |
| P0-3 | PIA(隐私影响评估)书面记录 | ✅ `docs/security/PIA.md` 已存在(4700 字节) |
| P1-1 | CoreInvariantsTest 补实质性测试 | ✅ **已如实处置**:该类(195 行 / 12 用例)已明确降级为「**常量取值口径守卫,不验证行为**」(DisplayName 已改),真实行为验证转由各 Integration 测试承担(Phase 13 复验)。原文"只是检查常量定义"的批评成立,但处置方式是**如实命名 + 转移**,而非硬塞行为断言 |
| P1-2 | PermissionMatrixTest 补自动化验证 | ✅ 已有 341 行 / 12 用例;权限覆盖另由 `V11PermissionMatrixContractTest`(**扫描全部迁移**)+ `OperatorEndpointPermissionCoverageTest` 承担 |
| P1-3 | 补全 Phase 23 系统测试完成报告 | ✅ `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md` 已存在(结论:**部分关闭**)。**注意原文指向的 `docs/Phase-23-Completion-Report.md` 从未存在** |
| P1-4 | 清理 CHANGELOG 重复内容 | ✅ **不成立**:`CHANGELOG.md` 实际 **4234 字节 / 73 行**,无重复条目问题;已加指向 `docs/V1_1_CHANGELOG.md` 的指针头注(2026-09-18) |
| P2-1 | AccountLifecycle 注销联动完善 | ✅ Phase 21 复验关闭(2026-09-18):**CLOSED 仍可登录、两处 TODO 桩、匿名化死代码、注销障碍设计矛盾**等 6 处缺陷已修 |
| P2-2 | PhotoService Presigned URL 实现 | ⚠️ **部分**:`PhotoService` 魔数校验恒失败、PDF 被误挡等已修(Phase 10 复验);`POST /me/photos/upload-token` / 下载凭证 / EXIF 清理的落地程度**待确认** |
| P2-3 | WebSocket 在线状态管理 | ✅ Phase 09 复验关闭(2026-09-16):下行链路死代码、握手鉴权、Unmatch 不联动会话等 4 处缺陷已修 |
---
### P0-2: 隐私政策最终版本确认
## 二、仍未完成(按优先级)
**问题**: Go/No-Go 检查单标注"待确认",为上线前绝对阻塞项。
### A. 阻塞 Phase 24(UAT)启动
**操作**:
1. 确认隐私政策文本(用户数据采集、存储、使用、共享、权利)
2. 确认用户协议文本
3. 将最终版本放入 `docs/legal/` 目录
4. 更新小程序登录页和设置页的协议链接
1. **Phase 23 剩余**:E2E 场景真实实现 + CI 串联 E2E —— 需「活栈供给方式」**基础设施决策**
(占位文件头已保留场景清单:首页 11 / 本地资格 11 / 互选 8 / 权限 10 / 预约 7 项)。
2. **管理端桌面端目视复验** —— 需**用户执行**。
3. **`docs/PENDING_DECISIONS.md` 13 项待决策** —— 需产品确认。
> 在 A 组完成前,**不应宣布「失败为零可进入 UAT」**(Phase 23 Gate 要求端到端链路被真实执行)。
### B. 上线前(运维 / 产品,需人工)
- **P0-4** 微信小程序审核提交(人工)。
- **P0-5** SSL 证书申请与 DNS 配置(人工)。
- **Phase 22 Deployment / Ops 口径 5 项未复验**:删除镜像跳过测试的发布路径、生产关 Mock WeChat/演示 Seed/Debug/Swagger、
端口与依赖检测、**备份加密 + 真实恢复演练**、Rollback 目标明确。
⚠️ 该 Phase 与 `STATUS.md` 所述「Phase 22 部署运维配置扩展(Feature Flag)」**是两份不同工作**。
- **Phase 25 运营素材**(真实 Banner/站点/咨询/课堂/反诈内容,不 Seed 假数据)、**Phase 26 上线前 13 项 smoke**。
### C. 代码级遗留(不阻塞 UAT,但需清偿)
- **Phase 21**:① 红娘服务进行中拦截**硬编码 `0`** —— 根因是数据模型**没有 `matchmaker_services` 表**,
拦截项在设计上无法落地(建表属扩权,待产品确认存储模型);② `getPrivacySettings` / `updatePrivacySettings` 仍是 TODO 桩。
- **Phase 22**:T01~T05 五个模块开关 + T07 私有桶为**死配置**(配置暴露但从未被业务读取)。
- **任务级 ⏳**:P16-T13(互选超时接线)、P13A-T31(随访审计)、P13A-T33(`RiskEvent source=counseling`)、
P04-T10(Onboarding 本地资格步骤)。
---
### P0-3: PIA(隐私影响评估)书面记录
## 三、原编号索引(便于对照历史引用)
**问题**: 9个场景需要书面记录,当前无记录。
**操作**:
1. 按以下场景撰写PIA记录:
- 实名认证(身份证采集)
- 照片存储与展示
- 位置/地区信息
- 聊天消息存储
- 活动报名数据
- 红娘服务数据
- 举报与风控数据
- 账号注销与数据删除
- 跨组织数据访问
2. 保存到 `docs/security/PIA.md`
| 原编号 | 原文标题 | 本文件位置 |
|---|---|---|
| P0-1 | 产品确认 16 项技术默认值 | 一、已关闭 |
| P0-2 | 隐私政策最终版本确认 | 一、已关闭(待法务终审) |
| P0-3 | PIA 书面记录 | 一、已关闭 |
| P0-4 | 提交微信小程序审核 | 二、B |
| P0-5 | SSL 证书申请与 DNS 配置 | 二、B |
| P1-1 | CoreInvariantsTest 补充实质性测试 | 一、已关闭 |
| P1-2 | PermissionMatrixTest 补充自动化验证 | 一、已关闭 |
| P1-3 | 补全 Phase 23 系统测试完成报告 | 一、已关闭 |
| P1-4 | 清理 CHANGELOG.md 重复内容 | 一、已关闭(原描述不成立) |
| P2-1 | AccountLifecycle 注销联动完善 | 一、已关闭 |
| P2-2 | PhotoService Presigned URL 实现 | 一、部分 |
| P2-3 | WebSocket 在线状态管理 | 一、已关闭 |
---
### P0-4: 提交微信小程序审核
## 四、验收标准(更新)
**问题**: 用户端核心入口,无审核通过的小程序无法服务用户。
**操作**:
1. 在微信公众平台提交小程序审核
2. 确保所有页面符合微信小程序审核规范
3. 配置好 `app.json` 中的类目和标签
4. 准备好隐私政策链接(见 P0-2)
| 项目 | 验收条件 | 状态 |
|---|---|---|
| P0-1 | 16 项全部产品确认,配置正确,文档更新 | ✅ 已完成 |
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 | ✅ 文件已就位(待终审确认) |
| P0-3 | `PIA.md` 存在,覆盖全部 9 个场景 | ✅ 文件已就位 |
| P1-1 | CoreInvariantsTest 用例如实命名;真实行为由 Integration 测试覆盖 | ✅ 已如实处置 |
| P1-2 | 权限矩阵 9×18 覆盖,实际调用验证 | ✅ 由契约守卫承担 |
| P1-3 | Phase 23 完成报告存在且内容完整 | ✅ 已存在(部分关闭) |
| P1-4 | `CHANGELOG.md` < 100KB,无重复条目 | ✅ 已满足(4.2KB) |
| P0-4 | 微信审核提交并通过 | ⬜ 待人工 |
| P0-5 | HTTPS 正常,证书有效 | ⬜ 待人工 |
| Phase 23 剩余 | E2E 场景实现 + CI 串联 | ⬜ 待决策 |
| Phase 24 | UAT 分角色验收,阻断缺陷为零 | ⬜ 待用户确认启动 |
---
### P0-5: SSL证书申请与DNS配置
**问题**: 生产HTTPS必需。
**操作**:
1. 申请SSL证书(Let's Encrypt 或商业证书)
2. 配置生产域名 DNS 解析(`match.danyang.gov.cn` 或约定域名)
3. 将证书放入 `infrastructure/certs/`
4. 验证 `nginx-production.conf` 中的证书路径
---
## P1 — 上线前应该完成(重要)
### P1-1: CoreInvariantsTest 补充实质性测试
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java`
**问题**: 当前测试只是检查常量定义,不是真正的业务逻辑验证。
**需要实现**(每个不变量需要真实业务逻辑测试):
```java
// INV-001: Required不可自动放宽
@Test
void testRequiredConditionNotRelaxed() {
// 创建用户A(要求对方学历=本科)和用户B(学历=专科)
// B进入A的推荐池 → 应该被Required条件过滤
// 验证B不在推荐结果中
}
// INV-002: 未Mutual不可聊天
@Test
void testCannotMessageWithoutMutual() {
// 用户A Like用户B,但B未Like A
// 调用发送消息接口 → 应返回403/禁止
}
// INV-003: Block优先级最高
@Test
void testBlockStopsAllInteractions() {
// A Block B后
// 验证B不在A的推荐中
// 验证A Like B后自动取消
// 验证A和B的Match自动解除
}
// INV-004: 双向并发Like只创建一个Match
@Test
void testConcurrentLikeCreatesOneMatch() {
// 线程A: A Like B
// 线程B: B Like A(并发)
// 验证只创建1个Match记录
}
// INV-005: 活动名额不超卖
@Test
void testActivityCapacityNotOversold() {
// 活动剩余1个名额
// 线程A和线程B同时报名
// 验证只有1人成功,1人被拒绝
}
// INV-006: 单向活动意向不泄露
@Test
void testActivityInterestNotDisclosed() {
// A选择B,但B没选A
// 验证B的查询结果中看不到A的选择
}
// INV-007: 红娘不能替用户Like
@Test
void testMatchmakerCannotLikeForUser() {
// 红娘角色调用Like接口 → 应返回403
}
// INV-008: 非授权Operator不能读取身份证
@Test
void testUnauthorizedOperatorCannotViewIdCard() {
// reviewer角色调用敏感身份查看接口 → 应返回403
}
// INV-009: 非案件上下文不能读取聊天
@Test
void testNoChatAccessOutsideCaseContext() {
// risk_operator无案件授权调用聊天记录 → 应返回403
}
// INV-010: 跨Organization不能越权
@Test
void testCrossOrgCannotAccess() {
// Organization A的用户,不能访问Organization B的数据
}
```
**注意**: 测试应使用真实数据库(集成测试模式),通过 `@SpringBootTest` + Testcontainers 或指定测试数据库。
---
### P1-2: PermissionMatrixTest 补充自动化验证
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/PermissionMatrixTest.java`
**问题**: 当前只有权限矩阵定义,没有实际API调用验证。
**需要实现**:
```java
@Test
void testPermissionMatrixAllRoles() {
// 遍历9个角色 × 18个权限
// 每个组合:
// 有权限 → 调用对应API → 应返回2xx
// 无权限 → 调用对应API → 应返回403
}
// 需要验证的角色:
// super_admin, platform_admin, organization_admin,
// reviewer, matchmaker, activity_manager,
// risk_operator, volunteer, data_viewer
// 需要验证的权限模块:
// user.view, user.sensitive.view, user.status.change,
// review.view, review.process,
// matchmaker.view, matchmaker.assign, matchmaker.service,
// activity.view, activity.create, activity.review_registration,
// risk.report.view, risk.report.process, risk.user.ban,
// organization.manage, permission.manage,
// analytics.view, data.export
```
**验证方式**: 通过 `MockMvc` 或 `RestAssured` 调用管理端API,使用不同角色的JWT Token认证。
---
### P1-3: 补全 Phase 23 系统测试完成报告
**文件**: `docs/Phase-23-Completion-Report.md`
**问题**: Phase 23(系统测试)缺少完成报告。
**需要包含**:
- 执行的测试类型和数量
- 测试结果(通过/失败/阻塞)
- 安全测试结果(SQL注入/XSS/CSRF等)
- 并发测试结果
- 发现的问题和修复情况
---
### P1-4: 清理 CHANGELOG.md 重复内容
**文件**: `CHANGELOG.md`
**问题**: 文件大小3.3MB,存在大量重复条目(同一个Phase变更被复制数十次)。
**操作**:
1. 去重,保留每项变更的唯一记录
2. 按版本(v1.0.0-rc.1, v1.0.0)组织
3. 文件压缩到合理大小(目标 < 100KB)
---
## P2 — 建议完成(非阻塞)
### P2-1: AccountLifecycle 注销联动完善
**现状**: 代码已实现,STATUS.md 标记为TODO。
**验证**: 确认注销后以下联动正确:
- 推荐停止
- 聊天停止(新消息禁止)
- 红娘服务停止
- 活动处理(未开始的按规则)
- 历史关系保留但标记为已注销
---
### P2-2: PhotoService Presigned URL 实现
**现状**: 代码骨架存在,功能未完整实现。
**需要完成**:
- `PhotoService` 生成 MinIO Presigned URL
- 上传凭证接口 `POST /me/photos/upload-token`
- 下载凭证接口(私有图片访问)
- EXIF信息清理逻辑
---
### P2-3: WebSocket 在线状态管理
**现状**: 骨架实现。
**需要完善**:
- 用户在线/离线状态跟踪
- 消息送达状态(已发送/已读)
- WebSocket 重连后的状态恢复
- 通知推送(`notification.new` 事件)
---
## 执行顺序建议
```
1. P0-1 (产品确认) → 产品负责人
2. P0-2 (隐私政策) → 产品/法务
3. P0-3 (PIA记录) → 安全负责人
4. P1-1 (Invariants测试) → 研发
5. P1-2 (权限矩阵测试) → 研发
6. P1-3 (Phase23报告) → 研发
7. P1-4 (CHANGELOG) → 研发
8. P0-4 (微信审核) → 产品
9. P0-5 (SSL/DNS) → 运维
10. P2-1 ~ P2-3 → 研发(可并行)
```
---
## 验收标准
每项完成后应满足:
| 项目 | 验收条件 |
|---|---|
| P0-1 | 16项全部产品确认,配置正确,文档更新 |
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 |
| P0-3 | PIA.md 存在,覆盖全部9个场景 |
| P1-1 | CoreInvariantsTest 10个用例全部包含真实业务逻辑 |
| P1-2 | PermissionMatrixTest 9×18全部覆盖,实际调用验证 |
| P1-3 | Phase-23-Completion-Report.md 存在且内容完整 |
| P1-4 | CHANGELOG.md < 100KB,无重复条目 |
| P0-4 | 微信审核提交并通过 |
| P0-5 | HTTPS正常,证书有效 |
---
*最后更新: 2026-09-01*
*最后更新: 2026-09-18 — 按实测重写(原文 2026-09-01,含 3 处与实际不符的陈述)*
+5
View File
@@ -1,3 +1,8 @@
> **⚠️ 部分取代(2026-09-18 头注)**:本文件为 V1.0 REST API 全量规范。V1.1 增量(Banner、
> 服务站、预约、咨询、本地资格、首页聚合等)以 `docs/善缘丹阳 V1.1 数据库与API增量设计.md` 为准,
> 路径收敛以 `docs/api/API_INVENTORY.md` 为准。本文件仍是后端代码注释与契约测试援引的协议出处
> (如 WebSocket 事件信封第 125 章),该部分继续有效。
# 丹阳公益婚恋服务平台
# V1.0 REST API 接口详细设计规范
+1 -1
View File
@@ -1 +1 @@
1.1.0-RC.1
1.1.0
@@ -1,3 +1,7 @@
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 项目规则总纲(333 章),最高执行文件已由
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.0 核心规则中仍生效的
> 部分已摘要进 `AGENTS.md`。本文件保留原位(`AGENTS_SUMMARY.md` 与历史报告仍援引),其余内容不再生效。
# 丹阳公益婚恋服务平台
# V1.0 WorkBuddy开发执行总指令、项目规则与任务拆解
@@ -1,3 +1,8 @@
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 首轮启动提示词,最高执行文件已由
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.1 需求以 `AGENTS.md`
> 点名的六份修订文档为准。本文件保留原位,仅作为 V1.0 规则来源与 Phase Gate 行号出处
> (`docs/Phase-XX-V11-Recovery-*` 报告援引)被继续引用,其余内容不再生效。
# 丹阳公益婚恋服务平台
# WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件
+5 -1
View File
@@ -2,7 +2,11 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- 原指向一个 SVG 图标文件,但仓库内并不存在该文件(public/ 下只有 brand/),
属既有的 404;且以 / 开头的绝对路径不会随 vite base 变化,
在 /admin/ 子路径部署下即使补上文件也取不到。
改为指向已存在、且会被 vite 自动加上 base 前缀的品牌标。 -->
<link rel="icon" type="image/png" href="/brand/logo-mark.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>丹阳公益婚恋 - 运营管理后台</title>
</head>
+14 -3
View File
@@ -15,9 +15,20 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
# 根路径没有内容:管理端产物挂在 /admin/ 子路径下(构建 base=/admin/)。
# 显式 301 而不是返回 nginx 默认页或 404,避免"打开有东西但其实是别的页面"的假成功。
location = / {
return 301 /admin/;
}
# SPA:/admin/** 映射到产物根目录。
# 上游入口(生产 nginx、本地整套栈 nginx)都**不剥离** /admin 前缀地转发到本容器,
# 因此这里必须自己认识这个前缀。
# try_files 的兜底写成 URI(/admin/index.html)而不是文件路径:与 alias 配合时,
# 写文件路径会被再次拼接 alias 而失效 —— 这是 alias + try_files 的已知坑。
location /admin/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /admin/index.html;
}
# API proxy to backend
+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": {
+2180
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -1,3 +1,27 @@
# 本目录只有一个前端包,不是 pnpm workspace。
#
# 2026-09-22 修复背景:
# 本文件原先只有两行 allowBuilds(esbuild / vue-demi),既没有 packages 声明,
# 也没写实际取值。pnpm 一旦检测到工作目录下存在 pnpm-workspace.yaml,
# 就会强制进入 workspace 模式,随后因读取不到 packages 而直接报错退出:
#
# ERROR packages field missing or empty
#
# 结果是 admin-web 的 `pnpm install --frozen-lockfile` 在任何环境都装不上依赖
# (本地与 CI 表现一致),表现为 admin-web job 秒红,且与业务代码无关。
#
# allowBuilds 是 pnpm 10 才引入的配置项;pnpm 9 不认识它。这意味着
# 「这个残缺文件是否被接受」取决于 runner 上的 pnpm 版本 —— 典型的版本漂移陷阱。
# 因此这里显式声明单包 workspace,并在 CI 中锁死 pnpm 版本(见 .github/workflows/ci.yml),
# 让「workspace 模式」与「只读 RFC2119 白名单」两条路径都不再受 runner 升级影响。
#
# esbuild 与 vue-demi 的 postinstall 是必需步骤(前者下载平台原生二进制,
# 后者生成 Vue 版本桥接),允许它们执行。
packages:
- '.'
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
onlyBuiltDependencies:
- esbuild
- vue-demi
+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 {
+14
View File
@@ -0,0 +1,14 @@
// 品牌静态资源的运行时前缀。
//
// admin-web/public/ 下的文件不受 vite `base` 影响(无论 base 是什么,都会被原样复制到
// 构建产物的根目录),因此 .vue 模板里若写死 `src="/brand/xxx.png"`,在产物部署于
// /admin/ 子路径时,浏览器会去站点根路径请求,最终 404(表现为 logo / 登录插画裂图)。
//
// 结论:public/ 下的运行时引用必须显式带上 import.meta.env.BASE_URL。
// 这里集中导出,避免每处各写一遍拼接而在改 base 时漏改。
const BRAND_BASE = `${import.meta.env.BASE_URL}brand/`
export const BRAND_LOGO = `${BRAND_BASE}logo-mark.png`
export const BRAND_LOGIN_SCENE = `${BRAND_BASE}login-illustration.jpg`
export const BRAND_ACTIVITY_FALLBACK = `${BRAND_BASE}activity-default.jpg`
export const BRAND_BACKDROP = `${BRAND_BASE}admin-danyang-backdrop.jpg`
+120 -97
View File
@@ -2,7 +2,7 @@
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
<div class="scene-brand">
<img src="/brand/logo-mark.png" alt="善缘丹阳" />
<img :src="BRAND_LOGO" alt="善缘丹阳" />
<div class="scene-brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
@@ -27,7 +27,7 @@
<el-container class="admin-shell">
<el-aside :width="isCollapse ? '76px' : '208px'" class="admin-sidebar">
<div class="brand" :class="{ compact: isCollapse }">
<img class="brand-mark" src="/brand/logo-mark.png" alt="" />
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
<div v-if="!isCollapse" class="brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
@@ -129,6 +129,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
@@ -173,8 +174,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 },
@@ -216,7 +217,7 @@ const currentRoleName = computed(() => {
if (perms.includes('counseling.service.view')) return '公益咨询人员'
if (perms.includes('service_appointment.view')) return '服务站运营人员'
if (perms.includes('review.view')) return '资料审核人员'
if (perms.includes('matchmaker.service.view')) return '公益红娘'
if (perms.includes('matchmaker.view')) return '公益红娘'
return '运营人员'
})
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
@@ -260,18 +261,23 @@ function handleLogout() {
min-height: 100vh;
padding: 18px 34px 14px;
overflow: hidden;
background: #fff7f7 url('/brand/admin-danyang-backdrop.jpg') center / cover fixed no-repeat;
/* 背景图是 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'));
}
.scene-header {
display: grid;
max-width: 1840px;
max-width: var(--admin-shell-max-width);
height: 188px;
margin: 0 auto;
grid-template-columns: minmax(640px, 1fr) auto;
grid-template-rows: 112px 76px;
align-items: center;
column-gap: 32px;
column-gap: var(--space-8);
}
.scene-brand,
@@ -290,23 +296,23 @@ function handleLogout() {
.scene-slogan {
display: flex;
padding-right: 24px;
padding-right: var(--space-6);
align-items: flex-end;
flex-direction: column;
color: var(--admin-coral);
font-family: KaiTi, STKaiti, serif;
color: var(--admin-coral-strong);
font-family: var(--font-family-brand);
line-height: 1.05;
transform: rotate(-7deg);
}
.scene-slogan span {
font-size: 17px;
font-size: var(--font-size-lg);
}
.scene-slogan strong {
margin-top: 4px;
font-size: 25px;
font-weight: 500;
margin-top: var(--space-1);
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-medium);
}
.scene-brand-copy {
@@ -314,18 +320,18 @@ function handleLogout() {
margin-left: 10px;
align-items: flex-start;
flex-direction: column;
color: var(--admin-coral);
color: var(--admin-coral-strong);
}
.scene-brand-copy strong {
font-size: 27px;
font-size: var(--font-size-4xl);
line-height: 1.15;
letter-spacing: 2px;
}
.scene-brand-copy span {
margin-top: 5px;
font-size: 12px;
font-size: var(--font-size-xs);
letter-spacing: 4px;
}
@@ -333,18 +339,18 @@ function handleLogout() {
min-width: 370px;
margin-left: 28px;
padding-left: 28px;
border-left: 1px solid rgba(244, 95, 104, 0.22);
border-left: 1px solid var(--admin-coral-border);
}
.scene-title h1 {
font-size: clamp(26px, 2.2vw, 38px);
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: #66717a;
color: var(--color-text-secondary);
}
.scene-benefits {
@@ -352,40 +358,41 @@ function handleLogout() {
align-self: start;
justify-content: flex-start;
gap: clamp(38px, 5vw, 92px);
padding: 4px 0 0 50px;
padding: var(--space-1) 0 0 50px;
}
.scene-benefits span {
display: grid;
grid-template-columns: 36px auto;
column-gap: 8px;
color: var(--admin-coral);
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: 30px;
font-size: var(--font-size-4xl);
}
.scene-benefits b {
font-size: 13px;
font-size: var(--font-size-sm);
}
.scene-benefits small {
margin-top: 2px;
color: #7b7f86;
font-size: 10px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
white-space: nowrap;
}
.admin-shell {
width: min(1840px, 100%);
width: min(var(--admin-shell-max-width), 100%);
height: calc(100vh - 238px);
min-height: 560px;
margin: 0 auto;
overflow: hidden;
background: var(--admin-shell-bg);
border: 1px solid rgba(255, 255, 255, 0.9);
border: 1px solid var(--color-bg-overlay-strong);
border-radius: var(--admin-shell-radius);
box-shadow: var(--admin-shadow);
backdrop-filter: blur(14px);
@@ -396,23 +403,23 @@ function handleLogout() {
flex-direction: column;
overflow: hidden;
color: var(--admin-sidebar-text);
background: linear-gradient(180deg, #202d38 0%, #192733 100%);
border-right: 1px solid rgba(255, 255, 255, 0.06);
transition: width 0.2s ease;
background: var(--admin-sidebar-gradient);
border-right: 1px solid var(--admin-sidebar-hairline-soft);
transition: width var(--motion-normal) var(--ease-standard);
}
.brand {
display: flex;
height: 82px;
padding: 17px 16px;
padding: 17px var(--space-4);
align-items: center;
gap: 11px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--admin-sidebar-hairline);
}
.brand.compact {
justify-content: center;
padding: 16px 8px;
padding: var(--space-4) var(--space-2);
}
.brand-mark {
@@ -430,15 +437,15 @@ function handleLogout() {
}
.brand-copy strong {
color: #fff;
font-size: 17px;
color: var(--color-text-inverse);
font-size: var(--font-size-lg);
letter-spacing: 1px;
}
.brand-copy span {
margin-top: 4px;
color: #b5c0c8;
font-size: 10px;
margin-top: var(--space-1);
color: var(--color-text-on-dark-muted);
font-size: var(--font-size-micro);
letter-spacing: 1.5px;
}
@@ -452,49 +459,54 @@ function handleLogout() {
background: transparent;
}
/* 主导航行项:桌面密度下取 44px(兼顾触屏笔记本,且远高于 WCAG 2.5.8 的 24px 下限) */
.sidebar-menu :deep(.el-menu-item) {
height: 41px;
height: var(--tap-min);
margin: 2px 0;
color: var(--admin-sidebar-text);
border-radius: 9px;
border-radius: var(--border-radius-sm);
transition: background var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.sidebar-menu :deep(.el-menu-item:hover) {
color: #fff;
color: var(--color-text-inverse);
background: var(--admin-sidebar-surface);
}
/* 激活态原先压的是明亮珊瑚渐变(#f45f68→#ee727b),白字只有 3.14:1;
改用达标渐变(两端 4.60:1 / 5.33:1)。 */
.sidebar-menu :deep(.el-menu-item.is-active) {
color: #fff;
font-weight: 650;
background: linear-gradient(135deg, #f45f68, #ee727b);
box-shadow: 0 8px 18px rgba(244, 95, 104, 0.26);
color: var(--color-text-inverse);
font-weight: var(--font-weight-semibold);
background: var(--gradient-brand-solid);
box-shadow: var(--shadow-brand);
}
.sidebar-menu :deep(.el-icon) {
font-size: 17px;
font-size: var(--font-size-lg);
}
.sidebar-motto {
display: flex;
margin: 8px 15px 2px;
padding: 14px 12px;
margin: var(--space-2) 15px 2px;
padding: 14px var(--space-3);
align-items: center;
gap: 11px;
color: #ff969c;
font-family: KaiTi, STKaiti, serif;
font-size: 15px;
color: var(--admin-coral-on-dark);
font-family: var(--font-family-brand);
font-size: var(--font-size-md);
line-height: 1.45;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--admin-sidebar-hairline);
}
.sidebar-motto .el-icon {
font-size: 31px;
font-size: var(--font-size-4xl);
}
.collapse-control {
display: flex;
height: 42px;
min-height: var(--tap-min);
margin: 2px 10px 10px;
align-items: center;
justify-content: center;
@@ -503,10 +515,14 @@ function handleLogout() {
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-sm);
transition: color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard);
}
.collapse-control:hover {
color: #fff;
color: var(--color-text-inverse);
background: var(--admin-sidebar-surface);
}
.workspace {
@@ -515,12 +531,12 @@ function handleLogout() {
.admin-topbar {
display: flex;
height: 64px;
padding: 0 24px;
height: var(--header-height);
padding: 0 var(--space-6);
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f2;
background: var(--color-bg-overlay-strong);
border-bottom: 1px solid var(--color-border-light);
}
.page-context,
@@ -532,15 +548,18 @@ function handleLogout() {
.page-context {
min-width: 180px;
gap: 12px;
gap: var(--space-3);
}
.mobile-collapse {
display: none;
color: #56616a;
min-width: var(--tap-icon);
min-height: var(--tap-icon);
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-sm);
}
.header-tools {
@@ -552,57 +571,59 @@ function handleLogout() {
}
.global-search :deep(.el-input__wrapper) {
background: #f6f7f8;
border-radius: 9px;
box-shadow: inset 0 0 0 1px #e8eaed;
background: var(--color-bg-input);
border-radius: var(--border-radius-sm);
box-shadow: inset 0 0 0 1px var(--color-border);
}
.notification-button {
position: relative;
display: grid;
width: 36px;
height: 36px;
color: #4f5b65;
width: var(--tap-icon);
height: var(--tap-icon);
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 9px;
border-radius: var(--border-radius-sm);
place-items: center;
transition: background var(--motion-fast) var(--ease-standard);
}
.notification-button:hover {
background: #f6f7f8;
background: var(--color-bg-input);
}
/* 角标字号原为 9px(低于可读下限),提到 11px,容器同步放大以容纳 */
.notification-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 15px;
height: 15px;
min-width: var(--space-4);
height: var(--space-4);
padding: 0 3px;
color: #fff;
font-size: 9px;
line-height: 15px;
color: var(--color-text-inverse);
font-size: var(--font-size-micro);
line-height: var(--space-4);
text-align: center;
background: var(--admin-coral);
border-radius: 999px;
background: var(--admin-coral-solid);
border-radius: var(--border-radius-pill);
}
.user-info {
gap: 8px;
gap: var(--space-2);
cursor: pointer;
}
.user-info .el-avatar {
color: #fff;
background: var(--admin-coral);
color: var(--color-text-inverse);
background: var(--admin-coral-solid);
}
.user-info strong {
max-width: 92px;
overflow: hidden;
font-size: 13px;
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -610,27 +631,28 @@ function handleLogout() {
.admin-content {
padding: 0;
overflow: auto;
background: var(--admin-page-bg);
background: var(--color-bg-page);
}
.scene-footer {
display: flex;
max-width: 1840px;
height: 32px;
max-width: var(--admin-shell-max-width);
height: var(--space-8);
margin: 0 auto;
align-items: flex-end;
gap: 10px;
color: #8d8587;
font-size: 11px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.scene-footer strong {
color: var(--admin-coral);
font-size: 14px;
color: var(--admin-coral-strong);
font-size: var(--font-size-base);
}
.scene-footer small {
margin-left: auto;
font-size: var(--font-size-micro);
letter-spacing: 4px;
}
@@ -648,15 +670,15 @@ function handleLogout() {
}
.scene-slogan {
padding-right: 12px;
padding-right: var(--space-3);
}
.scene-slogan span {
font-size: 14px;
font-size: var(--font-size-base);
}
.scene-slogan strong {
font-size: 20px;
font-size: var(--font-size-2xl);
}
.scene-brand > img {
@@ -665,7 +687,7 @@ function handleLogout() {
}
.scene-brand-copy strong {
font-size: 22px;
font-size: var(--font-size-2xl);
}
.scene-title {
@@ -674,8 +696,9 @@ function handleLogout() {
padding-left: 18px;
}
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
.scene-title h1 {
font-size: 27px;
font-size: var(--font-size-3xl);
}
.scene-benefits {
@@ -702,7 +725,7 @@ function handleLogout() {
}
.admin-sidebar {
width: 76px !important;
width: var(--sidebar-width-collapsed) !important;
}
.brand-copy,
@@ -736,7 +759,7 @@ function handleLogout() {
}
.admin-topbar {
padding: 0 12px;
padding: 0 var(--space-3);
}
}
</style>
@@ -80,6 +80,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/**
* U32 活动签到工作台(Phase 15)
*
@@ -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 '-'
@@ -183,13 +184,13 @@ onMounted(loadActivities)
}
.form-hint {
margin-top: 8px;
color: #909399;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
}
.opt-type {
float: right;
color: #909399;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
}
.result-card {
margin-top: 12px;
@@ -42,6 +42,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
@@ -55,7 +56,7 @@ 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
@@ -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) {
@@ -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() {
@@ -434,8 +435,8 @@ onMounted(() => {
}
.form-hint {
margin-top: 4px;
color: #8a90a2;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
line-height: 1.5;
}
.partner-row {
@@ -213,8 +213,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client'
import { token } from '@/theme/tokens'
const loading = ref(false)
const dateRange = ref<[string, string]>([
@@ -227,87 +229,108 @@ const safetyStats = ref<any>({})
const v11 = ref<any>({})
const dailySummary = ref<any[]>([])
const funnelStages = [
{ key: 'registered', label: '累计注册', value: 0, rate: null as string | null, color: '#409EFF' },
{ key: 'verified', label: '已实名', value: 0, rate: null as string | null, color: '#67C23A' },
{ key: 'approved', label: '审核通过', value: 0, rate: null as string | null, color: '#E59A32' },
{ key: 'active', label: '7日活跃', value: 0, rate: null as string | null, color: '#E96B6B' }
]
// 漏斗卡与安全卡一律**派生自上面的 ref**,不再各自持有一份可变副本。
//
// 历史缺陷(2026-09-23 渲染态实测发现):这三组曾是 `const x = [...]` 普通数组,
// loadFunnel() 直接对元素赋值(`x[0].value = ...`),模板读的却是同一个数组 ——
// 非响应式写入不触发重渲染,于是卡片恒显示 0,而同一张卡里的汇总表(走 ref)
// 显示 12846/9217/8462/3812,界面自相矛盾。
// 改为 computed 派生后两者同源,结构上不可能再对不上。
const funnelStages = computed(() => [
{
key: 'registered', label: '累计注册',
value: funnelStats.value.totalRegistered || 0,
rate: null as string | null, color: token('--color-info')
},
{
key: 'verified', label: '已实名',
value: funnelStats.value.verifiedCount || 0,
rate: funnelStats.value.verifyRate || '-', color: token('--color-success')
},
{
key: 'approved', label: '审核通过',
value: funnelStats.value.profileApproved || 0,
rate: funnelStats.value.approvalRate || '-', color: token('--color-warning')
},
{
key: 'active', label: '7日活跃',
value: funnelStats.value.activeUsers || 0,
rate: funnelStats.value.activeRate || '-', color: token('--color-primary')
}
])
const matchStages = [
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
]
const matchStages = computed(() => [
{
key: 'recommended', label: '推荐',
value: matchFunnelStats.value.totalRecommended || 0,
rate: '-', color: token('--color-info')
},
{
key: 'liked', label: '喜欢',
value: matchFunnelStats.value.totalLiked || 0,
rate: matchFunnelStats.value.likeRate || '-', color: token('--color-success')
},
{
key: 'matches', label: 'Match',
value: matchFunnelStats.value.totalMatches || 0,
rate: matchFunnelStats.value.matchRate || '-', color: token('--color-warning')
},
{
key: 'chats', label: '聊天',
value: matchFunnelStats.value.chatsStarted || 0,
rate: matchFunnelStats.value.chatRate || '-', color: token('--color-neutral')
},
{
key: 'knowing', label: '了解中',
value: matchFunnelStats.value.gettingToKnow || 0,
rate: '-', color: token('--color-primary')
},
{
key: 'success', label: '成功牵手',
value: matchFunnelStats.value.successPairs || 0,
rate: matchFunnelStats.value.successRate || '-', color: token('--color-danger')
}
])
const safetyCards = [
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
]
const safetyCards = computed(() => [
{ key: 'pending', label: '待处理举报', value: safetyStats.value.pendingReports || 0, color: token('--color-primary') },
{ key: 'resolved', label: '已处理举报', value: safetyStats.value.resolvedReports || 0, color: token('--color-success') },
{ key: 'highRisk', label: '高风险用户', value: safetyStats.value.highRiskUsers || 0, color: token('--color-warning') },
{ key: 'banned', label: '封禁用户', value: safetyStats.value.bannedUsers || 0, color: token('--color-danger') }
])
async function loadFunnel() {
try {
const token = localStorage.getItem('accessToken')
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = getAccessToken()
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
}, { Authorization: `Bearer ${authToken}` }),
get('/operator/analytics/match-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }),
}, { Authorization: `Bearer ${authToken}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${authToken}` }),
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
// 后端已实现却无人调用 —— 本行起补齐。
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` })
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${authToken}` })
])
const userData = userRes.data || {}
const matchData = matchRes.data || {}
const safetyData = safetyRes.data || {}
const v11Data = v11Res.data || {}
funnelStats.value = userData
matchFunnelStats.value = matchData
safetyStats.value = safetyData
v11.value = v11Data
// 更新漏斗显示
funnelStages[0].value = userData.totalRegistered || 0
funnelStages[1].value = userData.verifiedCount || 0
funnelStages[1].rate = userData.verifyRate || '-'
funnelStages[2].value = userData.profileApproved || 0
funnelStages[2].rate = userData.approvalRate || '-'
funnelStages[3].value = userData.activeUsers || 0
funnelStages[3].rate = userData.activeRate || '-'
matchStages[0].value = matchData.totalRecommended || 0
matchStages[1].value = matchData.totalLiked || 0
matchStages[1].rate = matchData.likeRate || '-'
matchStages[2].value = matchData.totalMatches || 0
matchStages[2].rate = matchData.matchRate || '-'
matchStages[3].value = matchData.chatsStarted || 0
matchStages[3].rate = matchData.chatRate || '-'
matchStages[4].value = matchData.gettingToKnow || 0
matchStages[5].value = matchData.successPairs || 0
matchStages[5].rate = matchData.successRate || '-'
safetyCards[0].value = safetyData.pendingReports || 0
safetyCards[1].value = safetyData.resolvedReports || 0
safetyCards[2].value = safetyData.highRiskUsers || 0
safetyCards[3].value = safetyData.bannedUsers || 0
// 只落 ref,卡片由上面三个 computed 自动派生 —— 不再手工同步第二份副本。
funnelStats.value = userRes.data || {}
matchFunnelStats.value = matchRes.data || {}
safetyStats.value = safetyRes.data || {}
v11.value = v11Res.data || {}
}
} catch (e) { console.error(e) }
}
async function loadDailySummary() {
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
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) }
}
@@ -329,21 +352,21 @@ onMounted(loadData)
padding: 20px;
text-align: center;
}
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
.funnel-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-bottom: 8px; }
.funnel-value { font-size: var(--font-size-4xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.funnel-rate { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 4px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
.mini-stat {
border: 1px solid #ebeef5;
border: 1px solid var(--color-border-light);
border-radius: 8px;
padding: 14px;
text-align: center;
background: #fafafa;
background: var(--color-bg-secondary);
}
.mini-value { font-size: 22px; font-weight: bold; color: #303133; }
.mini-label { font-size: 12px; color: #909399; margin-top: 6px; }
.metric-note { font-size: 12px; color: #909399; margin-top: 12px; line-height: 1.6; }
.unavailable { color: #c0c4cc; font-style: italic; }
.mini-value { font-size: var(--font-size-3xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.mini-label { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 6px; }
.metric-note { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 12px; line-height: 1.6; }
.unavailable { color: var(--color-text-disabled); font-style: italic; }
</style>
@@ -60,7 +60,7 @@
</el-descriptions>
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">审核决策</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="decision" style="width:100%">
@@ -84,8 +84,10 @@
</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'
const loading = ref(false)
const processing = ref(false)
@@ -108,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()
@@ -118,15 +120,15 @@ 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 = [
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: '#E96B6B' },
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: '#E59A32' },
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: '#67C23A' },
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: '#909399' },
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: '#F56C6C' }
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: tokenColor('--color-warning') },
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: tokenColor('--color-success') },
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: tokenColor('--color-neutral') },
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: tokenColor('--color-danger') }
]
} catch {}
}
@@ -137,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) }
@@ -147,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}` })
@@ -168,8 +170,8 @@ onMounted(loadData)
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); color: var(--admin-coral-strong); }
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.text-muted { color: #BBB; font-size: 13px; }
.text-muted { color: var(--color-text-disabled); font-size: var(--font-size-sm); }
</style>
+2 -1
View File
@@ -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,
@@ -302,21 +302,21 @@ onMounted(loadBanners)
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.preview-stage { padding: 20px; background: #f5f6f8; border-radius: 12px; }
.preview-stage { padding: 20px; background: var(--color-bg-subtle); border-radius: 12px; }
.preview-card {
padding: 20px 18px;
border-radius: 16px;
background: linear-gradient(135deg, #ffe9df, #fff7f2);
background: var(--gradient-brand-soft); /* 还原消费端 Banner 预览底色 */
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
}
.preview-tag {
display: inline-block; padding: 2px 10px; border-radius: 999px;
background: rgba(255, 255, 255, 0.85); color: #c8553d; font-size: 12px;
background: var(--color-bg-overlay); color: var(--admin-coral-strong); font-size: var(--font-size-xs);
}
.preview-title { margin-top: 10px; font-size: 17px; font-weight: 700; color: #2c2c34; }
.preview-subtitle { margin-top: 6px; font-size: 12px; line-height: 1.6; color: #6b6b78; }
.preview-title { margin-top: 10px; font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.preview-subtitle { margin-top: 6px; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-text-tertiary); }
.preview-button {
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
background: linear-gradient(135deg, #ff7d6e, #ff5c82); color: #fff; font-size: 12px; font-weight: 600;
background: var(--gradient-brand-vivid); color: var(--color-text-inverse); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); min-height: var(--tap-control); /* 还原消费端 Banner 按钮外观预览 */
}
</style>
@@ -253,5 +253,5 @@ onMounted(() => { loadCategories(); loadArticles() })
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.field-hint { width: 100%; font-size: var(--font-size-xs); color: var(--el-text-color-secondary); line-height: 1.5; }
</style>
@@ -3,7 +3,8 @@
<el-card>
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
<el-tabs v-model="activeTab">
<el-tab-pane label="服务请求队列" name="services">
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
<el-table :data="services" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="160">
@@ -19,7 +20,7 @@
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
</template>
</el-table-column>
</el-table>
@@ -38,16 +39,77 @@
</el-table-column>
</el-table>
</el-tab-pane>
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
<el-tab-pane label="服务对象分配" name="assignments">
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="assignmentScope" label="范围" width="90">
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
</el-table-column>
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="assignedAt" label="分配时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
</el-table-column>
<el-table-column prop="expiresAt" label="有效期至" width="170">
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
<el-tab-pane label="联系授权审批" name="contactAuths">
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
<el-table-column prop="operatorId" label="红娘ID" width="120" />
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="channel" label="渠道" width="110">
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
</el-table-column>
<el-table-column prop="purpose" label="授权目的" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }"><el-tag :type="contactStatusTag(row.status)">{{ contactStatusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="createdAt" label="申请时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="expiresAt" label="窗口/有效期至" width="170">
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="220">
<template #default="{ row }">
<template v-if="row.status === 'pending_approval' && canApprove">
<el-button size="small" type="primary" @click="doContactApprove(row)">批准</el-button>
<el-button size="small" type="danger" @click="doContactReject(row)">拒绝</el-button>
</template>
<el-button v-if="['active','pending_approval','pending_consent'].includes(row.status)"
size="small" @click="doContactRevoke(row)">撤销</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 分配弹窗 -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
<el-form label-width="80px">
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
<el-option label="王红娘" value="operator_wang" />
<el-option label="陈红娘" value="operator_chen" />
</el-select></el-form-item>
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
<el-form label-width="90px">
<el-form-item label="红娘">
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select>
</el-form-item>
<el-form-item label="分配理由" required>
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button>
@@ -55,6 +117,37 @@
</template>
</el-dialog>
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
<el-form label-width="90px">
<el-form-item label="新红娘">
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
</el-select>
</el-form-item>
<el-form-item label="转分配理由" required>
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
</template>
</el-dialog>
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
<el-form label-width="90px">
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="terminateDialogVisible=false">取消</el-button>
<el-button type="danger" @click="doTerminate">确认</el-button>
</template>
</el-dialog>
<!-- 创建推荐弹窗 -->
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
<el-form label-width="100px">
@@ -71,18 +164,35 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const activeTab = ref('services')
const authStore = useAuthStore()
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
const canAssign = computed(() => {
const perms: string[] = authStore.permissions || []
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
})
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
const loading = ref(false)
const services = ref<any[]>([])
const recommendations = ref<any[]>([])
const assignments = ref<any[]>([])
const assignmentsLoading = ref(false)
const assignableMatchmakers = ref<any[]>([])
const assignDialogVisible = ref(false)
const recDialogVisible = ref(false)
const transferDialogVisible = ref(false)
const terminateDialogVisible = ref(false)
const currentServiceId = ref('')
const assignForm = ref({ operatorId: '' })
const currentUserId = ref('')
const currentAssignmentId = ref('')
const terminateAction = ref<'revoke' | 'end'>('revoke')
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
const terminateForm = ref({ reason: '' })
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = {
@@ -90,10 +200,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
referral_activity: '转介活动', referral_station: '转介服务站'
}
const STATUS_LABEL: Record<string, string> = {
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
processing: '处理中', completed: '已完成', cancelled: '已取消'
}
const STATUS_TAG: Record<string, string> = {
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
processing: '', completed: 'success', cancelled: 'danger'
}
const REC_STATUS_LABEL: Record<string, string> = {
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
@@ -103,18 +215,79 @@ const REC_STATUS_TAG: Record<string, string> = {
pending_view: 'warning', viewed: 'info', liked: 'primary',
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
}
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
}
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
}
// ===== ZC-MM-003 / B1:受控联系授权(枚举码不上屏,中文映射) =====
const CONTACT_CHANNEL_LABEL: Record<string, string> = {
phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式'
}
const CONTACT_STATUS_LABEL: Record<string, string> = {
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
rejected: '已拒绝', expired: '已过期', revoked: '已撤销'
}
const CONTACT_STATUS_TAG: Record<string, string> = {
pending_approval: 'warning', pending_consent: 'warning', active: 'success',
rejected: 'danger', expired: 'info', revoked: 'info'
}
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
// ===== ZC-MM-003 / B1:受控联系授权 =====
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
const canApprove = computed(() => {
const perms: string[] = authStore.permissions || []
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
})
const contactAuths = ref<any[]>([])
const contactAuthsLoading = ref(false)
async function loadContactAuths() {
contactAuthsLoading.value = true
try { const res: any = await get('/operator/matchmaker/contact-authorizations/my'); contactAuths.value = res?.data?.list || [] } finally { contactAuthsLoading.value = false }
}
async function doContactApprove(row: any) {
try {
await post(`/operator/matchmaker/contact-authorizations/${row.id}/approve`, {})
ElMessage.success('已批准,等待用户单独确认'); loadContactAuths()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function doContactReject(row: any) {
try {
await post(`/operator/matchmaker/contact-authorizations/${row.id}/reject`, {})
ElMessage.success('已拒绝'); loadContactAuths()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function doContactRevoke(row: any) {
try {
const { value } = await ElMessageBox.prompt('撤销理由(必填)', '撤销联系授权', {
confirmButtonText: '确认撤销', cancelButtonText: '取消',
inputValidator: (v: string) => (v && v.trim() ? true : '撤销理由必填'),
})
await post(`/operator/matchmaker/contact-authorizations/${row.id}/revoke`, { reason: value.trim() })
ElMessage.success('已撤销'); loadContactAuths()
} catch { /* 用户取消 */ }
}
async function loadServices() {
loading.value = true
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
}
async function loadRecommendations() {
@@ -123,19 +296,81 @@ async function loadRecommendations() {
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
}
async function loadAssignments() {
assignmentsLoading.value = true
// ZC-MM-002:本人名下生效中的服务对象分配
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
}
async function loadAssignableMatchmakers() {
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
}
async function refresh() {
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
if (activeTab.value === 'services') await loadServices()
else if (activeTab.value === 'assignments') await loadAssignments()
else if (activeTab.value === 'contactAuths') await loadContactAuths()
else await loadRecommendations()
}
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
async function openAssign(row: any) {
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
currentServiceId.value = row.serviceId || row.id
currentUserId.value = row.userId
assignForm.value = { matchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
assignDialogVisible.value = true
}
async function doAssign() {
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
await post('/operator/matchmaker/assignments', {
userId: currentUserId.value,
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
serviceId: currentServiceId.value,
reason: assignForm.value.reason.trim()
})
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function openTransfer(row: any) {
currentAssignmentId.value = row.id
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
await loadAssignableMatchmakers()
transferDialogVisible.value = true
}
async function doTransfer() {
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
reason: transferForm.value.reason.trim()
})
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openTerminate(row: any, action: 'revoke' | 'end') {
currentAssignmentId.value = row.id
terminateAction.value = action
terminateForm.value = { reason: '' }
terminateDialogVisible.value = true
}
async function doTerminate() {
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
}
try {
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
reason: terminateForm.value.reason.trim()
})
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openRecommendation(row: any) {
currentServiceId.value = row.id
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
@@ -62,6 +62,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
@@ -79,7 +80,7 @@ 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 || []
total.value = res.data?.total || 0
@@ -89,7 +90,7 @@ async function loadData() {
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 })
@@ -105,7 +106,7 @@ async function save() {
if (!form.value.username || !form.value.password) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
formVisible.value = false
loadData()
@@ -115,7 +116,7 @@ async function save() {
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 +127,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) }
}
@@ -60,6 +60,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
@@ -78,7 +79,7 @@ const ORG_TYPE_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/organizations', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
} catch (e) { console.error(e) }
@@ -101,7 +102,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,7 +120,7 @@ async function save() {
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/organizations/${row.id}/${action}`, {}, {
Authorization: `Bearer ${token}`
+1 -1
View File
@@ -284,5 +284,5 @@ onMounted(loadPage)
</script>
<style scoped>
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:12px;font-weight:650;letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:12px}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:16px}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:var(--font-size-xs);font-weight:var(--font-weight-semibold);letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:var(--font-size-lg)}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
</style>
+14 -12
View File
@@ -60,7 +60,7 @@
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">被举报人风险画像</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">被举报人风险画像</h4>
<el-descriptions :column="2" border v-if="detail.riskProfile">
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
@@ -72,7 +72,7 @@
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">处置操作</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">处置操作</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="processResult" style="width:100%">
@@ -98,8 +98,10 @@
</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'
const loading = ref(false)
const processing = ref(false)
@@ -123,7 +125,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()
@@ -133,14 +135,14 @@ 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 = [
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: '#E96B6B' },
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: '#E59A32' },
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: '#67C23A' },
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: '#909399' }
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: tokenColor('--color-warning') },
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: tokenColor('--color-success') },
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: tokenColor('--color-neutral') }
]
} catch {}
}
@@ -151,7 +153,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) }
@@ -161,7 +163,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}` })
@@ -188,7 +190,7 @@ onMounted(loadData)
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: bold; }
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
.stat-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-top: 8px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -348,6 +348,6 @@ onMounted(loadStations)
.form-hint {
margin-left: 10px;
color: var(--el-text-color-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
}
</style>
@@ -20,7 +20,7 @@
<div class="identity-summary">
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="#EE666B" />
<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">
<el-tab-pane label="基础信息" name="basic">
@@ -129,5 +129,5 @@ function formatTime(time?: string) { return time ? new Date(time).toLocaleString
onMounted(loadDetail)
</script>
<style scoped>
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:#fff;background:var(--admin-primary)}.identity-summary h2{font-size:20px}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:12px}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:26px}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid #F3CCCC;border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:var(--color-text-inverse);background:var(--admin-coral-solid)}.identity-summary h2{font-size:var(--font-size-2xl)}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:var(--font-size-4xl)}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid var(--color-border-danger);border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger-strong)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
</style>
@@ -206,8 +206,8 @@ onMounted(loadData)
.page-kicker {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 1px;
}
+9 -4
View File
@@ -37,19 +37,24 @@ 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'] } },
],
},
]
const router = createRouter({ history: createWebHistory(), routes })
// history 的 base 必须与 vite 的 base 保持一致(生产构建为 /admin/)。
// 否则部署在 /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 } }
+56 -23
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
@@ -12,9 +13,9 @@ interface OperatorInfo {
const previewRolePermissions: Record<string, string[]> = {
reviewer: ['review.view', 'user.view'],
counselor: ['counseling.service.view', 'referral.manage'],
counselor: ['counseling.service.view', 'matchmaker.service'],
station: ['service_location.view', 'service_appointment.view'],
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'referral.manage'],
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'matchmaker.view', 'matchmaker.service'],
}
export const useAuthStore = defineStore('auth', () => {
@@ -23,38 +24,70 @@ export const useAuthStore = defineStore('auth', () => {
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
: null
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
const operator = ref<OperatorInfo | null>(
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
)
const isLoggedIn = computed(() => !!token.value)
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
const operator = ref<OperatorInfo | null>(previewOperator)
let sessionInitialized = false
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || [])
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
const hasPermission = (code: string) => permissions.value.includes(code)
function applyAuthData(data: any) {
token.value = data.accessToken || ''
operator.value = {
operatorId: data.operatorId,
name: data.name,
username: data.username,
permissions: data.permissions || [],
organizationId: data.organizationId ?? null,
}
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
setAccessToken(token.value)
}
async function login(username: string, password: string) {
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 }
})
+378 -59
View File
@@ -1,93 +1,412 @@
/* ============================================================================
* 管理端设计系统 · v2
*
* 本文件是管理端唯一的设计 token 事实源。24 个 .vue 的 <style> 只允许通过
* var(--token) 取色 / 取字号 / 取间距,不应出现字面色值(由门控脚本
* scripts/lint-admin-tokens.mjs 拦截)。
*
* 立此版本的原因(2026-09-22 审计实测,不是主观判断):
* · 97 处硬编码颜色 / 67 种色值:6 种近似粉底(#FFF0F1 #FFF3F2 #FFF6F6
* #FFF7F7 #FFF8F7 #FFFAFA)、6 种近似浅灰边框、大量灰阶碎片。
* · 88 处硬编码字号 / 23 种档位,其中 20 处 ≤11px(9px 见于 .metric-copy em、
* .activity-status、.todo-copy small),低于可读下限。
* · --color-text-tertiary(#888888) 白底 3.54:1、--color-warning(#E59A32) 2.33:1、
* --admin-coral(#F45F68) 3.14:1 —— 在各自用法下均不达 WCAG AA 的 4.5:1。
* · Element Plus 的 --el-color-*-light-N / -dark-2 / -rgb 是**编译期静态生成**的,
* 项目只覆盖了 --el-color-primary,于是开关、滑块、焦点环、分页、标签等
* 组件仍是默认蓝 —— 品牌色与 EP 蓝混用。
*
* 三条硬规则:
* 1) 品牌色三级分化:基础色只做色块/渐变/图形(不承载文字);
* -strong 做白底文字;-solid 做实心底压白字;两者均实算 ≥4.5:1。
* 2) 字重只用 400 / 500 / 600 / 700 四档(跨平台缺字重会回退,导致渲染不一致)。
* 3) 字号有下限:正文最小 12px,角标/计数不得低于 11px。
*
* 所有对比度数值均由脚本按 WCAG 相对亮度公式实算(非估算),标注在各 token 后。
* ========================================================================== */
:root {
--admin-coral: #f45f68;
--admin-coral-hover: #df505a;
--admin-coral-soft: #fff0f1;
--admin-shell-bg: rgba(255, 255, 255, 0.96);
--admin-sidebar-bg: #202d38;
--admin-sidebar-surface: #2b3a46;
--admin-sidebar-text: #D9E1E6;
--admin-sidebar-muted: #8FA1AC;
--admin-page-bg: #f7f9fb;
/* ---------------- 品牌色 ---------------- */
/* 基础色:仅用于色块 / 渐变 / 图标 / 边框氛围。白底作文字只有 3.14:1,禁止承载文字。 */
--admin-coral: #F45F68;
--admin-coral-hover: #E5535D;
--admin-coral-strong: #C0414A; /* 白底文字 5.13:1 */
--admin-coral-solid: #C84A53; /* 实心底压白字 4.60:1 */
--admin-coral-solid-hover: #B8434A; /* hover 反而更深更清晰 5.33:1 */
--admin-coral-on-dark: #FF969C; /* 侧栏深底文字 6.76:1 */
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
--admin-coral-border: rgba(244, 95, 104, 0.22);
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
/* 水平进度条填充(对轨道 --color-primary-light):原 #F56B75→#EF5A65 仅 2.53:1,
条与轨道难以区分;改达标渐变后 4.01:1。 */
--gradient-brand-bar: linear-gradient(90deg, #C84A53 0%, #B8434A 100%);
/* 兼容既有引用名(全站仍在用) */
--admin-primary: var(--admin-coral);
--admin-primary-hover: var(--admin-coral-hover);
--admin-card-radius: 14px;
--admin-shell-radius: 22px;
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
--admin-header-bg: #FFFFFF;
--admin-soft-pink: var(--admin-coral-soft);
--admin-soft-blue: #EEF5FF;
--admin-soft-green: #EDF8F1;
--admin-soft-purple: #F4EFFF;
--color-primary: var(--admin-primary);
--color-primary-hover: var(--admin-primary-hover);
--color-primary-light: #FFF3F2;
--color-primary-lighter: #FFF8F7;
--color-success: #2E9B68;
--color-warning: #E59A32;
--color-danger: #D94A4A;
--color-info: #4A7CD4;
--color-text-primary: #222222;
--color-text-secondary: #555555;
--color-text-tertiary: #888888;
--admin-primary-strong: var(--admin-coral-strong);
--admin-primary-solid: var(--admin-coral-solid);
--color-primary: var(--admin-coral);
--color-primary-hover: var(--admin-coral-hover);
--color-primary-strong: var(--admin-coral-strong);
--color-primary-solid: var(--admin-coral-solid);
--color-primary-light: var(--admin-coral-soft);
--color-primary-lighter: var(--admin-coral-lighter);
/* ---------------- 语义色五组 ----------------
基色做图形/色块;-strong 做文字(实算 ≥4.5:1);-soft 为配套浅底。 */
--color-success: #2E9B68; /* 白底 3.50:1 —— 仅图形 */
--color-success-strong: #1F7A4F; /* 白底 5.31:1 */
--color-success-soft: #EDF8F1;
--color-warning: #E59A32; /* 白底 2.33:1 —— 仅图形 */
--color-warning-strong: #8F5D0F; /* 白底 5.61:1 */
--color-warning-soft: #FFF6E6;
--color-danger: #D94A4A; /* 白底 4.18:1 —— 图形 / ≥24px 大字 */
--color-danger-strong: #B8353A; /* 白底 5.82:1 */
--color-danger-soft: #FFF6F6;
--color-info: #4A7CD4; /* 白底 4.10:1 —— 图形 */
--color-info-strong: #3A63B0; /* 白底 5.85:1 */
--color-info-soft: #EEF5FF;
--color-purple: #8B63BC;
--color-purple-strong: #6B47A0; /* 白底 6.92:1 */
--color-purple-soft: #F4EFFF;
--color-neutral: #8A9099;
--color-neutral-strong: #656C79; /* 白底 5.28:1 */
--color-neutral-soft: #F4F5F7;
/* ---------------- 文字 ---------------- */
--color-text-primary: #1F2328; /* 白底 15.80:1 */
--color-text-secondary: #555555; /* 白底 7.46:1 */
--color-text-tertiary: #676E7B; /* 白底 5.13:1;浅底 #F5F6F8 上 4.74:1(原 #888 仅 3.54:1) */
--color-text-placeholder: #8A9099; /* 3.25:1:占位符按惯例弱于正文一档(EP 默认 2.5:1) */
--color-text-disabled: #B9BEC7;
--color-text-inverse: #FFFFFF;
--color-text-on-dark: #D9E1E6; /* 侧栏深底 10.62:1 */
--color-text-on-dark-muted: #8FA1AC; /* 侧栏深底 5.26:1 */
--color-icon-muted: #D7DCE0; /* 空态大图标等纯装饰 */
/* ---------------- 背景 ---------------- */
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #FFF8F7;
--color-bg-page: #FAFAF8;
--color-border: #E8E5E2;
--color-border-light: #F0EDE9;
--color-bg-page: #F7F9FB;
--color-bg-secondary: #FAFAFB;
--color-bg-subtle: #F5F6F8;
--color-bg-input: #F6F7F8;
--color-bg-overlay: rgba(255, 255, 255, 0.86);
--color-bg-overlay-strong: rgba(255, 255, 255, 0.96);
/* ---------------- 边框 ---------------- */
--color-border: #E4E7EB;
--color-border-light: #EDF0F2;
--color-border-hairline: #F2F3F4;
--color-border-strong: #D7DCE0;
--color-border-danger: #F3CCCC;
/* ---------------- 侧栏(深底) ---------------- */
--admin-sidebar-bg: #202D38;
--admin-sidebar-bg-deep: #192733;
--admin-sidebar-gradient: linear-gradient(180deg, #202D38 0%, #192733 100%);
--admin-sidebar-surface: #2B3A46;
--admin-sidebar-text: #D9E1E6;
--admin-sidebar-muted: #8FA1AC;
--admin-sidebar-hairline: rgba(255, 255, 255, 0.08);
--admin-sidebar-hairline-soft: rgba(255, 255, 255, 0.06);
/* ---------------- 壳与栅格 ---------------- */
--admin-shell-bg: rgba(255, 255, 255, 0.96);
--admin-header-bg: #FFFFFF;
--admin-shell-radius: 22px;
--admin-card-radius: 14px;
--admin-shell-max-width: 1840px;
--admin-page-max-width: 1440px;
--admin-content-max-width: 1500px;
--sidebar-width: 220px;
--sidebar-width-collapsed: 76px;
--header-height: 64px;
/* ---------------- 圆角 ---------------- */
--border-radius-xs: 6px;
--border-radius-sm: 8px;
--border-radius-md: var(--admin-card-radius);
--border-radius-md: 14px;
--border-radius-lg: 16px;
--border-radius-xl: 18px;
--border-radius-pill: 999px;
/* ---------------- 阴影 ---------------- */
--shadow-panel: 0 8px 20px rgba(48, 58, 67, 0.035);
--shadow-card: 0 8px 24px rgba(75, 55, 46, 0.06);
--el-color-primary: var(--color-primary);
--shadow-raise: 0 10px 24px rgba(68, 48, 55, 0.1);
--shadow-brand: 0 8px 18px rgba(200, 74, 83, 0.26);
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
/* ---------------- 排版刻度 ----------------
正文 14px 起;角标/计数下限 11px;元信息下限 12px。 */
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-family-brand: KaiTi, STKaiti, serif; /* 品牌书法体:仅装饰性标语 */
--font-size-micro: 11px;
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 15px;
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-2xl: 20px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
--font-size-display: 38px;
--font-line-tight: 1.2;
--font-line-snug: 1.35;
--font-line-base: 1.6;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* ---------------- 间距 ---------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
/* ---------------- 动效 ---------------- */
--motion-fast: 140ms;
--motion-normal: 200ms;
--motion-slow: 320ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--press-scale: 0.985;
--press-opacity: 0.88;
/* ---------------- 交互热区 ----------------
桌面端按下限为 WCAG 2.5.8(AA) 的 24×24px;本项目常规控件取 32px(舒适),
主导航行取 44px(兼顾触屏笔记本)。不套用移动端 44px 全量规则,
否则会把管理端的单屏信息密度牺牲掉。 */
--tap-min: 44px;
--tap-control: 32px;
--tap-icon: 36px;
/* ---------------- tone 色调组(工作台指标卡 / 待办图标) ----------------
每组三档:--tone-x 用于图标与图形;--tone-x-ink 用于文字(压 --tone-x-soft
实算均 ≥4.6:1);--tone-x-soft 为浅底。 */
--tone-rose: #F45F68; --tone-rose-ink: var(--admin-coral-strong); --tone-rose-soft: #FFF0F1;
--tone-amber: #E59A32; --tone-amber-ink: var(--color-warning-strong); --tone-amber-soft: #FFF6E6;
--tone-blue: #4A7CD4; --tone-blue-ink: var(--color-info-strong); --tone-blue-soft: #EEF5FF;
--tone-green: #2E9B68; --tone-green-ink: var(--color-success-strong); --tone-green-soft: #EDF8F1;
--tone-purple: #8B63BC; --tone-purple-ink: var(--color-purple-strong); --tone-purple-soft: #F4EFFF;
--tone-slate: #8A9099; --tone-slate-ink: var(--color-neutral-strong); --tone-slate-soft: #F4F5F7;
--tone-red: #D94A4A; --tone-red-ink: var(--color-danger-strong); --tone-red-soft: #FFF6F6;
/* 兼容既有引用名 */
--admin-soft-pink: var(--admin-coral-soft);
--admin-soft-blue: var(--color-info-soft);
--admin-soft-green: var(--color-success-soft);
--admin-soft-purple: var(--color-purple-soft);
/* ==========================================================================
Element Plus 联动
EP 的 --el-color-{t}-light-{i} / -dark-2 / -rgb 由 SCSS 在**编译期**按默认色
静态生成,运行时改 --el-color-primary 不会带动它们(开关、滑块、焦点环、
分页、标签因此仍是默认蓝)。这里按 EP 的派生公式补全:
light-{i} = mix(#FFFFFF, base, i*10%) i ∈ 1..9
dark-2 = mix(#000000, base, 20%)
基色取 -strong / -solid 档 —— 既能当文字色也能做实心底,使 EP 全组件与品牌一致。
info 保持中性灰:EP 的 info 语义是「中性/未知」,不应变成品牌红。
========================================================================== */
--el-color-primary: #C84A53;
--el-color-primary-rgb: 200, 74, 83;
--el-color-primary-light-3: #d98087;
--el-color-primary-light-5: #e4a5a9;
--el-color-primary-light-7: #efc9cb;
--el-color-primary-light-8: #f4dbdd;
--el-color-primary-light-9: #faedee;
--el-color-primary-dark-2: #a03b42;
--el-color-success: #1F7A4F;
--el-color-success-rgb: 31, 122, 79;
--el-color-success-light-3: #62a284;
--el-color-success-light-5: #8fbda7;
--el-color-success-light-7: #bcd7ca;
--el-color-success-light-8: #d2e4dc;
--el-color-success-light-9: #e9f2ed;
--el-color-success-dark-2: #19623f;
--el-color-warning: #8F5D0F;
--el-color-warning-rgb: 143, 93, 15;
--el-color-warning-light-3: #b18e57;
--el-color-warning-light-5: #c7ae87;
--el-color-warning-light-7: #ddceb7;
--el-color-warning-light-8: #e9dfcf;
--el-color-warning-light-9: #f4efe7;
--el-color-warning-dark-2: #724a0c;
--el-color-danger: #B8353A;
--el-color-danger-rgb: 184, 53, 58;
--el-color-danger-light-3: #cd7275;
--el-color-danger-light-5: #dc9a9d;
--el-color-danger-light-7: #eac2c4;
--el-color-danger-light-8: #f1d7d8;
--el-color-danger-light-9: #f8ebeb;
--el-color-danger-dark-2: #932a2e;
--el-color-info: #656C79;
--el-color-info-rgb: 101, 108, 121;
--el-color-info-light-3: #9398a1;
--el-color-info-light-5: #b2b6bc;
--el-color-info-light-7: #d1d3d7;
--el-color-info-light-8: #e0e2e4;
--el-color-info-light-9: #f0f0f2;
--el-color-info-dark-2: #515661;
/* EP 中性色(文字 / 边框 / 填充)接到同一套 token,避免表格、输入框另成一套灰 */
--el-text-color-primary: var(--color-text-primary);
--el-text-color-regular: var(--color-text-secondary);
--el-text-color-secondary: var(--color-text-tertiary);
--el-text-color-placeholder: var(--color-text-placeholder);
--el-text-color-disabled: var(--color-text-disabled);
--el-border-color: var(--color-border);
--el-border-color-light: var(--color-border-light);
--el-border-color-lighter: var(--color-border-light);
--el-border-color-extra-light: var(--color-border-hairline);
--el-fill-color: var(--color-bg-subtle);
--el-fill-color-light: var(--color-bg-secondary);
--el-fill-color-lighter: var(--color-bg-secondary);
--el-bg-color: var(--color-bg-primary);
--el-bg-color-page: var(--color-bg-page);
--el-border-radius-base: var(--border-radius-sm);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
font-family: var(--font-family-base);
font-size: var(--font-size-base);
color: var(--color-text-primary);
background-color: var(--color-bg-page);
}
/* 管理端是桌面密度布局,1100px 以下由 MainLayout 的 .admin-scene 承担横向滚动。 */
body { min-width: 1100px; }
/* <small> 的浏览器默认样式是 font-size: smaller(0.833em),会按父级字号再缩一档:
父级 12px 时实际渲染 10px、11px 时渲染 9.17px —— 静默跌破 11px 下限,
且源码里看不到任何非法字号,静态检查抓不到(2026-09-22 由渲染态实测发现)。
这里统一改为继承父级字号,需要更小的场景必须显式声明 token 字号(守卫会校验)。 */
small { font-size: inherit; }
button:focus-visible,
a:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(238, 102, 107, 0.32);
outline: 3px solid var(--admin-coral-border);
outline-offset: 2px;
}
/* ---------------- Element Plus 组件级覆盖 ---------------- */
/* EP 默认 hover 是把主色「加白」(白字对比度随之掉到 2.2:1)。这里改成更深一档,
既保住白字可读性(5.33:1),hover 反馈也更明确。 */
.el-button--primary {
--el-button-bg-color: var(--color-primary);
--el-button-border-color: var(--color-primary);
--el-button-hover-bg-color: var(--color-primary-hover);
--el-button-hover-border-color: var(--color-primary-hover);
--el-button-bg-color: var(--admin-coral-solid);
--el-button-border-color: var(--admin-coral-solid);
--el-button-hover-bg-color: var(--admin-coral-solid-hover);
--el-button-hover-border-color: var(--admin-coral-solid-hover);
--el-button-active-bg-color: var(--el-color-primary-dark-2);
--el-button-active-border-color: var(--el-color-primary-dark-2);
}
.el-radio-group .el-radio-button__inner:checked {
background-color: var(--color-primary);
border-color: var(--color-primary);
background-color: var(--admin-coral-solid);
border-color: var(--admin-coral-solid);
}
.el-card { --el-card-border-radius: var(--border-radius-md); --el-card-border-color: var(--color-border-light); }
.el-table { --el-table-header-bg-color: #FBFAF8; --el-table-border-color: var(--color-border-light); }
.el-tag { border-radius: 999px; }
.page-container { padding: 24px; }
.page-shell { max-width: 1440px; margin: 0 auto; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.page-heading h1 { font-size: 24px; line-height: 1.35; font-weight: 650; }
.el-card {
--el-card-border-radius: var(--border-radius-md);
--el-card-border-color: var(--color-border-light);
}
.el-table {
--el-table-header-bg-color: var(--color-bg-secondary);
--el-table-border-color: var(--color-border-light);
}
.el-tag { border-radius: var(--border-radius-pill); }
/* ---------------- 通用版式 ---------------- */
.page-container { padding: var(--space-6); }
.page-shell { max-width: var(--admin-page-max-width); margin: 0 auto; }
.page-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.page-heading h1 {
font-size: var(--font-size-3xl);
line-height: var(--font-line-snug);
font-weight: var(--font-weight-semibold);
}
.page-heading p { margin-top: 6px; color: var(--color-text-tertiary); }
.surface-card { background: var(--color-bg-primary); border: 1px solid var(--color-border-light); border-radius: var(--border-radius-md); box-shadow: var(--shadow-card); }
.surface-card {
background: var(--color-bg-primary);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-card);
}
/* 语义文字色工具类(比在页面里各写一遍 color: #xxx 更不容易漂移) */
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-tertiary { color: var(--color-text-tertiary); }
.mt-sm { margin-top: 8px; }
.mt-md { margin-top: 16px; }
.mt-lg { margin-top: 24px; }
.mb-sm { margin-bottom: 8px; }
.mb-md { margin-bottom: 16px; }
.mb-lg { margin-bottom: 24px; }
.text-muted { color: var(--color-text-tertiary); }
.text-brand { color: var(--admin-coral-strong); }
.text-success { color: var(--color-success-strong); }
.text-warning { color: var(--color-warning-strong); }
.text-danger { color: var(--color-danger-strong); }
/* 语义底色工具类 */
.bg-card { background: var(--color-bg-primary); }
.bg-subtle { background: var(--color-bg-subtle); }
.bg-brand-soft { background: var(--admin-coral-soft); }
/* 间距工具类 */
.mt-sm { margin-top: var(--space-2); }
.mt-md { margin-top: var(--space-4); }
.mt-lg { margin-top: var(--space-6); }
.mb-sm { margin-bottom: var(--space-2); }
.mb-md { margin-bottom: var(--space-4); }
.mb-lg { margin-bottom: var(--space-6); }
.flex-center { display: flex; align-items: center; justify-content: center; }
@media (max-width: 1200px) { body { min-width: 0; } }
@media (max-width: 900px) { .page-container { padding: 16px; } .page-heading { align-items: flex-start; flex-direction: column; } }
/* ---------------- 交互反馈 ---------------- */
/* 桌面端控件热区下限:32px(WCAG 2.5.8 AA 为 24px,留出舒适余量) */
.tap-control { min-height: var(--tap-control); min-width: var(--tap-control); }
/* 按压反馈:统一走 token,避免各页面各写一套 scale/opacity */
.pressable { transition: transform var(--motion-fast) var(--ease-standard), opacity var(--motion-fast) var(--ease-standard); }
.pressable:active { transform: scale(var(--press-scale)); opacity: var(--press-opacity); }
@media (max-width: 1200px) {
body { min-width: 0; }
}
@media (max-width: 900px) {
.page-container { padding: var(--space-4); }
.page-heading { align-items: flex-start; flex-direction: column; }
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 管理端调色板常量(TS 侧)。
*
* 为什么需要这个文件:
* 部分模板用 `:style="{ backgroundColor: stage.color + '15' }"` 把 6 位 hex 拼成
* 8 位带 alpha 的颜色('#409EFF' + '15' → '#409EFF15',即 8% 不透明度)。
* 这类位置**不能**用 CSS var —— `'var(--x)' + '15'` 是无效值,浏览器会整条丢弃。
* 于是「色值只有一处事实源」这条规则在 TS 侧会漏。本文件补上这个缺口。
*
* 单一事实源约定:
* 键名就是 src/theme/index.css 里的 var 名,值必须与之一致。
* 门控脚本 scripts/lint-admin-tokens.mjs 会逐项比对,任一侧改了而另一侧没改即失败。
*/
export const TOKEN_HEX: Record<string, string> = {
'--color-primary': '#F45F68',
'--color-primary-strong': '#C0414A',
'--color-primary-solid': '#C84A53',
'--color-success': '#2E9B68',
'--color-success-strong': '#1F7A4F',
'--color-warning': '#E59A32',
'--color-warning-strong': '#8F5D0F',
'--color-danger': '#D94A4A',
'--color-danger-strong': '#B8353A',
'--color-info': '#4A7CD4',
'--color-info-strong': '#3A63B0',
'--color-purple': '#8B63BC',
'--color-purple-strong': '#6B47A0',
'--color-neutral': '#8A9099',
'--color-neutral-strong': '#656C79',
'--color-text-primary': '#1F2328',
'--color-text-secondary': '#555555',
'--color-text-tertiary': '#676E7B',
'--color-bg-page': '#F7F9FB',
'--color-border': '#E4E7EB',
}
/**
* 取 token 的 hex 字面量。用于 :style 绑定(含 hex 拼接透明度的场景)。
* @example token('--color-success') // '#2E9B68'
*/
export function token(name: string): string {
const value = TOKEN_HEX[name]
if (!value) throw new Error(`未登记的 token: ${name}(请先加入 TOKEN_HEX 并同步 theme/index.css)`)
return value
}
/**
* 生成带 alpha 的 8 位 hex。alpha 传 2 位十六进制('15' = 21/255 ≈ 8%)。
* 与 `color + '15'` 等价,但把「必须 6 位 hex」这个约束显式化,避免误把 var() 传进来。
*/
export function tint(hex: string, alphaHex: string): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
throw new Error(`tint() 需要 6 位 hex(不能是 var() 或 3/8 位 hex):${hex}`)
}
return `${hex}${alphaHex}`
}
/** 指标卡 / 待办项的色调组,与 theme/index.css 的 --tone-* 一一对应。 */
export interface Tone {
/** 图标与图形用的鲜亮色 */
vivid: string
/** 文字用的可读色(压 soft 底 ≥4.5:1) */
ink: string
/** 浅底 */
soft: string
}
export const TONES: Record<string, Tone> = {
rose: { vivid: '#F45F68', ink: '#C0414A', soft: '#FFF0F1' },
amber: { vivid: '#E59A32', ink: '#8F5D0F', soft: '#FFF6E6' },
blue: { vivid: '#4A7CD4', ink: '#3A63B0', soft: '#EEF5FF' },
green: { vivid: '#2E9B68', ink: '#1F7A4F', soft: '#EDF8F1' },
purple: { vivid: '#8B63BC', ink: '#6B47A0', soft: '#F4EFFF' },
slate: { vivid: '#8A9099', ink: '#656C79', soft: '#F4F5F7' },
red: { vivid: '#D94A4A', ink: '#B8353A', soft: '#FFF6F6' },
}
+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 = ''
}
+132 -110
View File
@@ -157,11 +157,12 @@ import {
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { createEmptyDashboard, loadDashboard } from '@/services/dashboard'
import { BRAND_ACTIVITY_FALLBACK } from '@/brand'
import type { DashboardActivity, DashboardModel } from '@/types/dashboard'
const router = useRouter()
const authStore = useAuthStore()
const activityFallback = '/brand/activity-default.jpg'
const activityFallback = BRAND_ACTIVITY_FALLBACK
const dashboard = ref<DashboardModel>(createEmptyDashboard())
const loading = ref(true)
const fatalError = ref('')
@@ -282,9 +283,9 @@ onMounted(retryLoad)
<style scoped>
.dashboard-page {
min-width: 0;
max-width: 1500px;
max-width: var(--admin-content-max-width);
margin: 0 auto;
padding: 20px 26px 28px;
padding: var(--space-5) 26px 28px;
}
.dashboard-welcome {
@@ -292,33 +293,34 @@ onMounted(retryLoad)
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 20px;
gap: var(--space-5);
}
.dashboard-welcome h1 {
font-size: 24px;
line-height: 1.25;
font-size: var(--font-size-3xl);
line-height: var(--font-line-snug);
}
.dashboard-welcome p {
margin-top: 6px;
color: #78828b;
font-size: 13px;
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
.dashboard-date {
display: flex;
align-items: center;
gap: 14px;
color: #7b858e;
font-size: 12px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-date small {
padding: 6px 10px;
color: #d75a63;
background: #fff0f1;
border-radius: 999px;
color: var(--admin-coral-strong);
background: var(--admin-coral-soft);
border-radius: var(--border-radius-pill);
font-size: var(--font-size-micro);
}
.dashboard-loading,
@@ -334,7 +336,7 @@ onMounted(retryLoad)
.metric-skeleton {
height: 105px;
border-radius: 12px;
border-radius: var(--border-radius-md);
}
.dashboard-error,
@@ -342,14 +344,14 @@ onMounted(retryLoad)
.dashboard-notice {
display: flex;
min-height: 48px;
margin: 8px 0 12px;
margin: var(--space-2) 0 var(--space-3);
padding: 10px 14px;
align-items: center;
gap: 10px;
color: #6f5355;
background: #fff6f6;
border: 1px solid #f7dfe1;
border-radius: 11px;
color: var(--color-danger-strong);
background: var(--color-danger-soft);
border: 1px solid var(--color-border-danger);
border-radius: var(--border-radius-sm);
}
.dashboard-error > span,
@@ -362,7 +364,8 @@ onMounted(retryLoad)
.dashboard-error small,
.dashboard-forbidden small {
margin-top: 3px;
color: #8c7d7e;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.dashboard-metrics {
@@ -374,33 +377,40 @@ onMounted(retryLoad)
display: grid;
min-width: 0;
min-height: 108px;
padding: 14px 12px;
padding: 14px var(--space-3);
grid-template-columns: 38px 1fr;
align-items: center;
gap: 9px;
overflow: hidden;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid rgba(235, 235, 238, 0.9);
border-radius: 12px;
transition: transform 0.18s ease, box-shadow 0.18s ease;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
transition: transform var(--motion-normal) var(--ease-standard),
box-shadow var(--motion-normal) var(--ease-standard);
}
.metric-card:hover {
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
box-shadow: var(--shadow-raise);
transform: translateY(-2px);
}
.metric-card:active {
transform: translateY(0) scale(var(--press-scale));
}
.metric-card.unavailable {
cursor: default;
opacity: 0.72;
}
/* 图标字形属「有意义的非文本内容」,按 WCAG 需 ≥3:1。
原先用鲜亮色调压同色系浅底(amber 仅 2.2:1),统一改用 -ink 档(≥4.6:1)。 */
.metric-icon,
.todo-icon {
display: grid;
color: var(--tone-color);
color: var(--tone-ink);
background: var(--tone-soft);
border-radius: 50%;
place-items: center;
@@ -409,7 +419,7 @@ onMounted(retryLoad)
.metric-icon {
width: 38px;
height: 38px;
font-size: 19px;
font-size: var(--font-size-xl);
}
.metric-copy {
@@ -420,24 +430,25 @@ onMounted(retryLoad)
.metric-copy small {
overflow: hidden;
color: #55616a;
font-size: 11px;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-copy strong {
margin-top: 3px;
color: #171a1d;
font-size: 25px;
color: var(--color-text-primary);
font-size: var(--font-size-3xl);
line-height: 1;
}
/* 原为 9px(低于可读下限),提到 11px */
.metric-copy em {
margin-top: 6px;
overflow: hidden;
color: var(--tone-color);
font-size: 9px;
color: var(--tone-ink);
font-size: var(--font-size-micro);
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
@@ -445,18 +456,20 @@ onMounted(retryLoad)
.metric-arrow {
position: absolute;
right: 8px;
bottom: 8px;
color: var(--tone-color);
font-size: 11px;
right: var(--space-2);
bottom: var(--space-2);
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
.tone-rose { --tone-color: #f05e69; --tone-soft: #ffe9eb; }
.tone-amber { --tone-color: #e58b39; --tone-soft: #fff1df; }
.tone-blue { --tone-color: #568ce9; --tone-soft: #eaf2ff; }
.tone-green { --tone-color: #4aad76; --tone-soft: #e9f7ef; }
.tone-purple { --tone-color: #9c69df; --tone-soft: #f2eaff; }
.tone-red { --tone-color: #ed5b64; --tone-soft: #ffe9eb; }
/* 色调组改为引用 theme/index.css 的 --tone-*-ink / --tone-*-soft。
--tone-ink 是本组件内的局部变量(由下面各 tone-* 类赋值),供图标与文字统一取用。 */
.tone-rose { --tone-ink: var(--tone-rose-ink); --tone-soft: var(--tone-rose-soft); }
.tone-amber { --tone-ink: var(--tone-amber-ink); --tone-soft: var(--tone-amber-soft); }
.tone-blue { --tone-ink: var(--tone-blue-ink); --tone-soft: var(--tone-blue-soft); }
.tone-green { --tone-ink: var(--tone-green-ink); --tone-soft: var(--tone-green-soft); }
.tone-purple { --tone-ink: var(--tone-purple-ink); --tone-soft: var(--tone-purple-soft); }
.tone-red { --tone-ink: var(--tone-red-ink); --tone-soft: var(--tone-red-soft); }
.dashboard-grid {
display: grid;
@@ -467,11 +480,11 @@ onMounted(retryLoad)
.dashboard-panel {
min-width: 0;
padding: 14px 16px;
background: #fff;
border: 1px solid #e9edf0;
padding: 14px var(--space-4);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--admin-card-radius);
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
box-shadow: var(--shadow-panel);
}
.panel-header {
@@ -479,42 +492,49 @@ onMounted(retryLoad)
min-height: 30px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f2;
border-bottom: 1px solid var(--color-border-hairline);
}
.panel-header h2 {
padding-bottom: 10px;
font-size: 16px;
font-size: var(--font-size-lg);
}
.panel-header h2 span {
display: inline-grid;
min-width: 20px;
height: 20px;
min-width: var(--space-5);
height: var(--space-5);
margin-left: 6px;
padding: 0 5px;
color: #fff;
font-size: 10px;
line-height: 20px;
background: var(--admin-coral);
border-radius: 999px;
color: var(--color-text-inverse);
font-size: var(--font-size-micro);
line-height: var(--space-5);
background: var(--admin-coral-solid);
border-radius: var(--border-radius-pill);
place-items: center;
}
.panel-header button {
display: flex;
min-height: var(--tap-control);
align-items: center;
gap: 4px;
color: #89929a;
font-size: 11px;
gap: var(--space-1);
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-xs);
transition: color var(--motion-fast) var(--ease-standard);
}
.panel-header button:hover {
color: var(--admin-coral-strong);
}
.panel-header > span {
color: #98a0a7;
font-size: 10px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.todo-list,
@@ -534,12 +554,13 @@ onMounted(retryLoad)
cursor: pointer;
background: transparent;
border: 0;
border-bottom: 1px solid #f2f3f4;
border-bottom: 1px solid var(--color-border-hairline);
transition: background var(--motion-fast) var(--ease-standard);
}
.todo-row:hover,
.activity-row:hover {
background: #fffafa;
background: var(--color-bg-secondary);
}
.todo-icon {
@@ -559,8 +580,8 @@ onMounted(retryLoad)
.todo-copy strong,
.activity-copy strong {
overflow: hidden;
color: #30373d;
font-size: 12px;
color: var(--color-text-primary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -569,22 +590,22 @@ onMounted(retryLoad)
.activity-copy small {
margin-top: 3px;
overflow: hidden;
color: #939ba2;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-row > b {
min-width: 29px;
color: var(--admin-coral);
font-size: 13px;
color: var(--admin-coral-strong);
font-size: var(--font-size-sm);
text-align: center;
}
.todo-row > em {
color: var(--admin-coral);
font-size: 10px;
color: var(--admin-coral-strong);
font-size: var(--font-size-micro);
font-style: normal;
}
@@ -593,21 +614,21 @@ onMounted(retryLoad)
height: 38px;
flex: none;
object-fit: cover;
border-radius: 7px;
border-radius: var(--border-radius-xs);
}
.activity-status {
padding: 3px 6px;
color: #318b5a;
font-size: 9px;
background: #eaf7ef;
border-radius: 999px;
color: var(--color-success-strong);
font-size: var(--font-size-micro);
background: var(--color-success-soft);
border-radius: var(--border-radius-pill);
}
.activity-row > em {
min-width: 76px;
color: #7d878f;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
text-align: right;
}
@@ -619,47 +640,47 @@ onMounted(retryLoad)
justify-content: center;
flex-direction: column;
gap: 10px;
color: #a0a7ad;
font-size: 11px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-empty .el-icon {
color: #d7dce0;
font-size: 34px;
color: var(--color-icon-muted);
font-size: var(--font-size-5xl);
}
.overview-grid {
display: grid;
min-height: 118px;
padding-top: 12px;
padding-top: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
gap: var(--space-2);
}
.overview-grid > div {
display: flex;
padding: 12px 10px;
padding: var(--space-3) 10px;
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--tone-soft);
border-radius: 8px;
border-radius: var(--border-radius-sm);
}
.overview-grid small {
color: var(--tone-color);
font-size: 10px;
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
.overview-grid strong {
margin-top: 7px;
font-size: 24px;
font-size: var(--font-size-3xl);
}
.overview-grid em {
margin-top: 7px;
color: #8a9298;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
}
@@ -674,52 +695,53 @@ onMounted(retryLoad)
grid-template-columns: 92px 1fr 76px;
align-items: center;
gap: 9px;
font-size: 10px;
font-size: var(--font-size-micro);
}
.effectiveness-row > span {
color: #747e87;
color: var(--color-text-tertiary);
}
/* 轨道与填充的对比原为 2.53:1(条几乎看不出进度),改后 4.01:1 */
.effectiveness-row > div {
height: 8px;
height: var(--space-2);
overflow: hidden;
background: #fdebee;
border-radius: 999px;
background: var(--color-primary-light);
border-radius: var(--border-radius-pill);
}
.effectiveness-row i {
display: block;
height: 100%;
background: linear-gradient(90deg, #f56b75, #ef5a65);
background: var(--gradient-brand-bar);
border-radius: inherit;
}
.effectiveness-row strong {
color: #75808a;
font-size: 10px;
font-weight: 500;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
text-align: right;
}
@media (max-width: 1450px), (max-height: 820px) {
.dashboard-page { padding: 12px 18px 20px; }
.dashboard-welcome { min-height: 54px; }
.dashboard-welcome h1 { font-size: 20px; }
.dashboard-welcome p { margin-top: 3px; font-size: 11px; }
.dashboard-welcome h1 { font-size: var(--font-size-2xl); }
.dashboard-welcome p { margin-top: 3px; font-size: var(--font-size-xs); }
.dashboard-metrics { margin-top: 6px; gap: 7px; }
.metric-card { min-height: 90px; padding: 10px 8px; grid-template-columns: 32px 1fr; gap: 6px; }
.metric-icon { width: 32px; height: 32px; font-size: 16px; }
.metric-copy strong { font-size: 21px; }
.metric-card { min-height: 90px; padding: 10px var(--space-2); grid-template-columns: var(--space-8) 1fr; gap: 6px; }
.metric-icon { width: var(--space-8); height: var(--space-8); font-size: var(--font-size-lg); }
.metric-copy strong { font-size: var(--font-size-2xl); }
.dashboard-grid { margin-top: 9px; gap: 9px; }
.dashboard-panel { padding: 10px 12px; }
.dashboard-panel { padding: 10px var(--space-3); }
.todo-list, .activity-list { min-height: 152px; }
.todo-row, .activity-row { min-height: 38px; padding: 4px; }
.todo-row, .activity-row { min-height: 38px; padding: var(--space-1); }
.activity-row img { width: 58px; height: 31px; }
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
.overview-grid, .effectiveness-list { min-height: 98px; }
.overview-grid > div { padding: 8px 6px; }
.overview-grid strong { font-size: 20px; }
.overview-grid > div { padding: var(--space-2) 6px; }
.overview-grid strong { font-size: var(--font-size-2xl); }
}
@media (max-width: 1180px) {
+15 -14
View File
@@ -1,10 +1,10 @@
<template>
<main class="login-shell">
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
<img class="brand-scene" src="/brand/login-illustration.jpg" alt="" />
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
<div class="brand-overlay">
<div class="brand-title">
<img src="/brand/logo-mark.png" alt="" />
<img :src="BRAND_LOGO" alt="" />
<div>
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
@@ -62,6 +62,7 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { BRAND_LOGIN_SCENE, BRAND_LOGO } from '@/brand'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -100,7 +101,7 @@ async function handleLogin() {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
min-height: 100vh;
background: #FFFFFF;
background: var(--color-bg-primary);
}
.brand-panel {
@@ -143,28 +144,28 @@ async function handleLogin() {
.brand-title strong {
color: var(--admin-primary);
font-size: 25px;
font-size: var(--font-size-3xl);
letter-spacing: 2px;
}
.brand-title span {
margin-top: 3px;
color: var(--color-text-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
}
.brand-message {
max-width: 590px;
padding: 26px 30px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.94);
background: var(--color-bg-overlay);
border: 1px solid var(--color-bg-overlay-strong);
border-radius: 18px;
backdrop-filter: blur(10px);
}
.brand-message p {
color: var(--admin-primary);
font-weight: 650;
font-weight: var(--font-weight-semibold);
letter-spacing: 3px;
}
@@ -187,7 +188,7 @@ async function handleLogin() {
align-items: center;
justify-content: center;
padding: 48px;
background: #FFFFFF;
background: var(--color-bg-primary);
}
.login-card {
@@ -200,14 +201,14 @@ async function handleLogin() {
.eyebrow {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 1px;
}
.login-header h2 {
margin-top: 10px;
font-size: 30px;
font-size: var(--font-size-5xl);
}
.login-header p {
@@ -227,7 +228,7 @@ async function handleLogin() {
margin-top: 24px;
padding: 14px 16px;
color: var(--color-text-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
line-height: 1.6;
background: var(--admin-soft-green);
border-radius: 10px;
@@ -242,7 +243,7 @@ async function handleLogin() {
footer {
margin-top: 40px;
color: var(--color-text-tertiary);
font-size: 12px;
font-size: var(--font-size-xs);
}
@media (max-width: 900px) {
+13 -3
View File
@@ -2,15 +2,25 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
export default defineConfig(({ command }) => ({
plugins: [vue()],
// 管理端产物部署在 nginx 的 /admin/ 子路径下(见 infrastructure/nginx-production.conf
// 与 admin-web/nginx-admin.conf)。构建时必须带 /admin/ 前缀,否则 index.html 引用的
// /assets/*.js 会落到站点根路径,被生产 nginx 的其它 location 兜成 404 或错误页面。
// 开发服务器保持根路径:http://localhost:13520/ 的既有开发与排查习惯不变
// (vite 的 base 对 dev 与 build 各自生效,故此处按 command 分叉)。
base: command === 'build' ? '/admin/' : '/',
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
port: 13520,
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:8085',
@@ -18,4 +28,4 @@ export default defineConfig({
},
},
},
})
}))
File diff suppressed because one or more lines are too long
+649
View File
@@ -0,0 +1,649 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>善缘丹阳 · 小程序设计系统 v2 改造对照</title>
<style>
:root{
/* ── 与 miniapp/app.wxss 完全一致的 token(此处 1rpx = 0.5px,按 375pt 机型还原) ── */
--primary:#E96B6B; --primary-strong:#B8434A; --primary-solid:#C24C50; --primary-light:#FFF3F2;
--night-start:#172A72; --night-end:#25104F; --night-soft:#3445A1;
--accent-pink:#FF4F91; --accent-coral:#FF6A78;
--text-strong:#1A1F36; --text-primary:#2A3050; --text-secondary:#4A5169; --text-tertiary:#646C85;
--text-placeholder:#9AA1B4; --text-inverse:#FFFFFF;
--bg-page:#F6F5F3; --bg-card:#FFFFFF; --bg-subtle:#F5F6FA;
--border:#E6E8EF; --divider:#EFF0F5;
--accent-purple-strong:#7C60BC; --accent-purple-light:#F3EEFF;
--success-strong:#17805A; --success-light:#ECF8F2; --success-on-night:#A6F5D7;
--warning-strong:#9C5F14; --warning-light:#FFF7E9; --danger-strong:#C62828; --danger-light:#FFF0F0;
--info-strong:#2A5DB0; --info-light:#EEF4FF;
--glow-coral:rgba(255,79,145,.40); --glow-indigo:rgba(94,124,255,.32);
--star:rgba(255,255,255,.70); --glass:rgba(255,255,255,.10); --hairline:rgba(255,255,255,.16);
--glass-success:rgba(90,226,184,.16);
--shadow-card:0 4px 14px rgba(35,35,95,.07); --shadow-raised:0 6px 18px rgba(35,35,95,.10);
--shadow-night:0 8px 20px rgba(28,30,88,.18);
--grad-smoke:linear-gradient(135deg,#FF6A78 0%,#E96B6B 100%);
--grad-solid:linear-gradient(135deg,#C24C50 0%,#B8434A 100%);
--scrim:linear-gradient(90deg,rgba(23,42,114,.84) 0%,rgba(37,16,79,.40) 56%,rgba(255,79,145,.12) 100%);
/* 文档层配色 */
--doc-bg:#F1F3F8; --doc-panel:#FFFFFF; --doc-line:#E2E6EF; --doc-ink:#1A1F36; --doc-mute:#5C6480;
}
*{box-sizing:border-box;}
body{
margin:0;background:var(--doc-bg);color:var(--doc-ink);
font-family:-apple-system,"PingFang SC","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
line-height:1.6;-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1180px;margin:0 auto;padding:40px 24px 80px;}
h1{font-size:28px;margin:0 0 8px;letter-spacing:-.2px;}
h2{font-size:20px;margin:0 0 4px;}
h3{font-size:15px;margin:0 0 10px;}
.sub{color:var(--doc-mute);font-size:13.5px;margin:0;}
.meta{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;}
.chip{
display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
background:#fff;border:1px solid var(--doc-line);font-size:12.5px;color:var(--doc-mute);
}
.chip b{color:var(--doc-ink);font-weight:600;}
.panel{background:var(--doc-panel);border:1px solid var(--doc-line);border-radius:14px;padding:22px 24px;margin-top:22px;}
.panel > p.lead{margin:6px 0 18px;color:var(--doc-mute);font-size:13.5px;}
/* 指标卡 */
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:14px;}
.metric{background:#fff;border:1px solid var(--doc-line);border-radius:12px;padding:16px 18px;}
.metric .k{font-size:12.5px;color:var(--doc-mute);}
.metric .v{font-size:24px;font-weight:700;margin:6px 0 2px;font-variant-numeric:tabular-nums;}
.metric .d{font-size:12.5px;color:var(--doc-mute);}
.up{color:var(--success-strong);} .flat{color:var(--doc-mute);}
.metric .from{color:var(--doc-mute);text-decoration:line-through;font-weight:500;font-size:16px;margin-left:6px;}
/* 手机模拟 */
.phones{display:flex;flex-wrap:wrap;gap:34px;justify-content:center;margin-top:8px;}
.phone-col{display:flex;flex-direction:column;align-items:center;gap:12px;}
.phone-tag{font-size:12.5px;padding:4px 12px;border-radius:999px;border:1px solid var(--doc-line);background:#fff;color:var(--doc-mute);}
.phone-tag.new{border-color:#C9E6D8;background:var(--success-light);color:var(--success-strong);font-weight:600;}
.phone{
width:375px;border-radius:30px;padding:9px;background:#15182B;
box-shadow:0 18px 44px rgba(20,24,50,.22),0 2px 6px rgba(20,24,50,.14);
}
.screen{width:100%;border-radius:22px;overflow:hidden;background:var(--bg-page);position:relative;}
.statusbar{height:22px;display:flex;align-items:center;justify-content:center;font-size:10px;color:rgba(255,255,255,.86);letter-spacing:.4px;}
/* ── 夜空头部:改造前(纯渐变) ── */
.night-a{
padding:0 12px 14px;
background:linear-gradient(160deg,var(--night-start),var(--night-end));
border-radius:0 0 21px 21px;box-shadow:var(--shadow-night);
}
/* ── 夜空头部:改造后(星点 + 双光晕 + 叠层) ── */
.night-b{
position:relative;overflow:hidden;padding:0 12px 16px;
background:
radial-gradient(circle at 12% 18%,rgba(255,255,255,.70) 0,transparent 1.25px),
radial-gradient(circle at 31% 66%,rgba(255,255,255,.70) 0,transparent 1px),
radial-gradient(circle at 57% 12%,rgba(255,255,255,.70) 0,transparent 1px),
radial-gradient(circle at 79% 52%,rgba(255,255,255,.70) 0,transparent 1.5px),
radial-gradient(circle at 93% 84%,rgba(255,255,255,.70) 0,transparent 1px),
linear-gradient(160deg,var(--night-start),var(--night-end));
border-radius:0 0 24px 24px;box-shadow:var(--shadow-night);
}
.night-b::before,.night-b::after{content:'';position:absolute;border-radius:50%;pointer-events:none;}
.night-b::before{top:-100px;right:-70px;width:280px;height:280px;
background:radial-gradient(circle,var(--glow-coral) 0%,transparent 68%);}
.night-b::after{bottom:-110px;left:-80px;width:310px;height:310px;
background:radial-gradient(circle,var(--glow-indigo) 0%,transparent 70%);}
.night-b > *{position:relative;z-index:1;}
.brand{min-height:56px;display:flex;align-items:center;justify-content:space-between;color:#fff;}
.brand-l{display:flex;align-items:center;}
.logo{width:33px;height:33px;border-radius:9px;background:linear-gradient(135deg,#FF7A88,#E96B6B);
display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;flex:none;}
.logo.b{width:34px;height:34px;}
.brand-name-a{font-size:17px;font-weight:700;letter-spacing:1px;line-height:1.25;}
.brand-name-b{font-size:18px;font-weight:700;letter-spacing:2px;line-height:1.25;}
.brand-slo-a{font-size:12px;letter-spacing:2px;color:rgba(255,255,255,.72);}
.brand-slo-b{font-size:12px;letter-spacing:3px;color:rgba(255,255,255,.72);}
.msg{font-size:12.5px;}
/* 状态条 */
.strip-a{margin:2px 0 8px;padding:9px 10px;border:1px solid rgba(255,255,255,.16);
background:rgba(255,255,255,.09);border-radius:10px;color:#fff;display:flex;align-items:center;gap:8px;}
.strip-b{margin:4px 0 8px;padding:12px;border:1px solid var(--hairline);
background:var(--glass);border-radius:12px;color:#fff;display:flex;align-items:center;gap:8px;}
.st-copy{flex:1;min-width:0;}
.st-title-a{font-size:12.5px;font-weight:600;}
.st-title-b{font-size:14px;font-weight:600;line-height:1.25;}
.st-desc{font-size:12px;color:rgba(255,255,255,.72);line-height:1.4;margin-top:2px;}
.cta-a{flex:none;min-width:54px;padding:0 9px;height:24px;display:flex;align-items:center;justify-content:center;
border-radius:14px;background:var(--grad-smoke);font-size:12px;font-weight:600;}
.cta-b{flex:none;min-width:58px;padding:0 12px;height:44px;display:flex;align-items:center;justify-content:center;
border-radius:999px;background:var(--grad-solid);font-size:13px;font-weight:600;}
.checks{display:flex;gap:5px;overflow:hidden;}
.check-a{padding:4px 6px;border-radius:10px;background:rgba(255,255,255,.08);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
.check-b{padding:4px 7px;border-radius:999px;background:var(--glass);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
.check-b.done{background:var(--glass-success);color:var(--success-on-night);font-weight:600;}
/* 横幅 */
.banner-a{height:136px;border-radius:13px;overflow:hidden;position:relative;
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
.banner-b{height:150px;border-radius:16px;overflow:hidden;position:relative;
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
.banner-a .scrim,.banner-b .scrim{position:absolute;inset:0;}
.banner-a .scrim{background:linear-gradient(90deg,rgba(25,22,86,.78) 0%,rgba(43,22,101,.25) 58%,rgba(255,79,145,.08) 100%);}
.banner-b .scrim{background:var(--scrim);}
.banner-copy{position:absolute;left:12px;width:68%;display:flex;flex-direction:column;color:#fff;}
.banner-a .banner-copy{bottom:13px;}
.banner-b .banner-copy{bottom:20px;}
.kick{font-size:12px;letter-spacing:1.5px;color:rgba(255,255,255,.78);}
.bt-a{margin-top:4px;font-size:17.5px;font-weight:700;line-height:1.2;text-shadow:0 1.5px 5px rgba(0,0,0,.2);}
.bt-b{margin-top:5px;font-size:24px;font-weight:700;line-height:1.25;letter-spacing:.5px;text-shadow:0 2px 7px rgba(0,0,0,.22);}
.bs-a,.bs-b{margin-top:4px;font-size:12px;color:rgba(255,255,255,.84);}
.bs-b{font-size:13px;color:rgba(255,255,255,.72);margin-top:5px;}
/* 快捷入口 */
.entries{position:relative;z-index:2;background:#fff;margin:-7px 12px 0;}
.entries.a{border-radius:15px;padding:11px 5px 9px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
.entries.b{border-radius:16px;padding:12px 4px 8px;box-shadow:var(--shadow-raised);}
.egrid{display:flex;flex-wrap:wrap;row-gap:8px;}
.eitem{width:25%;display:flex;flex-direction:column;align-items:center;justify-content:center;}
.eitem.a{min-height:52px;} .eitem.b{min-height:58px;}
.eicon{display:flex;align-items:center;justify-content:center;overflow:hidden;}
.eicon.a{width:33px;height:33px;border-radius:10px;background:var(--primary-light);font-size:15px;}
.eicon.b{width:36px;height:36px;border-radius:12px;background:var(--primary-light);font-size:16px;}
.elabel{margin-top:4px;color:var(--text-strong);}
.elabel.a{font-size:12px;font-weight:500;}
.elabel.b{font-size:13px;font-weight:500;}
/* 区块标题 */
.sec{padding:0 12px;}
.sec.a{margin-top:16px;} .sec.b{margin-top:20px;}
.shead{display:flex;align-items:center;justify-content:space-between;}
.shead.a{padding:11px 12px 11px 0;} .shead.b{padding:8px 0;}
.st-a{font-size:18px;font-weight:600;color:var(--text-primary);}
.st-b{font-size:20px;font-weight:600;color:var(--text-strong);}
.ssub{margin-left:8px;color:var(--text-tertiary);}
.ssub.a{font-size:13px;} .ssub.b{font-size:12px;}
.sact{display:flex;align-items:center;}
.sact.a{font-size:13px;color:var(--primary);height:18px;}
.sact.b{font-size:13px;color:var(--primary-strong);min-height:44px;padding-left:12px;font-weight:500;}
.caret{font-size:11px;margin-left:2px;}
/* 卡片 */
.card{background:#fff;display:flex;align-items:center;overflow:hidden;}
.card.a{border-radius:13px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
.card.b{border-radius:16px;box-shadow:var(--shadow-card);}
.rec{display:flex;align-items:center;gap:8px;padding:7px 8px;}
.rec .av{width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#D9D3F0,#BFC8EE);flex:none;}
.rec .info{flex:1;min-width:0;}
.rec .nm-a{font-size:13px;font-weight:700;color:var(--text-strong);}
.rec .nm-b{font-size:14px;font-weight:700;color:var(--text-strong);}
.rec .meta{font-size:12px;color:var(--text-tertiary);margin-top:2px;}
.rec .tags{display:flex;gap:4px;margin-top:4px;}
.rec .tag{padding:2px 5px;border-radius:6px;background:var(--accent-purple-light);color:var(--accent-purple-strong);font-size:12px;}
.heart-a{width:31px;height:31px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:14px;}
.heart-b{width:44px;height:44px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:16px;}
.divider{height:1px;background:var(--divider);}
/* 空态 */
.empty{background:#fff;display:flex;align-items:center;gap:8px;}
.empty.a{border-radius:13px;padding:11px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
.empty.b{border-radius:16px;padding:12px;box-shadow:var(--shadow-card);}
.logo-s{width:38px;height:38px;border-radius:10px;background:var(--grad-smoke);flex:none;
display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700;}
.et-a{font-size:12.5px;font-weight:600;color:var(--text-strong);}
.et-b{font-size:14px;font-weight:600;color:var(--text-strong);}
.ed{font-size:12px;color:var(--text-tertiary);line-height:1.4;margin-top:2px;}
.ed.b{font-size:13px;}
.eacts{display:flex;flex-direction:column;gap:4px;}
.ebtn{display:flex;align-items:center;justify-content:center;border-radius:12px;}
.ebtn.a{min-width:56px;height:24px;padding:0 6px;font-size:12px;}
.ebtn.b{min-width:60px;height:44px;padding:0 8px;font-size:13px;font-weight:500;border-radius:999px;}
.ebtn.sec-a{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
.ebtn.sec-b{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
.ebtn.pri-a{color:#fff;background:var(--grad-smoke);}
.ebtn.pri-b{color:#fff;background:var(--grad-solid);}
.hidden-note{margin-top:8px;font-size:11.5px;color:var(--danger-strong);
background:var(--danger-light);border-radius:8px;padding:6px 8px;}
.fixed-note{margin-top:8px;font-size:11.5px;color:var(--success-strong);
background:var(--success-light);border-radius:8px;padding:6px 8px;}
/* 注释气泡 */
.callout{display:flex;gap:10px;align-items:flex-start;margin-top:14px;max-width:375px;}
.callout .bullet{flex:none;width:20px;height:20px;border-radius:50%;background:var(--primary-solid);color:#fff;
font-size:11.5px;display:flex;align-items:center;justify-content:center;font-weight:700;margin-top:1px;}
.callout .txt{font-size:12.5px;color:var(--doc-mute);}
.callout .txt b{color:var(--doc-ink);}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--divider);vertical-align:top;}
th{color:var(--doc-mute);font-weight:600;font-size:12.5px;background:var(--bg-subtle);}
td code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
.bad{color:var(--danger-strong);font-weight:600;}
.good{color:var(--success-strong);font-weight:600;}
.sw{display:inline-flex;align-items:center;gap:7px;}
.dot{width:14px;height:14px;border-radius:4px;border:1px solid rgba(0,0,0,.08);flex:none;}
ul.clean{margin:8px 0 0;padding-left:18px;font-size:13.5px;color:var(--text-secondary);}
ul.clean li{margin:5px 0;}
ul.clean code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
.gate{display:flex;flex-wrap:wrap;gap:10px;}
.gate .g{flex:1 1 210px;border:1px solid #CFE9DC;background:var(--success-light);border-radius:11px;padding:12px 14px;}
.gate .g .n{font-size:19px;font-weight:700;color:var(--success-strong);}
.gate .g .l{font-size:12.5px;color:var(--text-secondary);margin-top:2px;}
.warnbox{border:1px solid #F2E3C4;background:var(--warning-light);border-radius:11px;padding:14px 16px;font-size:13px;color:#6B4E12;}
.warnbox b{color:var(--warning-strong);}
footer{margin-top:34px;font-size:12.5px;color:var(--doc-mute);text-align:center;}
</style>
</head>
<body>
<div class="wrap">
<h1>善缘丹阳 · 小程序设计系统 v2 改造对照</h1>
<p class="sub">以首页为样板页,展示「设计底座落地 + 氛围层 + 排版层级 + 可访问性」四类改动的实际效果。</p>
<div class="meta">
<span class="chip">基线 <b>main @ 825fd41</b></span>
<span class="chip">改动文件 <b>80</b></span>
<span class="chip">wxss 覆盖 <b>88 / 88</b></span>
<span class="chip">UI 契约门禁 <b>18 / 18 PASS</b></span>
</div>
<div class="panel">
<h2>一、量化指标</h2>
<p class="lead">改动前的问题不是「不够好看」,而是设计底座没落地 —— 88 个样式文件里 78% 绕过了 token。</p>
<div class="metrics">
<div class="metric">
<div class="k">页面层硬编码颜色</div>
<div class="v flat">0<span class="from">770</span></div>
<div class="d">仅 app.wxss 保留 56 个 token 定义色</div>
</div>
<div class="metric">
<div class="k">不可读字号(&lt;12px 非装饰)</div>
<div class="v flat">0<span class="from">140</span></div>
<div class="d">元信息下限统一到 24rpx(12px)</div>
</div>
<div class="metric">
<div class="k">非法字重(550/650/750)</div>
<div class="v flat">0<span class="from">30</span></div>
<div class="d">只保留 400/500/600/700 四档</div>
</div>
<div class="metric">
<div class="k">未定义的 token 引用</div>
<div class="v flat">0<span class="from">2</span></div>
<div class="d">此前静默失效(背景/过渡丢失)</div>
</div>
<div class="metric">
<div class="k">未达标文字对比度</div>
<div class="v flat">0<span class="from">41</span></div>
<div class="d">3.10:1 → 5.33:1</div>
</div>
<div class="metric">
<div class="k">白字压在浅渐变上的 CTA</div>
<div class="v flat">0<span class="from">10</span></div>
<div class="d">2.77:1 → 4.73:1</div>
</div>
<div class="metric">
<div class="k">品牌色实心底 + 白字</div>
<div class="v flat">0<span class="from">32</span></div>
<div class="d">按钮/标签/徽标统一换 solid 变体</div>
</div>
<div class="metric">
<div class="k">低于 44px 的触摸目标</div>
<div class="v flat">0<span class="from">42</span></div>
<div class="d">组件库按钮 40px、喜欢键 31px</div>
</div>
</div>
</div>
<div class="panel">
<h2>二、首页对照</h2>
<p class="lead">左侧是改造前(HEAD 版本的真实取值),右侧是改造后。两侧均用 app.wxss 里的真实 token 渲染,1rpx = 0.5px。</p>
<div class="phones">
<!-- ══════════ 改造前 ══════════ -->
<div class="phone-col">
<span class="phone-tag">改造前</span>
<div class="phone"><div class="screen">
<div class="night-a">
<div class="statusbar">9:41</div>
<div class="brand">
<div class="brand-l">
<div class="logo">善</div>
<div style="margin-left:6px">
<div class="brand-name-a">善缘丹阳</div>
<div class="brand-slo-a">公益婚恋服务平台</div>
</div>
</div>
<div class="msg">消息</div>
</div>
<div class="strip-a">
<div class="st-copy">
<div class="st-title-a">完善资料,开启安心相遇</div>
<div class="st-desc">补全 3 项信息即可进入推荐</div>
</div>
<div class="cta-a">去完善</div>
</div>
<div class="banner-a">
<div class="scrim"></div>
<div class="banner-copy">
<div class="kick">公益 · 本地 · 真实 · 安全</div>
<div class="bt-a">在丹阳,遇见更好的你</div>
<div class="bs-a">认真认识,温暖相伴</div>
</div>
</div>
</div>
<div class="entries a">
<div class="egrid">
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
</div>
</div>
<div class="sec a">
<div class="shead a">
<div><span class="st-a">今日推荐</span><span class="ssub a">为你认真筛选</span></div>
<div class="sact a">查看更多<span class="caret">›</span></div>
</div>
<div class="card a">
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-a">林晓 · 28</div>
<div class="meta">丹阳 · 教师 · 本科</div>
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
</div>
<div class="heart-a">❤</div>
</div>
<div class="divider"></div>
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-a">周敏 · 30</div>
<div class="meta">丹阳 · 医护 · 硕士</div>
<div class="tags"><span class="tag">同城</span></div>
</div>
<div class="heart-a">❤</div>
</div>
</div>
</div>
</div></div>
<div class="callout"><div class="bullet">1</div><div class="txt">
<b>夜空只有一条平渐变</b>:参考图里的暖粉光晕、星点、玻璃质感全部缺失,头部是「一块紫布」。
</div></div>
<div class="callout"><div class="bullet">2</div><div class="txt">
<b>层级读不出来</b>:hero 标题 17.5px、区块标题 18px、卡片标题 12.5px —— hero 与区块标题几乎同号。
</div></div>
<div class="callout"><div class="bullet">3</div><div class="txt">
<b>触摸目标不足</b>:「去完善」按钮高 24px、「查看更多」18px,远低于 44px 无障碍下限。
</div></div>
<div class="callout"><div class="bullet">4</div><div class="txt">
<b>对比度不达标</b>:CTA 白字压在珊瑚粉渐变上仅 <span class="bad">2.77:1</span>;文字链接 <code>#FF4F91</code> 仅 <span class="bad">3.10:1</span>。
</div></div>
</div>
<!-- ══════════ 改造后 ══════════ -->
<div class="phone-col">
<span class="phone-tag new">改造后</span>
<div class="phone"><div class="screen">
<div class="night-b">
<div class="statusbar">9:41</div>
<div class="brand">
<div class="brand-l">
<div class="logo b">善</div>
<div style="margin-left:8px">
<div class="brand-name-b">善缘丹阳</div>
<div class="brand-slo-b">公益婚恋服务平台</div>
</div>
</div>
<div class="msg">消息</div>
</div>
<div class="strip-b">
<div class="st-copy">
<div class="st-title-b">完善资料,开启安心相遇</div>
<div class="st-desc">补全 3 项信息即可进入推荐</div>
</div>
<div class="cta-b">去完善</div>
</div>
<div class="banner-b">
<div class="scrim"></div>
<div class="banner-copy">
<div class="kick">公益 · 本地 · 真实 · 安全</div>
<div class="bt-b">在丹阳,遇见更好的你</div>
<div class="bs-b">认真认识,温暖相伴</div>
</div>
</div>
</div>
<div class="entries b">
<div class="egrid">
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
</div>
</div>
<div class="sec b">
<div class="shead b">
<div><span class="st-b">今日推荐</span><span class="ssub b">为你认真筛选</span></div>
<div class="sact b">查看更多<span class="caret">›</span></div>
</div>
<div class="card b">
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-b">林晓 · 28</div>
<div class="meta">丹阳 · 教师 · 本科</div>
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
</div>
<div class="heart-b">❤</div>
</div>
<div class="divider"></div>
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-b">周敏 · 30</div>
<div class="meta">丹阳 · 医护 · 硕士</div>
<div class="tags"><span class="tag">同城</span></div>
</div>
<div class="heart-b">❤</div>
</div>
</div>
</div>
</div></div>
<div class="callout"><div class="bullet">1</div><div class="txt">
<b>三层叠加做成氛围</b>:<code>--mini-starfield</code> 星点 + 夜空渐变 + 两个定位光晕(右上珊瑚粉、左下靛蓝)。全部用 CSS 渐变,零图片、零额外请求。
</div></div>
<div class="callout"><div class="bullet">2</div><div class="txt">
<b>四级排版阶梯</b>:hero <b>24px/700</b> &gt; 区块标题 <b>20px/600</b> &gt; 卡片标题 <b>14px/600</b> &gt; 元信息 12px。
</div></div>
<div class="callout"><div class="bullet">3</div><div class="txt">
<b>触摸目标全部达标</b>:CTA 44px;区块标题右侧「查看更多」由 18px 提到 44px 热区,且热区向左补白不挤压标题。
</div></div>
<div class="callout"><div class="bullet">4</div><div class="txt">
<b>对比度达标</b>:CTA 换 <code>--gradient-brand-solid</code> → <span class="good">4.73:1</span>;文字链接换 <code>--color-primary-strong</code> → <span class="good">5.33:1</span>。
</div></div>
<div class="callout"><div class="bullet">5</div><div class="txt">
<b>标题与卡片对齐</b>:旧版标题比卡片多缩进 32rpx(.section 的 24rpx 与组件自带 32rpx 叠加),安全提醒卡还因 <code>padding:0</code> 顶到屏幕边缘。
</div></div>
</div>
</div>
</div>
<div class="panel">
<h2>三、小屏(≤340px)的行为差异</h2>
<p class="lead">这是本轮改动里唯一一处「功能级」缺陷修复,值得单独看。</p>
<div style="display:flex;gap:34px;flex-wrap:wrap;justify-content:center">
<div class="phone-col">
<span class="phone-tag">改造前</span>
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 21px 21px"></div>
<div class="entries a" style="margin-top:-7px">
<div class="egrid">
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
</div>
</div>
<div class="sec a">
<div class="empty a">
<div class="logo-s">善</div>
<div style="flex:1;min-width:0">
<div class="et-a">暂时没有完全符合的人</div>
<div class="ed">我们坚持 Required 条件</div>
</div>
<div style="width:56px"></div>
</div>
<div class="hidden-note">@media (max-width:340px){ .empty-actions{display:none} } —— 两个操作入口整块消失</div>
</div>
</div></div>
</div>
<div class="phone-col">
<span class="phone-tag new">改造后</span>
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 24px 24px"></div>
<div class="entries b" style="margin-top:-7px">
<div class="egrid">
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
</div>
</div>
<div class="sec b">
<div class="empty b">
<div class="logo-s">善</div>
<div style="flex:1;min-width:0">
<div class="et-b">暂时没有完全符合的人</div>
<div class="ed b">我们坚持 Required 条件</div>
</div>
<div class="eacts">
<div class="ebtn sec-b">调整条件</div>
<div class="ebtn pri-b">公益红娘</div>
</div>
</div>
<div class="fixed-note">小屏只收紧尺寸与字号,不再砍掉任何入口</div>
</div>
</div></div>
</div>
</div>
</div>
<div class="panel">
<h2>四、设计 token 变更要点</h2>
<p class="lead">品牌色分级是这一版最重要的结构性决定:基础品牌色 <code>#E96B6B</code> 无论做背景还是做文字都不满足 4.5:1,因此拆成三个语义层。</p>
<table>
<thead><tr><th style="width:230px">Token</th><th style="width:130px">取值</th><th style="width:110px">对比度</th><th>用途限制</th></tr></thead>
<tbody>
<tr>
<td><span class="sw"><span class="dot" style="background:#E96B6B"></span><code>--color-primary</code></span></td>
<td><code>#E96B6B</code></td><td class="bad">3.10:1</td>
<td>仅大色块、渐变、光晕、图标底。禁止承载文字。</td>
</tr>
<tr>
<td><span class="sw"><span class="dot" style="background:#B8434A"></span><code>--color-primary-strong</code></span></td>
<td><code>#B8434A</code></td><td class="good">5.33:1</td>
<td>所有品牌色文字、小图标、纯文字链接。</td>
</tr>
<tr>
<td><span class="sw"><span class="dot" style="background:#C24C50"></span><code>--color-primary-solid</code></span></td>
<td><code>#C24C50</code></td><td class="good">4.73:1</td>
<td>实心按钮底(配白字)、小徽标底色。</td>
</tr>
<tr>
<td><code>--gradient-brand</code></td>
<td>#FF6A78 → #E96B6B</td><td class="bad">2.77:1</td>
<td>明亮活泼,仅装饰填充,不得压白字。</td>
</tr>
<tr>
<td><code>--gradient-brand-solid</code></td>
<td>#C24C50 → #B8434A</td><td class="good">4.73 ~ 5.33</td>
<td>所有承载白字的 CTA 渐变(本轮新增)。</td>
</tr>
<tr>
<td><code>--color-text-tertiary</code></td>
<td>#777 → <code>#646C85</code></td><td class="good">5.22:1</td>
<td>说明文字。旧值 4.48:1 卡在阈值下沿。</td>
</tr>
<tr>
<td><code>--mini-glow-coral</code> / <code>--mini-glow-indigo</code></td>
<td>rgba 粉 / 蓝</td><td class="flat">—</td>
<td>夜空光晕层(本轮新增,参考图关键特征)。</td>
</tr>
<tr>
<td><code>--mini-starfield</code></td>
<td>5 个径向渐变</td><td class="flat">—</td>
<td>星点层(本轮新增),叠在夜空渐变之上。</td>
</tr>
<tr>
<td><code>--mini-glass</code> / <code>--mini-hairline</code></td>
<td>rgba(255,255,255,.10/.16)</td><td class="flat">—</td>
<td>深底玻璃面与分隔线(本轮新增)。</td>
</tr>
<tr>
<td><code>--tap-min</code> / <code>--tap-gap</code></td>
<td>88rpx / 16rpx</td><td class="flat">44px</td>
<td>触摸目标下限与最小间距(本轮新增)。</td>
</tr>
<tr>
<td><code>--ease-spring</code> / <code>--press-scale</code></td>
<td>cubic-bezier(.34,1.4,.64,1) / 0.97</td><td class="flat">—</td>
<td>按压回弹与缩放(本轮新增)。</td>
</tr>
</tbody>
</table>
</div>
<div class="panel">
<h2>五、本轮修掉的实质缺陷</h2>
<p class="lead">不只是样式替换 —— 其中三条是会被静默吞掉的语法级错误。</p>
<ul class="clean">
<li><b>3 处声明粘连(脚本副作用)</b>:<code>recommend-card</code>、<code>discover</code>、<code>service-station</code> 里出现
<code>box-shadow:…rgba(255,79,145,.18)min-width:var(--tap-min);</code> 这类<b>缺少分号</b>的合并声明。
浏览器会把整条声明判为非法并丢弃 —— 于是「喜欢」按钮的 44px 热区与阴影双双静默失效,
而花括号平衡、hex 配额等所有静态检查仍然全绿。</li>
<li><b>2 处引用了不存在的 token</b>:<code>--button-press-feedback</code>、<code>--color-bg-input</code> 此前从未定义,
导致过渡动画与输入框背景直接失效(无报错、无告警)。</li>
<li><b>小屏砍掉操作入口</b>:<code>@media (max-width:340px)</code> 里 <code>.empty-actions{display:none}</code>,
在窄屏机型上把「调整条件 / 公益红娘」两个入口整块移除,属功能级缺陷。</li>
<li><b>安全提醒卡错位</b>:旧版 <code>.safety-card{padding:0}</code> 覆盖了 <code>.section</code> 的左右内边距,
该卡片比其它区块宽 48rpx、直接贴到屏幕边缘。</li>
<li><b>触摸目标普遍不足</b>:组件库主按钮 40px、小号 28px、纯文字变体 <code>min-height:auto</code>、
「喜欢」键 31px、区块标题右侧 action 18px —— 全站已统一到 44px 下限。</li>
<li><b>对比度系统性不达标</b>:41 处把品牌基础色当文字用(3.10:1)、32 处把基础色做实心底压白字、
10 处白字压在明亮渐变上(2.77:1)。已按「文字用 strong / 实心底用 solid / 渐变按是否压字分流」三类语义收敛。</li>
<li><b>字重机型不一致</b>:30 处 550/650/750 在 Android 无对应字重会回退,同一设计在不同机型上粗细不一致。</li>
</ul>
</div>
<div class="panel">
<h2>六、验证结果</h2>
<p class="lead">以下为实际执行结果,非声明性描述。</p>
<div class="gate">
<div class="g"><div class="n">18 / 18</div><div class="l">UI 契约门禁全部通过(源码结构 + 关键行为断言)</div></div>
<div class="g"><div class="n">0</div><div class="l">未定义的 token 引用(扫描 88 个 wxss / 131 个 token)</div></div>
<div class="g"><div class="n">88 / 88</div><div class="l">wxss 文件通过声明级体检(无粘连、无空规则、括号平衡)</div></div>
<div class="g"><div class="n">0</div><div class="l">app.wxss 之外的硬编码颜色(56 个 hex 全部是 token 定义)</div></div>
<div class="g"><div class="n">0</div><div class="l">非百位字重 / 非装饰类不可读字号</div></div>
<div class="g"><div class="n">88 / 88</div><div class="l">文件编码统一为 UTF-8 无 BOM(与仓库既有约定一致)</div></div>
</div>
<div class="warnbox" style="margin-top:16px">
<b>尚未验证的部分(不作已验收)</b><br />
· 本页是<b>静态还原</b>,用真实 token 值复刻布局,不是小程序真机截图;动态横幅轮播、真实数据、字号在真实设备上的渲染需要在小程序开发者工具里目视确认。<br />
· 氛围层用到的 <code>background</code> 多层渐变与 <code>radial-gradient</code> 需要在真机(iOS / Android 各一台)复验,尤其确认低端机上不出现色带。<br />
· 布局类改动(首页重做、标题热区加高、触摸目标统一为 44px)尚无自动化视觉回归,需要人工过一遍主要页面。
</div>
</div>
<footer>
善缘丹阳公益婚恋服务平台 · 小程序设计系统 v2 · 生成于 2026-09-22 · 基线 main @ 825fd41
</footer>
</div>
</body>
</html>
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
+46 -8
View File
@@ -1,11 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<mirrors>
<mirror>
<id>central</id>
<mirrorOf>central</mirrorOf>
<name>Maven Central</name>
<url>https://repo1.maven.org/maven2</url>
</mirror>
</mirrors>
<!--
本地构建必须走 aliyun 镜像:本机 Maven Central(repo1.maven.org)被 DNS 投毒,
TLS 握手被中断("Remote host terminated the handshake"),直连 central 必然失败。
aliyun 作为主源、central 作为兜底(仅当 aliyun 缺失某构件时才回退,正常构件不会触达 central)。
CI runner 能直连 central,但 aliyun 同样可达,故该配置对 CI 也无害。
-->
<profiles>
<profile>
<id>aliyun-with-central-fallback</id>
<repositories>
<repository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<repository>
<id>central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>aliyun-plugin-public</id>
<name>Aliyun Plugin Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>central-plugin</id>
<name>Maven Central Plugins</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>aliyun-with-central-fallback</activeProfile>
</activeProfiles>
</settings>
+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>
@@ -1,5 +1,8 @@
package com.danyang.match.platform.shared.dto;
import com.danyang.match.platform.shared.policy.MatchingPolicy;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@@ -11,8 +14,12 @@ import java.util.Map;
*/
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppController {
private final ProfilePolicy profilePolicy;
private final MatchingPolicy matchingPolicy;
/**
* 获取应用配置
* GET /api/v1/app/config
@@ -36,13 +43,17 @@ public class AppController {
config.put("features", features);
// 业务限制参数
// 注:maxProfilePhotos / matchScoreThreshold 改由统一配置源(ProfilePolicy / MatchingPolicy)
// 下发,禁止在此写死(PROFILE-001 / MATCH-001)。其余项(dailyRecommendationLimit、
// maxInterestTags、selfIntroMaxLength、recommendationCooldownDays)本轮未纳入,
// 仍为硬编码,已在 config-drift-audit 中登记为「待统一」未来项。
Map<String, Object> businessLimits = new HashMap<>();
businessLimits.put("dailyRecommendationLimit", 6);
businessLimits.put("maxProfilePhotos", 6);
businessLimits.put("maxProfilePhotos", profilePolicy.getMaxPhotoCount());
businessLimits.put("maxInterestTags", 8);
businessLimits.put("selfIntroMaxLength", 500);
businessLimits.put("recommendationCooldownDays", 180);
businessLimits.put("matchScoreThreshold", 60);
businessLimits.put("matchScoreThreshold", matchingPolicy.getRecommendationMinScore());
config.put("businessLimits", businessLimits);
// 审核相关
@@ -0,0 +1,52 @@
package com.danyang.match.platform.account.controller;
import com.danyang.match.platform.account.service.DataRetentionService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 注销数据到期处置(Operator 端,2026-09-19 数据保留决策落地)。
*
* <p>权限 {@code account.retention.purge}(V049)仅授予 super_admin ——
* 处置不可逆,不做批量授权。流程为「人工审核」模式:先查到期清单,
* 运营确认后逐个执行;保留期内(产品确认下限 36 个月)的账号服务端拒绝处置。
*/
@RestController
@RequestMapping("/operator/account/retention")
@RequiredArgsConstructor
public class AccountRetentionOperatorController {
private final DataRetentionService dataRetentionService;
/**
* 保留期已满的注销账号清单
* GET /api/v1/operator/account/retention/due
*/
@GetMapping("/due")
@PreAuthorize("hasAuthority('account.retention.purge')")
public ApiResponse<List<Map<String, Object>>> dueAccounts() {
return ApiResponse.ok(dataRetentionService.listDueAccounts());
}
/**
* 执行到期匿名化清除(幂等)
* POST /api/v1/operator/account/retention/{userId}/purge
*/
@PostMapping("/{userId}/purge")
@PreAuthorize("hasAuthority('account.retention.purge')")
public ApiResponse<Map<String, Object>> purge(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String userId) {
if (principal == null) {
return ApiResponse.unauthorized();
}
return ApiResponse.ok(dataRetentionService.purgeExpired(userId, principal.id()));
}
}
@@ -10,6 +10,7 @@ import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.PrivacyService;
import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.constant.Constants;
@@ -38,6 +39,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 账号生命周期服务
@@ -68,6 +71,7 @@ public class AccountLifecycleService {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final PrivacyService privacyService;
private final ActivityRegistrationMapper registrationMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ReportMapper reportMapper;
@@ -83,6 +87,7 @@ public class AccountLifecycleService {
private final ChatService chatService;
private final UserIdentityMapper userIdentityMapper;
private final CounselingFollowupMapper counselingFollowupMapper;
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
// ==================== 退出婚恋服务(暂停推荐) ====================
@@ -94,7 +99,7 @@ public class AccountLifecycleService {
public Map<String, Object> withdrawDatingService(String userId, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
// 检查是否有进行中的Match(不能直接退出)
@@ -132,7 +137,7 @@ public class AccountLifecycleService {
public Map<String, Object> resumeDatingService(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
user.setRecommendationEnabled(true);
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
@@ -159,13 +164,14 @@ public class AccountLifecycleService {
public Map<String, Object> checkCanWithdraw(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
Map<String, Object> blockers = new HashMap<>();
// 检查是否有进行中的红娘服务
long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper
// 检查是否有进行中的红娘服务(2e 接线:原硬编码 0,blocker 从未生效)。
// 状态口径 = 生产 chk_matchmaker_status 白名单的非终态值(submitted/assigned/in_service/waiting_user)。
long activeMatchmakerServices = matchmakerServiceMapper.countActiveByUserId(userId);
if (activeMatchmakerServices > 0) {
blockers.put("matchmakerService", "您有进行中的红娘服务,请先完成或取消");
}
@@ -243,7 +249,7 @@ public class AccountLifecycleService {
public Map<String, Object> closeAccount(String userId, String operatorId, String reason) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
// 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。
@@ -268,8 +274,8 @@ public class AccountLifecycleService {
// 2b. 通知所有活跃 Match 对方
notifyActiveMatches(user.getId(), "account_closed");
// 2c. 通知红娘(如果有)
notifyMatchmaker(user.getId());
// 2c. 终止残留红娘服务(竞态兜底,见方法注释;不能向 operator 发通知——Phase 18 D2 FK 教训)
terminateActiveMatchmakerServices(user.getId());
// 2d. 取消所有未开始的活动报名
cancelPendingActivityRegistrations(userId);
@@ -294,6 +300,9 @@ public class AccountLifecycleService {
// 4. 记录退出时间
Instant now = Instant.now();
// V049:注销时间落库 —— 数据保留到期(closed_at + account.data-retention-months,
// 产品 2026-09-19 确认下限 36 个月)由此起算;此前只存在于日志,保留期无法计算。
user.setClosedAt(now);
user.setUpdatedAt(now);
userMapper.updateById(user);
@@ -325,7 +334,7 @@ public class AccountLifecycleService {
public Map<String, Object> withdrawConsent(String userId, String consentType) {
ConsentRecord record = consentRecordMapper.findByUserAndType(userId, consentType).orElse(null);
if (record == null) {
throw new RuntimeException("未找到该同意记录");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该同意记录");
}
record.setAgreed(false);
consentRecordMapper.updateById(record);
@@ -342,26 +351,57 @@ public class AccountLifecycleService {
// ==================== 隐私设置 ====================
/**
* 获取隐私设置
* 小程序 privacy-settings 页的契约键集(产品裁定:隐私设置键集<b>以小程序为准</b>)。
* 用于入参校验:未知键宽容忽略,但<b>已知键类型错误必须显式报错</b> ——
* 若静默丢弃,用户会看到"保存成功"而实际未生效,这类缺陷最难排查。
*/
private static final Set<String> PRIVACY_KEYS =
Set.of("visibleInRecommendations", "allowActivityInvitation", "showLastOnline");
/**
* 获取隐私设置。
*
* <p>原为 TODO 桩:永远返回写死的默认值、<b>不读库</b>,用户改过的设置读不回来。
* 现委托 {@link PrivacyService} 读真实 {@code user_privacy_settings}。
*/
public Map<String, Object> getPrivacySettings(String userId) {
// TODO: Phase 21 实现 user_privacy_settings 查询
Map<String, Object> settings = new HashMap<>();
settings.put("visibleInRecommendations", true);
settings.put("allowActivityInvitation", true);
settings.put("showLastOnline", false);
return settings;
return new HashMap<>(privacyService.getPrivacySettings(userId));
}
/**
* 更新隐私设置
* 更新隐私设置。
*
* <p>原为 TODO 桩:只回 {@code {updated:true}}、<b>不落库</b>,
* 用户看到"保存成功"后刷新即丢失。现委托 {@link PrivacyService} 真实 upsert。
*/
@Transactional
public Map<String, Object> updatePrivacySettings(String userId, Map<String, Object> settings) {
// TODO: Phase 21 实现 user_privacy_settings 更新
Map<String, Object> result = new HashMap<>();
result.put("updated", true);
return result;
return new HashMap<>(privacyService.updatePrivacySettings(userId, toBooleanMap(settings)));
}
private Map<String, Boolean> toBooleanMap(Map<String, Object> settings) {
Map<String, Boolean> typed = new HashMap<>();
if (settings == null) {
return typed;
}
for (Map.Entry<String, Object> e : settings.entrySet()) {
String key = e.getKey();
Object value = e.getValue();
if (value == null) {
continue;
}
if (value instanceof Boolean b) {
typed.put(key, b);
} else if (value instanceof String s
&& ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s))) {
// 容错:部分客户端会把 boolean 序列化成字符串
typed.put(key, Boolean.valueOf(s));
} else if (PRIVACY_KEYS.contains(key)) {
throw new IllegalArgumentException("隐私设置字段类型错误: " + key);
}
// 未知键:宽容忽略
}
return typed;
}
// ==================== 私有辅助方法 ====================
@@ -392,11 +432,34 @@ public class AccountLifecycleService {
}
/**
* 通知红娘服务已终止
* 注销收尾(2e 接线,原 {@code notifyMatchmaker} 为 TODO 空转):把该用户残留的非终态
* 红娘服务置为 cancelled。
*
* <p>正常路径下 {@code checkCanWithdraw} 的 blocker 会要求用户先完成/取消红娘服务,
* 走到注销时不应再有非终态记录 —— 本方法是<b>竞态兜底</b>(blocker 检查与注销事务之间,
* 红娘管理员恰好受理了新服务)。
*
* <p><b>为什么不是「发通知」</b>:Phase 18 D2 的教训 —— {@code notifications.user_id → users(id)}
* 外键下,operator 不在 users 表,向红娘发用户通知在真实 PG 上必然使注销事务回滚。
* 因此以状态收尾 + 日志代替「通知」,效果更实质且不会炸注销事务。
*/
private void notifyMatchmaker(String userId) {
// TODO: 查询该用户的红娘服务,通知相关红娘
log.info("通知红娘服务终止: userId={}", userId);
private void terminateActiveMatchmakerServices(String userId) {
List<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord> active =
matchmakerServiceMapper.selectList(
new LambdaQueryWrapper<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord>()
.eq(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getUserId, userId)
.in(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getStatus,
Constants.MATCHMAKER_STATUS_SUBMITTED,
Constants.MATCHMAKER_STATUS_ASSIGNED,
Constants.MATCHMAKER_STATUS_IN_SERVICE,
Constants.MATCHMAKER_STATUS_WAITING_USER)
.isNull(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getDeletedAt));
for (com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord svc : active) {
svc.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED);
svc.setUpdatedAt(Instant.now());
matchmakerServiceMapper.updateById(svc);
log.info("注销收尾: 残留红娘服务已终止 userId={}, serviceId={}", userId, svc.getId());
}
}
/**
@@ -0,0 +1,181 @@
package com.danyang.match.platform.account.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.base.BaseEntity;
import com.danyang.match.platform.shared.config.AccountConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
/**
* 注销数据到期处置(批 3 收尾:数据保留流程,2026-09-19 产品决策落地)。
*
* <h3>决策与口径</h3>
* 产品 2026-09-19 确认:注销后用户数据保留<b>不得低于 36 个月</b>(隐私政策 §3.3 原文
* 「30 天内删除」与本决策冲突,已按用户最新明确指令同步修订政策文本)。
* 保留期经 {@link AccountConfig#getDataRetentionMonths()} 配置化(AGENTS.md #12),
* 到期 = {@code closed_at + retentionMonths}。
*
* <h3>「配置 + 人工审核」而非自动作业</h3>
* 计划 Phase 21 的既定要求是「数据保留期限未确认的敏感材料采用配置和人工审核,
* 不擅自永久保存或删除」。因此本服务只提供<b>人工审核的处置工具</b>:
* {@code listDueAccounts()} 列出保留期已满的注销账号,由持
* {@code account.retention.purge} 权限(V049,仅授 super_admin)的运营确认后
* 调用 {@code purgeExpired()} 执行 —— <b>不引入定时自动删除</b>。
*
* <h3>处置内容(断开 PII 关联,保留统计与审计所需最小信息)</h3>
* <ol>
* <li>{@code users.wechat_openid} → {@code purged-<userId>}(不可再登录、不可回连微信身份;
* phone 清空。列有 UNIQUE 约束,purged 前缀 + userId 保证唯一);</li>
* <li>{@code user_profiles}:displayName → 「已注销用户」、selfIntro 清空
* (性别/年龄段等非直接标识符保留,供匿名统计);</li>
* <li>{@code user_photos} / {@code local_eligibility_evidences}:全部软删;</li>
* <li>审计:{@code audit_logs} 记 {@code DATA_RETENTION_PURGE}。</li>
* </ol>
*
* <p><b>如实登记的边界</b>:佐证/照片的<b>对象存储文件本体</b>不在此处删除
* (需要对象存储客户端与逐文件凭证),由对象存储生命周期规则兜底 —— 登记于
* {@code docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md} 批 3。
* 咨询摘要/正文已在<b>注销时</b>脱敏({@code anonymizeServiceHistory}),无需重复处理。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataRetentionService {
static final String PURGED_OPENID_PREFIX = "purged-";
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final UserPhotoMapper userPhotoMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final LocalEligibilityEvidenceMapper evidenceMapper;
private final AuditLogMapper auditLogMapper;
private final AccountConfig accountConfig;
/** 保留到期时点:closed_at + retentionMonths(按 UTC 日历月计算,非 30 天近似) */
public Instant retentionDueAt(Instant closedAt) {
return closedAt.atZone(ZoneOffset.UTC).plusMonths(accountConfig.getDataRetentionMonths())
.toInstant();
}
/** 保留期已满、待人工审核处置的注销账号清单 */
public List<Map<String, Object>> listDueAccounts() {
Instant now = Instant.now();
List<User> closed = userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getAccountStatus, Constants.ACCOUNT_STATUS_CLOSED)
.isNotNull(User::getClosedAt)
.isNull(BaseEntity::getDeletedAt));
List<Map<String, Object>> due = new ArrayList<>();
for (User user : closed) {
Instant dueAt = retentionDueAt(user.getClosedAt());
if (dueAt.isAfter(now)) {
continue; // 仍在保留期内 —— 产品确认的下限,不得提前处置
}
boolean alreadyPurged = user.getWechatOpenid() != null
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX);
Map<String, Object> row = new HashMap<>();
row.put("userId", user.getId());
row.put("closedAt", user.getClosedAt());
row.put("retentionMonths", accountConfig.getDataRetentionMonths());
row.put("retentionDueAt", dueAt);
row.put("purged", alreadyPurged);
due.add(row);
}
return due;
}
/**
* 执行到期匿名化清除(人工审核后调用)。
*
* @return 处置结果;{@code purged=true} 表示此前已处置过(幂等重放)
*/
@Transactional
public Map<String, Object> purgeExpired(String userId, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
if (!Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
throw new IllegalArgumentException("仅限已注销账号执行到期处置");
}
if (user.getClosedAt() == null || retentionDueAt(user.getClosedAt()).isAfter(Instant.now())) {
throw new IllegalArgumentException("该账号仍在保留期内(下限 "
+ accountConfig.getDataRetentionMonths() + " 个月),不得提前处置");
}
Map<String, Object> result = new HashMap<>();
if (user.getWechatOpenid() != null
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX)) {
result.put("purged", true);
result.put("alreadyPurged", true);
return result; // 幂等:重复调用不产生第二次审计与写入
}
// 1. 断开账号级直接标识符。
// ⚠️ 必须用 LambdaUpdateWrapper.set(..., null):MyBatis-Plus 默认 FieldStrategy=NOT_NULL,
// updateById 时 phone=null 不会进 SQL(守卫实测:清 phone 断言失败,旧值仍在库)。
userMapper.update(null, new LambdaUpdateWrapper<User>()
.eq(User::getId, userId)
.set(User::getWechatOpenid, PURGED_OPENID_PREFIX + userId)
.set(User::getPhone, null));
// 2. 资料直接标识字段匿名化(selfIntro 置 null 同理须显式 set)
userProfileMapper.findByUserId(userId).ifPresent(profile ->
userProfileMapper.update(null, new LambdaUpdateWrapper<UserProfile>()
.eq(UserProfile::getUserId, userId)
.set(UserProfile::getDisplayName, "已注销用户")
.set(UserProfile::getSelfIntro, null)));
// 3. 照片与本地资格佐证软删(文件本体由对象存储生命周期规则兜底,见类注释边界)。
// 佐证表无 user_id 直连列,须经 user_local_eligibilities 关联。
Instant now = Instant.now();
for (UserPhoto photo : userPhotoMapper.selectList(
new LambdaQueryWrapper<UserPhoto>().eq(UserPhoto::getUserId, userId))) {
photo.setDeletedAt(now);
userPhotoMapper.updateById(photo);
}
localEligibilityMapper.findByUserId(userId).ifPresent(el -> {
for (LocalEligibilityEvidence evidence : evidenceMapper.selectList(
new LambdaQueryWrapper<LocalEligibilityEvidence>()
.eq(LocalEligibilityEvidence::getLocalEligibilityId, el.getId()))) {
evidence.setDeletedAt(now);
evidenceMapper.updateById(evidence);
}
});
// 4. 审计(不可逆操作必须留痕)
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction("DATA_RETENTION_PURGE");
auditLog.setResourceType("user");
auditLog.setResourceId(userId);
auditLogMapper.insert(auditLog);
log.info("注销数据到期处置完成: userId={}, operatorId={}, retentionMonths={}",
userId, operatorId, accountConfig.getDataRetentionMonths());
result.put("purged", true);
result.put("alreadyPurged", false);
return result;
}
}
@@ -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;
@@ -30,7 +30,8 @@ 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;
@GetMapping("/operator/activity-interest-summary")
@@ -105,7 +106,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")
@@ -120,13 +126,16 @@ public class ActivityInterestController {
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));
}
/**
@@ -24,6 +24,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
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;
/**
* 活动互选服务
@@ -59,17 +62,17 @@ public class ActivityInterestService {
// 1. 验证活动已结束
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new RuntimeException("活动不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
}
if (!Constants.ACTIVITY_STATUS_ENDED.equals(activity.getStatus())) {
throw new RuntimeException("活动尚未结束,暂不支持反馈");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动尚未结束,暂不支持反馈");
}
// 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 RuntimeException("报名未通过审核,无法提交反馈");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
}
// 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己)
@@ -30,6 +30,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
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
@@ -110,9 +113,9 @@ public class ActivityService {
@Transactional
public Activity publishActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许发布");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许发布");
}
activity.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN);
if (activity.getRegistrationStartAt() == null) {
@@ -129,10 +132,10 @@ public class ActivityService {
@Transactional
public Activity cancelActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus()) &&
!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许取消");
}
activity.setStatus(Constants.ACTIVITY_STATUS_CANCELLED);
activityMapper.updateById(activity);
@@ -220,9 +223,9 @@ public class ActivityService {
String emergencyContactName,
String emergencyContactPhoneEnc) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("活动未开放报名或已结束");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开放报名或已结束");
}
// Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at
// 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。
@@ -237,7 +240,7 @@ public class ActivityService {
if (existing.isPresent()
&& !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus())
&& !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) {
throw new RuntimeException("已经报名过该活动");
throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "已经报名过该活动");
}
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
@@ -265,7 +268,7 @@ public class ActivityService {
if (registrationMapper.reuseIfCapacityAvailable(
old.getId(), activityId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
@@ -294,7 +297,7 @@ public class ActivityService {
if (registrationMapper.insertIfCapacityAvailable(
newId, activityId, userId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
@@ -334,9 +337,9 @@ public class ActivityService {
@Transactional
public ActivityRegistration approveRegistration(String registrationId, String operatorId) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核通过");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核通过");
}
// Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动
@@ -355,7 +358,7 @@ public class ActivityService {
}
if (affected == 0) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满或状态已变更,无法通过审核");
}
// 回读事务内最新状态(条件更新不回填实体)
registration = registrationMapper.selectById(registrationId);
@@ -376,16 +379,16 @@ public class ActivityService {
@Transactional
public ActivityRegistration rejectRegistration(String registrationId, String operatorId, String reason) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核");
}
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
// Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时
// 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。
if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法拒绝");
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法拒绝");
}
registration = registrationMapper.selectById(registrationId);
@@ -405,14 +408,14 @@ 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 RuntimeException("当前状态不能取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
}
// 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额
if (registrationMapper.cancelIfActive(registration.getId()) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法取消");
}
// 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退)
if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
@@ -721,7 +724,7 @@ public class ActivityService {
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
}
if (!verified) {
throw new RuntimeException("该活动需完成实名认证后方可报名");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需完成实名认证后方可报名");
}
}
@@ -733,7 +736,7 @@ public class ActivityService {
log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage());
}
if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) {
throw new RuntimeException("该活动需通过本地资格核验后方可报名");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需通过本地资格核验后方可报名");
}
}
}
@@ -746,10 +749,10 @@ public class ActivityService {
if (birth == null) return; // 资料不全时不阻断报名
int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
if (activity.getMinAge() != null && age < activity.getMinAge()) {
throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄未满 " + activity.getMinAge() + " 周岁");
}
if (activity.getMaxAge() != null && age > activity.getMaxAge()) {
throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
}
}
@@ -782,10 +785,10 @@ public class ActivityService {
private void assertWithinRegistrationWindow(Activity activity) {
Instant now = Instant.now();
if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) {
throw new RuntimeException("活动报名尚未开始");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名尚未开始");
}
if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) {
throw new RuntimeException("活动报名已截止");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名已截止");
}
}
@@ -835,7 +838,7 @@ public class ActivityService {
String groupCode = resolveCapacityGroupCode(activityId, gender);
if (groupCode == null) return null;
if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) {
throw new RuntimeException("该分组名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "该分组名额已满");
}
return groupCode;
}
@@ -26,6 +26,10 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
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;
/**
* 签到服务
@@ -61,21 +65,21 @@ 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 RuntimeException("报名尚未审核通过,无法签到");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
}
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("您已经签到过了");
throw new BusinessException(ApiResponseCode.ALREADY_CHECKED_IN, "您已经签到过了");
}
Activity activity = requireCheckableActivity(activityId);
Instant now = Instant.now();
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
throw new RuntimeException("活动未开始或已结束,无法签到");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开始或已结束,无法签到");
}
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
@@ -290,7 +294,7 @@ public class CheckinService {
byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("签名生成失败", e);
throw new ServerErrorException("签名生成失败", e);
}
}
@@ -1,17 +1,23 @@
package com.danyang.match.platform.activity_mutual.service;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.config.ActivityConfig;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
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
@@ -20,6 +26,8 @@ public class ActivityMutualConsentService {
private final ActivityMutualConsentMapper consentMapper;
private final RelationshipService relationshipService;
private final ActivityMapper activityMapper;
private final ActivityConfig activityConfig;
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
@@ -44,6 +52,15 @@ public class ActivityMutualConsentService {
}
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
// P16-T13 接线:写入 expires_at,否则 confirmSideIfPending 的
// `(expires_at IS NULL OR expires_at > NOW())` 过滤因恒 NULL 而永不生效,
// 「截止后 48 小时」窗口(PENDING_DECISIONS 已确认口径)形同虚设。
// 基准点 = 活动结束时间(ActivityConfig.mutualDeadlineHours 注释的既定语义),
// 活动不存在或无结束时间时回退为创建时刻。
Instant deadlineBase = Optional.ofNullable(activityMapper.selectById(activityId))
.map(Activity::getEndAt)
.orElseGet(Instant::now);
consent.setExpiresAt(deadlineBase.plus(Duration.ofHours(activityConfig.getMutualDeadlineHours())));
consentMapper.insert(consent);
}
return consent;
@@ -52,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。
@@ -61,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());
@@ -105,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 {
@@ -21,7 +21,9 @@ import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.shared.exception.BusinessException;
// 必须是 GlobalExceptionHandler.BusinessException —— shared.exception.BusinessException
// 此前是另一个未映射的类,抛出来直落 500(days 越界本该 400)。见该类 javadoc 的修复记录。
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
@@ -252,7 +254,7 @@ public class AnalyticsService {
public List<Map<String, Object>> getDailySummary(int days) {
if (days < 1 || days > 365) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "days 必须在 1~365 之间");
}
List<Map<String, Object>> summary = new ArrayList<>();
LocalDate today = LocalDate.now();
@@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
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;
/**
* 申诉服务
@@ -97,29 +99,29 @@ public class AppealService {
public Map<String, Object> submitAppeal(String userId, AppealSubmitDTO dto) {
// FeatureFlag 检查
if (!featureFlagConfig.isAppealEnabled()) {
throw new RuntimeException("申诉功能暂未开放");
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "申诉功能暂未开放");
}
if (dto == null || dto.getRelatedActionId() == null || dto.getRelatedActionId().isBlank()) {
throw new RuntimeException("缺少关联的处置记录");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少关联的处置记录");
}
// 验证关联的 risk_action 存在且仍有效
RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId());
if (targetAction == null) {
throw new RuntimeException("关联的处置记录不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联的处置记录不存在");
}
if (!Constants.RISK_ACTION_STATUS_ACTIVE.equals(targetAction.getStatus())) {
throw new RuntimeException("该处置已失效,无法申诉");
throw new BusinessException(ApiResponseCode.CONFLICT, "该处置已失效,无法申诉");
}
if (!userId.equals(targetAction.getUserId())) {
throw new RuntimeException("无权对此处置提交申诉");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权对此处置提交申诉");
}
// 必须是 ban 或 suspend 类型
if (!Constants.RISK_ACTION_BAN.equals(targetAction.getActionType()) &&
!Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(targetAction.getActionType())) {
throw new RuntimeException("当前处置类型不支持申诉");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "当前处置类型不支持申诉");
}
// Phase 18:校验 appealType 白名单(生产 chk_appeal_type),并必须与处置类型一致。
@@ -132,16 +134,16 @@ public class AppealService {
}
if (!Constants.APPEAL_TYPE_BAN.equals(appealType) &&
!Constants.APPEAL_TYPE_SUSPEND.equals(appealType)) {
throw new RuntimeException("无效的申诉类型");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉类型");
}
if (!expectedType.equals(appealType)) {
throw new RuntimeException("申诉类型与处置类型不匹配");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "申诉类型与处置类型不匹配");
}
// 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉
Optional<Appeal> existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId());
if (existing.isPresent()) {
throw new RuntimeException("您已就此处置提交过申诉,请等待处理结果");
throw new BusinessException(ApiResponseCode.CONFLICT, "您已就此处置提交过申诉,请等待处理结果");
}
Appeal appeal = new Appeal();
@@ -190,7 +192,7 @@ public class AppealService {
public AppealItemDTO getAppealDetail(String appealId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null || appeal.getDeletedAt() != null) {
throw new RuntimeException("申诉不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
}
return toDTO(appeal);
}
@@ -202,10 +204,10 @@ public class AppealService {
public Map<String, Object> processAppeal(String appealId, AppealProcessDTO dto, String operatorId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null || appeal.getDeletedAt() != null) {
throw new RuntimeException("申诉不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
}
if (dto == null || dto.getDecision() == null) {
throw new RuntimeException("无效的申诉决策");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
}
// Phase 18:原子「认领」——只有把申诉从可处理态(pending/reviewing/escalated)
@@ -213,7 +215,7 @@ public class AppealService {
// 同一申诉时,只有一个赢家能继续,另一个直接失败,避免 approve/reject 相互覆盖。
int claimed = appealMapper.markReviewingIfProcessable(appealId);
if (claimed != 1) {
throw new RuntimeException("该申诉已处理完毕");
throw new BusinessException(ApiResponseCode.CONFLICT, "该申诉已处理完毕");
}
// 回读最新状态(status 已为 reviewing)
@@ -224,7 +226,7 @@ public class AppealService {
case "approve" -> approveAppeal(fresh, dto.getDecisionNote(), operatorId);
case "reject" -> rejectAppeal(fresh, dto.getDecisionNote(), operatorId);
case "escalate" -> escalateAppeal(fresh, dto.getDecisionNote(), operatorId);
default -> throw new RuntimeException("无效的申诉决策");
default -> throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
}
saveAuditLog(operatorId, "PROCESS_APPEAL", "appeal", appealId, null, null);
@@ -19,4 +19,15 @@ public class AuditLog extends BaseEntity {
private String afterData;
private String ip;
private String userAgent;
// ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ====================
/** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */
private String purposeCode;
/** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */
private String serviceAssignmentId;
/** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */
private String actorRoleSnapshot;
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
private String requestTraceId;
}
@@ -52,6 +52,11 @@ public class AuditLogService {
m.put("ip", log.getIp());
m.put("userAgent", log.getUserAgent());
m.put("createdAt", log.getCreatedAt());
// ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容)
m.put("purposeCode", log.getPurposeCode());
m.put("serviceAssignmentId", log.getServiceAssignmentId());
m.put("actorRoleSnapshot", log.getActorRoleSnapshot());
m.put("requestTraceId", log.getRequestTraceId());
return m;
}).collect(java.util.stream.Collectors.toList());
@@ -73,11 +78,23 @@ public class AuditLogService {
*/
@Transactional
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
recordSensitiveAccess(operatorId, resourceType, resourceId, null, null);
}
/**
* ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。
* 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。
*/
@Transactional
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId,
String purposeCode, String serviceAssignmentId) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction("SENSITIVE_ACCESS");
auditLog.setResourceType(resourceType);
auditLog.setResourceId(resourceId);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
HttpServletRequest req = attrs.getRequest();
auditLog.setIp(req.getRemoteAddr());
@@ -1,6 +1,8 @@
package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.service.AuthService;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -26,8 +28,7 @@ public class AuthController {
public ApiResponse<Map<String, Object>> wechatLogin(@RequestBody Map<String, String> body) {
String code = body.get("code");
if (code == null || code.isBlank()) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "code 不能为空");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "code 不能为空");
}
return ApiResponse.ok(authService.wechatLogin(code));
}
@@ -44,8 +45,7 @@ public class AuthController {
@RequestBody Map<String, String> body) {
String phone = body.get("phone");
if (phone == null || phone.isBlank()) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "手机号不能为空");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
}
authService.bindPhone(principal.id(), phone);
return ApiResponse.ok();
@@ -54,17 +54,19 @@ public class AuthController {
/**
* 刷新 Token
* POST /api/v1/auth/refresh
* Body: { "userId": "...", "refreshToken": "..." }
* Body: { "refreshToken": "..." },userId 可选
*
* <p>userId 不再是必填:refresh token 的 subject 就是 userId,令牌本身自证身份。
* 此前强制要求客户端重复传 userId,漏传即 400 —— 小程序 2026-09-22 的
* 首页 401 事故正是被这个 400 放大的。显式传入时仍会被校验与令牌主体一致。
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refreshToken(@RequestBody Map<String, String> body) {
String userId = body.get("userId");
String refreshToken = body.get("refreshToken");
if (userId == null || refreshToken == null) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "参数缺失");
if (refreshToken == null || refreshToken.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "refreshToken 不能为空");
}
return ApiResponse.ok(authService.refreshToken(userId, refreshToken));
return ApiResponse.ok(authService.refreshToken(body.get("userId"), refreshToken));
}
/**
@@ -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();
}
}
@@ -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);
@@ -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;
}
}
@@ -7,6 +7,9 @@ import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.capability.service.CapabilityService;
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.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
@@ -27,6 +30,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
@@ -58,7 +62,7 @@ public class AuthService {
String unionid = session.get("unionid");
if (openid == null || openid.isBlank()) {
throw new RuntimeException("微信登录失败:无法获取 openid");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
// 查找或创建用户
@@ -87,7 +91,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,12 +114,12 @@ public class AuthService {
public void bindPhone(String userId, String phoneCode) {
// TODO: Phase 03 对接真实短信验证码
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
// 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
throw new RuntimeException("该手机号已被其他账号绑定");
throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已被其他账号绑定");
}
});
@@ -126,31 +132,58 @@ public class AuthService {
/**
* 刷新 Token
*
* <p><b>2026-09-22 修复三处缺陷</b>:
* <ol>
* <li>验签用错了密钥:refresh token 由 refresh 密钥签发,此前却调
* {@code validateToken}(用 access 密钥验签)→ 必然失败,
* 于是「刷新」这条路在修好前端字段名之后仍然走不通。改用
* {@link JwtTokenService#validateRefreshToken}。</li>
* <li>失败语义是 500:客户端据此判断为服务端故障并重试,实际应回 401
* 让它停止重试、走重新登录。改抛 {@link UnauthorizedException}。</li>
* <li>userId 被强制要求:refresh token 的 subject 本身就是 userId,
* 让客户端重复传一个它已持有凭据里已包含的信息,只会制造
* 「漏传 → 400」这类无意义失败。改为**以令牌主体为准**,
* 显式传入时仅用于一致性校验。</li>
* </ol>
*/
public Map<String, Object> refreshToken(String userId, String refreshToken) {
// 验证 Redis 中存储的 refresh token
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new RuntimeException("Refresh Token 无效或已过期");
if (refreshToken == null || refreshToken.isBlank()) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 验证 JWT
if (!jwtTokenService.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token 无效");
// 1. 令牌自证:签名有效(refresh 密钥)、未过期、type=refresh、未被撤销
if (!jwtTokenService.validateRefreshToken(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 2. 用户身份以令牌主体为准;客户端显式传入时必须是同一个用户,
// 否则就是拿 A 的令牌去刷 B 的会话。
String tokenUserId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
if (userId != null && !userId.isBlank() && !userId.equals(tokenUserId)) {
throw new UnauthorizedException("Refresh Token 与用户不匹配");
}
userId = tokenUserId;
// 3. 校验 Redis 中登记的令牌:登出或换发后,旧令牌立即失效(可撤销性)
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 签发新 Token
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);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", accessTokenExpireMs / 1000);
result.put("userId", userId);
return result;
}
@@ -170,7 +203,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());
@@ -206,13 +239,13 @@ public class AuthService {
private void checkAccountStatus(User user) {
if (Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已注销");
throw new BusinessException(ApiResponseCode.ACCOUNT_CLOSED, "账号已注销");
}
if (Constants.ACCOUNT_STATUS_BANNED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已被封禁");
throw new BusinessException(ApiResponseCode.USER_BANNED, "账号已被封禁");
}
if (Constants.ACCOUNT_STATUS_SUSPENDED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已暂停");
throw new BusinessException(ApiResponseCode.USER_SUSPENDED, "账号已暂停");
}
}
@@ -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());
@@ -144,7 +148,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()
@@ -165,11 +169,25 @@ public class OperatorAuthService {
/**
* 刷新 Operator Token
*
* <p>与 {@link AuthService#refreshToken} 对齐两点(2026-09-22):
* 令牌先自证(签名/有效期/type,用 refresh 密钥),再比对 Redis 登记值;
* 失败回 401 而非 500 —— 否则客户端会把「该重新登录」误判为服务端故障并重试。
*/
public Map<String, Object> refreshToken(String operatorId, String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()
|| !jwtTokenService.validateRefreshToken(refreshToken)) {
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 RuntimeException("Refresh Token 无效");
throw new UnauthorizedException("Refresh Token 无效");
}
Map<String, Object> operatorInfo = getMe(operatorId);
@@ -184,6 +202,12 @@ 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("name", operatorInfo.get("name"));
result.put("username", operatorInfo.get("username"));
result.put("organizationId", operatorInfo.get("organizationId"));
result.put("permissions", operatorInfo.get("permissions"));
return result;
}
@@ -10,11 +10,20 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class JwtTokenService {
/**
* Refresh Token 的用途声明值。
*
* <p>校验时必须匹配,见 {@link #validateRefreshToken}:一旦运维把两把密钥配成同一个值,
* 签名校验就无法区分两类令牌,只有这个声明能阻止 access token 被当作 refresh token 续期。
*/
public static final String TOKEN_TYPE_REFRESH = "refresh";
private final TokenBlacklistService tokenBlacklistService;
@Value("${jwt.access-token-secret}")
@@ -46,6 +55,12 @@ public class JwtTokenService {
JwtBuilder builder = Jwts.builder()
.subject(userId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "access")
@@ -67,9 +82,15 @@ public class JwtTokenService {
return Jwts.builder()
.subject(userId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "refresh")
.claim("type", TOKEN_TYPE_REFRESH)
.signWith(getRefreshKey())
.compact();
}
@@ -83,6 +104,12 @@ public class JwtTokenService {
JwtBuilder builder = Jwts.builder()
.subject(operatorId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "operator_access")
@@ -142,9 +169,67 @@ public class JwtTokenService {
}
/**
* 获取 Token 过期时间
* 验证并解析 **Refresh** Token。
*
* <p><b>必须用 refresh 密钥验签</b>:{@link #generateRefreshToken} 用
* {@code jwt.refresh-token-secret} 签发,而 {@link #parseToken} 用的是
* {@code jwt.access-token-secret}。两把密钥在 dev / test / production 的取值
* 各不相同(见 application-*.yml),因此拿 {@code parseToken} 去验 refresh token
* 必然抛签名异常 —— 这正是 2026-09-22 定位到的「刷新永远失败 → 登录态被清 →
* 全站 401」的根因:前端此前修好了缺 userId 的 400,仍会在这一层必然失败。
*/
public Claims parseRefreshToken(String token) {
return Jwts.parser()
.verifyWith(getRefreshKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 校验 Refresh Token:签名有效、未过期、未被撤销,且 {@code type} 必须为 refresh。
*
* <p>type 断言不是冗余:一旦运维把两把密钥配成同一个值(或都用了默认占位值),
* 签名校验就无法区分两类令牌,access token 会被当作 refresh token 换出新令牌,
* 使「短期凭证」获得「长期凭证」的续期能力。此处把令牌用途钉死在声明里。
*/
public boolean validateRefreshToken(String token) {
if (token == null || token.isBlank()) {
return false;
}
try {
if (tokenBlacklistService.isBlacklisted(token)) {
return false;
}
return TOKEN_TYPE_REFRESH.equals(parseRefreshToken(token).get("type", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 从 Refresh Token 解析用户 ID(用于客户端未传 userId 时自证身份,见 AuthService#refreshToken)。
*/
public String getUserIdFromRefreshToken(String token) {
return parseRefreshToken(token).getSubject();
}
/**
* 获取 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;
}
}
@@ -0,0 +1,55 @@
package com.danyang.match.platform.contactauth.controller;
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 受控联系授权控制器(C 端用户,ZC-MM-003 / B1 立项)。
*
* <p>端点(基路径 {@code /api/v1/contact-authorizations},USER 角色兜底):
* <ul>
* <li>GET {@code /my} —— 我的授权列表 + 待同意队列;</li>
* <li>POST {@code /{id}/consent} —— 用户<b>单独明示同意</b>(独立端点,
* 非任何其他操作的捆绑步骤、无预勾选/预同意参数);</li>
* <li>POST {@code /{id}/decline} —— 用户拒绝。</li>
* </ul>
*
* <p>归属校验(userId.equals)在 Service 层 {@code requireOwnedAuthorization} 承担;
* 不碰 SecurityConfig(路径落入 {@code /** → hasRole("USER")} 兜底规则)。
*/
@RestController
@RequestMapping("/contact-authorizations")
@RequiredArgsConstructor
public class ContactAuthorizationUserController {
private final ContactAuthorizationService contactAuthorizationService;
/** 我的授权列表 + 待同意队列。 */
@GetMapping("/my")
public ApiResponse<Map<String, Object>> my(@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(contactAuthorizationService.myAuthorizationsForUser(principal.id()));
}
/** 单独明示同意(独立端点,无预同意参数)。 */
@PostMapping("/{id}/consent")
public ApiResponse<Object> consent(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(contactAuthorizationService.consent(id, principal.id()));
}
/** 拒绝授权请求。 */
@PostMapping("/{id}/decline")
public ApiResponse<Object> decline(@PathVariable String id,
@AuthenticationPrincipal UserPrincipal principal) {
if (principal == null) return ApiResponse.unauthorized();
return ApiResponse.ok(contactAuthorizationService.decline(id, principal.id()));
}
}
@@ -1,5 +1,6 @@
package com.danyang.match.platform.counseling.service;
import com.danyang.match.platform.audit.service.AuditLogService;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
@@ -78,6 +79,7 @@ public class CounselingService {
private final NotificationService notificationService;
private final RiskReferralService riskReferralService;
private final DataScopeGuard dataScopeGuard;
private final AuditLogService auditLogService;
// ==================== 用户端:申请 ====================
@@ -264,6 +266,23 @@ public class CounselingService {
fu.setResultCode(resultCode);
fu.setNextFollowupAt(nextFollowupAt);
followupMapper.insert(fu);
// P13A-T33: 随访正文与申请摘要共用同一风险检测(来源同为 source=counseling)。
// 随访由 counselor 撰写、更贴近用户近况,是风险关键词的另一个真实入口;
// 此前只有申请摘要(apply:133)被扫描,随访正文完全绕过风控。
// INV-013 加固同 apply:绝不记录正文,只记级别与记录 ID。
CounselingServiceRecord owner = mapper.findFull(serviceId);
if (owner != null) {
try {
String riskLevel = riskReferralService.detectCounselingRisk(owner.getUserId(), content);
if (riskLevel != null) {
log.info("随访内容触发风险检测: serviceId={}, followupId={}, riskLevel={}",
serviceId, fu.getId(), riskLevel);
}
} catch (Exception e) {
log.warn("随访风险检测异常: serviceId={}, err={}", serviceId, e.getMessage());
}
}
return fu;
}
@@ -333,6 +352,12 @@ public class CounselingService {
"INV-012: 只能查看分配给自己的咨询服务");
return null;
}
// P13A-T31: assigned Counselor 读取的是**他人**的咨询正文与随访记录 ——
// 敏感访问必须留痕(同 Phase 17 report/risk_profile 的 recordSensitiveAccess 模式)。
// 本人读自己的不算敏感访问;manager/matchmaker 走脱敏摘要视图,不触正文,无需审计。
if (isAssignedCounselor) {
auditLogService.recordSensitiveAccess(requesterId, "counseling_followup", serviceId);
}
return svc;
}
@@ -20,4 +20,9 @@ public class RecommendationRecord extends BaseEntity {
private String action; // liked, passed, blocked, matched
private java.time.Instant actionAt;
private Integer ruleVersion;
/**
* 推荐判定当次所用的最低评分阈值快照(V050,MATCH-001)。
* 配合 {@link #ruleVersion} 追溯历史推荐依据的规则版本;历史行为 null。
*/
private Integer thresholdSnapshot;
}
@@ -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;
}
@@ -175,36 +180,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 +199,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 +228,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;
/**
* 获取每日推荐列表
@@ -241,86 +242,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 +272,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) {
@@ -386,6 +313,8 @@ public class RecommendationEngineService {
record.setReasons(String.join(";", reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
recommendationRecordMapper.insert(record);
}
@@ -64,6 +64,8 @@ public class RecommendationService {
record.setReasons(new java.util.ArrayList<>(reasons).toString());
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
recommendationRecordMapper.insert(record);
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
}
@@ -0,0 +1,67 @@
package com.danyang.match.platform.matchmaker.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 受控联系授权配置(ZC-MM-003 / B1 立项,对照 {@code ProfileProperties} 范式)。
*
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):
* 五个 canonical key 统一在此管理,禁止散落在 Controller/Service/前端各自写死;
* 治理登记见 T06 config-register(OQ-MATCHMAKER-003)。
*
* <p><b>fail-safe 语义</b>:{@code enabled} 的 Java 字段缺省为 <b>false</b> ——
* 若配置键完全缺失(如新环境漏配),机制整体关闭(fail-safe 关闭优于意外开放)。
* 生产 {@code application.yml} 显式声明 {@code matchmaker.contact.enabled: true};
* "默认开启" 指的是配置注册表中的生效默认值,而非代码缺省。
*/
@Data
@Component
@ConfigurationProperties(prefix = "matchmaker.contact")
public class ContactAuthorizationConfig {
/**
* 机制总开关。
* Java 缺省 false(fail-safe:配置缺失 → 机制关闭);生产 yml 显式置 true。
* disclose(H4 唯一披露出口)前强校验,关闭时任何披露请求 404 化拒绝。
*/
private boolean enabled = false;
/**
* 审批岗权限码(逗号分隔,<b>分层语义</b>,用户裁决①:组长主审 + 管理员兜底)。
* 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。
*
* <p>分层规则(ContactAuthorizationService.assertApproverAuthority):
* <ol>
* <li>列表<b>首段 = 主审岗</b>(默认 {@code matchmaker.team.assign},组长)——
* 仅可审批本组织红娘的申请(NULL=NULL 平台直属,assertTeamScope 复用);</li>
* <li><b>兜底岗键固定 {@code matchmaker.assign}</b>(平台管理员)——
* 可审批全部申请且不做组织校验;兜底键不受本列表调整影响(权限面不放大);</li>
* <li>两者皆无 → 403 拒绝。</li>
* </ol>
*
* <p>审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize
* 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。
*/
private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign";
/**
* 用户同意窗口(小时):pending_consent 超窗未同意 → 惰性 expired。
* 默认 72h(治理登记 OQ-MATCHMAKER-003 技术默认,待产品确认)。
*/
private int consentDeadlineHours = 72;
/**
* 授权默认有效期(天):用户 consent 起算,expires_at = consent + N 天。
* 默认 7 天;到期由双态惰性收敛置 expired。
*/
private int defaultValidityDays = 7;
/**
* 授权审计数据保留期(天):受控联系相关审计/敏感访问记录的保留下限口径。
* 默认 365 天;当前以 /my 响应元数据形式下发(retentionDays 字段)供前端展示,
* 治理登记 T06 open-questions 同步说明。
*/
private int retentionDays = 365;
}
@@ -0,0 +1,149 @@
package com.danyang.match.platform.matchmaker.controller;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* 服务对象分配接口(运营端,ZC-MM-002 / A1)。
*
* <p>权限模型(D2):
* <ul>
* <li>写操作(创建/转分配/撤销/结束/可分配列表):
* {@code matchmaker.assign}(平台授权人员,不限组织)
* 或 {@code matchmaker.team.assign}(红娘组长,同组织范围);</li>
* <li>读自己名下分配(/my):{@code matchmaker.service}(普通红娘即可)。</li>
* </ul>
* operatorId 一律取当前认证主体(principal),不信任请求参数。
*/
@RestController
@RequestMapping("/operator/matchmaker/assignments")
@RequiredArgsConstructor
public class MatchmakerAssignmentController {
private final MatchmakerAssignmentService assignmentService;
/**
* 创建分配。
* Body: userId / matchmakerOperatorId / serviceId? / reason(必填)/ expiresAt?(ISO-8601)
*/
@PostMapping
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> create(@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, String> body) {
MatchmakerAssignment created = assignmentService.create(
principal.id(),
principal.hasPermission("matchmaker.assign"),
body.get("userId"),
body.get("matchmakerOperatorId"),
body.getOrDefault("serviceId", null),
body.get("reason"),
parseInstant(body.get("expiresAt")));
return ApiResponse.ok(assignmentView(created));
}
/** 转分配:旧记录置 transferred,同事务新建 active。reason 必填。 */
@PostMapping("/{id}/transfer")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> transfer(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
MatchmakerAssignment created = assignmentService.transfer(
id,
principal.id(),
principal.hasPermission("matchmaker.assign"),
body.get("newMatchmakerOperatorId"),
body.get("reason"));
return ApiResponse.ok(assignmentView(created));
}
/** 撤销分配。reason 必填。 */
@PostMapping("/{id}/revoke")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> revoke(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(assignmentView(assignmentService.revoke(
id, principal.id(), principal.hasPermission("matchmaker.assign"),
body.get("reason"))));
}
/** 结束分配。reason 必填。 */
@PostMapping("/{id}/end")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<?> end(@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(assignmentView(assignmentService.end(
id, principal.id(), principal.hasPermission("matchmaker.assign"),
body.get("reason"))));
}
/** 我名下生效中的服务对象分配(普通红娘工作台)。 */
@GetMapping("/my")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<List<Map<String, Object>>> my(
@AuthenticationPrincipal OperatorPrincipal principal) {
List<Map<String, Object>> list = assignmentService.listMy(principal.id()).stream()
.map(this::assignmentView)
.toList();
return ApiResponse.ok(list);
}
/**
* 可分配红娘列表(创建/转分配弹窗数据源)。
* 返回 id/name/organizationId,不含联系方式(字段级隐私,ZC-MM-005 范围)。
*/
@GetMapping("/assignable-matchmakers")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> assignable(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(assignmentService.listAssignableMatchmakers(
principal.id(), principal.hasPermission("matchmaker.assign")));
}
/**
* 组长团队统计(ZC-MM-005 / E4 新增)。仅 matchmaker.team.assign 持有者可调;
* 以组长本人 organization_id 为边界(NULL 组长统计平台直属)。
* 返回纯脱敏计数:activeAssignmentCount / perMatchmakerCounts / activeMatchmakerCount,
* 不含任何用户身份或联系方式。
*/
@GetMapping("/team/statistics")
@PreAuthorize("hasAuthority('matchmaker.team.assign')")
public ApiResponse<Map<String, Object>> teamStatistics(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(assignmentService.teamStatistics(principal.id()));
}
private Instant parseInstant(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
return Instant.parse(raw);
}
private Map<String, Object> assignmentView(MatchmakerAssignment a) {
return Map.ofEntries(
Map.entry("id", a.getId()),
Map.entry("userId", a.getUserId()),
Map.entry("matchmakerOperatorId", a.getMatchmakerOperatorId()),
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
Map.entry("assignmentScope", a.getAssignmentScope()),
Map.entry("status", a.getStatus()),
Map.entry("assignmentReason", a.getAssignmentReason()),
Map.entry("assignedAt", a.getAssignedAt() == null ? "" : a.getAssignedAt().toString()),
Map.entry("expiresAt", a.getExpiresAt() == null ? "" : a.getExpiresAt().toString()),
Map.entry("endedAt", a.getEndedAt() == null ? "" : a.getEndedAt().toString()),
Map.entry("endReason", a.getEndReason() == null ? "" : a.getEndReason())
);
}
}
@@ -0,0 +1,98 @@
package com.danyang.match.platform.matchmaker.controller;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 受控联系授权控制器(运营端,ZC-MM-003 / B1 立项)。
*
* <p>端点(基路径 {@code /api/v1/operator/matchmaker/contact-authorizations}):
* <ul>
* <li>POST {@code ""} —— 红娘发起授权申请(matchmaker.service,purpose 必填);</li>
* <li>POST {@code /{id}/approve} / {@code /{id}/reject} —— 审批岗批准/拒绝
* (审批岗权限码在 Service 层动态判定 ∈ approver-authorities,组长另有组织范围校验);</li>
* <li>GET {@code /my} —— 红娘本人授权列表;</li>
* <li>POST {@code /{id}/revoke} —— 申请人或审批人撤销(reason 必填);</li>
* <li>GET {@code /disclose} —— H4 唯一披露出口(userId/purpose 必填,
* 每次落 SensitiveAccessLog;phone 不落任何应用日志)。</li>
* </ul>
*
* <p>类级 {@code matchmaker.view} 覆盖列表/详情读;写动作方法级收敛;
* 审批岗动态权限与 G4 404 化拒绝、approve 403 边界均由
* {@code ContactAuthorizationService} 承担(T06 open-questions OQ-MATCHMAKER-009)。
*/
@RestController
@RequestMapping("/operator/matchmaker/contact-authorizations")
@PreAuthorize("hasAuthority('matchmaker.view')")
@RequiredArgsConstructor
public class MatchmakerContactAuthorizationController {
private final ContactAuthorizationService contactAuthorizationService;
/** 红娘发起授权申请(须持分配;purpose 必填)。 */
@PostMapping
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<MatchmakerContactAuthorization> request(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(contactAuthorizationService.request(
principal.id(),
body.get("userId"),
body.get("channel"),
body.get("purpose")));
}
/** 审批岗批准(权限动态判定 + 组长组织范围校验)。 */
@PostMapping("/{id}/approve")
public ApiResponse<MatchmakerContactAuthorization> approve(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(contactAuthorizationService.approve(
id, principal.id(), principal.permissions()));
}
/** 审批岗拒绝。 */
@PostMapping("/{id}/reject")
public ApiResponse<MatchmakerContactAuthorization> reject(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(contactAuthorizationService.reject(
id, principal.id(), principal.permissions()));
}
/** 红娘本人授权列表(含保留期元数据)。 */
@GetMapping("/my")
public ApiResponse<Map<String, Object>> my(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(contactAuthorizationService.myAuthorizations(principal.id()));
}
/** 申请人或审批人撤销(reason 必填)。 */
@PostMapping("/{id}/revoke")
public ApiResponse<MatchmakerContactAuthorization> revoke(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(contactAuthorizationService.revoke(
id, principal.id(), body.get("reason")));
}
/** H4 唯一披露出口:userId/purpose 必填,channel 缺省 phone。 */
@GetMapping("/disclose")
public ApiResponse<Map<String, Object>> disclose(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestParam String userId,
@RequestParam String purpose,
@RequestParam(required = false) String channel) {
return ApiResponse.ok(contactAuthorizationService.disclose(
principal.id(), userId, channel, purpose));
}
}
@@ -16,7 +16,7 @@ import java.util.List;
import java.util.Map;
/**
* 红娘服务端点
* 红娘服务端点(用户端)
*
* 用户端:
* POST /api/v1/matchmaker/services — 提交红娘服务请求
@@ -26,10 +26,15 @@ import java.util.Map;
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
* POST /api/v1/matchmaker/recommendations/{id}/pass
*
* 操作人端(Matchmaker/Operator role):
* PUT /api/v1/matchmaker/services/{id}/assign
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务
* 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
* <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
* {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
* {@code POST /services/{id}/complete})。
*
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
*/
@RestController
@RequestMapping("/matchmaker")
@@ -13,6 +13,11 @@ import java.util.Map;
/**
* 红娘工作台控制器(Operator调用)
*
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
* 服务单状态由分配动作同事务联动(submitted → assigned)。
*/
@RestController
@RequestMapping("/operator/matchmaker")
@@ -29,42 +34,57 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId));
}
/**
* 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者
* (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列,
* 普通红娘(仅 matchmaker.view/service)403。
*
* <p>组长视角说明(ZC-MM-005 / E4,治理登记 007):matchmaker_services 表无组织归属字段
* (organization_id 恒 null 的已知限制),因此待分配队列无法按组长组织过滤,
* 权衡后对分配权持有者全局可读 —— 待分配服务单尚无负责人,队列不含敏感分配关系。
* 组织归属字段的补齐登记至 ZC-MM-007。
*/
@GetMapping("/pending")
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
public ApiResponse<List<Map<String, Object>>> getPending(
@AuthenticationPrincipal OperatorPrincipal principal) {
return ApiResponse.ok(matchmakerServiceService.getPendingServices());
}
/**
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
*/
@PostMapping("/services/{id}/assign")
@PreAuthorize("hasAuthority('matchmaker.assign')")
public ApiResponse<?> assignService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = principal.id();
String assignOperatorId = body.get("operatorId");
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null));
String assignerOperatorId = principal.id();
String targetOperatorId = body.get("operatorId");
String reason = body.getOrDefault("reason", "红娘服务单分配");
return ApiResponse.ok(matchmakerServiceService.assignService(
id, assignerOperatorId, targetOperatorId, reason));
}
@PostMapping("/services/{id}/claim")
@PreAuthorize("hasAuthority('matchmaker.service')")
public ApiResponse<?> claimService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
String operatorId = principal.id();
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
}
@GetMapping("/my-services")
public ApiResponse<?> getMyServices(
@AuthenticationPrincipal OperatorPrincipal principal) {
String operatorId = principal.id();
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
}
// GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
// 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
// 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
// GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
// getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
/**
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
* 红娘名下服务的全部推荐列表(运营端工作台用)。
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
*
* <p>口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments}
* 中本人<b>生效中的分配</b>(active 且未过有效期)→ 该分配对象(user)名下
* 服务单上的推荐,经 matchmaker_services 桥接;既不按
* matchmaker_services.assigned_operator_id 残留列过滤,也<b>不</b>按
* recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status
* 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见
* (DEC-002 规则7:撤销即失权)。
*/
@GetMapping("/recommendations")
public ApiResponse<List<Map<String, Object>>> getRecommendations(
@@ -72,16 +92,19 @@ public class MatchmakerOperatorController {
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
}
/**
* 服务详情(ZC-MM-005 / E2 防探测 404)。
*
* <p>语义变更:非持有人与查无 serviceId 抛出完全相同的
* BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致
* (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。
* 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。
*/
@GetMapping("/services/{id}")
public ApiResponse<Map<String, Object>> getServiceDetail(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
if (detail == null) {
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
}
return ApiResponse.ok(detail);
return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
}
@PostMapping("/services/{id}/recommend")
@@ -114,7 +137,8 @@ public class MatchmakerOperatorController {
public ApiResponse<?> completeService(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id) {
return ApiResponse.ok(matchmakerServiceService.completeService(id));
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy(服务层完成)
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
}
}
@@ -0,0 +1,62 @@
package com.danyang.match.platform.matchmaker.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 服务对象分配(ZC-MM-002 / A1)
* 对应 DB: matchmaker_assignments(V053)
*
* <p>建模说明:
* <ul>
* <li>{@code matchmakerOperatorId}:持有该用户服务对象的红娘(operators 主体,
* 沿用 matchmaker_services.assigned_operator_id 惯例)。</li>
* <li>{@code assignedBy}:做出本次分配的操作者(组长 / 平台授权人员)。</li>
* <li>状态机(D5):active → transferred / revoked / ended;
* expires_at 届满由 {@code ServiceAssignmentPolicy} 惰性置 expired(无定时任务);
* 终态一律拒绝再转换。</li>
* <li>scope:primary(主责,同用户仅一条生效中,DB 部分唯一索引兜底)/
* collaborating(协同)。</li>
* </ul>
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_assignments")
public class MatchmakerAssignment extends BaseEntity {
/** 被服务用户 */
private String userId;
/** 持有服务对象的红娘(operators.id) */
private String matchmakerOperatorId;
/** 关联的服务单(可空:允许脱离服务单的用户级分配) */
private String serviceId;
/** 做出本次分配的操作者(operators.id) */
private String assignedBy;
/** 分配范围:primary / collaborating(与 V053 chk_mm_assignment_scope 白名单一致) */
private String assignmentScope;
/** 分配理由(必填) */
private String assignmentReason;
/** 状态:active/ended/revoked/transferred/expired(与 V053 chk_mm_assignment_status 白名单一致) */
private String status;
private Instant assignedAt;
/** 有效期届满时间;null 表示长期有效 */
private Instant expiresAt;
/** 终态时间(ended/revoked/transferred/expired 时落库) */
private Instant endedAt;
/** 终态理由(revoked/ended 必填;transferred 记录转分配理由;expired 为固定文案) */
private String endReason;
}
@@ -0,0 +1,42 @@
package com.danyang.match.platform.matchmaker.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.Instant;
/**
* 受控联系授权(ZC-MM-003 / B1 例外通道,架构师设计 H4)。
*
* <p>状态机(六态):pending_approval → pending_consent → active / rejected;
* pending_consent / active 双态惰性收敛为 expired(有效期或同意窗口届满);
* active → revoked(申请人或审批人撤销)。V056 建表。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("matchmaker_contact_authorizations")
public class MatchmakerContactAuthorization extends BaseEntity {
/** 发起申请的红娘 operator id */
private String operatorId;
/** 被联系用户 id */
private String userId;
/** 联系渠道:phone / offline_meeting / full_contact */
private String channel;
/** 授权目的(必填,用户同意页明示) */
private String purpose;
/** 状态:pending_approval / pending_consent / active / rejected / expired / revoked */
private String status;
/** 审批岗 operator id */
private String approvedBy;
private Instant approvedAt;
/** 用户单独同意时间(独立端点,无预同意参数) */
private Instant userConsentedAt;
/** 生效截止:pending 期为同意窗口上界,consent 后重算为 consent + validity days */
private Instant expiresAt;
private Instant revokedAt;
private String revokedBy;
private String revokeReason;
}
@@ -0,0 +1,100 @@
package com.danyang.match.platform.matchmaker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 服务对象分配 Mapper(ZC-MM-002 / A1)
*
* <p>「生效中」的统一口径:status = 'active' 且(expires_at IS NULL OR expires_at &gt; NOW())。
* 惰性过期由 {@link #expireOverdue} 负责(读前触发,无定时任务),
* 这里的 expires 条件是双保险:即使惰性过期尚未执行,已届满的行也不会被当作生效中。
*/
@Mapper
public interface MatchmakerAssignmentMapper extends BaseMapper<MatchmakerAssignment> {
@Select("SELECT * FROM matchmaker_assignments WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findById(@Param("id") String id);
/** 某红娘名下全部生效中的分配(GET /my、GET /assignable 的工作台口径) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findActiveByOperator(@Param("operatorId") String operatorId);
/** 某红娘对某用户是否持有生效中的分配(数据范围判定核心查询) */
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
"AND user_id = #{userId} AND status = 'active' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
/** 某用户当前生效中的主责分配(创建/转分配前的唯一性预检) */
@Select("SELECT * FROM matchmaker_assignments WHERE user_id = #{userId} " +
"AND status = 'active' AND assignment_scope = 'primary' " +
"AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerAssignment> findActivePrimaryByUser(@Param("userId") String userId);
/** 某服务单上生效中的分配(服务单维度的归属判定) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
Optional<MatchmakerAssignment> findActiveByService(@Param("serviceId") String serviceId);
/** 某服务单的全部分配历史(追溯用) */
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
List<MatchmakerAssignment> findByService(@Param("serviceId") String serviceId);
/**
* 惰性过期:把 expires_at 已届满的 active 行置为 expired 并落库。
* 由 {@code ServiceAssignmentPolicy} 在读取路径前触发,无定时任务。
*
* @return 本次被置为 expired 的行数
*/
@Update("UPDATE matchmaker_assignments SET status = 'expired', " +
"ended_at = NOW(), end_reason = '有效期届满自动失效', updated_at = NOW() " +
"WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() " +
"AND deleted_at IS NULL")
int expireOverdue();
// ==================== ZC-MM-005 / E4:组长团队统计(纯脱敏计数) ====================
// 组织边界:organization_id IS NOT DISTINCT FROM #{organizationId} ——
// NULL=NULL 平台直属规则在 SQL 层等价复用(有组织组长统计本组织;NULL 组长统计平台直属)。
/** 某组织范围内生效中的分配总数 */
@Select("SELECT COUNT(*) FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
long countActiveByOrganization(@Param("organizationId") String organizationId);
/** 某组织范围内各红娘的生效分配数(operatorId/name/activeCount;别名加引号保持驼峰键名) */
@Select("SELECT o.id AS \"operatorId\", o.name AS \"operatorName\", COUNT(*) AS \"activeCount\" " +
"FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId} " +
"GROUP BY o.id, o.name ORDER BY \"activeCount\" DESC")
List<Map<String, Object>> countActiveGroupByOperator(@Param("organizationId") String organizationId);
/** 某组织范围内持有生效分配的红娘数(去重) */
@Select("SELECT COUNT(DISTINCT ma.matchmaker_operator_id) FROM matchmaker_assignments ma " +
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
long countActiveOperators(@Param("organizationId") String organizationId);
}
@@ -0,0 +1,60 @@
package com.danyang.match.platform.matchmaker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Optional;
/**
* 受控联系授权 Mapper(ZC-MM-003 / B1 立项)。
*
* <p>双态惰性收敛(对照 005 expireOverdue 范式,读前触发、无定时任务):
* ① active 且 expires_at 届满 → expired(授权有效期到);
* ② pending_consent 且同意窗口(expires_at 在 request/approve 期承载窗口上界)届满 → expired。
*/
@Mapper
public interface MatchmakerContactAuthorizationMapper extends BaseMapper<MatchmakerContactAuthorization> {
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerContactAuthorization> findById(@Param("id") String id);
/** 某红娘的全部授权记录(工作台列表,倒序) */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerContactAuthorization> findByOperator(@Param("operatorId") String operatorId);
/** 用户待同意队列(pending_consent,C 端同意页数据源) */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
"AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " +
"ORDER BY created_at DESC")
List<MatchmakerContactAuthorization> findPendingConsentByUser(@Param("userId") String userId);
/** 某红娘对某用户某渠道当前生效中的授权 */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
"AND user_id = #{userId} AND channel = #{channel} AND status = 'active' " +
"AND expires_at > NOW() AND deleted_at IS NULL LIMIT 1")
Optional<MatchmakerContactAuthorization> findActiveByOperatorUserChannel(@Param("operatorId") String operatorId,
@Param("userId") String userId,
@Param("channel") String channel);
/** 某用户的全部授权记录(C 端我的授权列表) */
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerContactAuthorization> findByUser(@Param("userId") String userId);
/**
* 双态惰性收敛:
* ① active 过期 → expired;
* ② pending_consent 超同意窗口 → expired(expires_at 即窗口上界)。
*
* @return 本次被收敛的行数
*/
@Update("UPDATE matchmaker_contact_authorizations SET status = 'expired', updated_at = NOW() " +
"WHERE status IN ('active', 'pending_consent') AND expires_at < NOW() AND deleted_at IS NULL")
int expireOverdue();
}
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
int markViewed(@Param("id") String id);
/**
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
* 避免运营端越权看到他人推荐。
* 某红娘推荐列表(运营端工作台用)。
*
* <p>ZC-MM-005 收口(P1-1):范围事实源由 matchmaker_services.assigned_operator_id
* 残留列切换为 matchmaker_assignments 生效中分配(active 且未过有效期)。
* 语义 = 「本人当前有效分配对象(user)名下服务单上的推荐」—— 分配 revoke/end 后
* 残留列不再构成可见凭据(DEC-002 规则7:撤销即失权)。
*/
@Select("SELECT r.* FROM matchmaker_recommendations r " +
"JOIN matchmaker_services s ON s.id = r.service_id " +
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
"JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"ORDER BY r.created_at DESC")
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
}
@@ -26,29 +26,55 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
long countSubmitted();
/** 某红娘进行中的服务数(工作台统计) */
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND status = 'processing' AND deleted_at IS NULL")
/**
* 某红娘进行中的服务数(工作台统计)。
* ZC-MM-005 收口(P1-2):范围事实源由 assigned_operator_id 残留列切换为
* matchmaker_assignments 生效中分配(active 且未过有效期)——「本人名下进行中」
* = 本人 active 分配关联 user 的服务单中 status='in_service'。
* 分配 end/revoke 后,该用户的服务单不再计入本人(DEC-002 规则7:服务结束失去访问权)。
* 状态口径沿用 ZC-MM-002(D4)修正:'in_service'('processing' 不在 V009 白名单)。
*/
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE s.status = 'in_service' AND s.deleted_at IS NULL")
long countInProgressByOperator(@Param("operatorId") String operatorId);
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-005 收口 / P1-2:按生效分配 join) */
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE CAST(s.assigned_at AS DATE) = CURRENT_DATE AND s.deleted_at IS NULL")
long countTodayByOperator(@Param("operatorId") String operatorId);
/**
* 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-005 收口 / P1-2)。
* 口径说明(与 E1「服务结束失去访问权」一致):按当前生效分配统计 ——
* 分配 end/revoke 后,该用户的历史 completed 服务单<b>不再计入</b>本人完成数;
* 仅当分配仍生效时,其名下 completed 单才计入。
*/
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
"AND a.matchmaker_operator_id = #{operatorId} " +
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
"AND a.deleted_at IS NULL " +
"WHERE s.status = 'completed' AND s.deleted_at IS NULL")
long countCompletedByOperator(@Param("operatorId") String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND deleted_at IS NULL ORDER BY created_at DESC")
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
/**
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
* 状态口径(submitted/assigned/in_service/waiting_user)。
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
*/
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
"AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " +
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id = #{userId} " +
"AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
long countActiveByUserId(@Param("userId") String userId);
}
@@ -0,0 +1,55 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* 受控联系授权判定器(ZC-MM-003 / B1 立项,架构师设计 G4)。
*
* <p>红娘对某用户某渠道能否披露联系方式,唯一事实源是
* {@code matchmaker_contact_authorizations} 中「生效中的授权」
* (status=active 且未过期,读前双态惰性收敛)。
*
* <p>判定无效时走 {@link DataScopeGuard#denyAsNotFound}:先落 DATA_SCOPE_DENIED 审计,
* 再抛 404「服务不存在」(G4 防探测范式,与分配判定器同款)。
*/
@Component
@RequiredArgsConstructor
public class ContactAuthorizationPolicy {
private final MatchmakerContactAuthorizationMapper authorizationMapper;
private final DataScopeGuard dataScopeGuard;
/**
* 断言生效中的授权;无效时先审计后 404。
*
* @return 生效中的授权记录(永不返回 null)
*/
public MatchmakerContactAuthorization assertContactAllowed(String operatorId, String userId,
String channel, String purpose) {
Optional<MatchmakerContactAuthorization> found = canContact(operatorId, userId, channel);
if (found.isPresent()) {
return found.get();
}
dataScopeGuard.denyAsNotFound(operatorId, "matchmaker_contact_authorization", userId,
"服务不存在", null, null);
return null; // denyAsNotFound 必然抛出,此行不可达
}
/**
* 徽标查询(不审计):双态惰性收敛后查生效授权。
* 供 UI「可联系」徽标等高频读场景,避免每徽标一次拒绝审计。
*/
public Optional<MatchmakerContactAuthorization> canContact(String operatorId, String userId,
String channel) {
authorizationMapper.expireOverdue();
return authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId,
channel == null ? Constants.CONTACT_CHANNEL_PHONE : channel);
}
}
@@ -0,0 +1,450 @@
package com.danyang.match.platform.matchmaker.service;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
import com.danyang.match.platform.matchmaker.config.ContactAuthorizationConfig;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 受控联系授权服务(ZC-MM-003 / B1 立项,架构师设计 H4)。
*
* <p>六操作状态机(全矩阵):
* <pre>
* request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
* approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
* {@link #assertApproverAuthority})pending_approval → pending_consent;
* 违规 403(无资源枚举面维持 403)
* reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
* consent : C 端用户本人(归属校验,独立端点、无预同意参数)
* pending_consent → active(expires_at = consent + default-validity-days)
* decline : C 端用户本人 pending_consent → rejected
* revoke : 申请人或审批人,reason 必填 → revoked
* disclose : H4 唯一披露出口 —— enabled(fail-safe) → 持分配 → 生效授权;
* 返回 {phone, disclosedAt, authorizationId};每次落 SensitiveAccessLog;
* phone 不落任何应用日志
* </pre>
*
* <p>拒绝口径(G4 防探测):disclose 及授权有效性判定一律 404 化 deny
* (先 DATA_SCOPE_DENIED 审计再 404「服务不存在」);approve/管理类违规维持 403。
* 状态机非法转换一律 CONFLICT。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ContactAuthorizationService {
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure";
/**
* 兜底审批岗权限键(用户裁决① 固定):持有者可审批全部申请且不做组织校验。
* 主审岗权限键仍由 approver-authorities 列表首段配置(默认 matchmaker.team.assign)。
*/
private static final String FALLBACK_APPROVER_AUTHORITY = "matchmaker.assign";
private final MatchmakerContactAuthorizationMapper authorizationMapper;
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
private final MatchmakerAssignmentService matchmakerAssignmentService;
private final ContactAuthorizationPolicy contactAuthorizationPolicy;
private final ContactAuthorizationConfig config;
private final DataScopeGuard dataScopeGuard;
private final AuditLogMapper auditLogMapper;
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final UserMapper userMapper;
// ==================== ① request:红娘发起申请 ====================
@Transactional
public MatchmakerContactAuthorization request(String operatorId, String userId,
String channel, String purpose) {
// fail-safe 最先(与 disclose 同口径):机制关闭(配置缺失/显式关闭)→ 404 化拒绝。
// ZC-MM-003 裁决落地 T-C:request 原实现漏查 enabled(真实缺口,本轮补齐)。
if (!config.isEnabled()) {
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
"服务不存在", null, null);
}
if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
}
requireValidChannel(channel);
// 未持分配申请 → 403 + DATA_SCOPE_DENIED 审计(申请凭据是分配而非授权本身)
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignment(
operatorId, userId, RESOURCE_TYPE);
// 同 (operator,user,channel) 已有生效授权或待处理申请 → CONFLICT(防重复申请洪泛)
if (authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId, channel).isPresent()) {
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在生效中的授权");
}
boolean hasPending = authorizationMapper.findByOperator(operatorId).stream()
.anyMatch(a -> a.getUserId().equals(userId) && a.getChannel().equals(channel)
&& (Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(a.getStatus())
|| Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(a.getStatus())));
if (hasPending) {
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在待处理的授权申请");
}
MatchmakerContactAuthorization auth = new MatchmakerContactAuthorization();
auth.setOperatorId(operatorId);
auth.setUserId(userId);
auth.setChannel(channel);
auth.setPurpose(purpose.trim());
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL);
// pending 期 expires_at 承载同意窗口上界(consent 后重算为有效期)
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
authorizationMapper.insert(auth);
log.info("受控联系授权申请: authId={}, operatorId={}, userId={}, channel={}",
auth.getId(), operatorId, userId, channel);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(),
assignment != null ? assignment.getId() : null,
"matchmaker.contact.request", "channel=" + channel + ";purpose=" + auth.getPurpose());
return auth;
}
// ==================== ② approve / ③ reject:审批岗(分层) ====================
/**
* 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
*
* <p>分层规则:
* <ol>
* <li>持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请,
* <b>不做组织校验</b>(见 {@link #assertOrgScopeForApprover});</li>
* <li>否则持主审岗权限键(approver-authorities 列表首段,默认
* {@code matchmaker.team.assign},组长)→ 主审,须通过
* {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);</li>
* <li>两者皆无 → 403 deny(不变)。</li>
* </ol>
*
* <p>主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign}
* —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。
*/
private void assertApproverAuthority(String approverOperatorId, String authId,
Set<String> principalPermissions) {
boolean authorized = isFallbackApprover(principalPermissions)
|| isPrimaryApprover(principalPermissions);
if (!authorized) {
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
"非审批岗,禁止审批受控联系授权");
}
}
/** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */
private boolean isFallbackApprover(Set<String> principalPermissions) {
return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY);
}
/** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */
private boolean isPrimaryApprover(Set<String> principalPermissions) {
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
return principalPermissions != null
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
}
/**
* 分层组织范围校验(用户裁决①):兜底岗(持 matchmaker.assign)跳过组织校验;
* 主审岗(组长)固定走 {@code assertTeamScope}(hasGlobalAssign=false ——
* 是否兜底由本层判定,不再向底层传组织豁免,防止权限面双通道扩大)。
*/
private void assertOrgScopeForApprover(String approverOperatorId, String authId,
Set<String> principalPermissions, String targetOperatorId) {
if (isFallbackApprover(principalPermissions)) {
return;
}
matchmakerAssignmentService.assertTeamScope(approverOperatorId, false,
targetOperatorId, authId);
}
@Transactional
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
Set<String> principalPermissions) {
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
MatchmakerContactAuthorization auth = requireAuthorization(authId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许审批");
}
// 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
auth.getOperatorId());
auth.setApprovedBy(approverOperatorId);
auth.setApprovedAt(Instant.now());
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT);
// 刷新同意窗口上界(给用户完整窗口)
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
authorizationMapper.updateById(auth);
log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
"result=approved;channel=" + auth.getChannel());
return auth;
}
@Transactional
public MatchmakerContactAuthorization reject(String authId, String approverOperatorId,
Set<String> principalPermissions) {
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
MatchmakerContactAuthorization auth = requireAuthorization(authId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许拒绝");
}
// 分层组织范围校验(用户裁决①,与 approve 同口径)
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
auth.getOperatorId());
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
authorizationMapper.updateById(auth);
log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId);
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
"result=rejected;channel=" + auth.getChannel());
return auth;
}
// ==================== ④ consent / ⑤ decline:C 端用户本人 ====================
@Transactional
public MatchmakerContactAuthorization consent(String authId, String userId) {
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许同意");
}
auth.setUserConsentedAt(Instant.now());
auth.setExpiresAt(Instant.now().plusSeconds(config.getDefaultValidityDays() * 86400L));
auth.setStatus(Constants.CONTACT_AUTH_STATUS_ACTIVE);
authorizationMapper.updateById(auth);
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.consent",
"channel=" + auth.getChannel());
return auth;
}
@Transactional
public MatchmakerContactAuthorization decline(String authId, String userId) {
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许拒绝");
}
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
authorizationMapper.updateById(auth);
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.decline",
"channel=" + auth.getChannel());
return auth;
}
// ==================== ⑥ revoke:申请人或审批人 ====================
@Transactional
public MatchmakerContactAuthorization revoke(String authId, String operatorId, String reason) {
if (reason == null || reason.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "撤销理由必填");
}
MatchmakerContactAuthorization auth = requireAuthorization(authId);
boolean isApplicant = operatorId.equals(auth.getOperatorId());
boolean isApprover = operatorId.equals(auth.getApprovedBy());
if (!isApplicant && !isApprover) {
dataScopeGuard.deny(operatorId, RESOURCE_TYPE, authId,
"仅申请人或审批人可撤销授权");
}
if (!Constants.CONTACT_AUTH_STATUS_ACTIVE.equals(auth.getStatus())
&& !Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())
&& !Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许撤销");
}
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REVOKED);
auth.setRevokedAt(Instant.now());
auth.setRevokedBy(operatorId);
auth.setRevokeReason(reason.trim());
authorizationMapper.updateById(auth);
log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId);
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId,
activeAssignmentIdOf(auth), "matchmaker.contact.revoke",
"reason=" + auth.getRevokeReason());
return auth;
}
// ==================== ⑦ disclose:H4 唯一披露出口 ====================
/**
* 披露用户电话(H4 唯一出口)。
* 链路:enabled fail-safe → 持分配(404 化)→ 生效授权(404 化)→ 返回三键;
* 每次落 SensitiveAccessLog(user_phone_disclosure);<b>phone 不落任何应用日志</b>。
*/
@Transactional
public Map<String, Object> disclose(String operatorId, String userId, String channel,
String purpose) {
// fail-safe:机制关闭(配置缺失/显式关闭)→ 404 化拒绝
if (!config.isEnabled()) {
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
"服务不存在", null, null);
}
if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "披露目的必填");
}
// 持分配校验(404 化,G4)
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
operatorId, userId, RESOURCE_TYPE, userId);
// 生效授权校验(404 化;未同意/已拒绝/已过期/已撤销全部落同一响应)
MatchmakerContactAuthorization auth = contactAuthorizationPolicy.assertContactAllowed(
operatorId, userId, channel, purpose);
User user = userMapper.selectById(userId);
String phone = user != null ? user.getPhone() : null;
SensitiveAccessLog accessLog = new SensitiveAccessLog();
accessLog.setOperatorId(operatorId);
accessLog.setUserId(userId);
accessLog.setDataType(SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE);
accessLog.setReason(purpose.trim());
accessLog.setAccessTarget(auth.getId());
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
sensitiveAccessLogMapper.insert(accessLog);
// 审计不含 phone(afterData 只记 channel;log.info 不打 phone)
log.info("受控联系披露: authId={}, operatorId={}, userId={}, channel={}",
auth.getId(), operatorId, userId, auth.getChannel());
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(),
assignment != null ? assignment.getId() : null,
"matchmaker.contact.disclose", "channel=" + auth.getChannel());
Map<String, Object> result = new HashMap<>();
result.put("phone", phone);
result.put("disclosedAt", Instant.now());
result.put("authorizationId", auth.getId());
return result;
}
// ==================== 查询 ====================
/** 红娘工作台:本人全部授权(含保留期元数据 retentionDays)。 */
public Map<String, Object> myAuthorizations(String operatorId) {
List<Map<String, Object>> items = authorizationMapper.findByOperator(operatorId).stream()
.map(this::toView).collect(Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("list", items);
result.put("retentionDays", config.getRetentionDays());
return result;
}
/** C 端:我的授权列表 + 待同意队列。 */
public Map<String, Object> myAuthorizationsForUser(String userId) {
List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream()
.map(this::toView).collect(Collectors.toList());
List<Map<String, Object>> pendingConsent = authorizationMapper
.findPendingConsentByUser(userId).stream()
.map(this::toView).collect(Collectors.toList());
Map<String, Object> result = new HashMap<>();
result.put("list", items);
result.put("pendingConsent", pendingConsent);
return result;
}
/** 视图投影:不含任何联系方式字段(授权表本身无 phone 列)。 */
private Map<String, Object> toView(MatchmakerContactAuthorization auth) {
Map<String, Object> item = new HashMap<>();
item.put("id", auth.getId());
item.put("operatorId", auth.getOperatorId());
item.put("userId", auth.getUserId());
item.put("channel", auth.getChannel());
item.put("purpose", auth.getPurpose());
item.put("status", auth.getStatus());
item.put("createdAt", auth.getCreatedAt());
item.put("approvedAt", auth.getApprovedAt());
item.put("userConsentedAt", auth.getUserConsentedAt());
item.put("expiresAt", auth.getExpiresAt());
item.put("revokeReason", auth.getRevokeReason());
return item;
}
// ==================== 内部实现 ====================
private void requireValidChannel(String channel) {
boolean valid = Constants.CONTACT_CHANNEL_PHONE.equals(channel)
|| Constants.CONTACT_CHANNEL_OFFLINE_MEETING.equals(channel)
|| Constants.CONTACT_CHANNEL_FULL_CONTACT.equals(channel);
if (!valid) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "不支持的授权渠道: " + channel);
}
}
private MatchmakerContactAuthorization requireAuthorization(String authId) {
return authorizationMapper.findById(authId)
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "授权记录不存在"));
}
/** C 端归属校验:仅授权记录的本人用户可 consent/decline,违规 403。 */
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
MatchmakerContactAuthorization auth = requireAuthorization(authId);
if (!auth.getUserId().equals(userId)) {
dataScopeGuard.deny(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求");
}
return auth;
}
/** 审计用 service_assignment_id:当前 (红娘,用户) 生效中的分配 id,无则 null。 */
private String activeAssignmentIdOf(MatchmakerContactAuthorization auth) {
Optional<MatchmakerAssignment> assignment =
serviceAssignmentPolicy.findActiveAssignment(auth.getOperatorId(), auth.getUserId());
return assignment.map(MatchmakerAssignment::getId).orElse(null);
}
/**
* 审计写入:purpose_code(matchmaker.contact.*)+ service_assignment_id +
* request_trace_id(MDC requestId)。afterData 一律不含 phone。
*/
private void saveAudit(String operatorId, String action, String authId,
String serviceAssignmentId, String purposeCode, String afterData) {
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction(action);
auditLog.setResourceType(RESOURCE_TYPE);
auditLog.setResourceId(authId);
auditLog.setAfterData(afterData);
auditLog.setPurposeCode(purposeCode);
auditLog.setServiceAssignmentId(serviceAssignmentId);
auditLog.setRequestTraceId(MDC.get("requestId"));
auditLogMapper.insert(auditLog);
}
}

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