Author SHA1 Message Date
WorkBuddy 47cccc9ca5 test: 补 v1.1.0 修复的回归测试
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
- RealIdentityVerificationProvider: 伪造证件校验位拦截、配置缺失 fail-fast、第三方 fail-closed
- AuthService: 钉死刷新 Token Redis TTL 用 getRefreshExpirationMs(14 天)而非 access*2(4 小时)
- PhotoService: 直传落库 + 归属/存在性护栏(修复「传了白传」)
- JwtAuthFilter: 管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 + 头优先于 Cookie
- miniapp profileLabels: 枚举码→中文映射不退化(serviceTypeLabel / labelOf)
2026-09-30 14:05:48 +08:00
WorkBuddy 5c97c57c76 Merge branch 'main' of http://120.27.207.134:13000/admin/danyang-match
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
# Conflicts:
#	README.md
2026-09-30 13:44:13 +08:00
WorkBuddy ca0db57f24 chore(release): 冻结 v1.1.0 版本
- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0
- 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md):
  - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast)
  - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto)
  - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天)
  - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入
  - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置
- 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证(均实际执行)
- node scripts/miniapp-compile-check.mjs → WXSS 88 个 + WXML 86 个,0 失败
- 负向注入:把 `.tap-gap > * + *` 改回去 → 预检 FAIL(exit 1),报错与用户原始报错逐字一致
  `app.wxss(364:12): error at token '*'`;已还原并复测通过
- node scripts/run-ui-contracts.mjs → 20 / 20 PASS
2026-09-23 08:25:11 +08:00
WorkBuddy 631e3000d3 管理端:设计系统 v2 收敛 + 修复漏斗卡恒为 0 的响应式缺陷
## 一、设计系统收敛

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

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

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

## 验证

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

test(deployment): MigrationValidationTest 基线 46 → 47

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

═══ 验证 ═══

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

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

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

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

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

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

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

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

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

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

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

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

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

═══ 验证 ═══

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

