10 Commits
Author SHA1 Message Date
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
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
184 changed files with 10862 additions and 3422 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 / 决策 / 风险编号 -->
+1 -1
View File
@@ -1 +1 @@
1.1.0-RC.1 1.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "danyang-match-admin", "name": "danyang-match-admin",
"version": "1.0.0-rc.2", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+3
View File
@@ -19,6 +19,9 @@
# 后者生成 Vue 版本桥接),允许它们执行。 # 后者生成 Vue 版本桥接),允许它们执行。
packages: packages:
- '.' - '.'
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
- vue-demi - vue-demi
+43 -5
View File
@@ -1,14 +1,18 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import router from '@/router' import router from '@/router'
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
const client = axios.create({ const client = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
timeout: 10000, timeout: 10000,
}) })
// 防止 401 时并发触发多次刷新
let refreshing = false
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use( client.interceptors.response.use(
(res) => res.data, (res) => res.data,
(err) => { async (err) => {
if (err.response?.status === 401) { const status = err.response?.status
localStorage.removeItem('accessToken') const original = err.config
router.push('/login') const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
refreshing = true
try {
const resp: any = await client.post('/auth/operator/refresh', {})
if (resp?.success) {
const newToken = resp.data.accessToken
setAccessToken(newToken)
refreshing = false
;(original as any).__retried = true
const headers = (original.headers || {}) as Record<string, string>
headers.Authorization = `Bearer ${newToken}`
original.headers = headers as any
return client(original)
}
} catch {
// 刷新失败:清除并跳登录
} finally {
refreshing = false
}
clearAccessToken()
ElMessage.error('登录已过期,请重新登录') ElMessage.error('登录已过期,请重新登录')
router.push('/login')
return Promise.reject(err)
}
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
if (status === 401) {
if (!isAuthEndpoint) {
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
}
} else if (err.response?.data?.message) { } else if (err.response?.data?.message) {
ElMessage.error(err.response.data.message) ElMessage.error(err.response.data.message)
} else { } else {
@@ -80,6 +80,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/** /**
* U32 活动签到工作台(Phase 15) * U32 活动签到工作台(Phase 15)
* *
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
} }
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类' const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` }) const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
const formatTime = (v: any) => { const formatTime = (v: any) => {
if (!v) return '-' if (!v) return '-'
@@ -42,6 +42,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get } from '@/api/client' import { get } from '@/api/client'
@@ -55,7 +56,7 @@ const mutualList = ref<any[]>([])
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
const rows = res.data || [] const rows = res.data || []
tableData.value = searchActivityId.value tableData.value = searchActivityId.value
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
drawerVisible.value = true drawerVisible.value = true
currentActivity.value = tableData.value.find(row => row.activityId === activityId) currentActivity.value = tableData.value.find(row => row.activityId === activityId)
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` }) const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
mutualList.value = mutualRes.data || [] mutualList.value = mutualRes.data || []
} catch (e) { } catch (e) {
@@ -168,6 +168,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/** /**
* 活动管理(V1.1 Phase 14) * 活动管理(V1.1 Phase 14)
* *
@@ -259,7 +260,7 @@ function emptyForm() {
} }
function authHeader() { function authHeader() {
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` } return { Authorization: `Bearer ${getAccessToken()}` }
} }
async function loadData() { async function loadData() {
@@ -213,6 +213,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client' import { get } from '@/api/client'
import { token } from '@/theme/tokens' import { token } from '@/theme/tokens'
@@ -302,7 +303,7 @@ async function loadFunnel() {
try { try {
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手** // 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。 // `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = localStorage.getItem('accessToken') const authToken = getAccessToken()
if (dateRange.value[0] && dateRange.value[1]) { if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([ const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', { get('/operator/analytics/user-funnel', {
@@ -328,7 +329,7 @@ async function loadFunnel() {
async function loadDailySummary() { async function loadDailySummary() {
try { try {
const authToken = localStorage.getItem('accessToken') const authToken = getAccessToken()
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` }) const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
dailySummary.value = res.data || [] dailySummary.value = res.data || []
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -84,6 +84,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens' import { token as tokenColor } from '@/theme/tokens'
@@ -109,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || [] tableData.value = res.data || []
loadStats() loadStats()
@@ -119,7 +120,7 @@ async function loadData() {
async function loadStats() { async function loadStats() {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {} const data = res.data || {}
stats.value = [ stats.value = [
@@ -138,7 +139,7 @@ async function openDetail(id: string) {
decision.value = 'approve' decision.value = 'approve'
decisionNote.value = '' decisionNote.value = ''
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` }) const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data detail.value = res.data
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -148,7 +149,7 @@ async function handleProcess() {
if (!decision.value) return if (!decision.value) return
processing.value = true processing.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await post(`/operator/appeals/${currentId.value}/process`, await post(`/operator/appeals/${currentId.value}/process`,
{ decision: decision.value, decisionNote: decisionNote.value }, { decision: decision.value, decisionNote: decisionNote.value },
{ Authorization: `Bearer ${token}` }) { Authorization: `Bearer ${token}` })
+2 -1
View File
@@ -38,6 +38,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get } from '@/api/client' import { get } from '@/api/client'
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/audit/logs', { const res: any = await get('/operator/audit/logs', {
resourceType: resourceFilter.value || undefined, resourceType: resourceFilter.value || undefined,
page: page.value, page: page.value,
@@ -62,6 +62,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client' import { get, post, put } from '@/api/client'
@@ -79,7 +80,7 @@ const orgMap = ref<Record<string, string>>({})
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
tableData.value = res.data?.list || [] tableData.value = res.data?.list || []
total.value = res.data?.total || 0 total.value = res.data?.total || 0
@@ -89,7 +90,7 @@ async function loadData() {
async function loadOrgs() { async function loadOrgs() {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
orgList.value = res.data || [] orgList.value = res.data || []
orgList.value.forEach(o => { orgMap.value[o.id] = o.name }) orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
@@ -105,7 +106,7 @@ async function save() {
if (!form.value.username || !form.value.password) return if (!form.value.username || !form.value.password) return
saving.value = true saving.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` }) await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
formVisible.value = false formVisible.value = false
loadData() loadData()
@@ -115,7 +116,7 @@ async function save() {
async function toggleStatus(row: any) { async function toggleStatus(row: any) {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable' const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` }) await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
loadData() loadData()
@@ -126,7 +127,7 @@ async function resetPwd(id: string) {
const pwd = prompt('请输入新密码(至少8位)') const pwd = prompt('请输入新密码(至少8位)')
if (!pwd || pwd.length < 8) return if (!pwd || pwd.length < 8) return
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` }) await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
} }
@@ -60,6 +60,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client' import { get, post, put } from '@/api/client'
@@ -78,7 +79,7 @@ const ORG_TYPE_TEXT: Record<string, string> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || [] tableData.value = res.data || []
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -101,7 +102,7 @@ async function save() {
if (!form.value.name) return if (!form.value.name) return
saving.value = true saving.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
if (editId.value) { if (editId.value) {
await put(`/operator/organizations/${editId.value}`, form.value, { await put(`/operator/organizations/${editId.value}`, form.value, {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
@@ -119,7 +120,7 @@ async function save() {
async function toggleStatus(row: any) { async function toggleStatus(row: any) {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable' const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/organizations/${row.id}/${action}`, {}, { await put(`/operator/organizations/${row.id}/${action}`, {}, {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
@@ -98,6 +98,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens' import { token as tokenColor } from '@/theme/tokens'
@@ -124,7 +125,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() { async function loadData() {
loading.value = true loading.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` }) const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || [] tableData.value = res.data || []
loadStats() loadStats()
@@ -134,7 +135,7 @@ async function loadData() {
async function loadStats() { async function loadStats() {
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` }) const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {} const data = res.data || {}
stats.value = [ stats.value = [
@@ -152,7 +153,7 @@ async function openDetail(reportId: string) {
processResult.value = 'no_action' processResult.value = 'no_action'
processNote.value = '' processNote.value = ''
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` }) const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data detail.value = res.data
} catch (e) { console.error(e) } } catch (e) { console.error(e) }
@@ -162,7 +163,7 @@ async function handleProcess() {
if (!processResult.value) return if (!processResult.value) return
processing.value = true processing.value = true
try { try {
const token = localStorage.getItem('accessToken') const token = getAccessToken()
await post(`/operator/risk/reports/${currentReportId.value}/process`, await post(`/operator/risk/reports/${currentReportId.value}/process`,
{ processResult: processResult.value, processNote: processNote.value }, { processResult: processResult.value, processNote: processNote.value },
{ Authorization: `Bearer ${token}` }) { Authorization: `Bearer ${token}` })
+4 -1
View File
@@ -48,10 +48,13 @@ const routes: RouteRecordRaw[] = [
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。 // 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }) const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
router.beforeEach((to) => { router.beforeEach(async (to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const requiredPerms = (to.meta.permissions as string[]) || [] const requiredPerms = (to.meta.permissions as string[]) || []
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
await authStore.initializeSession()
// 1. 登录检查 // 1. 登录检查
if (to.path !== '/login' && !authStore.isLoggedIn) { if (to.path !== '/login' && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } } return { path: '/login', query: { redirect: to.fullPath } }
+52 -21
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { post } from '@/api/client' import { post } from '@/api/client'
import { setAccessToken, clearAccessToken } from '@/utils/token'
interface OperatorInfo { interface OperatorInfo {
operatorId: string operatorId: string
@@ -23,38 +24,68 @@ export const useAuthStore = defineStore('auth', () => {
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole] const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null } ? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
: null : null
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
const operator = ref<OperatorInfo | null>(
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
)
const isLoggedIn = computed(() => !!token.value) const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
const operator = ref<OperatorInfo | null>(previewOperator)
let sessionInitialized = false
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || []) const permissions = computed(() => operator.value?.permissions || [])
function applyAuthData(data: any) {
token.value = data.accessToken || ''
operator.value = {
operatorId: data.operatorId,
name: data.name,
username: data.username,
permissions: data.permissions || [],
organizationId: data.organizationId ?? null,
}
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
setAccessToken(token.value)
}
async function login(username: string, password: string) { async function login(username: string, password: string) {
const res: any = await post('/auth/operator/login', { username, password }) const res: any = await post('/auth/operator/login', { username, password })
if (res.success) { if (res.success) {
token.value = res.data.accessToken applyAuthData(res.data)
operator.value = {
operatorId: res.data.operatorId,
name: res.data.name,
username: res.data.username,
permissions: res.data.permissions || [],
organizationId: res.data.organizationId ?? null,
}
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value))
return res.data return res.data
} }
throw new Error(res.message || '登录失败') throw new Error(res.message || '登录失败')
} }
function logout() { /**
token.value = '' * 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
operator.value = null * 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
localStorage.removeItem('accessToken') * 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
localStorage.removeItem('operator') */
async function initializeSession(): Promise<boolean> {
if (sessionInitialized) return isLoggedIn.value
sessionInitialized = true
if (token.value) return true
try {
const res: any = await post('/auth/operator/refresh', {})
if (res.success) {
applyAuthData(res.data)
return true
}
} catch {
// 未登录或 refresh 失效,交由路由守卫跳转登录页
}
return false
} }
return { token, operator, isLoggedIn, permissions, login, logout } function logout() {
// 尽力通知后端注销并清除 Cookie(best-effort)
try {
post('/auth/operator/logout', {}).catch(() => {})
} catch {
// ignore
}
token.value = ''
operator.value = null
clearAccessToken()
}
return { token, operator, isLoggedIn, permissions, login, logout, initializeSession }
}) })
+19
View File
@@ -0,0 +1,19 @@
// 内存态访问令牌。
//
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
// Authorization 头承载。两者并存,互不冲突。
let currentToken = ''
export function getAccessToken(): string {
return currentToken
}
export function setAccessToken(token: string): void {
currentToken = token || ''
}
export function clearAccessToken(): void {
currentToken = ''
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,9 +13,9 @@
<groupId>com.danyang.match</groupId> <groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId> <artifactId>danyang-match-platform</artifactId>
<version>1.0.0-RC.1</version> <version>1.1.0</version>
<name>Danyang Match Platform</name> <name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description> <description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
<properties> <properties>
<java.version>21</java.version> <java.version>21</java.version>
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.shared.idempotency.Idempotent; import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.relationship.service.RelationshipService; import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal; import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -30,7 +30,8 @@ import java.util.Map;
public class ActivityInterestController { public class ActivityInterestController {
private final ActivityInterestService interestService; private final ActivityInterestService interestService;
private final RelationshipService relationshipService; /** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
private final ActivityMutualConsentService consentService;
private final ActivityMapper activityMapper; private final ActivityMapper activityMapper;
@GetMapping("/operator/activity-interest-summary") @GetMapping("/operator/activity-interest-summary")
@@ -105,7 +106,12 @@ public class ActivityInterestController {
/** /**
* 确认开启线上沟通(V1.1 Phase 08) * 确认开启线上沟通(V1.1 Phase 08)
* POST /api/v1/activities/{id}/interests/confirm * POST /api/v1/activities/{id}/interests/confirm
* 双方确认后创建 Match Episode,不自动创建 Conversation *
* <p><b>2026-09-23 UAT 走查修复(INV-014/015)</b>:此前本端点仅校验「双向互选」便直调
* {@code RelationshipService.confirmActivityInterest} —— 那是「建 Match」的底层原语
* (不做任何同意校验),于是**单方调用即可建 Match/会话**,绕过「双方各自显式确认」
* 的二阶段同意。现改为与 {@code /activities/{id}/mutual/*} 走**同一条**二阶段链路:
* 本端点只登记「我方确认」,只有双方都 accepted 时才建 Match/会话。
*/ */
@Idempotent @Idempotent
@PostMapping("/{id}/interests/confirm") @PostMapping("/{id}/interests/confirm")
@@ -120,13 +126,16 @@ public class ActivityInterestController {
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"partnerId不能为空"); "partnerId不能为空");
} }
// 验证互选状态 // 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
if (!interestService.hasMutualInterest(userId, partnerId, id)) { if (!interestService.hasMutualInterest(userId, partnerId, id)) {
return ApiResponse.error( return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT, com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
"您与该用户未形成活动双向互选"); "您与该用户未形成活动双向互选");
} }
return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId)); // 二阶段同意:先幂等确保同意记录存在,再仅提交「我方确认」。
// 单方调用不再可能创建 Match(INV-014/015)。
consentService.createMutualConsent(id, userId, partnerId);
return ApiResponse.ok(consentService.confirmConnection(userId, id, partnerId));
} }
/** /**
@@ -26,6 +26,7 @@ import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* 活动互选服务 * 活动互选服务
@@ -69,7 +70,7 @@ public class ActivityInterestService {
// 2. 验证用户已报名且通过审核 // 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您未报名该活动")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈"); throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
} }
@@ -32,6 +32,7 @@ import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -407,7 +408,7 @@ public class ActivityService {
@Transactional @Transactional
public ActivityRegistration cancelRegistration(String userId, String activityId) { public ActivityRegistration cancelRegistration(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("没有找到报名记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) && if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { !Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException; import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* 签到服务 * 签到服务
@@ -64,7 +65,7 @@ public class CheckinService {
*/ */
public Map<String, Object> generateCheckinToken(String userId, String activityId) { public Map<String, Object> generateCheckinToken(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId) ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您没有报名该活动")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) { if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到"); throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
@@ -16,6 +16,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
@Slf4j @Slf4j
@Service @Service
@@ -67,7 +69,7 @@ public class ActivityMutualConsentService {
@Transactional @Transactional
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) { public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId) ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted, // 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。 // 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
@@ -76,7 +78,7 @@ public class ActivityMutualConsentService {
// 回读权威状态(可能受并发另一方提交影响) // 回读权威状态(可能受并发另一方提交影响)
consent = consentMapper.findByActivityPair(activityId, userId, partnerId) consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("互选记录已不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()); boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()); boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
@@ -120,7 +122,7 @@ public class ActivityMutualConsentService {
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) { public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId) ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) { if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED); consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
} else { } else {
@@ -1,8 +1,13 @@
package com.danyang.match.platform.auth.controller; package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
import com.danyang.match.platform.auth.service.OperatorAuthService; import com.danyang.match.platform.auth.service.OperatorAuthService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -17,14 +22,83 @@ import java.util.Map;
public class OperatorAuthController { public class OperatorAuthController {
private final OperatorAuthService operatorAuthService; private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter;
private final JwtTokenService jwtTokenService;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) { public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String username = body.get("username"); String username = body.get("username");
String password = body.get("password"); String password = body.get("password");
if (username == null || username.isBlank() || password == null || password.isBlank()) { if (username == null || username.isBlank() || password == null || password.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空"); return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
} }
return ApiResponse.ok(operatorAuthService.login(username, password)); Map<String, Object> result = operatorAuthService.login(username, password);
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌
cookieWriter.writeLoginCookies(response,
(String) result.get("accessToken"), (String) result.get("refreshToken"),
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
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"),
jwtTokenService.getExpirationMs() / 1000, jwtTokenService.getRefreshExpirationMs() / 1000);
return ApiResponse.ok(result);
}
/**
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
* token 取自 Authorization 头,缺省时回退到 access Cookie。
*/
@PostMapping("/logout")
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) {
String token = readBearer(request);
if (token != null) {
try {
String operatorId = jwtTokenService.getUserIdFromToken(token);
operatorAuthService.logout(operatorId, token);
} catch (Exception ignored) {
// token 已失效也无妨,仍清除 Cookie
}
}
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,50 @@
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;
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
long accessMaxAgeSec, long refreshMaxAgeSec) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, refreshToken, refreshMaxAgeSec).toString());
}
public void clearCookies(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, "", 0).toString());
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, "", 0).toString());
}
private ResponseCookie build(String name, String value, long maxAgeSec) {
return ResponseCookie.from(name, value)
.httpOnly(true)
.secure(secure)
.sameSite("Lax")
.path("/api/v1")
.maxAge(maxAgeSec)
.build();
}
}
@@ -13,10 +13,15 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Mock 微信登录 Provider(开发/测试环境使用) * Mock 微信登录 Provider(开发/测试环境使用)。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
*/ */
@Component @Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true) @ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider { public class MockWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class); private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
@@ -0,0 +1,100 @@
package com.danyang.match.platform.auth.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 真实微信登录 Provider(生产环境使用)。
*
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>安全要点:
* <ul>
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
* <li>绝不把 appSecret / session_key 回传给前端。</li>
* </ul>
*/
@Component
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
public class RealWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
@Value("${wechat.app-id:}")
private String appId;
@Value("${wechat.app-secret:}")
private String appSecret;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, String> getCodeSession(String code) {
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
}
if (code == null || code.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
}
String url = CODE2SESSION_URL
+ "?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
+ "&grant_type=authorization_code";
try {
String resp = restTemplate.getForObject(url, String.class);
JsonNode node = objectMapper.readTree(resp);
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
}
String openid = node.has("openid") ? node.get("openid").asText() : null;
if (openid == null || openid.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
Map<String, String> result = new HashMap<>();
result.put("openid", openid);
result.put("unionid", unionid);
result.put("session_key", sessionKey);
result.put("appid", appId);
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Wechat] code2session 调用异常", e);
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
}
}
@Override
public boolean isMock() {
return false;
}
}
@@ -30,6 +30,7 @@ import java.time.Duration;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -90,7 +91,9 @@ public class AuthService {
// 将 refreshToken 存入 Redis(用于撤销) // 将 refreshToken 存入 Redis(用于撤销)
String refreshTokenKey = "auth:refresh_token:" + user.getId(); String refreshTokenKey = "auth:refresh_token:" + user.getId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2)); // 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
// 记录审计日志 // 记录审计日志
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null); saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
@@ -111,7 +114,7 @@ public class AuthService {
public void bindPhone(String userId, String phoneCode) { public void bindPhone(String userId, String phoneCode) {
// TODO: Phase 03 对接真实短信验证码 // TODO: Phase 03 对接真实短信验证码
User user = userMapper.findById(userId) User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
// 检查手机号是否已被其他用户占用 // 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> { userMapper.findByPhone(phoneCode).ifPresent(existing -> {
@@ -172,9 +175,9 @@ public class AuthService {
String newAccessToken = jwtTokenService.generateAccessToken(userId, null); String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
String newRefreshToken = jwtTokenService.generateRefreshToken(userId); String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
// 更新 Redis // 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken, redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2)); Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccessToken); result.put("accessToken", newAccessToken);
@@ -200,7 +203,7 @@ public class AuthService {
*/ */
public Map<String, Object> getMeStatus(String userId) { public Map<String, Object> getMeStatus(String userId) {
User user = userMapper.findById(userId) User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
Map<String, Object> status = new HashMap<>(); Map<String, Object> status = new HashMap<>();
status.put("userId", user.getId()); status.put("userId", user.getId());
@@ -25,6 +25,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -144,7 +145,7 @@ public class OperatorAuthService {
*/ */
public Map<String, Object> getMe(String operatorId) { public Map<String, Object> getMe(String operatorId) {
Operator operator = operatorMapper.findById(operatorId) Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId); List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
List<String> permissionCodes = permissions.stream() List<String> permissionCodes = permissions.stream()
@@ -176,6 +177,11 @@ public class OperatorAuthService {
throw new UnauthorizedException("Refresh Token 无效"); throw new UnauthorizedException("Refresh Token 无效");
} }
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
if (operatorId == null || operatorId.isBlank()) {
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
}
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId); String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) { if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效"); throw new UnauthorizedException("Refresh Token 无效");
@@ -193,6 +199,12 @@ public class OperatorAuthService {
result.put("accessToken", accessToken); result.put("accessToken", accessToken);
result.put("refreshToken", newRefreshToken); result.put("refreshToken", newRefreshToken);
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000); result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
result.put("operatorId", operatorInfo.get("operatorId"));
result.put("name", operatorInfo.get("name"));
result.put("username", operatorInfo.get("username"));
result.put("organizationId", operatorInfo.get("organizationId"));
result.put("permissions", operatorInfo.get("permissions"));
return result; return result;
} }
@@ -215,9 +215,21 @@ public class JwtTokenService {
} }
/** /**
* 获取 Token 过期时间 * 获取 Access Token 过期时间(毫秒)
*/ */
public long getExpirationMs() { public long getExpirationMs() {
return accessExpirationMs; return accessExpirationMs;
} }
/**
* 获取 Refresh Token 过期时间(毫秒)。
*
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
*/
public long getRefreshExpirationMs() {
return refreshExpirationMs;
}
} }
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper; private final LocalEligibilityMapper localEligibilityMapper;
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
private final MatchScoringService matchScoringService;
/** /**
* 获取每日推荐列表 * 获取每日推荐列表
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
return null; return null;
} }
// 2. 双向 Required 检查 // 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null); UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
if (targetPref == null) { if (targetPref == null) {
return null; // 目标用户未设置择偶条件,无法双向验证 return null; // 目标用户未设置择偶条件,无法双向验证
} }
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
// A → B Required 检查 UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
if (!checkBidirectionalRequired(myPref, targetPref)) { if (myProfile == null || targetProfile == null) {
return null; // 资料缺失则无法核验 Required,不假定满足
}
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
return null; return null;
} }
@@ -175,36 +180,18 @@ public class MatchEligibilityService {
return "verified".equals(el.getVerificationStatus()); return "verified".equals(el.getVerificationStatus());
} }
/** // checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
* 双向 Required 检查 // (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
* Required 是 Hard Constraint,不可自动放宽
*/
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
return true;
}
/** /**
* 方向性评分(A对B的适配度) * 方向性评分(A对B的适配度)
* 基于 Preferred 维度计算 0-100 分 * 基于 Preferred 维度计算 0-100 分
*/ */
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) { private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
double score = 0; // 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() + return userProfileMapper.findByUserId(toUserId)
pref.getMaritalWeight() + pref.getChildrenWeight() + .map(p -> matchScoringService.scoreDirectionally(pref, p))
pref.getEducationWeight() + pref.getHeightWeight() + .orElse(0.0);
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
pref.getLifestyleWeight() + pref.getInterestWeight();
// TODO: 实现各维度评分逻辑
// 年龄匹配:在范围内得满分,超出范围按比例扣分
// 地区匹配:同地区得满分,不同地区按比例扣分
// ...
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
return (int)(Math.round(score / totalWeight * 100));
} }
/** /**
@@ -212,20 +199,21 @@ public class MatchEligibilityService {
*/ */
private List<String> generateReasons(String userId, String targetUserId, private List<String> generateReasons(String userId, String targetUserId,
UserPreference myPref, UserPreference targetPref) { UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>(); // 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
// TODO: 根据实际资料生成真实推荐理由 return userProfileMapper.findByUserId(targetUserId)
reasons.add("年龄符合双方择偶要求"); .map(p -> matchScoringService.reasons(myPref, p))
reasons.add("都长期生活在丹阳"); .orElseGet(ArrayList::new);
reasons.add("婚姻计划接近");
return reasons;
} }
/** /**
* 获取已Pass的用户ID集合(冷却期内) * 获取已Pass的用户ID集合(冷却期内)
*/ */
private Set<String> getPassedUserIds(String userId, Instant since) { private Set<String> getPassedUserIds(String userId, Instant since) {
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id // 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
return Collections.emptySet(); return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
.filter(r -> "passed".equals(r.getAction()))
.map(RecommendationRecord::getTargetUserId)
.collect(Collectors.toSet());
} }
/** /**
@@ -240,14 +228,28 @@ public class MatchEligibilityService {
} }
/** /**
* 构建候选池(简化版) * 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
* TODO: Phase 06 后续接入数据库查询可用用户 * (2026-09-23:替换原 `return List.of()` 空实现。)
*/ */
private List<String> buildCandidatePool(String userId, UserPreference myPref, private List<String> buildCandidatePool(String userId, UserPreference myPref,
Set<String> excludedIds, Set<String> alreadyRecommended) { Set<String> excludedIds, Set<String> alreadyRecommended) {
// TODO: 从数据库查询符合条件的用户 // 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
// 当前返回空列表,等待后续阶段完善 // 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
return List.of(); UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProfile> wrapper =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
wrapper.isNotNull(UserProfile::getGender)
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
.isNull(UserProfile::getDeletedAt);
if (me != null && me.getGender() != null && !me.getGender().isBlank()) {
wrapper.ne(UserProfile::getGender, me.getGender());
}
return userProfileMapper.selectList(wrapper).stream()
.map(UserProfile::getUserId)
.filter(id -> !id.equals(userId))
.filter(id -> !excludedIds.contains(id))
.filter(id -> !alreadyRecommended.contains(id))
.collect(Collectors.toList());
} }
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) { private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
@@ -0,0 +1,284 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 匹配评分 —— **全仓唯一事实源**。
*
* <p>背景:`RecommendationEngineService.calculateScore` 与
* `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
* 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
* `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
* 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
*
* <p>本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
*
* <p><b>规则(不新造常量)</b>:直接使用 {@link UserPreference} 上<b>已存在</b>的
* 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
* 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
* 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
*
* <p>返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
*/
@Service
public class MatchScoringService {
/** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
private static final List<String> EDUCATION_ORDER = List.of(
"primary", "junior_high", "senior_high", "vocational", "college", "bachelor", "master", "doctor");
/**
* 方向性评分:seeker 的择偶条件对 candidate 资料的适配度(0–100)。
*
* @param pref seeker 的择偶条件(可为 null → 无约束)
* @param candidate 候选人资料
*/
public double scoreDirectionally(UserPreference pref, UserProfile candidate) {
if (candidate == null) return 0;
if (pref == null) return 100;
double acc = 0; // 加权得分
double denom = 0; // 已设置条件的权重合计
// —— 年龄 ——(设置了 min/maxAge 才构成约束)
if (pref.getMinAge() != null && pref.getMaxAge() != null) {
double w = w(pref.getAgeWeight());
denom += w;
acc += w * rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge());
}
// —— 身高 ——
if (pref.getMinHeightCm() != null && pref.getMaxHeightCm() != null) {
double w = w(pref.getHeightWeight());
denom += w;
Integer h = candidate.getHeightCm();
acc += w * (h == null ? 0 : rangeRatio(h, pref.getMinHeightCm(), pref.getMaxHeightCm()));
}
// —— 地区(regionScope 为地区码数组)——
if (hasJsonArray(pref.getRegionScope())) {
double w = w(pref.getRegionWeight());
denom += w;
acc += w * (inJsonArray(pref.getRegionScope(), candidate.getRegionCode()) ? 1 : 0);
}
// —— 婚姻状况(maritalStatuses 数组)——
if (hasJsonArray(pref.getMaritalStatuses())) {
double w = w(pref.getMaritalWeight());
denom += w;
acc += w * (inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus()) ? 1 : 0);
}
// —— 子女(childrenAcceptance:any/accept/reject,兼容中文)——
if (notBlank(pref.getChildrenAcceptance())) {
double w = w(pref.getChildrenWeight());
denom += w;
acc += w * (childrenAcceptable(pref.getChildrenAcceptance(), candidate.getChildrenStatus()) ? 1 : 0);
}
// —— 学历(minEducationCode 为下限)——
if (notBlank(pref.getMinEducationCode())) {
double w = w(pref.getEducationWeight());
denom += w;
acc += w * (educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode()) ? 1 : 0);
}
// —— 婚恋规划(marriagePlanPreference 与 candidate.marriagePlan 一致)——
if (notBlank(pref.getMarriagePlanPreference())) {
double w = w(pref.getMarriagePlanWeight());
denom += w;
acc += w * (sameValue(pref.getMarriagePlanPreference(), candidate.getMarriagePlan()) ? 1 : 0);
}
// —— 定居意愿(settlementPreference 对照 candidate 长期/未来丹阳意向)——
if (notBlank(pref.getSettlementPreference())) {
double w = w(pref.getSettlementWeight());
denom += w;
boolean ok = sameValue(pref.getSettlementPreference(), candidate.getLongTermDanyang())
|| sameValue(pref.getSettlementPreference(), candidate.getFutureDanyang());
acc += w * (ok ? 1 : 0);
}
// —— 生活方式(吸烟 / 饮酒,取已设置者的均值)——
double lifeW = w(pref.getLifestyleWeight());
int lifeDims = 0;
double lifeHit = 0;
if (notBlank(pref.getSmokingPreference())) {
lifeDims++;
if (sameValue(pref.getSmokingPreference(), candidate.getSmokingStatus())) lifeHit++;
}
if (notBlank(pref.getDrinkingPreference())) {
lifeDims++;
if (sameValue(pref.getDrinkingPreference(), candidate.getDrinkingStatus())) lifeHit++;
}
if (lifeDims > 0) {
denom += lifeW;
acc += lifeW * (lifeHit / lifeDims);
}
// interestWeight:当前无对应择偶条件字段,不参与计分(保留权重以备扩展)。
if (denom == 0) return 100; // 用户未设置任何条件 → 无约束
return clamp(acc / denom * 100.0);
}
/**
* 生成中文推荐理由(基于**实际达成**的维度,不再是无条件套话)。
* 未达成任何具体维度时回退为「资料符合基本要求」。
*/
public List<String> reasons(UserPreference pref, UserProfile candidate) {
List<String> out = new ArrayList<>();
if (pref == null || candidate == null) return out;
if (pref.getMinAge() != null && pref.getMaxAge() != null
&& rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge()) > 0) {
out.add("年龄在您的要求范围内");
}
if (hasJsonArray(pref.getRegionScope()) && inJsonArray(pref.getRegionScope(), candidate.getRegionCode())) {
out.add("同在您期望的地区");
}
if (hasJsonArray(pref.getMaritalStatuses())
&& inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus())) {
out.add("婚姻状况符合您的期望");
}
if (notBlank(pref.getMinEducationCode())
&& educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode())) {
out.add("学历符合您的要求");
}
if (notBlank(pref.getAgePriority()) && "required".equals(pref.getAgePriority())) {
out.add("年龄为您设定的必要条件");
}
if (out.isEmpty()) out.add("资料符合基本要求");
return out;
}
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——唯一事实源。
*
* <p>任一方把某维度设为 `required`,则对方该维度<b>必须</b>落在要求内,否则整条候选被否。
* 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 Required 维度)。
*
* @return true 表示双向 Required 全部满足
*/
public boolean bidirectionalRequiredSatisfied(UserPreference myPref, UserProfile myProfile,
UserPreference targetPref, UserProfile targetProfile) {
if (myPref == null || myProfile == null || targetPref == null || targetProfile == null) return false;
// 年龄
if (isRequired(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
Integer candAge = age(targetProfile.getBirthDate());
if (candAge == null || candAge < myPref.getMinAge() || candAge > myPref.getMaxAge()) return false;
}
if (isRequired(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
Integer myAge = age(myProfile.getBirthDate());
if (myAge == null || myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) return false;
}
// 身高
if (isRequired(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
Integer h = targetProfile.getHeightCm();
if (h == null || h < myPref.getMinHeightCm() || h > myPref.getMaxHeightCm()) return false;
}
if (isRequired(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
Integer h = myProfile.getHeightCm();
if (h == null || h < targetPref.getMinHeightCm() || h > targetPref.getMaxHeightCm()) return false;
}
// 地区(Required 用 isJsonArray:空数组=自相矛盾的硬约束 → 拒绝;不可放宽为「无约束」)
if (isRequired(myPref.getRegionPriority()) && isJsonArray(myPref.getRegionScope())
&& !inJsonArray(myPref.getRegionScope(), targetProfile.getRegionCode())) return false;
if (isRequired(targetPref.getRegionPriority()) && isJsonArray(targetPref.getRegionScope())
&& !inJsonArray(targetPref.getRegionScope(), myProfile.getRegionCode())) return false;
// 婚姻状况
if (isRequired(myPref.getMaritalPriority()) && isJsonArray(myPref.getMaritalStatuses())
&& !inJsonArray(myPref.getMaritalStatuses(), targetProfile.getMaritalStatus())) return false;
if (isRequired(targetPref.getMaritalPriority()) && isJsonArray(targetPref.getMaritalStatuses())
&& !inJsonArray(targetPref.getMaritalStatuses(), myProfile.getMaritalStatus())) return false;
return true;
}
private static boolean isRequired(String priority) { return "required".equals(priority); }
// ==================== 私有工具 ====================
private static double w(Double v) { return v == null ? 0 : Math.max(0, v); }
private static boolean notBlank(String s) { return s != null && !s.isBlank(); }
private static Integer age(LocalDate birthDate) {
if (birthDate == null) return null;
return Period.between(birthDate, LocalDate.now()).getYears();
}
/** 在 [min,max] 内 → 1;否则按越界距离线性衰减(跨度越大衰减越慢),最低 0。 */
private static double rangeRatio(Integer value, int min, int max) {
if (value == null) return 0;
if (min > max) { int t = min; min = max; max = t; }
if (value >= min && value <= max) return 1.0;
int span = Math.max(1, max - min);
int distance = value < min ? (min - value) : (value - max);
return Math.max(0.0, 1.0 - (double) distance / span);
}
/** 评分用:仅当数组**非空**才算「用户表达了该条件」。空数组 = 未设置,不计入分母。 */
private static boolean hasJsonArray(JsonNode node) {
return node != null && node.isArray() && node.size() > 0;
}
/**
* Required 校验用:只要节点是数组即「用户声明了该硬约束」。
* 空数组是自相矛盾的声明(要求必填却无任何可接受值)→ 交由调用方判为不满足,
* 与「Required 不可自动放宽」(INV-001)一致;不可退化成「无约束」。
*/
private static boolean isJsonArray(JsonNode node) {
return node != null && node.isArray();
}
private static boolean inJsonArray(JsonNode node, String value) {
if (node == null || !node.isArray() || value == null) return false;
for (int i = 0; i < node.size(); i++) {
if (value.equals(node.get(i).asText())) return true;
}
return false;
}
/** 子女接受度:any/不限 → 恒满足;accept/可接受 → 有孩与否皆可;reject/不接受 → 仅无孩。 */
private static boolean childrenAcceptable(String acceptance, String childrenStatus) {
String a = acceptance == null ? "" : acceptance.trim();
boolean candidateHasChildren = "yes".equals(childrenStatus) || "has".equals(childrenStatus)
|| "有".equals(childrenStatus);
return switch (a) {
case "any", "不限" -> true;
case "accept", "可接受" -> true; // 接受带孩(且同样接受无孩)
case "reject", "不接受" -> !candidateHasChildren;
default -> true;
};
}
private static boolean educationAtLeast(String candidateCode, String minCode) {
int ci = EDUCATION_ORDER.indexOf(candidateCode);
int mi = EDUCATION_ORDER.indexOf(minCode);
if (mi < 0) return true; // 下限码不在白名单 → 不做约束
if (ci < 0) return false; // 候选人学历缺失/未知 → 无法核验,不假定满足
return ci >= mi;
}
/** 宽松相等:去空白、忽略大小写;两边都非空才比较。 */
private static boolean sameValue(String a, String b) {
if (!notBlank(a) || !notBlank(b)) return false;
return a.trim().equalsIgnoreCase(b.trim());
}
private static double clamp(double v) {
if (v < 0) return 0;
if (v > 100) return 100;
return v;
}
/** 便于单测/文档:常量键说明。 */
public static Map<String, String> weightKeys() {
return Map.of(
"age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
"children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
"marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
"settlement", "settlementWeight", "interest", "interestWeight");
}
}
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.Period;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final UserPrivacySettingsMapper privacySettingsMapper; private final UserPrivacySettingsMapper privacySettingsMapper;
private final LocalEligibilityMapper localEligibilityMapper; private final LocalEligibilityMapper localEligibilityMapper;
/** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
private final MatchScoringService matchScoringService;
/** /**
* 获取每日推荐列表 * 获取每日推荐列表
@@ -241,86 +242,16 @@ public class RecommendationEngineService {
return userProfileMapper.findByUserId(userId).orElse(null); return userProfileMapper.findByUserId(userId).orElse(null);
} }
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——委托
* {@link MatchScoringService#bidirectionalRequiredSatisfied} 唯一事实源。
*
* <p>此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;2026-09-23 收敛为单点。
* 参数对映:本方法的 {@code candidate} 即事实源签名中的 {@code targetProfile}。
*/
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate, boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) { UserPreference targetPref, UserProfile myProfile) {
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false; return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
// 1. 年龄 Required 检查
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
int candidateAge = calculateAge(candidate.getBirthDate());
if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) {
return false;
}
}
if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
int myAge = calculateAge(myProfile.getBirthDate());
if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) {
return false;
}
}
// 2. 身高 Required 检查
if ("required".equals(myPref.getHeightPriority())
&& myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
if (candidate.getHeightCm() == null
|| candidate.getHeightCm() < myPref.getMinHeightCm()
|| candidate.getHeightCm() > myPref.getMaxHeightCm()) {
return false;
}
}
if ("required".equals(targetPref.getHeightPriority())
&& targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
if (myProfile.getHeightCm() == null
|| myProfile.getHeightCm() < targetPref.getMinHeightCm()
|| myProfile.getHeightCm() > targetPref.getMaxHeightCm()) {
return false;
}
}
// 3. 地区 Required 检查
if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < myPref.getRegionScope().size(); i++) {
if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < targetPref.getRegionScope().size(); i++) {
if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
// 4. 婚姻状况 Required 检查
if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) {
if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) {
if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
return true;
} }
private boolean isParticipantEligible(String userId) { private boolean isParticipantEligible(String userId) {
@@ -341,23 +272,19 @@ public class RecommendationEngineService {
return true; return true;
} }
/**
* 方向性评分:委托 {@link MatchScoringService}(唯一事实源)。
*
* <p>此前为桩 `return 50.0`,而阈值默认 60 → `mutualScore < 60` 恒成立,
* 导致 `GET /recommendations` **恒返回空数组**(2026-09-23 UAT 走查实测)。
*/
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) { private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
// TODO: Phase 07 后续完善评分逻辑 return matchScoringService.scoreDirectionally(pref, target);
return 50.0; // V1 简化:默认中间分
} }
private List<String> generateReasons(String userId, UserProfile candidate, private List<String> generateReasons(String userId, UserProfile candidate,
UserPreference myPref, UserPreference targetPref) { UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>(); return matchScoringService.reasons(myPref, candidate);
// TODO: 基于真实资料生成推荐理由
reasons.add("年龄符合双方择偶要求");
reasons.add("都希望长期在丹阳生活");
return reasons;
}
private int calculateAge(LocalDate birthDate) {
if (birthDate == null) return 0;
return Period.between(birthDate, LocalDate.now()).getYears();
} }
private Set<String> getPassedUserIds(String userId, Instant since) { private Set<String> getPassedUserIds(String userId, Instant since) {
@@ -16,7 +16,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 红娘服务端点 * 红娘服务端点(用户端)
* *
* 用户端: * 用户端:
* POST /api/v1/matchmaker/services — 提交红娘服务请求 * POST /api/v1/matchmaker/services — 提交红娘服务请求
@@ -26,10 +26,15 @@ import java.util.Map;
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target) * POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
* POST /api/v1/matchmaker/recommendations/{id}/pass * POST /api/v1/matchmaker/recommendations/{id}/pass
* *
* 操作人端(Matchmaker/Operator role): * 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
* PUT /api/v1/matchmaker/services/{id}/assign * <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象 * {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务 * {@code POST /services/{id}/complete})。
*
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
*/ */
@RestController @RestController
@RequestMapping("/matchmaker") @RequestMapping("/matchmaker")
@@ -232,6 +232,25 @@ public class MeController {
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType)); return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
} }
/**
* 确认直传照片落库(REST §29 补)。
* 客户端先 {@code POST /me/photos/upload-token} 拿预签名 URL 直传对象存储,
* 上传成功后再调本端点把 fileKey 落成 user_photos 记录;否则照片不展示、签名访问也过不了归属校验。
* POST /api/v1/me/photos/confirm
*/
@PostMapping("/photos/confirm")
public ApiResponse<Map<String, Object>> confirmPhoto(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String fileKey = body.get("fileKey");
String photoType = body.getOrDefault("photoType", "lifestyle");
UserPhoto photo = photoService.confirmDirectUpload(principal.id(), fileKey, photoType);
Map<String, Object> result = new LinkedHashMap<>();
result.put("photoId", photo.getId());
result.put("photoType", photo.getPhotoType());
return ApiResponse.ok(result);
}
/** /**
* 删除本人照片(REST §32)。 * 删除本人照片(REST §32)。
* DELETE /api/v1/me/photos/{photoId} * DELETE /api/v1/me/photos/{photoId}
@@ -20,6 +20,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* Operator 管理服务 * Operator 管理服务
@@ -85,7 +86,7 @@ public class OperatorService {
@Transactional @Transactional
public void disableOperator(String operatorId) { public void disableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId) Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED); operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
operator.setUpdatedAt(Instant.now()); operator.setUpdatedAt(Instant.now());
@@ -101,7 +102,7 @@ public class OperatorService {
@Transactional @Transactional
public void enableOperator(String operatorId) { public void enableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId) Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL); operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
operator.setUpdatedAt(Instant.now()); operator.setUpdatedAt(Instant.now());
@@ -187,6 +187,31 @@ public class PhotoService {
return photo; return photo;
} }
/**
* 确认直传照片落库(客户端直传对象存储成功后调用)。
*
* <p>直传路径({@code /me/photos/upload-token} 签发预签名 PUT URL)对象先进入私有桶,
* 但此前没有任何端点把 {@code fileKey} 写成 {@code user_photos} 记录,导致「传了白传」:
* 列表不展示、签名访问的归属校验也过不了。本方法补齐落库,并设两道护栏:
* <ol>
* <li>归属校验:{@code fileKey} 必须以 {@code photos/{userId}/} 开头(由本用户直传凭证生成),
* 禁止客户端传入任意 fileKey 把别人的对象据为己有或写入非法 key;</li>
* <li>存在性校验:对象必须已存在于私有桶(客户端确实完成了 PUT),否则拒绝落库,
* 避免只拿了凭证却没上传成功就生成孤儿记录。</li>
* </ol>
*/
@Transactional
public UserPhoto confirmDirectUpload(String userId, String fileKey, String photoType) {
if (fileKey == null || !fileKey.startsWith("photos/" + userId + "/")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
}
int sortOrder = photoMapper.countActiveProfilePhotos(userId) + 1;
return savePhoto(userId, fileKey, photoType, sortOrder);
}
/** /**
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录 * 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
*/ */
@@ -1,11 +1,13 @@
package com.danyang.match.platform.profile.service; package com.danyang.match.platform.profile.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.profile.entity.UserPreference; import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -25,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 @Transactional
public UserPreference savePreference(String userId, Map<String, Object> data) { public UserPreference savePreference(String userId, Map<String, Object> data) {
UserPreference pref = userPreferenceMapper.findByUserId(userId) UserPreference pref = userPreferenceMapper.findByUserId(userId).orElse(null);
.orElseGet(() -> { boolean exists = pref != null;
UserPreference p = new UserPreference(); if (!exists) {
p.setUserId(userId); pref = new UserPreference();
return p; pref.setUserId(userId);
}); }
if (data != null) {
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge")); if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge")); if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority")); 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("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference")); if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference")); if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
}
userPreferenceMapper.insert(pref);; if (exists) {
pref.setUpdatedAt(Instant.now());
userPreferenceMapper.update(pref,
new LambdaUpdateWrapper<UserPreference>().eq(UserPreference::getUserId, userId));
} else {
userPreferenceMapper.insert(pref);
}
return pref; return pref;
} }
@@ -11,6 +11,10 @@ import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper; import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.verification.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -27,6 +31,7 @@ public class UserProfileService {
private final UserPreferenceMapper userPreferenceMapper; private final UserPreferenceMapper userPreferenceMapper;
private final UserPrivacySettingsMapper privacySettingsMapper; private final UserPrivacySettingsMapper privacySettingsMapper;
private final UserRelationMapper relationMapper; private final UserRelationMapper relationMapper;
private final UserIdentityMapper userIdentityMapper;
/** /**
* 获取自己完整资料(含隐私设置) * 获取自己完整资料(含隐私设置)
@@ -60,8 +65,13 @@ public class UserProfileService {
prefMap.put("maxHeightCm", pref.getMaxHeightCm()); prefMap.put("maxHeightCm", pref.getMaxHeightCm());
prefMap.put("heightPriority", pref.getHeightPriority()); prefMap.put("heightPriority", pref.getHeightPriority());
prefMap.put("regionScope", pref.getRegionScope()); prefMap.put("regionScope", pref.getRegionScope());
prefMap.put("regionPriority", pref.getRegionPriority());
prefMap.put("maritalStatuses", pref.getMaritalStatuses()); prefMap.put("maritalStatuses", pref.getMaritalStatuses());
prefMap.put("maritalPriority", pref.getMaritalPriority());
prefMap.put("minEducationCode", pref.getMinEducationCode());
prefMap.put("educationPriority", pref.getEducationPriority());
prefMap.put("childrenAcceptance", pref.getChildrenAcceptance()); prefMap.put("childrenAcceptance", pref.getChildrenAcceptance());
prefMap.put("childrenPriority", pref.getChildrenPriority());
prefMap.put("smokingPreference", pref.getSmokingPreference()); prefMap.put("smokingPreference", pref.getSmokingPreference());
prefMap.put("drinkingPreference", pref.getDrinkingPreference()); prefMap.put("drinkingPreference", pref.getDrinkingPreference());
prefMap.put("settlementPreference", pref.getSettlementPreference()); prefMap.put("settlementPreference", pref.getSettlementPreference());
@@ -84,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 @Transactional
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) { public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId) UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
.orElseGet(() -> { boolean exists = profile != null;
UserProfile p = new UserProfile(); if (!exists) {
p.setUserId(userId); profile = new UserProfile();
p.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS); profile.setUserId(userId);
return p; profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
}); }
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
applyProfileFields(profile, fields);
ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile)); profile.setProfileCompletion(calculateCompletion(profile));
userProfileMapper.insert(profile); persistProfile(profile, exists);
return profile; return profile;
} }
/** /**
* 更新个人资料 * 更新个人资料(资料完善向导的最终提交,置为 completed)。
*
* <p>2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
*/ */
@Transactional @Transactional
public void updateProfile(String userId, Map<String, Object> fields) { public void updateProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(new UserProfile()); UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
boolean exists = profile != null;
if (!exists) {
profile = new UserProfile();
profile.setUserId(userId); profile.setUserId(userId);
}
// 基础字段映射 applyProfileFields(profile, fields);
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName")); ensureIdentityFields(profile);
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"));
profile.setProfileCompletion(calculateCompletion(profile)); profile.setProfileCompletion(calculateCompletion(profile));
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED); 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); 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 java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/** /**
* 关系服务 * 关系服务
@@ -104,7 +105,7 @@ public class RelationshipService {
@Transactional @Transactional
public void cancelLike(String userId, String targetUserId) { public void cancelLike(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED) UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED)
.orElseThrow(() -> new RuntimeException("没有找到喜欢关系")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到喜欢关系"));
relationMapper.deleteById(relation.getId()); relationMapper.deleteById(relation.getId());
log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId); log.info("取消喜欢: userId={}, targetUserId={}", userId, targetUserId);
@@ -184,7 +185,7 @@ public class RelationshipService {
@Transactional @Transactional
public void unblock(String userId, String targetUserId) { public void unblock(String userId, String targetUserId) {
UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED) UserRelation relation = relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED)
.orElseThrow(() -> new RuntimeException("没有拉黑过该用户")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有拉黑过该用户"));
relationMapper.deleteById(relation.getId()); relationMapper.deleteById(relation.getId());
log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId); log.info("解除拉黑: userId={}, targetUserId={}", userId, targetUserId);
@@ -323,7 +324,7 @@ public class RelationshipService {
@Transactional @Transactional
public void unmatch(String userId, String targetUserId) { public void unmatch(String userId, String targetUserId) {
Match match = matchMapper.findActiveMatch(userId, targetUserId) Match match = matchMapper.findActiveMatch(userId, targetUserId)
.orElseThrow(() -> new RuntimeException("没有找到有效的匹配关系")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到有效的匹配关系"));
match.setStatus(Constants.MATCH_STATUS_UNMATCHED); match.setStatus(Constants.MATCH_STATUS_UNMATCHED);
match.setUnmatchedAt(Instant.now()); match.setUnmatchedAt(Instant.now());
@@ -32,6 +32,7 @@ import java.time.Instant;
import java.util.*; import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -280,7 +281,7 @@ public class ReviewService {
public ReviewTask resubmit(String userId) { public ReviewTask resubmit(String userId) {
// 找到最新的需修改任务 // 找到最新的需修改任务
ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId) ReviewTask latestTask = reviewTaskMapper.findLatestByUserId(userId)
.orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到可重新提交的审核任务"));
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) { if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交"); throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
@@ -7,6 +7,7 @@ import com.danyang.match.platform.shared.security.principal.PlatformPrincipal;
import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -58,12 +59,26 @@ public class JwtAuthFilter extends OncePerRequestFilter {
} }
String authHeader = request.getHeader("Authorization"); String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) { String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
} else {
// 浏览器同源请求:管理端登录后由 HttpOnly Cookie(dy_op_at)携带 access token。
// 作为 Authorization 头的兜底(抗 XSS 读取;小程序/用户端仍走 Authorization 头,不受影响)。
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("dy_op_at".equals(c.getName())) {
token = c.getValue();
break;
}
}
}
}
if (token == null) {
chain.doFilter(request, response); chain.doFilter(request, response);
return; return;
} }
String token = authHeader.substring(7);
if (!jwtTokenService.validateToken(token)) { if (!jwtTokenService.validateToken(token)) {
sendUnauthorized(response); sendUnauthorized(response);
return; return;
@@ -12,10 +12,15 @@ import java.util.UUID;
/** /**
* Mock 实名认证 Provider * Mock 实名认证 Provider
* 开发/测试环境使用,生产环境替换为正式 Provider * 开发/测试环境使用,生产环境替换为正式 Provider。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code identity.verification.provider},
* 由 {@link RealIdentityVerificationProvider} 承接 {@code real} 取值;本类保留
* {@code matchIfMissing = true},未显式配置时本地默认仍是 Mock。生产必须在
* {@code application-production.yml} 显式置 {@code identity.verification.provider=real}。
*/ */
@Component @Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true) @ConditionalOnProperty(name = "identity.verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockIdentityVerificationProvider implements IdentityVerificationProvider { public class MockIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class); private static final Logger log = LoggerFactory.getLogger(MockIdentityVerificationProvider.class);
@@ -0,0 +1,190 @@
package com.danyang.match.platform.verification.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 真实实名认证 Provider(生产环境使用)。
*
* <p>由配置 {@code identity.verification.provider=real} 激活;未显式配置时默认是 Mock
* (见 {@link MockIdentityVerificationProvider}),因此生产环境必须在
* {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>核验策略(fail-closed,绝不静默放行):
* <ul>
* <li>本地强校验:身份证号 GB 11643-1999 校验位(mod 11-2)。校验位不符直接判定未通过,
* 在发起任何外部调用前拦截明显伪造的证件号。</li>
* <li>第三方核验:必须配置 {@code identity.verification.real.api-url} 与
* {@code identity.verification.real.api-key}(环境变量 {@code VERIFICATION_API_URL} /
* {@code VERIFICATION_API_KEY})。未配置即 fail-fast —— 真实核验无法凭空完成,
* 绝不能回退成「放行」。</li>
* <li>任何无法判定为明确的「核验通过」的响应,一律按「未通过」处理(fail-closed)。</li>
* <li>绝不把身份证号原文作为日志明文输出;仅记录脱敏值。</li>
* </ul>
*
* <p>对接说明:第三方请求/响应契约需在接入具体厂商(如公安实名、运营商三要素)时最终确认。
* 当前实现以「明确的 success/match/verified/passed 布尔为 true」判定通过,其余一律视为未通过。
*/
@Component
@ConditionalOnProperty(name = "identity.verification.provider", havingValue = "real")
public class RealIdentityVerificationProvider implements IdentityVerificationProvider {
private static final Logger log = LoggerFactory.getLogger(RealIdentityVerificationProvider.class);
/** 身份证号校验位权重(GB 11643-1999,前 17 位) */
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
/** 校验位映射:索引 = 加权和 mod 11;'X' 表示余数 2 */
private static final String CHECK_CODES = "10X98765432";
@Value("${identity.verification.real.api-url:}")
private String apiUrl;
@Value("${identity.verification.real.api-key:}")
private String apiKey;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, Object> verify(String realName, String idNumber) {
if (apiUrl == null || apiUrl.isBlank() || apiKey == null || apiKey.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意证件
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "实名核验未配置第三方核验服务");
}
if (realName == null || realName.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "实名认证失败:缺少姓名");
}
// 1. 本地校验位强校验:伪造证件号在外部调用前即拒绝
if (!isValidChecksum(idNumber)) {
log.warn("[Identity] 身份证校验位不合法: idNumber={}", maskIdNumber(idNumber));
return buildResult(false, idNumber, realName, null, null, UUID.randomUUID().toString());
}
LocalDate birthDate = parseBirthDateFromIdNumber(idNumber);
String gender = parseGenderFromIdNumber(idNumber);
// 2. 第三方核验(fail-closed)
boolean matched = callThirdParty(realName, idNumber);
return buildResult(matched, idNumber, realName, birthDate.toString(), gender, UUID.randomUUID().toString());
}
/**
* 调用第三方实名核验。仅当响应中明确出现「通过」语义的布尔字段为 true 时才返回 true;
* 其余(缺字段、字段为 false、解析失败、网络异常)一律返回 false(fail-closed)。
*/
private boolean callThirdParty(String realName, String idNumber) {
try {
Map<String, Object> body = new HashMap<>();
body.put("realName", realName);
body.put("idNumber", idNumber);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> resp = restTemplate.postForEntity(apiUrl, entity, String.class);
if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
log.warn("[Identity] 第三方核验返回非成功状态: status={}", resp.getStatusCode());
return false;
}
JsonNode node = objectMapper.readTree(resp.getBody());
return isExplicitlyVerified(node);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Identity] 第三方核验调用异常", e);
return false;
}
}
/** 仅在响应中明确的「通过」布尔字段为 true 时判定通过。 */
private boolean isExplicitlyVerified(JsonNode node) {
for (String field : new String[]{"success", "match", "verified", "passed", "isValid"}) {
JsonNode v = node.get(field);
if (v != null && v.isBoolean() && v.asBoolean()) {
return true;
}
}
// 兼容 { "code": 0, "message": "ok" } 形态
JsonNode code = node.get("code");
if (code != null && code.isNumber() && code.asInt() == 0) {
return true;
}
return false;
}
private Map<String, Object> buildResult(boolean verified, String idNumber, String realName,
String birthDate, String gender, String requestId) {
Map<String, Object> result = new HashMap<>();
result.put("verified", verified);
result.put("realName", realName);
result.put("idNumberHash", idNumber);
result.put("birthDate", birthDate);
result.put("gender", gender);
result.put("requestId", requestId);
return result;
}
@Override
public boolean isMock() {
return false;
}
/** GB 11643-1999 校验位(mod 11-2)。 */
private boolean isValidChecksum(String idNumber) {
if (idNumber == null || idNumber.length() != 18) {
return false;
}
if (!idNumber.substring(0, 17).matches("\\d{17}")) {
return false;
}
char last = idNumber.charAt(17);
if (!(Character.isDigit(last) || last == 'X' || last == 'x')) {
return false;
}
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += (idNumber.charAt(i) - '0') * WEIGHTS[i];
}
char expected = CHECK_CODES.charAt(sum % 11);
return Character.toUpperCase(last) == expected;
}
private LocalDate parseBirthDateFromIdNumber(String idNumber) {
try {
int year = Integer.parseInt(idNumber.substring(6, 10));
int month = Integer.parseInt(idNumber.substring(10, 12));
int day = Integer.parseInt(idNumber.substring(12, 14));
return LocalDate.of(year, month, day);
} catch (Exception e) {
return LocalDate.now().minusYears(25);
}
}
private String parseGenderFromIdNumber(String idNumber) {
int genderDigit = Integer.parseInt(idNumber.substring(16, 17));
return (genderDigit % 2 == 1) ? "male" : "female";
}
private String maskIdNumber(String idNumber) {
if (idNumber == null || idNumber.length() < 10) return "****";
return idNumber.substring(0, 6) + "**********" + idNumber.substring(idNumber.length() - 4);
}
}
@@ -24,6 +24,7 @@ import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException; import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -136,7 +137,7 @@ public class IdentityVerificationService {
*/ */
public Map<String, Object> getIdentityStatus(String userId) { public Map<String, Object> getIdentityStatus(String userId) {
User user = userMapper.findById(userId) User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
return userIdentityMapper.findByUserId(userId) return userIdentityMapper.findByUserId(userId)
.map(identity -> { .map(identity -> {
@@ -212,7 +213,7 @@ public class IdentityVerificationService {
@Transactional @Transactional
public void cancelIdentity(String userId) { public void cancelIdentity(String userId) {
UserIdentity identity = userIdentityMapper.findByUserId(userId) UserIdentity identity = userIdentityMapper.findByUserId(userId)
.orElseThrow(() -> new RuntimeException("未找到实名认证记录")); .orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "未找到实名认证记录"));
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) { if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销"); throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
@@ -38,11 +38,18 @@ object-storage:
region: us-east-1 region: us-east-1
signature-version: s3v4 signature-version: s3v4
# 实名核验:docker 本地/CI 默认走 Mock(VERIFICATION_PROVIDER=real 时启用真实实现,需同时配置 api-url/api-key)
identity:
verification: verification:
provider: ${VERIFICATION_PROVIDER:mock} provider: ${VERIFICATION_PROVIDER:mock}
real:
api-url: ${VERIFICATION_API_URL:} api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:} api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:docker 内部为 http,关闭 Secure 标志
app:
auth-cookie-secure: false
match: match:
rule-version: 1 rule-version: 1
default-daily-limit: 6 default-daily-limit: 6
@@ -14,3 +14,27 @@ spring:
user: user:
name: disabled name: disabled
password: ${SPRING_SECURITY_FALLBACK_PASSWORD} password: ${SPRING_SECURITY_FALLBACK_PASSWORD}
# 微信登录:生产必须显式启用真实实现(wechat.login.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealWechatLoginProvider 不会激活、
# MockWechatLoginProvider 又因 havingValue=mock 不匹配 → 无 WechatLoginProvider Bean,启动即失败(fail-fast,不悄悄放行任意 code)。
wechat:
login:
provider: real
app-id: ${WECHAT_APP_ID}
app-secret: ${WECHAT_APP_SECRET}
# 实名核验:生产必须显式启用真实实现(identity.verification.provider=real)。
# 未配置时本地默认走 Mock;生产若漏配此行,RealIdentityVerificationProvider 不会激活、
# MockIdentityVerificationProvider 又因 havingValue=mock 不匹配 → 无 IdentityVerificationProvider Bean,启动即失败(fail-fast,不悄悄放行任意证件)。
# 真实核验还需配置第三方核验服务地址与密钥(缺失则核验请求 fail-fast)。
identity:
verification:
provider: real
real:
api-url: ${VERIFICATION_API_URL:}
api-key: ${VERIFICATION_API_KEY:}
# 管理端鉴权 Cookie:生产走 https,开启 Secure 标志(HttpOnly + SameSite=Lax 始终生效)
app:
auth-cookie-secure: true
@@ -0,0 +1,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());
}
}
@@ -44,7 +44,8 @@ class RecommendationEligibilityPhase06Test {
void setUp() { void setUp() {
config = new MatchRuleConfigProperties(); config = new MatchRuleConfigProperties();
service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper, service = new RecommendationEngineService(profileMapper, preferenceMapper, recordMapper,
config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper); config, redisTemplate, relationMapper, userMapper, privacyMapper, eligibilityMapper,
new MatchScoringService());
} }
@Test @Test
@@ -0,0 +1,118 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
/**
* 评分唯一事实源的行为型测试。
*
* <p>存在意义:修复「推荐恒空」(2026-09-23 UAT 走查)之前的桩 `calculateScore()=50`
* 与阈值 60 使「满足条件的候选」也被丢弃,而既有测试只验阈值数学、从不验真实评分能达标 →
* 缺陷长期漏网。本测试直接断言**满足条件的候选评分 ≥ 阈值**。
*/
class MatchScoringServiceTest {
private final MatchScoringService svc = new MatchScoringService();
private final ObjectMapper mapper = new ObjectMapper();
private static final int THRESHOLD = 60; // 与 match.default-score-threshold 默认值一致
private UserProfile profile(int ageYears, Integer heightCm, String region, String marital) {
UserProfile p = new UserProfile();
p.setBirthDate(LocalDate.now().minusYears(ageYears));
p.setHeightCm(heightCm);
p.setRegionCode(region);
p.setMaritalStatus(marital);
return p;
}
@Test
@DisplayName("修复回归:完全符合条件的候选,评分必须 ≥ 阈值(不再恒 50)")
void matchingCandidateScoresAtLeastThreshold() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
pref.setMinHeightCm(160);
pref.setMaxHeightCm(175);
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
UserProfile candidate = profile(30, 168, "321181", "never_married");
double score = svc.scoreDirectionally(pref, candidate);
assertTrue(score >= THRESHOLD,
"满足全部条件的候选必须过阈值,实际:" + score);
}
@Test
@DisplayName("反向:条件明显不符的候选,评分低于阈值(证明有判别力)")
void nonMatchingCandidateBelowThreshold() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
pref.setMinHeightCm(160);
pref.setMaxHeightCm(175);
pref.setRegionScope(mapper.valueToTree(new String[]{"321181"}));
pref.setMaritalStatuses(mapper.valueToTree(new String[]{"never_married"}));
// 年龄 48(远超)、身高 150(不足)、地区 320500、离异 —— 四个维度全不符
UserProfile candidate = profile(48, 150, "320500", "divorced");
double score = svc.scoreDirectionally(pref, candidate);
assertTrue(score < THRESHOLD, "全不符的候选必须被过滤,实际:" + score);
}
@Test
@DisplayName("未设置任何条件 → 视为无约束,返回 100")
void noCriteriaMeansNoConstraint() {
assertEquals(100.0, svc.scoreDirectionally(new UserPreference(), profile(30, 170, "321181", "never_married")),
0.001);
}
@Test
@DisplayName("候选人身高缺失 → 身高维度计 0(不假定满足),且不阻断其他维度")
void missingCandidateHeightCountsZero() {
UserPreference pref = new UserPreference();
pref.setMinHeightCm(170);
pref.setMaxHeightCm(180);
UserProfile candidate = profile(30, null, "321181", "never_married");
double score = svc.scoreDirectionally(pref, candidate);
assertEquals(0.0, score, 0.001, "唯一条件是身高而资料缺失 → 0 分");
}
@Test
@DisplayName("INV-001:双向 Required 任一不满足即否")
void bidirectionalRequiredEnforced() {
UserPreference myPref = new UserPreference();
myPref.setAgePriority("required");
myPref.setMinAge(25);
myPref.setMaxAge(30);
UserPreference targetPref = new UserPreference(); // 无 Required
UserProfile me = profile(30, 175, "321181", "never_married");
UserProfile inRange = profile(28, 170, "321181", "never_married");
UserProfile outOfRange = profile(35, 170, "321181", "never_married");
assertTrue(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, inRange));
assertFalse(svc.bidirectionalRequiredSatisfied(myPref, me, targetPref, outOfRange));
}
@Test
@DisplayName("推荐理由基于实际达成维度,且非空")
void reasonsReflectMatchedDimensions() {
UserPreference pref = new UserPreference();
pref.setMinAge(25);
pref.setMaxAge(35);
var reasons = svc.reasons(pref, profile(30, 170, "321181", "never_married"));
assertFalse(reasons.isEmpty());
assertTrue(reasons.stream().anyMatch(r -> r.contains("年龄")), "应包含年龄维度理由:" + reasons);
}
}
@@ -0,0 +1,196 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
import com.danyang.match.platform.match.dto.RecommendationCandidateDTO;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 推荐链路的<b>行为型</b>回归守卫。
*
* <p><b>存在意义</b>:UAT 走查(2026-09-23)在本机真实栈实测
* {@code GET /recommendations} 恒返回 {@code {"data":[]}}。根因是评分桩
* {@code calculateScore()=50} 而阈值默认 60({@code 50 < 60} 恒真)→ 所有候选被丢弃。
* 该缺陷逃过了 757 个后端用例 + 65 个 E2E + 22 个 UI 守卫,因为
* <b>没有任何用例断言「推荐非空」</b>(E2E 零推荐用例)。
*
* <p>本测试在<b>引擎层</b>(候选池查询 → 双向 Required → 评分 → 阈值过滤 → 排序 → 落库)
* 断言「存在合格候选时必须产出非空推荐」,并配一条反向用例证明其有判别力。
* 这是此类「恒空」缺陷的直接防线 —— 桩式/纯配置断言抓不到它。
*/
class RecommendationEngineServiceBehaviorTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String ME = "u-me";
private static final String CANDIDATE = "u-cand";
private UserProfileMapper userProfileMapper;
private UserPreferenceMapper preferenceMapper;
private RecommendationRecordMapper recommendationRecordMapper;
private MatchRuleConfigProperties matchConfig;
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOps;
private UserRelationMapper relationMapper;
private UserMapper userMapper;
private UserPrivacySettingsMapper privacySettingsMapper;
private LocalEligibilityMapper localEligibilityMapper;
private RecommendationEngineService engine;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
userProfileMapper = mock(UserProfileMapper.class);
preferenceMapper = mock(UserPreferenceMapper.class);
recommendationRecordMapper = mock(RecommendationRecordMapper.class);
matchConfig = mock(MatchRuleConfigProperties.class);
redisTemplate = mock(StringRedisTemplate.class);
valueOps = mock(ValueOperations.class);
relationMapper = mock(UserRelationMapper.class);
userMapper = mock(UserMapper.class);
privacySettingsMapper = mock(UserPrivacySettingsMapper.class);
localEligibilityMapper = mock(LocalEligibilityMapper.class);
// 用**真实**评分实现(纯函数、无依赖)→ 端到端证明「真实评分能让推荐非空」。
// 若用 mock 掉评分,本测试就退化成「只验接线」,无法守住「评分恒 50」这类回归。
MatchScoringService scoring = new MatchScoringService();
engine = new RecommendationEngineService(
userProfileMapper, preferenceMapper, recommendationRecordMapper,
matchConfig, redisTemplate, relationMapper, userMapper,
privacySettingsMapper, localEligibilityMapper, scoring);
// —— 匹配规则配置(与 application-dev.yml 默认一致)——
when(matchConfig.getDefaultDailyLimit()).thenReturn(6);
when(matchConfig.getDefaultScoreThreshold()).thenReturn(60);
when(matchConfig.getDefaultCooldownDays()).thenReturn(180);
when(matchConfig.getRuleVersion()).thenReturn(1);
when(matchConfig.isLocalEligibilityRequired()).thenReturn(false);
// —— Redis:缓存未命中 ——
when(redisTemplate.opsForValue()).thenReturn(valueOps);
when(valueOps.get(anyString())).thenReturn(null);
// —— 双方账号均正常、可被推荐、无隐私限制 ——
when(userMapper.findById(ME)).thenReturn(Optional.of(user(ME)));
when(userMapper.findById(CANDIDATE)).thenReturn(Optional.of(user(CANDIDATE)));
when(privacySettingsMapper.findByUserId(anyString())).thenReturn(Optional.empty());
// —— 资料 ——
when(userProfileMapper.findByUserId(ME)).thenReturn(Optional.of(myProfile()));
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(candidateProfile()));
when(userProfileMapper.selectList(any())).thenReturn(List.of(candidateProfile()));
// —— 择偶条件:自己设了条件;候选人未设(方向 B→A 不受约束)——
when(preferenceMapper.findByUserId(ME)).thenReturn(Optional.of(myPreference()));
when(preferenceMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(new UserPreference()));
// —— 无历史推荐 / 无 Pass / 无拉黑 ——
when(recommendationRecordMapper.findRecentByUserId(anyString(), any())).thenReturn(List.of());
when(relationMapper.findBlockedByUser(anyString())).thenReturn(List.of());
when(relationMapper.findByRelation(anyString(), anyString(), anyString())).thenReturn(Optional.empty());
}
@Test
@DisplayName("回归守卫:存在合格候选 → 推荐必须非空(此前评分桩 50<60 导致恒空)")
void recommendationsAreNotEmptyWhenEligibleCandidateExists() {
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
assertFalse(result.isEmpty(),
"存在满足条件的候选人时,推荐不得为空 —— 这正是 UAT 实测暴露的缺陷");
assertEquals(CANDIDATE, result.get(0).getUserId());
assertNotNull(result.get(0).getRecommendationReasons());
assertFalse(result.get(0).getRecommendationReasons().isEmpty(), "应带中文推荐理由");
assertEquals("high", result.get(0).getRecommendationLevel(),
"完全匹配的候选应落在 high 档(评分 100)");
}
@Test
@DisplayName("判别力:候选各维度均不符 → 被阈值过滤,推荐为空")
void ineligibleCandidateIsFilteredOut() {
// 候选人年龄 48 / 身高 150 / 地区 320500 / 离异 —— 与择偶条件全不符
UserProfile bad = candidateProfile();
bad.setBirthDate(LocalDate.now().minusYears(48));
bad.setHeightCm(150);
bad.setRegionCode("320500");
bad.setMaritalStatus("divorced");
when(userProfileMapper.findByUserId(CANDIDATE)).thenReturn(Optional.of(bad));
when(userProfileMapper.selectList(any())).thenReturn(List.of(bad));
List<RecommendationCandidateDTO> result = engine.getDailyRecommendations(ME);
assertTrue(result.isEmpty(), "全维度不符的候选必须被阈值过滤(证明评分确有判别力)");
}
// ==================== fixtures ====================
private User user(String id) {
User u = new User();
u.setId(id);
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
u.setRecommendationEnabled(true);
return u;
}
private UserProfile myProfile() {
UserProfile p = new UserProfile();
p.setUserId(ME);
p.setGender("male");
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
p.setBirthDate(LocalDate.now().minusYears(33));
p.setHeightCm(175);
p.setRegionCode("321181");
p.setMaritalStatus("never_married");
return p;
}
private UserProfile candidateProfile() {
UserProfile p = new UserProfile();
p.setUserId(CANDIDATE);
p.setGender("female");
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
p.setBirthDate(LocalDate.now().minusYears(30));
p.setHeightCm(168);
p.setRegionCode("321181");
p.setMaritalStatus("never_married");
return p;
}
/** 四个维度均设条件,且默认权重(age .15 / height .05 / region .20 / marital .10)。 */
private UserPreference myPreference() {
UserPreference p = new UserPreference();
p.setMinAge(25);
p.setMaxAge(35);
p.setMinHeightCm(160);
p.setMaxHeightCm(175);
p.setRegionScope(MAPPER.valueToTree(new String[]{"321181"}));
p.setMaritalStatuses(MAPPER.valueToTree(new String[]{"never_married"}));
return p;
}
}
@@ -0,0 +1,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;
}
}
@@ -18,7 +18,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; 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} 没有携带 * <p><b>为什么这是一个需要测试来守的约束</b>:裸 {@code RuntimeException} 没有携带
* HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成 * HTTP 语义,落进 {@link GlobalExceptionHandler#handleUnknownException} 只能兜底成
@@ -42,13 +44,20 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class RuntimeExceptionThrowGuardTest { 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} 不在守卫范围内: * <p>{@code IllegalStateException} / {@code IllegalArgumentException} 不在守卫范围内:
* 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。 * 前者已有专用处理器映射到 409(有明确状态语义),后者映射到 400。
*/ */
private static final Pattern BARE_THROW = private static final Pattern BARE_RUNTIME_NEW =
Pattern.compile("throw\\s+new\\s+RuntimeException\\s*\\("); Pattern.compile("new\\s+RuntimeException\\s*\\(");
private static final Path MAIN_SOURCES = Paths.get("backend/src/main/java"); 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 content = Files.readString(file, StandardCharsets.UTF_8);
String[] lines = stripComments(content).split("\n", -1); String[] lines = stripComments(content).split("\n", -1);
for (int i = 0; i < lines.length; i++) { 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()); offenders.add(file + ":" + (i + 1) + " " + lines[i].trim());
} }
} }
} }
assertTrue(offenders.isEmpty(), assertTrue(offenders.isEmpty(),
"发现 " + offenders.size() + " 处裸抛 RuntimeException。" "发现 " + offenders.size() + " 处裸抛/裸构造 RuntimeException(含 orElseThrow 的 Supplier 写法)。"
+ "业务拒绝请改成 new BusinessException(ApiResponseCode.XXX, \"文案\")," + "业务拒绝请改成 new GlobalExceptionHandler.BusinessException(ApiResponseCode.XXX, \"文案\"),"
+ "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n" + "服务端故障请改成 new ServerErrorException(\"文案\", e)。\n"
+ String.join("\n", offenders)); + String.join("\n", offenders));
} }
@@ -92,6 +101,7 @@ class RuntimeExceptionThrowGuardTest {
/* throw new RuntimeException("块注释里的也不应命中"); */ /* throw new RuntimeException("块注释里的也不应命中"); */
if (x == null) throw new RuntimeException("代码里的必须命中"); if (x == null) throw new RuntimeException("代码里的必须命中");
throw new RuntimeException("跨空格的必须命中"); throw new RuntimeException("跨空格的必须命中");
return repo.findByUserId(id).orElseThrow(() -> new RuntimeException("Supplier 写法必须命中"));
throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常"); throw new BusinessException(ApiResponseCode.NOT_FOUND, "业务异常");
throw new ServerErrorException("服务端故障"); throw new ServerErrorException("服务端故障");
"""; """;
@@ -99,14 +109,16 @@ class RuntimeExceptionThrowGuardTest {
String[] lines = stripComments(sample).split("\n", -1); String[] lines = stripComments(sample).split("\n", -1);
int hits = 0; int hits = 0;
for (String line : lines) { for (String line : lines) {
if (BARE_THROW.matcher(line).find()) { if (BARE_RUNTIME_NEW.matcher(line).find()) {
hits++; 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) { private static boolean matches(String code) {
Matcher m = BARE_THROW.matcher(code); Matcher m = BARE_RUNTIME_NEW.matcher(code);
return m.find(); return m.find();
} }
+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 | ✅ 已完成 | | Home Aggregate API + V1.1首页 | Phase 07 | ✅ 已完成 |
| Banner 管理(后端+前端) | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) | | Banner 管理(后端+前端) | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
| 本地资格声明与审核 | Phase 04~05 | ⏳ 待开发 | | 本地资格声明与审核 | Phase 04~05 | ✅ 已完成(2026-09-12 复验关闭;`LocalEligibilityService` 已实现、测试库 `local_eligibility_*` 表就位;仅「触发策略 / 文件保留期限」属产品待确认,见 `PENDING_DECISIONS.md`) |
| 公益红娘服务转介 | Phase 13 | ✅ 已完成 | | 公益红娘服务转介 | Phase 13 | ✅ 已完成 |
| 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ✅ 已完成(2026-09-17 复验关闭) | | 婚恋咨询 + 婚前辅导(Phase 13A) | Phase 13A | ✅ 已完成(2026-09-17 复验关闭) |
| 内容管理(婚恋课堂/反诈) | Phase 19 | ✅ 已完成(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)。
2. PR 模板所有 **T 级对应清单项**已逐项打勾并附验证说明。
3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。
4. 新增/修改的测试已做负向注入。
5. 跨端契约字段、数据库约束、设计 token 已对齐各自事实源。
### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能)
| CI job | 它验证了什么 | 它验证不了的(必须由人审) |
|--------|--------------|----------------------------|
| Backend Build & Test | `mvn verify` 编译+单测 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 |
| 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`),自认 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 #!/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}" BASE_URL="${BASE_URL:-https://match.danyang.gov.cn}"
API="${BASE_URL%/}/api/v1"
PASS=0 PASS=0
FAIL=0 FAIL=0
WARN=0
check() { # check_status NAME URL EXPECTED_CODE
local name="$1" check_status() {
local url="$2" local name="$1" url="$2" expected="$3"
local expected="$3"
local code 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 if [ "$code" = "$expected" ]; then
PASS=$((PASS + 1)) PASS=$((PASS + 1)); echo " ✅ $name (HTTP $code)"
echo " ✅ $name (HTTP $code)"
else else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1)); echo " ❌ $name (期望 $expected, 实际 $code) — $url"
echo " ❌ $name (期望$expected, 实际$code)" 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 fi
} }
echo "============================================" echo "============================================"
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 ""
echo "【健康检查】"
check "后端live" "/api/v1/health/live" "200"
check "后端ready" "/api/v1/health/ready" "200"
check "版本信息" "/version" "200"
echo "" echo ""
echo "【认证流程】" echo "【1. 健康检查】"
check "微信登录端点" "/api/v1/auth/wechat/login" "401" # 无code参数应400或401 check_status "后端 live" "${API}/health/live" "200"
check_status "后端 ready" "${API}/health/ready" "200"
check_status "版本信息" "${BASE_URL%/}/version" "200"
echo "" echo ""
echo "【核心功能】" echo "【2. 匿名鉴权守卫(应 401)】"
check "用户资料接口" "/api/v1/profile" "401" # 未登录应401 check_auth_reject "微信登录缺参" "${API}/auth/wechat/login"
check "推荐列表接口" "/api/v1/matches/recommendations" "401" 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 ""
echo "【安全接口】" echo "【3. 安全接口(应 401,不泄露)】"
check "管理员接口无认证拒绝" "/api/v1/operator/users" "401" check_auth_reject "管理端统计" "${API}/operator/analytics/v11-dashboard"
check "举报提交接口" "/api/v1/risk/reports" "401"
echo "" echo ""
echo "【Nginx】" echo "【4. Nginx / 入口】"
check "Nginx首页" "/" "200" 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 "============================================" echo "============================================"
echo " 结果: PASS=${PASS} FAIL=${FAIL}" echo " 结果: PASS=${PASS} FAIL=${FAIL} WARN=${WARN}"
echo "============================================" 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 "enablePullDownRefresh": false
}, },
"tabBar": { "tabBar": {
"color": "#999999", "color": "#47528C",
"selectedColor": "#E96B6B", "selectedColor": "#F4005A",
"backgroundColor": "#FFFFFF", "backgroundColor": "#FFFFFF",
"borderStyle": "black", "borderStyle": "black",
"list": [ "list": [
+35 -32
View File
@@ -66,6 +66,22 @@ page {
--gradient-night: linear-gradient(160deg, #172A72 0%, #25104F 100%); --gradient-night: linear-gradient(160deg, #172A72 0%, #25104F 100%);
--gradient-night-soft: linear-gradient(160deg, #3445A1 0%, #172A72 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 新增;参考图有光晕与星点,此前完全没有) ---- */ /* ---- 夜间氛围层(v2 新增;参考图有光晕与星点,此前完全没有) ---- */
--mini-glow-coral: rgba(255, 79, 145, 0.40); --mini-glow-coral: rgba(255, 79, 145, 0.40);
--mini-glow-indigo: rgba(94, 124, 255, 0.32); --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) | | 安全区域 | 53 × 53 px(最小边距 14px) |
| 线宽 | 4px(缩放到导航栏后约 1.3px) | | 线宽 | 4px(缩放到导航栏后约 1.3px) |
| 圆角 | 端点圆角 + 转折圆角 | | 圆角 | 端点圆角 + 转折圆角 |
| 填充 | 默认态线性;选中态填充核心爱心 | | 填充 | 默认态线性描边;选中态实心填充 + 白点/白线点缀 |
## 颜色规范 ## 颜色规范
| 状态 | 颜色值 | 用途 | | 状态 | 颜色值 | 用途 |
|------|--------|------| |------|--------|------|
| 未选中 | `#999999` | 默认状态图标 | | 未选中 | `#47528C` | 默认状态图标(藏青蓝,与导航栏品牌色同系) |
| 选中 | `#E96B6B` | 激活状态图标 | | 选中 | `#F4005A` | 激活状态图标(玫红/品红,高对比) |
| 背景 | `#FFFFFF` | 透明背景(PNG 支持 alpha) | | 背景 | 透明 | PNG 支持 alpha,无需底色 |
## 图标清单 ## 图标清单
### 1. 首页 (home) ### 1. 首页 (home)
- **意象**: 爱心小屋 - **意象**: 家
- **元素**: 圆角屋顶 + 房屋主体 + 门内爱心 - **元素**: 圆角屋顶 + 房屋主体 + 门洞(线条描边,选中态实心玫红)
- **文件**: `home.png` / `home-active.png` - **文件**: `home.png` / `home-active.png`
### 2. 找对象 (discover) ### 2. 找对象 (discover)
- **意象**: 双人缘分 - **意象**: 心动缘分
- **元素**: 两个人物轮廓 + 中央爱心 - **元素**: 单颗爱心轮廓(选中态实心玫红),呼应「找对象」主题
- **文件**: `discover.png` / `discover-active.png` - **文件**: `discover.png` / `discover-active.png`
### 3. 消息 (message) ### 3. 消息 (message)
- **意象**: 真诚沟通 - **意象**: 真诚沟通
- **元素**: 圆角气泡 + 爱心 - **元素**: 圆角对话气泡 + 三个圆点(选中态气泡实心玫红、圆点为白)
- **文件**: `message.png` / `message-active.png` - **文件**: `message.png` / `message-active.png`
### 4. 活动 (activity) ### 4. 活动 (activity)
- **意象**: 公益相遇 - **意象**: 公益相遇
- **元素**: 日历主体 + 挂环 + 爱心 - **元素**: 圆角日历主体 + 顶部双挂环 + 中央加号(选中态日历实心玫红、加号为白)
- **文件**: `activity.png` / `activity-active.png` - **文件**: `activity.png` / `activity-active.png`
### 5. 我的 (mine) ### 5. 我的 (mine)
- **意象**: 个人守护 - **意象**: 个人中心
- **元素**: 单人轮廓 + 胸前爱心 - **元素**: 圆形头像 + 半身轮廓(选中态实心玫红)
- **文件**: `mine.png` / `mine-active.png` - **文件**: `mine.png` / `mine-active.png`
## 导出尺寸 ## 导出尺寸
@@ -64,7 +78,7 @@
所有图标均提供可编辑的 SVG 版本,位于 `svg/` 目录: 所有图标均提供可编辑的 SVG 版本,位于 `svg/` 目录:
- 可直接在 Figma 中导入编辑 - 可直接在 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

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