17 Commits
Author SHA1 Message Date
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
196 changed files with 11705 additions and 3439 deletions
+37
View File
@@ -0,0 +1,37 @@
# CODEOWNERS — 善缘丹阳公益婚恋平台 审查角色分配(草案)
#
# 作用:开 PR 时 GitHub 自动向对应路径的 owner 请求审查;配合分支保护与
# docs/governance/CODE_REVIEW_STANDARDS.md 的 T0/T1/T2 分级与双审规则使用。
# 语法:https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
#
# 当前为一人团队,默认全部归技术负责人 @g1307092382。
# 待扩员时,把各端拆给对应熟悉者即可;T2 高危改动按审查标准须 2 人复核(其中 1 人为该端 owner)。
# 说明:CODEOWNERS 只负责「请求谁审」,不强制「必须不同人」;双审由审查流程纪律保证。
# 排序:子路径必须放在父路径之后(GitHub 取最后一个匹配的规则)。
# —— 默认兜底 ——
* @g1307092382
# —— 后端(Spring Boot / Java)——
/backend/ @g1307092382
# 数据库迁移(Flyway):schema 变更属 T2,须重点复核(放在 /backend/ 之后)
/backend/src/main/resources/db/migration/ @g1307092382
# —— 微信小程序(原生 WXML/WXSS/TS)——
/miniapp/ @g1307092382
# —— 管理端(Vue3 / TS / pnpm)——
/admin-web/ @g1307092382
# —— 测试与契约守卫(跨端契约,需熟悉各端)——
/tests/ @g1307092382
/ui-tests/ @g1307092382
/scripts/run-ui-contracts.mjs @g1307092382
# —— 基础设施 / 部署(Compose / 镜像 / 密钥)——
/infrastructure/ @g1307092382
# —— 治理与审查机制文档(变更须经技术负责人确认)——
/docs/governance/ @g1307092382
/.github/ @g1307092382
+69
View File
@@ -0,0 +1,69 @@
<!--
提 PR 前请先读 docs/governance/CODE_REVIEW_STANDARDS.md。
本模板是合并准入的硬性门槛:逐项打勾 = 已实际验证(不是"应该没问题")。
证据优先:描述里的"✅已完成"不是证据,请贴命令+输出/代码 diff/实测结论。
-->
## 变更摘要
<!-- 改了什么、为什么、影响面 -->
## 风险分级(见审查标准 §2)
- [ ] T0 琐碎(纯文案/文档/格式化/资源重命名)
- [ ] T1 常规(单端功能/Bug/UI/单 API)
- [ ] T2 高危(鉴权/支付/隐私/跨端契约/DB schema/并发幂等/已知阻塞项)— 需 2 人复核
## 验证方式(必填,证据优先)
<!-- 贴实际跑过的命令与关键输出;不要只写"已自测通过" -->
- 本地测试/契约守卫命令与结果:
- 运行时实测(如涉及渲染/接口,贴 mock/路由/关键响应):
- 负向注入(新增或修改测试时必填:故意改坏后测试是否转红):
## 技术栈审查清单(按本 PR 涉及项打勾)
### 后端 Java / Spring Boot
- [ ] 业务拒绝用 `BusinessException`+`ApiResponseCode`(HTTP 400≤code<500),无裸 `RuntimeException`
- [ ] 测试对业务拒绝断言 `400 ≤ status < 500`(非仅 `!= 200`)
- [ ] 核心不变量(INV-014/015、Required/Mutual/Block 等)未被绕过
- [ ] 主键/唯一键/CHECK 约束与 Java 枚举一致,无静默 no-op 写
- [ ] 无新增死表/死配置(`system_configs`/`match_rule_configs` 类)
### 小程序 miniapp
- [ ] `app.json`/`pages.json` 路径与磁盘文件一致、无前导 `/`
- [ ] `ignoreDevUnusedFiles`/`ignoreUploadUnusedFiles` = false(资源不被吞)
- [ ] 跨端字段名与后端规范名一致(tsc 不保护 JS 层字段名)
- [ ] WXSS 无通配符/属性选择器/嵌套注释;`<image>` 显式高度
- [ ] 胶囊安全区让出 `--nav-right`
### 管理端 admin-web
- [ ] 渲染相关状态走 `ref`/`reactive`,已补渲染态实测
- [ ] 颜色/字号收敛到 `src/theme/` token,EP 主色阶补全 `-light-N`/`-dark-2`/`-rgb`
- [ ] 表单校验类型安全、出错保留输入
- [ ] 触摸目标 ≥44px,尊重 `prefers-reduced-motion`
### 数据库迁移 SQL
- [ ] `V<序号>__<描述>.sql` 命名,版本连续唯一
- [ ] 向后兼容,NOT NULL 新列有默认值/迁移
- [ ] CHECK 约束与枚举一致,破坏性变更有回滚方案
### 测试 / 契约守卫
- [ ] 无空壳 `skip`/`pass` 测试
- [ ] 已做负向注入证明判别力
- [ ] `scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 23) 同步(增删守卫必改)
### 脚本 / 基础设施
- [ ] 脚本真实依赖已在 CI 对应 job 安装(非仅本地能跑)
- [ ] 密钥仅运行期、不落盘;本地栈固定 `-p danyang`
## 判据纪律自查(见审查标准 §5,对应改动必查)
- [ ] 非空数据验证(无空数据假绿,如推荐/列表返回空也能过)
- [ ] 无直连绕过(关键路径至少一个经 HTTP 层断言)
- [ ] 约束相关改动在带约束环境验证(无测试库缺约束假绿)
- [ ] 小程序改动不走 `Mini Program Compile Check` 的"仅 tsc"假绿,已真编译/查资源
- [ ] 合并后回读 `git rev-parse HEAD` + 轮询 CI 确认本次 head 真 success
## 已知阻塞 / 决策关联
- [ ] 本 PR 是否触碰上线阻塞项(微信真实登录 / 照片直传落库)?若是:✅本次解决 / ⚠️未解决已登记 known-risks
- [ ] 是否与 `decision-register` 的 LOCKED 决策冲突?若是:已回决策层确认(附结论)
- [ ] 是否产生新债务?若是:已登记 `known-risks.md`(编号 KR-xx)
## 关联
<!-- 关联 Issue / 决策 / 风险编号 -->
+7
View File
@@ -25,6 +25,13 @@ jobs:
working-directory: backend
run: mvn verify -B
# 后端契约门禁(与 scripts/run-ui-contracts.mjs 同级):
# 固化关键后端回归/契约测试(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底
# + 核心不变量),先校验这些测试源文件存在性下限(防改名/删除后门禁假绿),
# 再用 `mvn test -Dtest=<列表>` 真跑。复用本 job 已编译产物与依赖缓存,开销极小。
- name: 后端契约门禁(curated + 下限校验)
run: node scripts/run-backend-contracts.mjs
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+1 -1
View File
@@ -1 +1 @@
1.1.0-RC.1
1.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "danyang-match-admin",
"version": "1.0.0-rc.2",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
+3
View File
@@ -19,6 +19,9 @@
# 后者生成 Vue 版本桥接),允许它们执行。
packages:
- '.'
allowBuilds:
esbuild: 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 {
+3 -3
View File
@@ -174,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 },
@@ -217,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)))
@@ -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 '-'
@@ -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() {
@@ -213,6 +213,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client'
import { token } from '@/theme/tokens'
@@ -302,7 +303,7 @@ async function loadFunnel() {
try {
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = localStorage.getItem('accessToken')
const authToken = getAccessToken()
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
@@ -328,7 +329,7 @@ async function loadFunnel() {
async function loadDailySummary() {
try {
const authToken = localStorage.getItem('accessToken')
const authToken = getAccessToken()
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
dailySummary.value = res.data || []
} catch (e) { console.error(e) }
@@ -84,6 +84,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
@@ -109,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
loadStats()
@@ -119,7 +120,7 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
@@ -138,7 +139,7 @@ async function openDetail(id: string) {
decision.value = 'approve'
decisionNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data
} catch (e) { console.error(e) }
@@ -148,7 +149,7 @@ async function handleProcess() {
if (!decision.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post(`/operator/appeals/${currentId.value}/process`,
{ decision: decision.value, decisionNote: decisionNote.value },
{ Authorization: `Bearer ${token}` })
+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,
@@ -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}`
@@ -98,6 +98,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
@@ -124,7 +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()
@@ -134,7 +135,7 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
@@ -152,7 +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) }
@@ -162,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}` })
+6 -3
View File
@@ -37,8 +37,8 @@ const routes: RouteRecordRaw[] = [
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['matchmaker.service'] } },
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.view'] } },
],
},
@@ -48,10 +48,13 @@ const routes: RouteRecordRaw[] = [
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
const requiredPerms = (to.meta.permissions as string[]) || []
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
await authStore.initializeSession()
// 1. 登录检查
if (to.path !== '/login' && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
+54 -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,68 @@ 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 || [])
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, login, logout, initializeSession }
})
+19
View File
@@ -0,0 +1,19 @@
// 内存态访问令牌。
//
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
// Authorization 头承载。两者并存,互不冲突。
let currentToken = ''
export function getAccessToken(): string {
return currentToken
}
export function setAccessToken(token: string): void {
currentToken = token || ''
}
export function clearAccessToken(): void {
currentToken = ''
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,9 +13,9 @@
<groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId>
<version>1.0.0-RC.1</version>
<version>1.1.0</version>
<name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
<properties>
<java.version>21</java.version>
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.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));
}
/**
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 活动互选服务
@@ -69,7 +70,7 @@ public class ActivityInterestService {
// 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
}
@@ -32,6 +32,7 @@ import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -407,7 +408,7 @@ public class ActivityService {
@Transactional
public ActivityRegistration cancelRegistration(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 签到服务
@@ -64,7 +65,7 @@ public class CheckinService {
*/
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
@Slf4j
@Service
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
@Transactional
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
// 回读权威状态(可能受并发另一方提交影响)
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("互选记录已不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
@@ -120,7 +122,7 @@ public class ActivityMutualConsentService {
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
} else {
@@ -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;
}
}
@@ -30,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
@@ -90,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);
@@ -111,7 +114,7 @@ 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 -> {
@@ -172,9 +175,9 @@ public class AuthService {
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
// 更新 Redis
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccessToken);
@@ -200,7 +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());
@@ -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
@@ -144,7 +145,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()
@@ -176,6 +177,11 @@ public class OperatorAuthService {
throw new UnauthorizedException("Refresh Token 无效");
}
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
if (operatorId == null || operatorId.isBlank()) {
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
}
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效");
@@ -193,6 +199,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;
}
@@ -215,9 +215,21 @@ public class JwtTokenService {
}
/**
* 获取 Token 过期时间
* 获取 Access Token 过期时间(毫秒)
*/
public long getExpirationMs() {
return accessExpirationMs;
}
/**
* 获取 Refresh Token 过期时间(毫秒)。
*
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
*/
public long getRefreshExpirationMs() {
return refreshExpirationMs;
}
}
@@ -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) {
@@ -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")
@@ -232,6 +232,25 @@ public class MeController {
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
}
/**
* 确认直传照片落库(REST §29 补)。
* 客户端先 {@code POST /me/photos/upload-token} 拿预签名 URL 直传对象存储,
* 上传成功后再调本端点把 fileKey 落成 user_photos 记录;否则照片不展示、签名访问也过不了归属校验。
* POST /api/v1/me/photos/confirm
*/
@PostMapping("/photos/confirm")
public ApiResponse<Map<String, Object>> confirmPhoto(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String fileKey = body.get("fileKey");
String photoType = body.getOrDefault("photoType", "lifestyle");
UserPhoto photo = photoService.confirmDirectUpload(principal.id(), fileKey, photoType);
Map<String, Object> result = new LinkedHashMap<>();
result.put("photoId", photo.getId());
result.put("photoType", photo.getPhotoType());
return ApiResponse.ok(result);
}
/**
* 删除本人照片(REST §32)。
* DELETE /api/v1/me/photos/{photoId}
@@ -20,6 +20,7 @@ import java.util.List;
import java.util.Map;
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;
/**
* Operator 管理服务
@@ -85,7 +86,7 @@ public class OperatorService {
@Transactional
public void disableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
operator.setUpdatedAt(Instant.now());
@@ -101,7 +102,7 @@ public class OperatorService {
@Transactional
public void enableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
operator.setUpdatedAt(Instant.now());
@@ -187,6 +187,33 @@ public class PhotoService {
return photo;
}
/**
* 确认直传照片落库(客户端直传对象存储成功后调用)。
*
* <p>直传路径({@code /me/photos/upload-token} 签发预签名 PUT URL)对象先进入私有桶,
* 但此前没有任何端点把 {@code fileKey} 写成 {@code user_photos} 记录,导致「传了白传」:
* 列表不展示、签名访问的归属校验也过不了。本方法补齐落库,并设两道护栏:
* <ol>
* <li>归属校验:{@code fileKey} 必须以 {@code photos/{userId}/} 开头(由本用户直传凭证生成),
* 禁止客户端传入任意 fileKey 把别人的对象据为己有或写入非法 key;</li>
* <li>存在性校验:对象必须已存在于私有桶(客户端确实完成了 PUT),否则拒绝落库,
* 避免只拿了凭证却没上传成功就生成孤儿记录。</li>
* </ol>
*/
@Transactional
public UserPhoto confirmDirectUpload(String userId, String fileKey, String photoType) {
if (fileKey == null || !fileKey.startsWith("photos/" + userId + "/")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
}
// countActiveProfilePhotos 返回 long,落库排序用 int(与 savePhoto 形参一致);
// 照片数量不会触及 int 上界,此处安全窄化。
int sortOrder = (int) (photoMapper.countActiveProfilePhotos(userId) + 1);
return savePhoto(userId, fileKey, photoType, sortOrder);
}
/**
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
*/
@@ -1,11 +1,13 @@
package com.danyang.match.platform.profile.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@@ -25,17 +27,28 @@ public class UserPreferenceService {
}
/**
* 保存择偶条件
* 保存择偶条件(无则插入、有则更新)。
*
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
* 再次保存即触发主键冲突(实测 {@code PUT /me/preferences} → 409
* {@code duplicate key value violates unique constraint "user_preferences_pkey"})。
*
* <p><b>为何不用 {@code updateById}</b>:{@code UserPreference} 继承 {@code BaseEntity},
* 其 {@code @TableId} 为 {@code id},而库中真正的主键是 {@code user_id}
* ({@code id} 是 V037 后补的可空列 + 唯一索引,V037 之后新插入的行 {@code id} 为 NULL)。
* 用 {@code updateById} 会退化成 {@code WHERE id IS NULL} 静默不更新,
* 因此按 {@code user_id} 定位。
*/
@Transactional
public UserPreference savePreference(String userId, Map<String, Object> data) {
UserPreference pref = userPreferenceMapper.findByUserId(userId)
.orElseGet(() -> {
UserPreference p = new UserPreference();
p.setUserId(userId);
return p;
});
UserPreference pref = userPreferenceMapper.findByUserId(userId).orElse(null);
boolean exists = pref != null;
if (!exists) {
pref = new UserPreference();
pref.setUserId(userId);
}
if (data != null) {
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
@@ -54,8 +67,15 @@ public class UserPreferenceService {
if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
}
userPreferenceMapper.insert(pref);;
if (exists) {
pref.setUpdatedAt(Instant.now());
userPreferenceMapper.update(pref,
new LambdaUpdateWrapper<UserPreference>().eq(UserPreference::getUserId, userId));
} else {
userPreferenceMapper.insert(pref);
}
return pref;
}
@@ -11,6 +11,10 @@ import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.verification.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -27,6 +31,7 @@ public class UserProfileService {
private final UserPreferenceMapper userPreferenceMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final UserRelationMapper relationMapper;
private final UserIdentityMapper userIdentityMapper;
/**
* 获取自己完整资料(含隐私设置)
@@ -60,8 +65,13 @@ public class UserProfileService {
prefMap.put("maxHeightCm", pref.getMaxHeightCm());
prefMap.put("heightPriority", pref.getHeightPriority());
prefMap.put("regionScope", pref.getRegionScope());
prefMap.put("regionPriority", pref.getRegionPriority());
prefMap.put("maritalStatuses", pref.getMaritalStatuses());
prefMap.put("maritalPriority", pref.getMaritalPriority());
prefMap.put("minEducationCode", pref.getMinEducationCode());
prefMap.put("educationPriority", pref.getEducationPriority());
prefMap.put("childrenAcceptance", pref.getChildrenAcceptance());
prefMap.put("childrenPriority", pref.getChildrenPriority());
prefMap.put("smokingPreference", pref.getSmokingPreference());
prefMap.put("drinkingPreference", pref.getDrinkingPreference());
prefMap.put("settlementPreference", pref.getSettlementPreference());
@@ -84,57 +94,186 @@ public class UserProfileService {
}
/**
* 更新基础资料
* 更新基础资料(资料完善流程分节写)。
*
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
* 已有资料再次保存 → 主键冲突 → 500。改为「无则 insert / 有则 updateById」。
* 另补 {@code gender}/{@code birth_date} 的 NOT NULL 兜底(见 {@link #ensureIdentityFields})。
*/
@Transactional
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId)
.orElseGet(() -> {
UserProfile p = new UserProfile();
p.setUserId(userId);
p.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
return p;
});
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
boolean exists = profile != null;
if (!exists) {
profile = new UserProfile();
profile.setUserId(userId);
profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
}
applyProfileFields(profile, fields);
ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile));
userProfileMapper.insert(profile);
persistProfile(profile, exists);
return profile;
}
/**
* 更新个人资料
* 更新个人资料(资料完善向导的最终提交,置为 completed)。
*
* <p>2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
*/
@Transactional
public void updateProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(new UserProfile());
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
boolean exists = profile != null;
if (!exists) {
profile = new UserProfile();
profile.setUserId(userId);
}
// 基础字段映射
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
applyProfileFields(profile, fields);
ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile));
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
persistProfile(profile, exists);
}
/**
* 落库:无则插入、有则按主键 user_id 更新。
*
* <p>{@code UserProfile} 的 {@code @TableId = userId},与库主键 {@code user_profiles.user_id}
* 一致,故 {@code updateById} 安全(对比 {@code UserPreference} 实体 id 与库主键不一致的情形)。
*/
private void persistProfile(UserProfile profile, boolean exists) {
if (exists) {
userProfileMapper.updateById(profile);
} else {
userProfileMapper.insert(profile);
}
}
/**
* 把请求字段映射到资料实体。
*
* <p>同时接受「规范字段名」与小程序的既有字段名,二者等价:
* <ul>
* <li>{@code childrenStatus}(none/yes)↔ {@code hasChildren}(布尔)</li>
* <li>{@code occupationCode} ↔ {@code occupationCategory}</li>
* <li>{@code workRegionCode} ↔ {@code workRegion}</li>
* </ul>
* 这使「REST 规范 / 管理端」与「小程序完善资料向导」共用同一写接口,
* 避免因命名不一致导致字段被静默丢弃(UAT 走查发现向导的子女/职业/工作地三项
* 此前根本写不进库)。
*/
private void applyProfileFields(UserProfile profile, Map<String, Object> fields) {
if (fields == null) return;
if (fields.containsKey("displayName")) profile.setDisplayName(asString(fields.get("displayName")));
if (fields.containsKey("gender")) profile.setGender(asString(fields.get("gender")));
if (fields.containsKey("birthDate")) profile.setBirthDate(asDate(fields.get("birthDate")));
if (fields.containsKey("heightCm")) profile.setHeightCm(asInteger(fields.get("heightCm")));
if (fields.containsKey("regionCode")) profile.setRegionCode(asString(fields.get("regionCode")));
if (fields.containsKey("regionName")) profile.setRegionName(asString(fields.get("regionName")));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus(asString(fields.get("maritalStatus")));
// 子女:规范名 childrenStatus(none/yes)优先,兼容向导的 hasChildren(布尔)
if (fields.containsKey("childrenStatus")) {
profile.setChildrenStatus(asString(fields.get("childrenStatus")));
} else if (fields.containsKey("hasChildren")) {
Boolean has = asBoolean(fields.get("hasChildren"));
if (has != null) profile.setChildrenStatus(has ? "yes" : "none");
}
if (fields.containsKey("educationCode")) profile.setEducationCode(asString(fields.get("educationCode")));
// 职业:规范名 occupationCode 优先,兼容向导的 occupationCategory
if (fields.containsKey("occupationCode")) {
profile.setOccupationCode(asString(fields.get("occupationCode")));
} else if (fields.containsKey("occupationCategory")) {
profile.setOccupationCode(asString(fields.get("occupationCategory")));
}
// 工作地:规范名 workRegionCode 优先,兼容向导的 workRegion
if (fields.containsKey("workRegionCode")) {
profile.setWorkRegionCode(asString(fields.get("workRegionCode")));
} else if (fields.containsKey("workRegion")) {
profile.setWorkRegionCode(asString(fields.get("workRegion")));
}
if (fields.containsKey("smokingStatus")) profile.setSmokingStatus(asString(fields.get("smokingStatus")));
if (fields.containsKey("drinkingStatus")) profile.setDrinkingStatus(asString(fields.get("drinkingStatus")));
if (fields.containsKey("longTermDanyang")) profile.setLongTermDanyang(asString(fields.get("longTermDanyang")));
if (fields.containsKey("futureDanyang")) profile.setFutureDanyang(asString(fields.get("futureDanyang")));
if (fields.containsKey("relationshipGoal")) profile.setRelationshipGoal(asString(fields.get("relationshipGoal")));
if (fields.containsKey("marriagePlan")) profile.setMarriagePlan(asString(fields.get("marriagePlan")));
if (fields.containsKey("selfIntro")) profile.setSelfIntro(asString(fields.get("selfIntro")));
}
/**
* {@code user_profiles.gender} 与 {@code birth_date} 在库中为 NOT NULL,而资料完善向导
* 不采集这两项(性别是「已认证」的只读信息)——权威来源是实名认证
* ({@code user_identities}),产品流程亦为「先实名、后完善资料」。
*
* <p>因此:请求未提供时从已通过实名认证的记录补齐;仍缺失则给出明确的
* 4xx 业务拒绝(而非让数据库抛 NOT NULL 约束错渲染成 500)。
*/
private void ensureIdentityFields(UserProfile profile) {
boolean needGender = profile.getGender() == null || profile.getGender().isBlank();
boolean needBirth = profile.getBirthDate() == null;
if (!needGender && !needBirth) return;
UserIdentity identity = userIdentityMapper.findByUserId(profile.getUserId()).orElse(null);
if (identity != null && Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
if (needGender && identity.getGender() != null && !identity.getGender().isBlank()) {
profile.setGender(identity.getGender());
needGender = false;
}
if (needBirth && identity.getBirthDate() != null) {
profile.setBirthDate(identity.getBirthDate());
needBirth = false;
}
}
if (needGender || needBirth) {
throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.BAD_REQUEST,
"请先完成实名认证后再完善资料");
}
}
private static String asString(Object v) {
return v == null ? null : String.valueOf(v);
}
private static Integer asInteger(Object v) {
if (v == null) return null;
if (v instanceof Number n) return n.intValue();
try {
return Integer.valueOf(String.valueOf(v).trim());
} catch (NumberFormatException e) {
return null;
}
}
private static Boolean asBoolean(Object v) {
if (v == null) return null;
if (v instanceof Boolean b) return b;
String s = String.valueOf(v).trim();
if ("true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "has".equalsIgnoreCase(s)) return true;
if ("false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) || "none".equalsIgnoreCase(s)) return false;
return null;
}
private static LocalDate asDate(Object v) {
if (v == null) return null;
if (v instanceof LocalDate d) return d;
String s = String.valueOf(v).trim();
if (s.isEmpty()) return null;
try {
return LocalDate.parse(s);
} catch (RuntimeException e) {
return null;
}
}
/**
* 获取公开资料(用于推荐卡片和详情页,需登录)
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 关系服务
@@ -104,7 +105,7 @@ public class RelationshipService {
@Transactional
public void cancelLike(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
.orElseThrow(() -> new RuntimeException("没有找到喜欢关系"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到喜欢关系"));
relationMapper.deleteById(relation.getId());
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
@@ -184,7 +185,7 @@ public class RelationshipService {
@Transactional
public void unblock(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
.orElseThrow(() -> new RuntimeException("没有拉黑过该用户"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有拉黑过该用户"));
relationMapper.deleteById(relation.getId());
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
@@ -323,7 +324,7 @@ public class RelationshipService {
@Transactional
public void unmatch(String userId, String targetUserId) {
Match match = matchMapper.findActiveMatch(userId, targetUserId)
.orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到有效的匹配关系"));
match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
match.setUnmatchedAt(Instant.now());
@@ -95,9 +95,9 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approve(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, Object> body) {
String operatorId = principal.id();
String comment = body.getOrDefault("comment", "");
String comment = (String) body.getOrDefault("comment", "");
return ApiResponse.ok(reviewService.approve(taskId, operatorId, comment));
}
@@ -140,8 +140,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> needEvidence(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/approve-local-eligibility")
@@ -149,8 +149,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> approveLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/reject-local-eligibility")
@@ -158,8 +158,8 @@ public class ReviewController {
public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment")));
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), (String) body.get("comment")));
}
@PostMapping("/{taskId}/evidences/{evidenceId}/access")
@@ -168,9 +168,9 @@ public class ReviewController {
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@PathVariable String evidenceId,
@RequestBody Map<String, String> body) {
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
taskId, evidenceId, principal.id(), body.get("purpose")));
taskId, evidenceId, principal.id(), (String) body.get("purpose")));
}
/**
@@ -32,6 +32,7 @@ import java.time.Instant;
import java.util.*;
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
@@ -280,7 +281,7 @@ public class ReviewService {
public ReviewTask resubmit(String userId) {
// 找到最新的需修改任务
ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId)
.orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到可重新提交的审核任务"));
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
@@ -7,6 +7,7 @@ import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -58,12 +59,26 @@ public class JwtAuthFilter extends OncePerRequestFilter {
}
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
} else {
// 浏览器同源请求:管理端登录后由 HttpOnly Cookie(dy_op_at)携带 access token。
// 作为 Authorization 头的兜底(抗 XSS 读取;小程序/用户端仍走 Authorization 头,不受影响)。
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("dy_op_at".equals(c.getName())) {
token = c.getValue();
break;
}
}
}
}
if (token == null) {
chain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtTokenService.validateToken(token)) {
sendUnauthorized(response);
return;
@@ -12,10 +12,15 @@ import java.util.UUID;
/**
* Mock 实名认证 Provider
* 开发/测试环境使用,生产环境替换为正式 Provider
* 开发/测试环境使用,生产环境替换为正式 Provider。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code identity.verification.provider},
* 由 {@link RealIdentityVerificationProvider} 承接 {@code real} 取值;本类保留
* {@code matchIfMissing = true},未显式配置时本地默认仍是 Mock。生产必须在
* {@code application-production.yml} 显式置 {@code identity.verification.provider=real}。
*/
@Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class);
@@ -0,0 +1,190 @@
package com.danyang.match.platform.verification.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 真实实名认证 Provider(生产环境使用)。
*
* <p>由配置 {@code identity.verification.provider=real} 激活;未显式配置时默认是 Mock
* (见 {@link MockIdentityVerificationProvider}),因此生产环境必须在
* {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>核验策略(fail-closed,绝不静默放行):
* <ul>
* <li>本地强校验:身份证号 GB 11643-1999 校验位(mod 11-2)。校验位不符直接判定未通过,
* 在发起任何外部调用前拦截明显伪造的证件号。</li>
* <li>第三方核验:必须配置 {@code identity.verification.real.api-url} 与
* {@code identity.verification.real.api-key}(环境变量 {@code VERIFICATION_API_URL} /
* {@code VERIFICATION_API_KEY})。未配置即 fail-fast —— 真实核验无法凭空完成,
* 绝不能回退成「放行」。</li>
* <li>任何无法判定为明确的「核验通过」的响应,一律按「未通过」处理(fail-closed)。</li>
* <li>绝不把身份证号原文作为日志明文输出;仅记录脱敏值。</li>
* </ul>
*
* <p>对接说明:第三方请求/响应契约需在接入具体厂商(如公安实名、运营商三要素)时最终确认。
* 当前实现以「明确的 success/match/verified/passed 布尔为 true」判定通过,其余一律视为未通过。
*/
@Component
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "real")
public class RealIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(RealIdentityVerificationProvider.class);
/** 身份证号校验位权重(GB 11643-1999,前 17 位) */
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
/** 校验位映射:索引 = 加权和 mod 11;'X' 表示余数 2 */
private static final String CHECK_CODES = "10X98765432";
@Value("${identity.verification.real.api-url:}")
private String apiUrl;
@Value("${identity.verification.real.api-key:}")
private String apiKey;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, Object> verify(String realName, String idNumber) {
if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意证件
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "实名核验未配置第三方核验服务");
}
if (realName == null || realName.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "实名认证失败:缺少姓名");
}
// 1. 本地校验位强校验:伪造证件号在外部调用前即拒绝
if (!isValidChecksum(idNumber)) {
log.warn("[Identity] 身份证校验位不合法: idNumber={}", maskIdNumber(idNumber));
return buildResult(false, idNumber, realName, null, null, UUID.randomUUID().toString());
}
LocalDate birthDate = parseBirthDateFromIdNumber(idNumber);
String gender = parseGenderFromIdNumber(idNumber);
// 2. 第三方核验(fail-closed)
boolean matched = callThirdParty(realName, idNumber);
return buildResult(matched, idNumber, realName, birthDate.toString(), gender, UUID.randomUUID().toString());
}
/**
* 调用第三方实名核验。仅当响应中明确出现「通过」语义的布尔字段为 true 时才返回 true;
* 其余(缺字段、字段为 false、解析失败、网络异常)一律返回 false(fail-closed)。
*/
private boolean callThirdParty(String realName, String idNumber) {
try {
Map<String, Object> body = new HashMap<>();
body.put("realName", realName);
body.put("idNumber", idNumber);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> resp = restTemplate.postForEntity(apiUrl, entity, String.class);
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
log.warn("[Identity] 第三方核验返回非成功状态: status={}", resp.getStatusCode());
return false;
}
JsonNode node = objectMapper.readTree(resp.getBody());
return isExplicitlyVerified(node);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Identity] 第三方核验调用异常", e);
return false;
}
}
/** 仅在响应中明确的「通过」布尔字段为 true 时判定通过。 */
private boolean isExplicitlyVerified(JsonNode node) {
for (String field : new String[]{"success", "match", "verified", "passed", "isValid"}) {
JsonNode v = node.get(field);
if (v != null && v.isBoolean() && v.asBoolean()) {
return true;
}
}
// 兼容 { "code": 0, "message": "ok" } 形态
JsonNode code = node.get("code");
if (code != null && code.isNumber() && code.asInt() == 0) {
return true;
}
return false;
}
private Map<String, Object> buildResult(boolean verified, String idNumber, String realName,
String birthDate, String gender, String requestId) {
Map<String, Object> result = new HashMap<>();
result.put("verified", verified);
result.put("realName", realName);
result.put("idNumberHash", idNumber);
result.put("birthDate", birthDate);
result.put("gender", gender);
result.put("requestId", requestId);
return result;
}
@Override
public boolean isMock() {
return false;
}
/** GB 11643-1999 校验位(mod 11-2)。 */
private boolean isValidChecksum(String idNumber) {
if (idNumber == null || idNumber.length() != 18) {
return false;
}
if (!idNumber.substring(0, 17).matches("\\d{17}")) {
return false;
}
char last = idNumber.charAt(17);
if (!(Character.isDigit(last) || last == 'X' || last == 'x')) {
return false;
}
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += (idNumber.charAt(i) - '0') * WEIGHTS[i];
}
char expected = CHECK_CODES.charAt(sum % 11);
return Character.toUpperCase(last) == expected;
}
private LocalDate parseBirthDateFromIdNumber(String idNumber) {
try {
int year = Integer.parseInt(idNumber.substring(6, 10));
int month = Integer.parseInt(idNumber.substring(10, 12));
int day = Integer.parseInt(idNumber.substring(12, 14));
return LocalDate.of(year, month, day);
} catch (Exception e) {
return LocalDate.now().minusYears(25);
}
}
private String parseGenderFromIdNumber(String idNumber) {
int genderDigit = Integer.parseInt(idNumber.substring(16, 17));
return (genderDigit % 2 == 1) ? "male" : "female";
}
private String maskIdNumber(String idNumber) {
if (idNumber == null || idNumber.length() < 10) return "****";
return idNumber.substring(0, 6) + "**********" + idNumber.substring(idNumber.length() - 4);
}
}
@@ -24,6 +24,7 @@ import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -136,7 +137,7 @@ public class IdentityVerificationService {
*/
public Map<String, Object> getIdentityStatus(String userId) {
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
return userIdentityMapper.findByUserId(userId)
.map(identity -> {
@@ -212,7 +213,7 @@ public class IdentityVerificationService {
@Transactional
public void cancelIdentity(String userId) {
UserIdentity identity = userIdentityMapper.findByUserId(userId)
.orElseThrow(() -> new RuntimeException("未找到实名认证记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到实名认证记录"));
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
@@ -38,11 +38,18 @@ object-storage:
region: us-east-1
signature-version: s3v4
# 实名核验:docker 本地/CI 默认走 Mock(VERIFICATION_PROVIDER=real 时启用真实实现,需同时配置 api-url/api-key)
identity:
verification:
provider: ${VERIFICATION_PROVIDER:mock}
real:
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:docker 内部为 http,关闭 Secure 标志
app:
auth-cookie-secure: false
match:
rule-version: 1
default-daily-limit: 6
@@ -14,3 +14,27 @@ spring:
user:
name: disabled
password: ${SPRING_SECURITY_FALLBACK_PASSWORD}
# 微信登录:生产必须显式启用真实实现(wechat.login.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealWechatLoginProvider 不会激活、
# MockWechatLoginProvider 又因 havingValue=mock 不匹配 → 无 WechatLoginProvider Bean,启动即失败(fail-fast,不悄悄放行任意 code)。
wechat:
login:
provider: real
app-id: ${WECHAT_APP_ID}
app-secret: ${WECHAT_APP_SECRET}
# 实名核验:生产必须显式启用真实实现(identity.verification.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealIdentityVerificationProvider 不会激活、
# MockIdentityVerificationProvider 又因 havingValue=mock 不匹配 → 无 IdentityVerificationProvider Bean,启动即失败(fail-fast,不悄悄放行任意证件)。
# 真实核验还需配置第三方核验服务地址与密钥(缺失则核验请求 fail-fast)。
identity:
verification:
provider: real
real:
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:生产走 https,开启 Secure 标志(HttpOnly + SameSite=Lax 始终生效)
app:
auth-cookie-secure: true
@@ -0,0 +1,28 @@
-- ============================================================
-- V052: 补齐 matchmaker(公益红娘)角色权限
--
-- 发现方式:2026-10-09 搭建本地演示栈、创建红娘运营账号 wanghongniang 并授予
-- matchmaker 角色后发现,该角色在 role_permissions 中零条授权。
-- 用该账号登录后台,点「红娘服务」菜单加载 GET /operator/matchmaker/dashboard
-- 直接 403;同时前端菜单权限码(referral.manage / matchmaker.service.view)与后端
-- 真实 authority 码(matchmaker.service / matchmaker.view)不匹配,导致这两个菜单
-- 对所有角色(含 super_admin)被 visibleNavigation 过滤隐藏。
--
-- 根因:与 V043/V044 同族 —— 角色定义了、权限定义了,两者之间从未连上。
-- V015 只把全部权限授予 super_admin;matchmaker 等运营角色从未被授予任何权限。
--
-- 授权范围(最小权限原则,与后端 @PreAuthorize 严格对应):
-- * matchmaker.view —— 查看红娘服务(GET /operator/matchmaker/*)
-- * matchmaker.service —— 认领/推荐/跟进/完成红娘服务 + 转介管理(referral 控制器)
-- * matchmaker.assign —— 分配红娘服务
-- 后端 MatchmakerOperatorController / ReferralController 的 @PreAuthorize 仅要求这三条,
-- 故仅授予这三条,不扩权。
--
-- 幂等:ON CONFLICT (role_id, permission_id) DO NOTHING。
-- ============================================================
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000000-0000-0000-0000-000000000005', id
FROM permissions
WHERE code IN ('matchmaker.view', 'matchmaker.service', 'matchmaker.assign')
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -0,0 +1,92 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.service.ActivityInterestService;
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 活动互选确认的**行为型**回归守卫(INV-014 / INV-015)。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)发现遗留端点
* {@code POST /activities/{id}/interests/confirm} 仅校验「双向互选」就直调
* {@code RelationshipService.confirmActivityInterest} —— 后者是**不做任何同意校验**的
* 「建 Match」底层原语(见其实现:查无 active Match 即 createMatch)——
* 于是**单方调用即可创建 Match 并开启会话**,绕过「双方各自显式确认」的二阶段同意。
*
* <p>修复后本端点与 {@code /activities/{id}/mutual/*} 共用
* {@link ActivityMutualConsentService}:本端点只登记「我方确认」,
* 只有双方都 accepted 才建 Match。本测试把这一接线钉死 ——
* 若有人把直连 {@code confirmActivityInterest} 的捷径改回来,这里会红。
*/
class ActivityInterestConfirmConsentBehaviorTest {
private ActivityInterestService interestService;
private ActivityMutualConsentService consentService;
private ActivityInterestController controller;
private static final String ACTIVITY = "act-1";
private static final String ME = "u-a";
private static final String PARTNER = "u-b";
@BeforeEach
void setUp() {
interestService = mock(ActivityInterestService.class);
consentService = mock(ActivityMutualConsentService.class);
controller = new ActivityInterestController(interestService, consentService, mock(ActivityMapper.class));
}
@Test
@DisplayName("INV-014/015:单方调用 confirm 只登记『我方确认』,不得直接建 Match")
void legacyConfirmOnlyRecordsMyOwnConsent() {
when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(true);
// 二阶段同意服务回「对方尚未确认」→ 不得建 Match
when(consentService.confirmConnection(ME, ACTIVITY, PARTNER))
.thenReturn(Map.of("myStatus", "accepted", "partnerStatus", "pending",
"bothConfirmed", false, "matchCreated", false));
ApiResponse<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER));
assertTrue(resp.isSuccess(), "调用本身应成功(我方确认已登记)");
assertEquals(Boolean.FALSE, resp.getData().get("matchCreated"),
"对方未确认时不得创建 Match —— 单方不可开启沟通");
assertEquals(Boolean.FALSE, resp.getData().get("bothConfirmed"));
// 关键接线:必经二阶段同意服务(幂等建档 + 提交我方确认)
verify(consentService).createMutualConsent(ACTIVITY, ME, PARTNER);
verify(consentService).confirmConnection(ME, ACTIVITY, PARTNER);
}
@Test
@DisplayName("判别力:无双向互选时直接拒绝,且完全不触碰同意服务")
void noMutualInterestIsRejectedWithoutTouchingConsent() {
when(interestService.hasMutualInterest(ME, PARTNER, ACTIVITY)).thenReturn(false);
ApiResponse<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", PARTNER));
assertFalse(resp.isSuccess(), "无双向互选不得进入确认环节");
verifyNoInteractions(consentService);
}
@Test
@DisplayName("partnerId 为空 → 400 语义拒绝,且不触碰同意服务")
void blankPartnerIdIsRejected() {
ApiResponse<Map<String, Object>> resp =
controller.confirmInterest(new UserPrincipal(ME), ACTIVITY, Map.of("partnerId", " "));
assertFalse(resp.isSuccess());
verifyNoInteractions(consentService);
verify(interestService, never()).hasMutualInterest(anyString(), anyString(), anyString());
}
}
@@ -0,0 +1,98 @@
package com.danyang.match.platform.auth.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:刷新 Token 在 Redis 中登记的 TTL 必须与 refresh JWT 自身有效期一致
* ({@code JwtTokenService#getRefreshExpirationMs},默认 14 天),而不是
* {@code getExpirationMs() * 2}(4 小时)。
*
* <p>此前 AuthService 用 {@code getExpirationMs() * 2} 写入,导致 4h 后 Redis 记录消失、
* 刷新即失效,用户被迫频繁重新登录。本测试钉死两处写入点(登录 / 刷新)。
*/
@ExtendWith(MockitoExtension.class)
class AuthServiceRefreshTtlTest {
@Mock private UserMapper userMapper;
@Mock private WechatLoginProvider wechatLoginProvider;
@Mock private JwtTokenService jwtTokenService;
@Mock private StringRedisTemplate redisTemplate;
@Mock private com.danyang.match.platform.audit.mapper.AuditLogMapper auditLogMapper;
@Mock private com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper localEligibilityMapper;
@Mock private com.danyang.match.platform.capability.service.CapabilityService capabilityService;
@Mock private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
@Mock private com.danyang.match.platform.review.mapper.ReviewTaskMapper reviewTaskMapper;
@Mock private ValueOperations<String, String> valueOps;
@InjectMocks private AuthService authService;
private static final long ACCESS_MS = 7_200_000L; // 2h
private static final long REFRESH_MS = 1_209_600_000L; // 14d
/** 捕获 refresh token 在 Redis 中的登记 TTL。 */
private Duration captureRefreshTtl() {
ArgumentCaptor<Duration> cap = ArgumentCaptor.forClass(Duration.class);
verify(valueOps).set(eq("auth:refresh_token:u1"), anyString(), cap.capture());
return cap.getValue();
}
@Test
void wechatLoginStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(wechatLoginProvider.getCodeSession("code")).thenReturn(Map.of("openid", "o1"));
User user = new User();
user.setId("u1");
user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
user.setPhone(null);
when(userMapper.findByWechatOpenid("o1")).thenReturn(Optional.of(user));
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("access");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("refresh");
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
when(auditLogMapper.insert(any())).thenReturn(1);
when(userMapper.updateById(any())).thenReturn(1);
authService.wechatLogin("code");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "登录:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
@Test
void refreshTokenStoresRefreshTokenWithRefreshExpiration() {
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(jwtTokenService.validateRefreshToken("rt")).thenReturn(true);
when(jwtTokenService.getUserIdFromRefreshToken("rt")).thenReturn("u1");
when(redisTemplate.opsForValue().get("auth:refresh_token:u1")).thenReturn("rt");
when(jwtTokenService.generateAccessToken("u1", null)).thenReturn("newAccess");
when(jwtTokenService.generateRefreshToken("u1")).thenReturn("newRefresh");
when(jwtTokenService.getRefreshExpirationMs()).thenReturn(REFRESH_MS);
authService.refreshToken("u1", "rt");
Duration ttl = captureRefreshTtl();
assertEquals(Duration.ofMillis(REFRESH_MS), ttl, "刷新:刷新 Token TTL 必须等于 refresh 有效期(14 天)");
assertNotEquals(Duration.ofMillis(ACCESS_MS * 2), ttl, "回归:不得再写成 access*2(4h)导致频繁登出");
}
}
@@ -44,7 +44,8 @@ class RecommendationEligibilityPhase06Test {
void setUp() {
config = new MatchRuleConfigProperties();
service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper,
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper);
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper,
new MatchScoringService());
}
@Test
@@ -0,0 +1,118 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
/**
* 评分唯一事实源的行为型测试。
*
* <p>存在意义:修复「推荐恒空」(2026-09-23 UAT 走查)之前的桩 `calculateScore()=50`
* 与阈值 60 使「满足条件的候选」也被丢弃,而既有测试只验阈值数学、从不验真实评分能达标 →
* 缺陷长期漏网。本测试直接断言**满足条件的候选评分 ≥ 阈值**。
*/
class MatchScoringServiceTest {
private final MatchScoringService svc = new MatchScoringService();
private final ObjectMapper mapper = new ObjectMapper();
private static final int THRESHOLD = 60; // 与 match.default-score-threshold 默认值一致
private UserProfile profile(int ageYears, Integer heightCm, String region, String marital) {
UserProfile p = new UserProfile();
p.setBirthDate(LocalDate.now().minusYears(ageYears));
p.setHeightCm(heightCm);
p.setRegionCode(region);
p.setMaritalStatus(marital);
return p;
}
@Test
@DisplayName("修复回归:完全符合条件的候选,评分必须 ≥ 阈值(不再恒 50)")
void matchingCandidateScoresAtLeastThreshold() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
pref.setMinHeightCm(160);
pref.setMaxHeightCm(175);
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
UserProfile candidate = profile(30, 168, "321181", "never_married");
double score = svc.scoreDirectionally(pref, candidate);
assertTrue(score >= THRESHOLD,
"满足全部条件的候选必须过阈值,实际:" + score);
}
@Test
@DisplayName("反向:条件明显不符的候选,评分低于阈值(证明有判别力)")
void nonMatchingCandidateBelowThreshold() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
pref.setMinHeightCm(160);
pref.setMaxHeightCm(175);
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
// 年龄 48(远超)、身高 150(不足)、地区 320500、离异 —— 四个维度全不符
UserProfile candidate = profile(48, 150, "320500", "divorced");
double score = svc.scoreDirectionally(pref, candidate);
assertTrue(score < THRESHOLD, "全不符的候选必须被过滤,实际:" + score);
}
@Test
@DisplayName("未设置任何条件 → 视为无约束,返回 100")
void noCriteriaMeansNoConstraint() {
assertEquals(100.0, svc.scoreDirectionally(new UserPreference(), profile(30, 170, "321181", "never_married")),
0.001);
}
@Test
@DisplayName("候选人身高缺失 → 身高维度计 0(不假定满足),且不阻断其他维度")
void missingCandidateHeightCountsZero() {
UserPreference pref = new UserPreference();
pref.setMinHeightCm(170);
pref.setMaxHeightCm(180);
UserProfile candidate = profile(30, null, "321181", "never_married");
double score = svc.scoreDirectionally(pref, candidate);
assertEquals(0.0, score, 0.001, "唯一条件是身高而资料缺失 → 0 分");
}
@Test
@DisplayName("INV-001:双向 Required 任一不满足即否")
void bidirectionalRequiredEnforced() {
UserPreference myPref = new UserPreference();
myPref.setAgePriority("required");
myPref.setMinAge(25);
myPref.setMaxAge(30);
UserPreference targetPref = new UserPreference(); // 无 Required
UserProfile me = profile(30, 175, "321181", "never_married");
UserProfile inRange = profile(28, 170, "321181", "never_married");
UserProfile outOfRange = profile(35, 170, "321181", "never_married");
assertTrue(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, inRange));
assertFalse(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, outOfRange));
}
@Test
@DisplayName("推荐理由基于实际达成维度,且非空")
void reasonsReflectMatchedDimensions() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
var reasons = svc.reasons(pref, profile(30, 170, "321181", "never_married"));
assertFalse(reasons.isEmpty());
assertTrue(reasons.stream().anyMatch(r -> r.contains("年龄")), "应包含年龄维度理由:" + reasons);
}
}
@@ -0,0 +1,196 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
import com.danyang.match.platform.match.dto.RecommendationCandidateDTO;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 推荐链路的<b>行为型</b>回归守卫。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)在本机真实栈实测
* {@code GET /recommendations} 恒返回 {@code {"data":[]}}。根因是评分桩
* {@code calculateScore()=50} 而阈值默认 60({@code 50 < 60} 恒真)→ 所有候选被丢弃。
* 该缺陷逃过了 757 个后端用例 + 65 个 E2E + 22 个 UI 守卫,因为
* <b>没有任何用例断言「推荐非空」</b>(E2E 零推荐用例)。
*
* <p>本测试在<b>引擎层</b>(候选池查询 → 双向 Required → 评分 → 阈值过滤 → 排序 → 落库)
* 断言「存在合格候选时必须产出非空推荐」,并配一条反向用例证明其有判别力。
* 这是此类「恒空」缺陷的直接防线 —— 桩式/纯配置断言抓不到它。
*/
class RecommendationEngineServiceBehaviorTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String ME = "u-me";
private static final String CANDIDATE = "u-cand";
private UserProfileMapper userProfileMapper;
private UserPreferenceMapper preferenceMapper;
private RecommendationRecordMapper recommendationRecordMapper;
private MatchRuleConfigProperties matchConfig;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private UserRelationMapper relationMapper;
private UserMapper userMapper;
private UserPrivacySettingsMapper privacySettingsMapper;
private LocalEligibilityMapper localEligibilityMapper;
private RecommendationEngineService engine;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
userProfileMapper = mock(UserProfileMapper.class);
preferenceMapper = mock(UserPreferenceMapper.class);
recommendationRecordMapper = mock(RecommendationRecordMapper.class);
matchConfig = mock(MatchRuleConfigProperties.class);
redisTemplate = mock(StringRedisTemplate.class);
valueOps = mock(ValueOperations.class);
relationMapper = mock(UserRelationMapper.class);
userMapper = mock(UserMapper.class);
privacySettingsMapper = mock(UserPrivacySettingsMapper.class);
localEligibilityMapper = mock(LocalEligibilityMapper.class);
// 用**真实**评分实现(纯函数、无依赖)→ 端到端证明「真实评分能让推荐非空」。
// 若用 mock 掉评分,本测试就退化成「只验接线」,无法守住「评分恒 50」这类回归。
MatchScoringService scoring = new MatchScoringService();
engine = new RecommendationEngineService(
userProfileMapper, preferenceMapper, recommendationRecordMapper,
matchConfig, redisTemplate, relationMapper, userMapper,
privacySettingsMapper, localEligibilityMapper, scoring);
// —— 匹配规则配置(与 application-dev.yml 默认一致)——
when(matchConfig.getDefaultDailyLimit()).thenReturn(6);
when(matchConfig.getDefaultScoreThreshold()).thenReturn(60);
when(matchConfig.getDefaultCooldownDays()).thenReturn(180);
when(matchConfig.getRuleVersion()).thenReturn(1);
when(matchConfig.isLocalEligibilityRequired()).thenReturn(false);
// —— Redis:缓存未命中 ——
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.get(anyString())).thenReturn(null);
// —— 双方账号均正常、可被推荐、无隐私限制 ——
when(userMapper.findById(ME)).thenReturn(Optional.of(user(ME)));
when(userMapper.findById(CANDIDATE)).thenReturn(Optional.of(user(CANDIDATE)));
when(privacySettingsMapper.findByUserId(anyString())).thenReturn(Optional.empty());
// —— 资料 ——
when(userProfileMapper.findByUserId(ME)).thenReturn(Optional.of(myProfile()));
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(candidateProfile()));
when(userProfileMapper.selectList(any())).thenReturn(List.of(candidateProfile()));
// —— 择偶条件:自己设了条件;候选人未设(方向 B→A 不受约束)——
when(preferenceMapper.findByUserId(ME)).thenReturn(Optional.of(myPreference()));
when(preferenceMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(new UserPreference()));
// —— 无历史推荐 / 无 Pass / 无拉黑 ——
when(recommendationRecordMapper.findRecentByUserId(anyString(), any())).thenReturn(List.of());
when(relationMapper.findBlockedByUser(anyString())).thenReturn(List.of());
when(relationMapper.findByRelation(anyString(), anyString(), anyString())).thenReturn(Optional.empty());
}
@Test
@DisplayName("回归守卫:存在合格候选 → 推荐必须非空(此前评分桩 50<60 导致恒空)")
void recommendationsAreNotEmptyWhenEligibleCandidateExists() {
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
assertFalse(result.isEmpty(),
"存在满足条件的候选人时,推荐不得为空 —— 这正是 UAT 实测暴露的缺陷");
assertEquals(CANDIDATE, result.get(0).getUserId());
assertNotNull(result.get(0).getRecommendationReasons());
assertFalse(result.get(0).getRecommendationReasons().isEmpty(), "应带中文推荐理由");
assertEquals("high", result.get(0).getRecommendationLevel(),
"完全匹配的候选应落在 high 档(评分 100)");
}
@Test
@DisplayName("判别力:候选各维度均不符 → 被阈值过滤,推荐为空")
void ineligibleCandidateIsFilteredOut() {
// 候选人年龄 48 / 身高 150 / 地区 320500 / 离异 —— 与择偶条件全不符
UserProfile bad = candidateProfile();
bad.setBirthDate(LocalDate.now().minusYears(48));
bad.setHeightCm(150);
bad.setRegionCode("320500");
bad.setMaritalStatus("divorced");
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(bad));
when(userProfileMapper.selectList(any())).thenReturn(List.of(bad));
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
assertTrue(result.isEmpty(), "全维度不符的候选必须被阈值过滤(证明评分确有判别力)");
}
// ==================== fixtures ====================
private User user(String id) {
User u = new User();
u.setId(id);
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
u.setRecommendationEnabled(true);
return u;
}
private UserProfile myProfile() {
UserProfile p = new UserProfile();
p.setUserId(ME);
p.setGender("male");
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
p.setBirthDate(LocalDate.now().minusYears(33));
p.setHeightCm(175);
p.setRegionCode("321181");
p.setMaritalStatus("never_married");
return p;
}
private UserProfile candidateProfile() {
UserProfile p = new UserProfile();
p.setUserId(CANDIDATE);
p.setGender("female");
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
p.setBirthDate(LocalDate.now().minusYears(30));
p.setHeightCm(168);
p.setRegionCode("321181");
p.setMaritalStatus("never_married");
return p;
}
/** 四个维度均设条件,且默认权重(age .15 / height .05 / region .20 / marital .10)。 */
private UserPreference myPreference() {
UserPreference p = new UserPreference();
p.setMinAge(25);
p.setMaxAge(35);
p.setMinHeightCm(160);
p.setMaxHeightCm(175);
p.setRegionScope(MAPPER.valueToTree(new String[]{"321181"}));
p.setMaritalStatuses(MAPPER.valueToTree(new String[]{"never_married"}));
return p;
}
}
@@ -0,0 +1,69 @@
package com.danyang.match.platform.photo;
import com.amazonaws.services.s3.AmazonS3;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.photo.service.PhotoService;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import com.danyang.match.platform.user.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 回归守卫:照片直传落库(此前「传了白传」——upload-token 之后没有任何端点写 user_photos)。
* <ul>
* <li>非法的 fileKey(不归属本人)必须被归属护栏拒绝;</li>
* <li>对象尚未上传完成(存储不存在)必须被存在性护栏拒绝;</li>
* <li>合法且已上传的直传必须真正落库(写入 user_photos 记录)。</li>
* </ul>
*/
class PhotoServiceConfirmUploadTest {
private final UserPhotoMapper photoMapper = mock(UserPhotoMapper.class);
private final AmazonS3 amazonS3 = mock(AmazonS3.class);
private final FeatureFlagConfig featureFlags = mock(FeatureFlagConfig.class);
private final ProfilePolicy profilePolicy = mock(ProfilePolicy.class);
private final UserMapper userMapper = mock(UserMapper.class);
private final AmazonS3 amazonS3Signer = mock(AmazonS3.class);
private final PhotoService photoService =
new PhotoService(photoMapper, amazonS3, featureFlags, profilePolicy, userMapper, amazonS3Signer);
@Test
void illegalFileKeyRejected() {
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "evil/key.jpg", "avatar"));
assertEquals("非法的文件标识", ex.getMessage());
}
@Test
void missingObjectRejected() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/x.jpg"))).thenReturn(false);
BusinessException ex = assertThrows(BusinessException.class,
() -> photoService.confirmDirectUpload("u1", "photos/u1/x.jpg", "lifestyle"));
assertEquals("文件尚未上传完成", ex.getMessage());
}
@Test
void validUploadPersistsPhoto() {
when(amazonS3.doesObjectExist(any(), eq("photos/u1/p.jpg"))).thenReturn(true);
when(photoMapper.countActiveProfilePhotos("u1")).thenReturn(0L);
when(profilePolicy.getMaxPhotoCount()).thenReturn(6);
when(photoMapper.insert(any())).thenReturn(1);
UserPhoto photo = photoService.confirmDirectUpload("u1", "photos/u1/p.jpg", "lifestyle");
assertNotNull(photo, "直传必须真实落库,不得白传");
assertEquals("u1", photo.getUserId());
assertEquals("photos/u1/p.jpg", photo.getFileKey());
assertEquals("lifestyle", photo.getPhotoType());
assertEquals(Constants.REVIEW_STATUS_PENDING, photo.getReviewStatus());
verify(photoMapper).insert(any(UserPhoto.class));
}
}
@@ -0,0 +1,212 @@
package com.danyang.match.platform.profile.service;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.verification.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 资料 / 择偶条件保存的**行为型**回归守卫。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)在本机真实栈实测
* {@code PUT /me/preferences} → <b>409</b>({@code duplicate key ... user_preferences_pkey})、
* {@code PUT /me/profile} → <b>500</b>({@code null value in column "gender" violates not-null})。
* 根因是两处 service 无论记录是否存在都调 {@code insert()},且资料更新缺少
* {@code gender}/{@code birth_date}(库中 NOT NULL)的兜底。
*
* <p>该缺陷逃过了全部后端用例,因为**没有任何用例覆盖「二次保存」**(零引用
* {@code savePreference}/{@code updateProfile})。本测试直接断言:
* 「已有记录时走 update 而非 insert」+「NOT NULL 字段可被补齐」+「向导字段名真的落库」。
*/
class ProfileSaveUpsertBehaviorTest {
private UserProfileMapper userProfileMapper;
private UserPreferenceMapper userPreferenceMapper;
private UserPrivacySettingsMapper privacySettingsMapper;
private UserRelationMapper relationMapper;
private UserIdentityMapper userIdentityMapper;
private UserProfileService profileService;
private UserPreferenceService preferenceService;
private static final String USER = "u-1";
@BeforeEach
void setUp() {
userProfileMapper = mock(UserProfileMapper.class);
userPreferenceMapper = mock(UserPreferenceMapper.class);
privacySettingsMapper = mock(UserPrivacySettingsMapper.class);
relationMapper = mock(UserRelationMapper.class);
userIdentityMapper = mock(UserIdentityMapper.class);
profileService = new UserProfileService(
userProfileMapper, userPreferenceMapper, privacySettingsMapper,
relationMapper, userIdentityMapper);
preferenceService = new UserPreferenceService(userPreferenceMapper);
}
// ==================== 择偶条件:二次保存不再 409 ====================
@Test
@DisplayName("回归:择偶条件首次保存 → insert")
void savePreferenceInsertsWhenAbsent() {
when(userPreferenceMapper.findByUserId(USER)).thenReturn(Optional.empty());
preferenceService.savePreference(USER, Map.of("minAge", 25, "maxAge", 35));
verify(userPreferenceMapper).insert(any(UserPreference.class));
verify(userPreferenceMapper, never()).update(any(), any());
}
@Test
@DisplayName("回归:择偶条件二次保存 → 走 update(此前恒 insert 必撞 user_preferences_pkey)")
void savePreferenceUpdatesWhenPresent() {
UserPreference existing = new UserPreference();
existing.setUserId(USER);
existing.setMinAge(25);
when(userPreferenceMapper.findByUserId(USER)).thenReturn(Optional.of(existing));
UserPreference saved = preferenceService.savePreference(USER, Map.of("maxAge", 35));
verify(userPreferenceMapper, never()).insert(any());
verify(userPreferenceMapper).update(any(UserPreference.class), any());
assertEquals(35, saved.getMaxAge());
assertEquals(25, saved.getMinAge(), "未提供的字段应保持原值");
}
// ==================== 资料:二次保存不再 500 ====================
@Test
@DisplayName("回归:资料二次保存 → 走 updateById(此前恒 insert 必撞 user_profiles_pkey)")
void updateProfileUpdatesWhenPresent() {
UserProfile existing = new UserProfile();
existing.setUserId(USER);
existing.setGender("female");
existing.setBirthDate(LocalDate.of(1993, 2, 2));
when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.of(existing));
profileService.updateProfile(USER, Map.of("heightCm", 165));
verify(userProfileMapper, never()).insert(any());
verify(userProfileMapper).updateById(any(UserProfile.class));
}
@Test
@DisplayName("回归:首次建档时 gender/birthDate 从『已通过实名认证』补齐(库中为 NOT NULL)")
void updateProfileDerivesIdentityFields() {
when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty());
when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity()));
UserProfile saved = profileService.updateBasicProfile(USER, Map.of("heightCm", 170));
verify(userProfileMapper).insert(any(UserProfile.class));
assertEquals("male", saved.getGender(), "性别应取自实名认证记录");
assertEquals(LocalDate.of(1990, 1, 1), saved.getBirthDate(), "生日应取自实名认证记录");
assertEquals(Constants.PROFILE_STATUS_IN_PROGRESS, saved.getProfileStatus());
}
@Test
@DisplayName("判别力:未实名就建档 → 明确的 4xx 业务拒绝(而非数据库 NOT NULL 渲染成 500)")
void updateProfileRejectsWhenIdentityMissing() {
when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty());
when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.empty());
GlobalExceptionHandler.BusinessException ex = assertThrows(
GlobalExceptionHandler.BusinessException.class,
() -> profileService.updateProfile(USER, Map.of("heightCm", 170)));
assertTrue(ex.getMessage().contains("实名认证"), "应提示先完成实名认证:" + ex.getMessage());
verify(userProfileMapper, never()).insert(any());
}
// ==================== 字段契约:向导字段名真的落库 ====================
@Test
@DisplayName("向导字段名可用:hasChildren / occupationCategory / workRegion 均落库")
void wizardFieldNamesArePersisted() {
when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty());
when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity()));
Map<String, Object> payload = new HashMap<>();
payload.put("hasChildren", true);
payload.put("occupationCategory", "teacher");
payload.put("workRegion", "321181");
UserProfile saved = profileService.updateBasicProfile(USER, payload);
assertEquals("yes", saved.getChildrenStatus(), "hasChildren=true → childrenStatus='yes'");
assertEquals("teacher", saved.getOccupationCode());
assertEquals("321181", saved.getWorkRegionCode());
}
@Test
@DisplayName("向导其余字段落库:生活方式 / 意向 / 自我介绍(此前被静默丢弃)")
void wizardLifestyleFieldsArePersisted() {
when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty());
when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity()));
Map<String, Object> payload = new HashMap<>();
payload.put("smokingStatus", "never");
payload.put("drinkingStatus", "sometimes");
payload.put("relationshipGoal", "结婚");
payload.put("marriagePlan", "一年内");
payload.put("selfIntro", "喜欢阅读");
payload.put("longTermDanyang", "是");
payload.put("futureDanyang", "是");
UserProfile saved = profileService.updateBasicProfile(USER, payload);
assertEquals("never", saved.getSmokingStatus());
assertEquals("sometimes", saved.getDrinkingStatus());
assertEquals("结婚", saved.getRelationshipGoal());
assertEquals("一年内", saved.getMarriagePlan());
assertEquals("喜欢阅读", saved.getSelfIntro());
assertEquals("是", saved.getLongTermDanyang());
assertEquals("是", saved.getFutureDanyang());
}
@Test
@DisplayName("规范字段名优先且不受影响:childrenStatus / occupationCode / workRegionCode")
void canonicalFieldNamesStillWork() {
when(userProfileMapper.findByUserId(USER)).thenReturn(Optional.empty());
when(userIdentityMapper.findByUserId(USER)).thenReturn(Optional.of(verifiedIdentity()));
Map<String, Object> payload = new HashMap<>();
payload.put("childrenStatus", "none");
payload.put("occupationCode", "bachelor_of_engineering");
payload.put("workRegionCode", "321181");
UserProfile saved = profileService.updateBasicProfile(USER, payload);
assertEquals("none", saved.getChildrenStatus());
assertEquals("bachelor_of_engineering", saved.getOccupationCode());
assertEquals("321181", saved.getWorkRegionCode());
}
private UserIdentity verifiedIdentity() {
UserIdentity id = new UserIdentity();
id.setUserId(USER);
id.setGender("male");
id.setBirthDate(LocalDate.of(1990, 1, 1));
id.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
return id;
}
}
@@ -0,0 +1,148 @@
package com.danyang.match.platform.review.controller;
import com.danyang.match.platform.review.dto.ReviewResultDTO;
import com.danyang.match.platform.review.service.ReviewService;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ReviewController 请求体契约测试 —— 锁定 HTTP / JSON 反序列化层。
*
* <p>背景(2026-10-09 实测缺陷):admin-web 的审核决策统一发送
* {@code {comment, reasonCodes}},其中 {@code reasonCodes} 是**数组**。而 ReviewController 的
* {@code approve} / {@code need-evidence} / {@code approve-local-eligibility} /
* {@code reject-local-eligibility} 等端点此前把 {@code @RequestBody} 声明为
* {@code Map<String,String>},数组值无法反序列化成 String → Jackson 抛
* {@code HttpMessageNotReadableException} → 400「请求体格式错误」。表现为
* 「小程序提交资料后,管理员点『通过』直接报错」。
*
* <p><b>为什么用 standalone MockMvc 而不是 {@code @SpringBootTest}</b>:本缺陷发生在
* **参数解析 / 反序列化层**。先例 {@code BannerOperatorPhase19IntegrationTest} 直接
* {@code new} 控制器再调方法,**绕过了该层**,因此天然抓不到这类缺陷。standalone MockMvc
* 走真实 DispatcherServlet + 消息转换器 + ControllerAdvice,又无需 DB / Redis / 安全链,
* 确定性强、可离线跑。
*
* <p><b>判别力自证</b>:若有人把端点改回 {@code Map<String,String>},本测试立即变红(400)。
* 已用负向注入验证过(见提交说明)。
*/
class ReviewControllerRequestBodyContractTest {
private static final String OPERATOR_ID = "00000000-0000-0000-0000-000000000002";
private static final String TASK_ID = "t-0001";
/** 与 admin-web {@code ReviewView.vue#runDecision} 的实际请求体完全一致(含 reasonCodes 数组)。 */
private static final String FRONTEND_PAYLOAD = "{\"comment\":\"已核验\",\"reasonCodes\":[]}";
private ReviewService reviewService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
reviewService = Mockito.mock(ReviewService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new ReviewController(reviewService))
.setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(new OperatorPrincipalResolver())
.build();
}
private ReviewResultDTO stubResult(String result) {
ReviewResultDTO dto = new ReviewResultDTO();
dto.setTaskId(TASK_ID);
dto.setResult(result);
return dto;
}
@Test
void approveAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.approve(eq(TASK_ID), eq(OPERATOR_ID), any())).thenReturn(stubResult("approved"));
mockMvc.perform(post("/operator/reviews/{taskId}/approve", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("approved"));
}
@Test
void approveLocalEligibilityAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.approveLocalEligibility(eq(TASK_ID), eq(OPERATOR_ID), any()))
.thenReturn(stubResult("approved"));
mockMvc.perform(post("/operator/reviews/{taskId}/approve-local-eligibility", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("approved"));
}
@Test
void rejectLocalEligibilityAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.rejectLocalEligibility(eq(TASK_ID), eq(OPERATOR_ID), any()))
.thenReturn(stubResult("rejected"));
mockMvc.perform(post("/operator/reviews/{taskId}/reject-local-eligibility", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("rejected"));
}
@Test
void needEvidenceAcceptsCommentAndReasonCodesArray() throws Exception {
Mockito.when(reviewService.needEvidence(eq(TASK_ID), eq(OPERATOR_ID), any()))
.thenReturn(stubResult("need_evidence"));
mockMvc.perform(post("/operator/reviews/{taskId}/need-evidence", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content(FRONTEND_PAYLOAD))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.result").value("need_evidence"));
}
/** 只带 comment、不带 reasonCodes 也必须可用(前端未选原因标签时的实际载荷)。 */
@Test
void approveWithoutReasonCodesStillWorks() throws Exception {
Mockito.when(reviewService.approve(eq(TASK_ID), eq(OPERATOR_ID), any())).thenReturn(stubResult("approved"));
mockMvc.perform(post("/operator/reviews/{taskId}/approve", TASK_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"comment\":\"通过\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
/** 提供 {@code @AuthenticationPrincipal OperatorPrincipal} 的解析器(standalone 无安全链)。 */
private static final class OperatorPrincipalResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return OperatorPrincipal.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
return new OperatorPrincipal(OPERATOR_ID);
}
}
}
@@ -18,7 +18,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 不变量:main 源码里不得出现裸的 {@code throw new RuntimeException(...)}。
* 不变量:main 源码里不得出现裸的 {@code new RuntimeException(...)}
* ——无论写成 {@code throw new RuntimeException(...)} 还是
* {@code orElseThrow(() -> new RuntimeException(...))}(2026-09-23 加宽判据)。
*
* <p><b>为什么这是一个需要测试来守的约束</b>:裸 {@code RuntimeException} 没有携带
* HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成
@@ -42,13 +44,20 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class RuntimeExceptionThrowGuardTest {
/**
* 允许 {@code throw} 与 {@code new} 之间有任意空白,以便跨行书写也被咬住。
* 咬住**任何**构造裸 {@code RuntimeException} 的写法,不限于 {@code throw} 关键字。
*
* <p><b>2026-09-23 判据加宽</b>:原正则为
* {@code throw\s+new\s+RuntimeException\s*\(},要求 {@code throw} 紧邻 {@code new}。
* 于是整类 <b>Supplier 写法</b> {@code orElseThrow(() -> new RuntimeException("..."))}
* 逃逸(其关键字是 {@code ->} 而非 {@code throw})—— 实测 17 处
* 「用户 / 关系 / 报名记录不存在」被静默兜底成 <b>500</b>,而本守卫与 CI 仍全绿。
* 判据改为「有没有构造裸 RuntimeException」后,这类逃逸在结构上不再可能。
*
* <p>{@code IllegalStateException} / {@code IllegalArgumentException} 不在守卫范围内:
* 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。
*/
private static final Pattern BARE_THROW =
Pattern.compile("throw\\s+new\\s+RuntimeException\\s*\\(");
private static final Pattern BARE_RUNTIME_NEW =
Pattern.compile("new\\s+RuntimeException\\s*\\(");
private static final Path MAIN_SOURCES = Paths.get("backend/src/main/java");
@@ -71,15 +80,15 @@ class RuntimeExceptionThrowGuardTest {
String content = Files.readString(file, StandardCharsets.UTF_8);
String[] lines = stripComments(content).split("\n", -1);
for (int i = 0; i < lines.length; i++) {
if (BARE_THROW.matcher(lines[i]).find()) {
if (BARE_RUNTIME_NEW.matcher(lines[i]).find()) {
offenders.add(file + ":" + (i + 1) + " " + lines[i].trim());
}
}
}
assertTrue(offenders.isEmpty(),
"发现 " + offenders.size() + " 处裸抛 RuntimeException。"
+ "业务拒绝请改成 new BusinessException(ApiResponseCode.XXX, \"文案\"),"
"发现 " + offenders.size() + " 处裸抛/裸构造 RuntimeException(含 orElseThrow 的 Supplier 写法)。"
+ "业务拒绝请改成 new GlobalExceptionHandler.BusinessException(ApiResponseCode.XXX, \"文案\"),"
+ "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n"
+ String.join("\n", offenders));
}
@@ -92,6 +101,7 @@ class RuntimeExceptionThrowGuardTest {
/* throw new RuntimeException("块注释里的也不应命中"); */
if (x == null) throw new RuntimeException("代码里的必须命中");
throw new RuntimeException("跨空格的必须命中");
return repo.findByUserId(id).orElseThrow(() -> new RuntimeException("Supplier 写法必须命中"));
throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常");
throw new ServerErrorException("服务端故障");
""";
@@ -99,14 +109,16 @@ class RuntimeExceptionThrowGuardTest {
String[] lines = stripComments(sample).split("\n", -1);
int hits = 0;
for (String line : lines) {
if (BARE_THROW.matcher(line).find()) {
if (BARE_RUNTIME_NEW.matcher(line).find()) {
hits++;
}
}
// 这条断言保证上面那个「零裸抛」不是恒真的空断言:
// 如果正则或注释剥离写坏(例如把代码也当成注释剥掉),这条会先红。
assertTrue(hits == 2, "应当且只应当命中 2 处裸抛(含同行 if 与多空格写法),实际命中 " + hits);
// 3 处 = 同行 if 写法 + 多空格写法 + orElseThrow Supplier 写法
// (最后一种正是 2026-09-23 加宽判据后才被咬住的逃逸类型)。
assertTrue(hits == 3, "应当且只应当命中 3 处裸抛,实际命中 " + hits);
}
/**
@@ -188,7 +200,7 @@ class RuntimeExceptionThrowGuardTest {
/** 供上面两条断言共享的调试入口:确认正则本身没写坏。 */
private static boolean matches(String code) {
Matcher m = BARE_THROW.matcher(code);
Matcher m = BARE_RUNTIME_NEW.matcher(code);
return m.find();
}
@@ -16,6 +16,7 @@ import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.servlet.http.Cookie;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@@ -98,4 +99,77 @@ class JwtAuthFilterTest {
request.addHeader("Authorization", "Bearer " + token);
return request;
}
// ===== 本轮修复回归:管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 =====
@Test
void cookieFallbackAuthenticatesOperatorWhenNoHeader() throws Exception {
String token = "cookie-op-token";
when(jwtTokenService.validateToken(token)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("operator-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("operator_access");
when(permissionService.getPermissions("operator-1")).thenReturn(List.of("content.edit"));
filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(OperatorPrincipal.class, authentication.getPrincipal());
assertEquals("operator-1", ((OperatorPrincipal) authentication.getPrincipal()).id());
}
@Test
void cookieFallbackAuthenticatesUserWhenNoHeader() throws Exception {
String token = "cookie-user-token";
when(jwtTokenService.validateToken(token)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(token)).thenReturn("user-1");
when(jwtTokenService.parseToken(token)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access");
filter.doFilter(cookieRequest(token), new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(UserPrincipal.class, authentication.getPrincipal());
}
@Test
void cookieWithInvalidTokenReturns401() throws Exception {
String token = "bad-cookie-token";
when(jwtTokenService.validateToken(token)).thenReturn(false);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilter(cookieRequest(token), response, new MockFilterChain());
assertEquals(401, response.getStatus());
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
void headerTakesPrecedenceOverCookie() throws Exception {
// Authorization 头里是有效 user token;Cookie 里是无效 token,应被忽略(Cookie 仅作兜底)
String headerToken = "header-token";
when(jwtTokenService.validateToken(headerToken)).thenReturn(true);
when(jwtTokenService.getUserIdFromToken(headerToken)).thenReturn("user-1");
when(jwtTokenService.parseToken(headerToken)).thenReturn(claims);
when(claims.get("type", String.class)).thenReturn("access");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.addHeader("Authorization", "Bearer " + headerToken);
request.setCookies(new Cookie("dy_op_at", "bad-cookie"));
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
var authentication = SecurityContextHolder.getContext().getAuthentication();
assertNotNull(authentication);
assertInstanceOf(UserPrincipal.class, authentication.getPrincipal());
assertEquals("user-1", ((UserPrincipal) authentication.getPrincipal()).id());
}
private MockHttpServletRequest cookieRequest(String token) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/content/operator/articles");
request.setCookies(new Cookie("dy_op_at", token));
return request;
}
}
@@ -0,0 +1,110 @@
package com.danyang.match.platform.verification.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* RealIdentityVerificationProvider 回归守卫:
* <ul>
* <li>伪造身份证号(校验位不符)必须在发起任何外部调用前被本地强校验拦截;</li>
* <li>配置缺失(apiUrl / apiKey 为空)必须 fail-fast,绝不静默放行任意证件;</li>
* <li>第三方核验 fail-closed:响应未明确「通过」一律视为未通过。</li>
* </ul>
*
* <p>使用 JDK 内置 {@link HttpServer} 模拟第三方核验端点,无需任何额外依赖。
*/
class RealIdentityVerificationProviderTest {
// 按 GB 11643-1999 手工计算得到的合法校验位身份证(前 17 位 + 校验位 '8')
private static final String VALID_ID = "110101199003073458";
// 校验位应为 '8',此处写成 'X' → 非法
private static final String INVALID_ID = "11010119900307345X";
private HttpServer server;
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
private RealIdentityVerificationProvider newProvider(String apiUrl, String apiKey) throws Exception {
RealIdentityVerificationProvider p = new RealIdentityVerificationProvider();
setField(p, "apiUrl", apiUrl);
setField(p, "apiKey", apiKey);
return p;
}
private void setField(Object target, String name, String value) throws Exception {
Field f = RealIdentityVerificationProvider.class.getDeclaredField(name);
f.setAccessible(true);
f.set(target, value);
}
/** 启动本地模拟端点,返回其 URL。 */
private String startServer(int statusCode, String body) throws Exception {
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/verify", exchange -> {
byte[] resp = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(statusCode, resp.length);
exchange.getResponseBody().write(resp);
exchange.close();
});
server.start();
return "http://localhost:" + server.getAddress().getPort() + "/verify";
}
@Test
void forgedIdRejectedBeforeAnyNetworkCall() throws Exception {
// apiUrl / apiKey 仅作占位(非空,避免误触 fail-fast 分支);
// 校验位不符应直接判未通过,且不应发起任何外部请求
RealIdentityVerificationProvider provider = newProvider("http://localhost:1/verify", "k");
Map<String, Object> result = provider.verify("张三", INVALID_ID);
assertFalse((Boolean) result.get("verified"), "校验位不符的伪造证件号必须被本地强校验判定为未通过");
}
@Test
void missingConfigFailsFast() throws Exception {
RealIdentityVerificationProvider provider = newProvider("", "");
BusinessException ex = assertThrows(BusinessException.class,
() -> provider.verify("张三", VALID_ID));
assertEquals(ApiResponseCode.SERVER_ERROR, ex.getCode(),
"缺第三方配置时必须 fail-fast 而非放行任意证件");
}
@Test
void validIdWithThirdPartySuccessVerified() throws Exception {
String url = startServer(200, "{\"success\":true}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertTrue((Boolean) result.get("verified"), "校验位合法 + 第三方明确通过 = 通过");
}
@Test
void validIdWithThirdPartyFailureNotVerified() throws Exception {
String url = startServer(200, "{\"success\":false}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertFalse((Boolean) result.get("verified"), "第三方未明确「通过」 = 未通过(fail-closed)");
}
@Test
void validIdWithThirdPartyErrorNotVerified() throws Exception {
String url = startServer(500, "{\"message\":\"internal\"}");
RealIdentityVerificationProvider provider = newProvider(url, "k");
Map<String, Object> result = provider.verify("张三", VALID_ID);
assertFalse((Boolean) result.get("verified"), "第三方非 2xx 响应 = 未通过(fail-closed)");
}
}
+178
View File
@@ -0,0 +1,178 @@
# 善缘丹阳 代码审查报告
> 审查日期:2026-09-24
> 审查范围:后端(Java 21 / Spring Boot 3)、微信小程序(原生 + TS)、管理端(Vue 3 + Vite + Pinia)
> 审查视角:CodeReviewExpert — 正确性 / 安全 / 可维护 / 性能 / 测试
> 核验声明:下方 🔴 阻断 1–4 与 🟡 刷新 Token TTL 均由主审查员**直接读源码 + grep 复核**确认;其余条目为子代理带文件路径核查,结论可信但建议落地前再回读一次原文件。
---
## 一、总览
**总体印象**:本项目的工程纪律落地得相当扎实——幂等(`@Idempotent` + 唯一索引)、数据权限守卫(`DataScopeGuard` 真实接入多处)、WXML 结构守卫、请求层"先 resolve 再收尾"的时序守卫,都不是文档承诺而是真实代码。这是少见的"纪律工程化"水平。
**但存在 2 个已确认的上线阻塞**(与 UAT 走查结论一致):微信登录无真实实现、照片直传不落库;此外发现若干安全/正确性缺陷,其中管理端 token 存储、小程序红娘接单鉴权头错位、刷新 Token TTL 误用会直接影响可用性与安全。
**优先级**:上线前必须关闭 🔴 1–4;🟡 建议同批修复。
---
## 二、🔴 阻断(必须修,否则不能上线)
### 1. 微信登录生产默认走 Mock —— 任意 code 可无限建号
`backend/.../auth/provider/MockWechatLoginProvider.java:19`
```java
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider {
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
// :35
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID()...);
```
**为什么是问题**:`matchIfMissing = true` 意味着**未显式配置即启用 Mock**。`application-production.yml` 中并无 `verification.provider`(只有 logging/springdoc/security),故生产环境默认走 Mock。Mock 实现对任意 `code` 都生成随机 openid 并自动建号——攻击者可凭任意字符串无限注册假账号,且 `wechat.app-id` 默认 `dev-app-id`。这是实名/风控链路的根基性缺口。
**修复**:
- 新增 `RealWechatLoginProvider`(`@ConditionalOnProperty(... havingValue = "real")`),调用 `https://api.weixin.qq.com/sns/jscode2session` 用真实 `code` 换取 openid/session_key,并校验 appid/secret。
- 生产 `application-production.yml` 显式置 `verification.provider=real`,并**删除 `matchIfMissing=true`**(或仅留给本地 dev profile)。
- 接入 `code2session` 失败、空 openid、appid 不匹配的拒绝分支。
### 2. 照片直传不落库 —— 直传后无"确认/落库"端点
`backend/.../me/controller/MeController.java:227` 与 `photo/controller/PhotoController.java:32` 均暴露 `POST /.../photos/upload-token`,调用 `PhotoService.getUploadToken` 返回 MinIO **预签名 PUT URL + fileKey**(`PhotoService.java:75-86`)。但全仓**没有任何端点接收直传的 `fileKey` 并写 `user_photos`**:
- `savePhoto(...)` 仅被 `PhotoService.java:233-245` 的多段上传 `POST /photos/upload`(`MultipartFile` 后端代传)调用;
- `getSignedUrl`(`:96-99`)按 `user_photos` 校验归属:`boolean owned = userPhotos.stream().anyMatch(p -> fileKey.equals(p.getFileKey()))`。
**为什么是问题**:走直传路径时,对象进了 MinIO,但 `user_photos` 无记录 → `getPhotos` 不展示,**且 `getSignedUrl` 归属校验直接失败,连访问鉴权都过不了**。直传等于"传了白传"。这是已知阻塞的真实根因。
**修复**:新增 `POST /me/photos/confirm`(或 `POST /photos/confirm`),入参 `fileKey + photoType`,服务端校验 fileKey 属当前用户 + 对象已存在于 MinIO 后调用 `savePhoto`,并触发人工审核状态机。小程序侧直传成功后调用该确认端点。
### 3. 小程序红娘"接单"鉴权头错位 —— 请求实际无鉴权
`miniapp/utils/api.js:272`
```js
post(url, data, headers, idempotencyKey) { ... }
```
`miniapp/pages/matchmaker-workbench/matchmaker-workbench.js:85`
```js
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, {}, { Authorization: `Bearer ${token}` })
```
**为什么是问题**:第 4 个参数是 `idempotencyKey`,第 3 个 `headers` 被传成 `{}`。于是 `Authorization` 对象被塞进幂等键位,而请求头里**没有任何鉴权头**。且 operator 流程用的是 `operatorToken`(非用户 `accessToken`),`api.getToken()` 读不到它——所以本请求必然 401,红娘"接单"必失败。同文件 `:50-51` 用 `api.get(url, {}, {Authorization})` 三参形式才正确,两处不一致即证据。
**修复**:改为 `api.post(url, {}, { Authorization: \`Bearer ${token}\` })`(去掉第 4 参)。
### 4. 管理端 token 存 localStorage + 权限清单可被本地篡改
`admin-web/src/stores/auth.ts:26,45-46`
```ts
const token = ref<string>(... localStorage.getItem('accessToken') || '')
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value)) // 含 permissions
```
`admin-web/src/api/client.ts:11` 拦截器统一注入 `Bearer`,但另有 30+ 处 `.vue` 直接 `localStorage.getItem('accessToken')` 手工注入(如 `ReportCenterView.vue:127/137/155/165`、`OperatorManagementView.vue` 多处、`ActivityCheckinView.vue:114`)。
**为什么是问题**:
- 令牌落 localStorage + 项目内大量 `any` 与裸 `console.error(e)`,任意 XSS 即可 `localStorage.getItem('accessToken')` 取走令牌直连后端,**账号接管**。
- `operator`(含 `permissions`)序列化进 localStorage,前端 `router/index.ts` 的 `hasPermission` 与 `MainLayout.vue` 菜单过滤只比对本地产物——攻击者改 `localStorage.operator.permissions` 即可在前端解锁菜单/按钮(虽服务端仍 403,但**可见性完全由可被篡改的本地数据决定**,违反"前端不是安全边界")。
**修复**:
- 短期:令牌改内存态 + 短时效,刷新令牌走 httpOnly Cookie;删除各 `.vue` 的手工 `localStorage.getItem('accessToken')`,统一依赖 `client.ts` 拦截器。
- 权限可见性由服务端下发 capability 信封驱动(见 🟡 #4),前端不再以本地 `permissions` 数组作为显隐的唯一依据。
---
## 三、🟡 建议(应优先修,真实缺陷)
### 5. 刷新 Token 的 Redis TTL 用错 → 用户每 4 小时被强制登出
`backend/.../auth/service/AuthService.java:94`(登出同 :177-178)
```java
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
```
**为什么是问题**:`getExpirationMs()` 返回 **access** 过期(默认 2h),`*2 = 4h`;而 refresh JWT 自身有效期是 `refreshExpirationMs`(默认 14 天,`JwtTokenService.java:39/81`)。`refreshToken()` 第 3 步(:167-169)用 Redis 记录做可撤销校验,4h 后记录消失即抛 `UnauthorizedException` → **用户每 4h 被迫重新登录**。
**修复**:改为 `Duration.ofMillis(jwtTokenService.getRefreshExpirationMs())`(或新增对应 getter),使 Redis 记录生命周期与 refresh JWT 一致。
### 6. 手机号绑定无真实校验(TODO 残留)
`AuthService.java:113`:`// TODO: Phase 03 对接真实短信验证码`,`bindPhone` 仅查重后 `user.setPhone(phoneCode)`。结合 #1 Mock 登录,任何人可绑定任意手机号,属实名链路缺口。需补短信验证码校验。
### 7. 小程序 `bind-phone` 空桩 + operator 登录双轨清 token
- `pages/bind-phone/bind-phone.js` 为 `Page({ data:{}, onLoad(){}, onShow(){} })` 空实现,用户进页空白。
- `pages/matchmaker-login/matchmaker-login.js:39-51` 自建 `operatorToken` 并裸用 `wx.request` 绕过 `utils/api.js`;当 operator 接口回 401 时,`api.onSessionExpired` → `api.clearToken()` 清的是**用户** `accessToken/refreshToken`(api.js:113-120),会**误清用户登录态**。建议 operator 通道也走 `api` 层并隔离失效回调。
### 8. 小程序生产域名是占位符
`miniapp/env.js:27` / `env.ts:24`:`: 'https://api.danyang-match.example.com/api/v1'`。`example.com` 为保留占位域名,生产构建连不上真实后端。需按环境注入真实域名(构建变量 / 配置中心)。
### 9. 小程序 `serviceType` 枚举直渲未映射中文
`pages/matchmaker-workbench/matchmaker-workbench.wxml:59`
```xml
<view class="service-tag">{{item.serviceType === 'recommend_partner' ? '推荐对象' : item.serviceType}}</view>
```
除 `recommend_partner` 外,其它 `serviceType`(如 `red_line_prevention`)会把英文枚举码直接显示给用户,违反 `constants/profileLabels.js`"界面不得出现英文枚举码"铁律。需补 `serviceType` 中文映射表。
### 10. 管理端敏感处置缺二次确认
`src/modules/risk/ReportCenterView.vue:91` 对封禁/暂停(30天)等直接 `post(.../process)`,无 `ElMessageBox.confirm`;而 `UserDetailView.vue:113` 改账号状态有确认。同类高危操作应统一二次确认 + 审计提示。
### 11. 管理端取数/显隐仍由前端权限启发式决定(Capability 应服务端驱动)
`src/services/dashboard.ts:66-67,93-119` 用 `permissions.includes('analytics.view')` 决定请求接口并打 `available` 控制显隐;`MainLayout.vue:209-222` 用权限拼"角色名"。能力虽源自服务端权限串,但映射仍在前端硬编码。理想做法是由服务端下发 capability 信封驱动可见性与取数。
### 12. 管理端 `any` 滥用、跨端字段漂移、举报列表未分页
- `UserDetailView.vue:90`、`UserManagementView.vue:112`、`ReportCenterView.vue` 多处 `ref<any>`、`catch(e)` 未标注类型;跨端字段如 `phoneMasked/sensitiveMasked`、`totalLimit↔capacity` 缺共享 TS 接口,易产生字段名不一致。
- `ReportCenterView.vue:129` 列表 `res.data || []` 直接渲染、未分页,大举报量有性能隐患。
---
## 四、💭 细节(已良好 / 留意)
**✅ 已落实良好的部分(值得肯定)**
- 请求层时序守卫:`utils/api.js:180-198` 已实现"先 `resolve(body.data)` 再做 `releaseIdempotencyKey` 收尾",正是工程纪律中"私有方法放 resolve 前致所有 Promise 永不 settle"坑的工程化规避;`:140-142` 的 `isAuthEndpoint` 豁免 `/auth/*` 自我递归。
- 数据权限守卫真实落地:`DataScopeGuard.deny` 已接入 `CheckinService`、`CounselingService`(Counselor 仅看 assigned + 敏感访问审计)、`ActivityService`、`ServiceAppointmentService`,非注释。
- 幂等机制扎实:`@Idempotent` 覆盖所有写端点,`IdempotencyInterceptor` 用 DB 唯一索引 `uk_idempotency_scope` 占位防并发重复。
- 配置可配置化良好:`MatchRuleConfigProperties` 权重/阈值/冷却期全部外部化(`match.*`),符合"不得写死业务规则"。
- 管理端设计系统 token 收敛良好(`theme/index.css` / `tokens.ts` 为唯一色源,输入框已含 `Microsoft YaHei`)。
**⚠️ 仍需留意 / 复核**
- SQL 注入:`.java` 内 `grep \${` 命中均为 `@Value` 占位符,mapper 走 MyBatis-Plus `LambdaQueryWrapper`,未见 `${}` 拼接 SQL。**但**项目含 MyBatis,需再扫 `src/main/resources` 下 `*Mapper.xml` 原生 SQL(`@Select` 等)确认无 `${}`。
- `SecurityConfig` 的 `requestMatchers` 相对 context-path `/api/v1` 是否被正确剥离(已有 `SecurityPathCoverageTest` 守护,风险低,建议回读确认)。
- `HomeBannerOperatorController:153` 仅拦截 `http(s)` 外链,需确认是否只允许 `/pages/` 内部跳转而非任意 http 内部域(INV-016 Banner 不能任意外链)。
- 跨端字段名靠运行时契约(tsc 不保护):`stores/authStore.js` 注释自曝已三次踩坑(`accessToken` 非 `token`、`isNewUser` 非 `!needPhone`、`regionCode/createdAt` 等 camelCase)。建议对关键响应用 `result.accessToken || result.token` 兜底断言。
---
## 五、表扬
- **纪律工程化**做到位:把"WXML 结构、私有方法调用、组件选择器、请求层时序、幂等"等易踩坑点都做成了守护测试与行为契约,而不是靠人肉 review。这是本项目最值得保留的资产。
- **文档与代码分离意识强**:AGENTS.md 明确"文档✅不是需求,只有代码/测试/验收才证明完成",且本审查中也确实发现若干文档声明与代码现实不符(微信登录、照片落库),说明团队有"不信绿"的底线。
---
## 六、下一步建议(优先级排序)
1. **上线前必须关闭**:🔴 #1 微信真实登录(已修复)、🔴 #2 照片直传落库端点(已修复)、🔴 #3 红娘接单鉴权头(已修复)、🔴 #4 管理端 token/权限存储(已修复,见第二节)。
2. **同批修复**:🟡 #5 刷新 Token TTL(影响所有用户登录体验)、#6 手机号真实校验。
3. **排期修复**:#7–#12。
4. **补一轮针对性测试**:直传落库端到端、刷新 Token 生命周期、operator 通道失效不误伤用户登录态、枚举中文映射契约测试。
> 说明:本审查以"证据优先"为原则,🔴 与 #5 均经主审查员直接读源码复核;落地修复前建议对每个文件再回读一次原文,确认行号与上下文未漂移。
---
## 七、第二轮修复(原延后项已落地,2026-09-24 续)
> 以下两项为上一轮"刻意未改/建议下一轮"的延后项,本轮已直接动手修复,均经源码级核验(本机 bash 不可用,未做编译/运行验证)。
### A. 生产实名核验 Mock → Real(对应 AGENTS.md 硬规则:本地资格/实名认证分离、敏感数据默认拒绝)
- 新增 `verification/provider/RealIdentityVerificationProvider.java`(`@ConditionalOnProperty(name="identity.verification.provider", havingValue="real")`):
- 本地强校验身份证号 **GB 11643-1999 校验位(mod 11-2)**,伪造证件号在外部调用前即拒绝;
- 第三方核验走可配置 `identity.verification.real.api-url` + `api-key`(环境变量 `VERIFICATION_API_URL` / `VERIFICATION_API_KEY`);
- **fail-closed**:任何无法明确判定为"通过"的响应一律视为未通过;**缺第三方配置即 fail-fast**(不静默放行任意证件)。
- `MockIdentityVerificationProvider` 属性从 `verification.provider` **解耦**为 `identity.verification.provider`(`matchIfMissing=true` 保留本地默认)。
- `application-production.yml` 显式置 `identity.verification.provider=real` + 注入 api-url/api-key;`application-dev.yml` / `application-docker.yml` 保持 mock 默认。
- **契约对接提示**:第三方请求/响应字段(如厂商选公安实名或运营商三要素)需在接入时最终确认;当前以 `success/match/verified/passed` 或 `code==0` 判定通过。
### B. 管理端 token 改 HttpOnly Cookie(对应原 🔴 #4)
- 后端 `OperatorCookieWriter`:登录/刷新成功下发 **HttpOnly + SameSite=Lax** 的 `dy_op_rt`(refresh)/ `dy_op_at`(access)Cookie;登出清除。`Secure` 由 `app.auth-cookie-secure` 控制(dev/docker false、production true)。
- `OperatorAuthController`:新增 `/auth/operator/refresh`(cookie 优先于 body,支持 silent-refresh)、`/auth/operator/logout`(清 Cookie + 黑名单);登录/刷新均重下发 Cookie。
- `JwtAuthFilter`:取 token 改为 **Authorization 头优先、缺则回退 `dy_op_at` Cookie**(小程序/用户端仍走头,不受影响)。
- 前端 `stores/auth.ts`:**移除 localStorage 存 token/权限**,改内存态;新增 `initializeSession()` 凭 HttpOnly refresh Cookie 静默恢复会话(刷新页面免重登);`client.ts` 拦截器 401 时自动静默刷新一次后重试(带防重入守卫);9 个 `.vue` 中的 `localStorage.getItem('accessToken')` 全部改为内存态 `getAccessToken()`。
- **安全收益**:XSS 无法直接读取/外泄令牌,也无法改写本地权限清单伪造菜单(服务端 `@PreAuthorize` 仍是真边界)。
- **验证缺口(需你确认)**:本机未编译/运行;建议后端补编译 + 管理端登录→刷新→登出端到端走查;dev 跨端口代理与 prod 同源场景均依赖 Cookie(Cookie 不按端口划分作用域,dev 同样可用)。
### 仍延后(非本轮范围 / 需外部凭证)
- **手机号绑定真实校验**:需接短信验证码网关,无凭证无法真实实现,保留 TODO,未伪造。
- 上一轮"仍需留意"项(MyBatis xml `${}` 原生 SQL、SecurityConfig 路径剥离、Banner 外链范围、跨端字段名契约)维持原结论,未改动。
+86
View File
@@ -0,0 +1,86 @@
# Phase 26 — 上线前 13 项手动验证清单
> 用途:V1.1 进入 **UAT / 生产上线** 前,由 **运营 / 产品 / 运维** 人工逐项确认的清单。
> 本清单只覆盖「上线前必须肉眼或真机确认」的项;自动化回归已由 CI 覆盖
> (后端 `mvn verify` 795 绿 / E2E 65 passed / UI 契约 23/23 / 真编译 WXSS 88+WXML 86)。
>
> 判据纪律(沿用项目一贯要求):**断言 ≠ 证据**。`✅ 已完成` 类文档声明一律以
> 「当前代码 + 测试 + 人工验收」为准。本清单每项都给出 **可执行的验证方式**,
> 不靠口头确认。
>
> 关联决策:`docs/PENDING_DECISIONS.md`(13 项产品待确认)、`docs/governance/open-questions.md`。
> 冒烟脚本:`infrastructure/scripts/smoke-test.sh`(自动化子集)。
> ⛔ **2026-09-23 走查结论:本清单第 2、5 项仍为硬阻塞,当前不具备上线条件。**
> 详见 `docs/UAT-WALKTHROUGH-2026-09-23.md`(源码证据 + 本地栈运行时实测 + 容器异常栈;修复进展见该报告第八节)。
> **仍阻塞**:① 微信登录无真实 `code2session`(全库仅 Mock Provider);② 照片直传后**不落库**、不可见。
>
> **已同日闭环(commit `44f75ac`,CI run `35827369458` 7/7 绿)**:
> ③ 推荐列表**恒空**(`calculateScore` 为桩恒 50 < 阈值 60)→ 新增 `MatchScoringService` 真实评分;
> ④ 资料 / 择偶条件**保存即失败**(409/500)→ 改 upsert + 由实名派生 NOT NULL 字段;
> ⑤ 遗留端点 `interests/confirm` 可单方直建 Match → 改走阶段二同意链路(INV-014/015)。
> 走查另发现跨端字段名 / 取值契约不符(4 类取值违反 DB CHECK 直接 500)→ 已随同修复。
>
> 上述缺陷此前**均逃过** 757 后端测试 + 65 E2E + 22 UI 守卫(测试盲区);现已补行为型用例 + 负向注入自证。
> **第 2、5 项修复并复测前不得按本清单放行。**
---
## 验证总表
| # | 验证项 | 验证方式 | 通过标准 | 负责方 | 关联决策 / 配置 |
|---|--------|----------|----------|--------|------------------|
| 1 | 部署栈存活 | `bash infrastructure/scripts/smoke-test.sh` | live/ready/version 全 200;postgres/redis/seaweedfs 就绪 | 运维 | — |
| 2 | 微信小程序登录链路 | 真机「善缘丹阳」小程序微信授权登录 | 返回 `accessToken`+`refreshToken`,进入首页;token 过期触发重新登录 | 产品+运营 | 微信 appid/secret 生产配置;`WECHAT_APP_SECRET` 已填 |
| 3 | 运营端登录与越权隔离 | 管理后台 `/admin/` 用 seed 账号登录;尝试越权访问用户端资源 | 登录成功;运营 token 不能进入用户端(`hasRole("USER")` 兜底) | 运营 | 运营账号仅 seed 创建(迁移无 operator 种子) |
| 4 | 用户资料与择偶条件读写 | 小程序「我的 → 编辑资料 / 择偶条件」保存并回读 | 保存 2xx 且落库正确;下拉选项**全中文简体**(本次中文化成果) | 产品 | 见第 9 项 |
| 5 | 照片上传(SeaweedFS 直传) | 小程序上传头像/相册,上传后打开查看 | presigned PUT 直传成功、GET 下载字节一致;`MINIO_PUBLIC_ENDPOINT` 对外可达 | 运维 | prod `MINIO_PUBLIC_ENDPOINT`(ingress 域名) |
| 6 | 推荐 / 匹配核心链路 | 首页推荐 → Like / Pass → 互选双确认 → 开聊 | 推荐列表中文标签正确;Match/会话建立;Block 优先 | 产品 | 推荐阈值 `matching.recommendation.minScore`(技术默认 60,OQ-MATCH-001);照片上限 `profile.photo.maxCount`(技术默认 6,OQ-PROFILE-001) |
| 7 | 即时聊天(WebSocket) | 互选成功后双方发文本消息 | 握手鉴权、消息实时下行、历史可拉取;图片经安全检测 | 产品 | — |
| 8 | 活动全链路 | 浏览多类型活动 → 报名 → 运营审核 → 签到码核销 | 报名/审核/签到全通;互选活动双确认建 Match+会话 | 运营 | 服务站开放时间(待确认);活动意向端点语义(见 OQ) |
| 9 | 婚恋咨询 / 婚前辅导 | 申请 → 分配 → 排期 → 跟进 → 完成(四态) | 状态机合法迁移;counselor 仅见指派数据;manager 见摘要不见正文 | 运营 | 咨询资质要求、预约时段规则(待确认) |
| 10 | 公益红娘转介 | 红娘建立服务关系 → 推荐 / 转介 | 归属校验生效;不能替非本人负责用户操作、不能自荐 | 运营 | — |
| 11 | 内容 / 反诈 / 课堂 / 服务站 | 运营端 Banner+文章 CRUD、服务站公告;用户端展示 | 内容发布/下架正确;Banner 不外链;服务站公告可见 | 运营 | Banner 最大活跃数量(OQ-BANNER-001,代码暂无限制,需定值) |
| 12 | 管理端驾驶舱口径真实 | 管理后台数据统计页 | 漏斗/安全卡显示**真实值**(非硬编码 0);与汇总表同源 | 产品 | 有效交友匹配报送口径(待确认) |
| 13 | 合规与备份 | ① 未认证 401 / 越权 403 / 非法枚举 400;② 敏感访问有审计;③ 注销后数据保留 ≥36 个月;④ 数据库备份可恢复演练 | 4 项均达成;保留期配置 `account.data-retention-months=36` | 运维+产品 | 数据保留期(2026-09-19 产品确认下限 36 个月);本地资格佐证保留期限(待确认) |
---
## 执行说明
### 1. 部署栈存活(自动化)
```bash
export BASE_URL="https://你的生产域名"
bash infrastructure/scripts/smoke-test.sh
```
脚本断言 `live / ready / version` 与关键匿名接口应 401;任一 FAIL 即退出 1。
带 token 的子集(推荐/匹配/聊天等需登录链路)在提供 `OPERATOR_TOKEN` 后展开,
未提供时明确打印「需人工真机验证」,**不做假绿**。
### 2~13. 人工链路(真机 / 管理端)
- **小程序**:用真机微信账号走「善缘丹阳」小程序,逐项核对页面文案**全中文简体**
(重点:我的页 → 择偶条件 → 子女下拉;详情页学历/职业/婚姻/子女状态标签)。
- **管理端**:浏览器开 `/admin/`,用 seed 运营账号逐模块走查;数据统计页核对数字真实性。
- **合规**:抽查 `/api/v1/operator/audit-logs`(或对应审计接口)确认敏感访问被记录;
数据库做一次备份恢复演练。
### 上线前配置核对(必填,部署时)
| 配置项 | 位置 | 说明 |
|--------|------|------|
| `WECHAT_APP_SECRET` | 后端环境变量 / secret | 生产微信 secret,不得留空 |
| `MINIO_PUBLIC_ENDPOINT` | `infrastructure/docker-compose.prod.yml` | 对外可达的 SeaweedFS 网关域名(presigned URL host),缺省 `http://seaweedfs:8333` 仅容器内通 |
| `JWT_ACCESS_SECRET` / `JWT_REFRESH_SECRET` | 后端环境变量 | 生产必须用强随机值,且与微信登录验签密钥分离(2026-09-22 已修复 refresh 用错密钥) |
| `ENCRYPTION_KEY` | 后端环境变量 | 身份证等敏感字段加解密密钥 |
| HTTPS 证书 | ingress / 反向代理 | 证书到期须纳入监控(部署文档第七十五章) |
| 限流(若启用) | 后端配置 | `RATE_LIMITED`/429 已就位,是否启用及阈值待产品确认 |
---
## 不计入本清单(需产品拍板,非技术阻塞)
- 人脸核验 Provider 选型(`identity.liveness.provider` 默认 `none`,Feature Flag 控制)
- 本地资格佐证触发策略 / 文件保留期限(需合规审核)
- 匹配报送口径、Banner 活跃上限、咨询资质/预约时段、服务站开放时间
- 第三方开放(API Key / OAuth / 限流配额 / OpenAPI 发布)—— 范围扩张,待决策
> 以上在 `docs/PENDING_DECISIONS.md` 逐项跟踪;未确认不影响「核心功能可上线」,
> 但相关默认值(阈值 60、照片 6 张等)应以产品正式确认值替换并落 `decision-register.md`。
+1 -1
View File
@@ -356,7 +356,7 @@
|------|-----------|------|
| Home Aggregate API + V1.1首页 | Phase 07 | ✅ 已完成 |
| Banner 管理(后端+前端) | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
| 本地资格声明与审核 | Phase 04~05 | ⏳ 待开发 |
| 本地资格声明与审核 | Phase 04~05 | ✅ 已完成(2026-09-12 复验关闭;`LocalEligibilityService` 已实现、测试库 `local_eligibility_*` 表就位;仅「触发策略 / 文件保留期限」属产品待确认,见 `PENDING_DECISIONS.md`) |
| 公益红娘服务转介 | Phase 13 | ✅ 已完成 |
| 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ✅ 已完成(2026-09-17 复验关闭) |
| 内容管理(婚恋课堂/反诈) | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
+178
View File
@@ -0,0 +1,178 @@
# UAT 走查报告 — 2026-09-23
> 对象:`docs/PHASE26-GO-LIVE-CHECKLIST.md` 的 13 项上线前验证。
> 方法:**对照源码逐项静态核验 + 本地真实栈运行时实测**(不采信文档声明,不采信测试“全绿”)。
> 环境:本地 Docker 栈(`danyang-match-backend` healthy,postgres/redis/seaweedfs healthy),
> API `http://localhost:8085/api/v1`;构建时间约 16 小时前。
> 判据纪律:`✅` 一律以「当前代码 + 实测」为准;`断言 ≠ 证据`。
---
## 一、结论摘要
**走查结论:当前状态不满足「核心功能可上线」。13 项中 4 项为硬阻塞(功能在代码层/运行时不成立),
另外 2 项存在越权面或文档不一致。**
| 判定 | 项 |
|---|---|
| ❌ 硬阻塞(功能不成立) | #2 微信登录、#4 资料/择偶条件读写、#5 照片上传、#6 推荐/匹配 |
| 🔶 部分 / 有风险 | #8 活动(遗留越权端点)、#10 红娘(文档与端点不一致) |
| ✅ 通过 | #1 部署栈存活(API 层)、#3 登录与越权隔离、#7 聊天、#9 咨询、#11 内容/Banner、#12 驾驶舱、#13 合规(除备份演练) |
> **关键判断**:CI 7/7 全绿**不能**证明可上线。本轮 4 个阻塞项**全部**逃过了 757 个后端测试、
> 65 个 E2E 与 22 个 UI 契约守卫——原因是**测试盲区**(见第五节),而非测试不够多。
---
## 二、逐项结论
| # | 项 | 判定 | 静态证据(文件:行) | 运行时实测 |
|---|----|------|---------------------|-----------|
| 1 | 部署栈存活 | ✅(API 层) | `infrastructure/docker-compose.prod.yml` 有 postgres/redis/seaweedfs/backend/admin-web + healthcheck | `GET /health/live|ready|version` 全 **200**,db+redis `up` |
| 2 | 微信小程序登录 | ❌ **阻塞** | 全库**仅** `MockWechatLoginProvider` 实现 `WechatLoginProvider`;**无任何 `code2session`**;`wechat.app-secret`(`application-dev.yml:16`)**无引用方** | 登录返回 200 + `accessToken/refreshToken`(**Mock 签发**);不构成真实微信对接 |
| 3 | 运营端登录与越权隔离 | ✅ | `SecurityConfig.java:94-121`:运营路径 `hasRole("OPERATOR")`、其余 `hasRole("USER")`;`SecurityPathCoverageTest` 机械校验 | 匿名→**401**;USER token 打 `/operator/**`→**403** |
| 4 | 用户资料与择偶条件读写 | ❌ **阻塞** | `UserPreferenceService.java:58` 恒 `insert()`;`UserProfileService.java:110,136` 恒 `insert()`;DDL `V002__…:32,69` 主键=`user_id` | `PUT /me/preferences`→**409**(`duplicate key … user_preferences_pkey`);`PUT /me/profile`→**500**(`null value in column "gender" … not-null`) |
| 5 | 照片上传(直传) | ❌ **阻塞** | `PhotoService.java:75-90` 只签 PUT;无「直传后落库」端点;`getSignedUrl`(:96-114)要求 fileKey 已在库;`miniapp/pages/profile-photos/profile-photos.js:110-144` 上传后**无落库调用** | `POST /me/photos/upload-token`→200,返回 `fileKey/uploadUrl/expiresIn`(前端期望 `photoId/accessUrl/expiresAt`,**契约不符**) |
| 6 | 推荐 / 匹配核心链路 | ❌ **阻塞** | `RecommendationEngineService.java:344-347` `calculateScore()` 为**桩,恒返回 50.0**;`:232` `mutualScore(50) < 阈值(60)` → 丢弃;阈值 `MatchRuleConfigProperties.java:22`=60。另 `MatchEligibilityService.java:246-251` 候选池**恒空**、`:182-187` 双向 Required 校验**恒 true** | `GET /recommendations`→200 且 `data:[]`(**恒空**) |
| 7 | 即时聊天(WebSocket) | ✅ | `WebSocketConfig.java:46-52`:`/ws/chat` + `ChatHandshakeInterceptor` 握手鉴权;`ConversationController`/`MessageController` 齐备 | 匿名 `/conversations`→**401** |
| 8 | 活动全链路 | 🔶 部分 | 正路(小程序在用):`ActivityMutualConsentService.java:68-102` **双方 accepted 才建 Match**(INV-015 ✓)。**遗留**:`ActivityInterestController.java:110-130` 的 `POST /activities/{id}/interests/confirm` 仅验互选即**单方直建 Match**(违反 INV-014/015,小程序未调用);`:43` `feedbackCount` 写死 0 | 未实测(小程序未走该端点) |
| 9 | 婚恋咨询 / 婚前辅导 | ✅ | `CounselingController.java` 四态状态机;`CounselingService` 层数据域校验(INV-012 Counselor 仅见指派、INV-013 manager 无正文) | 未实测 |
| 10 | 公益红娘转介 | 🔶 部分 | 用户端/操作端端点存在且写库(`MatchmakerController`、`MatchmakerOperatorController`)。**不一致**:`MatchmakerController.java:29-32` JSDoc 声称的 3 个 operator 端点**并未在该控制器实现**(实际在 `/operator/matchmaker`);自荐校验未覆盖「target ≠ 操作红娘本人」 | 未实测 |
| 11 | 内容 / 反诈 / 课堂 / 服务站 | ✅ | `HomeBannerOperatorController.java:122-186`:`targetType` 白名单 + **禁外链**(`http/https///javascript:/data:text/html`)→ 400;无活跃上限(**与 OQ-BANNER-001 一致**,需产品定值) | 未实测 |
| 12 | 管理端驾驶舱口径真实 | ✅ | `AnalyticsService.java` 经 `AnalyticsMetricsCalculator` + Mapper 真实 SQL 聚合(含「用户数当活动数死代码」修复记录),非硬编码 0 | 未实测 |
| 13 | 合规与备份 | ✅(除备份演练) | `GlobalExceptionHandler` 由 `ApiResponseCode.getHttpStatus()` 单一来源派生 400/401/403/404/405/409/500;审计**真实接线**(`RiskController.java:53`、`ReportOperatorController.java:68,105`、`CounselingService.java:359`);保留期 `account.data-retention-months`=36 LOCKED | 匿名→401 / 越权→403 / 重复→409 实测符合;登录写 `audit_logs(action=LOGIN)`;**备份恢复演练未做** |
---
## 三、阻塞项详述(含运行时证据)
### 阻塞 #2 — 微信登录没有真实实现
- 唯一实现是 `MockWechatLoginProvider`(`@ConditionalOnProperty(name="verification.provider", havingValue="mock", matchIfMissing=true)`),
`getCodeSession()` 直接**伪造** openid/unionid。
- 全库搜索 `code2session` **零命中**;配置了 `WECHAT_APP_SECRET` 但**没有任何代码读取它**。
- **后果**:生产环境用户点「微信登录」无法真正换取 openid → 登录不可用。
- 需补:真实 `WechatLoginProvider` 实现(调用 `https://api.weixin.qq.com/sns/jscode2session`)+ appid/secret 生产配置。
### 阻塞 #4 — 资料 / 择偶条件**保存即失败**
运行时实测(本地栈):
```
PUT /me/preferences -> 409 {"code":"DUPLICATE_SUBMISSION"}
日志: duplicate key value violates unique constraint "user_preferences_pkey"
PUT /me/profile -> 500 {"code":"SERVER_ERROR"}
日志: null value in column "gender" of relation "user_profiles" violates not-null constraint
```
- 根因:`savePreference`/`updateProfile`/`updateBasicProfile` **恒调 `insert()`**,从不 upsert/update。
- 择偶条件:目标行已存在 → 主键冲突 → 409。
- 资料:构造的新实体缺 `gender`/`birth_date`(NOT NULL)→ `DataIntegrityViolationException`(未映射)→ 500。
- **后果**:用户「编辑资料」「择偶条件」保存**全部失败**(新人首存也可能直接 500/409)。
### 阻塞 #5 — 照片直传后不落库
- 小程序上传链路(`profile-photos._uploadPhoto`):`requestUploadToken` → `wx.uploadFile(uploadUrl)` → **随后只调 `setMainPhoto(tokenRes.photoId)`**。
- 后端 `getUploadToken` **不返回 `photoId`**(返回 `fileKey`),且**不存在**「直传后确认落库」端点(无 `POST /me/photos`)。
- **后果**:生活照文件可能进了对象存储,但 `user_photos` 无记录 → `getSignedUrl` 抛 403 → 照片**不可见**、设主头像失败。
- 附:字段契约不符(前端 `photoId/accessUrl/expiresAt` ↔ 后端 `fileKey/uploadUrl/expiresIn`)。
- 注:本地资格佐证上传走 multipart `POST /photos/upload`(→ `uploadAndSave`)**可落库**,不受此影响。
### 阻塞 #6 — 推荐列表恒空
- `RecommendationEngineService.calculateScore()` 是**桩**:`// TODO: Phase 07 后续完善评分逻辑; return 50.0;`
- `mutualScore = 0.6·min(50,50) + 0.4·50 = 50`;判定 `50 < 60`(阈值来自 `match.default-score-threshold`)→ **全部候选被丢弃**。
- 另一条路径 `MatchEligibilityService.buildCandidatePool()` 直接 `return List.of()`(「TODO: 从数据库查询」)。
- 运行时实测:`GET /recommendations` → 200 且 `data:[]`。
- **后果**:首页「今日推荐」**恒为空**,「推荐 → Like → 互选 → 开聊」的核心入口不可用
(Like/Pass/Match/Block 端点本身可用,但用户无从发起)。
---
## 四、次要发现
- **#8 遗留越权端点**:`POST /activities/{id}/interests/confirm` 可被任何 API 消费者调用,
单方即建 Match,绕过 INV-014/015 的二阶段同意。小程序未用,但属**真实越权面**,建议下线或加二阶段守卫。
`getActivityInterestSummary` 的 `feedbackCount` 恒 0(管理端统计失真)。
- **#10 文档与代码不一致**:`MatchmakerController` 类注释宣称的 operator 端点未实现;
自荐校验未覆盖「红娘自己」;`MatchmakerService.createReferral` 无 HTTP 调用方(疑似死代码)。
- **#11 Banner 无活跃上限**:与产品决策 OQ-BANNER-001 一致,属待定值而非缺陷。
- **`GET /me/profile` 运行时返回 500**:与当前源码 `getSelfProfile`(对空资料有兜底)**不一致**,
疑为本地镜像(16h 前构建)与源码漂移,或未定位路径;建议以当前源码重建镜像后复测。
---
## 五、根因:为什么「全绿」却功能不成立
本轮 4 个阻塞项**全部**逃过了 757 后端测试 + 65 E2E + 22 UI 守卫,属**测试盲区**:
| 阻塞项 | 为何测试没抓到 |
|---|---|
| #6 推荐恒空 | E2E **零**推荐用例(`grep recommendation tests/e2e` 无命中);后端 `RecommendationEngineServiceTest`/`…ThresholdSnapshotGuardTest` 只验**阈值逻辑**(用合成分数 62/70),从不调真实 `calculateScore` → 桩返回 50 被静默过滤 |
| #4 保存失败 | 后端测试**零**引用 `savePreference`/`updateProfile`/`updateBasicProfile` → 「二次保存 / 已有行保存」无覆盖 |
| #2 微信登录 | 测试全程使用 Mock Provider → 真实 `code2session` 缺失不可见 |
| #5 照片不落库 | 测试走 `uploadAndSave`(multipart,会落库);**直传 + 确认落库**路径无用例 |
**这正是本项目反复记录的失败模式:`断言 ≠ 证据`、`全绿 ≠ 有效`。** 静态守卫、编译、单元测试都无法替代
「真跑一遍看结果」(本轮靠运行时实测 + 容器日志才暴露)。
---
## 六、修复建议(按优先级)
| 优先级 | 项 | 动作 | 需决策? |
|---|---|---|---|
| P0 | #6 推荐 | 实现真实评分(`calculateScore` / `directionalScore` / 候选池),或先把阈值降到 ≤50 并用集成测试断言「非空」 | 评分算法属 Phase 07 设计 |
| P0 | #4 保存 | 改为 upsert(先查后 update,或 `INSERT … ON CONFLICT DO UPDATE`);资料保存补齐 `gender`/`birth_date`;补「二次保存」集成测试 | 否 |
| P0 | #2 微信登录 | 实现真实 `WechatLoginProvider`(code2session)+ 生产 secret | 需微信凭据 |
| P0 | #5 照片 | 增加「直传确认落库」端点并接线;统一 `upload-token` 字段契约(或前端改读 `fileKey`) | 需定接口契约 |
| P1 | #8 越权端点 | 下线 `interests/confirm` 或补二阶段守卫;`feedbackCount` 接真实统计 | 否 |
| P1 | #10 红娘 | 修正类注释;补自荐校验;清理死代码 | 否 |
| P2 | 守卫 | 为上述 4 项各补**行为型**集成/E2E 用例(断言非空、断言二次保存成功),并做负向注入证明有判别力 | 否 |
---
## 七、走查方法与复现
- 静态:逐项 `Read`/`Grep` 后端 Controller/Service、Flyway DDL、小程序 service/页面、配置 yml。
- 运行时:本地栈直接打 API(探针脚本 `C:/Users/13070/uat_probe*.py`),并从 `docker logs danyang-match-backend` 取异常栈。
- 未覆盖:`infrastructure/scripts/smoke-test.sh` 在本机 bash 下触发 `wsl.exe`(安全策略黑名单,不可绕过),
故其断言改由上述直接探针覆盖;手工项(真机 UI、备份恢复演练)仍需人工执行。
> 本报告为**走查发现**,不是修复。修复进展见第八节(2026-09-23 同日闭环 P0 #4 / P0 #6 / P1 #8 / P1 #10)。
---
## 八、修复进展(同日闭环:P0 #4 · P0 #6 · P1 #8 · P1 #10)
### 8.1 后端
| 项 | 修复 | 判别性证据 |
|---|---|---|
| **#6 推荐恒空** | 新增 `MatchScoringService` 作为**唯一评分事实源**(用 `UserPreference` **既有** 10 维权重加权,零新造常量);`RecommendationEngineService.calculateScore/generateReasons` 由桩改为委托;`MatchEligibilityService`(死代码)的 3 处桩全部去雷并委托同一事实源 | 新测试 `RecommendationEngineServiceBehaviorTest`(断言推荐**非空**)、`MatchScoringServiceTest`(方向性、边界、降级) |
| **#4 保存失败** | `UserProfileService.updateProfile/updateBasicProfile`、`UserPreferenceService.savePreference` 改为**无则插入 / 有则更新**(`UserPreference` 实体 `@TableId=id` 与库主键 `user_id` 不一致,故按 `user_id` 定位更新,不用 `updateById`);`gender`/`birth_date`(NOT NULL)在请求缺失时从**已通过实名认证**的 `user_identities` 补齐,仍缺则抛 400 业务拒绝(不再让 `DataIntegrityViolationException` 渲染成 500);`GET /me/profile` 的 `preferences` 补回 5 个此前未返回字段(`regionPriority`/`maritalPriority`/`minEducationCode`/`educationPriority`/`childrenPriority`) | 新测试 `ProfileSaveUpsertBehaviorTest`(二次保存成功、NOT NULL 兜底、别名映射) |
| **#8 遗留越权端点** | `POST /activities/{id}/interests/confirm` 不再直调「建 Match 原语」`RelationshipService.confirmActivityInterest`,改为与 `/activities/{id}/mutual/*` **同一条**二阶段链路(`createMutualConsent` + `confirmConnection`)——单方调用不再可能创建 Match(INV-014/015) | 新测试 `ActivityInterestConfirmConsentBehaviorTest`(`verify` 接线 + 单方不建 Match + 前置拒绝) |
| **#10 文档不一致** | `MatchmakerController` 类注释更正(operator 端点实际在 `/operator/matchmaker`)。**更正本报告**:「自荐校验未覆盖」表述不准确 —— `MatchmakerServiceService.recommendPartner` 已有该校验 | — |
| **存量 500 家族** | `RuntimeExceptionThrowGuardTest` 正则只认 `throw new RuntimeException(`,而 17 处裸抛写作 `orElseThrow(() -> new RuntimeException(...))` **整体绕过守卫**(CI 全绿却仍有 500 家族缺陷)→ 17 处全量改为 `GlobalExceptionHandler.BusinessException(ApiResponseCode.XXX, …)`;守卫扩为「整段代码 + 该形态」并加自检断言(防止守卫退化成恒真空断言) | 负向注入实测:注入 `orElseThrow` 形态 → 守卫精确报出 `RecommendationEngineService.java:282` |
### 8.2 小程序(跨端字段名 / 取值契约)
走查顺带发现**同一类、更深一层**的缺陷:向导把「展示形态」直发后端,字段名与取值**双双对不上** ——
除被静默丢弃外,还有 4 类取值**违反 DB CHECK 直接 500**。已统一到「后端规范字段名 + 枚举码」:
- **字段名**:`hasChildren`→`childrenStatus`、`occupationCategory`→`occupationCode`、`workRegion`→`workRegionCode`、
`ageMin/ageImportance`→`minAge/agePriority`、`educationAccepted/maritalStatusAccepted`→`minEducationCode/maritalStatuses`。
- **取值**:`has`→`yes`;`high_school`→`senior_high`、`phd`→`doctor`(`chk_profile_edu`);
`occasional`→`sometimes`(`chk_profile_smoking` / `chk_profile_drinking`)。
- **回填**:预填此前读 `profile.hasChildren / occupationCategory / workRegion` —— 服务端**根本不返回**这些字段
(返回的是 `childrenStatus / occupationCode / workRegionCode`)→ 表单永远回填不上;已改为读服务端真实字段。
- **完成度**:`profile-confirm` 改为直接采用服务端 `profileCompletion`(AGENTS 第 9 条:DB 是 Source of Truth),
废弃原先 22 条「读服务端不存在字段」的本地猜测式断言(这是完成度长期偏低的真因)。
- **新守卫** `ui-tests/miniapp-profile-contracts.mjs`:桩掉 `stores`,**真跑**三个页面的事件处理与 `onNext()`,
断言提交负载用规范字段名与枚举码(`MIN_GUARDS` 21→23;顺带修掉上一轮 `MIN_GUARDS` 实际仍是 21 的漏改 ——
该值当时与守卫数不符,使「数量下限」保护形同虚设)。
### 8.3 仍未修(需产品/数据决策,或不在本轮范围)
1. **#2 微信登录真实实现**、**#5 照片直传落库** —— 不在本轮范围(分别需微信凭据、需先定接口契约)。
2. **地区(region)语义**:`regionScope` 需 GB/T 2260 区划码,而小程序选项含「镇江其他辖区」这类**非单值**项,
而 `region_code` / `work_region_code` 是**单值**列 → **不臆造区划码**,本轮不做映射。
现状:择偶条件「地区」暂不生效(该约束默认 `preferred` 软约束,不会误过滤)。
**需产品明确「平台支持哪些区域码」**(种子只体现了 `321181` 丹阳、`321100` 镇江、`320500` 苏州)。
3. **`occupationPreference`(职业偏好)**:后端 `user_preferences` 无对应列。
4. **`settlementPreference`(定居偏好)**:后端无既定取值字典。
5. 其余产品决策见 `docs/PENDING_DECISIONS.md`。
+196
View File
@@ -0,0 +1,196 @@
# 代码审查标准与流程 — 善缘丹阳公益婚恋平台
> **版本**:V1.0 · **生效**:2026-09-23 · **owner**:技术负责人 / 全体提交者
> **配套**:`PLAYBOOK.md`(操作手法) · `known-risks.md`(已知债务) · `decision-register.md`(已确认决策) · `.github/workflows/ci.yml`(自动门禁)
> **目的**:本仓库是多端单体仓库(Spring Boot 后端 + 微信小程序 + Vue3 管理端 + 迁移脚本 + E2E),历史上已多次出现"本地/CI 全绿但线上失效"的**假绿**陷阱。本文档把已踩过的坑固化成**人在合并前必须执行的审查纪律**,与 CI 自动门禁形成互补——CI 是底线兜底,人工审查是质量上限。
---
## 0. 总原则
1. **证据优先,信任代码而非措辞**:PR 描述里的"✅ 已完成 / 已修复 / 已验证"**不是证据**,只是主张。审查者只认:可读的代码改动 + 能证明判别力的测试 + 运行时实测结论。文档/PPT/截图里写的"已完成"一律按"未证实"处理。
2. **审查是协作质量门,不是找茬**:目标是让合并后的代码比提交时更稳。区分"必须拦下"(Blocker/Major) 与"建议改进"(Minor/Nit)。
3. **清单是地板不是天花板**:本文清单覆盖的是本仓库**反复出过事**的点;未列出的通用工程规范(命名、复杂度、可读性)同样适用。
4. **任何放行都需可追溯**:审查意见、拒绝原因、已知债务豁免都留在 PR 或 `known-risks.md`,不靠口头"应该没问题"。
---
## 1. 适用范围与仓库分层
| 层 | 路径 | 技术栈 | 本仓库最高风险点 |
|----|------|--------|------------------|
| 后端 | `backend/` | Spring Boot / Java 21 / Maven / Flyway / JWT | 裸 `RuntimeException`→500、弱断言、业务不变量被绕过 |
| 小程序 | `miniapp/` | 原生 WXML/WXSS/TS,`app.json`/`pages.json` 声明式 | `tsc` 不保跨端字段名;`ignoreDevUnusedFiles` 吞资源;WXSS 编译静默失效 |
| 管理端 | `admin-web/` | Vue3 / TS / pnpm / Element Plus | 非响应式状态不重渲染;设计 token 编译期生成;`vite build` 清 `dist/` |
| 迁移 | `backend/src/main/resources/db/migration/V*.sql` | Flyway | 版本缺号/重复、CHECK 约束与 Java 枚举漂移 |
| 测试 | `tests/e2e/`(pytest)、`ui-tests/*.mjs`(契约守卫)、`backend/src/test/` | 多框架 | 空壳测试、缺负向注入、守卫数下限被悄悄删 |
| 脚本/基建 | `scripts/`、`infrastructure/` | Node/Python/Compose | 依赖声明与实际不符(本地绿/CI 红) |
---
## 2. 审查分级(按变更风险)
| 级 | 名称 | 触发条件 | 最少审查人数 | 审查深度 |
|----|------|----------|--------------|----------|
| **T0** | 琐碎 | 纯文案/注释/文档、`*.md`、与逻辑无关的格式化、资源重命名(无引用变更) | 1(可自审) | 仅看是否误伤其他引用 |
| **T1** | 常规 | 单端功能开发、Bug 修复、UI 调整、单个 API 增改 | 1 | 走 §4 对应技术栈清单 + §5 判据纪律 |
| **T2** | 高危 | 涉及:鉴权/登录链路、支付/资金、数据隐私(PII)、跨端契约字段、数据库 schema 变更、并发/幂等、未接真实现的已知阻塞项(见 §6.4) | 2(含 1 名对应该端熟悉者) | 全清单 + 运行时实测结论 + 回滚方案 |
> 判定不清时**就高不就低**。一人团队下"第 2 人"可由技术负责人交叉复核,或在 PR 中显式标注"已自审 + 已跑实测"并附命令与输出。
---
## 3. 严重级别定义
| 级 | 含义 | 合并策略 | 本项目真实示例 |
|----|------|----------|----------------|
| **Blocker** | 合并后会破坏生产或导致数据错误/安全泄露 | **必须修**,否则拒绝合并 | 裸 `RuntimeException` 掩盖业务拒绝(→500);`updateById` 对 `user_id` 主键静默 no-op;跨端字段名写错使请求永不落库 |
| **Major** | 重要功能不可用或存在明显技术债放大风险 | 必须修或可书面豁免(登记到 `known-risks.md`) | `tsc` 通过但 WXML 多一个 `</view>` 编译失败;守卫被删导致"假绿";新增死表/死配置 |
| **Minor** | 不影响功能但降低可维护性/一致性 | 建议修,可不阻塞 | 魔法数字未抽常量;日志缺失;字段命名与 `decision-register` 不一致 |
| **Nit** | 风格/偏好层面 | 不阻塞,鼓励修 | 换行、注释措辞、导入顺序 |
**原则**:Blocker/Major 必须在该 PR 闭环或明确转移到 `known-risks.md`;Minor/Nit 不得作为合并阻塞,避免"审查变成挑刺"。
---
## 4. 各技术栈审查清单(核心)
> 每项审查时**逐项打勾**(在 PR 模板里),打勾 = 已实际验证,不是"应该没问题"。
### 4.1 后端 Java / Spring Boot
- [ ] **异常分类正确**:业务拒绝抛 `BusinessException` + `ApiResponseCode`,HTTP 落在 `400 ≤ code < 500`;**不得**用裸 `RuntimeException`(`orElseThrow(() -> new RuntimeException(...))` 形态也要改,历史上漏过 17 处)。
- [ ] **断言有判别力**:测试对"业务拒绝"断言 `400 ≤ status < 500`,**不得**只断言 `!= 200`(会把 500 洗成"业务拒绝"假绿)。
- [ ] **不变量守住**:匹配/聊天/活动互选等核心规则(见 `decision-register` 的 LOCKED 项,如 INV-014/015 两阶段同意、Required/Mutual/Block)未被绕过;无"直连绕过"或"单方直建"的捷径。
- [ ] **数据落库正确**:主键/唯一键用法正确(如 `UserPreference` 真实主键是 `user_id`,不能用 `updateById` 对其 no-op);NOT NULL / CHECK 约束与 Java 枚举一致。
- [ ] **幂等/并发**:写接口有幂等键或乐观锁;无裸 `synchronized` 跨实例假设。
- [ ] **配置可信源**:新增开关走活键(`application*.yml` 的 `match.*`/`features.*`),**不得**新增 `system_configs`/`match_rule_configs` 这类"死表"(见 KR-02/KR-03)。
- [ ] **敏感信息**:密钥/口令不入库、不进日志;JWT Secret 来自环境变量。
### 4.2 小程序 miniapp(原生)
- [ ] **`app.json` / `pages.json` 一致性**:tabBar、页面路径、资源路径与磁盘真实文件一致;路径**不带前导 `/`**;引用的图标/图片文件确实存在。
- [ ] **资源不被吞**:`project.config.json` 的 `ignoreDevUnusedFiles` / `ignoreUploadUnusedFiles` 必须为 `false`(否则只被 `app.json` 声明、不被任何 WXML 引用的图标会被打包/上传剔除)。
- [ ] **跨端字段名**:小程序发出的字段名与后端规范名完全一致(历史漂移:`hasChildren`/`occupationCategory`/`workRegion`/`ageMin`/`educationAccepted`/`maritalStatusAccepted` 等);**tsc 不保护 JS 层字段名**,必须看值。
- [ ] **WXSS 合法性**:无通配符 `*`(编译期致命);不用属性选择器 `[attr]`(静默失效);注释不嵌套;`display:grid` 在 Skyline 不支持→用 flex。
- [ ] **胶囊安全区**:自定义导航让出 `--nav-right`(`utils/nav-metrics.js` 唯一事实源),不遮挡右上角胶囊。
- [ ] **`<image>` 高度**:只写 `width` 不写 `height` 会按默认 240px 撑高(卡片异常高大真因),必须显式高度或 `mode`。
### 4.3 管理端 admin-web(Vue3 / TS)
- [ ] **响应式状态**:涉及渲染的数组/对象改动走 `ref`/`reactive` 或 `reactive()` 包裹,**不**用普通声明数组当状态写且出现在模板(曾致"静态守卫全绿但页面不重渲染");改完必须补**渲染态实测**。
- [ ] **设计 token**:颜色/字号收敛到 `src/theme/index.css` + `src/theme/tokens.ts`(同名取值双向校验);Element Plus 主色阶是编译期静态生成,`--el-color-primary` 改了必须补全 `-light-N`/`-dark-2`/`-rgb`,否则残留默认蓝。
- [ ] **类型安全**:跨组件 props/接口用 TS 类型;不靠 `any` 绕过;表单校验用 Zod/类型安全方案,错误内联且**保留用户输入**(出错不清空)。
- [ ] **触摸目标**:交互元素 ≥ 44×44px,间距 ≥ 8px;尊重 `prefers-reduced-motion`。
### 4.4 数据库迁移 SQL(Flyway)
- [ ] **版本连续唯一**:`V<序号>__<描述>.sql` 命名,无缺号/重复(CI 的 `Migration Validation` job 会校验,但人也要看变更意图)。
- [ ] **向后兼容**:不破坏现有列/约束;NOT NULL 新列须有默认值或配套数据迁移。
- [ ] **CHECK 约束与枚举一致**:新增/修改 CHECK(如 `chk_profile_gender`、`chk_profile_edu`)必须与 Java 枚举值逐一对应,避免 `DataIntegrityViolationException`(500)。
- [ ] **可逆性**:破坏性变更(DROP/重命名)必须有专门的回滚迁移或明确登记为一次性。
### 4.5 测试(单测 / E2E / 契约守卫)
- [ ] **无空壳**:无 `skip`/`pass` 空测试(CI `Test Shell Guard` 会拦,但人也要看测试是否真在断言什么)。
- [ ] **负向注入**:新增或修改测试后**必须做负向注入**证明测试有判别力(故意改坏被测代码,测试应转红);不做负向注入 = 视为未验证。
- [ ] **守卫数下限**:`scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 **23**)是硬下限,增删守卫必须同步改该常量;审查时确认守卫数量未偷偷下降。
- [ ] **E2E 真实执行**:`tests/e2e` 用例在本机用真实栈跑过(非 import 期即崩的"假绿");依赖 `docker` 在 PATH(否则 `FileNotFoundError` 误判)。
### 4.6 脚本与基础设施
- [ ] **依赖声明即真实依赖**:脚本 import 的包(如 `ui-tests` 守卫依赖 `miniapp/node_modules/typescript`)必须在 CI 对应 job 里安装——"本地能跑"不等于"CI 能跑"。
- [ ] **幂等与可重入**:脚本可重复执行、不产生副作用累积;密钥仅在运行期、不落盘。
- [ ] **Compose 隔离**:本地栈固定 `-p danyang`,不与邻项目撞名(KR-09 教训:`-p infra` 互删容器)。
---
## 5. 判据纪律:审查必须拒收的"假绿"模式(重点)
> 以下每一条都来自本仓库真实事故。审查者看到对应改动,**必须主动排查**,不能因为 CI 绿就放行。
| # | 假绿模式 | 为什么是缺陷 | 审查动作 |
|---|----------|--------------|----------|
| 1 | **文档/PPT 写"✅ 已完成"** | 措辞不是证据 | 要求贴代码 diff / 测试输出 / 实测命令,否则按未证实处理 |
| 2 | **空数据假绿** | 列表/推荐接口返回空数组也能"通过",掩盖查询逻辑失效(推荐曾恒空) | 审查测试是否用**真实种子数据**断言非空结果 |
| 3 | **直连绕过假绿** | 测试直连 Service 而非走 Controller,跳过鉴权/序列化/HTTP 语义 | 关键路径至少有一个经 HTTP 层的端到端断言 |
| 4 | **测试库缺约束假绿** | 测试库没建 CHECK/外键,生产库的约束会在运行时炸成 500 | 约束相关改动必须在带约束环境下验证 |
| 5 | **弱断言 `!= 200`** | 把 500 洗成"业务拒绝",假绿 | 业务拒绝一律断言 `400 ≤ code < 500` |
| 6 | **裸 `RuntimeException`** | 属于 500 家族缺陷,掩盖真实错误 | grep `new RuntimeException` 与 `orElseThrow(() -> new RuntimeException`,要求改 `BusinessException` |
| 7 | **代码合法 ≠ 渲染正确** | Vue 普通声明数组当状态、WXML 结构错误,静态守卫全绿仍不渲染 | 涉及渲染的改动必须补**渲染态实测**(CDP/截图) |
| 8 | **job 名 ≠ 实际行为** | CI `Mini Program Compile Check` 只跑 `tsc --noEmit`,**不真编译** WXML/WXSS | 小程序改动不能只看该 job 绿;真编译走 `scripts/miniapp-compile-check.mjs`(本地) |
| 9 | **跨端字段名漂移** | JS 层字段名不受 tsc 保护,写错使请求字段落空/不落库 | 跨端契约改动逐字段核对 `miniapp` ↔ `backend` 规范名 |
| 10 | **同一文件并行 Edit** | 后者覆盖前者,改动丢失 | 确认 PR 内对同一文件无冲突编辑;合并后回读该文件 |
| 11 | **缺负向注入** | 测试必然全绿,无判别力 | 要求演示"改坏后测试转红" |
---
## 6. 合并准入(Definition of Done / 合并门槛)
### 6.1 硬性门槛(全部满足才可合并)
1. CI 7 道门禁全绿(backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e)。其中 backend 门禁在 `mvn verify` 之外,额外由 `scripts/run-backend-contracts.mjs`(与 `run-ui-contracts.mjs` 同级)固化一组关键后端契约/回归测试,并校验这些测试源文件的存在性下限(防静默删除/改名后门禁假绿)。
2. PR 模板所有 **T 级对应清单项**已逐项打勾并附验证说明。
3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。
4. 新增/修改的测试已做负向注入。
5. 跨端契约字段、数据库约束、设计 token 已对齐各自事实源。
### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能)
| CI job | 它验证了什么 | 它验证不了的(必须由人审) |
|--------|--------------|----------------------------|
| Backend Build & Test | `mvn verify` 编译+单测;`run-backend-contracts.mjs` 固化关键回归/契约测试并校验源文件下限 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 |
| Admin Web Build | `pnpm build` 通过 | 运行时渲染正确性、token 是否补全色阶、响应式是否真重渲染 |
| Mini Program Compile Check | 仅 `tsc --noEmit` | **真编译** WXML/WXSS、资源是否被 `ignoreDevUnusedFiles` 吞、胶囊安全区 |
| Migration Validation | 序列/命名/非空 | CHECK 与枚举语义一致、向后兼容、可逆性 |
| Test Shell Guard | 无空壳测试 | 测试是否真在断言、是否缺负向注入 |
| UI Contracts | 23 守卫结构门 | 守卫数下限是否被偷偷删、守卫是否覆盖新契约 |
| E2E (live stack) | 活栈 pytest | 隐私/资金链路、已知阻塞项是否被正确处置 |
### 6.3 合并后验证(回读纪律)
- 合并/推送后**必须回读**:`git rev-parse HEAD` / `git log -1` / `git status --porcelain`,不只用退出码判断。
- 远端真值只认 `git ls-remote origin refs/heads/main`(本机 `origin/main` 会卡旧值谎报 ahead)。
- 推送后轮询 CI(`.workbuddy/poll_ci.py`),确认本次 head 的 run 真正 success,而非"上次绿的缓存印象"。
### 6.4 已知上线阻塞(触碰即须显式处置)
以下两项为当前**上线阻塞**(见 `PLAYBOOK.md §E`),任何相关 PR 必须:要么本次解决,要么在 PR 中明确说明"本次未解决,仍依赖 Mock/未落库,已登记 `known-risks`"。
- **微信登录无真实实现**:全库仅 `MockWechatLoginProvider`,`WECHAT_APP_SECRET` 无人读。
- **照片直传不落库**:无直传确认端点;小程序用 `photoId`、后端返 `fileKey`,契约不符。
---
## 7. 审查流程(端到端)
```
作者自检 ──▶ 提 PR(用 .github/PULL_REQUEST_TEMPLATE.md)──▶ Reviewer 按 T 级执行 §4+§5
│
▼
有 Blocker/Major? ──是──▶ 退回修改(登记若豁免)
│否
CI 7 门禁触发 ──失败──▶ 修复并重跑
│全绿
合并(保护分支)──▶ 合并后回读 + 轮询 CI
```
- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`、`scripts/run-backend-contracts.mjs`),自认 T 级。
- **提 PR**:必须填 PR 模板,含"改了什么 / 怎么验证的(命令+输出)/ 风险级别 / 相关决策或已知风险"。
- **Reviewer**:先判 T 级,再走清单;对 Blocker/Major 给**可执行**的修改建议(具体到文件:行),不只写"这里有问题"。
- **CI 回退**:CI 红必须修复或证明是环境问题(如 KR-07 的 `docker` PATH、KR-08 的镜像源),不得"重试蒙过"。
- **合并**:保护分支禁止直接 push `main`;合并即视为已承担该 PR 的审查结论。
- **合并后**:回读 + 轮询 CI(§6.3)。
---
## 8. Reviewer 职责与礼仪
- **区分 blocker 与 nit**:Blocker/Major 拦下并给方案;Minor/Nit 用"建议"语气,不阻塞。
- **给可执行的反馈**:"建议把这里的 `RuntimeException` 换成 `BusinessException(ApiResponseCode.USER_NOT_FOUND)`" 优于 "异常处理不规范"。
- **不自以为替代测试**:审查不豁免作者写测试与负向注入的责任。
- **尊重决策源**:与 `decision-register` 的 LOCKED 项冲突的改动,先要求回到决策层确认,不擅自放行。
- **留痕**:拒绝理由、豁免转移都留在 PR 或 `known-risks.md`,便于复盘"逃逸"。
---
## 9. 度量与持续改进
- **逃逸率**:合并后在生产/实测发现的缺陷 ÷ 合并总数,按月统计,目标递减。
- **审查时延**:PR 从开到合并的中位时长,避免积压。
- **假绿复发计数**:§5 的 11 类模式再次出现的次数,归零为目标。
- **季度回看**:结合 `known-risks.md` 与 `PLAYBOOK.md`,把新踩的坑补充进 §4/§5,把已解决的债务从 `known-risks` 移除。
- **守卫同步**:每次新增契约守卫,同步上调 `MIN_GUARDS`;审查时核对守卫数。
---
*最后更新: 2026-09-23 — V1.0 初版。基于 PLAYBOOK 判据纪律、known-risks、decision-register 与 ci.yml 门禁,建立人工审查分级/清单/严重级别/合并准入/端到端流程。*
+92 -30
View File
@@ -1,59 +1,121 @@
#!/bin/bash
# ============================================
# 丹阳公益婚恋服务平台 — 生产上线冒烟测试
# 丹阳公益婚恋服务平台 — 生产上线冒烟测试(骨架)
# ============================================
set -euo pipefail
# 设计纪律(沿用项目「断言 ≠ 证据 / 不假绿」原则):
# - 任一 FAIL 立即退出 1;脚本不做「skip 伪装通过」。
# - 需登录的核心链路默认不跑;提供 OPERATOR_TOKEN / USER_TOKEN 后才展开,
# 未提供时明确打印「需人工真机验证」,不静默报绿。
# - 本脚本只覆盖「上线前可自动探活」的子集;13 项完整人工清单见
# docs/PHASE26-GO-LIVE-CHECKLIST.md。
set -uo pipefail
BASE_URL="${BASE_URL:-https://match.danyang.gov.cn}"
API="${BASE_URL%/}/api/v1"
PASS=0
FAIL=0
WARN=0
check() {
local name="$1"
local url="$2"
local expected="$3"
# check_status NAME URL EXPECTED_CODE
check_status() {
local name="$1" url="$2" expected="$3"
local code
code=$(curl -sf -o /dev/null -w "%{http_code}" "${BASE_URL}${url}" 2>/dev/null || echo "000")
code=$(curl -sS -o /dev/null -w "%{http_code}" "${url}" 2>/dev/null || echo "000")
if [ "$code" = "$expected" ]; then
PASS=$((PASS + 1))
echo " ✅ $name (HTTP $code)"
PASS=$((PASS + 1)); echo " ✅ $name (HTTP $code)"
else
FAIL=$((FAIL + 1))
echo " ❌ $name (期望$expected, 实际$code)"
FAIL=$((FAIL + 1)); echo " ❌ $name (期望 $expected, 实际 $code) — $url"
fi
}
# check_auth_reject NAME URL — 未带凭证必须 401
check_auth_reject() {
local name="$1" url="$2"
local code
code=$(curl -sS -o /dev/null -w "%{http_code}" "${url}" 2>/dev/null || echo "000")
if [ "$code" = "401" ]; then
PASS=$((PASS + 1)); echo " ✅ $name (未认证 401)"
else
FAIL=$((FAIL + 1)); echo " ❌ $name (期望 401, 实际 $code) — $url"
fi
}
# guarded NAME URL TOKEN — 带 token 探活,期望 2xx
guarded() {
local name="$1" url="$2" token="$3"
local code
code=$(curl -fsS -H "Authorization: Bearer ${token}" -o /dev/null -w "%{http_code}" "${url}" 2>/dev/null || echo "000")
if [ "$code" = "200" ]; then
PASS=$((PASS + 1)); echo " ✅ $name (HTTP 200)"
else
FAIL=$((FAIL + 1)); echo " ❌ $name (期望 200, 实际 $code) — $url"
fi
}
echo "============================================"
echo " 丹阳公益婚恋平台 — 生产冒烟测试"
echo " $(date '+%Y-%m-%d %H:%M:%S')"
echo " $(date '+%Y-%m-%d %H:%M:%S') BASE_URL=$BASE_URL"
echo "============================================"
echo ""
echo "【健康检查】"
check "后端live" "/api/v1/health/live" "200"
check "后端ready" "/api/v1/health/ready" "200"
check "版本信息" "/version" "200"
echo ""
echo "【认证流程】"
check "微信登录端点" "/api/v1/auth/wechat/login" "401" # 无code参数应400或401
echo "【1. 健康检查】"
check_status "后端 live" "${API}/health/live" "200"
check_status "后端 ready" "${API}/health/ready" "200"
check_status "版本信息" "${BASE_URL%/}/version" "200"
echo ""
echo "【核心功能】"
check "用户资料接口" "/api/v1/profile" "401" # 未登录应401
check "推荐列表接口" "/api/v1/matches/recommendations" "401"
echo "【2. 匿名鉴权守卫(应 401)】"
check_auth_reject "微信登录缺参" "${API}/auth/wechat/login"
check_auth_reject "用户资料未认证" "${API}/me/profile"
check_auth_reject "择偶条件未认证" "${API}/me/preferences"
check_auth_reject "推荐列表未认证" "${API}/matches/recommendations"
check_auth_reject "运营用户列表" "${API}/operator/users"
check_auth_reject "举报提交" "${API}/risk/reports"
echo ""
echo "【安全接口】"
check "管理员接口无认证拒绝" "/api/v1/operator/users" "401"
check "举报提交接口" "/api/v1/risk/reports" "401"
echo "【3. 安全接口(应 401,不泄露)】"
check_auth_reject "管理端统计" "${API}/operator/analytics/v11-dashboard"
echo ""
echo "【Nginx】"
check "Nginx首页" "/" "200"
echo "【4. Nginx / 入口】"
check_status "Nginx 根路径" "${BASE_URL%/}/" "200"
check_status "管理端子路径" "${BASE_URL%/}/admin/" "200"
# ---- 需登录链路:仅在提供 token 时展开 ----
if [ -n "${OPERATOR_TOKEN:-}" ]; then
echo ""
echo "【5. 运营端核心链路(已提供 OPERATOR_TOKEN)】"
guarded "运营用户列表" "${API}/operator/users" "${OPERATOR_TOKEN}"
guarded "管理端统计" "${API}/operator/analytics/v11-dashboard" "${OPERATOR_TOKEN}"
else
WARN=$((WARN + 1))
echo ""
echo " ⚠️ 未提供 OPERATOR_TOKEN:运营端/用户端需登录链路(推荐·匹配·聊天·活动·咨询·红娘·内容·照片 presigned)"
echo " 跳过自动探测,请按 docs/PHASE26-GO-LIVE-CHECKLIST.md 第 2~13 项真机/管理端人工验证。"
fi
if [ -n "${USER_TOKEN:-}" ]; then
echo ""
echo "【6. 用户端核心链路(已提供 USER_TOKEN)】"
guarded "我的资料" "${API}/me/profile" "${USER_TOKEN}"
guarded "择偶条件" "${API}/me/preferences" "${USER_TOKEN}"
guarded "推荐列表" "${API}/matches/recommendations" "${USER_TOKEN}"
else
WARN=$((WARN + 1))
echo ""
echo " ⚠️ 未提供 USER_TOKEN:用户端核心链路跳过(同上,需人工真机验证)。"
fi
echo ""
echo "============================================"
echo " 结果: PASS=${PASS} FAIL=${FAIL}"
echo " 结果: PASS=${PASS} FAIL=${FAIL} WARN=${WARN}"
echo "============================================"
[ ${FAIL} -eq 0 ] && echo "✅ 冒烟测试通过" && exit 0 || echo "❌ 存在${FAIL}个失败项" && exit 1
if [ "${FAIL}" -ne 0 ]; then
echo "❌ 冒烟存在 ${FAIL} 个失败项,未通过"
exit 1
fi
if [ "${WARN}" -ne 0 ]; then
echo "⚠️ 自动探活通过,但 ${WARN} 类需登录链路未覆盖(见上方 ⚠️),上线前须人工确认。"
fi
echo "✅ 冒烟自动探活通过"
exit 0
+2 -2
View File
@@ -76,8 +76,8 @@
"enablePullDownRefresh": false
},
"tabBar": {
"color": "#999999",
"selectedColor": "#E96B6B",
"color": "#47528C",
"selectedColor": "#F4005A",
"backgroundColor": "#FFFFFF",
"borderStyle": "black",
"list": [
+35 -32
View File
@@ -66,6 +66,22 @@ page {
--gradient-night: linear-gradient(160deg, #172A72 0%, #25104F 100%);
--gradient-night-soft: linear-gradient(160deg, #3445A1 0%, #172A72 100%);
/* ---- 首页 4×2 入口宫格图标渐变(参考图 2:高饱和圆角砖 + 白色描画图标) ----
白图标的对比度判据是**非文本图形 3:1**(WCAG 1.4.11)。渐变是斜向的、图标又跨在
两色之间,所以**较亮的一端也必须达标** —— 下表注释给的是 0% 处实算值,100% 更暗只会更高。
色相按业务语义固定(不随 tone 名漂移):找对象/婚前辅导=玫瑰粉系、公益红娘/服务站=暖橙系、
公益活动=紫罗兰、婚恋咨询=蓝、婚恋课堂=紫、婚恋反诈=绿。 */
--gradient-entry-rose: linear-gradient(135deg, #F56287 0%, #F34671 100%); /* 3.01:1 / 3.54:1 */
--gradient-entry-orange: linear-gradient(135deg, #F66A06 0%, #D95E06 100%); /* 3.01:1 / 3.78:1 */
--gradient-entry-violet: linear-gradient(135deg, #A380F5 0%, #8F64F2 100%); /* 3.02:1 / 3.98:1 */
--gradient-entry-blue: linear-gradient(135deg, #5195F6 0%, #3484F4 100%); /* 3.01:1 / 3.65:1 */
--gradient-entry-purple: linear-gradient(135deg, #B27BF0 0%, #A260ED 100%); /* 3.01:1 / 3.87:1 */
--gradient-entry-pink: linear-gradient(135deg, #F2619E 0%, #F0458C 100%); /* 3.01:1 / 3.54:1 */
--gradient-entry-green: linear-gradient(135deg, #18A979 0%, #148F66 100%); /* 3.00:1 / 4.08:1 */
--gradient-entry-amber: linear-gradient(135deg, #E9740C 0%, #CC650B 100%); /* 3.01:1 / 3.86:1 */
/* 图标砖落影:比 --mini-shadow 更小,避免 4 列宫格里相邻砖块的投影互相压边 */
--mini-entry-shadow: 0 8rpx 18rpx rgba(28, 30, 88, 0.18);
/* ---- 夜间氛围层(v2 新增;参考图有光晕与星点,此前完全没有) ---- */
--mini-glow-coral: rgba(255, 79, 145, 0.40);
--mini-glow-indigo: rgba(94, 124, 255, 0.32);
@@ -340,36 +356,23 @@ page {
}
/* ==========================================================================
可访问性工具类(v2 新增)
可访问性:只用 token,不用工具类(2026-09-23 修订)
--------------------------------------------------------------------------
v2 曾在此处新增 .tap-min / .tap-gap / .focusable / .pressable 四个工具类。
复检发现全部零引用(等价能力已各自写在组件样式内,例如 button/index.wxss
直接使用 min-height:var(--tap-min)),且存在两个硬问题:
1) `.tap-gap > * + *` 用了通配符与兄弟选择器 —— WXSS 不支持,
wcc 编译期直接报 `error at token '*'`,导致整个小程序编译失败。
(这是编译错误,不是静默失效,代价最高。)
2) `.pressable:active` / `.focusable:focus-visible` 的伪类在 view 上
不生效(小程序须用 hover-class),属于"写了等于没写"。
故整块移除,保留 token。需要热区/间距时直接引用 token:
min-height: var(--tap-min); → 88rpx = 44px,最小可点区域
gap: var(--tap-gap); → 16rpx = 8px,相邻目标间距(flex gap)
【WXSS 禁用清单】星号通配符、兄弟选择器、属性选择器。
【注释约束】WXSS 注释**不可嵌套**:内层注释的结束标记会提前终止外层注释,
其后的文字会被当成真实样式解析,导致编译失败或样式被污染。
(2026-09-23 实证:本块注释初版即踩此坑。)
========================================================================== */
/* 保证任意可点元素达到 44×44px 热区,且视觉尺寸可独立控制 */
.tap-min {
min-width: var(--tap-min);
min-height: var(--tap-min);
display: flex;
align-items: center;
justify-content: center;
}
/* 相邻可点元素之间的最小间距 */
.tap-gap > * + * {
margin-left: var(--tap-gap);
}
/* 焦点可见(小程序支持键盘/无障碍焦点) */
.focusable:focus,
.focusable:focus-visible {
outline: 4rpx solid var(--color-border-focus);
outline-offset: 2rpx;
}
/* 按压反馈:所有可点元素统一使用,替代"点了没反应" */
.pressable {
transition: transform var(--motion-fast) var(--ease-spring),
opacity var(--motion-fast) var(--ease-standard);
}
.pressable:active {
transform: scale(var(--press-scale));
opacity: var(--press-opacity);
}
+28 -14
View File
@@ -2,7 +2,21 @@
## 概述
为小程序 5 个底部导航 Tab 设计的圆润线性风格图标,包含未选中(灰色)和选中(玫瑰红)两种状态,适配三种屏幕密度(1x/2x/3x)。
为小程序 5 个底部导航 Tab 设计的圆润线性风格图标,包含未选中(藏青蓝 `#47528C`)和选中(玫红 `#F4005A`)两种状态,适配三种屏幕密度(1x/2x/3x)。配色取自产品参考图,藏青蓝与导航栏品牌色(`#171D72`)同系、玫红作为高对比激活色。
> ⚠️ **改动本目录前必读 —— 图标整排变「破图」的头号原因**
>
> 微信开发者工具自 `1.05.2112301` 起默认开启**「过滤无依赖文件」**(`ignoreDevUnusedFiles`)。
> 它的依赖分析只看 **WXML/JS**,而 **tabBar 图标只在 `app.json` 里被引用**,
> 于是被判定为「无依赖文件」并从**开发包中剔除** → 模拟器/真机上 5 个图标**全部加载失败**,
> 表现为底部导航整排破图(而 WXML 引用的图片一切正常,极易误判为图片损坏)。
>
> 本目录的 `assets/tabbar/*.png` 是**全仓库唯一「只被 app.json 引用」的图片资源**,因此是唯一受害者。
>
> 处置:`project.config.json`(仓库根 + `miniapp/`)与本地 `project.private.config.json`
> 中 `setting.ignoreDevUnusedFiles` / `setting.ignoreUploadUnusedFiles` **必须为 `false`**。
> 已由守卫 `ui-tests/tabbar-icons.mjs` 强制校验,请勿改回 `true`。
## 设计规范
@@ -12,41 +26,41 @@
| 安全区域 | 53 × 53 px(最小边距 14px) |
| 线宽 | 4px(缩放到导航栏后约 1.3px) |
| 圆角 | 端点圆角 + 转折圆角 |
| 填充 | 默认态线性;选中态填充核心爱心 |
| 填充 | 默认态线性描边;选中态实心填充 + 白点/白线点缀 |
## 颜色规范
| 状态 | 颜色值 | 用途 |
|------|--------|------|
| 未选中 | `#999999` | 默认状态图标 |
| 选中 | `#E96B6B` | 激活状态图标 |
| 背景 | `#FFFFFF` | 透明背景(PNG 支持 alpha) |
| 未选中 | `#47528C` | 默认状态图标(藏青蓝,与导航栏品牌色同系) |
| 选中 | `#F4005A` | 激活状态图标(玫红/品红,高对比) |
| 背景 | 透明 | PNG 支持 alpha,无需底色 |
## 图标清单
### 1. 首页 (home)
- **意象**: 爱心小屋
- **元素**: 圆角屋顶 + 房屋主体 + 门内爱心
- **意象**: 家
- **元素**: 圆角屋顶 + 房屋主体 + 门洞(线条描边,选中态实心玫红)
- **文件**: `home.png` / `home-active.png`
### 2. 找对象 (discover)
- **意象**: 双人缘分
- **元素**: 两个人物轮廓 + 中央爱心
- **意象**: 心动缘分
- **元素**: 单颗爱心轮廓(选中态实心玫红),呼应「找对象」主题
- **文件**: `discover.png` / `discover-active.png`
### 3. 消息 (message)
- **意象**: 真诚沟通
- **元素**: 圆角气泡 + 爱心
- **元素**: 圆角对话气泡 + 三个圆点(选中态气泡实心玫红、圆点为白)
- **文件**: `message.png` / `message-active.png`
### 4. 活动 (activity)
- **意象**: 公益相遇
- **元素**: 日历主体 + 挂环 + 爱心
- **元素**: 圆角日历主体 + 顶部双挂环 + 中央加号(选中态日历实心玫红、加号为白)
- **文件**: `activity.png` / `activity-active.png`
### 5. 我的 (mine)
- **意象**: 个人守护
- **元素**: 单人轮廓 + 胸前爱心
- **意象**: 个人中心
- **元素**: 圆形头像 + 半身轮廓(选中态实心玫红)
- **文件**: `mine.png` / `mine-active.png`
## 导出尺寸
@@ -64,7 +78,7 @@
所有图标均提供可编辑的 SVG 版本,位于 `svg/` 目录:
- 可直接在 Figma 中导入编辑
- 可调整颜色、粗细、圆角等参数
- 颜色替换:将 `stroke="#999999"` 改为 `stroke="#E96B6B"` 即可切换激活状态
- 颜色替换:将 `stroke="#47528C"` 改为 `fill="#F4005A" stroke="#F4005A"` 即可切换激活状态(选中态为实心填充 + 白色点缀)
## 文件位置
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

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