注:vite build 直接执行会失败,栈停在 emptyDir(dist) 且全部位于本机
node-safe-delete-shim(批量删除守卫)内,与本次改动无关;改用全新 outDir 复跑即
成功,据此排除「改动导致构建失败」。该现象此前无记录,已写入技能备查。
2026-09-18 19:36:07 +08:00
498 changed files with 31147 additions and 17254 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 / 决策 / 风险编号 -->
+111 -8
View File
@@ -42,8 +42,36 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: admin-web/pnpm-lock.yaml
# 这里不能写 cache: 'pnpm'。
# 2026-09-22 实测:runner 镜像(ubuntu-24.04 / runner 2.337.0)**已不再预装
# pnpm**,而 setup-node 的 cache 步骤在 restore 阶段就需要调用 pnpm 可执行文件,
# 于是直接报 "Unable to locate executable file: pnpm",整个 job 在第一步失败、
# 后续步骤全部 skipped —— 表现为 admin-web job 秒红,且与业务代码无关。
# (同批的 miniapp job 没配 cache 所以活了下来,正好反向印证了这一点。)
# 因此:先让 pnpm 存在于 PATH,缓存改为手动配置。
- name: 启用并锁定 pnpm
# corepack 随 Node 分发,runner 上没有 pnpm 但有 corepack。
# 钉 9.15.9 的理由:pnpm 9 与 10 对「是否默认放行依赖 build 脚本」语义相反,
# 而 pnpm-workspace.yaml 里的 onlyBuiltDependencies 正是版本敏感项
# (esbuild / vue-demi 的 postinstall 若被静默阻断,会出现 install 能过、
# build 变红,且错误信息不指向真正原因)。钉死后不再随 runner 镜像升级而变化。
run: |
corepack enable pnpm
corepack prepare pnpm@9.15.9 --activate
pnpm --version
- name: 解析 pnpm store 路径
id: pnpm-store
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: 缓存 pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('admin-web/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install Dependencies
working-directory: admin-web
@@ -143,9 +171,45 @@ jobs:
- name: 禁止 skip/pass 空壳测试
run: python3 scripts/check-no-empty-tests.py tests
ui-contracts:
name: UI Contracts (source-structure gates)
runs-on: ubuntu-latest
# Phase 23 遗留(收尾计划 批 1 Task 1.1):UI 契约门禁此前**只在本地手工执行**,
# 没有 CI 把关 —— 即「改动破坏了源码结构契约也不会被发现」。
# 2026-09-19:14 → 15 → 18 个守卫。
# 第 15 项 WXML 结构守卫(此前 14 绿时小程序仍有两个页面编译失败:
# service-history / service-program 各多一个 </view>);
# 第 16/17 项:`this._x()` 无定义(运行期 TypeError,曾致所有请求 Promise 永不 settle)
# 与组件 WXSS 违规选择器(标签/ID/属性选择器);
# 第 18 项:api.js 请求层**行为**契约(桩 wx.request 真跑,非源码断言)。
# 统一执行器负责逐个跑 + 汇总 + 非零退出,
# 并校验守卫数量下限(防止守卫被删除/改名后仍然报绿)。
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
# 2026-09-22 修正:本 job 此前注释称「零 npm 依赖,故无需装依赖」,这个声明是错的。
# 至少 phase04 守卫会 import miniapp/node_modules/typescript/lib/typescript.js
# (用 TS 编译器 API 解析小程序源码)。本地能跑通只因为工作区早已装过 miniapp 依赖;
# CI 的每个 job 都在**独立 runner**上,miniapp job 装的东西不会共享过来,
# 于是这里直接 ERR_MODULE_NOT_FOUND。
# 「依赖声明与实际不符」正是本地绿 / CI 红最典型的一类来源。
- name: 安装 miniapp 依赖(phase04 守卫需要 typescript 编译器 API)
working-directory: miniapp
run: npm install
- name: 运行 UI 契约门禁
run: node scripts/run-ui-contracts.mjs
e2e:
name: E2E (live stack)
runs-on: ubuntu-latest
# 防挂死:GH Actions 默认 360 分钟,起栈失败若无人超时会把流水线拖住数小时。
timeout-minutes: 30
# Phase 23 遗留 D-23-D:计划要求 CI 串联 E2E。此前 E2E 从未真正执行过——
# 无 conftest、fixture 不存在、文件在 import 期就崩。本 job 起真实栈后执行。
needs: [backend, migration]
@@ -176,20 +240,56 @@ jobs:
WECHAT_APP_SECRET=
ENV
- name: 起真实栈(postgres + redis + backend)
# 「活栈供给方式」决策(2026-09-18):**在 runner 内用 Compose 起全栈**。
# 备选是 service containers + 在 runner 上 `mvn package` 后 `java -jar`,但那需要为后端
# 另写一套 host 网络配置,等于制造第二条与本地不一致的路径;Compose 全栈则与本地验证
# 路径完全相同(2026-09-18 本机按同一序列实测:中间件三项 healthcheck 全 Healthy、
# backend /health/live 200、seed 幂等可执行、**pytest tests/e2e 49 passed**)。
- name: 校验 compose 配置(秒级快速失败,早于任何构建)
working-directory: infrastructure
run: docker compose up -d --build postgres redis backend
run: docker compose config --quiet
# 2026-09-22:compose 的对象存储已换成 chrislusf/seaweedfs:4.47 并钉版。
# 单独保留这一步的理由:镜像拉取失败在 compose up 里会埋进几十行输出,
# 这里能在起栈前秒级暴露「上游镜像下架」这类外部依赖失效 ——
# 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库
# (minio/minio 现返回 404;教训是「第三方镜像」本身就是外部依赖风险点)。
- name: 校验 seaweedfs 钉版镜像可拉取并打印版本
run: |
docker pull chrislusf/seaweedfs:4.47
docker run --rm chrislusf/seaweedfs:4.47 version
# 保证库为全新:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷是上一轮残留,
# 账号密码会停留在上一轮的 `E2E_OPERATOR_PASSWORD` 上 → 本轮登录 401(表现为各种业务失败)。
- name: 清理可能的残留栈与数据卷
working-directory: infrastructure
run: docker compose down -v --remove-orphans || true
# 构建与起栈分两步:构建失败时的日志不混杂在 up 输出里,定位更快。
- name: 构建 backend 镜像
working-directory: infrastructure
run: docker compose build backend
- name: 起真实栈(postgres + redis + seaweedfs + 建桶 + backend)
# 显式列出 seaweedfs 与 storage-init:backend 的 depends_on 虽会隐式拉起它们,
# 但隐式依赖不利于阅读,且 seaweedfs 的 healthcheck 是 backend 启动的前置条件。
# storage-init 是**一次性**建桶容器 —— SeaweedFS 不会自动建桶,
# PutObject 到不存在的 bucket 会直接失败,少了它照片链路就是断的
# (E2E 对存储零覆盖,不会替你发现)。
working-directory: infrastructure
run: docker compose up -d postgres redis seaweedfs storage-init backend
- name: 等待 backend 就绪(超时即失败,不静默跳过)
# 窗口 90×5s=7.5min:首次构建后还需 Spring 启动 + Flyway 46 个迁移,5min 偏紧。
run: |
for i in $(seq 1 60); do
for i in $(seq 1 90); do
if curl -sf "$E2E_BASE_URL/health/live" > /dev/null; then
echo "backend READY after ${i} checks"; exit 0
fi
sleep 5
done
echo "::error::backend 未在 5 分钟内就绪"
docker compose -f infrastructure/docker-compose.yml logs backend
echo "::error::backend 未在 7.5 分钟内就绪"
docker compose -f infrastructure/docker-compose.yml logs --tail=200 backend
exit 1
- name: Seed E2E 运营账号(迁移不含 operator 种子,全新库无账号可登录)
@@ -199,7 +299,10 @@ jobs:
-v pwd="$E2E_OPERATOR_PASSWORD" -f - < ../tests/e2e/sql/seed_e2e_operators.sql
- name: Run E2E
run: pytest -v
# 显式给出目录:pytest.ini 的 `testpaths = tests/e2e` 已限定范围,但显式传参可防
# 该配置被改动后悄悄扩大收集范围(仓库根另有 tests/*_test.py 不是 E2E 用例,
# 且它们在 import 期即依赖 psycopg2 与 E2E_DB_PASSWORD,会被收集成 error)。
run: pytest tests/e2e -v
- name: Teardown(无论成败都清理并留日志)
if: always()
+30 -1
View File
@@ -10,11 +10,30 @@ package-lock.json
yarn.lock
pnpm-lock.yaml
# 例外:依赖锁文件必须入库,CI 的可复现安装依赖它们。
#
# 2026-09-22 修复背景:上面四条规则此前把 admin-web/pnpm-lock.yaml 与
# miniapp/package-lock.json 一并挡在版本控制之外,于是 CI checkout 后的环境
# 与本机完全不同:
# - admin-web:`pnpm install --frozen-lockfile` 找不到 lockfile → 直接失败
# (frozen 模式的语义就是「必须存在且不可变更」,缺文件不是警告而是错误);
# - miniapp:`npm install` 无 lockfile 约束,版本随上游浮动,
# `npx tsc --noEmit` 的结果不与任何一次本地验证对齐。
# 二者共同导致「本地绿、CI 红」且二者都不可解释。
#
# 只放行每个包真正使用的那把锁:admin-web 走 pnpm,miniapp 走 npm。
# admin-web/package-lock.json 仍然忽略 —— 同一目录并存两把锁会让两种包管理器
# 各自按自己的锁安装,漂移时彼此看不出问题,属于典型的双锁冲突。
!admin-web/pnpm-lock.yaml
!miniapp/package-lock.json
# Build outputs
backend/target/
admin-web/dist/
miniapp/dist/
miniapp/unpackage/
# vite 在 dev server 被强杀(未优雅退出)时会遗留配置临时文件
admin-web/vite.config.ts.timestamp-*.mjs
# IDE
.idea/
@@ -51,7 +70,17 @@ Thumbs.db
.DS_Store
# Secrets and certificates
docker-compose.prod.yml
#
# 2026-09-22 修正:`docker-compose.prod.yml` 原先被列在本段(当作密钥)而排除出
# 版本控制,这是**误分类** —— 该文件不含任何明文密钥:所有敏感值都通过
# `${VAR:?请设置...}` 注入,db 密码走 Docker secrets(./secrets/db_password.txt),
# 文件头注释本身也写着「敏感信息通过 .env 文件注入,不进 Git」。
#
# 后果:backend 的 `DeploymentTests.testDockerComposeProdExists` 断言该文件存在,
# 于是「本地恒绿(文件在工作区)、CI 恒红(checkout 拿不到)」——
# 一次典型的本地绿/CI 红假绿,且从本地永远发现不了。
# 生产部署配置需要可复现、可评审,应与 staging 版一致纳入版本控制。
# 真正的密钥仍由下面三条挡住。
infrastructure/secrets/
infrastructure/certs/
infrastructure/backups/
+2 -1
View File
@@ -22,7 +22,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因
业务 Phase 唯一执行计划:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
当前 UI 重设计唯一总计划:`docs/superpowers/plans/2026-09-13-reference-driven-dual-client-ui-redesign.md`。首批范围(小程序 11 个核心页面 + 管理端框架/工作台)已于 **2026-09-16 通过用户验收(结论:基本达标)**,此前"视觉否决 / REOPENED"的判定随之关闭,UI 契约门禁 13/13 全绿。管理端桌面端目视复验仍待用户执行。
当前 UI 重设计唯一总计划:`docs/superpowers/plans/2026-09-13-reference-driven-dual-client-ui-redesign.md`。首批范围(小程序 11 个核心页面 + 管理端框架/工作台)已于 **2026-09-16 通过用户验收(结论:基本达标)**,此前"视觉否决 / REOPENED"的判定随之关闭,UI 契约门禁 **18/18** 全绿(2026-09-16 时为 13 项,此后新增端口与 `/admin/` 子路径一致性门禁;2026-09-19 连补 4 项 —— 第 15 项 **WXML 结构守卫**:此前全绿时小程序实际有两个页面 `service-history` / `service-program` 因多一个 `</view>` **编译失败**;第 16 项 **私有方法调用守卫**:`this._x()` 无定义会抛 TypeError,且该调用位于 `resolve` 之前时会让**所有请求**的 Promise 永不 settle;第 17 项 **组件 WXSS 选择器守卫**;第 18 项 **api.js 请求层行为契约**(桩掉 `wx.request` 真跑)。前三项说明既有门禁全是源码内容断言、没有一条会解析 WXML/WXSS 或符号)。管理端桌面端目视复验仍待用户执行。
---
@@ -61,6 +61,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因
24. **Service Station Operator 不可浏览全用户库** — 服务站人员只能处理现场预约和指定任务
25. **主办单位关系不自动授予数据权限** — Organization relationship ≠ 用户数据访问权限,权限由 Role+Permission+DataScope 控制
26. **前端 Capability 控制入口** — 用户端各功能入口可见性由服务端 CapabilityService 返回决定,前端不自行拼状态判断
27. **配置与规则治理(V1.0.1 纠偏基线)** — 技术默认值 ≠ 产品正式确认规则。所有未经产品确认的数量/阈值/时间/比例/权重/容量/开关/冷却期,一律按「`DEFAULT + CONFIG`」管理:可配置化(`@ConfigurationProperties`/Feature Flag)、**不得**作为 `LOCKED` 业务规则写死;每个配置有唯一 canonical key,登记 `docs/governance/config-register.md`,未确认项登记 `docs/governance/open-questions.md`,已确认项才入 `docs/governance/decision-register.md`。涉及并发的上限必须有服务器端强校验 + 并发突破测试。详见 `docs/governance/`(config-register / open-questions / decision-register / hardcoded-rule-audit / config-drift-audit)。
---
+3
View File
@@ -1,3 +1,6 @@
> **📌 指针(2026-09-18 头注)**:V1.1 变更记录见 `docs/V1_1_CHANGELOG.md`;发布级变更另见
> `releases/v1.0.0-RC.1|RC.2/`。本文件不再逐版本维护。
# CHANGELOG
所有重要变更将记录在此文件中。
-1
View File
@@ -1 +0,0 @@
婚恋服务小程序
+24
View File
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+74 -300
View File
@@ -1,322 +1,96 @@
# 丹阳公益婚恋服务平台 V1.0 — 上线前剩余工作
# 丹阳公益婚恋服务平台 V1.1 — 上线前剩余工作
> 本文件为上线前必须完成的工作清单,按优先级排序。
> 交由 WorkBuddy 执行。
> **最后更新:2026-09-18**(原文为 2026-09-01,内容已严重过期,本轮按实测重写)
>
> ⚠️ **重写说明**:本文件此前被多处引用,但内容与实际严重不符 —— 称 `CHANGELOG.md` 有 **3.3MB**(实际
> **4234 字节 / 73 行**);P1-3 指向**已删除**的 `docs/Phase-23-Completion-Report.md`。2026-09-18 文档治理按实测重写,
> 并保留原编号以便对照历史引用。**逐项处置计划与批次划分**见
> `docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md`。
---
## P0 — 上线前必须完成(阻塞性)
## 一、已关闭项(原文标为阻塞 / 重要,实测已完成)
### P0-1: 产品确认16项技术默认值
**文件**: `docs/PENDING_DECISIONS.md`
**问题**: 以下技术默认值已硬编码,但无产品最终确认:
| 决策项 | 当前技术默认值 | 需要确认 |
| 原编号 | 事项 | 结论(2026-09-18 复核) |
|---|---|---|
| 每日推荐数量 | 6 人 | 产品确认 |
| 推荐最低阈值分数 | 60 分 | 产品确认 |
| Pass 冷却时间 | 180 天 | 产品确认 |
| 照片上限 | 6 张 | 产品确认 |
| 兴趣标签上限 | 8 个 | 产品确认 |
| 自我介绍长度上限 | 500 字符 | 产品确认 |
| Privacy 默认值 | 全部 TRUE | 产品确认 |
| 活动是否需要审核 | TRUE | 产品确认 |
| 活动互选最大人数 | 3 人 | 产品确认 |
| 活动互选截止时间 | 48 小时 | 产品确认 |
| 红娘是否允许电话联系 | FALSE | 产品确认 |
| "正在了解"是否自动暂停推荐 | 用户选择 | 产品确认 |
| RelationshipStatus 最终命名 | searching/getting_to_know/paused/matched_successfully/service_exited | 产品确认 |
| Appeal 是否 V1.0 首发 | 否(Feature Flag 预留) | 产品确认 |
| 每日推荐重置时间 | 00:00 Asia/Shanghai | 产品确认 |
**操作**:
1. 产品负责人逐一确认以上16项
2. 确认后更新 `application.yml` 或对应配置类
3. 从 `docs/PENDING_DECISIONS.md` 移除已确认项
4. 更新 `AGENTS.md` 中的规则说明(如有影响)
| P0-1 | 产品确认 16 项技术默认值 | ✅ **已确认 16 项**(见 `docs/PENDING_DECISIONS.md`「已确认决策列表」);仍有 13 项待确认(另见该文件「第三方开放遗留」7 项) |
| P0-2 | 隐私政策最终版本确认 | ✅ `docs/legal/privacy-policy.md` 已存在(2996 字节)。上线前仍需确认是否经法务终审签署 |
| P0-3 | PIA(隐私影响评估)书面记录 | ✅ `docs/security/PIA.md` 已存在(4700 字节) |
| P1-1 | CoreInvariantsTest 补实质性测试 | ✅ **已如实处置**:该类(195 行 / 12 用例)已明确降级为「**常量取值口径守卫,不验证行为**」(DisplayName 已改),真实行为验证转由各 Integration 测试承担(Phase 13 复验)。原文"只是检查常量定义"的批评成立,但处置方式是**如实命名 + 转移**,而非硬塞行为断言 |
| P1-2 | PermissionMatrixTest 补自动化验证 | ✅ 已有 341 行 / 12 用例;权限覆盖另由 `V11PermissionMatrixContractTest`(**扫描全部迁移**)+ `OperatorEndpointPermissionCoverageTest` 承担 |
| P1-3 | 补全 Phase 23 系统测试完成报告 | ✅ `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md` 已存在(结论:**部分关闭**)。**注意原文指向的 `docs/Phase-23-Completion-Report.md` 从未存在** |
| P1-4 | 清理 CHANGELOG 重复内容 | ✅ **不成立**:`CHANGELOG.md` 实际 **4234 字节 / 73 行**,无重复条目问题;已加指向 `docs/V1_1_CHANGELOG.md` 的指针头注(2026-09-18) |
| P2-1 | AccountLifecycle 注销联动完善 | ✅ Phase 21 复验关闭(2026-09-18):**CLOSED 仍可登录、两处 TODO 桩、匿名化死代码、注销障碍设计矛盾**等 6 处缺陷已修 |
| P2-2 | PhotoService Presigned URL 实现 | ⚠️ **部分**:`PhotoService` 魔数校验恒失败、PDF 被误挡等已修(Phase 10 复验);`POST /me/photos/upload-token` / 下载凭证 / EXIF 清理的落地程度**待确认** |
| P2-3 | WebSocket 在线状态管理 | ✅ Phase 09 复验关闭(2026-09-16):下行链路死代码、握手鉴权、Unmatch 不联动会话等 4 处缺陷已修 |
---
### P0-2: 隐私政策最终版本确认
## 二、仍未完成(按优先级)
**问题**: Go/No-Go 检查单标注"待确认",为上线前绝对阻塞项。
### A. 阻塞 Phase 24(UAT)启动
**操作**:
1. 确认隐私政策文本(用户数据采集、存储、使用、共享、权利)
2. 确认用户协议文本
3. 将最终版本放入 `docs/legal/` 目录
4. 更新小程序登录页和设置页的协议链接
1. **Phase 23 剩余**:E2E 场景真实实现 + CI 串联 E2E —— 需「活栈供给方式」**基础设施决策**
(占位文件头已保留场景清单:首页 11 / 本地资格 11 / 互选 8 / 权限 10 / 预约 7 项)。
2. **管理端桌面端目视复验** —— 需**用户执行**。
3. **`docs/PENDING_DECISIONS.md` 13 项待决策** —— 需产品确认。
> 在 A 组完成前,**不应宣布「失败为零可进入 UAT」**(Phase 23 Gate 要求端到端链路被真实执行)。
### B. 上线前(运维 / 产品,需人工)
- **P0-4** 微信小程序审核提交(人工)。
- **P0-5** SSL 证书申请与 DNS 配置(人工)。
- **Phase 22 Deployment / Ops 口径 5 项未复验**:删除镜像跳过测试的发布路径、生产关 Mock WeChat/演示 Seed/Debug/Swagger、
端口与依赖检测、**备份加密 + 真实恢复演练**、Rollback 目标明确。
⚠️ 该 Phase 与 `STATUS.md` 所述「Phase 22 部署运维配置扩展(Feature Flag)」**是两份不同工作**。
- **Phase 25 运营素材**(真实 Banner/站点/咨询/课堂/反诈内容,不 Seed 假数据)、**Phase 26 上线前 13 项 smoke**。
### C. 代码级遗留(不阻塞 UAT,但需清偿)
- **Phase 21**:① 红娘服务进行中拦截**硬编码 `0`** —— 根因是数据模型**没有 `matchmaker_services` 表**,
拦截项在设计上无法落地(建表属扩权,待产品确认存储模型);② `getPrivacySettings` / `updatePrivacySettings` 仍是 TODO 桩。
- **Phase 22**:T01~T05 五个模块开关 + T07 私有桶为**死配置**(配置暴露但从未被业务读取)。
- **任务级 ⏳**:P16-T13(互选超时接线)、P13A-T31(随访审计)、P13A-T33(`RiskEvent source=counseling`)、
P04-T10(Onboarding 本地资格步骤)。
---
### P0-3: PIA(隐私影响评估)书面记录
## 三、原编号索引(便于对照历史引用)
**问题**: 9个场景需要书面记录,当前无记录。
**操作**:
1. 按以下场景撰写PIA记录:
- 实名认证(身份证采集)
- 照片存储与展示
- 位置/地区信息
- 聊天消息存储
- 活动报名数据
- 红娘服务数据
- 举报与风控数据
- 账号注销与数据删除
- 跨组织数据访问
2. 保存到 `docs/security/PIA.md`
| 原编号 | 原文标题 | 本文件位置 |
|---|---|---|
| P0-1 | 产品确认 16 项技术默认值 | 一、已关闭 |
| P0-2 | 隐私政策最终版本确认 | 一、已关闭(待法务终审) |
| P0-3 | PIA 书面记录 | 一、已关闭 |
| P0-4 | 提交微信小程序审核 | 二、B |
| P0-5 | SSL 证书申请与 DNS 配置 | 二、B |
| P1-1 | CoreInvariantsTest 补充实质性测试 | 一、已关闭 |
| P1-2 | PermissionMatrixTest 补充自动化验证 | 一、已关闭 |
| P1-3 | 补全 Phase 23 系统测试完成报告 | 一、已关闭 |
| P1-4 | 清理 CHANGELOG.md 重复内容 | 一、已关闭(原描述不成立) |
| P2-1 | AccountLifecycle 注销联动完善 | 一、已关闭 |
| P2-2 | PhotoService Presigned URL 实现 | 一、部分 |
| P2-3 | WebSocket 在线状态管理 | 一、已关闭 |
---
### P0-4: 提交微信小程序审核
## 四、验收标准(更新)
**问题**: 用户端核心入口,无审核通过的小程序无法服务用户。
**操作**:
1. 在微信公众平台提交小程序审核
2. 确保所有页面符合微信小程序审核规范
3. 配置好 `app.json` 中的类目和标签
4. 准备好隐私政策链接(见 P0-2)
| 项目 | 验收条件 | 状态 |
|---|---|---|
| P0-1 | 16 项全部产品确认,配置正确,文档更新 | ✅ 已完成 |
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 | ✅ 文件已就位(待终审确认) |
| P0-3 | `PIA.md` 存在,覆盖全部 9 个场景 | ✅ 文件已就位 |
| P1-1 | CoreInvariantsTest 用例如实命名;真实行为由 Integration 测试覆盖 | ✅ 已如实处置 |
| P1-2 | 权限矩阵 9×18 覆盖,实际调用验证 | ✅ 由契约守卫承担 |
| P1-3 | Phase 23 完成报告存在且内容完整 | ✅ 已存在(部分关闭) |
| P1-4 | `CHANGELOG.md` < 100KB,无重复条目 | ✅ 已满足(4.2KB) |
| P0-4 | 微信审核提交并通过 | ⬜ 待人工 |
| P0-5 | HTTPS 正常,证书有效 | ⬜ 待人工 |
| Phase 23 剩余 | E2E 场景实现 + CI 串联 | ⬜ 待决策 |
| Phase 24 | UAT 分角色验收,阻断缺陷为零 | ⬜ 待用户确认启动 |
---
### P0-5: SSL证书申请与DNS配置
**问题**: 生产HTTPS必需。
**操作**:
1. 申请SSL证书(Let's Encrypt 或商业证书)
2. 配置生产域名 DNS 解析(`match.danyang.gov.cn` 或约定域名)
3. 将证书放入 `infrastructure/certs/`
4. 验证 `nginx-production.conf` 中的证书路径
---
## P1 — 上线前应该完成(重要)
### P1-1: CoreInvariantsTest 补充实质性测试
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java`
**问题**: 当前测试只是检查常量定义,不是真正的业务逻辑验证。
**需要实现**(每个不变量需要真实业务逻辑测试):
```java
// INV-001: Required不可自动放宽
@Test
void testRequiredConditionNotRelaxed() {
// 创建用户A(要求对方学历=本科)和用户B(学历=专科)
// B进入A的推荐池 → 应该被Required条件过滤
// 验证B不在推荐结果中
}
// INV-002: 未Mutual不可聊天
@Test
void testCannotMessageWithoutMutual() {
// 用户A Like用户B,但B未Like A
// 调用发送消息接口 → 应返回403/禁止
}
// INV-003: Block优先级最高
@Test
void testBlockStopsAllInteractions() {
// A Block B后
// 验证B不在A的推荐中
// 验证A Like B后自动取消
// 验证A和B的Match自动解除
}
// INV-004: 双向并发Like只创建一个Match
@Test
void testConcurrentLikeCreatesOneMatch() {
// 线程A: A Like B
// 线程B: B Like A(并发)
// 验证只创建1个Match记录
}
// INV-005: 活动名额不超卖
@Test
void testActivityCapacityNotOversold() {
// 活动剩余1个名额
// 线程A和线程B同时报名
// 验证只有1人成功,1人被拒绝
}
// INV-006: 单向活动意向不泄露
@Test
void testActivityInterestNotDisclosed() {
// A选择B,但B没选A
// 验证B的查询结果中看不到A的选择
}
// INV-007: 红娘不能替用户Like
@Test
void testMatchmakerCannotLikeForUser() {
// 红娘角色调用Like接口 → 应返回403
}
// INV-008: 非授权Operator不能读取身份证
@Test
void testUnauthorizedOperatorCannotViewIdCard() {
// reviewer角色调用敏感身份查看接口 → 应返回403
}
// INV-009: 非案件上下文不能读取聊天
@Test
void testNoChatAccessOutsideCaseContext() {
// risk_operator无案件授权调用聊天记录 → 应返回403
}
// INV-010: 跨Organization不能越权
@Test
void testCrossOrgCannotAccess() {
// Organization A的用户,不能访问Organization B的数据
}
```
**注意**: 测试应使用真实数据库(集成测试模式),通过 `@SpringBootTest` + Testcontainers 或指定测试数据库。
---
### P1-2: PermissionMatrixTest 补充自动化验证
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/PermissionMatrixTest.java`
**问题**: 当前只有权限矩阵定义,没有实际API调用验证。
**需要实现**:
```java
@Test
void testPermissionMatrixAllRoles() {
// 遍历9个角色 × 18个权限
// 每个组合:
// 有权限 → 调用对应API → 应返回2xx
// 无权限 → 调用对应API → 应返回403
}
// 需要验证的角色:
// super_admin, platform_admin, organization_admin,
// reviewer, matchmaker, activity_manager,
// risk_operator, volunteer, data_viewer
// 需要验证的权限模块:
// user.view, user.sensitive.view, user.status.change,
// review.view, review.process,
// matchmaker.view, matchmaker.assign, matchmaker.service,
// activity.view, activity.create, activity.review_registration,
// risk.report.view, risk.report.process, risk.user.ban,
// organization.manage, permission.manage,
// analytics.view, data.export
```
**验证方式**: 通过 `MockMvc` 或 `RestAssured` 调用管理端API,使用不同角色的JWT Token认证。
---
### P1-3: 补全 Phase 23 系统测试完成报告
**文件**: `docs/Phase-23-Completion-Report.md`
**问题**: Phase 23(系统测试)缺少完成报告。
**需要包含**:
- 执行的测试类型和数量
- 测试结果(通过/失败/阻塞)
- 安全测试结果(SQL注入/XSS/CSRF等)
- 并发测试结果
- 发现的问题和修复情况
---
### P1-4: 清理 CHANGELOG.md 重复内容
**文件**: `CHANGELOG.md`
**问题**: 文件大小3.3MB,存在大量重复条目(同一个Phase变更被复制数十次)。
**操作**:
1. 去重,保留每项变更的唯一记录
2. 按版本(v1.0.0-rc.1, v1.0.0)组织
3. 文件压缩到合理大小(目标 < 100KB)
---
## P2 — 建议完成(非阻塞)
### P2-1: AccountLifecycle 注销联动完善
**现状**: 代码已实现,STATUS.md 标记为TODO。
**验证**: 确认注销后以下联动正确:
- 推荐停止
- 聊天停止(新消息禁止)
- 红娘服务停止
- 活动处理(未开始的按规则)
- 历史关系保留但标记为已注销
---
### P2-2: PhotoService Presigned URL 实现
**现状**: 代码骨架存在,功能未完整实现。
**需要完成**:
- `PhotoService` 生成 MinIO Presigned URL
- 上传凭证接口 `POST /me/photos/upload-token`
- 下载凭证接口(私有图片访问)
- EXIF信息清理逻辑
---
### P2-3: WebSocket 在线状态管理
**现状**: 骨架实现。
**需要完善**:
- 用户在线/离线状态跟踪
- 消息送达状态(已发送/已读)
- WebSocket 重连后的状态恢复
- 通知推送(`notification.new` 事件)
---
## 执行顺序建议
```
1. P0-1 (产品确认) → 产品负责人
2. P0-2 (隐私政策) → 产品/法务
3. P0-3 (PIA记录) → 安全负责人
4. P1-1 (Invariants测试) → 研发
5. P1-2 (权限矩阵测试) → 研发
6. P1-3 (Phase23报告) → 研发
7. P1-4 (CHANGELOG) → 研发
8. P0-4 (微信审核) → 产品
9. P0-5 (SSL/DNS) → 运维
10. P2-1 ~ P2-3 → 研发(可并行)
```
---
## 验收标准
每项完成后应满足:
| 项目 | 验收条件 |
|---|---|
| P0-1 | 16项全部产品确认,配置正确,文档更新 |
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 |
| P0-3 | PIA.md 存在,覆盖全部9个场景 |
| P1-1 | CoreInvariantsTest 10个用例全部包含真实业务逻辑 |
| P1-2 | PermissionMatrixTest 9×18全部覆盖,实际调用验证 |
| P1-3 | Phase-23-Completion-Report.md 存在且内容完整 |
| P1-4 | CHANGELOG.md < 100KB,无重复条目 |
| P0-4 | 微信审核提交并通过 |
| P0-5 | HTTPS正常,证书有效 |
---
*最后更新: 2026-09-01*
*最后更新: 2026-09-18 — 按实测重写(原文 2026-09-01,含 3 处与实际不符的陈述)*
+5
View File
@@ -1,3 +1,8 @@
> **⚠️ 部分取代(2026-09-18 头注)**:本文件为 V1.0 REST API 全量规范。V1.1 增量(Banner、
> 服务站、预约、咨询、本地资格、首页聚合等)以 `docs/善缘丹阳 V1.1 数据库与API增量设计.md` 为准,
> 路径收敛以 `docs/api/API_INVENTORY.md` 为准。本文件仍是后端代码注释与契约测试援引的协议出处
> (如 WebSocket 事件信封第 125 章),该部分继续有效。
# 丹阳公益婚恋服务平台
# V1.0 REST API 接口详细设计规范
+1 -1
View File
@@ -1 +1 @@
1.1.0-RC.1
1.1.0
@@ -1,3 +1,7 @@
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 项目规则总纲(333 章),最高执行文件已由
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.0 核心规则中仍生效的
> 部分已摘要进 `AGENTS.md`。本文件保留原位(`AGENTS_SUMMARY.md` 与历史报告仍援引),其余内容不再生效。
# 丹阳公益婚恋服务平台
# V1.0 WorkBuddy开发执行总指令、项目规则与任务拆解
@@ -1,3 +1,8 @@
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 首轮启动提示词,最高执行文件已由
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.1 需求以 `AGENTS.md`
> 点名的六份修订文档为准。本文件保留原位,仅作为 V1.0 规则来源与 Phase Gate 行号出处
> (`docs/Phase-XX-V11-Recovery-*` 报告援引)被继续引用,其余内容不再生效。
# 丹阳公益婚恋服务平台
# WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件
+5 -1
View File
@@ -2,7 +2,11 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- 原指向一个 SVG 图标文件,但仓库内并不存在该文件(public/ 下只有 brand/),
属既有的 404;且以 / 开头的绝对路径不会随 vite base 变化,
在 /admin/ 子路径部署下即使补上文件也取不到。
改为指向已存在、且会被 vite 自动加上 base 前缀的品牌标。 -->
<link rel="icon" type="image/png" href="/brand/logo-mark.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>丹阳公益婚恋 - 运营管理后台</title>
</head>
+14 -3
View File
@@ -15,9 +15,20 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
# 根路径没有内容:管理端产物挂在 /admin/ 子路径下(构建 base=/admin/)。
# 显式 301 而不是返回 nginx 默认页或 404,避免"打开有东西但其实是别的页面"的假成功。
location = / {
return 301 /admin/;
}
# SPA:/admin/** 映射到产物根目录。
# 上游入口(生产 nginx、本地整套栈 nginx)都**不剥离** /admin 前缀地转发到本容器,
# 因此这里必须自己认识这个前缀。
# try_files 的兜底写成 URI(/admin/index.html)而不是文件路径:与 alias 配合时,
# 写文件路径会被再次拼接 alias 而失效 —— 这是 alias + try_files 的已知坑。
location /admin/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /admin/index.html;
}
# API proxy to backend
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "danyang-match-admin",
"version": "1.0.0-rc.2",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
+2180
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -1,3 +1,27 @@
# 本目录只有一个前端包,不是 pnpm workspace。
#
# 2026-09-22 修复背景:
# 本文件原先只有两行 allowBuilds(esbuild / vue-demi),既没有 packages 声明,
# 也没写实际取值。pnpm 一旦检测到工作目录下存在 pnpm-workspace.yaml,
# 就会强制进入 workspace 模式,随后因读取不到 packages 而直接报错退出:
#
# ERROR packages field missing or empty
#
# 结果是 admin-web 的 `pnpm install --frozen-lockfile` 在任何环境都装不上依赖
# (本地与 CI 表现一致),表现为 admin-web job 秒红,且与业务代码无关。
#
# allowBuilds 是 pnpm 10 才引入的配置项;pnpm 9 不认识它。这意味着
# 「这个残缺文件是否被接受」取决于 runner 上的 pnpm 版本 —— 典型的版本漂移陷阱。
# 因此这里显式声明单包 workspace,并在 CI 中锁死 pnpm 版本(见 .github/workflows/ci.yml),
# 让「workspace 模式」与「只读 RFC2119 白名单」两条路径都不再受 runner 升级影响。
#
# esbuild 与 vue-demi 的 postinstall 是必需步骤(前者下载平台原生二进制,
# 后者生成 Vue 版本桥接),允许它们执行。
packages:
- '.'
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
onlyBuiltDependencies:
- esbuild
- vue-demi
+43 -5
View File
@@ -1,14 +1,18 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
})
// 防止 401 时并发触发多次刷新
let refreshing = false
client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('accessToken')
router.push('/login')
async (err) => {
const status = err.response?.status
const original = err.config
const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
refreshing = true
try {
const resp: any = await client.post('/auth/operator/refresh', {})
if (resp?.success) {
const newToken = resp.data.accessToken
setAccessToken(newToken)
refreshing = false
;(original as any).__retried = true
const headers = (original.headers || {}) as Record<string, string>
headers.Authorization = `Bearer ${newToken}`
original.headers = headers as any
return client(original)
}
} catch {
// 刷新失败:清除并跳登录
} finally {
refreshing = false
}
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
return Promise.reject(err)
}
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
if (status === 401) {
if (!isAuthEndpoint) {
clearAccessToken()
ElMessage.error('登录已过期,请重新登录')
router.push('/login')
}
} else if (err.response?.data?.message) {
ElMessage.error(err.response.data.message)
} else {
+14
View File
@@ -0,0 +1,14 @@
// 品牌静态资源的运行时前缀。
//
// admin-web/public/ 下的文件不受 vite `base` 影响(无论 base 是什么,都会被原样复制到
// 构建产物的根目录),因此 .vue 模板里若写死 `src="/brand/xxx.png"`,在产物部署于
// /admin/ 子路径时,浏览器会去站点根路径请求,最终 404(表现为 logo / 登录插画裂图)。
//
// 结论:public/ 下的运行时引用必须显式带上 import.meta.env.BASE_URL。
// 这里集中导出,避免每处各写一遍拼接而在改 base 时漏改。
const BRAND_BASE = `${import.meta.env.BASE_URL}brand/`
export const BRAND_LOGO = `${BRAND_BASE}logo-mark.png`
export const BRAND_LOGIN_SCENE = `${BRAND_BASE}login-illustration.jpg`
export const BRAND_ACTIVITY_FALLBACK = `${BRAND_BASE}activity-default.jpg`
export const BRAND_BACKDROP = `${BRAND_BASE}admin-danyang-backdrop.jpg`
+117 -94
View File
@@ -2,7 +2,7 @@
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
<div class="scene-brand">
<img src="/brand/logo-mark.png" alt="善缘丹阳" />
<img :src="BRAND_LOGO" alt="善缘丹阳" />
<div class="scene-brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
@@ -27,7 +27,7 @@
<el-container class="admin-shell">
<el-aside :width="isCollapse ? '76px' : '208px'" class="admin-sidebar">
<div class="brand" :class="{ compact: isCollapse }">
<img class="brand-mark" src="/brand/logo-mark.png" alt="" />
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
<div v-if="!isCollapse" class="brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
@@ -129,6 +129,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
@@ -260,18 +261,23 @@ function handleLogout() {
min-height: 100vh;
padding: 18px 34px 14px;
overflow: hidden;
background: #fff7f7 url('/brand/admin-danyang-backdrop.jpg') center / cover fixed no-repeat;
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
/* 先给纯色兜底,避免图未加载完成时闪白 */
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
background-image: url(v-bind('BRAND_BACKDROP'));
}
.scene-header {
display: grid;
max-width: 1840px;
max-width: var(--admin-shell-max-width);
height: 188px;
margin: 0 auto;
grid-template-columns: minmax(640px, 1fr) auto;
grid-template-rows: 112px 76px;
align-items: center;
column-gap: 32px;
column-gap: var(--space-8);
}
.scene-brand,
@@ -290,23 +296,23 @@ function handleLogout() {
.scene-slogan {
display: flex;
padding-right: 24px;
padding-right: var(--space-6);
align-items: flex-end;
flex-direction: column;
color: var(--admin-coral);
font-family: KaiTi, STKaiti, serif;
color: var(--admin-coral-strong);
font-family: var(--font-family-brand);
line-height: 1.05;
transform: rotate(-7deg);
}
.scene-slogan span {
font-size: 17px;
font-size: var(--font-size-lg);
}
.scene-slogan strong {
margin-top: 4px;
font-size: 25px;
font-weight: 500;
margin-top: var(--space-1);
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-medium);
}
.scene-brand-copy {
@@ -314,18 +320,18 @@ function handleLogout() {
margin-left: 10px;
align-items: flex-start;
flex-direction: column;
color: var(--admin-coral);
color: var(--admin-coral-strong);
}
.scene-brand-copy strong {
font-size: 27px;
font-size: var(--font-size-4xl);
line-height: 1.15;
letter-spacing: 2px;
}
.scene-brand-copy span {
margin-top: 5px;
font-size: 12px;
font-size: var(--font-size-xs);
letter-spacing: 4px;
}
@@ -333,18 +339,18 @@ function handleLogout() {
min-width: 370px;
margin-left: 28px;
padding-left: 28px;
border-left: 1px solid rgba(244, 95, 104, 0.22);
border-left: 1px solid var(--admin-coral-border);
}
.scene-title h1 {
font-size: clamp(26px, 2.2vw, 38px);
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
line-height: 1.1;
letter-spacing: 1px;
}
.scene-title p {
margin-top: 9px;
color: #66717a;
color: var(--color-text-secondary);
}
.scene-benefits {
@@ -352,40 +358,41 @@ function handleLogout() {
align-self: start;
justify-content: flex-start;
gap: clamp(38px, 5vw, 92px);
padding: 4px 0 0 50px;
padding: var(--space-1) 0 0 50px;
}
.scene-benefits span {
display: grid;
grid-template-columns: 36px auto;
column-gap: 8px;
color: var(--admin-coral);
column-gap: var(--space-2);
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
color: var(--admin-coral-strong);
}
.scene-benefits .el-icon {
grid-row: 1 / 3;
font-size: 30px;
font-size: var(--font-size-4xl);
}
.scene-benefits b {
font-size: 13px;
font-size: var(--font-size-sm);
}
.scene-benefits small {
margin-top: 2px;
color: #7b7f86;
font-size: 10px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
white-space: nowrap;
}
.admin-shell {
width: min(1840px, 100%);
width: min(var(--admin-shell-max-width), 100%);
height: calc(100vh - 238px);
min-height: 560px;
margin: 0 auto;
overflow: hidden;
background: var(--admin-shell-bg);
border: 1px solid rgba(255, 255, 255, 0.9);
border: 1px solid var(--color-bg-overlay-strong);
border-radius: var(--admin-shell-radius);
box-shadow: var(--admin-shadow);
backdrop-filter: blur(14px);
@@ -396,23 +403,23 @@ function handleLogout() {
flex-direction: column;
overflow: hidden;
color: var(--admin-sidebar-text);
background: linear-gradient(180deg, #202d38 0%, #192733 100%);
border-right: 1px solid rgba(255, 255, 255, 0.06);
transition: width 0.2s ease;
background: var(--admin-sidebar-gradient);
border-right: 1px solid var(--admin-sidebar-hairline-soft);
transition: width var(--motion-normal) var(--ease-standard);
}
.brand {
display: flex;
height: 82px;
padding: 17px 16px;
padding: 17px var(--space-4);
align-items: center;
gap: 11px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-bottom: 1px solid var(--admin-sidebar-hairline);
}
.brand.compact {
justify-content: center;
padding: 16px 8px;
padding: var(--space-4) var(--space-2);
}
.brand-mark {
@@ -430,15 +437,15 @@ function handleLogout() {
}
.brand-copy strong {
color: #fff;
font-size: 17px;
color: var(--color-text-inverse);
font-size: var(--font-size-lg);
letter-spacing: 1px;
}
.brand-copy span {
margin-top: 4px;
color: #b5c0c8;
font-size: 10px;
margin-top: var(--space-1);
color: var(--color-text-on-dark-muted);
font-size: var(--font-size-micro);
letter-spacing: 1.5px;
}
@@ -452,49 +459,54 @@ function handleLogout() {
background: transparent;
}
/* 主导航行项:桌面密度下取 44px(兼顾触屏笔记本,且远高于 WCAG 2.5.8 的 24px 下限) */
.sidebar-menu :deep(.el-menu-item) {
height: 41px;
height: var(--tap-min);
margin: 2px 0;
color: var(--admin-sidebar-text);
border-radius: 9px;
border-radius: var(--border-radius-sm);
transition: background var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard);
}
.sidebar-menu :deep(.el-menu-item:hover) {
color: #fff;
color: var(--color-text-inverse);
background: var(--admin-sidebar-surface);
}
/* 激活态原先压的是明亮珊瑚渐变(#f45f68→#ee727b),白字只有 3.14:1;
改用达标渐变(两端 4.60:1 / 5.33:1)。 */
.sidebar-menu :deep(.el-menu-item.is-active) {
color: #fff;
font-weight: 650;
background: linear-gradient(135deg, #f45f68, #ee727b);
box-shadow: 0 8px 18px rgba(244, 95, 104, 0.26);
color: var(--color-text-inverse);
font-weight: var(--font-weight-semibold);
background: var(--gradient-brand-solid);
box-shadow: var(--shadow-brand);
}
.sidebar-menu :deep(.el-icon) {
font-size: 17px;
font-size: var(--font-size-lg);
}
.sidebar-motto {
display: flex;
margin: 8px 15px 2px;
padding: 14px 12px;
margin: var(--space-2) 15px 2px;
padding: 14px var(--space-3);
align-items: center;
gap: 11px;
color: #ff969c;
font-family: KaiTi, STKaiti, serif;
font-size: 15px;
color: var(--admin-coral-on-dark);
font-family: var(--font-family-brand);
font-size: var(--font-size-md);
line-height: 1.45;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid var(--admin-sidebar-hairline);
}
.sidebar-motto .el-icon {
font-size: 31px;
font-size: var(--font-size-4xl);
}
.collapse-control {
display: flex;
height: 42px;
min-height: var(--tap-min);
margin: 2px 10px 10px;
align-items: center;
justify-content: center;
@@ -503,10 +515,14 @@ function handleLogout() {
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-sm);
transition: color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard);
}
.collapse-control:hover {
color: #fff;
color: var(--color-text-inverse);
background: var(--admin-sidebar-surface);
}
.workspace {
@@ -515,12 +531,12 @@ function handleLogout() {
.admin-topbar {
display: flex;
height: 64px;
padding: 0 24px;
height: var(--header-height);
padding: 0 var(--space-6);
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f2;
background: var(--color-bg-overlay-strong);
border-bottom: 1px solid var(--color-border-light);
}
.page-context,
@@ -532,15 +548,18 @@ function handleLogout() {
.page-context {
min-width: 180px;
gap: 12px;
gap: var(--space-3);
}
.mobile-collapse {
display: none;
color: #56616a;
min-width: var(--tap-icon);
min-height: var(--tap-icon);
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-sm);
}
.header-tools {
@@ -552,57 +571,59 @@ function handleLogout() {
}
.global-search :deep(.el-input__wrapper) {
background: #f6f7f8;
border-radius: 9px;
box-shadow: inset 0 0 0 1px #e8eaed;
background: var(--color-bg-input);
border-radius: var(--border-radius-sm);
box-shadow: inset 0 0 0 1px var(--color-border);
}
.notification-button {
position: relative;
display: grid;
width: 36px;
height: 36px;
color: #4f5b65;
width: var(--tap-icon);
height: var(--tap-icon);
color: var(--color-text-secondary);
cursor: pointer;
background: transparent;
border: 0;
border-radius: 9px;
border-radius: var(--border-radius-sm);
place-items: center;
transition: background var(--motion-fast) var(--ease-standard);
}
.notification-button:hover {
background: #f6f7f8;
background: var(--color-bg-input);
}
/* 角标字号原为 9px(低于可读下限),提到 11px,容器同步放大以容纳 */
.notification-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 15px;
height: 15px;
min-width: var(--space-4);
height: var(--space-4);
padding: 0 3px;
color: #fff;
font-size: 9px;
line-height: 15px;
color: var(--color-text-inverse);
font-size: var(--font-size-micro);
line-height: var(--space-4);
text-align: center;
background: var(--admin-coral);
border-radius: 999px;
background: var(--admin-coral-solid);
border-radius: var(--border-radius-pill);
}
.user-info {
gap: 8px;
gap: var(--space-2);
cursor: pointer;
}
.user-info .el-avatar {
color: #fff;
background: var(--admin-coral);
color: var(--color-text-inverse);
background: var(--admin-coral-solid);
}
.user-info strong {
max-width: 92px;
overflow: hidden;
font-size: 13px;
font-size: var(--font-size-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -610,27 +631,28 @@ function handleLogout() {
.admin-content {
padding: 0;
overflow: auto;
background: var(--admin-page-bg);
background: var(--color-bg-page);
}
.scene-footer {
display: flex;
max-width: 1840px;
height: 32px;
max-width: var(--admin-shell-max-width);
height: var(--space-8);
margin: 0 auto;
align-items: flex-end;
gap: 10px;
color: #8d8587;
font-size: 11px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.scene-footer strong {
color: var(--admin-coral);
font-size: 14px;
color: var(--admin-coral-strong);
font-size: var(--font-size-base);
}
.scene-footer small {
margin-left: auto;
font-size: var(--font-size-micro);
letter-spacing: 4px;
}
@@ -648,15 +670,15 @@ function handleLogout() {
}
.scene-slogan {
padding-right: 12px;
padding-right: var(--space-3);
}
.scene-slogan span {
font-size: 14px;
font-size: var(--font-size-base);
}
.scene-slogan strong {
font-size: 20px;
font-size: var(--font-size-2xl);
}
.scene-brand > img {
@@ -665,7 +687,7 @@ function handleLogout() {
}
.scene-brand-copy strong {
font-size: 22px;
font-size: var(--font-size-2xl);
}
.scene-title {
@@ -674,8 +696,9 @@ function handleLogout() {
padding-left: 18px;
}
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
.scene-title h1 {
font-size: 27px;
font-size: var(--font-size-3xl);
}
.scene-benefits {
@@ -702,7 +725,7 @@ function handleLogout() {
}
.admin-sidebar {
width: 76px !important;
width: var(--sidebar-width-collapsed) !important;
}
.brand-copy,
@@ -736,7 +759,7 @@ function handleLogout() {
}
.admin-topbar {
padding: 0 12px;
padding: 0 var(--space-3);
}
}
</style>
@@ -80,6 +80,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/**
* U32 活动签到工作台(Phase 15)
*
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
}
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
const formatTime = (v: any) => {
if (!v) return '-'
@@ -183,13 +184,13 @@ onMounted(loadActivities)
}
.form-hint {
margin-top: 8px;
color: #909399;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
}
.opt-type {
float: right;
color: #909399;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
}
.result-card {
margin-top: 12px;
@@ -42,6 +42,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
@@ -55,7 +56,7 @@ const mutualList = ref<any[]>([])
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
const rows = res.data || []
tableData.value = searchActivityId.value
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
drawerVisible.value = true
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
mutualList.value = mutualRes.data || []
} catch (e) {
@@ -168,6 +168,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
/**
* 活动管理(V1.1 Phase 14)
*
@@ -259,7 +260,7 @@ function emptyForm() {
}
function authHeader() {
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
return { Authorization: `Bearer ${getAccessToken()}` }
}
async function loadData() {
@@ -434,8 +435,8 @@ onMounted(() => {
}
.form-hint {
margin-top: 4px;
color: #8a90a2;
font-size: 12px;
color: var(--color-text-placeholder);
font-size: var(--font-size-xs);
line-height: 1.5;
}
.partner-row {
@@ -213,8 +213,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAccessToken } from '@/utils/token'
import { ref, computed, onMounted } from 'vue'
import { get } from '@/api/client'
import { token } from '@/theme/tokens'
const loading = ref(false)
const dateRange = ref<[string, string]>([
@@ -227,87 +229,108 @@ const safetyStats = ref<any>({})
const v11 = ref<any>({})
const dailySummary = ref<any[]>([])
const funnelStages = [
{ key: 'registered', label: '累计注册', value: 0, rate: null as string | null, color: '#409EFF' },
{ key: 'verified', label: '已实名', value: 0, rate: null as string | null, color: '#67C23A' },
{ key: 'approved', label: '审核通过', value: 0, rate: null as string | null, color: '#E59A32' },
{ key: 'active', label: '7日活跃', value: 0, rate: null as string | null, color: '#E96B6B' }
]
// 漏斗卡与安全卡一律**派生自上面的 ref**,不再各自持有一份可变副本。
//
// 历史缺陷(2026-09-23 渲染态实测发现):这三组曾是 `const x = [...]` 普通数组,
// loadFunnel() 直接对元素赋值(`x[0].value = ...`),模板读的却是同一个数组 ——
// 非响应式写入不触发重渲染,于是卡片恒显示 0,而同一张卡里的汇总表(走 ref)
// 显示 12846/9217/8462/3812,界面自相矛盾。
// 改为 computed 派生后两者同源,结构上不可能再对不上。
const funnelStages = computed(() => [
{
key: 'registered', label: '累计注册',
value: funnelStats.value.totalRegistered || 0,
rate: null as string | null, color: token('--color-info')
},
{
key: 'verified', label: '已实名',
value: funnelStats.value.verifiedCount || 0,
rate: funnelStats.value.verifyRate || '-', color: token('--color-success')
},
{
key: 'approved', label: '审核通过',
value: funnelStats.value.profileApproved || 0,
rate: funnelStats.value.approvalRate || '-', color: token('--color-warning')
},
{
key: 'active', label: '7日活跃',
value: funnelStats.value.activeUsers || 0,
rate: funnelStats.value.activeRate || '-', color: token('--color-primary')
}
])
const matchStages = [
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
]
const matchStages = computed(() => [
{
key: 'recommended', label: '推荐',
value: matchFunnelStats.value.totalRecommended || 0,
rate: '-', color: token('--color-info')
},
{
key: 'liked', label: '喜欢',
value: matchFunnelStats.value.totalLiked || 0,
rate: matchFunnelStats.value.likeRate || '-', color: token('--color-success')
},
{
key: 'matches', label: 'Match',
value: matchFunnelStats.value.totalMatches || 0,
rate: matchFunnelStats.value.matchRate || '-', color: token('--color-warning')
},
{
key: 'chats', label: '聊天',
value: matchFunnelStats.value.chatsStarted || 0,
rate: matchFunnelStats.value.chatRate || '-', color: token('--color-neutral')
},
{
key: 'knowing', label: '了解中',
value: matchFunnelStats.value.gettingToKnow || 0,
rate: '-', color: token('--color-primary')
},
{
key: 'success', label: '成功牵手',
value: matchFunnelStats.value.successPairs || 0,
rate: matchFunnelStats.value.successRate || '-', color: token('--color-danger')
}
])
const safetyCards = [
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
]
const safetyCards = computed(() => [
{ key: 'pending', label: '待处理举报', value: safetyStats.value.pendingReports || 0, color: token('--color-primary') },
{ key: 'resolved', label: '已处理举报', value: safetyStats.value.resolvedReports || 0, color: token('--color-success') },
{ key: 'highRisk', label: '高风险用户', value: safetyStats.value.highRiskUsers || 0, color: token('--color-warning') },
{ key: 'banned', label: '封禁用户', value: safetyStats.value.bannedUsers || 0, color: token('--color-danger') }
])
async function loadFunnel() {
try {
const token = localStorage.getItem('accessToken')
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
const authToken = getAccessToken()
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
}, { Authorization: `Bearer ${authToken}` }),
get('/operator/analytics/match-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }),
}, { Authorization: `Bearer ${authToken}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${authToken}` }),
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
// 后端已实现却无人调用 —— 本行起补齐。
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` })
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${authToken}` })
])
const userData = userRes.data || {}
const matchData = matchRes.data || {}
const safetyData = safetyRes.data || {}
const v11Data = v11Res.data || {}
funnelStats.value = userData
matchFunnelStats.value = matchData
safetyStats.value = safetyData
v11.value = v11Data
// 更新漏斗显示
funnelStages[0].value = userData.totalRegistered || 0
funnelStages[1].value = userData.verifiedCount || 0
funnelStages[1].rate = userData.verifyRate || '-'
funnelStages[2].value = userData.profileApproved || 0
funnelStages[2].rate = userData.approvalRate || '-'
funnelStages[3].value = userData.activeUsers || 0
funnelStages[3].rate = userData.activeRate || '-'
matchStages[0].value = matchData.totalRecommended || 0
matchStages[1].value = matchData.totalLiked || 0
matchStages[1].rate = matchData.likeRate || '-'
matchStages[2].value = matchData.totalMatches || 0
matchStages[2].rate = matchData.matchRate || '-'
matchStages[3].value = matchData.chatsStarted || 0
matchStages[3].rate = matchData.chatRate || '-'
matchStages[4].value = matchData.gettingToKnow || 0
matchStages[5].value = matchData.successPairs || 0
matchStages[5].rate = matchData.successRate || '-'
safetyCards[0].value = safetyData.pendingReports || 0
safetyCards[1].value = safetyData.resolvedReports || 0
safetyCards[2].value = safetyData.highRiskUsers || 0
safetyCards[3].value = safetyData.bannedUsers || 0
// 只落 ref,卡片由上面三个 computed 自动派生 —— 不再手工同步第二份副本。
funnelStats.value = userRes.data || {}
matchFunnelStats.value = matchRes.data || {}
safetyStats.value = safetyRes.data || {}
v11.value = v11Res.data || {}
}
} catch (e) { console.error(e) }
}
async function loadDailySummary() {
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
const authToken = getAccessToken()
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
dailySummary.value = res.data || []
} catch (e) { console.error(e) }
}
@@ -329,21 +352,21 @@ onMounted(loadData)
padding: 20px;
text-align: center;
}
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
.funnel-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-bottom: 8px; }
.funnel-value { font-size: var(--font-size-4xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.funnel-rate { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 4px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
.mini-stat {
border: 1px solid #ebeef5;
border: 1px solid var(--color-border-light);
border-radius: 8px;
padding: 14px;
text-align: center;
background: #fafafa;
background: var(--color-bg-secondary);
}
.mini-value { font-size: 22px; font-weight: bold; color: #303133; }
.mini-label { font-size: 12px; color: #909399; margin-top: 6px; }
.metric-note { font-size: 12px; color: #909399; margin-top: 12px; line-height: 1.6; }
.unavailable { color: #c0c4cc; font-style: italic; }
.mini-value { font-size: var(--font-size-3xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.mini-label { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 6px; }
.metric-note { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 12px; line-height: 1.6; }
.unavailable { color: var(--color-text-disabled); font-style: italic; }
</style>
@@ -60,7 +60,7 @@
</el-descriptions>
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">审核决策</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="decision" style="width:100%">
@@ -84,8 +84,10 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
const loading = ref(false)
const processing = ref(false)
@@ -108,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
loadStats()
@@ -118,15 +120,15 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: '#E96B6B' },
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: '#E59A32' },
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: '#67C23A' },
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: '#909399' },
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: '#F56C6C' }
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: tokenColor('--color-warning') },
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: tokenColor('--color-success') },
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: tokenColor('--color-neutral') },
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: tokenColor('--color-danger') }
]
} catch {}
}
@@ -137,7 +139,7 @@ async function openDetail(id: string) {
decision.value = 'approve'
decisionNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data
} catch (e) { console.error(e) }
@@ -147,7 +149,7 @@ async function handleProcess() {
if (!decision.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post(`/operator/appeals/${currentId.value}/process`,
{ decision: decision.value, decisionNote: decisionNote.value },
{ Authorization: `Bearer ${token}` })
@@ -168,8 +170,8 @@ onMounted(loadData)
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); color: var(--admin-coral-strong); }
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.text-muted { color: #BBB; font-size: 13px; }
.text-muted { color: var(--color-text-disabled); font-size: var(--font-size-sm); }
</style>
+2 -1
View File
@@ -38,6 +38,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get } from '@/api/client'
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/audit/logs', {
resourceType: resourceFilter.value || undefined,
page: page.value,
@@ -302,21 +302,21 @@ onMounted(loadBanners)
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.preview-stage { padding: 20px; background: #f5f6f8; border-radius: 12px; }
.preview-stage { padding: 20px; background: var(--color-bg-subtle); border-radius: 12px; }
.preview-card {
padding: 20px 18px;
border-radius: 16px;
background: linear-gradient(135deg, #ffe9df, #fff7f2);
background: var(--gradient-brand-soft); /* 还原消费端 Banner 预览底色 */
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
}
.preview-tag {
display: inline-block; padding: 2px 10px; border-radius: 999px;
background: rgba(255, 255, 255, 0.85); color: #c8553d; font-size: 12px;
background: var(--color-bg-overlay); color: var(--admin-coral-strong); font-size: var(--font-size-xs);
}
.preview-title { margin-top: 10px; font-size: 17px; font-weight: 700; color: #2c2c34; }
.preview-subtitle { margin-top: 6px; font-size: 12px; line-height: 1.6; color: #6b6b78; }
.preview-title { margin-top: 10px; font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
.preview-subtitle { margin-top: 6px; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-text-tertiary); }
.preview-button {
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
background: linear-gradient(135deg, #ff7d6e, #ff5c82); color: #fff; font-size: 12px; font-weight: 600;
background: var(--gradient-brand-vivid); color: var(--color-text-inverse); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); min-height: var(--tap-control); /* 还原消费端 Banner 按钮外观预览 */
}
</style>
@@ -253,5 +253,5 @@ onMounted(() => { loadCategories(); loadArticles() })
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.field-hint { width: 100%; font-size: var(--font-size-xs); color: var(--el-text-color-secondary); line-height: 1.5; }
</style>
@@ -62,6 +62,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
@@ -79,7 +80,7 @@ const orgMap = ref<Record<string, string>>({})
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
tableData.value = res.data?.list || []
total.value = res.data?.total || 0
@@ -89,7 +90,7 @@ async function loadData() {
async function loadOrgs() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
orgList.value = res.data || []
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
@@ -105,7 +106,7 @@ async function save() {
if (!form.value.username || !form.value.password) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
formVisible.value = false
loadData()
@@ -115,7 +116,7 @@ async function save() {
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
loadData()
@@ -126,7 +127,7 @@ async function resetPwd(id: string) {
const pwd = prompt('请输入新密码(至少8位)')
if (!pwd || pwd.length < 8) return
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
} catch (e) { console.error(e) }
}
@@ -60,6 +60,7 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
@@ -78,7 +79,7 @@ const ORG_TYPE_TEXT: Record<string, string> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
} catch (e) { console.error(e) }
@@ -101,7 +102,7 @@ async function save() {
if (!form.value.name) return
saving.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
if (editId.value) {
await put(`/operator/organizations/${editId.value}`, form.value, {
Authorization: `Bearer ${token}`
@@ -119,7 +120,7 @@ async function save() {
async function toggleStatus(row: any) {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const action = row.status === 'normal' ? 'disable' : 'enable'
await put(`/operator/organizations/${row.id}/${action}`, {}, {
Authorization: `Bearer ${token}`
+1 -1
View File
@@ -284,5 +284,5 @@ onMounted(loadPage)
</script>
<style scoped>
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:12px;font-weight:650;letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:12px}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:16px}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:var(--font-size-xs);font-weight:var(--font-weight-semibold);letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:var(--font-size-lg)}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
</style>
+14 -12
View File
@@ -60,7 +60,7 @@
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">被举报人风险画像</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">被举报人风险画像</h4>
<el-descriptions :column="2" border v-if="detail.riskProfile">
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
@@ -72,7 +72,7 @@
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:20px 0 12px;color:#E96B6B">处置操作</h4>
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">处置操作</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
<el-select v-model="processResult" style="width:100%">
@@ -98,8 +98,10 @@
</template>
<script setup lang="ts">
import { getAccessToken } from '@/utils/token'
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { token as tokenColor } from '@/theme/tokens'
const loading = ref(false)
const processing = ref(false)
@@ -123,7 +125,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
async function loadData() {
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
tableData.value = res.data || []
loadStats()
@@ -133,14 +135,14 @@ async function loadData() {
async function loadStats() {
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
const data = res.data || {}
stats.value = [
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: '#E96B6B' },
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: '#E59A32' },
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: '#67C23A' },
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: '#909399' }
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: tokenColor('--color-warning') },
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: tokenColor('--color-success') },
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: tokenColor('--color-neutral') }
]
} catch {}
}
@@ -151,7 +153,7 @@ async function openDetail(reportId: string) {
processResult.value = 'no_action'
processNote.value = ''
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
detail.value = res.data
} catch (e) { console.error(e) }
@@ -161,7 +163,7 @@ async function handleProcess() {
if (!processResult.value) return
processing.value = true
try {
const token = localStorage.getItem('accessToken')
const token = getAccessToken()
await post(`/operator/risk/reports/${currentReportId.value}/process`,
{ processResult: processResult.value, processNote: processNote.value },
{ Authorization: `Bearer ${token}` })
@@ -188,7 +190,7 @@ onMounted(loadData)
<style scoped>
.page-container { padding: 24px; }
.stat-card { text-align: center; }
.stat-value { font-size: 36px; font-weight: bold; }
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
.stat-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-top: 8px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
</style>
@@ -348,6 +348,6 @@ onMounted(loadStations)
.form-hint {
margin-left: 10px;
color: var(--el-text-color-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
}
</style>
@@ -20,7 +20,7 @@
<div class="identity-summary">
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="#EE666B" />
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
</div>
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="基础信息" name="basic">
@@ -129,5 +129,5 @@ function formatTime(time?: string) { return time ? new Date(time).toLocaleString
onMounted(loadDetail)
</script>
<style scoped>
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:#fff;background:var(--admin-primary)}.identity-summary h2{font-size:20px}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:12px}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:26px}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid #F3CCCC;border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:var(--color-text-inverse);background:var(--admin-coral-solid)}.identity-summary h2{font-size:var(--font-size-2xl)}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:var(--font-size-4xl)}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid var(--color-border-danger);border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger-strong)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
</style>
@@ -206,8 +206,8 @@ onMounted(loadData)
.page-kicker {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 1px;
}
+7 -2
View File
@@ -44,12 +44,17 @@ const routes: RouteRecordRaw[] = [
},
]
const router = createRouter({ history: createWebHistory(), routes })
// history 的 base 必须与 vite 的 base 保持一致(生产构建为 /admin/)。
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const authStore = useAuthStore()
const requiredPerms = (to.meta.permissions as string[]) || []
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
await authStore.initializeSession()
// 1. 登录检查
if (to.path !== '/login' && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
+52 -21
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { post } from '@/api/client'
import { setAccessToken, clearAccessToken } from '@/utils/token'
interface OperatorInfo {
operatorId: string
@@ -23,38 +24,68 @@ export const useAuthStore = defineStore('auth', () => {
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
: null
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
const operator = ref<OperatorInfo | null>(
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
)
const isLoggedIn = computed(() => !!token.value)
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
const operator = ref<OperatorInfo | null>(previewOperator)
let sessionInitialized = false
const isLoggedIn = computed(() => !!token.value && !!operator.value)
const permissions = computed(() => operator.value?.permissions || [])
function applyAuthData(data: any) {
token.value = data.accessToken || ''
operator.value = {
operatorId: data.operatorId,
name: data.name,
username: data.username,
permissions: data.permissions || [],
organizationId: data.organizationId ?? null,
}
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
setAccessToken(token.value)
}
async function login(username: string, password: string) {
const res: any = await post('/auth/operator/login', { username, password })
if (res.success) {
token.value = res.data.accessToken
operator.value = {
operatorId: res.data.operatorId,
name: res.data.name,
username: res.data.username,
permissions: res.data.permissions || [],
organizationId: res.data.organizationId ?? null,
}
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value))
applyAuthData(res.data)
return res.data
}
throw new Error(res.message || '登录失败')
}
function logout() {
token.value = ''
operator.value = null
localStorage.removeItem('accessToken')
localStorage.removeItem('operator')
/**
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
*/
async function initializeSession(): Promise<boolean> {
if (sessionInitialized) return isLoggedIn.value
sessionInitialized = true
if (token.value) return true
try {
const res: any = await post('/auth/operator/refresh', {})
if (res.success) {
applyAuthData(res.data)
return true
}
} catch {
// 未登录或 refresh 失效,交由路由守卫跳转登录页
}
return false
}
return { token, operator, isLoggedIn, permissions, login, logout }
function logout() {
// 尽力通知后端注销并清除 Cookie(best-effort)
try {
post('/auth/operator/logout', {}).catch(() => {})
} catch {
// ignore
}
token.value = ''
operator.value = null
clearAccessToken()
}
return { token, operator, isLoggedIn, permissions, login, logout, initializeSession }
})
+378 -59
View File
@@ -1,93 +1,412 @@
/* ============================================================================
* 管理端设计系统 · v2
*
* 本文件是管理端唯一的设计 token 事实源。24 个 .vue 的 <style> 只允许通过
* var(--token) 取色 / 取字号 / 取间距,不应出现字面色值(由门控脚本
* scripts/lint-admin-tokens.mjs 拦截)。
*
* 立此版本的原因(2026-09-22 审计实测,不是主观判断):
* · 97 处硬编码颜色 / 67 种色值:6 种近似粉底(#FFF0F1 #FFF3F2 #FFF6F6
* #FFF7F7 #FFF8F7 #FFFAFA)、6 种近似浅灰边框、大量灰阶碎片。
* · 88 处硬编码字号 / 23 种档位,其中 20 处 ≤11px(9px 见于 .metric-copy em、
* .activity-status、.todo-copy small),低于可读下限。
* · --color-text-tertiary(#888888) 白底 3.54:1、--color-warning(#E59A32) 2.33:1、
* --admin-coral(#F45F68) 3.14:1 —— 在各自用法下均不达 WCAG AA 的 4.5:1。
* · Element Plus 的 --el-color-*-light-N / -dark-2 / -rgb 是**编译期静态生成**的,
* 项目只覆盖了 --el-color-primary,于是开关、滑块、焦点环、分页、标签等
* 组件仍是默认蓝 —— 品牌色与 EP 蓝混用。
*
* 三条硬规则:
* 1) 品牌色三级分化:基础色只做色块/渐变/图形(不承载文字);
* -strong 做白底文字;-solid 做实心底压白字;两者均实算 ≥4.5:1。
* 2) 字重只用 400 / 500 / 600 / 700 四档(跨平台缺字重会回退,导致渲染不一致)。
* 3) 字号有下限:正文最小 12px,角标/计数不得低于 11px。
*
* 所有对比度数值均由脚本按 WCAG 相对亮度公式实算(非估算),标注在各 token 后。
* ========================================================================== */
:root {
--admin-coral: #f45f68;
--admin-coral-hover: #df505a;
--admin-coral-soft: #fff0f1;
--admin-shell-bg: rgba(255, 255, 255, 0.96);
--admin-sidebar-bg: #202d38;
--admin-sidebar-surface: #2b3a46;
--admin-sidebar-text: #D9E1E6;
--admin-sidebar-muted: #8FA1AC;
--admin-page-bg: #f7f9fb;
/* ---------------- 品牌色 ---------------- */
/* 基础色:仅用于色块 / 渐变 / 图标 / 边框氛围。白底作文字只有 3.14:1,禁止承载文字。 */
--admin-coral: #F45F68;
--admin-coral-hover: #E5535D;
--admin-coral-strong: #C0414A; /* 白底文字 5.13:1 */
--admin-coral-solid: #C84A53; /* 实心底压白字 4.60:1 */
--admin-coral-solid-hover: #B8434A; /* hover 反而更深更清晰 5.33:1 */
--admin-coral-on-dark: #FF969C; /* 侧栏深底文字 6.76:1 */
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
--admin-coral-border: rgba(244, 95, 104, 0.22);
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
/* 水平进度条填充(对轨道 --color-primary-light):原 #F56B75→#EF5A65 仅 2.53:1,
条与轨道难以区分;改达标渐变后 4.01:1。 */
--gradient-brand-bar: linear-gradient(90deg, #C84A53 0%, #B8434A 100%);
/* 兼容既有引用名(全站仍在用) */
--admin-primary: var(--admin-coral);
--admin-primary-hover: var(--admin-coral-hover);
--admin-card-radius: 14px;
--admin-shell-radius: 22px;
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
--admin-header-bg: #FFFFFF;
--admin-soft-pink: var(--admin-coral-soft);
--admin-soft-blue: #EEF5FF;
--admin-soft-green: #EDF8F1;
--admin-soft-purple: #F4EFFF;
--color-primary: var(--admin-primary);
--color-primary-hover: var(--admin-primary-hover);
--color-primary-light: #FFF3F2;
--color-primary-lighter: #FFF8F7;
--color-success: #2E9B68;
--color-warning: #E59A32;
--color-danger: #D94A4A;
--color-info: #4A7CD4;
--color-text-primary: #222222;
--color-text-secondary: #555555;
--color-text-tertiary: #888888;
--admin-primary-strong: var(--admin-coral-strong);
--admin-primary-solid: var(--admin-coral-solid);
--color-primary: var(--admin-coral);
--color-primary-hover: var(--admin-coral-hover);
--color-primary-strong: var(--admin-coral-strong);
--color-primary-solid: var(--admin-coral-solid);
--color-primary-light: var(--admin-coral-soft);
--color-primary-lighter: var(--admin-coral-lighter);
/* ---------------- 语义色五组 ----------------
基色做图形/色块;-strong 做文字(实算 ≥4.5:1);-soft 为配套浅底。 */
--color-success: #2E9B68; /* 白底 3.50:1 —— 仅图形 */
--color-success-strong: #1F7A4F; /* 白底 5.31:1 */
--color-success-soft: #EDF8F1;
--color-warning: #E59A32; /* 白底 2.33:1 —— 仅图形 */
--color-warning-strong: #8F5D0F; /* 白底 5.61:1 */
--color-warning-soft: #FFF6E6;
--color-danger: #D94A4A; /* 白底 4.18:1 —— 图形 / ≥24px 大字 */
--color-danger-strong: #B8353A; /* 白底 5.82:1 */
--color-danger-soft: #FFF6F6;
--color-info: #4A7CD4; /* 白底 4.10:1 —— 图形 */
--color-info-strong: #3A63B0; /* 白底 5.85:1 */
--color-info-soft: #EEF5FF;
--color-purple: #8B63BC;
--color-purple-strong: #6B47A0; /* 白底 6.92:1 */
--color-purple-soft: #F4EFFF;
--color-neutral: #8A9099;
--color-neutral-strong: #656C79; /* 白底 5.28:1 */
--color-neutral-soft: #F4F5F7;
/* ---------------- 文字 ---------------- */
--color-text-primary: #1F2328; /* 白底 15.80:1 */
--color-text-secondary: #555555; /* 白底 7.46:1 */
--color-text-tertiary: #676E7B; /* 白底 5.13:1;浅底 #F5F6F8 上 4.74:1(原 #888 仅 3.54:1) */
--color-text-placeholder: #8A9099; /* 3.25:1:占位符按惯例弱于正文一档(EP 默认 2.5:1) */
--color-text-disabled: #B9BEC7;
--color-text-inverse: #FFFFFF;
--color-text-on-dark: #D9E1E6; /* 侧栏深底 10.62:1 */
--color-text-on-dark-muted: #8FA1AC; /* 侧栏深底 5.26:1 */
--color-icon-muted: #D7DCE0; /* 空态大图标等纯装饰 */
/* ---------------- 背景 ---------------- */
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #FFF8F7;
--color-bg-page: #FAFAF8;
--color-border: #E8E5E2;
--color-border-light: #F0EDE9;
--color-bg-page: #F7F9FB;
--color-bg-secondary: #FAFAFB;
--color-bg-subtle: #F5F6F8;
--color-bg-input: #F6F7F8;
--color-bg-overlay: rgba(255, 255, 255, 0.86);
--color-bg-overlay-strong: rgba(255, 255, 255, 0.96);
/* ---------------- 边框 ---------------- */
--color-border: #E4E7EB;
--color-border-light: #EDF0F2;
--color-border-hairline: #F2F3F4;
--color-border-strong: #D7DCE0;
--color-border-danger: #F3CCCC;
/* ---------------- 侧栏(深底) ---------------- */
--admin-sidebar-bg: #202D38;
--admin-sidebar-bg-deep: #192733;
--admin-sidebar-gradient: linear-gradient(180deg, #202D38 0%, #192733 100%);
--admin-sidebar-surface: #2B3A46;
--admin-sidebar-text: #D9E1E6;
--admin-sidebar-muted: #8FA1AC;
--admin-sidebar-hairline: rgba(255, 255, 255, 0.08);
--admin-sidebar-hairline-soft: rgba(255, 255, 255, 0.06);
/* ---------------- 壳与栅格 ---------------- */
--admin-shell-bg: rgba(255, 255, 255, 0.96);
--admin-header-bg: #FFFFFF;
--admin-shell-radius: 22px;
--admin-card-radius: 14px;
--admin-shell-max-width: 1840px;
--admin-page-max-width: 1440px;
--admin-content-max-width: 1500px;
--sidebar-width: 220px;
--sidebar-width-collapsed: 76px;
--header-height: 64px;
/* ---------------- 圆角 ---------------- */
--border-radius-xs: 6px;
--border-radius-sm: 8px;
--border-radius-md: var(--admin-card-radius);
--border-radius-md: 14px;
--border-radius-lg: 16px;
--border-radius-xl: 18px;
--border-radius-pill: 999px;
/* ---------------- 阴影 ---------------- */
--shadow-panel: 0 8px 20px rgba(48, 58, 67, 0.035);
--shadow-card: 0 8px 24px rgba(75, 55, 46, 0.06);
--el-color-primary: var(--color-primary);
--shadow-raise: 0 10px 24px rgba(68, 48, 55, 0.1);
--shadow-brand: 0 8px 18px rgba(200, 74, 83, 0.26);
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
/* ---------------- 排版刻度 ----------------
正文 14px 起;角标/计数下限 11px;元信息下限 12px。 */
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-family-brand: KaiTi, STKaiti, serif; /* 品牌书法体:仅装饰性标语 */
--font-size-micro: 11px;
--font-size-xs: 12px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-md: 15px;
--font-size-lg: 16px;
--font-size-xl: 18px;
--font-size-2xl: 20px;
--font-size-3xl: 24px;
--font-size-4xl: 28px;
--font-size-5xl: 32px;
--font-size-display: 38px;
--font-line-tight: 1.2;
--font-line-snug: 1.35;
--font-line-base: 1.6;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* ---------------- 间距 ---------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
/* ---------------- 动效 ---------------- */
--motion-fast: 140ms;
--motion-normal: 200ms;
--motion-slow: 320ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--press-scale: 0.985;
--press-opacity: 0.88;
/* ---------------- 交互热区 ----------------
桌面端按下限为 WCAG 2.5.8(AA) 的 24×24px;本项目常规控件取 32px(舒适),
主导航行取 44px(兼顾触屏笔记本)。不套用移动端 44px 全量规则,
否则会把管理端的单屏信息密度牺牲掉。 */
--tap-min: 44px;
--tap-control: 32px;
--tap-icon: 36px;
/* ---------------- tone 色调组(工作台指标卡 / 待办图标) ----------------
每组三档:--tone-x 用于图标与图形;--tone-x-ink 用于文字(压 --tone-x-soft
实算均 ≥4.6:1);--tone-x-soft 为浅底。 */
--tone-rose: #F45F68; --tone-rose-ink: var(--admin-coral-strong); --tone-rose-soft: #FFF0F1;
--tone-amber: #E59A32; --tone-amber-ink: var(--color-warning-strong); --tone-amber-soft: #FFF6E6;
--tone-blue: #4A7CD4; --tone-blue-ink: var(--color-info-strong); --tone-blue-soft: #EEF5FF;
--tone-green: #2E9B68; --tone-green-ink: var(--color-success-strong); --tone-green-soft: #EDF8F1;
--tone-purple: #8B63BC; --tone-purple-ink: var(--color-purple-strong); --tone-purple-soft: #F4EFFF;
--tone-slate: #8A9099; --tone-slate-ink: var(--color-neutral-strong); --tone-slate-soft: #F4F5F7;
--tone-red: #D94A4A; --tone-red-ink: var(--color-danger-strong); --tone-red-soft: #FFF6F6;
/* 兼容既有引用名 */
--admin-soft-pink: var(--admin-coral-soft);
--admin-soft-blue: var(--color-info-soft);
--admin-soft-green: var(--color-success-soft);
--admin-soft-purple: var(--color-purple-soft);
/* ==========================================================================
Element Plus 联动
EP 的 --el-color-{t}-light-{i} / -dark-2 / -rgb 由 SCSS 在**编译期**按默认色
静态生成,运行时改 --el-color-primary 不会带动它们(开关、滑块、焦点环、
分页、标签因此仍是默认蓝)。这里按 EP 的派生公式补全:
light-{i} = mix(#FFFFFF, base, i*10%) i ∈ 1..9
dark-2 = mix(#000000, base, 20%)
基色取 -strong / -solid 档 —— 既能当文字色也能做实心底,使 EP 全组件与品牌一致。
info 保持中性灰:EP 的 info 语义是「中性/未知」,不应变成品牌红。
========================================================================== */
--el-color-primary: #C84A53;
--el-color-primary-rgb: 200, 74, 83;
--el-color-primary-light-3: #d98087;
--el-color-primary-light-5: #e4a5a9;
--el-color-primary-light-7: #efc9cb;
--el-color-primary-light-8: #f4dbdd;
--el-color-primary-light-9: #faedee;
--el-color-primary-dark-2: #a03b42;
--el-color-success: #1F7A4F;
--el-color-success-rgb: 31, 122, 79;
--el-color-success-light-3: #62a284;
--el-color-success-light-5: #8fbda7;
--el-color-success-light-7: #bcd7ca;
--el-color-success-light-8: #d2e4dc;
--el-color-success-light-9: #e9f2ed;
--el-color-success-dark-2: #19623f;
--el-color-warning: #8F5D0F;
--el-color-warning-rgb: 143, 93, 15;
--el-color-warning-light-3: #b18e57;
--el-color-warning-light-5: #c7ae87;
--el-color-warning-light-7: #ddceb7;
--el-color-warning-light-8: #e9dfcf;
--el-color-warning-light-9: #f4efe7;
--el-color-warning-dark-2: #724a0c;
--el-color-danger: #B8353A;
--el-color-danger-rgb: 184, 53, 58;
--el-color-danger-light-3: #cd7275;
--el-color-danger-light-5: #dc9a9d;
--el-color-danger-light-7: #eac2c4;
--el-color-danger-light-8: #f1d7d8;
--el-color-danger-light-9: #f8ebeb;
--el-color-danger-dark-2: #932a2e;
--el-color-info: #656C79;
--el-color-info-rgb: 101, 108, 121;
--el-color-info-light-3: #9398a1;
--el-color-info-light-5: #b2b6bc;
--el-color-info-light-7: #d1d3d7;
--el-color-info-light-8: #e0e2e4;
--el-color-info-light-9: #f0f0f2;
--el-color-info-dark-2: #515661;
/* EP 中性色(文字 / 边框 / 填充)接到同一套 token,避免表格、输入框另成一套灰 */
--el-text-color-primary: var(--color-text-primary);
--el-text-color-regular: var(--color-text-secondary);
--el-text-color-secondary: var(--color-text-tertiary);
--el-text-color-placeholder: var(--color-text-placeholder);
--el-text-color-disabled: var(--color-text-disabled);
--el-border-color: var(--color-border);
--el-border-color-light: var(--color-border-light);
--el-border-color-lighter: var(--color-border-light);
--el-border-color-extra-light: var(--color-border-hairline);
--el-fill-color: var(--color-bg-subtle);
--el-fill-color-light: var(--color-bg-secondary);
--el-fill-color-lighter: var(--color-bg-secondary);
--el-bg-color: var(--color-bg-primary);
--el-bg-color-page: var(--color-bg-page);
--el-border-radius-base: var(--border-radius-sm);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #app {
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
font-family: var(--font-family-base);
font-size: var(--font-size-base);
color: var(--color-text-primary);
background-color: var(--color-bg-page);
}
/* 管理端是桌面密度布局,1100px 以下由 MainLayout 的 .admin-scene 承担横向滚动。 */
body { min-width: 1100px; }
/* <small> 的浏览器默认样式是 font-size: smaller(0.833em),会按父级字号再缩一档:
父级 12px 时实际渲染 10px、11px 时渲染 9.17px —— 静默跌破 11px 下限,
且源码里看不到任何非法字号,静态检查抓不到(2026-09-22 由渲染态实测发现)。
这里统一改为继承父级字号,需要更小的场景必须显式声明 token 字号(守卫会校验)。 */
small { font-size: inherit; }
button:focus-visible,
a:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(238, 102, 107, 0.32);
outline: 3px solid var(--admin-coral-border);
outline-offset: 2px;
}
/* ---------------- Element Plus 组件级覆盖 ---------------- */
/* EP 默认 hover 是把主色「加白」(白字对比度随之掉到 2.2:1)。这里改成更深一档,
既保住白字可读性(5.33:1),hover 反馈也更明确。 */
.el-button--primary {
--el-button-bg-color: var(--color-primary);
--el-button-border-color: var(--color-primary);
--el-button-hover-bg-color: var(--color-primary-hover);
--el-button-hover-border-color: var(--color-primary-hover);
--el-button-bg-color: var(--admin-coral-solid);
--el-button-border-color: var(--admin-coral-solid);
--el-button-hover-bg-color: var(--admin-coral-solid-hover);
--el-button-hover-border-color: var(--admin-coral-solid-hover);
--el-button-active-bg-color: var(--el-color-primary-dark-2);
--el-button-active-border-color: var(--el-color-primary-dark-2);
}
.el-radio-group .el-radio-button__inner:checked {
background-color: var(--color-primary);
border-color: var(--color-primary);
background-color: var(--admin-coral-solid);
border-color: var(--admin-coral-solid);
}
.el-card { --el-card-border-radius: var(--border-radius-md); --el-card-border-color: var(--color-border-light); }
.el-table { --el-table-header-bg-color: #FBFAF8; --el-table-border-color: var(--color-border-light); }
.el-tag { border-radius: 999px; }
.page-container { padding: 24px; }
.page-shell { max-width: 1440px; margin: 0 auto; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.page-heading h1 { font-size: 24px; line-height: 1.35; font-weight: 650; }
.el-card {
--el-card-border-radius: var(--border-radius-md);
--el-card-border-color: var(--color-border-light);
}
.el-table {
--el-table-header-bg-color: var(--color-bg-secondary);
--el-table-border-color: var(--color-border-light);
}
.el-tag { border-radius: var(--border-radius-pill); }
/* ---------------- 通用版式 ---------------- */
.page-container { padding: var(--space-6); }
.page-shell { max-width: var(--admin-page-max-width); margin: 0 auto; }
.page-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.page-heading h1 {
font-size: var(--font-size-3xl);
line-height: var(--font-line-snug);
font-weight: var(--font-weight-semibold);
}
.page-heading p { margin-top: 6px; color: var(--color-text-tertiary); }
.surface-card { background: var(--color-bg-primary); border: 1px solid var(--color-border-light); border-radius: var(--border-radius-md); box-shadow: var(--shadow-card); }
.surface-card {
background: var(--color-bg-primary);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-card);
}
/* 语义文字色工具类(比在页面里各写一遍 color: #xxx 更不容易漂移) */
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-tertiary { color: var(--color-text-tertiary); }
.mt-sm { margin-top: 8px; }
.mt-md { margin-top: 16px; }
.mt-lg { margin-top: 24px; }
.mb-sm { margin-bottom: 8px; }
.mb-md { margin-bottom: 16px; }
.mb-lg { margin-bottom: 24px; }
.text-muted { color: var(--color-text-tertiary); }
.text-brand { color: var(--admin-coral-strong); }
.text-success { color: var(--color-success-strong); }
.text-warning { color: var(--color-warning-strong); }
.text-danger { color: var(--color-danger-strong); }
/* 语义底色工具类 */
.bg-card { background: var(--color-bg-primary); }
.bg-subtle { background: var(--color-bg-subtle); }
.bg-brand-soft { background: var(--admin-coral-soft); }
/* 间距工具类 */
.mt-sm { margin-top: var(--space-2); }
.mt-md { margin-top: var(--space-4); }
.mt-lg { margin-top: var(--space-6); }
.mb-sm { margin-bottom: var(--space-2); }
.mb-md { margin-bottom: var(--space-4); }
.mb-lg { margin-bottom: var(--space-6); }
.flex-center { display: flex; align-items: center; justify-content: center; }
@media (max-width: 1200px) { body { min-width: 0; } }
@media (max-width: 900px) { .page-container { padding: 16px; } .page-heading { align-items: flex-start; flex-direction: column; } }
/* ---------------- 交互反馈 ---------------- */
/* 桌面端控件热区下限:32px(WCAG 2.5.8 AA 为 24px,留出舒适余量) */
.tap-control { min-height: var(--tap-control); min-width: var(--tap-control); }
/* 按压反馈:统一走 token,避免各页面各写一套 scale/opacity */
.pressable { transition: transform var(--motion-fast) var(--ease-standard), opacity var(--motion-fast) var(--ease-standard); }
.pressable:active { transform: scale(var(--press-scale)); opacity: var(--press-opacity); }
@media (max-width: 1200px) {
body { min-width: 0; }
}
@media (max-width: 900px) {
.page-container { padding: var(--space-4); }
.page-heading { align-items: flex-start; flex-direction: column; }
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 管理端调色板常量(TS 侧)。
*
* 为什么需要这个文件:
* 部分模板用 `:style="{ backgroundColor: stage.color + '15' }"` 把 6 位 hex 拼成
* 8 位带 alpha 的颜色('#409EFF' + '15' → '#409EFF15',即 8% 不透明度)。
* 这类位置**不能**用 CSS var —— `'var(--x)' + '15'` 是无效值,浏览器会整条丢弃。
* 于是「色值只有一处事实源」这条规则在 TS 侧会漏。本文件补上这个缺口。
*
* 单一事实源约定:
* 键名就是 src/theme/index.css 里的 var 名,值必须与之一致。
* 门控脚本 scripts/lint-admin-tokens.mjs 会逐项比对,任一侧改了而另一侧没改即失败。
*/
export const TOKEN_HEX: Record<string, string> = {
'--color-primary': '#F45F68',
'--color-primary-strong': '#C0414A',
'--color-primary-solid': '#C84A53',
'--color-success': '#2E9B68',
'--color-success-strong': '#1F7A4F',
'--color-warning': '#E59A32',
'--color-warning-strong': '#8F5D0F',
'--color-danger': '#D94A4A',
'--color-danger-strong': '#B8353A',
'--color-info': '#4A7CD4',
'--color-info-strong': '#3A63B0',
'--color-purple': '#8B63BC',
'--color-purple-strong': '#6B47A0',
'--color-neutral': '#8A9099',
'--color-neutral-strong': '#656C79',
'--color-text-primary': '#1F2328',
'--color-text-secondary': '#555555',
'--color-text-tertiary': '#676E7B',
'--color-bg-page': '#F7F9FB',
'--color-border': '#E4E7EB',
}
/**
* 取 token 的 hex 字面量。用于 :style 绑定(含 hex 拼接透明度的场景)。
* @example token('--color-success') // '#2E9B68'
*/
export function token(name: string): string {
const value = TOKEN_HEX[name]
if (!value) throw new Error(`未登记的 token: ${name}(请先加入 TOKEN_HEX 并同步 theme/index.css)`)
return value
}
/**
* 生成带 alpha 的 8 位 hex。alpha 传 2 位十六进制('15' = 21/255 ≈ 8%)。
* 与 `color + '15'` 等价,但把「必须 6 位 hex」这个约束显式化,避免误把 var() 传进来。
*/
export function tint(hex: string, alphaHex: string): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
throw new Error(`tint() 需要 6 位 hex(不能是 var() 或 3/8 位 hex):${hex}`)
}
return `${hex}${alphaHex}`
}
/** 指标卡 / 待办项的色调组,与 theme/index.css 的 --tone-* 一一对应。 */
export interface Tone {
/** 图标与图形用的鲜亮色 */
vivid: string
/** 文字用的可读色(压 soft 底 ≥4.5:1) */
ink: string
/** 浅底 */
soft: string
}
export const TONES: Record<string, Tone> = {
rose: { vivid: '#F45F68', ink: '#C0414A', soft: '#FFF0F1' },
amber: { vivid: '#E59A32', ink: '#8F5D0F', soft: '#FFF6E6' },
blue: { vivid: '#4A7CD4', ink: '#3A63B0', soft: '#EEF5FF' },
green: { vivid: '#2E9B68', ink: '#1F7A4F', soft: '#EDF8F1' },
purple: { vivid: '#8B63BC', ink: '#6B47A0', soft: '#F4EFFF' },
slate: { vivid: '#8A9099', ink: '#656C79', soft: '#F4F5F7' },
red: { vivid: '#D94A4A', ink: '#B8353A', soft: '#FFF6F6' },
}
+19
View File
@@ -0,0 +1,19 @@
// 内存态访问令牌。
//
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
// Authorization 头承载。两者并存,互不冲突。
let currentToken = ''
export function getAccessToken(): string {
return currentToken
}
export function setAccessToken(token: string): void {
currentToken = token || ''
}
export function clearAccessToken(): void {
currentToken = ''
}
+132 -110
View File
@@ -157,11 +157,12 @@ import {
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { createEmptyDashboard, loadDashboard } from '@/services/dashboard'
import { BRAND_ACTIVITY_FALLBACK } from '@/brand'
import type { DashboardActivity, DashboardModel } from '@/types/dashboard'
const router = useRouter()
const authStore = useAuthStore()
const activityFallback = '/brand/activity-default.jpg'
const activityFallback = BRAND_ACTIVITY_FALLBACK
const dashboard = ref<DashboardModel>(createEmptyDashboard())
const loading = ref(true)
const fatalError = ref('')
@@ -282,9 +283,9 @@ onMounted(retryLoad)
<style scoped>
.dashboard-page {
min-width: 0;
max-width: 1500px;
max-width: var(--admin-content-max-width);
margin: 0 auto;
padding: 20px 26px 28px;
padding: var(--space-5) 26px 28px;
}
.dashboard-welcome {
@@ -292,33 +293,34 @@ onMounted(retryLoad)
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 20px;
gap: var(--space-5);
}
.dashboard-welcome h1 {
font-size: 24px;
line-height: 1.25;
font-size: var(--font-size-3xl);
line-height: var(--font-line-snug);
}
.dashboard-welcome p {
margin-top: 6px;
color: #78828b;
font-size: 13px;
color: var(--color-text-tertiary);
font-size: var(--font-size-sm);
}
.dashboard-date {
display: flex;
align-items: center;
gap: 14px;
color: #7b858e;
font-size: 12px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-date small {
padding: 6px 10px;
color: #d75a63;
background: #fff0f1;
border-radius: 999px;
color: var(--admin-coral-strong);
background: var(--admin-coral-soft);
border-radius: var(--border-radius-pill);
font-size: var(--font-size-micro);
}
.dashboard-loading,
@@ -334,7 +336,7 @@ onMounted(retryLoad)
.metric-skeleton {
height: 105px;
border-radius: 12px;
border-radius: var(--border-radius-md);
}
.dashboard-error,
@@ -342,14 +344,14 @@ onMounted(retryLoad)
.dashboard-notice {
display: flex;
min-height: 48px;
margin: 8px 0 12px;
margin: var(--space-2) 0 var(--space-3);
padding: 10px 14px;
align-items: center;
gap: 10px;
color: #6f5355;
background: #fff6f6;
border: 1px solid #f7dfe1;
border-radius: 11px;
color: var(--color-danger-strong);
background: var(--color-danger-soft);
border: 1px solid var(--color-border-danger);
border-radius: var(--border-radius-sm);
}
.dashboard-error > span,
@@ -362,7 +364,8 @@ onMounted(retryLoad)
.dashboard-error small,
.dashboard-forbidden small {
margin-top: 3px;
color: #8c7d7e;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.dashboard-metrics {
@@ -374,33 +377,40 @@ onMounted(retryLoad)
display: grid;
min-width: 0;
min-height: 108px;
padding: 14px 12px;
padding: 14px var(--space-3);
grid-template-columns: 38px 1fr;
align-items: center;
gap: 9px;
overflow: hidden;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid rgba(235, 235, 238, 0.9);
border-radius: 12px;
transition: transform 0.18s ease, box-shadow 0.18s ease;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
transition: transform var(--motion-normal) var(--ease-standard),
box-shadow var(--motion-normal) var(--ease-standard);
}
.metric-card:hover {
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
box-shadow: var(--shadow-raise);
transform: translateY(-2px);
}
.metric-card:active {
transform: translateY(0) scale(var(--press-scale));
}
.metric-card.unavailable {
cursor: default;
opacity: 0.72;
}
/* 图标字形属「有意义的非文本内容」,按 WCAG 需 ≥3:1。
原先用鲜亮色调压同色系浅底(amber 仅 2.2:1),统一改用 -ink 档(≥4.6:1)。 */
.metric-icon,
.todo-icon {
display: grid;
color: var(--tone-color);
color: var(--tone-ink);
background: var(--tone-soft);
border-radius: 50%;
place-items: center;
@@ -409,7 +419,7 @@ onMounted(retryLoad)
.metric-icon {
width: 38px;
height: 38px;
font-size: 19px;
font-size: var(--font-size-xl);
}
.metric-copy {
@@ -420,24 +430,25 @@ onMounted(retryLoad)
.metric-copy small {
overflow: hidden;
color: #55616a;
font-size: 11px;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-copy strong {
margin-top: 3px;
color: #171a1d;
font-size: 25px;
color: var(--color-text-primary);
font-size: var(--font-size-3xl);
line-height: 1;
}
/* 原为 9px(低于可读下限),提到 11px */
.metric-copy em {
margin-top: 6px;
overflow: hidden;
color: var(--tone-color);
font-size: 9px;
color: var(--tone-ink);
font-size: var(--font-size-micro);
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
@@ -445,18 +456,20 @@ onMounted(retryLoad)
.metric-arrow {
position: absolute;
right: 8px;
bottom: 8px;
color: var(--tone-color);
font-size: 11px;
right: var(--space-2);
bottom: var(--space-2);
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
.tone-rose { --tone-color: #f05e69; --tone-soft: #ffe9eb; }
.tone-amber { --tone-color: #e58b39; --tone-soft: #fff1df; }
.tone-blue { --tone-color: #568ce9; --tone-soft: #eaf2ff; }
.tone-green { --tone-color: #4aad76; --tone-soft: #e9f7ef; }
.tone-purple { --tone-color: #9c69df; --tone-soft: #f2eaff; }
.tone-red { --tone-color: #ed5b64; --tone-soft: #ffe9eb; }
/* 色调组改为引用 theme/index.css 的 --tone-*-ink / --tone-*-soft。
--tone-ink 是本组件内的局部变量(由下面各 tone-* 类赋值),供图标与文字统一取用。 */
.tone-rose { --tone-ink: var(--tone-rose-ink); --tone-soft: var(--tone-rose-soft); }
.tone-amber { --tone-ink: var(--tone-amber-ink); --tone-soft: var(--tone-amber-soft); }
.tone-blue { --tone-ink: var(--tone-blue-ink); --tone-soft: var(--tone-blue-soft); }
.tone-green { --tone-ink: var(--tone-green-ink); --tone-soft: var(--tone-green-soft); }
.tone-purple { --tone-ink: var(--tone-purple-ink); --tone-soft: var(--tone-purple-soft); }
.tone-red { --tone-ink: var(--tone-red-ink); --tone-soft: var(--tone-red-soft); }
.dashboard-grid {
display: grid;
@@ -467,11 +480,11 @@ onMounted(retryLoad)
.dashboard-panel {
min-width: 0;
padding: 14px 16px;
background: #fff;
border: 1px solid #e9edf0;
padding: 14px var(--space-4);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--admin-card-radius);
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
box-shadow: var(--shadow-panel);
}
.panel-header {
@@ -479,42 +492,49 @@ onMounted(retryLoad)
min-height: 30px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f2;
border-bottom: 1px solid var(--color-border-hairline);
}
.panel-header h2 {
padding-bottom: 10px;
font-size: 16px;
font-size: var(--font-size-lg);
}
.panel-header h2 span {
display: inline-grid;
min-width: 20px;
height: 20px;
min-width: var(--space-5);
height: var(--space-5);
margin-left: 6px;
padding: 0 5px;
color: #fff;
font-size: 10px;
line-height: 20px;
background: var(--admin-coral);
border-radius: 999px;
color: var(--color-text-inverse);
font-size: var(--font-size-micro);
line-height: var(--space-5);
background: var(--admin-coral-solid);
border-radius: var(--border-radius-pill);
place-items: center;
}
.panel-header button {
display: flex;
min-height: var(--tap-control);
align-items: center;
gap: 4px;
color: #89929a;
font-size: 11px;
gap: var(--space-1);
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--border-radius-xs);
transition: color var(--motion-fast) var(--ease-standard);
}
.panel-header button:hover {
color: var(--admin-coral-strong);
}
.panel-header > span {
color: #98a0a7;
font-size: 10px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
}
.todo-list,
@@ -534,12 +554,13 @@ onMounted(retryLoad)
cursor: pointer;
background: transparent;
border: 0;
border-bottom: 1px solid #f2f3f4;
border-bottom: 1px solid var(--color-border-hairline);
transition: background var(--motion-fast) var(--ease-standard);
}
.todo-row:hover,
.activity-row:hover {
background: #fffafa;
background: var(--color-bg-secondary);
}
.todo-icon {
@@ -559,8 +580,8 @@ onMounted(retryLoad)
.todo-copy strong,
.activity-copy strong {
overflow: hidden;
color: #30373d;
font-size: 12px;
color: var(--color-text-primary);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -569,22 +590,22 @@ onMounted(retryLoad)
.activity-copy small {
margin-top: 3px;
overflow: hidden;
color: #939ba2;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-row > b {
min-width: 29px;
color: var(--admin-coral);
font-size: 13px;
color: var(--admin-coral-strong);
font-size: var(--font-size-sm);
text-align: center;
}
.todo-row > em {
color: var(--admin-coral);
font-size: 10px;
color: var(--admin-coral-strong);
font-size: var(--font-size-micro);
font-style: normal;
}
@@ -593,21 +614,21 @@ onMounted(retryLoad)
height: 38px;
flex: none;
object-fit: cover;
border-radius: 7px;
border-radius: var(--border-radius-xs);
}
.activity-status {
padding: 3px 6px;
color: #318b5a;
font-size: 9px;
background: #eaf7ef;
border-radius: 999px;
color: var(--color-success-strong);
font-size: var(--font-size-micro);
background: var(--color-success-soft);
border-radius: var(--border-radius-pill);
}
.activity-row > em {
min-width: 76px;
color: #7d878f;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
text-align: right;
}
@@ -619,47 +640,47 @@ onMounted(retryLoad)
justify-content: center;
flex-direction: column;
gap: 10px;
color: #a0a7ad;
font-size: 11px;
color: var(--color-text-tertiary);
font-size: var(--font-size-xs);
}
.dashboard-empty .el-icon {
color: #d7dce0;
font-size: 34px;
color: var(--color-icon-muted);
font-size: var(--font-size-5xl);
}
.overview-grid {
display: grid;
min-height: 118px;
padding-top: 12px;
padding-top: var(--space-3);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
gap: var(--space-2);
}
.overview-grid > div {
display: flex;
padding: 12px 10px;
padding: var(--space-3) 10px;
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--tone-soft);
border-radius: 8px;
border-radius: var(--border-radius-sm);
}
.overview-grid small {
color: var(--tone-color);
font-size: 10px;
color: var(--tone-ink);
font-size: var(--font-size-micro);
}
.overview-grid strong {
margin-top: 7px;
font-size: 24px;
font-size: var(--font-size-3xl);
}
.overview-grid em {
margin-top: 7px;
color: #8a9298;
font-size: 9px;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-style: normal;
}
@@ -674,52 +695,53 @@ onMounted(retryLoad)
grid-template-columns: 92px 1fr 76px;
align-items: center;
gap: 9px;
font-size: 10px;
font-size: var(--font-size-micro);
}
.effectiveness-row > span {
color: #747e87;
color: var(--color-text-tertiary);
}
/* 轨道与填充的对比原为 2.53:1(条几乎看不出进度),改后 4.01:1 */
.effectiveness-row > div {
height: 8px;
height: var(--space-2);
overflow: hidden;
background: #fdebee;
border-radius: 999px;
background: var(--color-primary-light);
border-radius: var(--border-radius-pill);
}
.effectiveness-row i {
display: block;
height: 100%;
background: linear-gradient(90deg, #f56b75, #ef5a65);
background: var(--gradient-brand-bar);
border-radius: inherit;
}
.effectiveness-row strong {
color: #75808a;
font-size: 10px;
font-weight: 500;
color: var(--color-text-tertiary);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
text-align: right;
}
@media (max-width: 1450px), (max-height: 820px) {
.dashboard-page { padding: 12px 18px 20px; }
.dashboard-welcome { min-height: 54px; }
.dashboard-welcome h1 { font-size: 20px; }
.dashboard-welcome p { margin-top: 3px; font-size: 11px; }
.dashboard-welcome h1 { font-size: var(--font-size-2xl); }
.dashboard-welcome p { margin-top: 3px; font-size: var(--font-size-xs); }
.dashboard-metrics { margin-top: 6px; gap: 7px; }
.metric-card { min-height: 90px; padding: 10px 8px; grid-template-columns: 32px 1fr; gap: 6px; }
.metric-icon { width: 32px; height: 32px; font-size: 16px; }
.metric-copy strong { font-size: 21px; }
.metric-card { min-height: 90px; padding: 10px var(--space-2); grid-template-columns: var(--space-8) 1fr; gap: 6px; }
.metric-icon { width: var(--space-8); height: var(--space-8); font-size: var(--font-size-lg); }
.metric-copy strong { font-size: var(--font-size-2xl); }
.dashboard-grid { margin-top: 9px; gap: 9px; }
.dashboard-panel { padding: 10px 12px; }
.dashboard-panel { padding: 10px var(--space-3); }
.todo-list, .activity-list { min-height: 152px; }
.todo-row, .activity-row { min-height: 38px; padding: 4px; }
.todo-row, .activity-row { min-height: 38px; padding: var(--space-1); }
.activity-row img { width: 58px; height: 31px; }
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
.overview-grid, .effectiveness-list { min-height: 98px; }
.overview-grid > div { padding: 8px 6px; }
.overview-grid strong { font-size: 20px; }
.overview-grid > div { padding: var(--space-2) 6px; }
.overview-grid strong { font-size: var(--font-size-2xl); }
}
@media (max-width: 1180px) {
+15 -14
View File
@@ -1,10 +1,10 @@
<template>
<main class="login-shell">
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
<img class="brand-scene" src="/brand/login-illustration.jpg" alt="" />
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
<div class="brand-overlay">
<div class="brand-title">
<img src="/brand/logo-mark.png" alt="" />
<img :src="BRAND_LOGO" alt="" />
<div>
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
@@ -62,6 +62,7 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { BRAND_LOGIN_SCENE, BRAND_LOGO } from '@/brand'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
@@ -100,7 +101,7 @@ async function handleLogin() {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
min-height: 100vh;
background: #FFFFFF;
background: var(--color-bg-primary);
}
.brand-panel {
@@ -143,28 +144,28 @@ async function handleLogin() {
.brand-title strong {
color: var(--admin-primary);
font-size: 25px;
font-size: var(--font-size-3xl);
letter-spacing: 2px;
}
.brand-title span {
margin-top: 3px;
color: var(--color-text-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
}
.brand-message {
max-width: 590px;
padding: 26px 30px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.94);
background: var(--color-bg-overlay);
border: 1px solid var(--color-bg-overlay-strong);
border-radius: 18px;
backdrop-filter: blur(10px);
}
.brand-message p {
color: var(--admin-primary);
font-weight: 650;
font-weight: var(--font-weight-semibold);
letter-spacing: 3px;
}
@@ -187,7 +188,7 @@ async function handleLogin() {
align-items: center;
justify-content: center;
padding: 48px;
background: #FFFFFF;
background: var(--color-bg-primary);
}
.login-card {
@@ -200,14 +201,14 @@ async function handleLogin() {
.eyebrow {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 1px;
}
.login-header h2 {
margin-top: 10px;
font-size: 30px;
font-size: var(--font-size-5xl);
}
.login-header p {
@@ -227,7 +228,7 @@ async function handleLogin() {
margin-top: 24px;
padding: 14px 16px;
color: var(--color-text-secondary);
font-size: 12px;
font-size: var(--font-size-xs);
line-height: 1.6;
background: var(--admin-soft-green);
border-radius: 10px;
@@ -242,7 +243,7 @@ async function handleLogin() {
footer {
margin-top: 40px;
color: var(--color-text-tertiary);
font-size: 12px;
font-size: var(--font-size-xs);
}
@media (max-width: 900px) {
+13 -3
View File
@@ -2,15 +2,25 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
export default defineConfig(({ command }) => ({
plugins: [vue()],
// 管理端产物部署在 nginx 的 /admin/ 子路径下(见 infrastructure/nginx-production.conf
// 与 admin-web/nginx-admin.conf)。构建时必须带 /admin/ 前缀,否则 index.html 引用的
// /assets/*.js 会落到站点根路径,被生产 nginx 的其它 location 兜成 404 或错误页面。
// 开发服务器保持根路径:http://localhost:13520/ 的既有开发与排查习惯不变
// (vite 的 base 对 dev 与 build 各自生效,故此处按 command 分叉)。
base: command === 'build' ? '/admin/' : '/',
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
port: 13520,
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
strictPort: true,
proxy: {
'/api': {
target: 'http://localhost:8085',
@@ -18,4 +28,4 @@ export default defineConfig({
},
},
},
})
}))
File diff suppressed because one or more lines are too long
+649
View File
@@ -0,0 +1,649 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>善缘丹阳 · 小程序设计系统 v2 改造对照</title>
<style>
:root{
/* ── 与 miniapp/app.wxss 完全一致的 token(此处 1rpx = 0.5px,按 375pt 机型还原) ── */
--primary:#E96B6B; --primary-strong:#B8434A; --primary-solid:#C24C50; --primary-light:#FFF3F2;
--night-start:#172A72; --night-end:#25104F; --night-soft:#3445A1;
--accent-pink:#FF4F91; --accent-coral:#FF6A78;
--text-strong:#1A1F36; --text-primary:#2A3050; --text-secondary:#4A5169; --text-tertiary:#646C85;
--text-placeholder:#9AA1B4; --text-inverse:#FFFFFF;
--bg-page:#F6F5F3; --bg-card:#FFFFFF; --bg-subtle:#F5F6FA;
--border:#E6E8EF; --divider:#EFF0F5;
--accent-purple-strong:#7C60BC; --accent-purple-light:#F3EEFF;
--success-strong:#17805A; --success-light:#ECF8F2; --success-on-night:#A6F5D7;
--warning-strong:#9C5F14; --warning-light:#FFF7E9; --danger-strong:#C62828; --danger-light:#FFF0F0;
--info-strong:#2A5DB0; --info-light:#EEF4FF;
--glow-coral:rgba(255,79,145,.40); --glow-indigo:rgba(94,124,255,.32);
--star:rgba(255,255,255,.70); --glass:rgba(255,255,255,.10); --hairline:rgba(255,255,255,.16);
--glass-success:rgba(90,226,184,.16);
--shadow-card:0 4px 14px rgba(35,35,95,.07); --shadow-raised:0 6px 18px rgba(35,35,95,.10);
--shadow-night:0 8px 20px rgba(28,30,88,.18);
--grad-smoke:linear-gradient(135deg,#FF6A78 0%,#E96B6B 100%);
--grad-solid:linear-gradient(135deg,#C24C50 0%,#B8434A 100%);
--scrim:linear-gradient(90deg,rgba(23,42,114,.84) 0%,rgba(37,16,79,.40) 56%,rgba(255,79,145,.12) 100%);
/* 文档层配色 */
--doc-bg:#F1F3F8; --doc-panel:#FFFFFF; --doc-line:#E2E6EF; --doc-ink:#1A1F36; --doc-mute:#5C6480;
}
*{box-sizing:border-box;}
body{
margin:0;background:var(--doc-bg);color:var(--doc-ink);
font-family:-apple-system,"PingFang SC","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
line-height:1.6;-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1180px;margin:0 auto;padding:40px 24px 80px;}
h1{font-size:28px;margin:0 0 8px;letter-spacing:-.2px;}
h2{font-size:20px;margin:0 0 4px;}
h3{font-size:15px;margin:0 0 10px;}
.sub{color:var(--doc-mute);font-size:13.5px;margin:0;}
.meta{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;}
.chip{
display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
background:#fff;border:1px solid var(--doc-line);font-size:12.5px;color:var(--doc-mute);
}
.chip b{color:var(--doc-ink);font-weight:600;}
.panel{background:var(--doc-panel);border:1px solid var(--doc-line);border-radius:14px;padding:22px 24px;margin-top:22px;}
.panel > p.lead{margin:6px 0 18px;color:var(--doc-mute);font-size:13.5px;}
/* 指标卡 */
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:14px;}
.metric{background:#fff;border:1px solid var(--doc-line);border-radius:12px;padding:16px 18px;}
.metric .k{font-size:12.5px;color:var(--doc-mute);}
.metric .v{font-size:24px;font-weight:700;margin:6px 0 2px;font-variant-numeric:tabular-nums;}
.metric .d{font-size:12.5px;color:var(--doc-mute);}
.up{color:var(--success-strong);} .flat{color:var(--doc-mute);}
.metric .from{color:var(--doc-mute);text-decoration:line-through;font-weight:500;font-size:16px;margin-left:6px;}
/* 手机模拟 */
.phones{display:flex;flex-wrap:wrap;gap:34px;justify-content:center;margin-top:8px;}
.phone-col{display:flex;flex-direction:column;align-items:center;gap:12px;}
.phone-tag{font-size:12.5px;padding:4px 12px;border-radius:999px;border:1px solid var(--doc-line);background:#fff;color:var(--doc-mute);}
.phone-tag.new{border-color:#C9E6D8;background:var(--success-light);color:var(--success-strong);font-weight:600;}
.phone{
width:375px;border-radius:30px;padding:9px;background:#15182B;
box-shadow:0 18px 44px rgba(20,24,50,.22),0 2px 6px rgba(20,24,50,.14);
}
.screen{width:100%;border-radius:22px;overflow:hidden;background:var(--bg-page);position:relative;}
.statusbar{height:22px;display:flex;align-items:center;justify-content:center;font-size:10px;color:rgba(255,255,255,.86);letter-spacing:.4px;}
/* ── 夜空头部:改造前(纯渐变) ── */
.night-a{
padding:0 12px 14px;
background:linear-gradient(160deg,var(--night-start),var(--night-end));
border-radius:0 0 21px 21px;box-shadow:var(--shadow-night);
}
/* ── 夜空头部:改造后(星点 + 双光晕 + 叠层) ── */
.night-b{
position:relative;overflow:hidden;padding:0 12px 16px;
background:
radial-gradient(circle at 12% 18%,rgba(255,255,255,.70) 0,transparent 1.25px),
radial-gradient(circle at 31% 66%,rgba(255,255,255,.70) 0,transparent 1px),
radial-gradient(circle at 57% 12%,rgba(255,255,255,.70) 0,transparent 1px),
radial-gradient(circle at 79% 52%,rgba(255,255,255,.70) 0,transparent 1.5px),
radial-gradient(circle at 93% 84%,rgba(255,255,255,.70) 0,transparent 1px),
linear-gradient(160deg,var(--night-start),var(--night-end));
border-radius:0 0 24px 24px;box-shadow:var(--shadow-night);
}
.night-b::before,.night-b::after{content:'';position:absolute;border-radius:50%;pointer-events:none;}
.night-b::before{top:-100px;right:-70px;width:280px;height:280px;
background:radial-gradient(circle,var(--glow-coral) 0%,transparent 68%);}
.night-b::after{bottom:-110px;left:-80px;width:310px;height:310px;
background:radial-gradient(circle,var(--glow-indigo) 0%,transparent 70%);}
.night-b > *{position:relative;z-index:1;}
.brand{min-height:56px;display:flex;align-items:center;justify-content:space-between;color:#fff;}
.brand-l{display:flex;align-items:center;}
.logo{width:33px;height:33px;border-radius:9px;background:linear-gradient(135deg,#FF7A88,#E96B6B);
display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;flex:none;}
.logo.b{width:34px;height:34px;}
.brand-name-a{font-size:17px;font-weight:700;letter-spacing:1px;line-height:1.25;}
.brand-name-b{font-size:18px;font-weight:700;letter-spacing:2px;line-height:1.25;}
.brand-slo-a{font-size:12px;letter-spacing:2px;color:rgba(255,255,255,.72);}
.brand-slo-b{font-size:12px;letter-spacing:3px;color:rgba(255,255,255,.72);}
.msg{font-size:12.5px;}
/* 状态条 */
.strip-a{margin:2px 0 8px;padding:9px 10px;border:1px solid rgba(255,255,255,.16);
background:rgba(255,255,255,.09);border-radius:10px;color:#fff;display:flex;align-items:center;gap:8px;}
.strip-b{margin:4px 0 8px;padding:12px;border:1px solid var(--hairline);
background:var(--glass);border-radius:12px;color:#fff;display:flex;align-items:center;gap:8px;}
.st-copy{flex:1;min-width:0;}
.st-title-a{font-size:12.5px;font-weight:600;}
.st-title-b{font-size:14px;font-weight:600;line-height:1.25;}
.st-desc{font-size:12px;color:rgba(255,255,255,.72);line-height:1.4;margin-top:2px;}
.cta-a{flex:none;min-width:54px;padding:0 9px;height:24px;display:flex;align-items:center;justify-content:center;
border-radius:14px;background:var(--grad-smoke);font-size:12px;font-weight:600;}
.cta-b{flex:none;min-width:58px;padding:0 12px;height:44px;display:flex;align-items:center;justify-content:center;
border-radius:999px;background:var(--grad-solid);font-size:13px;font-weight:600;}
.checks{display:flex;gap:5px;overflow:hidden;}
.check-a{padding:4px 6px;border-radius:10px;background:rgba(255,255,255,.08);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
.check-b{padding:4px 7px;border-radius:999px;background:var(--glass);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
.check-b.done{background:var(--glass-success);color:var(--success-on-night);font-weight:600;}
/* 横幅 */
.banner-a{height:136px;border-radius:13px;overflow:hidden;position:relative;
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
.banner-b{height:150px;border-radius:16px;overflow:hidden;position:relative;
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
.banner-a .scrim,.banner-b .scrim{position:absolute;inset:0;}
.banner-a .scrim{background:linear-gradient(90deg,rgba(25,22,86,.78) 0%,rgba(43,22,101,.25) 58%,rgba(255,79,145,.08) 100%);}
.banner-b .scrim{background:var(--scrim);}
.banner-copy{position:absolute;left:12px;width:68%;display:flex;flex-direction:column;color:#fff;}
.banner-a .banner-copy{bottom:13px;}
.banner-b .banner-copy{bottom:20px;}
.kick{font-size:12px;letter-spacing:1.5px;color:rgba(255,255,255,.78);}
.bt-a{margin-top:4px;font-size:17.5px;font-weight:700;line-height:1.2;text-shadow:0 1.5px 5px rgba(0,0,0,.2);}
.bt-b{margin-top:5px;font-size:24px;font-weight:700;line-height:1.25;letter-spacing:.5px;text-shadow:0 2px 7px rgba(0,0,0,.22);}
.bs-a,.bs-b{margin-top:4px;font-size:12px;color:rgba(255,255,255,.84);}
.bs-b{font-size:13px;color:rgba(255,255,255,.72);margin-top:5px;}
/* 快捷入口 */
.entries{position:relative;z-index:2;background:#fff;margin:-7px 12px 0;}
.entries.a{border-radius:15px;padding:11px 5px 9px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
.entries.b{border-radius:16px;padding:12px 4px 8px;box-shadow:var(--shadow-raised);}
.egrid{display:flex;flex-wrap:wrap;row-gap:8px;}
.eitem{width:25%;display:flex;flex-direction:column;align-items:center;justify-content:center;}
.eitem.a{min-height:52px;} .eitem.b{min-height:58px;}
.eicon{display:flex;align-items:center;justify-content:center;overflow:hidden;}
.eicon.a{width:33px;height:33px;border-radius:10px;background:var(--primary-light);font-size:15px;}
.eicon.b{width:36px;height:36px;border-radius:12px;background:var(--primary-light);font-size:16px;}
.elabel{margin-top:4px;color:var(--text-strong);}
.elabel.a{font-size:12px;font-weight:500;}
.elabel.b{font-size:13px;font-weight:500;}
/* 区块标题 */
.sec{padding:0 12px;}
.sec.a{margin-top:16px;} .sec.b{margin-top:20px;}
.shead{display:flex;align-items:center;justify-content:space-between;}
.shead.a{padding:11px 12px 11px 0;} .shead.b{padding:8px 0;}
.st-a{font-size:18px;font-weight:600;color:var(--text-primary);}
.st-b{font-size:20px;font-weight:600;color:var(--text-strong);}
.ssub{margin-left:8px;color:var(--text-tertiary);}
.ssub.a{font-size:13px;} .ssub.b{font-size:12px;}
.sact{display:flex;align-items:center;}
.sact.a{font-size:13px;color:var(--primary);height:18px;}
.sact.b{font-size:13px;color:var(--primary-strong);min-height:44px;padding-left:12px;font-weight:500;}
.caret{font-size:11px;margin-left:2px;}
/* 卡片 */
.card{background:#fff;display:flex;align-items:center;overflow:hidden;}
.card.a{border-radius:13px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
.card.b{border-radius:16px;box-shadow:var(--shadow-card);}
.rec{display:flex;align-items:center;gap:8px;padding:7px 8px;}
.rec .av{width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#D9D3F0,#BFC8EE);flex:none;}
.rec .info{flex:1;min-width:0;}
.rec .nm-a{font-size:13px;font-weight:700;color:var(--text-strong);}
.rec .nm-b{font-size:14px;font-weight:700;color:var(--text-strong);}
.rec .meta{font-size:12px;color:var(--text-tertiary);margin-top:2px;}
.rec .tags{display:flex;gap:4px;margin-top:4px;}
.rec .tag{padding:2px 5px;border-radius:6px;background:var(--accent-purple-light);color:var(--accent-purple-strong);font-size:12px;}
.heart-a{width:31px;height:31px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:14px;}
.heart-b{width:44px;height:44px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:16px;}
.divider{height:1px;background:var(--divider);}
/* 空态 */
.empty{background:#fff;display:flex;align-items:center;gap:8px;}
.empty.a{border-radius:13px;padding:11px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
.empty.b{border-radius:16px;padding:12px;box-shadow:var(--shadow-card);}
.logo-s{width:38px;height:38px;border-radius:10px;background:var(--grad-smoke);flex:none;
display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700;}
.et-a{font-size:12.5px;font-weight:600;color:var(--text-strong);}
.et-b{font-size:14px;font-weight:600;color:var(--text-strong);}
.ed{font-size:12px;color:var(--text-tertiary);line-height:1.4;margin-top:2px;}
.ed.b{font-size:13px;}
.eacts{display:flex;flex-direction:column;gap:4px;}
.ebtn{display:flex;align-items:center;justify-content:center;border-radius:12px;}
.ebtn.a{min-width:56px;height:24px;padding:0 6px;font-size:12px;}
.ebtn.b{min-width:60px;height:44px;padding:0 8px;font-size:13px;font-weight:500;border-radius:999px;}
.ebtn.sec-a{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
.ebtn.sec-b{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
.ebtn.pri-a{color:#fff;background:var(--grad-smoke);}
.ebtn.pri-b{color:#fff;background:var(--grad-solid);}
.hidden-note{margin-top:8px;font-size:11.5px;color:var(--danger-strong);
background:var(--danger-light);border-radius:8px;padding:6px 8px;}
.fixed-note{margin-top:8px;font-size:11.5px;color:var(--success-strong);
background:var(--success-light);border-radius:8px;padding:6px 8px;}
/* 注释气泡 */
.callout{display:flex;gap:10px;align-items:flex-start;margin-top:14px;max-width:375px;}
.callout .bullet{flex:none;width:20px;height:20px;border-radius:50%;background:var(--primary-solid);color:#fff;
font-size:11.5px;display:flex;align-items:center;justify-content:center;font-weight:700;margin-top:1px;}
.callout .txt{font-size:12.5px;color:var(--doc-mute);}
.callout .txt b{color:var(--doc-ink);}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--divider);vertical-align:top;}
th{color:var(--doc-mute);font-weight:600;font-size:12.5px;background:var(--bg-subtle);}
td code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
.bad{color:var(--danger-strong);font-weight:600;}
.good{color:var(--success-strong);font-weight:600;}
.sw{display:inline-flex;align-items:center;gap:7px;}
.dot{width:14px;height:14px;border-radius:4px;border:1px solid rgba(0,0,0,.08);flex:none;}
ul.clean{margin:8px 0 0;padding-left:18px;font-size:13.5px;color:var(--text-secondary);}
ul.clean li{margin:5px 0;}
ul.clean code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
.gate{display:flex;flex-wrap:wrap;gap:10px;}
.gate .g{flex:1 1 210px;border:1px solid #CFE9DC;background:var(--success-light);border-radius:11px;padding:12px 14px;}
.gate .g .n{font-size:19px;font-weight:700;color:var(--success-strong);}
.gate .g .l{font-size:12.5px;color:var(--text-secondary);margin-top:2px;}
.warnbox{border:1px solid #F2E3C4;background:var(--warning-light);border-radius:11px;padding:14px 16px;font-size:13px;color:#6B4E12;}
.warnbox b{color:var(--warning-strong);}
footer{margin-top:34px;font-size:12.5px;color:var(--doc-mute);text-align:center;}
</style>
</head>
<body>
<div class="wrap">
<h1>善缘丹阳 · 小程序设计系统 v2 改造对照</h1>
<p class="sub">以首页为样板页,展示「设计底座落地 + 氛围层 + 排版层级 + 可访问性」四类改动的实际效果。</p>
<div class="meta">
<span class="chip">基线 <b>main @ 825fd41</b></span>
<span class="chip">改动文件 <b>80</b></span>
<span class="chip">wxss 覆盖 <b>88 / 88</b></span>
<span class="chip">UI 契约门禁 <b>18 / 18 PASS</b></span>
</div>
<div class="panel">
<h2>一、量化指标</h2>
<p class="lead">改动前的问题不是「不够好看」,而是设计底座没落地 —— 88 个样式文件里 78% 绕过了 token。</p>
<div class="metrics">
<div class="metric">
<div class="k">页面层硬编码颜色</div>
<div class="v flat">0<span class="from">770</span></div>
<div class="d">仅 app.wxss 保留 56 个 token 定义色</div>
</div>
<div class="metric">
<div class="k">不可读字号(&lt;12px 非装饰)</div>
<div class="v flat">0<span class="from">140</span></div>
<div class="d">元信息下限统一到 24rpx(12px)</div>
</div>
<div class="metric">
<div class="k">非法字重(550/650/750)</div>
<div class="v flat">0<span class="from">30</span></div>
<div class="d">只保留 400/500/600/700 四档</div>
</div>
<div class="metric">
<div class="k">未定义的 token 引用</div>
<div class="v flat">0<span class="from">2</span></div>
<div class="d">此前静默失效(背景/过渡丢失)</div>
</div>
<div class="metric">
<div class="k">未达标文字对比度</div>
<div class="v flat">0<span class="from">41</span></div>
<div class="d">3.10:1 → 5.33:1</div>
</div>
<div class="metric">
<div class="k">白字压在浅渐变上的 CTA</div>
<div class="v flat">0<span class="from">10</span></div>
<div class="d">2.77:1 → 4.73:1</div>
</div>
<div class="metric">
<div class="k">品牌色实心底 + 白字</div>
<div class="v flat">0<span class="from">32</span></div>
<div class="d">按钮/标签/徽标统一换 solid 变体</div>
</div>
<div class="metric">
<div class="k">低于 44px 的触摸目标</div>
<div class="v flat">0<span class="from">42</span></div>
<div class="d">组件库按钮 40px、喜欢键 31px</div>
</div>
</div>
</div>
<div class="panel">
<h2>二、首页对照</h2>
<p class="lead">左侧是改造前(HEAD 版本的真实取值),右侧是改造后。两侧均用 app.wxss 里的真实 token 渲染,1rpx = 0.5px。</p>
<div class="phones">
<!-- ══════════ 改造前 ══════════ -->
<div class="phone-col">
<span class="phone-tag">改造前</span>
<div class="phone"><div class="screen">
<div class="night-a">
<div class="statusbar">9:41</div>
<div class="brand">
<div class="brand-l">
<div class="logo">善</div>
<div style="margin-left:6px">
<div class="brand-name-a">善缘丹阳</div>
<div class="brand-slo-a">公益婚恋服务平台</div>
</div>
</div>
<div class="msg">消息</div>
</div>
<div class="strip-a">
<div class="st-copy">
<div class="st-title-a">完善资料,开启安心相遇</div>
<div class="st-desc">补全 3 项信息即可进入推荐</div>
</div>
<div class="cta-a">去完善</div>
</div>
<div class="banner-a">
<div class="scrim"></div>
<div class="banner-copy">
<div class="kick">公益 · 本地 · 真实 · 安全</div>
<div class="bt-a">在丹阳,遇见更好的你</div>
<div class="bs-a">认真认识,温暖相伴</div>
</div>
</div>
</div>
<div class="entries a">
<div class="egrid">
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
</div>
</div>
<div class="sec a">
<div class="shead a">
<div><span class="st-a">今日推荐</span><span class="ssub a">为你认真筛选</span></div>
<div class="sact a">查看更多<span class="caret">›</span></div>
</div>
<div class="card a">
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-a">林晓 · 28</div>
<div class="meta">丹阳 · 教师 · 本科</div>
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
</div>
<div class="heart-a">❤</div>
</div>
<div class="divider"></div>
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-a">周敏 · 30</div>
<div class="meta">丹阳 · 医护 · 硕士</div>
<div class="tags"><span class="tag">同城</span></div>
</div>
<div class="heart-a">❤</div>
</div>
</div>
</div>
</div></div>
<div class="callout"><div class="bullet">1</div><div class="txt">
<b>夜空只有一条平渐变</b>:参考图里的暖粉光晕、星点、玻璃质感全部缺失,头部是「一块紫布」。
</div></div>
<div class="callout"><div class="bullet">2</div><div class="txt">
<b>层级读不出来</b>:hero 标题 17.5px、区块标题 18px、卡片标题 12.5px —— hero 与区块标题几乎同号。
</div></div>
<div class="callout"><div class="bullet">3</div><div class="txt">
<b>触摸目标不足</b>:「去完善」按钮高 24px、「查看更多」18px,远低于 44px 无障碍下限。
</div></div>
<div class="callout"><div class="bullet">4</div><div class="txt">
<b>对比度不达标</b>:CTA 白字压在珊瑚粉渐变上仅 <span class="bad">2.77:1</span>;文字链接 <code>#FF4F91</code> 仅 <span class="bad">3.10:1</span>。
</div></div>
</div>
<!-- ══════════ 改造后 ══════════ -->
<div class="phone-col">
<span class="phone-tag new">改造后</span>
<div class="phone"><div class="screen">
<div class="night-b">
<div class="statusbar">9:41</div>
<div class="brand">
<div class="brand-l">
<div class="logo b">善</div>
<div style="margin-left:8px">
<div class="brand-name-b">善缘丹阳</div>
<div class="brand-slo-b">公益婚恋服务平台</div>
</div>
</div>
<div class="msg">消息</div>
</div>
<div class="strip-b">
<div class="st-copy">
<div class="st-title-b">完善资料,开启安心相遇</div>
<div class="st-desc">补全 3 项信息即可进入推荐</div>
</div>
<div class="cta-b">去完善</div>
</div>
<div class="banner-b">
<div class="scrim"></div>
<div class="banner-copy">
<div class="kick">公益 · 本地 · 真实 · 安全</div>
<div class="bt-b">在丹阳,遇见更好的你</div>
<div class="bs-b">认真认识,温暖相伴</div>
</div>
</div>
</div>
<div class="entries b">
<div class="egrid">
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
</div>
</div>
<div class="sec b">
<div class="shead b">
<div><span class="st-b">今日推荐</span><span class="ssub b">为你认真筛选</span></div>
<div class="sact b">查看更多<span class="caret">›</span></div>
</div>
<div class="card b">
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-b">林晓 · 28</div>
<div class="meta">丹阳 · 教师 · 本科</div>
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
</div>
<div class="heart-b">❤</div>
</div>
<div class="divider"></div>
<div class="rec">
<div class="av"></div>
<div class="info">
<div class="nm-b">周敏 · 30</div>
<div class="meta">丹阳 · 医护 · 硕士</div>
<div class="tags"><span class="tag">同城</span></div>
</div>
<div class="heart-b">❤</div>
</div>
</div>
</div>
</div></div>
<div class="callout"><div class="bullet">1</div><div class="txt">
<b>三层叠加做成氛围</b>:<code>--mini-starfield</code> 星点 + 夜空渐变 + 两个定位光晕(右上珊瑚粉、左下靛蓝)。全部用 CSS 渐变,零图片、零额外请求。
</div></div>
<div class="callout"><div class="bullet">2</div><div class="txt">
<b>四级排版阶梯</b>:hero <b>24px/700</b> &gt; 区块标题 <b>20px/600</b> &gt; 卡片标题 <b>14px/600</b> &gt; 元信息 12px。
</div></div>
<div class="callout"><div class="bullet">3</div><div class="txt">
<b>触摸目标全部达标</b>:CTA 44px;区块标题右侧「查看更多」由 18px 提到 44px 热区,且热区向左补白不挤压标题。
</div></div>
<div class="callout"><div class="bullet">4</div><div class="txt">
<b>对比度达标</b>:CTA 换 <code>--gradient-brand-solid</code> → <span class="good">4.73:1</span>;文字链接换 <code>--color-primary-strong</code> → <span class="good">5.33:1</span>。
</div></div>
<div class="callout"><div class="bullet">5</div><div class="txt">
<b>标题与卡片对齐</b>:旧版标题比卡片多缩进 32rpx(.section 的 24rpx 与组件自带 32rpx 叠加),安全提醒卡还因 <code>padding:0</code> 顶到屏幕边缘。
</div></div>
</div>
</div>
</div>
<div class="panel">
<h2>三、小屏(≤340px)的行为差异</h2>
<p class="lead">这是本轮改动里唯一一处「功能级」缺陷修复,值得单独看。</p>
<div style="display:flex;gap:34px;flex-wrap:wrap;justify-content:center">
<div class="phone-col">
<span class="phone-tag">改造前</span>
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 21px 21px"></div>
<div class="entries a" style="margin-top:-7px">
<div class="egrid">
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
</div>
</div>
<div class="sec a">
<div class="empty a">
<div class="logo-s">善</div>
<div style="flex:1;min-width:0">
<div class="et-a">暂时没有完全符合的人</div>
<div class="ed">我们坚持 Required 条件</div>
</div>
<div style="width:56px"></div>
</div>
<div class="hidden-note">@media (max-width:340px){ .empty-actions{display:none} } —— 两个操作入口整块消失</div>
</div>
</div></div>
</div>
<div class="phone-col">
<span class="phone-tag new">改造后</span>
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 24px 24px"></div>
<div class="entries b" style="margin-top:-7px">
<div class="egrid">
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
</div>
</div>
<div class="sec b">
<div class="empty b">
<div class="logo-s">善</div>
<div style="flex:1;min-width:0">
<div class="et-b">暂时没有完全符合的人</div>
<div class="ed b">我们坚持 Required 条件</div>
</div>
<div class="eacts">
<div class="ebtn sec-b">调整条件</div>
<div class="ebtn pri-b">公益红娘</div>
</div>
</div>
<div class="fixed-note">小屏只收紧尺寸与字号,不再砍掉任何入口</div>
</div>
</div></div>
</div>
</div>
</div>
<div class="panel">
<h2>四、设计 token 变更要点</h2>
<p class="lead">品牌色分级是这一版最重要的结构性决定:基础品牌色 <code>#E96B6B</code> 无论做背景还是做文字都不满足 4.5:1,因此拆成三个语义层。</p>
<table>
<thead><tr><th style="width:230px">Token</th><th style="width:130px">取值</th><th style="width:110px">对比度</th><th>用途限制</th></tr></thead>
<tbody>
<tr>
<td><span class="sw"><span class="dot" style="background:#E96B6B"></span><code>--color-primary</code></span></td>
<td><code>#E96B6B</code></td><td class="bad">3.10:1</td>
<td>仅大色块、渐变、光晕、图标底。禁止承载文字。</td>
</tr>
<tr>
<td><span class="sw"><span class="dot" style="background:#B8434A"></span><code>--color-primary-strong</code></span></td>
<td><code>#B8434A</code></td><td class="good">5.33:1</td>
<td>所有品牌色文字、小图标、纯文字链接。</td>
</tr>
<tr>
<td><span class="sw"><span class="dot" style="background:#C24C50"></span><code>--color-primary-solid</code></span></td>
<td><code>#C24C50</code></td><td class="good">4.73:1</td>
<td>实心按钮底(配白字)、小徽标底色。</td>
</tr>
<tr>
<td><code>--gradient-brand</code></td>
<td>#FF6A78 → #E96B6B</td><td class="bad">2.77:1</td>
<td>明亮活泼,仅装饰填充,不得压白字。</td>
</tr>
<tr>
<td><code>--gradient-brand-solid</code></td>
<td>#C24C50 → #B8434A</td><td class="good">4.73 ~ 5.33</td>
<td>所有承载白字的 CTA 渐变(本轮新增)。</td>
</tr>
<tr>
<td><code>--color-text-tertiary</code></td>
<td>#777 → <code>#646C85</code></td><td class="good">5.22:1</td>
<td>说明文字。旧值 4.48:1 卡在阈值下沿。</td>
</tr>
<tr>
<td><code>--mini-glow-coral</code> / <code>--mini-glow-indigo</code></td>
<td>rgba 粉 / 蓝</td><td class="flat">—</td>
<td>夜空光晕层(本轮新增,参考图关键特征)。</td>
</tr>
<tr>
<td><code>--mini-starfield</code></td>
<td>5 个径向渐变</td><td class="flat">—</td>
<td>星点层(本轮新增),叠在夜空渐变之上。</td>
</tr>
<tr>
<td><code>--mini-glass</code> / <code>--mini-hairline</code></td>
<td>rgba(255,255,255,.10/.16)</td><td class="flat">—</td>
<td>深底玻璃面与分隔线(本轮新增)。</td>
</tr>
<tr>
<td><code>--tap-min</code> / <code>--tap-gap</code></td>
<td>88rpx / 16rpx</td><td class="flat">44px</td>
<td>触摸目标下限与最小间距(本轮新增)。</td>
</tr>
<tr>
<td><code>--ease-spring</code> / <code>--press-scale</code></td>
<td>cubic-bezier(.34,1.4,.64,1) / 0.97</td><td class="flat">—</td>
<td>按压回弹与缩放(本轮新增)。</td>
</tr>
</tbody>
</table>
</div>
<div class="panel">
<h2>五、本轮修掉的实质缺陷</h2>
<p class="lead">不只是样式替换 —— 其中三条是会被静默吞掉的语法级错误。</p>
<ul class="clean">
<li><b>3 处声明粘连(脚本副作用)</b>:<code>recommend-card</code>、<code>discover</code>、<code>service-station</code> 里出现
<code>box-shadow:…rgba(255,79,145,.18)min-width:var(--tap-min);</code> 这类<b>缺少分号</b>的合并声明。
浏览器会把整条声明判为非法并丢弃 —— 于是「喜欢」按钮的 44px 热区与阴影双双静默失效,
而花括号平衡、hex 配额等所有静态检查仍然全绿。</li>
<li><b>2 处引用了不存在的 token</b>:<code>--button-press-feedback</code>、<code>--color-bg-input</code> 此前从未定义,
导致过渡动画与输入框背景直接失效(无报错、无告警)。</li>
<li><b>小屏砍掉操作入口</b>:<code>@media (max-width:340px)</code> 里 <code>.empty-actions{display:none}</code>,
在窄屏机型上把「调整条件 / 公益红娘」两个入口整块移除,属功能级缺陷。</li>
<li><b>安全提醒卡错位</b>:旧版 <code>.safety-card{padding:0}</code> 覆盖了 <code>.section</code> 的左右内边距,
该卡片比其它区块宽 48rpx、直接贴到屏幕边缘。</li>
<li><b>触摸目标普遍不足</b>:组件库主按钮 40px、小号 28px、纯文字变体 <code>min-height:auto</code>、
「喜欢」键 31px、区块标题右侧 action 18px —— 全站已统一到 44px 下限。</li>
<li><b>对比度系统性不达标</b>:41 处把品牌基础色当文字用(3.10:1)、32 处把基础色做实心底压白字、
10 处白字压在明亮渐变上(2.77:1)。已按「文字用 strong / 实心底用 solid / 渐变按是否压字分流」三类语义收敛。</li>
<li><b>字重机型不一致</b>:30 处 550/650/750 在 Android 无对应字重会回退,同一设计在不同机型上粗细不一致。</li>
</ul>
</div>
<div class="panel">
<h2>六、验证结果</h2>
<p class="lead">以下为实际执行结果,非声明性描述。</p>
<div class="gate">
<div class="g"><div class="n">18 / 18</div><div class="l">UI 契约门禁全部通过(源码结构 + 关键行为断言)</div></div>
<div class="g"><div class="n">0</div><div class="l">未定义的 token 引用(扫描 88 个 wxss / 131 个 token)</div></div>
<div class="g"><div class="n">88 / 88</div><div class="l">wxss 文件通过声明级体检(无粘连、无空规则、括号平衡)</div></div>
<div class="g"><div class="n">0</div><div class="l">app.wxss 之外的硬编码颜色(56 个 hex 全部是 token 定义)</div></div>
<div class="g"><div class="n">0</div><div class="l">非百位字重 / 非装饰类不可读字号</div></div>
<div class="g"><div class="n">88 / 88</div><div class="l">文件编码统一为 UTF-8 无 BOM(与仓库既有约定一致)</div></div>
</div>
<div class="warnbox" style="margin-top:16px">
<b>尚未验证的部分(不作已验收)</b><br />
· 本页是<b>静态还原</b>,用真实 token 值复刻布局,不是小程序真机截图;动态横幅轮播、真实数据、字号在真实设备上的渲染需要在小程序开发者工具里目视确认。<br />
· 氛围层用到的 <code>background</code> 多层渐变与 <code>radial-gradient</code> 需要在真机(iOS / Android 各一台)复验,尤其确认低端机上不出现色带。<br />
· 布局类改动(首页重做、标题热区加高、触摸目标统一为 44px)尚无自动化视觉回归,需要人工过一遍主要页面。
</div>
</div>
<footer>
善缘丹阳公益婚恋服务平台 · 小程序设计系统 v2 · 生成于 2026-09-22 · 基线 main @ 825fd41
</footer>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

File diff suppressed because it is too large Load Diff
+44 -6
View File
@@ -1,11 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<mirrors>
<mirror>
<!--
本地构建必须走 aliyun 镜像:本机 Maven Central(repo1.maven.org)被 DNS 投毒,
TLS 握手被中断("Remote host terminated the handshake"),直连 central 必然失败。
aliyun 作为主源、central 作为兜底(仅当 aliyun 缺失某构件时才回退,正常构件不会触达 central)。
CI runner 能直连 central,但 aliyun 同样可达,故该配置对 CI 也无害。
-->
<profiles>
<profile>
<id>aliyun-with-central-fallback</id>
<repositories>
<repository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<repository>
<id>central</id>
<mirrorOf>central</mirrorOf>
<name>Maven Central</name>
<url>https://repo1.maven.org/maven2</url>
</mirror>
</mirrors>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>aliyun-plugin-public</id>
<name>Aliyun Plugin Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>central-plugin</id>
<name>Maven Central Plugins</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>aliyun-with-central-fallback</activeProfile>
</activeProfiles>
</settings>
+2 -2
View File
@@ -13,9 +13,9 @@
<groupId>com.danyang.match</groupId>
<artifactId>danyang-match-platform</artifactId>
<version>1.0.0-RC.1</version>
<version>1.1.0</version>
<name>Danyang Match Platform</name>
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
<properties>
<java.version>21</java.version>
@@ -1,5 +1,8 @@
package com.danyang.match.platform.shared.dto;
import com.danyang.match.platform.shared.policy.MatchingPolicy;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@@ -11,8 +14,12 @@ import java.util.Map;
*/
@RestController
@RequestMapping("/app")
@RequiredArgsConstructor
public class AppController {
private final ProfilePolicy profilePolicy;
private final MatchingPolicy matchingPolicy;
/**
* 获取应用配置
* GET /api/v1/app/config
@@ -36,13 +43,17 @@ public class AppController {
config.put("features", features);
// 业务限制参数
// 注:maxProfilePhotos / matchScoreThreshold 改由统一配置源(ProfilePolicy / MatchingPolicy)
// 下发,禁止在此写死(PROFILE-001 / MATCH-001)。其余项(dailyRecommendationLimit、
// maxInterestTags、selfIntroMaxLength、recommendationCooldownDays)本轮未纳入,
// 仍为硬编码,已在 config-drift-audit 中登记为「待统一」未来项。
Map<String, Object> businessLimits = new HashMap<>();
businessLimits.put("dailyRecommendationLimit", 6);
businessLimits.put("maxProfilePhotos", 6);
businessLimits.put("maxProfilePhotos", profilePolicy.getMaxPhotoCount());
businessLimits.put("maxInterestTags", 8);
businessLimits.put("selfIntroMaxLength", 500);
businessLimits.put("recommendationCooldownDays", 180);
businessLimits.put("matchScoreThreshold", 60);
businessLimits.put("matchScoreThreshold", matchingPolicy.getRecommendationMinScore());
config.put("businessLimits", businessLimits);
// 审核相关
@@ -0,0 +1,52 @@
package com.danyang.match.platform.account.controller;
import com.danyang.match.platform.account.service.DataRetentionService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 注销数据到期处置(Operator 端,2026-09-19 数据保留决策落地)。
*
* <p>权限 {@code account.retention.purge}(V049)仅授予 super_admin ——
* 处置不可逆,不做批量授权。流程为「人工审核」模式:先查到期清单,
* 运营确认后逐个执行;保留期内(产品确认下限 36 个月)的账号服务端拒绝处置。
*/
@RestController
@RequestMapping("/operator/account/retention")
@RequiredArgsConstructor
public class AccountRetentionOperatorController {
private final DataRetentionService dataRetentionService;
/**
* 保留期已满的注销账号清单
* GET /api/v1/operator/account/retention/due
*/
@GetMapping("/due")
@PreAuthorize("hasAuthority('account.retention.purge')")
public ApiResponse<List<Map<String, Object>>> dueAccounts() {
return ApiResponse.ok(dataRetentionService.listDueAccounts());
}
/**
* 执行到期匿名化清除(幂等)
* POST /api/v1/operator/account/retention/{userId}/purge
*/
@PostMapping("/{userId}/purge")
@PreAuthorize("hasAuthority('account.retention.purge')")
public ApiResponse<Map<String, Object>> purge(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String userId) {
if (principal == null) {
return ApiResponse.unauthorized();
}
return ApiResponse.ok(dataRetentionService.purgeExpired(userId, principal.id()));
}
}
@@ -10,6 +10,7 @@ import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.PrivacyService;
import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.constant.Constants;
@@ -38,6 +39,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 账号生命周期服务
@@ -68,6 +71,7 @@ public class AccountLifecycleService {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final PrivacyService privacyService;
private final ActivityRegistrationMapper registrationMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ReportMapper reportMapper;
@@ -83,6 +87,7 @@ public class AccountLifecycleService {
private final ChatService chatService;
private final UserIdentityMapper userIdentityMapper;
private final CounselingFollowupMapper counselingFollowupMapper;
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
// ==================== 退出婚恋服务(暂停推荐) ====================
@@ -94,7 +99,7 @@ public class AccountLifecycleService {
public Map<String, Object> withdrawDatingService(String userId, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
// 检查是否有进行中的Match(不能直接退出)
@@ -132,7 +137,7 @@ public class AccountLifecycleService {
public Map<String, Object> resumeDatingService(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
user.setRecommendationEnabled(true);
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
@@ -159,13 +164,14 @@ public class AccountLifecycleService {
public Map<String, Object> checkCanWithdraw(String userId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
Map<String, Object> blockers = new HashMap<>();
// 检查是否有进行中的红娘服务
long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper
// 检查是否有进行中的红娘服务(2e 接线:原硬编码 0,blocker 从未生效)。
// 状态口径 = 生产 chk_matchmaker_status 白名单的非终态值(submitted/assigned/in_service/waiting_user)。
long activeMatchmakerServices = matchmakerServiceMapper.countActiveByUserId(userId);
if (activeMatchmakerServices > 0) {
blockers.put("matchmakerService", "您有进行中的红娘服务,请先完成或取消");
}
@@ -243,7 +249,7 @@ public class AccountLifecycleService {
public Map<String, Object> closeAccount(String userId, String operatorId, String reason) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
// 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。
@@ -268,8 +274,8 @@ public class AccountLifecycleService {
// 2b. 通知所有活跃 Match 对方
notifyActiveMatches(user.getId(), "account_closed");
// 2c. 通知红娘(如果有)
notifyMatchmaker(user.getId());
// 2c. 终止残留红娘服务(竞态兜底,见方法注释;不能向 operator 发通知——Phase 18 D2 FK 教训)
terminateActiveMatchmakerServices(user.getId());
// 2d. 取消所有未开始的活动报名
cancelPendingActivityRegistrations(userId);
@@ -294,6 +300,9 @@ public class AccountLifecycleService {
// 4. 记录退出时间
Instant now = Instant.now();
// V049:注销时间落库 —— 数据保留到期(closed_at + account.data-retention-months,
// 产品 2026-09-19 确认下限 36 个月)由此起算;此前只存在于日志,保留期无法计算。
user.setClosedAt(now);
user.setUpdatedAt(now);
userMapper.updateById(user);
@@ -325,7 +334,7 @@ public class AccountLifecycleService {
public Map<String, Object> withdrawConsent(String userId, String consentType) {
ConsentRecord record = consentRecordMapper.findByUserAndType(userId, consentType).orElse(null);
if (record == null) {
throw new RuntimeException("未找到该同意记录");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该同意记录");
}
record.setAgreed(false);
consentRecordMapper.updateById(record);
@@ -342,26 +351,57 @@ public class AccountLifecycleService {
// ==================== 隐私设置 ====================
/**
* 获取隐私设置
* 小程序 privacy-settings 页的契约键集(产品裁定:隐私设置键集<b>以小程序为准</b>)。
* 用于入参校验:未知键宽容忽略,但<b>已知键类型错误必须显式报错</b> ——
* 若静默丢弃,用户会看到"保存成功"而实际未生效,这类缺陷最难排查。
*/
private static final Set<String> PRIVACY_KEYS =
Set.of("visibleInRecommendations", "allowActivityInvitation", "showLastOnline");
/**
* 获取隐私设置。
*
* <p>原为 TODO 桩:永远返回写死的默认值、<b>不读库</b>,用户改过的设置读不回来。
* 现委托 {@link PrivacyService} 读真实 {@code user_privacy_settings}。
*/
public Map<String, Object> getPrivacySettings(String userId) {
// TODO: Phase 21 实现 user_privacy_settings 查询
Map<String, Object> settings = new HashMap<>();
settings.put("visibleInRecommendations", true);
settings.put("allowActivityInvitation", true);
settings.put("showLastOnline", false);
return settings;
return new HashMap<>(privacyService.getPrivacySettings(userId));
}
/**
* 更新隐私设置
* 更新隐私设置。
*
* <p>原为 TODO 桩:只回 {@code {updated:true}}、<b>不落库</b>,
* 用户看到"保存成功"后刷新即丢失。现委托 {@link PrivacyService} 真实 upsert。
*/
@Transactional
public Map<String, Object> updatePrivacySettings(String userId, Map<String, Object> settings) {
// TODO: Phase 21 实现 user_privacy_settings 更新
Map<String, Object> result = new HashMap<>();
result.put("updated", true);
return result;
return new HashMap<>(privacyService.updatePrivacySettings(userId, toBooleanMap(settings)));
}
private Map<String, Boolean> toBooleanMap(Map<String, Object> settings) {
Map<String, Boolean> typed = new HashMap<>();
if (settings == null) {
return typed;
}
for (Map.Entry<String, Object> e : settings.entrySet()) {
String key = e.getKey();
Object value = e.getValue();
if (value == null) {
continue;
}
if (value instanceof Boolean b) {
typed.put(key, b);
} else if (value instanceof String s
&& ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s))) {
// 容错:部分客户端会把 boolean 序列化成字符串
typed.put(key, Boolean.valueOf(s));
} else if (PRIVACY_KEYS.contains(key)) {
throw new IllegalArgumentException("隐私设置字段类型错误: " + key);
}
// 未知键:宽容忽略
}
return typed;
}
// ==================== 私有辅助方法 ====================
@@ -392,11 +432,34 @@ public class AccountLifecycleService {
}
/**
* 通知红娘服务已终止
* 注销收尾(2e 接线,原 {@code notifyMatchmaker} 为 TODO 空转):把该用户残留的非终态
* 红娘服务置为 cancelled。
*
* <p>正常路径下 {@code checkCanWithdraw} 的 blocker 会要求用户先完成/取消红娘服务,
* 走到注销时不应再有非终态记录 —— 本方法是<b>竞态兜底</b>(blocker 检查与注销事务之间,
* 红娘管理员恰好受理了新服务)。
*
* <p><b>为什么不是「发通知」</b>:Phase 18 D2 的教训 —— {@code notifications.user_id → users(id)}
* 外键下,operator 不在 users 表,向红娘发用户通知在真实 PG 上必然使注销事务回滚。
* 因此以状态收尾 + 日志代替「通知」,效果更实质且不会炸注销事务。
*/
private void notifyMatchmaker(String userId) {
// TODO: 查询该用户的红娘服务,通知相关红娘
log.info("通知红娘服务终止: userId={}", userId);
private void terminateActiveMatchmakerServices(String userId) {
List<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord> active =
matchmakerServiceMapper.selectList(
new LambdaQueryWrapper<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord>()
.eq(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getUserId, userId)
.in(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getStatus,
Constants.MATCHMAKER_STATUS_SUBMITTED,
Constants.MATCHMAKER_STATUS_ASSIGNED,
Constants.MATCHMAKER_STATUS_IN_SERVICE,
Constants.MATCHMAKER_STATUS_WAITING_USER)
.isNull(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getDeletedAt));
for (com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord svc : active) {
svc.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED);
svc.setUpdatedAt(Instant.now());
matchmakerServiceMapper.updateById(svc);
log.info("注销收尾: 残留红娘服务已终止 userId={}, serviceId={}", userId, svc.getId());
}
}
/**
@@ -0,0 +1,181 @@
package com.danyang.match.platform.account.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.base.BaseEntity;
import com.danyang.match.platform.shared.config.AccountConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
/**
* 注销数据到期处置(批 3 收尾:数据保留流程,2026-09-19 产品决策落地)。
*
* <h3>决策与口径</h3>
* 产品 2026-09-19 确认:注销后用户数据保留<b>不得低于 36 个月</b>(隐私政策 §3.3 原文
* 「30 天内删除」与本决策冲突,已按用户最新明确指令同步修订政策文本)。
* 保留期经 {@link AccountConfig#getDataRetentionMonths()} 配置化(AGENTS.md #12),
* 到期 = {@code closed_at + retentionMonths}。
*
* <h3>「配置 + 人工审核」而非自动作业</h3>
* 计划 Phase 21 的既定要求是「数据保留期限未确认的敏感材料采用配置和人工审核,
* 不擅自永久保存或删除」。因此本服务只提供<b>人工审核的处置工具</b>:
* {@code listDueAccounts()} 列出保留期已满的注销账号,由持
* {@code account.retention.purge} 权限(V049,仅授 super_admin)的运营确认后
* 调用 {@code purgeExpired()} 执行 —— <b>不引入定时自动删除</b>。
*
* <h3>处置内容(断开 PII 关联,保留统计与审计所需最小信息)</h3>
* <ol>
* <li>{@code users.wechat_openid} → {@code purged-<userId>}(不可再登录、不可回连微信身份;
* phone 清空。列有 UNIQUE 约束,purged 前缀 + userId 保证唯一);</li>
* <li>{@code user_profiles}:displayName → 「已注销用户」、selfIntro 清空
* (性别/年龄段等非直接标识符保留,供匿名统计);</li>
* <li>{@code user_photos} / {@code local_eligibility_evidences}:全部软删;</li>
* <li>审计:{@code audit_logs} 记 {@code DATA_RETENTION_PURGE}。</li>
* </ol>
*
* <p><b>如实登记的边界</b>:佐证/照片的<b>对象存储文件本体</b>不在此处删除
* (需要对象存储客户端与逐文件凭证),由对象存储生命周期规则兜底 —— 登记于
* {@code docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md} 批 3。
* 咨询摘要/正文已在<b>注销时</b>脱敏({@code anonymizeServiceHistory}),无需重复处理。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataRetentionService {
static final String PURGED_OPENID_PREFIX = "purged-";
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final UserPhotoMapper userPhotoMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final LocalEligibilityEvidenceMapper evidenceMapper;
private final AuditLogMapper auditLogMapper;
private final AccountConfig accountConfig;
/** 保留到期时点:closed_at + retentionMonths(按 UTC 日历月计算,非 30 天近似) */
public Instant retentionDueAt(Instant closedAt) {
return closedAt.atZone(ZoneOffset.UTC).plusMonths(accountConfig.getDataRetentionMonths())
.toInstant();
}
/** 保留期已满、待人工审核处置的注销账号清单 */
public List<Map<String, Object>> listDueAccounts() {
Instant now = Instant.now();
List<User> closed = userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getAccountStatus, Constants.ACCOUNT_STATUS_CLOSED)
.isNotNull(User::getClosedAt)
.isNull(BaseEntity::getDeletedAt));
List<Map<String, Object>> due = new ArrayList<>();
for (User user : closed) {
Instant dueAt = retentionDueAt(user.getClosedAt());
if (dueAt.isAfter(now)) {
continue; // 仍在保留期内 —— 产品确认的下限,不得提前处置
}
boolean alreadyPurged = user.getWechatOpenid() != null
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX);
Map<String, Object> row = new HashMap<>();
row.put("userId", user.getId());
row.put("closedAt", user.getClosedAt());
row.put("retentionMonths", accountConfig.getDataRetentionMonths());
row.put("retentionDueAt", dueAt);
row.put("purged", alreadyPurged);
due.add(row);
}
return due;
}
/**
* 执行到期匿名化清除(人工审核后调用)。
*
* @return 处置结果;{@code purged=true} 表示此前已处置过(幂等重放)
*/
@Transactional
public Map<String, Object> purgeExpired(String userId, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
if (!Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
throw new IllegalArgumentException("仅限已注销账号执行到期处置");
}
if (user.getClosedAt() == null || retentionDueAt(user.getClosedAt()).isAfter(Instant.now())) {
throw new IllegalArgumentException("该账号仍在保留期内(下限 "
+ accountConfig.getDataRetentionMonths() + " 个月),不得提前处置");
}
Map<String, Object> result = new HashMap<>();
if (user.getWechatOpenid() != null
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX)) {
result.put("purged", true);
result.put("alreadyPurged", true);
return result; // 幂等:重复调用不产生第二次审计与写入
}
// 1. 断开账号级直接标识符。
// ⚠️ 必须用 LambdaUpdateWrapper.set(..., null):MyBatis-Plus 默认 FieldStrategy=NOT_NULL,
// updateById 时 phone=null 不会进 SQL(守卫实测:清 phone 断言失败,旧值仍在库)。
userMapper.update(null, new LambdaUpdateWrapper<User>()
.eq(User::getId, userId)
.set(User::getWechatOpenid, PURGED_OPENID_PREFIX + userId)
.set(User::getPhone, null));
// 2. 资料直接标识字段匿名化(selfIntro 置 null 同理须显式 set)
userProfileMapper.findByUserId(userId).ifPresent(profile ->
userProfileMapper.update(null, new LambdaUpdateWrapper<UserProfile>()
.eq(UserProfile::getUserId, userId)
.set(UserProfile::getDisplayName, "已注销用户")
.set(UserProfile::getSelfIntro, null)));
// 3. 照片与本地资格佐证软删(文件本体由对象存储生命周期规则兜底,见类注释边界)。
// 佐证表无 user_id 直连列,须经 user_local_eligibilities 关联。
Instant now = Instant.now();
for (UserPhoto photo : userPhotoMapper.selectList(
new LambdaQueryWrapper<UserPhoto>().eq(UserPhoto::getUserId, userId))) {
photo.setDeletedAt(now);
userPhotoMapper.updateById(photo);
}
localEligibilityMapper.findByUserId(userId).ifPresent(el -> {
for (LocalEligibilityEvidence evidence : evidenceMapper.selectList(
new LambdaQueryWrapper<LocalEligibilityEvidence>()
.eq(LocalEligibilityEvidence::getLocalEligibilityId, el.getId()))) {
evidence.setDeletedAt(now);
evidenceMapper.updateById(evidence);
}
});
// 4. 审计(不可逆操作必须留痕)
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction("DATA_RETENTION_PURGE");
auditLog.setResourceType("user");
auditLog.setResourceId(userId);
auditLogMapper.insert(auditLog);
log.info("注销数据到期处置完成: userId={}, operatorId={}, retentionMonths={}",
userId, operatorId, accountConfig.getDataRetentionMonths());
result.put("purged", true);
result.put("alreadyPurged", false);
return result;
}
}
@@ -9,7 +9,7 @@ import com.danyang.match.platform.activity.entity.ActivityInterest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
@@ -30,7 +30,8 @@ import java.util.Map;
public class ActivityInterestController {
private final ActivityInterestService interestService;
private final RelationshipService relationshipService;
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
private final ActivityMutualConsentService consentService;
private final ActivityMapper activityMapper;
@GetMapping("/operator/activity-interest-summary")
@@ -105,7 +106,12 @@ public class ActivityInterestController {
/**
* 确认开启线上沟通(V1.1 Phase 08)
* POST /api/v1/activities/{id}/interests/confirm
* 双方确认后创建 Match Episode,不自动创建 Conversation
*
* <p><b>2026-09-23 UAT 走查修复(INV-014/015)</b>:此前本端点仅校验「双向互选」便直调
* {@code RelationshipService.confirmActivityInterest} —— 那是「建 Match」的底层原语
* (不做任何同意校验),于是**单方调用即可建 Match/会话**,绕过「双方各自显式确认」
* 的二阶段同意。现改为与 {@code /activities/{id}/mutual/*} 走**同一条**二阶段链路:
* 本端点只登记「我方确认」,只有双方都 accepted 时才建 Match/会话。
*/
@Idempotent
@PostMapping("/{id}/interests/confirm")
@@ -120,13 +126,16 @@ public class ActivityInterestController {
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"partnerId不能为空");
}
// 验证互选状态
// 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
"您与该用户未形成活动双向互选");
}
return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId));
// 二阶段同意:先幂等确保同意记录存在,再仅提交「我方确认」。
// 单方调用不再可能创建 Match(INV-014/015)。
consentService.createMutualConsent(id, userId, partnerId);
return ApiResponse.ok(consentService.confirmConnection(userId, id, partnerId));
}
/**
@@ -24,6 +24,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 活动互选服务
@@ -59,17 +62,17 @@ public class ActivityInterestService {
// 1. 验证活动已结束
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new RuntimeException("活动不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
}
if (!Constants.ACTIVITY_STATUS_ENDED.equals(activity.getStatus())) {
throw new RuntimeException("活动尚未结束,暂不支持反馈");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动尚未结束,暂不支持反馈");
}
// 2. 验证用户已报名且通过审核
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未通过审核,无法提交反馈");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
}
// 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己)
@@ -30,6 +30,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -110,9 +113,9 @@ public class ActivityService {
@Transactional
public Activity publishActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许发布");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许发布");
}
activity.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN);
if (activity.getRegistrationStartAt() == null) {
@@ -129,10 +132,10 @@ public class ActivityService {
@Transactional
public Activity cancelActivity(String activityId, String operatorId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus()) &&
!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("当前状态不允许取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许取消");
}
activity.setStatus(Constants.ACTIVITY_STATUS_CANCELLED);
activityMapper.updateById(activity);
@@ -220,9 +223,9 @@ public class ActivityService {
String emergencyContactName,
String emergencyContactPhoneEnc) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) throw new RuntimeException("活动不存在");
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("活动未开放报名或已结束");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开放报名或已结束");
}
// Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at
// 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。
@@ -237,7 +240,7 @@ public class ActivityService {
if (existing.isPresent()
&& !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus())
&& !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) {
throw new RuntimeException("已经报名过该活动");
throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "已经报名过该活动");
}
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
@@ -265,7 +268,7 @@ public class ActivityService {
if (registrationMapper.reuseIfCapacityAvailable(
old.getId(), activityId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
@@ -294,7 +297,7 @@ public class ActivityService {
if (registrationMapper.insertIfCapacityAvailable(
newId, activityId, userId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
@@ -334,9 +337,9 @@ public class ActivityService {
@Transactional
public ActivityRegistration approveRegistration(String registrationId, String operatorId) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核通过");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核通过");
}
// Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动
@@ -355,7 +358,7 @@ public class ActivityService {
}
if (affected == 0) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满或状态已变更,无法通过审核");
}
// 回读事务内最新状态(条件更新不回填实体)
registration = registrationMapper.selectById(registrationId);
@@ -376,16 +379,16 @@ public class ActivityService {
@Transactional
public ActivityRegistration rejectRegistration(String registrationId, String operatorId, String reason) {
ActivityRegistration registration = registrationMapper.selectById(registrationId);
if (registration == null) throw new RuntimeException("报名记录不存在");
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核");
}
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
// Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时
// 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。
if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法拒绝");
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法拒绝");
}
registration = registrationMapper.selectById(registrationId);
@@ -405,14 +408,14 @@ public class ActivityService {
@Transactional
public ActivityRegistration cancelRegistration(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不能取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
}
// 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额
if (registrationMapper.cancelIfActive(registration.getId()) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法取消");
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法取消");
}
// 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退)
if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
@@ -721,7 +724,7 @@ public class ActivityService {
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
}
if (!verified) {
throw new RuntimeException("该活动需完成实名认证后方可报名");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需完成实名认证后方可报名");
}
}
@@ -733,7 +736,7 @@ public class ActivityService {
log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage());
}
if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) {
throw new RuntimeException("该活动需通过本地资格核验后方可报名");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需通过本地资格核验后方可报名");
}
}
}
@@ -746,10 +749,10 @@ public class ActivityService {
if (birth == null) return; // 资料不全时不阻断报名
int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
if (activity.getMinAge() != null && age < activity.getMinAge()) {
throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄未满 " + activity.getMinAge() + " 周岁");
}
if (activity.getMaxAge() != null && age > activity.getMaxAge()) {
throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
}
}
@@ -782,10 +785,10 @@ public class ActivityService {
private void assertWithinRegistrationWindow(Activity activity) {
Instant now = Instant.now();
if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) {
throw new RuntimeException("活动报名尚未开始");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名尚未开始");
}
if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) {
throw new RuntimeException("活动报名已截止");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名已截止");
}
}
@@ -835,7 +838,7 @@ public class ActivityService {
String groupCode = resolveCapacityGroupCode(activityId, gender);
if (groupCode == null) return null;
if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) {
throw new RuntimeException("该分组名额已满");
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "该分组名额已满");
}
return groupCode;
}
@@ -26,6 +26,10 @@ import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* 签到服务
@@ -61,21 +65,21 @@ public class CheckinService {
*/
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名尚未审核通过,无法签到");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
}
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("您已经签到过了");
throw new BusinessException(ApiResponseCode.ALREADY_CHECKED_IN, "您已经签到过了");
}
Activity activity = requireCheckableActivity(activityId);
Instant now = Instant.now();
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
throw new RuntimeException("活动未开始或已结束,无法签到");
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开始或已结束,无法签到");
}
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
@@ -290,7 +294,7 @@ public class CheckinService {
byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
throw new RuntimeException("签名生成失败", e);
throw new ServerErrorException("签名生成失败", e);
}
}
@@ -1,17 +1,23 @@
package com.danyang.match.platform.activity_mutual.service;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.config.ActivityConfig;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
@Slf4j
@Service
@@ -20,6 +26,8 @@ public class ActivityMutualConsentService {
private final ActivityMutualConsentMapper consentMapper;
private final RelationshipService relationshipService;
private final ActivityMapper activityMapper;
private final ActivityConfig activityConfig;
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
@@ -44,6 +52,15 @@ public class ActivityMutualConsentService {
}
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
// P16-T13 接线:写入 expires_at,否则 confirmSideIfPending 的
// `(expires_at IS NULL OR expires_at > NOW())` 过滤因恒 NULL 而永不生效,
// 「截止后 48 小时」窗口(PENDING_DECISIONS 已确认口径)形同虚设。
// 基准点 = 活动结束时间(ActivityConfig.mutualDeadlineHours 注释的既定语义),
// 活动不存在或无结束时间时回退为创建时刻。
Instant deadlineBase = Optional.ofNullable(activityMapper.selectById(activityId))
.map(Activity::getEndAt)
.orElseGet(Instant::now);
consent.setExpiresAt(deadlineBase.plus(Duration.ofHours(activityConfig.getMutualDeadlineHours())));
consentMapper.insert(consent);
}
return consent;
@@ -52,7 +69,7 @@ public class ActivityMutualConsentService {
@Transactional
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
@@ -61,7 +78,7 @@ public class ActivityMutualConsentService {
// 回读权威状态(可能受并发另一方提交影响)
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("互选记录已不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
@@ -105,7 +122,7 @@ public class ActivityMutualConsentService {
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
} else {
@@ -21,7 +21,9 @@ import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.shared.exception.BusinessException;
// 必须是 GlobalExceptionHandler.BusinessException —— shared.exception.BusinessException
// 此前是另一个未映射的类,抛出来直落 500(days 越界本该 400)。见该类 javadoc 的修复记录。
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
@@ -252,7 +254,7 @@ public class AnalyticsService {
public List<Map<String, Object>> getDailySummary(int days) {
if (days < 1 || days > 365) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "days 必须在 1~365 之间");
}
List<Map<String, Object>> summary = new ArrayList<>();
LocalDate today = LocalDate.now();
@@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 申诉服务
@@ -97,29 +99,29 @@ public class AppealService {
public Map<String, Object> submitAppeal(String userId, AppealSubmitDTO dto) {
// FeatureFlag 检查
if (!featureFlagConfig.isAppealEnabled()) {
throw new RuntimeException("申诉功能暂未开放");
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "申诉功能暂未开放");
}
if (dto == null || dto.getRelatedActionId() == null || dto.getRelatedActionId().isBlank()) {
throw new RuntimeException("缺少关联的处置记录");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少关联的处置记录");
}
// 验证关联的 risk_action 存在且仍有效
RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId());
if (targetAction == null) {
throw new RuntimeException("关联的处置记录不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联的处置记录不存在");
}
if (!Constants.RISK_ACTION_STATUS_ACTIVE.equals(targetAction.getStatus())) {
throw new RuntimeException("该处置已失效,无法申诉");
throw new BusinessException(ApiResponseCode.CONFLICT, "该处置已失效,无法申诉");
}
if (!userId.equals(targetAction.getUserId())) {
throw new RuntimeException("无权对此处置提交申诉");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权对此处置提交申诉");
}
// 必须是 ban 或 suspend 类型
if (!Constants.RISK_ACTION_BAN.equals(targetAction.getActionType()) &&
!Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(targetAction.getActionType())) {
throw new RuntimeException("当前处置类型不支持申诉");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "当前处置类型不支持申诉");
}
// Phase 18:校验 appealType 白名单(生产 chk_appeal_type),并必须与处置类型一致。
@@ -132,16 +134,16 @@ public class AppealService {
}
if (!Constants.APPEAL_TYPE_BAN.equals(appealType) &&
!Constants.APPEAL_TYPE_SUSPEND.equals(appealType)) {
throw new RuntimeException("无效的申诉类型");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉类型");
}
if (!expectedType.equals(appealType)) {
throw new RuntimeException("申诉类型与处置类型不匹配");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "申诉类型与处置类型不匹配");
}
// 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉
Optional<Appeal> existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId());
if (existing.isPresent()) {
throw new RuntimeException("您已就此处置提交过申诉,请等待处理结果");
throw new BusinessException(ApiResponseCode.CONFLICT, "您已就此处置提交过申诉,请等待处理结果");
}
Appeal appeal = new Appeal();
@@ -190,7 +192,7 @@ public class AppealService {
public AppealItemDTO getAppealDetail(String appealId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null || appeal.getDeletedAt() != null) {
throw new RuntimeException("申诉不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
}
return toDTO(appeal);
}
@@ -202,10 +204,10 @@ public class AppealService {
public Map<String, Object> processAppeal(String appealId, AppealProcessDTO dto, String operatorId) {
Appeal appeal = appealMapper.selectById(appealId);
if (appeal == null || appeal.getDeletedAt() != null) {
throw new RuntimeException("申诉不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
}
if (dto == null || dto.getDecision() == null) {
throw new RuntimeException("无效的申诉决策");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
}
// Phase 18:原子「认领」——只有把申诉从可处理态(pending/reviewing/escalated)
@@ -213,7 +215,7 @@ public class AppealService {
// 同一申诉时,只有一个赢家能继续,另一个直接失败,避免 approve/reject 相互覆盖。
int claimed = appealMapper.markReviewingIfProcessable(appealId);
if (claimed != 1) {
throw new RuntimeException("该申诉已处理完毕");
throw new BusinessException(ApiResponseCode.CONFLICT, "该申诉已处理完毕");
}
// 回读最新状态(status 已为 reviewing)
@@ -224,7 +226,7 @@ public class AppealService {
case "approve" -> approveAppeal(fresh, dto.getDecisionNote(), operatorId);
case "reject" -> rejectAppeal(fresh, dto.getDecisionNote(), operatorId);
case "escalate" -> escalateAppeal(fresh, dto.getDecisionNote(), operatorId);
default -> throw new RuntimeException("无效的申诉决策");
default -> throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
}
saveAuditLog(operatorId, "PROCESS_APPEAL", "appeal", appealId, null, null);
@@ -1,6 +1,8 @@
package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.service.AuthService;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -26,8 +28,7 @@ public class AuthController {
public ApiResponse<Map<String, Object>> wechatLogin(@RequestBody Map<String, String> body) {
String code = body.get("code");
if (code == null || code.isBlank()) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "code 不能为空");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "code 不能为空");
}
return ApiResponse.ok(authService.wechatLogin(code));
}
@@ -44,8 +45,7 @@ public class AuthController {
@RequestBody Map<String, String> body) {
String phone = body.get("phone");
if (phone == null || phone.isBlank()) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "手机号不能为空");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
}
authService.bindPhone(principal.id(), phone);
return ApiResponse.ok();
@@ -54,17 +54,19 @@ public class AuthController {
/**
* 刷新 Token
* POST /api/v1/auth/refresh
* Body: { "userId": "...", "refreshToken": "..." }
* Body: { "refreshToken": "..." },userId 可选
*
* <p>userId 不再是必填:refresh token 的 subject 就是 userId,令牌本身自证身份。
* 此前强制要求客户端重复传 userId,漏传即 400 —— 小程序 2026-09-22 的
* 首页 401 事故正是被这个 400 放大的。显式传入时仍会被校验与令牌主体一致。
*/
@PostMapping("/refresh")
public ApiResponse<Map<String, Object>> refreshToken(@RequestBody Map<String, String> body) {
String userId = body.get("userId");
String refreshToken = body.get("refreshToken");
if (userId == null || refreshToken == null) {
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "参数缺失");
if (refreshToken == null || refreshToken.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "refreshToken 不能为空");
}
return ApiResponse.ok(authService.refreshToken(userId, refreshToken));
return ApiResponse.ok(authService.refreshToken(body.get("userId"), refreshToken));
}
/**
@@ -1,8 +1,13 @@
package com.danyang.match.platform.auth.controller;
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
import com.danyang.match.platform.auth.service.OperatorAuthService;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -17,14 +22,83 @@ import java.util.Map;
public class OperatorAuthController {
private final OperatorAuthService operatorAuthService;
private final OperatorCookieWriter cookieWriter;
private final JwtTokenService jwtTokenService;
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) {
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
HttpServletRequest request, HttpServletResponse response) {
String username = body.get("username");
String password = body.get("password");
if (username == null || username.isBlank() || password == null || password.isBlank()) {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
}
return ApiResponse.ok(operatorAuthService.login(username, password));
Map<String, Object> result = operatorAuthService.login(username, password);
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌
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;
/**
* Mock 微信登录 Provider(开发/测试环境使用)
* Mock 微信登录 Provider(开发/测试环境使用)。
*
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
*/
@Component
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
@@ -0,0 +1,100 @@
package com.danyang.match.platform.auth.provider;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 真实微信登录 Provider(生产环境使用)。
*
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
*
* <p>安全要点:
* <ul>
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
* <li>绝不把 appSecret / session_key 回传给前端。</li>
* </ul>
*/
@Component
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
public class RealWechatLoginProvider implements WechatLoginProvider {
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
@Value("${wechat.app-id:}")
private String appId;
@Value("${wechat.app-secret:}")
private String appSecret;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Map<String, String> getCodeSession(String code) {
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
}
if (code == null || code.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
}
String url = CODE2SESSION_URL
+ "?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
+ "&grant_type=authorization_code";
try {
String resp = restTemplate.getForObject(url, String.class);
JsonNode node = objectMapper.readTree(resp);
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
}
String openid = node.has("openid") ? node.get("openid").asText() : null;
if (openid == null || openid.isBlank()) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
Map<String, String> result = new HashMap<>();
result.put("openid", openid);
result.put("unionid", unionid);
result.put("session_key", sessionKey);
result.put("appid", appId);
return result;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("[Wechat] code2session 调用异常", e);
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
}
}
@Override
public boolean isMock() {
return false;
}
}
@@ -7,6 +7,9 @@ import com.danyang.match.platform.auth.provider.WechatLoginProvider;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.capability.service.CapabilityService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.UnauthorizedException;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
@@ -27,6 +30,7 @@ import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -58,7 +62,7 @@ public class AuthService {
String unionid = session.get("unionid");
if (openid == null || openid.isBlank()) {
throw new RuntimeException("微信登录失败:无法获取 openid");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
}
// 查找或创建用户
@@ -87,7 +91,9 @@ public class AuthService {
// 将 refreshToken 存入 Redis(用于撤销)
String refreshTokenKey = "auth:refresh_token:" + user.getId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
// 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
// 记录审计日志
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
@@ -108,12 +114,12 @@ public class AuthService {
public void bindPhone(String userId, String phoneCode) {
// TODO: Phase 03 对接真实短信验证码
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
// 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
throw new RuntimeException("该手机号已被其他账号绑定");
throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已被其他账号绑定");
}
});
@@ -126,31 +132,58 @@ public class AuthService {
/**
* 刷新 Token
*
* <p><b>2026-09-22 修复三处缺陷</b>:
* <ol>
* <li>验签用错了密钥:refresh token 由 refresh 密钥签发,此前却调
* {@code validateToken}(用 access 密钥验签)→ 必然失败,
* 于是「刷新」这条路在修好前端字段名之后仍然走不通。改用
* {@link JwtTokenService#validateRefreshToken}。</li>
* <li>失败语义是 500:客户端据此判断为服务端故障并重试,实际应回 401
* 让它停止重试、走重新登录。改抛 {@link UnauthorizedException}。</li>
* <li>userId 被强制要求:refresh token 的 subject 本身就是 userId,
* 让客户端重复传一个它已持有凭据里已包含的信息,只会制造
* 「漏传 → 400」这类无意义失败。改为**以令牌主体为准**,
* 显式传入时仅用于一致性校验。</li>
* </ol>
*/
public Map<String, Object> refreshToken(String userId, String refreshToken) {
// 验证 Redis 中存储的 refresh token
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new RuntimeException("Refresh Token 无效或已过期");
if (refreshToken == null || refreshToken.isBlank()) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 验证 JWT
if (!jwtTokenService.validateToken(refreshToken)) {
throw new RuntimeException("Refresh Token 无效");
// 1. 令牌自证:签名有效(refresh 密钥)、未过期、type=refresh、未被撤销
if (!jwtTokenService.validateRefreshToken(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 2. 用户身份以令牌主体为准;客户端显式传入时必须是同一个用户,
// 否则就是拿 A 的令牌去刷 B 的会话。
String tokenUserId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
if (userId != null && !userId.isBlank() && !userId.equals(tokenUserId)) {
throw new UnauthorizedException("Refresh Token 与用户不匹配");
}
userId = tokenUserId;
// 3. 校验 Redis 中登记的令牌:登出或换发后,旧令牌立即失效(可撤销性)
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效或已过期");
}
// 签发新 Token
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
// 更新 Redis
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
Map<String, Object> result = new HashMap<>();
result.put("accessToken", newAccessToken);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", accessTokenExpireMs / 1000);
result.put("userId", userId);
return result;
}
@@ -170,7 +203,7 @@ public class AuthService {
*/
public Map<String, Object> getMeStatus(String userId) {
User user = userMapper.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
Map<String, Object> status = new HashMap<>();
status.put("userId", user.getId());
@@ -206,13 +239,13 @@ public class AuthService {
private void checkAccountStatus(User user) {
if (Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已注销");
throw new BusinessException(ApiResponseCode.ACCOUNT_CLOSED, "账号已注销");
}
if (Constants.ACCOUNT_STATUS_BANNED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已被封禁");
throw new BusinessException(ApiResponseCode.USER_BANNED, "账号已被封禁");
}
if (Constants.ACCOUNT_STATUS_SUSPENDED.equals(user.getAccountStatus())) {
throw new RuntimeException("账号已暂停");
throw new BusinessException(ApiResponseCode.USER_SUSPENDED, "账号已暂停");
}
}
@@ -25,6 +25,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
@Service
@RequiredArgsConstructor
@@ -144,7 +145,7 @@ public class OperatorAuthService {
*/
public Map<String, Object> getMe(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
List<String> permissionCodes = permissions.stream()
@@ -165,11 +166,25 @@ public class OperatorAuthService {
/**
* 刷新 Operator Token
*
* <p>与 {@link AuthService#refreshToken} 对齐两点(2026-09-22):
* 令牌先自证(签名/有效期/type,用 refresh 密钥),再比对 Redis 登记值;
* 失败回 401 而非 500 —— 否则客户端会把「该重新登录」误判为服务端故障并重试。
*/
public Map<String, Object> refreshToken(String operatorId, String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()
|| !jwtTokenService.validateRefreshToken(refreshToken)) {
throw new UnauthorizedException("Refresh Token 无效");
}
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
if (operatorId == null || operatorId.isBlank()) {
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
}
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
if (storedToken == null || !storedToken.equals(refreshToken)) {
throw new RuntimeException("Refresh Token 无效");
throw new UnauthorizedException("Refresh Token 无效");
}
Map<String, Object> operatorInfo = getMe(operatorId);
@@ -184,6 +199,12 @@ public class OperatorAuthService {
result.put("accessToken", accessToken);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
result.put("operatorId", operatorInfo.get("operatorId"));
result.put("name", operatorInfo.get("name"));
result.put("username", operatorInfo.get("username"));
result.put("organizationId", operatorInfo.get("organizationId"));
result.put("permissions", operatorInfo.get("permissions"));
return result;
}
@@ -10,11 +10,20 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class JwtTokenService {
/**
* Refresh Token 的用途声明值。
*
* <p>校验时必须匹配,见 {@link #validateRefreshToken}:一旦运维把两把密钥配成同一个值,
* 签名校验就无法区分两类令牌,只有这个声明能阻止 access token 被当作 refresh token 续期。
*/
public static final String TOKEN_TYPE_REFRESH = "refresh";
private final TokenBlacklistService tokenBlacklistService;
@Value("${jwt.access-token-secret}")
@@ -46,6 +55,12 @@ public class JwtTokenService {
JwtBuilder builder = Jwts.builder()
.subject(userId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "access")
@@ -67,9 +82,15 @@ public class JwtTokenService {
return Jwts.builder()
.subject(userId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "refresh")
.claim("type", TOKEN_TYPE_REFRESH)
.signWith(getRefreshKey())
.compact();
}
@@ -83,6 +104,12 @@ public class JwtTokenService {
JwtBuilder builder = Jwts.builder()
.subject(operatorId)
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
.id(UUID.randomUUID().toString())
.issuedAt(now)
.expiration(expiry)
.claim("type", "operator_access")
@@ -142,9 +169,67 @@ public class JwtTokenService {
}
/**
* 获取 Token 过期时间
* 验证并解析 **Refresh** Token。
*
* <p><b>必须用 refresh 密钥验签</b>:{@link #generateRefreshToken} 用
* {@code jwt.refresh-token-secret} 签发,而 {@link #parseToken} 用的是
* {@code jwt.access-token-secret}。两把密钥在 dev / test / production 的取值
* 各不相同(见 application-*.yml),因此拿 {@code parseToken} 去验 refresh token
* 必然抛签名异常 —— 这正是 2026-09-22 定位到的「刷新永远失败 → 登录态被清 →
* 全站 401」的根因:前端此前修好了缺 userId 的 400,仍会在这一层必然失败。
*/
public Claims parseRefreshToken(String token) {
return Jwts.parser()
.verifyWith(getRefreshKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 校验 Refresh Token:签名有效、未过期、未被撤销,且 {@code type} 必须为 refresh。
*
* <p>type 断言不是冗余:一旦运维把两把密钥配成同一个值(或都用了默认占位值),
* 签名校验就无法区分两类令牌,access token 会被当作 refresh token 换出新令牌,
* 使「短期凭证」获得「长期凭证」的续期能力。此处把令牌用途钉死在声明里。
*/
public boolean validateRefreshToken(String token) {
if (token == null || token.isBlank()) {
return false;
}
try {
if (tokenBlacklistService.isBlacklisted(token)) {
return false;
}
return TOKEN_TYPE_REFRESH.equals(parseRefreshToken(token).get("type", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 从 Refresh Token 解析用户 ID(用于客户端未传 userId 时自证身份,见 AuthService#refreshToken)。
*/
public String getUserIdFromRefreshToken(String token) {
return parseRefreshToken(token).getSubject();
}
/**
* 获取 Access Token 过期时间(毫秒)
*/
public long getExpirationMs() {
return accessExpirationMs;
}
/**
* 获取 Refresh Token 过期时间(毫秒)。
*
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
*/
public long getRefreshExpirationMs() {
return refreshExpirationMs;
}
}
@@ -1,5 +1,6 @@
package com.danyang.match.platform.counseling.service;
import com.danyang.match.platform.audit.service.AuditLogService;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
@@ -78,6 +79,7 @@ public class CounselingService {
private final NotificationService notificationService;
private final RiskReferralService riskReferralService;
private final DataScopeGuard dataScopeGuard;
private final AuditLogService auditLogService;
// ==================== 用户端:申请 ====================
@@ -264,6 +266,23 @@ public class CounselingService {
fu.setResultCode(resultCode);
fu.setNextFollowupAt(nextFollowupAt);
followupMapper.insert(fu);
// P13A-T33: 随访正文与申请摘要共用同一风险检测(来源同为 source=counseling)。
// 随访由 counselor 撰写、更贴近用户近况,是风险关键词的另一个真实入口;
// 此前只有申请摘要(apply:133)被扫描,随访正文完全绕过风控。
// INV-013 加固同 apply:绝不记录正文,只记级别与记录 ID。
CounselingServiceRecord owner = mapper.findFull(serviceId);
if (owner != null) {
try {
String riskLevel = riskReferralService.detectCounselingRisk(owner.getUserId(), content);
if (riskLevel != null) {
log.info("随访内容触发风险检测: serviceId={}, followupId={}, riskLevel={}",
serviceId, fu.getId(), riskLevel);
}
} catch (Exception e) {
log.warn("随访风险检测异常: serviceId={}, err={}", serviceId, e.getMessage());
}
}
return fu;
}
@@ -333,6 +352,12 @@ public class CounselingService {
"INV-012: 只能查看分配给自己的咨询服务");
return null;
}
// P13A-T31: assigned Counselor 读取的是**他人**的咨询正文与随访记录 ——
// 敏感访问必须留痕(同 Phase 17 report/risk_profile 的 recordSensitiveAccess 模式)。
// 本人读自己的不算敏感访问;manager/matchmaker 走脱敏摘要视图,不触正文,无需审计。
if (isAssignedCounselor) {
auditLogService.recordSensitiveAccess(requesterId, "counseling_followup", serviceId);
}
return svc;
}
@@ -20,4 +20,9 @@ public class RecommendationRecord extends BaseEntity {
private String action; // liked, passed, blocked, matched
private java.time.Instant actionAt;
private Integer ruleVersion;
/**
* 推荐判定当次所用的最低评分阈值快照(V050,MATCH-001)。
* 配合 {@link #ruleVersion} 追溯历史推荐依据的规则版本;历史行为 null。
*/
private Integer thresholdSnapshot;
}
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper;
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
private final MatchScoringService matchScoringService;
/**
* 获取每日推荐列表
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
return null;
}
// 2. 双向 Required 检查
// 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
if (targetPref == null) {
return null; // 目标用户未设置择偶条件,无法双向验证
}
// A → B Required 检查
if (!checkBidirectionalRequired(myPref, targetPref)) {
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
if (myProfile == null || targetProfile == null) {
return null; // 资料缺失则无法核验 Required,不假定满足
}
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
return null;
}
@@ -175,36 +180,18 @@ public class MatchEligibilityService {
return "verified".equals(el.getVerificationStatus());
}
/**
* 双向 Required 检查
* Required 是 Hard Constraint,不可自动放宽
*/
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
return true;
}
// checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
// (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
/**
* 方向性评分(A对B的适配度)
* 基于 Preferred 维度计算 0-100 分
*/
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
double score = 0;
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() +
pref.getMaritalWeight() + pref.getChildrenWeight() +
pref.getEducationWeight() + pref.getHeightWeight() +
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
pref.getLifestyleWeight() + pref.getInterestWeight();
// TODO: 实现各维度评分逻辑
// 年龄匹配:在范围内得满分,超出范围按比例扣分
// 地区匹配:同地区得满分,不同地区按比例扣分
// ...
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
return (int)(Math.round(score / totalWeight * 100));
// 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
return userProfileMapper.findByUserId(toUserId)
.map(p -> matchScoringService.scoreDirectionally(pref, p))
.orElse(0.0);
}
/**
@@ -212,20 +199,21 @@ public class MatchEligibilityService {
*/
private List<String> generateReasons(String userId, String targetUserId,
UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>();
// TODO: 根据实际资料生成真实推荐理由
reasons.add("年龄符合双方择偶要求");
reasons.add("都长期生活在丹阳");
reasons.add("婚姻计划接近");
return reasons;
// 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
return userProfileMapper.findByUserId(targetUserId)
.map(p -> matchScoringService.reasons(myPref, p))
.orElseGet(ArrayList::new);
}
/**
* 获取已Pass的用户ID集合(冷却期内)
*/
private Set<String> getPassedUserIds(String userId, Instant since) {
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id
return Collections.emptySet();
// 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
.filter(r -> "passed".equals(r.getAction()))
.map(RecommendationRecord::getTargetUserId)
.collect(Collectors.toSet());
}
/**
@@ -240,14 +228,28 @@ public class MatchEligibilityService {
}
/**
* 构建候选池(简化版)
* TODO: Phase 06 后续接入数据库查询可用用户
* 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
* (2026-09-23:替换原 `return List.of()` 空实现。)
*/
private List<String> buildCandidatePool(String userId, UserPreference myPref,
Set<String> excludedIds, Set<String> alreadyRecommended) {
// TODO: 从数据库查询符合条件的用户
// 当前返回空列表,等待后续阶段完善
return List.of();
// 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
// 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProfile> wrapper =
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
wrapper.isNotNull(UserProfile::getGender)
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
.isNull(UserProfile::getDeletedAt);
if (me != null && me.getGender() != null && !me.getGender().isBlank()) {
wrapper.ne(UserProfile::getGender, me.getGender());
}
return userProfileMapper.selectList(wrapper).stream()
.map(UserProfile::getUserId)
.filter(id -> !id.equals(userId))
.filter(id -> !excludedIds.contains(id))
.filter(id -> !alreadyRecommended.contains(id))
.collect(Collectors.toList());
}
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
@@ -0,0 +1,284 @@
package com.danyang.match.platform.match.service;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 匹配评分 —— **全仓唯一事实源**。
*
* <p>背景:`RecommendationEngineService.calculateScore` 与
* `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
* 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
* `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
* 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
*
* <p>本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
*
* <p><b>规则(不新造常量)</b>:直接使用 {@link UserPreference} 上<b>已存在</b>的
* 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
* 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
* 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
*
* <p>返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
*/
@Service
public class MatchScoringService {
/** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
private static final List<String> EDUCATION_ORDER = List.of(
"primary", "junior_high", "senior_high", "vocational", "college", "bachelor", "master", "doctor");
/**
* 方向性评分:seeker 的择偶条件对 candidate 资料的适配度(0–100)。
*
* @param pref seeker 的择偶条件(可为 null → 无约束)
* @param candidate 候选人资料
*/
public double scoreDirectionally(UserPreference pref, UserProfile candidate) {
if (candidate == null) return 0;
if (pref == null) return 100;
double acc = 0; // 加权得分
double denom = 0; // 已设置条件的权重合计
// —— 年龄 ——(设置了 min/maxAge 才构成约束)
if (pref.getMinAge() != null && pref.getMaxAge() != null) {
double w = w(pref.getAgeWeight());
denom += w;
acc += w * rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge());
}
// —— 身高 ——
if (pref.getMinHeightCm() != null && pref.getMaxHeightCm() != null) {
double w = w(pref.getHeightWeight());
denom += w;
Integer h = candidate.getHeightCm();
acc += w * (h == null ? 0 : rangeRatio(h, pref.getMinHeightCm(), pref.getMaxHeightCm()));
}
// —— 地区(regionScope 为地区码数组)——
if (hasJsonArray(pref.getRegionScope())) {
double w = w(pref.getRegionWeight());
denom += w;
acc += w * (inJsonArray(pref.getRegionScope(), candidate.getRegionCode()) ? 1 : 0);
}
// —— 婚姻状况(maritalStatuses 数组)——
if (hasJsonArray(pref.getMaritalStatuses())) {
double w = w(pref.getMaritalWeight());
denom += w;
acc += w * (inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus()) ? 1 : 0);
}
// —— 子女(childrenAcceptance:any/accept/reject,兼容中文)——
if (notBlank(pref.getChildrenAcceptance())) {
double w = w(pref.getChildrenWeight());
denom += w;
acc += w * (childrenAcceptable(pref.getChildrenAcceptance(), candidate.getChildrenStatus()) ? 1 : 0);
}
// —— 学历(minEducationCode 为下限)——
if (notBlank(pref.getMinEducationCode())) {
double w = w(pref.getEducationWeight());
denom += w;
acc += w * (educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode()) ? 1 : 0);
}
// —— 婚恋规划(marriagePlanPreference 与 candidate.marriagePlan 一致)——
if (notBlank(pref.getMarriagePlanPreference())) {
double w = w(pref.getMarriagePlanWeight());
denom += w;
acc += w * (sameValue(pref.getMarriagePlanPreference(), candidate.getMarriagePlan()) ? 1 : 0);
}
// —— 定居意愿(settlementPreference 对照 candidate 长期/未来丹阳意向)——
if (notBlank(pref.getSettlementPreference())) {
double w = w(pref.getSettlementWeight());
denom += w;
boolean ok = sameValue(pref.getSettlementPreference(), candidate.getLongTermDanyang())
|| sameValue(pref.getSettlementPreference(), candidate.getFutureDanyang());
acc += w * (ok ? 1 : 0);
}
// —— 生活方式(吸烟 / 饮酒,取已设置者的均值)——
double lifeW = w(pref.getLifestyleWeight());
int lifeDims = 0;
double lifeHit = 0;
if (notBlank(pref.getSmokingPreference())) {
lifeDims++;
if (sameValue(pref.getSmokingPreference(), candidate.getSmokingStatus())) lifeHit++;
}
if (notBlank(pref.getDrinkingPreference())) {
lifeDims++;
if (sameValue(pref.getDrinkingPreference(), candidate.getDrinkingStatus())) lifeHit++;
}
if (lifeDims > 0) {
denom += lifeW;
acc += lifeW * (lifeHit / lifeDims);
}
// interestWeight:当前无对应择偶条件字段,不参与计分(保留权重以备扩展)。
if (denom == 0) return 100; // 用户未设置任何条件 → 无约束
return clamp(acc / denom * 100.0);
}
/**
* 生成中文推荐理由(基于**实际达成**的维度,不再是无条件套话)。
* 未达成任何具体维度时回退为「资料符合基本要求」。
*/
public List<String> reasons(UserPreference pref, UserProfile candidate) {
List<String> out = new ArrayList<>();
if (pref == null || candidate == null) return out;
if (pref.getMinAge() != null && pref.getMaxAge() != null
&& rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge()) > 0) {
out.add("年龄在您的要求范围内");
}
if (hasJsonArray(pref.getRegionScope()) && inJsonArray(pref.getRegionScope(), candidate.getRegionCode())) {
out.add("同在您期望的地区");
}
if (hasJsonArray(pref.getMaritalStatuses())
&& inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus())) {
out.add("婚姻状况符合您的期望");
}
if (notBlank(pref.getMinEducationCode())
&& educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode())) {
out.add("学历符合您的要求");
}
if (notBlank(pref.getAgePriority()) && "required".equals(pref.getAgePriority())) {
out.add("年龄为您设定的必要条件");
}
if (out.isEmpty()) out.add("资料符合基本要求");
return out;
}
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——唯一事实源。
*
* <p>任一方把某维度设为 `required`,则对方该维度<b>必须</b>落在要求内,否则整条候选被否。
* 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 Required 维度)。
*
* @return true 表示双向 Required 全部满足
*/
public boolean bidirectionalRequiredSatisfied(UserPreference myPref, UserProfile myProfile,
UserPreference targetPref, UserProfile targetProfile) {
if (myPref == null || myProfile == null || targetPref == null || targetProfile == null) return false;
// 年龄
if (isRequired(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
Integer candAge = age(targetProfile.getBirthDate());
if (candAge == null || candAge < myPref.getMinAge() || candAge > myPref.getMaxAge()) return false;
}
if (isRequired(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
Integer myAge = age(myProfile.getBirthDate());
if (myAge == null || myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) return false;
}
// 身高
if (isRequired(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
Integer h = targetProfile.getHeightCm();
if (h == null || h < myPref.getMinHeightCm() || h > myPref.getMaxHeightCm()) return false;
}
if (isRequired(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
Integer h = myProfile.getHeightCm();
if (h == null || h < targetPref.getMinHeightCm() || h > targetPref.getMaxHeightCm()) return false;
}
// 地区(Required 用 isJsonArray:空数组=自相矛盾的硬约束 → 拒绝;不可放宽为「无约束」)
if (isRequired(myPref.getRegionPriority()) && isJsonArray(myPref.getRegionScope())
&& !inJsonArray(myPref.getRegionScope(), targetProfile.getRegionCode())) return false;
if (isRequired(targetPref.getRegionPriority()) && isJsonArray(targetPref.getRegionScope())
&& !inJsonArray(targetPref.getRegionScope(), myProfile.getRegionCode())) return false;
// 婚姻状况
if (isRequired(myPref.getMaritalPriority()) && isJsonArray(myPref.getMaritalStatuses())
&& !inJsonArray(myPref.getMaritalStatuses(), targetProfile.getMaritalStatus())) return false;
if (isRequired(targetPref.getMaritalPriority()) && isJsonArray(targetPref.getMaritalStatuses())
&& !inJsonArray(targetPref.getMaritalStatuses(), myProfile.getMaritalStatus())) return false;
return true;
}
private static boolean isRequired(String priority) { return "required".equals(priority); }
// ==================== 私有工具 ====================
private static double w(Double v) { return v == null ? 0 : Math.max(0, v); }
private static boolean notBlank(String s) { return s != null && !s.isBlank(); }
private static Integer age(LocalDate birthDate) {
if (birthDate == null) return null;
return Period.between(birthDate, LocalDate.now()).getYears();
}
/** 在 [min,max] 内 → 1;否则按越界距离线性衰减(跨度越大衰减越慢),最低 0。 */
private static double rangeRatio(Integer value, int min, int max) {
if (value == null) return 0;
if (min > max) { int t = min; min = max; max = t; }
if (value >= min && value <= max) return 1.0;
int span = Math.max(1, max - min);
int distance = value < min ? (min - value) : (value - max);
return Math.max(0.0, 1.0 - (double) distance / span);
}
/** 评分用:仅当数组**非空**才算「用户表达了该条件」。空数组 = 未设置,不计入分母。 */
private static boolean hasJsonArray(JsonNode node) {
return node != null && node.isArray() && node.size() > 0;
}
/**
* Required 校验用:只要节点是数组即「用户声明了该硬约束」。
* 空数组是自相矛盾的声明(要求必填却无任何可接受值)→ 交由调用方判为不满足,
* 与「Required 不可自动放宽」(INV-001)一致;不可退化成「无约束」。
*/
private static boolean isJsonArray(JsonNode node) {
return node != null && node.isArray();
}
private static boolean inJsonArray(JsonNode node, String value) {
if (node == null || !node.isArray() || value == null) return false;
for (int i = 0; i < node.size(); i++) {
if (value.equals(node.get(i).asText())) return true;
}
return false;
}
/** 子女接受度:any/不限 → 恒满足;accept/可接受 → 有孩与否皆可;reject/不接受 → 仅无孩。 */
private static boolean childrenAcceptable(String acceptance, String childrenStatus) {
String a = acceptance == null ? "" : acceptance.trim();
boolean candidateHasChildren = "yes".equals(childrenStatus) || "has".equals(childrenStatus)
|| "有".equals(childrenStatus);
return switch (a) {
case "any", "不限" -> true;
case "accept", "可接受" -> true; // 接受带孩(且同样接受无孩)
case "reject", "不接受" -> !candidateHasChildren;
default -> true;
};
}
private static boolean educationAtLeast(String candidateCode, String minCode) {
int ci = EDUCATION_ORDER.indexOf(candidateCode);
int mi = EDUCATION_ORDER.indexOf(minCode);
if (mi < 0) return true; // 下限码不在白名单 → 不做约束
if (ci < 0) return false; // 候选人学历缺失/未知 → 无法核验,不假定满足
return ci >= mi;
}
/** 宽松相等:去空白、忽略大小写;两边都非空才比较。 */
private static boolean sameValue(String a, String b) {
if (!notBlank(a) || !notBlank(b)) return false;
return a.trim().equalsIgnoreCase(b.trim());
}
private static double clamp(double v) {
if (v < 0) return 0;
if (v > 100) return 100;
return v;
}
/** 便于单测/文档:常量键说明。 */
public static Map<String, String> weightKeys() {
return Map.of(
"age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
"children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
"marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
"settlement", "settlementWeight", "interest", "interestWeight");
}
}
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.concurrent.TimeUnit;
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
private final UserMapper userMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final LocalEligibilityMapper localEligibilityMapper;
/** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
private final MatchScoringService matchScoringService;
/**
* 获取每日推荐列表
@@ -241,86 +242,16 @@ public class RecommendationEngineService {
return userProfileMapper.findByUserId(userId).orElse(null);
}
/**
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——委托
* {@link MatchScoringService#bidirectionalRequiredSatisfied} 唯一事实源。
*
* <p>此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;2026-09-23 收敛为单点。
* 参数对映:本方法的 {@code candidate} 即事实源签名中的 {@code targetProfile}。
*/
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) {
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
// 1. 年龄 Required 检查
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
int candidateAge = calculateAge(candidate.getBirthDate());
if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) {
return false;
}
}
if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
int myAge = calculateAge(myProfile.getBirthDate());
if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) {
return false;
}
}
// 2. 身高 Required 检查
if ("required".equals(myPref.getHeightPriority())
&& myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
if (candidate.getHeightCm() == null
|| candidate.getHeightCm() < myPref.getMinHeightCm()
|| candidate.getHeightCm() > myPref.getMaxHeightCm()) {
return false;
}
}
if ("required".equals(targetPref.getHeightPriority())
&& targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
if (myProfile.getHeightCm() == null
|| myProfile.getHeightCm() < targetPref.getMinHeightCm()
|| myProfile.getHeightCm() > targetPref.getMaxHeightCm()) {
return false;
}
}
// 3. 地区 Required 检查
if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < myPref.getRegionScope().size(); i++) {
if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) {
boolean inScope = false;
for (int i = 0; i < targetPref.getRegionScope().size(); i++) {
if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) {
inScope = true;
break;
}
}
if (!inScope) return false;
}
// 4. 婚姻状况 Required 检查
if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) {
if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) {
boolean accepted = false;
for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) {
if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) {
accepted = true;
break;
}
}
if (!accepted) return false;
}
return true;
return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
}
private boolean isParticipantEligible(String userId) {
@@ -341,23 +272,19 @@ public class RecommendationEngineService {
return true;
}
/**
* 方向性评分:委托 {@link MatchScoringService}(唯一事实源)。
*
* <p>此前为桩 `return 50.0`,而阈值默认 60 → `mutualScore < 60` 恒成立,
* 导致 `GET /recommendations` **恒返回空数组**(2026-09-23 UAT 走查实测)。
*/
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
// TODO: Phase 07 后续完善评分逻辑
return 50.0; // V1 简化:默认中间分
return matchScoringService.scoreDirectionally(pref, target);
}
private List<String> generateReasons(String userId, UserProfile candidate,
UserPreference myPref, UserPreference targetPref) {
List<String> reasons = new ArrayList<>();
// TODO: 基于真实资料生成推荐理由
reasons.add("年龄符合双方择偶要求");
reasons.add("都希望长期在丹阳生活");
return reasons;
}
private int calculateAge(LocalDate birthDate) {
if (birthDate == null) return 0;
return Period.between(birthDate, LocalDate.now()).getYears();
return matchScoringService.reasons(myPref, candidate);
}
private Set<String> getPassedUserIds(String userId, Instant since) {
@@ -386,6 +313,8 @@ public class RecommendationEngineService {
record.setReasons(String.join(";", reasons));
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
recommendationRecordMapper.insert(record);
}
@@ -64,6 +64,8 @@ public class RecommendationService {
record.setReasons(new java.util.ArrayList<>(reasons).toString());
record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
recommendationRecordMapper.insert(record);
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
}
@@ -16,7 +16,7 @@ import java.util.List;
import java.util.Map;
/**
* 红娘服务端点
* 红娘服务端点(用户端)
*
* 用户端:
* POST /api/v1/matchmaker/services — 提交红娘服务请求
@@ -26,10 +26,15 @@ import java.util.Map;
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
* POST /api/v1/matchmaker/recommendations/{id}/pass
*
* 操作人端(Matchmaker/Operator role):
* PUT /api/v1/matchmaker/services/{id}/assign
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务
* 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
* <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
* {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
* {@code POST /services/{id}/complete})。
*
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
*/
@RestController
@RequestMapping("/matchmaker")
@@ -51,4 +51,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
/**
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
*/
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id = #{userId} " +
"AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
long countActiveByUserId(@Param("userId") String userId);
}
@@ -24,6 +24,8 @@ import java.time.Instant;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
@Service
@RequiredArgsConstructor
@@ -85,9 +87,9 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord assignService(String serviceId, String operatorId, String organizationId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_SUBMITTED.equals(service.getStatus())) {
throw new RuntimeException("当前状态不允许分配");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许分配");
}
service.setAssignedOperatorId(operatorId);
@@ -107,9 +109,9 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord claimService(String operatorId, String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_ASSIGNED.equals(service.getStatus())) {
throw new RuntimeException("当前状态不允许接单");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许接单");
}
service.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
@@ -141,7 +143,7 @@ public class MatchmakerServiceService {
public Map<String, Object> recommendPartner(String serviceId, String operatorId,
String targetUserId, String reason) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
if (!operatorId.equals(service.getAssignedOperatorId())) {
@@ -206,10 +208,10 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord completeService(String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
if (!Constants.MATCHMAKER_STATUS_IN_SERVICE.equals(service.getStatus()) &&
!Constants.MATCHMAKER_STATUS_WAITING_USER.equals(service.getStatus())) {
throw new RuntimeException("当前状态不能完成");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能完成");
}
service.setStatus(Constants.MATCHMAKER_STATUS_COMPLETED);
@@ -227,7 +229,7 @@ public class MatchmakerServiceService {
@Transactional
public MatchmakerServiceRecord cancelService(String serviceId) {
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
if (service == null) throw new RuntimeException("服务不存在");
if (service == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "服务不存在");
service.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED);
serviceMapper.updateById(service);
log.info("红娘服务取消: serviceId={}", serviceId);
@@ -232,6 +232,25 @@ public class MeController {
return ApiResponse.ok(photoService.getUploadToken(principal.id(), photoType));
}
/**
* 确认直传照片落库(REST §29 补)。
* 客户端先 {@code POST /me/photos/upload-token} 拿预签名 URL 直传对象存储,
* 上传成功后再调本端点把 fileKey 落成 user_photos 记录;否则照片不展示、签名访问也过不了归属校验。
* POST /api/v1/me/photos/confirm
*/
@PostMapping("/photos/confirm")
public ApiResponse<Map<String, Object>> confirmPhoto(
@AuthenticationPrincipal UserPrincipal principal,
@RequestBody Map<String, String> body) {
String fileKey = body.get("fileKey");
String photoType = body.getOrDefault("photoType", "lifestyle");
UserPhoto photo = photoService.confirmDirectUpload(principal.id(), fileKey, photoType);
Map<String, Object> result = new LinkedHashMap<>();
result.put("photoId", photo.getId());
result.put("photoType", photo.getPhotoType());
return ApiResponse.ok(result);
}
/**
* 删除本人照片(REST §32)。
* DELETE /api/v1/me/photos/{photoId}
@@ -14,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
@Service
@RequiredArgsConstructor
@@ -73,7 +75,7 @@ public class OperatorManagementService {
@Transactional
public void disableOperator(String operatorId, String byOperatorId) {
Operator operator = operatorMapper.selectById(operatorId);
if (operator == null) throw new RuntimeException("Operator不存在");
if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在");
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
operator.setUpdatedAt(Instant.now());
operatorMapper.updateById(operator);
@@ -82,7 +84,7 @@ public class OperatorManagementService {
@Transactional
public void enableOperator(String operatorId, String byOperatorId) {
Operator operator = operatorMapper.selectById(operatorId);
if (operator == null) throw new RuntimeException("Operator不存在");
if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在");
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
operator.setUpdatedAt(Instant.now());
operatorMapper.updateById(operator);
@@ -91,7 +93,7 @@ public class OperatorManagementService {
@Transactional
public void resetPassword(String operatorId, String newPassword, String byOperatorId) {
Operator operator = operatorMapper.selectById(operatorId);
if (operator == null) throw new RuntimeException("Operator不存在");
if (operator == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "Operator不存在");
operator.setPasswordHash(passwordEncoder.encode(newPassword));
operator.setUpdatedAt(Instant.now());
operatorMapper.updateById(operator);
@@ -18,6 +18,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
/**
* Operator 管理服务
@@ -47,7 +50,7 @@ public class OperatorService {
// 校验唯一性
operatorMapper.findByUsername(username).ifPresent(op -> {
throw new RuntimeException("用户名已存在: " + username);
throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在: " + username);
});
Operator operator = new Operator();
@@ -83,7 +86,7 @@ public class OperatorService {
@Transactional
public void disableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
operator.setUpdatedAt(Instant.now());
@@ -99,7 +102,7 @@ public class OperatorService {
@Transactional
public void enableOperator(String operatorId) {
Operator operator = operatorMapper.findById(operatorId)
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
operator.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
operator.setUpdatedAt(Instant.now());
@@ -35,7 +35,7 @@ public class OrganizationController {
@PostMapping
public ApiResponse<?> create(@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, Object> params) {
return ApiResponse.ok(organizationService.createOrganization(params));
return ApiResponse.ok(organizationService.createOrganization(params, principal.id()));
}
@PutMapping("/{id}")
@@ -6,6 +6,8 @@ import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.organization.entity.Organization;
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -13,14 +15,56 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 机构管理服务
*
* <h3>2026-09-21 修复:三处写操作必然 500(High)</h3>
* 原实现把机构状态写成 {@code Constants.ACCOUNT_STATUS_NORMAL}("normal") /
* {@code ACCOUNT_STATUS_SUSPENDED}("suspended") —— 那是 {@code users.account_status} 的取值,
* 而 {@code organizations.status} 的 CHECK 白名单(V001 {@code chk_org_status})只有
* <b>('active', 'inactive')</b>,于是**创建 / 停用 / 启用三个写操作全部违约**,
* 生产上必然 {@code PSQLException: violates check constraint "chk_org_status"} → HTTP 500。
*
* <p>二阶影响:{@code activities.organizer_org_id} 为 NOT NULL —— 无机构即无法新建活动,
* 运营端「建机构 → 建活动 → 报名 → 互选」整条链路的第一环是断的。
*
* <p>为何长期未被发现:① 全仓无任何测试引用本类;② E2E 的
* {@code tests/e2e/sql/seed_e2e_activity.sql} 直接写库插入机构,绕过本 Service,
* 测试全绿掩盖了 API 路径的失败;③ 测试库 schema-h2.sql 的 {@code organizations}
* 表当时没有复刻这两条 CHECK(非法值在测试期畅通无阻)。
* 三者已在本轮一并处置,并由 {@code OrganizationConstraintParityTest} 长期守卫。
*/
@Service
@RequiredArgsConstructor
public class OrganizationService {
/**
* 机构类型白名单 —— 与生产迁移 V001 的 {@code chk_org_type} 逐值对应。
*
* <p>⚠️ 不得在此处单方面增删:{@code OrganizationConstraintParityTest} 会从迁移 DDL
* 解析白名单与本集合比对,任何一侧漂移立即变红(写入非法值原本要打到 PostgreSQL
* 才以 500 暴露,现在由本集合前置拦截为 400)。
*/
public static final Set<String> ALLOWED_TYPES = Set.of(
"women_federation",
"trade_union",
"charity_center",
"volunteer_team",
"civil_affairs_partner",
"other");
/**
* 机构状态白名单 —— 与生产迁移 V001 的 {@code chk_org_status} 逐值对应。
*
* <p>注意与 {@code users.account_status} 的区别:后者是
* normal / reviewing_risk / limited / suspended / banned / closed。混用即本轮修复的缺陷。
*/
public static final Set<String> ALLOWED_STATUSES = Set.of(
Constants.ORG_STATUS_ACTIVE,
Constants.ORG_STATUS_INACTIVE);
private final OrganizationMapper organizationMapper;
private final AuditLogMapper auditLogMapper;
@@ -34,33 +78,64 @@ public class OrganizationService {
return organizationMapper.selectById(id);
}
/**
* 创建机构。
*
* <p>⚠️ {@code operatorId} 必须由 Controller 从认证主体透传:本方法会写
* {@code CREATE_ORGANIZATION} 审计行,缺失操作人即审计不可归属。
* 2026-09-21 活栈验收实测发现该参数原先未透传 —— 7 条 CREATE 审计的
* {@code operator_id} 全为 NULL,而停用 / 启用却正确记录,属「同一族缺陷的创建分支」。
*/
@Transactional
public Organization createOrganization(Map<String, Object> params) {
public Organization createOrganization(Map<String, Object> params, String operatorId) {
String name = asNonBlankString(params.get("name"));
if (name == null) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "机构名称不能为空");
}
String type = asNonBlankString(params.get("type"));
requireAllowedType(type);
Organization org = new Organization();
org.setName((String) params.get("name"));
org.setType((String) params.get("type"));
org.setContactName((String) params.get("contactName"));
org.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
org.setName(name);
org.setType(type);
org.setContactName(asNonBlankString(params.get("contactName")));
org.setStatus(Constants.ORG_STATUS_ACTIVE);
organizationMapper.insert(org);
saveAuditLog(null, "CREATE_ORGANIZATION", "organization", org.getId(), null, null);
saveAuditLog(operatorId, "CREATE_ORGANIZATION", "organization", org.getId(), null, null);
return org;
}
@Transactional
public void updateOrganization(String id, Map<String, Object> params) {
Organization org = organizationMapper.selectById(id);
if (org == null) throw new RuntimeException("机构不存在");
if (params.get("name") != null) org.setName((String) params.get("name"));
if (params.get("type") != null) org.setType((String) params.get("type"));
if (params.get("contactName") != null) org.setContactName((String) params.get("contactName"));
if (org == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "机构不存在");
}
if (params.get("name") != null) {
String name = asNonBlankString(params.get("name"));
if (name == null) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "机构名称不能为空");
}
org.setName(name);
}
if (params.get("type") != null) {
String type = asNonBlankString(params.get("type"));
requireAllowedType(type);
org.setType(type);
}
if (params.get("contactName") != null) {
org.setContactName(asNonBlankString(params.get("contactName")));
}
organizationMapper.updateById(org);
}
@Transactional
public void disableOrganization(String id, String operatorId) {
Organization org = organizationMapper.selectById(id);
if (org == null) throw new RuntimeException("机构不存在");
org.setStatus(Constants.ACCOUNT_STATUS_SUSPENDED);
if (org == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "机构不存在");
}
org.setStatus(Constants.ORG_STATUS_INACTIVE);
org.setUpdatedAt(Instant.now());
organizationMapper.updateById(org);
saveAuditLog(operatorId, "DISABLE_ORGANIZATION", "organization", id, null, null);
@@ -69,13 +144,32 @@ public class OrganizationService {
@Transactional
public void enableOrganization(String id, String operatorId) {
Organization org = organizationMapper.selectById(id);
if (org == null) throw new RuntimeException("机构不存在");
org.setStatus(Constants.ACCOUNT_STATUS_NORMAL);
if (org == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "机构不存在");
}
org.setStatus(Constants.ORG_STATUS_ACTIVE);
org.setUpdatedAt(Instant.now());
organizationMapper.updateById(org);
saveAuditLog(operatorId, "ENABLE_ORGANIZATION", "organization", id, null, null);
}
/** 去空白后取非空字符串;非字符串 / 空白串一律视为「未提供」。 */
private static String asNonBlankString(Object value) {
if (!(value instanceof String raw)) {
return null;
}
String trimmed = raw.trim();
return trimmed.isEmpty() ? null : trimmed;
}
/** 类型必须落在生产 CHECK 白名单内,否则前置返回 400,而不是打到 PostgreSQL 才 500。 */
private static void requireAllowedType(String type) {
if (type == null || !ALLOWED_TYPES.contains(type)) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR,
"机构类型不合法:" + type + ",允许值 " + ALLOWED_TYPES);
}
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
@@ -3,6 +3,7 @@ package com.danyang.match.platform.photo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.photo.entity.UserPhoto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@@ -15,4 +16,16 @@ public interface UserPhotoMapper extends BaseMapper<UserPhoto> {
@Select("SELECT * FROM user_photos WHERE user_id = #{userId} AND photo_type = 'avatar' AND deleted_at IS NULL LIMIT 1")
UserPhoto findAvatar(String userId);
/**
* 统计该用户当前 active(未软删)的个人照片数(头像 + 生活照)。
*
* <p>本地资格佐证(local_eligibility)各有独立上限(localEvidenceMaxFiles),不计入。
* 并发安全由调用方保证:先 {@code UserMapper.lockUserRow(userId)}(FOR UPDATE 锁用户行,
* 串行化同一用户并发写、且 0 张照片时也有锁可加),<b>锁后再执行本 COUNT</b>(新语句、
* READ COMMITTED 下读到最新已提交数据)。勿在无锁事务里单独使用本计数做上限判定。
*/
@Select("SELECT COUNT(*) FROM user_photos WHERE user_id = #{userId} AND deleted_at IS NULL "
+ "AND photo_type IN ('avatar','lifestyle')")
long countActiveProfilePhotos(@Param("userId") String userId);
}
@@ -9,9 +9,11 @@ import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import lombok.RequiredArgsConstructor;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -27,17 +29,39 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
@Service
@RequiredArgsConstructor
public class PhotoService {
private static final Logger log = LoggerFactory.getLogger(PhotoService.class);
private static final int SIGNED_URL_EXPIRE_HOURS = 24;
private final UserPhotoMapper photoMapper;
/** 后端服务内连存储用的内网客户端(putObject 等服务端上传走它)。 */
private final AmazonS3 amazonS3;
/** 给客户端签发 presigned URL 用的客户端,指向对外可达的 S3 网关(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
private final FeatureFlagConfig featureFlags;
private final ProfilePolicy profilePolicy;
private final com.danyang.match.platform.user.mapper.UserMapper userMapper;
@Autowired
public PhotoService(
UserPhotoMapper photoMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
FeatureFlagConfig featureFlags,
ProfilePolicy profilePolicy,
com.danyang.match.platform.user.mapper.UserMapper userMapper,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.photoMapper = photoMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
this.featureFlags = featureFlags;
this.profilePolicy = profilePolicy;
this.userMapper = userMapper;
}
@Value("${object-storage.bucket-private}")
private String privateBucket;
@@ -56,7 +80,7 @@ public class PhotoService {
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3.generatePresignedUrl(putRequest).toString();
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
@@ -74,14 +98,14 @@ public class PhotoService {
List<UserPhoto> userPhotos = photoMapper.findByUserIdOrderBySort(userId);
boolean owned = userPhotos.stream().anyMatch(p -> fileKey.equals(p.getFileKey()));
if (!owned) {
throw new RuntimeException("无权访问该图片");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权访问该图片");
}
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(SIGNED_URL_EXPIRE_HOURS));
GeneratePresignedUrlRequest getRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration);
String url = amazonS3.generatePresignedUrl(getRequest).toString();
String url = amazonS3Signer.generatePresignedUrl(getRequest).toString();
Map<String, Object> result = new HashMap<>();
result.put("url", url);
@@ -95,7 +119,46 @@ public class PhotoService {
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration);
return Map.of("url", amazonS3.generatePresignedUrl(request).toString(), "expiresIn", 600);
return Map.of("url", amazonS3Signer.generatePresignedUrl(request).toString(), "expiresIn", 600);
}
/**
* 个人照片数量上限校验(服务器端强制,前端提示不可绕过)。
*
* <p>须在 {@code @Transactional} 事务内调用(调用方 {@link #savePhoto} /
* {@link #uploadAndSave} 均已标注)。并发安全走「用户行锁 + 锁后计数」两步(纠偏方案 §四
* 的 {@code userRepository.lockById + countActivePhotos} 模式):
* <ol>
* <li>{@code UserMapper.lockUserRow}({@code SELECT ... FOR UPDATE})锁住恒存在的用户行 ——
* 串行化同一用户的并发写,且 **0 张照片时也有锁可加**(「首张并发」缺口);</li>
* <li>锁<b>之后</b>再用新语句 {@code COUNT} active 照片(READ COMMITTED 下读到最新已提交数据)。
* 若锁照片行再计数,H2 MVCC 快照可能漏读并发提交的新行(PHOTO-004 实测 3/3 全成功、
* 突破上限的根因),故改为锁用户行。</li>
* </ol>
*
* <p>规则(PROFILE-001 / OQ-PROFILE-001):个人照片(头像 + 生活照)合计须 ≤
* {@code profile.photo.maxCount}(当前技术默认 6,未获产品正式确认,见 config-register)。
* <ul>
* <li>lifestyle 新增 +1:当前 active 数 ≥ 上限即拒(PHOTO-002);</li>
* <li>avatar 为槽位替换(旧头像软删后插新,总量不变):仅在已超限时拒;</li>
* <li>local_eligibility 等佐证类型各有独立上限(localEvidenceMaxFiles),不在此计。</li>
* </ul>
*/
public void ensureWithinPhotoLimit(String userId, String photoType) {
if (!"avatar".equals(photoType) && !"lifestyle".equals(photoType)) {
return; // 非个人照片类型(佐证等)走各自上限,不在个人照片上限约束内
}
// 1) 锁用户行:串行化同用户并发写;用户不存在时返回 null,后续 insert 由 FK 兜底拒绝
userMapper.lockUserRow(userId);
// 2) 锁后计数(新语句,READ COMMITTED 读最新已提交数据)
long active = photoMapper.countActiveProfilePhotos(userId);
int maxCount = profilePolicy.getMaxPhotoCount();
boolean willExceed = "lifestyle".equals(photoType) ? active >= maxCount : active > maxCount;
if (willExceed) {
throw new GlobalExceptionHandler.BusinessException(
com.danyang.match.platform.shared.enums.ApiResponseCode.PHOTO_LIMIT_EXCEEDED,
"照片数量已达到当前允许上限");
}
}
/**
@@ -103,6 +166,9 @@ public class PhotoService {
*/
@Transactional
public UserPhoto savePhoto(String userId, String fileKey, String photoType, int sortOrder) {
// 数量上限校验:所有 insert 路径统一在此强制(并发安全,见 ensureWithinPhotoLimit)
ensureWithinPhotoLimit(userId, photoType);
// 如果是头像,先清除旧头像
if ("avatar".equals(photoType)) {
photoMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<UserPhoto>()
@@ -121,6 +187,31 @@ public class PhotoService {
return photo;
}
/**
* 确认直传照片落库(客户端直传对象存储成功后调用)。
*
* <p>直传路径({@code /me/photos/upload-token} 签发预签名 PUT URL)对象先进入私有桶,
* 但此前没有任何端点把 {@code fileKey} 写成 {@code user_photos} 记录,导致「传了白传」:
* 列表不展示、签名访问的归属校验也过不了。本方法补齐落库,并设两道护栏:
* <ol>
* <li>归属校验:{@code fileKey} 必须以 {@code photos/{userId}/} 开头(由本用户直传凭证生成),
* 禁止客户端传入任意 fileKey 把别人的对象据为己有或写入非法 key;</li>
* <li>存在性校验:对象必须已存在于私有桶(客户端确实完成了 PUT),否则拒绝落库,
* 避免只拿了凭证却没上传成功就生成孤儿记录。</li>
* </ol>
*/
@Transactional
public UserPhoto confirmDirectUpload(String userId, String fileKey, String photoType) {
if (fileKey == null || !fileKey.startsWith("photos/" + userId + "/")) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "非法的文件标识");
}
if (!amazonS3.doesObjectExist(privateBucket, fileKey)) {
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件尚未上传完成");
}
int sortOrder = photoMapper.countActiveProfilePhotos(userId) + 1;
return savePhoto(userId, fileKey, photoType, sortOrder);
}
/**
* 处理后端直传:接收文件 → 上传到 MinIO → 保存记录
*/
@@ -134,13 +225,13 @@ public class PhotoService {
boolean isLocalEvidence = "local_eligibility".equals(photoType);
if (isLocalEvidence) {
if (!isAllowedLocalEvidenceMimeType(contentType)) {
throw new RuntimeException("不支持的佐证文件格式: " + contentType);
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的佐证文件格式: " + contentType);
}
} else if (!isAllowedMimeType(contentType)) {
throw new RuntimeException("不支持的图片格式: " + contentType);
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的图片格式: " + contentType);
}
if (!validateSize(file, photoType)) {
throw new RuntimeException("文件大小超过限制");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "文件大小超过限制");
}
// 2. 魔数校验(仅图片;PDF 佐证跳过——Content-Type 已由白名单约束,
@@ -150,13 +241,19 @@ public class PhotoService {
try (InputStream headerStream = file.getInputStream()) {
header = headerStream.readNBytes(12);
} catch (IOException e) {
throw new RuntimeException("读取文件头失败", e);
throw new ServerErrorException("读取文件头失败", e);
}
if (header.length < 3 || !validateMagicNumber(header)) {
throw new RuntimeException("无效的图片文件头");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的图片文件头");
}
}
// 2.5 数量上限校验:必须放在对象存储上传之前 —— 超限若先传 S3 再在入库时拒绝,
// 会留下无人引用的孤儿对象。本地资格佐证不在个人照片上限内(isLocalEvidence 跳过)。
if (!isLocalEvidence) {
ensureWithinPhotoLimit(userId, photoType);
}
// 3. 生成 key 并上传(使用全新输入流,与校验流隔离,互不干扰)
String fileKey = generateFileKey(userId, photoType, contentType);
try {
@@ -166,7 +263,7 @@ public class PhotoService {
setContentLength(file.getSize());
}});
} catch (IOException e) {
throw new RuntimeException("上传文件失败", e);
throw new ServerErrorException("上传文件失败", e);
}
// 4. 保存记录
@@ -17,4 +17,9 @@ public class UserPrivacySettings extends BaseEntity {
private Boolean showLifestylePhotos;
private Boolean receiveActivityRecommendations;
private Boolean acceptMatchmakerRecommendations;
// V047:小程序 privacy-settings 页契约字段(产品裁定以小程序为准)。
// 默认值与小程序 data 初值一致 —— showLastOnline 默认 false(不公开在线状态)。
private Boolean allowActivityInvitation;
private Boolean showLastOnline;
}
@@ -3,11 +3,22 @@ package com.danyang.match.platform.profile.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
@Mapper
public interface UserPreferenceMapper extends BaseMapper<UserPreference> {
/**
* 按用户读取择偶偏好(推荐引擎打分与匹配资格判定的输入)。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>,
* 调用必抛 {@code Invalid bound statement}。它在推荐链路上有 6 处调用点,
* 但因候选池为空而从未被执行到。
*
* <p>{@code deleted_at IS NULL}:V037 已为 user_preferences 补上软删列。
*/
@Select("SELECT * FROM user_preferences WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
Optional<UserPreference> findByUserId(String userId);
}
@@ -3,11 +3,24 @@ package com.danyang.match.platform.profile.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
@Mapper
public interface UserPrivacySettingsMapper extends BaseMapper<UserPrivacySettings> {
/**
* 按用户读取隐私设置。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
* 本项目全仓无 mapper XML、无自定义 {@code ISqlInjector},自定义方法只能靠注解提供 SQL,
* 因此调用必抛 {@code Invalid bound statement}。之所以长期未被发现,是因为
* 测试环境候选池为空、且测试库<b>根本没有这张表</b>,两条路径都走不到。
*
* <p>{@code deleted_at IS NULL}:V037 已为 user_privacy_settings 补上软删列,
* 不过滤会读到已删记录。
*/
@Select("SELECT * FROM user_privacy_settings WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1")
Optional<UserPrivacySettings> findByUserId(String userId);
}
@@ -1,6 +1,6 @@
package com.danyang.match.platform.profile.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import lombok.RequiredArgsConstructor;
@@ -17,59 +17,89 @@ public class PrivacyService {
private final UserPrivacySettingsMapper privacySettingsMapper;
/**
* 获取隐私设置
* 获取隐私设置。
*
* <p><b>键集以小程序为准</b>(2026-09-19 产品裁定):
* {@code visibleInRecommendations} / {@code allowActivityInvitation} / {@code showLastOnline}。
* 后两列由 V047 补齐(此前后端表与小程序只有 {@code visibleInRecommendations} 一个交集)。
*
* <p>默认值与小程序 {@code data} 初值<b>严格一致</b>,尤其
* {@code showLastOnline} 默认 <b>false</b>(不公开在线状态)—— 写成 true 等于默认公开。
*
* <p>注意:既有的 6 个列并未废弃 —— {@link com.danyang.match.platform.profile.service.UserProfileService}
* 的资料详情 DTO 仍在全量返回它们。此处只是<b>隐私设置读写契约</b>以小程序为准。
*/
public Map<String, Boolean> getPrivacySettings(String userId) {
return privacySettingsMapper.findByUserId(userId)
.map(s -> {
.map(this::toMiniappContract)
.orElseGet(this::defaults);
}
private Map<String, Boolean> toMiniappContract(UserPrivacySettings s) {
Map<String, Boolean> map = new HashMap<>();
map.put("visibleInRecommendations", s.getVisibleInRecommendations());
map.put("visibleInSearch", s.getVisibleInSearch());
map.put("showOccupation", s.getShowOccupation());
map.put("showLifestylePhotos", s.getShowLifestylePhotos());
map.put("receiveActivityRecommendations", s.getReceiveActivityRecommendations());
map.put("acceptMatchmakerRecommendations", s.getAcceptMatchmakerRecommendations());
map.put("allowActivityInvitation", s.getAllowActivityInvitation());
map.put("showLastOnline", s.getShowLastOnline());
return map;
}
private Map<String, Boolean> defaults() {
Map<String, Boolean> map = new HashMap<>();
map.put("visibleInRecommendations", true);
map.put("allowActivityInvitation", true);
map.put("showLastOnline", false);
return map;
})
.orElseGet(() -> {
// 返回默认值
Map<String, Boolean> defaults = new HashMap<>();
defaults.put("visibleInRecommendations", true);
defaults.put("visibleInSearch", true);
defaults.put("showOccupation", true);
defaults.put("showLifestylePhotos", true);
defaults.put("receiveActivityRecommendations", true);
defaults.put("acceptMatchmakerRecommendations", true);
return defaults;
});
}
/**
* 更新隐私设置
* 更新隐私设置(upsert 语义:存在则更新,不存在则插入)。
*
* <p><b>修复说明</b>:此前无论记录是否已存在都无条件走 {@code insert},
* 而 {@code user_privacy_settings} 的主键自 V002 起就是 {@code user_id},
* 因此同一用户<b>第二次</b>调用必然撞主键冲突 → 500,隐私设置从此再也改不动。
*
* <p><b>更新条件刻意用 {@code user_id} 而非实体 {@code id}</b>:该表的业务主键是
* {@code user_id};{@code id} 是 V037 为「实体↔表对齐」后补的独立列,
* 不作为寻址依据 —— 否则一旦某行 id 缺失(V037 回填前的历史行),更新会静默失效。
*
* <p><b>已知边界(未擅自处理)</b>:{@code findByUserId} 带 {@code deleted_at IS NULL},
* 若将来引入软删路径,已软删的行会让本方法走插入分支并撞主键。当前生产代码
* <b>不存在任何软删隐私设置的路径</b>,故该分支不可达;若将来新增软删,需同步改为
* 显式 upsert(如 PG 的 {@code ON CONFLICT (user_id) DO UPDATE})。
*/
@Transactional
public Map<String, Boolean> updatePrivacySettings(String userId, Map<String, Boolean> settings) {
UserPrivacySettings privacy = privacySettingsMapper.findByUserId(userId)
.orElseGet(() -> {
UserPrivacySettings p = new UserPrivacySettings();
p.setUserId(userId);
p.setVisibleInRecommendations(true);
p.setVisibleInSearch(true);
p.setShowOccupation(true);
p.setShowLifestylePhotos(true);
p.setReceiveActivityRecommendations(true);
p.setAcceptMatchmakerRecommendations(true);
return p;
});
UserPrivacySettings existing = privacySettingsMapper.findByUserId(userId).orElse(null);
UserPrivacySettings privacy;
if (existing != null) {
privacy = existing;
} else {
privacy = new UserPrivacySettings();
privacy.setUserId(userId);
// 既有 6 列:仍被 UserProfileService 资料详情 DTO 读取,保持默认 true
privacy.setVisibleInRecommendations(true);
privacy.setVisibleInSearch(true);
privacy.setShowOccupation(true);
privacy.setShowLifestylePhotos(true);
privacy.setReceiveActivityRecommendations(true);
privacy.setAcceptMatchmakerRecommendations(true);
// V047 新增:小程序契约列(showLastOnline 默认 false)
privacy.setAllowActivityInvitation(true);
privacy.setShowLastOnline(false);
}
if (settings.containsKey("visibleInRecommendations")) privacy.setVisibleInRecommendations(settings.get("visibleInRecommendations"));
if (settings.containsKey("visibleInSearch")) privacy.setVisibleInSearch(settings.get("visibleInSearch"));
if (settings.containsKey("showOccupation")) privacy.setShowOccupation(settings.get("showOccupation"));
if (settings.containsKey("showLifestylePhotos")) privacy.setShowLifestylePhotos(settings.get("showLifestylePhotos"));
if (settings.containsKey("receiveActivityRecommendations")) privacy.setReceiveActivityRecommendations(settings.get("receiveActivityRecommendations"));
if (settings.containsKey("acceptMatchmakerRecommendations")) privacy.setAcceptMatchmakerRecommendations(settings.get("acceptMatchmakerRecommendations"));
if (settings.containsKey("allowActivityInvitation")) privacy.setAllowActivityInvitation(settings.get("allowActivityInvitation"));
if (settings.containsKey("showLastOnline")) privacy.setShowLastOnline(settings.get("showLastOnline"));
if (existing != null) {
privacySettingsMapper.update(privacy,
new LambdaUpdateWrapper<UserPrivacySettings>()
.eq(UserPrivacySettings::getUserId, userId));
} else {
privacySettingsMapper.insert(privacy);
}
return getPrivacySettings(userId);
}
}
@@ -1,11 +1,13 @@
package com.danyang.match.platform.profile.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@@ -25,17 +27,28 @@ public class UserPreferenceService {
}
/**
* 保存择偶条件
* 保存择偶条件(无则插入、有则更新)。
*
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
* 再次保存即触发主键冲突(实测 {@code PUT /me/preferences} → 409
* {@code duplicate key value violates unique constraint "user_preferences_pkey"})。
*
* <p><b>为何不用 {@code updateById}</b>:{@code UserPreference} 继承 {@code BaseEntity},
* 其 {@code @TableId} 为 {@code id},而库中真正的主键是 {@code user_id}
* ({@code id} 是 V037 后补的可空列 + 唯一索引,V037 之后新插入的行 {@code id} 为 NULL)。
* 用 {@code updateById} 会退化成 {@code WHERE id IS NULL} 静默不更新,
* 因此按 {@code user_id} 定位。
*/
@Transactional
public UserPreference savePreference(String userId, Map<String, Object> data) {
UserPreference pref = userPreferenceMapper.findByUserId(userId)
.orElseGet(() -> {
UserPreference p = new UserPreference();
p.setUserId(userId);
return p;
});
UserPreference pref = userPreferenceMapper.findByUserId(userId).orElse(null);
boolean exists = pref != null;
if (!exists) {
pref = new UserPreference();
pref.setUserId(userId);
}
if (data != null) {
if (data.containsKey("minAge")) pref.setMinAge((Integer) data.get("minAge"));
if (data.containsKey("maxAge")) pref.setMaxAge((Integer) data.get("maxAge"));
if (data.containsKey("agePriority")) pref.setAgePriority((String) data.get("agePriority"));
@@ -54,8 +67,15 @@ public class UserPreferenceService {
if (data.containsKey("drinkingPreference")) pref.setDrinkingPreference((String) data.get("drinkingPreference"));
if (data.containsKey("settlementPreference")) pref.setSettlementPreference((String) data.get("settlementPreference"));
if (data.containsKey("marriagePlanPreference")) pref.setMarriagePlanPreference((String) data.get("marriagePlanPreference"));
}
userPreferenceMapper.insert(pref);;
if (exists) {
pref.setUpdatedAt(Instant.now());
userPreferenceMapper.update(pref,
new LambdaUpdateWrapper<UserPreference>().eq(UserPreference::getUserId, userId));
} else {
userPreferenceMapper.insert(pref);
}
return pref;
}
@@ -11,6 +11,10 @@ import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.verification.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -27,6 +31,7 @@ public class UserProfileService {
private final UserPreferenceMapper userPreferenceMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final UserRelationMapper relationMapper;
private final UserIdentityMapper userIdentityMapper;
/**
* 获取自己完整资料(含隐私设置)
@@ -60,8 +65,13 @@ public class UserProfileService {
prefMap.put("maxHeightCm", pref.getMaxHeightCm());
prefMap.put("heightPriority", pref.getHeightPriority());
prefMap.put("regionScope", pref.getRegionScope());
prefMap.put("regionPriority", pref.getRegionPriority());
prefMap.put("maritalStatuses", pref.getMaritalStatuses());
prefMap.put("maritalPriority", pref.getMaritalPriority());
prefMap.put("minEducationCode", pref.getMinEducationCode());
prefMap.put("educationPriority", pref.getEducationPriority());
prefMap.put("childrenAcceptance", pref.getChildrenAcceptance());
prefMap.put("childrenPriority", pref.getChildrenPriority());
prefMap.put("smokingPreference", pref.getSmokingPreference());
prefMap.put("drinkingPreference", pref.getDrinkingPreference());
prefMap.put("settlementPreference", pref.getSettlementPreference());
@@ -84,57 +94,186 @@ public class UserProfileService {
}
/**
* 更新基础资料
* 更新基础资料(资料完善流程分节写)。
*
* <p>2026-09-23 UAT 走查修复:此前无论记录是否存在都调 {@code insert()},
* 已有资料再次保存 → 主键冲突 → 500。改为「无则 insert / 有则 updateById」。
* 另补 {@code gender}/{@code birth_date} 的 NOT NULL 兜底(见 {@link #ensureIdentityFields})。
*/
@Transactional
public UserProfile updateBasicProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId)
.orElseGet(() -> {
UserProfile p = new UserProfile();
p.setUserId(userId);
p.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
return p;
});
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
boolean exists = profile != null;
if (!exists) {
profile = new UserProfile();
profile.setUserId(userId);
profile.setProfileStatus(Constants.PROFILE_STATUS_IN_PROGRESS);
}
applyProfileFields(profile, fields);
ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile));
userProfileMapper.insert(profile);
persistProfile(profile, exists);
return profile;
}
/**
* 更新个人资料
* 更新个人资料(资料完善向导的最终提交,置为 completed)。
*
* <p>2026-09-23 UAT 走查修复:同 {@link #updateBasicProfile},消除「二次保存必崩」。
*/
@Transactional
public void updateProfile(String userId, Map<String, Object> fields) {
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(new UserProfile());
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
boolean exists = profile != null;
if (!exists) {
profile = new UserProfile();
profile.setUserId(userId);
}
// 基础字段映射
if (fields.containsKey("displayName")) profile.setDisplayName((String) fields.get("displayName"));
if (fields.containsKey("heightCm")) profile.setHeightCm((Integer) fields.get("heightCm"));
if (fields.containsKey("regionCode")) profile.setRegionCode((String) fields.get("regionCode"));
if (fields.containsKey("regionName")) profile.setRegionName((String) fields.get("regionName"));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus((String) fields.get("maritalStatus"));
if (fields.containsKey("childrenStatus")) profile.setChildrenStatus((String) fields.get("childrenStatus"));
if (fields.containsKey("educationCode")) profile.setEducationCode((String) fields.get("educationCode"));
if (fields.containsKey("occupationCode")) profile.setOccupationCode((String) fields.get("occupationCode"));
if (fields.containsKey("workRegionCode")) profile.setWorkRegionCode((String) fields.get("workRegionCode"));
applyProfileFields(profile, fields);
ensureIdentityFields(profile);
profile.setProfileCompletion(calculateCompletion(profile));
profile.setProfileStatus(Constants.PROFILE_STATUS_COMPLETED);
persistProfile(profile, exists);
}
/**
* 落库:无则插入、有则按主键 user_id 更新。
*
* <p>{@code UserProfile} 的 {@code @TableId = userId},与库主键 {@code user_profiles.user_id}
* 一致,故 {@code updateById} 安全(对比 {@code UserPreference} 实体 id 与库主键不一致的情形)。
*/
private void persistProfile(UserProfile profile, boolean exists) {
if (exists) {
userProfileMapper.updateById(profile);
} else {
userProfileMapper.insert(profile);
}
}
/**
* 把请求字段映射到资料实体。
*
* <p>同时接受「规范字段名」与小程序的既有字段名,二者等价:
* <ul>
* <li>{@code childrenStatus}(none/yes)↔ {@code hasChildren}(布尔)</li>
* <li>{@code occupationCode} ↔ {@code occupationCategory}</li>
* <li>{@code workRegionCode} ↔ {@code workRegion}</li>
* </ul>
* 这使「REST 规范 / 管理端」与「小程序完善资料向导」共用同一写接口,
* 避免因命名不一致导致字段被静默丢弃(UAT 走查发现向导的子女/职业/工作地三项
* 此前根本写不进库)。
*/
private void applyProfileFields(UserProfile profile, Map<String, Object> fields) {
if (fields == null) return;
if (fields.containsKey("displayName")) profile.setDisplayName(asString(fields.get("displayName")));
if (fields.containsKey("gender")) profile.setGender(asString(fields.get("gender")));
if (fields.containsKey("birthDate")) profile.setBirthDate(asDate(fields.get("birthDate")));
if (fields.containsKey("heightCm")) profile.setHeightCm(asInteger(fields.get("heightCm")));
if (fields.containsKey("regionCode")) profile.setRegionCode(asString(fields.get("regionCode")));
if (fields.containsKey("regionName")) profile.setRegionName(asString(fields.get("regionName")));
if (fields.containsKey("maritalStatus")) profile.setMaritalStatus(asString(fields.get("maritalStatus")));
// 子女:规范名 childrenStatus(none/yes)优先,兼容向导的 hasChildren(布尔)
if (fields.containsKey("childrenStatus")) {
profile.setChildrenStatus(asString(fields.get("childrenStatus")));
} else if (fields.containsKey("hasChildren")) {
Boolean has = asBoolean(fields.get("hasChildren"));
if (has != null) profile.setChildrenStatus(has ? "yes" : "none");
}
if (fields.containsKey("educationCode")) profile.setEducationCode(asString(fields.get("educationCode")));
// 职业:规范名 occupationCode 优先,兼容向导的 occupationCategory
if (fields.containsKey("occupationCode")) {
profile.setOccupationCode(asString(fields.get("occupationCode")));
} else if (fields.containsKey("occupationCategory")) {
profile.setOccupationCode(asString(fields.get("occupationCategory")));
}
// 工作地:规范名 workRegionCode 优先,兼容向导的 workRegion
if (fields.containsKey("workRegionCode")) {
profile.setWorkRegionCode(asString(fields.get("workRegionCode")));
} else if (fields.containsKey("workRegion")) {
profile.setWorkRegionCode(asString(fields.get("workRegion")));
}
if (fields.containsKey("smokingStatus")) profile.setSmokingStatus(asString(fields.get("smokingStatus")));
if (fields.containsKey("drinkingStatus")) profile.setDrinkingStatus(asString(fields.get("drinkingStatus")));
if (fields.containsKey("longTermDanyang")) profile.setLongTermDanyang(asString(fields.get("longTermDanyang")));
if (fields.containsKey("futureDanyang")) profile.setFutureDanyang(asString(fields.get("futureDanyang")));
if (fields.containsKey("relationshipGoal")) profile.setRelationshipGoal(asString(fields.get("relationshipGoal")));
if (fields.containsKey("marriagePlan")) profile.setMarriagePlan(asString(fields.get("marriagePlan")));
if (fields.containsKey("selfIntro")) profile.setSelfIntro(asString(fields.get("selfIntro")));
}
/**
* {@code user_profiles.gender} 与 {@code birth_date} 在库中为 NOT NULL,而资料完善向导
* 不采集这两项(性别是「已认证」的只读信息)——权威来源是实名认证
* ({@code user_identities}),产品流程亦为「先实名、后完善资料」。
*
* <p>因此:请求未提供时从已通过实名认证的记录补齐;仍缺失则给出明确的
* 4xx 业务拒绝(而非让数据库抛 NOT NULL 约束错渲染成 500)。
*/
private void ensureIdentityFields(UserProfile profile) {
boolean needGender = profile.getGender() == null || profile.getGender().isBlank();
boolean needBirth = profile.getBirthDate() == null;
if (!needGender && !needBirth) return;
UserIdentity identity = userIdentityMapper.findByUserId(profile.getUserId()).orElse(null);
if (identity != null && Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
if (needGender && identity.getGender() != null && !identity.getGender().isBlank()) {
profile.setGender(identity.getGender());
needGender = false;
}
if (needBirth && identity.getBirthDate() != null) {
profile.setBirthDate(identity.getBirthDate());
needBirth = false;
}
}
if (needGender || needBirth) {
throw new GlobalExceptionHandler.BusinessException(ApiResponseCode.BAD_REQUEST,
"请先完成实名认证后再完善资料");
}
}
private static String asString(Object v) {
return v == null ? null : String.valueOf(v);
}
private static Integer asInteger(Object v) {
if (v == null) return null;
if (v instanceof Number n) return n.intValue();
try {
return Integer.valueOf(String.valueOf(v).trim());
} catch (NumberFormatException e) {
return null;
}
}
private static Boolean asBoolean(Object v) {
if (v == null) return null;
if (v instanceof Boolean b) return b;
String s = String.valueOf(v).trim();
if ("true".equalsIgnoreCase(s) || "yes".equalsIgnoreCase(s) || "has".equalsIgnoreCase(s)) return true;
if ("false".equalsIgnoreCase(s) || "no".equalsIgnoreCase(s) || "none".equalsIgnoreCase(s)) return false;
return null;
}
private static LocalDate asDate(Object v) {
if (v == null) return null;
if (v instanceof LocalDate d) return d;
String s = String.valueOf(v).trim();
if (s.isEmpty()) return null;
try {
return LocalDate.parse(s);
} catch (RuntimeException e) {
return null;
}
}
/**
* 获取公开资料(用于推荐卡片和详情页,需登录)
@@ -8,6 +8,8 @@ import com.danyang.match.platform.permission.mapper.OperatorMapper;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -94,7 +96,7 @@ public class ReferralService {
new LambdaQueryWrapper<ActivityRegistration>()
.eq(ActivityRegistration::getActivityId, activityId)
.eq(ActivityRegistration::getUserId, userId));
if (existing != null) throw new RuntimeException("用户已报名该活动");
if (existing != null) throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "用户已报名该活动");
String operatorName = getOperatorName(matchmakerId);
ActivityRegistration reg = new ActivityRegistration();
@@ -126,12 +128,26 @@ public class ReferralService {
return result;
}
/**
* 用户确认转介(预约 / 活动报名)。
*
* <p>拒绝路径统一走 {@link BusinessException}(带语义化业务码 → 4xx),
* 不再用裸 {@code RuntimeException}:后者在 {@code GlobalExceptionHandler}
* 只会命中兜底分支变成 HTTP 500,「无权操作」被报成服务端故障。
*/
@Transactional
public Map<String, Object> confirmReferral(String userId, String targetId, String targetType) {
if ("service_station".equals(targetType)) {
ServiceAppointment appt = appointmentMapper.selectById(targetId);
if (appt == null || !userId.equals(appt.getUserId())) throw new RuntimeException("预约不存在或无权操作");
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(appt.getSource())) throw new RuntimeException("该预约不是转介类型");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!userId.equals(appt.getUserId())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权确认他人的转介预约");
}
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(appt.getSource())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "该预约不是转介类型");
}
appt.setUserConfirmedAt(Instant.now());
appt.setStatus(Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION);
appt.setUpdatedAt(Instant.now());
@@ -144,8 +160,15 @@ public class ReferralService {
return result;
} else if ("activity".equals(targetType)) {
ActivityRegistration reg = activityRegistrationMapper.selectById(targetId);
if (reg == null || !userId.equals(reg.getUserId())) throw new RuntimeException("报名不存在或无权操作");
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(reg.getSource())) throw new RuntimeException("该报名不是转介类型");
if (reg == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名不存在");
}
if (!userId.equals(reg.getUserId())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权确认他人的转介报名");
}
if (!Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL.equals(reg.getSource())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "该报名不是转介类型");
}
reg.setUserConfirmedAt(Instant.now());
reg.setUpdatedAt(Instant.now());
activityRegistrationMapper.updateById(reg);
@@ -156,7 +179,7 @@ public class ReferralService {
result.put("confirmedAt", reg.getUserConfirmedAt());
return result;
} else {
throw new RuntimeException("不支持的转介类型: " + targetType);
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不支持的转介类型: " + targetType);
}
}

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