Author SHA1 Message Date
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
346 changed files with 20043 additions and 13996 deletions
+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 @@
婚恋服务小程序
+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,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
+2180
View File
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -1,3 +1,24 @@
allowBuilds:
esbuild: set this to true or false
vue-demi: set this to true or false
# 本目录只有一个前端包,不是 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:
- '.'
onlyBuiltDependencies:
- esbuild
- vue-demi
+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>
@@ -183,13 +183,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;
@@ -434,8 +434,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,9 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
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 +228,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 = localStorage.getItem('accessToken')
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 = localStorage.getItem('accessToken')
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 +351,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%">
@@ -86,6 +86,7 @@
<script setup lang="ts">
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)
@@ -122,11 +123,11 @@ async function loadStats() {
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 {}
}
@@ -168,8 +169,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>
@@ -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>
+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>
@@ -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%">
@@ -100,6 +100,7 @@
<script setup lang="ts">
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)
@@ -137,10 +138,10 @@ async function loadStats() {
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 {}
}
@@ -188,7 +189,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;
}
+3 -1
View File
@@ -44,7 +44,9 @@ 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) => {
const authStore = useAuthStore()
+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' },
}
+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>
+46 -8
View File
@@ -1,11 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<mirrors>
<mirror>
<id>central</id>
<mirrorOf>central</mirrorOf>
<name>Maven Central</name>
<url>https://repo1.maven.org/maven2</url>
</mirror>
</mirrors>
<!--
本地构建必须走 aliyun 镜像:本机 Maven Central(repo1.maven.org)被 DNS 投毒,
TLS 握手被中断("Remote host terminated the handshake"),直连 central 必然失败。
aliyun 作为主源、central 作为兜底(仅当 aliyun 缺失某构件时才回退,正常构件不会触达 central)。
CI runner 能直连 central,但 aliyun 同样可达,故该配置对 CI 也无害。
-->
<profiles>
<profile>
<id>aliyun-with-central-fallback</id>
<repositories>
<repository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<repository>
<id>central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>aliyun-plugin-public</id>
<name>Aliyun Plugin Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>central-plugin</id>
<name>Maven Central Plugins</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>aliyun-with-central-fallback</activeProfile>
</activeProfiles>
</settings>
@@ -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;
}
}
@@ -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;
/**
* 活动互选服务
@@ -59,17 +61,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("您未报名该活动"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未通过审核,无法提交反馈");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
}
// 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己)
@@ -30,6 +30,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;
@Service
@RequiredArgsConstructor
@@ -110,9 +112,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 +131,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 +222,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 +239,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 +267,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 +296,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 +336,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 +357,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 +378,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);
@@ -408,11 +410,11 @@ public class ActivityService {
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
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 +723,7 @@ public class ActivityService {
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
}
if (!verified) {
throw new RuntimeException("该活动需完成实名认证后方可报名");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需完成实名认证后方可报名");
}
}
@@ -733,7 +735,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 +748,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 +784,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 +837,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,9 @@ 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;
/**
* 签到服务
@@ -64,18 +67,18 @@ public class CheckinService {
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
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 +293,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,15 +1,19 @@
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.*;
@@ -20,6 +24,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 +50,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;
@@ -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));
}
/**
@@ -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;
@@ -58,7 +61,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");
}
// 查找或创建用户
@@ -113,7 +116,7 @@ public class AuthService {
// 检查手机号是否已被其他用户占用
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
if (!existing.getId().equals(userId)) {
throw new RuntimeException("该手机号已被其他账号绑定");
throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已被其他账号绑定");
}
});
@@ -126,17 +129,43 @@ 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
@@ -151,6 +180,7 @@ public class AuthService {
result.put("accessToken", newAccessToken);
result.put("refreshToken", newRefreshToken);
result.put("expiresIn", accessTokenExpireMs / 1000);
result.put("userId", userId);
return result;
}
@@ -206,13 +236,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, "账号已暂停");
}
}
@@ -165,11 +165,20 @@ 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 无效");
}
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);
@@ -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")
@@ -141,6 +168,52 @@ public class JwtTokenService {
return parseToken(token).getSubject();
}
/**
* 验证并解析 **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();
}
/**
* 获取 Token 过期时间
*/
@@ -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;
}
@@ -386,6 +386,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);
}
@@ -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);
@@ -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,8 @@ 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;
/**
* Operator 管理服务
@@ -47,7 +49,7 @@ public class OperatorService {
// 校验唯一性
operatorMapper.findByUsername(username).ifPresent(op -> {
throw new RuntimeException("用户名已存在: " + username);
throw new BusinessException(ApiResponseCode.CONFLICT, "用户名已存在: " + username);
});
Operator operator = new Operator();
@@ -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>()
@@ -134,13 +200,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 +216,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 +238,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<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());
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;
});
.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("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;
}
/**
* 更新隐私设置
* 更新隐私设置(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"));
privacySettingsMapper.insert(privacy);
if (existing != null) {
privacySettingsMapper.update(privacy,
new LambdaUpdateWrapper<UserPrivacySettings>()
.eq(UserPrivacySettings::getUserId, userId));
} else {
privacySettingsMapper.insert(privacy);
}
return getPrivacySettings(userId);
}
}
@@ -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);
}
}
@@ -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;
/**
* 关系服务
@@ -53,12 +55,12 @@ public class RelationshipService {
public Map<String, Object> like(String userId, String targetUserId) {
// 检查是否已Block
if (isBlocked(userId, targetUserId)) {
throw new RuntimeException("对方已将你拉黑,无法表达喜欢");
throw new BusinessException(ApiResponseCode.USER_BLOCKED, "对方已将你拉黑,无法表达喜欢");
}
// 检查是否已Like
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_LIKED).isPresent()) {
throw new RuntimeException("已经表达过喜欢");
throw new BusinessException(ApiResponseCode.ALREADY_LIKED, "已经表达过喜欢");
}
// 原子插入
@@ -117,7 +119,7 @@ public class RelationshipService {
public void pass(String userId, String targetUserId) {
// 检查是否已Block(Block优先于Pass)
if (isBlocked(userId, targetUserId)) {
throw new RuntimeException("对方已将你拉黑");
throw new BusinessException(ApiResponseCode.USER_BLOCKED, "对方已将你拉黑");
}
// 原子插入(唯一约束保证幂等)
@@ -147,7 +149,7 @@ public class RelationshipService {
public void block(String userId, String targetUserId) {
// 检查是否已Block
if (relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()) {
throw new RuntimeException("已经拉黑过该用户");
throw new BusinessException(ApiResponseCode.ALREADY_BLOCKED, "已经拉黑过该用户");
}
// 插入Block关系
@@ -204,7 +206,7 @@ public class RelationshipService {
Match existing = matchMapper.findActiveMatch(userId, targetUserId).orElse(null);
if (existing != null) {
// 已存在有效Match,不需要重复创建
throw new RuntimeException("匹配已存在");
throw new BusinessException(ApiResponseCode.CONFLICT, "匹配已存在");
}
// Phase 12 复验修复(D5):本方法此前不设置 episode / lastActivityAt,
@@ -3,11 +3,20 @@ package com.danyang.match.platform.review.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.review.entity.ReviewItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReviewItemMapper extends BaseMapper<ReviewItem> {
/**
* 按审核任务读取审核项。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
*
* <p>{@code deleted_at IS NULL}:V037 已为 review_items 补上软删列。
*/
@Select("SELECT * FROM review_items WHERE review_task_id = #{reviewTaskId} AND deleted_at IS NULL ORDER BY created_at")
List<ReviewItem> findByReviewTaskId(String reviewTaskId);
}
@@ -3,13 +3,28 @@ package com.danyang.match.platform.review.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.review.entity.ReviewReason;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ReviewReasonMapper extends BaseMapper<ReviewReason> {
/**
* 按类别读取审核原因字典。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
*
* <p>{@code deleted_at IS NULL}:V037 已为 review_reasons 补上软删列(该表 V003 初始无此列)。
*/
@Select("SELECT * FROM review_reasons WHERE category = #{category} AND deleted_at IS NULL ORDER BY sort_order")
List<ReviewReason> findByCategory(String category);
/**
* 读取全部启用中的审核原因(审核结论下拉选项)。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
*/
@Select("SELECT * FROM review_reasons WHERE enabled = TRUE AND deleted_at IS NULL ORDER BY sort_order")
List<ReviewReason> findAllEnabled();
}
@@ -30,6 +30,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
@@ -172,10 +174,10 @@ public class ReviewService {
public ReviewResultDTO approve(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核");
}
// 更新任务状态
@@ -210,10 +212,10 @@ public class ReviewService {
public ReviewResultDTO needEdit(String taskId, String operatorId, String comment, List<String> reasonCodes) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核");
}
task.setStatus(Constants.REVIEW_STATUS_NEED_EDIT);
@@ -244,10 +246,10 @@ public class ReviewService {
public ReviewResultDTO reject(String taskId, String operatorId, String comment, List<String> reasonCodes) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许审核");
}
task.setStatus(Constants.REVIEW_STATUS_REJECTED);
@@ -281,7 +283,7 @@ public class ReviewService {
.orElseThrow(() -> new RuntimeException("未找到可重新提交的审核任务"));
if (!Constants.REVIEW_STATUS_NEED_EDIT.equals(latestTask.getStatus())) {
throw new RuntimeException("当前状态不允许重新提交");
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许重新提交");
}
// 创建新版本的审核任务
@@ -336,13 +338,13 @@ public class ReviewService {
public ReviewResultDTO needEvidence(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作");
}
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
@@ -378,13 +380,13 @@ public class ReviewService {
public ReviewResultDTO approveLocalEligibility(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作");
}
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
@@ -422,13 +424,13 @@ public class ReviewService {
public ReviewResultDTO rejectLocalEligibility(String taskId, String operatorId, String comment) {
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "审核任务不存在");
}
if (!Constants.REVIEW_STATUS_PROCESSING.equals(task.getStatus())) {
throw new RuntimeException("该任务当前状态不允许操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务当前状态不允许操作");
}
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
throw new BusinessException(ApiResponseCode.CONFLICT, "该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
@@ -479,21 +481,21 @@ public class ReviewService {
public Map<String, Object> accessLocalEligibilityEvidence(
String taskId, String evidenceId, String operatorId, String purpose) {
if (purpose == null || purpose.isBlank()) {
throw new RuntimeException("查看佐证必须填写访问目的");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "查看佐证必须填写访问目的");
}
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null || !Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
if (task != null) {
logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "denied");
}
throw new RuntimeException("本地资格审核任务不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "本地资格审核任务不存在");
}
try {
requireClaimOwner(task, operatorId);
LocalEligibility eligibility = requireLocalEligibility(task.getUserId());
LocalEligibilityEvidence evidence = localEligibilityEvidenceMapper.selectById(evidenceId);
if (evidence == null || !eligibility.getId().equals(evidence.getLocalEligibilityId())) {
throw new RuntimeException("佐证不存在或不属于当前审核任务");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "佐证不存在或不属于当前审核任务");
}
Map<String, Object> result = photoService.getSignedUrlForAuthorizedReview(evidence.getFileId());
logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "success");
@@ -520,7 +522,7 @@ public class ReviewService {
private void requireClaimOwner(ReviewTask task, String operatorId) {
if (!Objects.equals(operatorId, task.getAssignedOperatorId())) {
throw new RuntimeException("请先领取该审核任务,且仅领取人可以处理");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "请先领取该审核任务,且仅领取人可以处理");
}
}
@@ -532,7 +534,7 @@ public class ReviewService {
private LocalEligibility requireLocalEligibility(String userId) {
LocalEligibility eligibility = findLocalEligibility(userId);
if (eligibility == null) throw new RuntimeException("本地资格记录不存在");
if (eligibility == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "本地资格记录不存在");
return eligibility;
}
@@ -31,6 +31,8 @@ import java.util.Map;
import java.util.List;
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;
/**
* 举报与风控服务
@@ -75,7 +77,7 @@ public class ReportService {
public Map<String, Object> submitReport(String reporterUserId, ReportSubmitDTO dto) {
// 不允许举报自己
if (reporterUserId.equals(dto.getReportedUserId())) {
throw new RuntimeException("不能举报自己");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "不能举报自己");
}
// 检查是否已被封禁
@@ -83,7 +85,7 @@ public class ReportService {
boolean isBanned = activeActions.stream()
.anyMatch(a -> Constants.RISK_ACTION_BAN.equals(a.getActionType()));
if (isBanned) {
throw new RuntimeException("您的账号已被封禁,无法提交举报");
throw new BusinessException(ApiResponseCode.FORBIDDEN, "您的账号已被封禁,无法提交举报");
}
Report report = new Report();
@@ -150,7 +152,7 @@ public class ReportService {
public Map<String, Object> getReportDetail(String reportId) {
Report report = reportMapper.selectById(reportId);
if (report == null) {
throw new RuntimeException("举报不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
Map<String, Object> detail = new HashMap<>();
detail.put("report", toDTO(report));
@@ -187,17 +189,17 @@ public class ReportService {
public Map<String, Object> processReport(String reportId, ReportProcessDTO dto, String operatorId) {
Report report = reportMapper.selectById(reportId);
if (report == null) {
throw new RuntimeException("举报不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "举报不存在");
}
if (!Constants.REPORT_STATUS_PENDING.equals(report.getStatus()) &&
!Constants.REPORT_STATUS_PROCESSING.equals(report.getStatus())) {
throw new RuntimeException("该举报已处理完毕,不可重复操作");
throw new BusinessException(ApiResponseCode.CONFLICT, "该举报已处理完毕,不可重复操作");
}
String result = dto.getProcessResult();
if (result == null) {
throw new RuntimeException("请选择处理结果");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "请选择处理结果");
}
report.setStatus(Constants.REPORT_STATUS_RESOLVED);
@@ -8,6 +8,8 @@ import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentM
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -58,10 +60,10 @@ public class ServiceAppointmentService {
public ServiceAppointmentDTO registerByOperator(String targetUserId, ServiceAppointmentRequest request,
String operatorId) {
if (targetUserId == null || targetUserId.isBlank()) {
throw new RuntimeException("代登记必须指定服务对象用户");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "代登记必须指定服务对象用户");
}
if (operatorId == null || operatorId.isBlank()) {
throw new RuntimeException("代登记必须由运营人员发起");
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "代登记必须由运营人员发起");
}
return doCreate(targetUserId, request, Constants.APPOINTMENT_SOURCE_OPERATOR_ASSISTED, operatorId);
}
@@ -134,12 +136,21 @@ public class ServiceAppointmentService {
/**
* 确认预约(工作人员)
*
* <p>状态闸门用 {@link BusinessException} + 语义化业务码,**不用裸 RuntimeException**:
* 后者落进 {@code GlobalExceptionHandler#handleUnknownException} 变成 HTTP 500。
* E2E {@code test_appointment_concurrent_safety} 让「用户取消」与「运营确认」并发,
* 落败的一侧必然读到已变更的状态 —— 那是**正常的并发状态冲突**,
* 却被旧代码报成 5xx,使调用方(含监控)把「别重试」误读为服务端故障。
*/
@Transactional
public ServiceAppointmentDTO confirmAppointment(String appointmentId, String operatorId) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || !"submitted".equals(appt.getStatus())) {
throw new RuntimeException("预约不存在或状态不正确");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!"submitted".equals(appt.getStatus())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许确认");
}
assertOperatorScope(appointmentId, operatorId);
appt.setStatus(Constants.APPOINTMENT_STATUS_CONFIRMED);
@@ -162,8 +173,11 @@ public class ServiceAppointmentService {
@Transactional
public ServiceAppointmentDTO completeAppointment(String appointmentId, String operatorId) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || !"confirmed".equals(appt.getStatus())) {
throw new RuntimeException("预约状态不正确");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!"confirmed".equals(appt.getStatus())) {
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许完成");
}
assertOperatorScope(appointmentId, operatorId);
appt.setStatus(Constants.APPOINTMENT_STATUS_COMPLETED);
@@ -178,8 +192,11 @@ public class ServiceAppointmentService {
@Transactional
public ServiceAppointmentDTO cancelAppointment(String appointmentId, String userId, String reason) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || !userId.equals(appt.getUserId())) {
throw new RuntimeException("预约不存在或无权操作");
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (!userId.equals(appt.getUserId())) {
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权操作该预约");
}
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
appt.setCancelReason(reason);
@@ -191,9 +208,12 @@ public class ServiceAppointmentService {
@Transactional
public ServiceAppointmentDTO cancelAppointmentByOperator(String appointmentId, String operatorId, String reason) {
ServiceAppointment appt = appointmentMapper.selectById(appointmentId);
if (appt == null || Constants.APPOINTMENT_STATUS_COMPLETED.equals(appt.getStatus())
if (appt == null) {
throw new BusinessException(ApiResponseCode.NOT_FOUND, "预约不存在");
}
if (Constants.APPOINTMENT_STATUS_COMPLETED.equals(appt.getStatus())
|| Constants.APPOINTMENT_STATUS_CANCELLED.equals(appt.getStatus())) {
throw new RuntimeException("预约不存在或状态不允许取消");
throw new BusinessException(ApiResponseCode.REDUNDANT_OPERATION, "预约当前状态不允许取消");
}
assertOperatorScope(appointmentId, operatorId);
appt.setStatus(Constants.APPOINTMENT_STATUS_CANCELLED);
@@ -0,0 +1,31 @@
package com.danyang.match.platform.shared.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 账户生命周期配置。
*
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):保留期来自产品决策
* (2026-09-19 确认「注销后数据保留不得低于 36 个月」),以配置承载并可经 yml 覆盖。
* 读取点:{@code DataRetentionService}(到期清单计算与处置闸)——
* 不留「配了没人读」的死配置(Phase 22 T01~T05 教训)。
*/
@Component
@ConfigurationProperties(prefix = "account")
public class AccountConfig {
/**
* 注销后数据保留月数(下限)。产品 2026-09-19 确认:不得低于 36 个月。
* 到期后由持 {@code account.retention.purge} 权限的运营经人工审核执行匿名化清除。
*/
private int dataRetentionMonths = 36;
public int getDataRetentionMonths() {
return dataRetentionMonths;
}
public void setDataRetentionMonths(int dataRetentionMonths) {
this.dataRetentionMonths = dataRetentionMonths;
}
}
@@ -1,16 +1,69 @@
package com.danyang.match.platform.shared.config;
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Feature Flag 配置
* V1.1: 新增 counseling / premarital / content / serviceLocation / localEligibility 开关
*
* <p><b>取值优先级(V048 起)</b>:数据库覆盖({@code feature_flag_overrides})
* &gt; yml / 环境变量 &gt; Java 字段默认值。
* 数据库覆盖在 {@link #reloadFromPersistence()} 中于容器启动时应用 —— 该时机晚于
* {@code @ConfigurationProperties} 绑定,因此压得住 yml 默认值;若反过来就会
* 被 yml 静默盖掉。
*/
@Slf4j
@Component
@ConfigurationProperties(prefix = "features")
public class FeatureFlagConfig {
public static final String TYPE_BOOLEAN = "boolean";
public static final String TYPE_INT = "int";
public static final String TYPE_STRING = "string";
/**
* Canonical 配置键别名映射(V1.0.1 配置与规则纠偏 CFG-001)。
*
* <p>key = 配置中心的<b>正式/遗留键</b>,value = Java 字段名(实现键)。
* 活体核验开关的<b>唯一正式配置键</b>是 {@code identity.liveness.enabled}
* (见 docs/governance/config-register.md,V051 起 feature_flag_overrides 只写该键);
* {@code livenessVerification.enabled} 是 V051 之前的 seed 遗留键,仅作<b>读兼容</b>别名。
* Java 内部字段保持 {@code livenessEnabled}(文档允许:DTO/Policy 内部变量可如此命名)。
*/
public static final Map<String, String> KEY_ALIASES = Map.of(
"identity.liveness.enabled", "livenessEnabled",
"livenessVerification.enabled", "livenessEnabled");
/**
* 实现字段名 → canonical 配置键(写库/写 seed 时用;避免同一业务含义落两套 key)。
* 非别名 flag 原样返回(其字段名即配置键)。
*/
public String canonicalKey(String fieldName) {
if ("livenessEnabled".equals(fieldName)) {
return "identity.liveness.enabled";
}
return fieldName;
}
/**
* 把配置中心键(canonical 或遗留别名)解析为实现字段名;未知键原样返回(由调用方判未知)。
*/
public String resolveKey(String flagName) {
return KEY_ALIASES.getOrDefault(flagName, flagName);
}
// ---- V1.0 已有 Flag ----
private boolean activityEnabled = true;
private boolean matchmakerEnabled = true;
@@ -18,16 +71,12 @@ public class FeatureFlagConfig {
private boolean appealEnabled = false;
// ---- V1.1 新增 Flag ----
/** 公益婚恋咨询功能开关 */
private boolean counselingEnabled = true;
/** 婚前辅导功能开关(复用 counseling,默认跟随 counselingEnabled) */
private boolean premaritalEnabled = true;
/** 内容管理(婚恋课堂/反诈文章)开关 */
private boolean contentEnabled = true;
/** 善缘服务站功能开关 */
private boolean serviceLocationEnabled = true;
/** 本地资格核验功能开关 */
private boolean localEligibilityEnabled = true;
// ⚠️ 2026-09-19 移除:counselingEnabled / premaritalEnabled / contentEnabled /
// serviceLocationEnabled / localEligibilityEnabled 五个开关**从未被任何业务代码读取**
// (全仓搜索仅命中本类的定义与 getAllFlags,外加 FeatureFlagControllerTest 一处断言)。
// 留着是「配置暴露但未强制」的假开关 —— 运维以为关掉能停功能,实际无效。
// 若将来真需要按开关控制这些模块,应重新加入并**同时写入业务调用点**。
//
/** 红娘服务转介功能开关 */
private boolean referralEnabled = true;
/** 活动互选二阶段同意开关 */
@@ -51,11 +100,170 @@ public class FeatureFlagConfig {
/** 佐证材料保留天数;实际清理由运维任务按此配置执行 */
private int localEvidenceRetentionDays = 365;
// ---- V1.1 咨询附件私存储 ----
/** 咨询附件是否使用私有存储桶 */
private boolean counselingAttachmentsPrivate = true;
/** 咨询附件存储桶名称 */
private String counselingAttachmentsBucket = "counseling-attachments";
// ⚠️ 2026-09-19 移除:counselingAttachmentsPrivate / counselingAttachmentsBucket 从未被读取 ——
// 咨询附件的存储实走 object-storage.bucket-private,这两项与真实链路无关。
/**
* 覆盖值的持久化服务。
*
* <p>用字段注入而非构造器注入:{@code @ConfigurationProperties} 依赖无参构造 + setter 绑定,
* 加 final 字段会把它变成构造器绑定,破坏既有绑定方式。
*/
@Autowired
private FeatureFlagOverrideService overrideService;
/**
* 默认配置快照(yml + Java 字段默认值),在首次 {@link #reloadFromPersistence()} 时捕获。
*
* <p>用途:DELETE 覆盖后要能回到「没改过」的那个值。快照必须在<b>应用覆盖之前</b>取,
* 且只取一次 —— 否则第二次 reload 会把上一次的覆盖值误当成默认值。
*/
private final Map<String, Object> defaultsSnapshot = new LinkedHashMap<>();
/**
* 从数据库加载覆盖值并应用。容器启动时由 {@code @PostConstruct} 触发一次。
*
* <p>为什么必须先回到默认再应用覆盖:该方法可能被重复调用(运维手工触发 / 测试),
* 若不清回默认,历史覆盖会滞留 —— reload 就不再是「反映数据库当前状态」。
*/
@PostConstruct
public void reloadFromPersistence() {
if (defaultsSnapshot.isEmpty()) {
defaultsSnapshot.putAll(getAllFlags());
} else {
applyValues(defaultsSnapshot);
}
if (overrideService == null) {
// 单元测试中手工 new 出来的实例(不走 Spring 生命周期):无持久化服务,保持默认值。
return;
}
overrideService.loadOverrides().forEach(this::applyPersistedValue);
}
/**
* 应用一条来自数据库的覆盖值。
*
* <p>两类异常都是<b>故意 fail-fast</b>:数据库是 Source of Truth,
* 一行写坏的覆盖若被静默忽略,表现为「后台显示已改、实际没生效」,比启动失败更难查。
* 修法明确:删掉那一行(DELETE /operator/feature-flags/{flagName})。
*/
private void applyPersistedValue(String flagName, String rawValue) {
// CFG-001:数据库行可能是 canonical 键(identity.liveness.enabled)或遗留别名
// (livenessVerification.enabled),先解析到实现字段名再校验/落值。
flagName = resolveKey(flagName);
if (!getAllFlags().containsKey(flagName)) {
log.warn("忽略数据库中已不存在的 flag 覆盖: {}(可由 DELETE /operator/feature-flags/{} 清理)",
flagName, flagName);
return;
}
if (rawValue == null) {
throw new IllegalStateException("flag 覆盖值为 null,拒绝启动: " + flagName);
}
Object parsed;
try {
parsed = parseRawValue(flagName, rawValue);
} catch (NumberFormatException e) {
throw new IllegalStateException(
"flag 覆盖值无法按声明类型解析,拒绝启动: " + flagName + " = " + rawValue
+ "(期望 " + valueTypeOf(flagName) + ")", e);
}
setFlagValue(flagName, parsed);
}
private void applyValues(Map<String, Object> values) {
values.forEach(this::setFlagValue);
}
/** 按字段声明类型把字符串解析回原类型。 */
private Object parseRawValue(String flagName, String rawValue) {
String type = valueTypeOf(flagName);
if (TYPE_BOOLEAN.equals(type)) {
return Boolean.parseBoolean(rawValue);
}
if (TYPE_INT.equals(type)) {
return Integer.valueOf(rawValue);
}
return rawValue;
}
/**
* 设置单个 flag 的值(按字段声明类型分派)。
*
* <p>与控制器共用同一份分派逻辑 —— 此前分派写在 Controller 里,
* 一旦新增类型就会出现「落库用一套、内存用另一套」的漂移。
*
* @throws ClassCastException value 类型与 flag 声明类型不匹配
* @throws NoSuchFieldException flag 不存在
*/
public void setFlagValue(String flagName, Object value) {
flagName = resolveKey(flagName); // CFG-001:canonical/遗留键都先解析到实现字段名
try {
Field field = FeatureFlagConfig.class.getDeclaredField(flagName);
Class<?> type = field.getType();
Method setter;
if (type == boolean.class || type == Boolean.class) {
setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), boolean.class);
setter.invoke(this, (Boolean) value);
} else if (type == int.class || type == Integer.class) {
setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), int.class);
setter.invoke(this, ((Number) value).intValue());
} else if (type == String.class) {
setter = FeatureFlagConfig.class.getMethod("set" + capitalize(flagName), String.class);
setter.invoke(this, Objects.toString(value, null));
} else {
throw new IllegalArgumentException("不支持的 Flag 类型: " + type.getSimpleName());
}
} catch (NoSuchFieldException | NoSuchMethodException e) {
throw new IllegalArgumentException("未知的 Feature Flag: " + flagName, e);
} catch (ReflectiveOperationException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
if (cause instanceof ClassCastException) {
throw (ClassCastException) cause;
}
throw new IllegalStateException("无法更新 Flag: " + flagName, cause);
}
}
/**
* 该 flag 的持久化类型标记。
*
* <p>回读时以<b>字段声明类型</b>为准,value_type 只用于记录与排障 ——
* 若以 DB 里的 value_type 为准,改一次字段名就会让历史数据按错的类型解析。
*/
public String valueTypeOf(String flagName) {
try {
Class<?> type = FeatureFlagConfig.class.getDeclaredField(resolveKey(flagName)).getType();
if (type == boolean.class || type == Boolean.class) {
return TYPE_BOOLEAN;
}
if (type == int.class || type == Integer.class) {
return TYPE_INT;
}
return TYPE_STRING;
} catch (NoSuchFieldException e) {
throw new IllegalArgumentException("未知的 Feature Flag: " + flagName, e);
}
}
/** 恢复为默认配置(删除覆盖后用)。 */
public void resetToDefault(String flagName) {
flagName = resolveKey(flagName);
Object def = defaultsSnapshot.get(flagName);
if (def == null) {
log.warn("默认配置快照为空,无法恢复 {}: reloadFromPersistence() 尚未执行", flagName);
return;
}
setFlagValue(flagName, def);
}
/** 默认配置快照(只读副本),供排障与测试核对。 */
public Map<String, Object> getDefaultsSnapshot() {
return Map.copyOf(defaultsSnapshot);
}
private String capitalize(String s) {
return s.substring(0, 1).toUpperCase() + s.substring(1);
}
// ==================== Getters & Setters ====================
@@ -71,21 +279,6 @@ public class FeatureFlagConfig {
public boolean isAppealEnabled() { return appealEnabled; }
public void setAppealEnabled(boolean appealEnabled) { this.appealEnabled = appealEnabled; }
public boolean isCounselingEnabled() { return counselingEnabled; }
public void setCounselingEnabled(boolean counselingEnabled) { this.counselingEnabled = counselingEnabled; }
public boolean isPremaritalEnabled() { return premaritalEnabled; }
public void setPremaritalEnabled(boolean premaritalEnabled) { this.premaritalEnabled = premaritalEnabled; }
public boolean isContentEnabled() { return contentEnabled; }
public void setContentEnabled(boolean contentEnabled) { this.contentEnabled = contentEnabled; }
public boolean isServiceLocationEnabled() { return serviceLocationEnabled; }
public void setServiceLocationEnabled(boolean serviceLocationEnabled) { this.serviceLocationEnabled = serviceLocationEnabled; }
public boolean isLocalEligibilityEnabled() { return localEligibilityEnabled; }
public void setLocalEligibilityEnabled(boolean localEligibilityEnabled) { this.localEligibilityEnabled = localEligibilityEnabled; }
public boolean isReferralEnabled() { return referralEnabled; }
public void setReferralEnabled(boolean referralEnabled) { this.referralEnabled = referralEnabled; }
@@ -116,26 +309,15 @@ public class FeatureFlagConfig {
public int getLocalEvidenceRetentionDays() { return localEvidenceRetentionDays; }
public void setLocalEvidenceRetentionDays(int localEvidenceRetentionDays) { this.localEvidenceRetentionDays = localEvidenceRetentionDays; }
public boolean isCounselingAttachmentsPrivate() { return counselingAttachmentsPrivate; }
public void setCounselingAttachmentsPrivate(boolean counselingAttachmentsPrivate) { this.counselingAttachmentsPrivate = counselingAttachmentsPrivate; }
public String getCounselingAttachmentsBucket() { return counselingAttachmentsBucket; }
public void setCounselingAttachmentsBucket(String counselingAttachmentsBucket) { this.counselingAttachmentsBucket = counselingAttachmentsBucket; }
/**
* 获取所有 Feature Flag 的当前值(供 /operator/feature-flags 接口使用)
*/
public java.util.Map<String, Object> getAllFlags() {
java.util.Map<String, Object> flags = new java.util.HashMap<>();
public Map<String, Object> getAllFlags() {
Map<String, Object> flags = new HashMap<>();
flags.put("activityEnabled", activityEnabled);
flags.put("matchmakerEnabled", matchmakerEnabled);
flags.put("chatImageEnabled", chatImageEnabled);
flags.put("appealEnabled", appealEnabled);
flags.put("counselingEnabled", counselingEnabled);
flags.put("premaritalEnabled", premaritalEnabled);
flags.put("contentEnabled", contentEnabled);
flags.put("serviceLocationEnabled", serviceLocationEnabled);
flags.put("localEligibilityEnabled", localEligibilityEnabled);
flags.put("referralEnabled", referralEnabled);
flags.put("mutualConsentEnabled", mutualConsentEnabled);
flags.put("serviceProgramEnabled", serviceProgramEnabled);
@@ -146,8 +328,6 @@ public class FeatureFlagConfig {
flags.put("localEvidenceMaxFileSizeMB", localEvidenceMaxFileSizeMB);
flags.put("localEvidenceAllowedTypes", localEvidenceAllowedTypes);
flags.put("localEvidenceRetentionDays", localEvidenceRetentionDays);
flags.put("counselingAttachmentsPrivate", counselingAttachmentsPrivate);
flags.put("counselingAttachmentsBucket", counselingAttachmentsBucket);
return flags;
}
}
@@ -1,65 +0,0 @@
package com.danyang.match.platform.shared.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 系统配置属性
* 所有未确认数量参数通过此类管理,不得硬编码为常量
*/
@Component
@ConfigurationProperties(prefix = "match")
public class MatchConfig {
/**
* 每日推荐上限(待确认)
*/
private int defaultDailyLimit = 6;
/**
* 推荐最低阈值分数(待确认)
*/
private int defaultScoreThreshold = 60;
/**
* Pass 冷却天数(待确认)
*/
private int defaultCooldownDays = 180;
/**
* 匹配规则版本号
*/
private int ruleVersion = 1;
public int getDefaultDailyLimit() {
return defaultDailyLimit;
}
public void setDefaultDailyLimit(int defaultDailyLimit) {
this.defaultDailyLimit = defaultDailyLimit;
}
public int getDefaultScoreThreshold() {
return defaultScoreThreshold;
}
public void setDefaultScoreThreshold(int defaultScoreThreshold) {
this.defaultScoreThreshold = defaultScoreThreshold;
}
public int getDefaultCooldownDays() {
return defaultCooldownDays;
}
public void setDefaultCooldownDays(int defaultCooldownDays) {
this.defaultCooldownDays = defaultCooldownDays;
}
public int getRuleVersion() {
return ruleVersion;
}
public void setRuleVersion(int ruleVersion) {
this.ruleVersion = ruleVersion;
}
}
@@ -0,0 +1,40 @@
package com.danyang.match.platform.shared.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 个人资料(profile)配置。
*
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):照片上限
* {@code profile.photo.maxCount} 当前技术默认 6,<b>并非产品正式确认值</b>(OQ-PROFILE-001)。
* 只作为 {@code DEFAULT + CONFIG} 管理:可经 yml 覆盖,禁止散落在 Controller/Service/前端各自写死。
* 服务器端强校验读取点见 {@code PhotoService.ensureWithinPhotoLimit},
* 前端读取点见 {@code AppController#getConfig}({@code businessLimits.maxProfilePhotos})。
* 不留「配了没人读」的死配置(Phase 22 T01~T05 教训)。
*/
@Component
@ConfigurationProperties(prefix = "profile")
public class ProfileProperties {
/** 个人资料子配置。 */
private Photo photo = new Photo();
public Photo getPhoto() {
return photo;
}
public void setPhoto(Photo photo) {
this.photo = photo;
}
@Data
public static class Photo {
/**
* 个人照片最大数量(头像 + 生活照合计上限)。
* 技术默认 6,未获产品正式确认;改值无需改代码,且服务器端据此强制校验。
*/
private int maxCount = 6;
}
}
@@ -30,6 +30,13 @@ public class S3Config {
@Value("${object-storage.region:us-east-1}")
private String region;
/**
* 对外可达的 S3 网关地址,用于给客户端签发 presigned URL。
* 缺省时回退到 {@code endpoint}(后端内网地址),保持向后兼容。
*/
@Value("${object-storage.public-endpoint:}")
private String publicEndpoint;
@Bean
public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
@@ -37,6 +44,48 @@ public class S3Config {
new AwsClientBuilder.EndpointConfiguration(endpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(
new BasicAWSCredentials(accessKey, secretKey)))
// 必须显式开启 path-style。
//
// AWS SDK v1 默认是 virtual-host 风格,会把 bucket 拼进主机名:
// endpoint=http://seaweedfs:8333 -> http://private.seaweedfs:8333/...
// 而 `seaweedfs` 是 Docker 内部服务名,`private.seaweedfs` 没有任何 DNS 记录,
// 请求必然失败。private 部署的对象存储几乎都用 IP 或容器名,
// virtual-host 天然不可用 —— path-style(http://host:port/bucket/key)才是正解。
//
// 这一点在换存储前就已埋着:MinIO 时代之所以没暴露,是因为对象存储链路
// 从未被任何测试真正走过(E2E 对存储零覆盖),而不是因为配置正确。
//
// 排查提醒:本机有 http_proxy 时,*.localhost 这类 virtual-host 域名
// 不在 NO_PROXY 内会走代理并返回 502,看起来像「存储不支持」,
// 实为代理假象 —— 判据里 502 不能当作「被拒绝」。
.withPathStyleAccessEnabled(true)
.build();
}
/**
* 给客户端签发 presigned URL 用的客户端,必须指向「对外可达」的 S3 网关地址。
*
* <p>旧实现只有 {@link #amazonS3()} 一个客户端(endpoint = {@code seaweedfs:8333},
* 后端连存储用的 Docker 内网地址)。{@code generatePresignedUrl} 会把 endpoint 的 host
* 直接写进下发给客户端的 uploadUrl —— 客户端在 Docker 网络之外,解析不了 {@code seaweedfs},
* 直传必然失败。这是换存储前就埋着的缺陷(E2E 对存储零覆盖,一直没暴露)。
*
* <p>这里单独建一个指向 {@code public-endpoint} 的客户端,签出来的 URL host 就是客户端能连的地址。
* {@code public-endpoint} 缺省时回退到内网 {@code endpoint}(未配置时行为与旧版一致)。
*
* <p>为何用独立客户端、而不是事后改 URL 字符串的 host:AWS SigV4 的签名覆盖 {@code host}
* 请求头,客户端实际请求的 Host 必须与签名时一致,否则 SeaweedFS 返回 403。用独立客户端签发,
* 签名里的 host 就是 public host,客户端命中同一个 public host,天然一致,无需事后改字符串。
*/
@Bean
public AmazonS3 amazonS3Signer() {
String signEndpoint = (publicEndpoint == null || publicEndpoint.isBlank()) ? endpoint : publicEndpoint;
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(
new AwsClientBuilder.EndpointConfiguration(signEndpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(
new BasicAWSCredentials(accessKey, secretKey)))
.withPathStyleAccessEnabled(true)
.build();
}
}
@@ -228,6 +228,13 @@ public final class Constants {
public static final String MUTUAL_CONSENT_STATUS_ACCEPTED = "accepted";
public static final String MUTUAL_CONSENT_STATUS_DECLINED = "declined";
// 机构状态(V001 organizations)
// ⚠️ 不得复用 users 的 ACCOUNT_STATUS_*:organizations.status 的 CHECK 白名单
// 只有 ('active', 'inactive'),而 account_status 是 normal/reviewing_risk/... 六值。
// 2026-09-21 修复的机构三处写操作 500 即源于此误用(见 OrganizationConstraintParityTest)。
public static final String ORG_STATUS_ACTIVE = "active";
public static final String ORG_STATUS_INACTIVE = "inactive";
// 活动组织角色(V1.1 Phase 14)
public static final String ORG_ROLE_HOST = "host";
public static final String ORG_ROLE_ORGANIZER = "organizer";
@@ -2,6 +2,8 @@ package com.danyang.match.platform.shared.controller;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.featureflag.FeatureFlagOverrideService;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -12,7 +14,7 @@ import java.util.Map;
/**
* Feature Flag 管理控制器
* V1.1 Phase 22
* V1.1 Phase 22;V048 起 PUT 真正落库(此前仅改内存单例,重启即丢)。
*/
@RestController
@RequestMapping("/operator/feature-flags")
@@ -21,6 +23,7 @@ import java.util.Map;
public class FeatureFlagController {
private final FeatureFlagConfig featureFlagConfig;
private final FeatureFlagOverrideService featureFlagOverrideService;
/**
* 获取所有 Feature Flag 当前值
@@ -32,67 +35,95 @@ public class FeatureFlagController {
}
/**
* 更新单个 Feature Flag
* 更新单个 Feature Flag(**持久化**)
* PUT /api/v1/operator/feature-flags/{flagName}
* Body: { "value": <boolean|int|string> }
* Body: { "value": &lt;boolean|int|string&gt; }
*
* <p>支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致 7 个非布尔 flag
* 经 API 更新必失败)。未知 flag 或缺失 value 返回 400,且不会静默把功能置为关闭。
* <p>顺序刻意是「先改内存、再落库」:落库成功但内存没改,会让运维看到旧值;
* 反过来(先落库再改内存)若落库失败,内存却已经改了 —— 那才是真正的分裂状态。
* 内存改动失败时直接返回 400,不会有任何一行被写进数据库。
*
* <p>支持布尔 / 整数 / 字符串三类 flag(此前仅支持布尔,导致若干非布尔 flag
* 经 API 更新必失败)。未知 flag、缺失 value 或类型不匹配返回 400,
* 且不会静默把功能置为关闭,也不会落库。
*/
@PutMapping("/{flagName}")
public ApiResponse<Map<String, Object>> updateFlag(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String flagName,
@RequestBody Map<String, Object> body) {
if (!featureFlagConfig.getAllFlags().containsKey(flagName)) {
// CFG-001:入参可能是 canonical 键(identity.liveness.enabled)或遗留别名
// (livenessVerification.enabled),先解析到实现字段名再校验/落值。
String field = featureFlagConfig.resolveKey(flagName);
if (!featureFlagConfig.getAllFlags().containsKey(field)) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
ApiResponseCode.BAD_REQUEST,
"未知的 Feature Flag: " + flagName);
}
if (!body.containsKey("value")) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
ApiResponseCode.BAD_REQUEST,
"请求体必须包含 value 字段");
}
Object value = body.get("value");
if (value == null) {
// 字符串化后会变成字面量 "null",回读时无法与真正的 "null" 区分 —— 直接拒。
return ApiResponse.error(
ApiResponseCode.BAD_REQUEST,
"value 不能为 null;要恢复默认请改用 DELETE");
}
try {
java.lang.reflect.Field field = featureFlagConfig.getClass().getDeclaredField(flagName);
Class<?> type = field.getType();
if (type == boolean.class || type == Boolean.class) {
boolean b = ((Boolean) value);
featureFlagConfig.getClass()
.getMethod("set" + capitalize(flagName), boolean.class)
.invoke(featureFlagConfig, b);
} else if (type == int.class || type == Integer.class) {
int i = ((Number) value).intValue();
featureFlagConfig.getClass()
.getMethod("set" + capitalize(flagName), int.class)
.invoke(featureFlagConfig, i);
} else if (type == String.class) {
featureFlagConfig.getClass()
.getMethod("set" + capitalize(flagName), String.class)
.invoke(featureFlagConfig, String.valueOf(value));
} else {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"不支持的 Flag 类型: " + type.getSimpleName());
}
return ApiResponse.ok(Map.of(
"flagName", flagName,
"value", featureFlagConfig.getAllFlags().get(flagName),
"updated", true));
featureFlagConfig.setFlagValue(field, value);
} catch (ClassCastException e) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
"value 类型与 Flag " + flagName + " 不匹配: " + e.getMessage());
ApiResponseCode.BAD_REQUEST,
"value 类型与 Flag " + field + " 不匹配: " + e.getMessage());
} catch (IllegalArgumentException e) {
return ApiResponse.error(
ApiResponseCode.BAD_REQUEST,
"无法更新该 Flag: " + e.getMessage());
} catch (Exception e) {
return ApiResponse.error(
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
ApiResponseCode.BAD_REQUEST,
"无法更新该 Flag: " + e.getMessage());
}
// 内存已生效,落库使其成为持久事实(重启后仍生效)。
// 落库键用 canonical 名(identity.liveness.enabled),禁止再写入 livenessEnabled 等旧键。
String operatorId = principal == null ? null : principal.id();
String canonicalKey = featureFlagConfig.canonicalKey(field);
featureFlagOverrideService.save(
canonicalKey,
String.valueOf(featureFlagConfig.getAllFlags().get(field)),
featureFlagConfig.valueTypeOf(field),
operatorId);
return ApiResponse.ok(Map.of(
"flagName", flagName,
"persistedKey", canonicalKey,
"value", featureFlagConfig.getAllFlags().get(field),
"updated", true,
"persisted", true));
}
private String capitalize(String s) {
return s.substring(0, 1).toUpperCase() + s.substring(1);
/**
* 恢复为默认配置(删除数据库覆盖)
* DELETE /api/v1/operator/feature-flags/{flagName}
*
* <p>没有这个端点,一次写错的覆盖只能靠手工改库才能撤销。
*/
@DeleteMapping("/{flagName}")
public ApiResponse<Map<String, Object>> resetFlag(@PathVariable String flagName) {
String field = featureFlagConfig.resolveKey(flagName);
if (!featureFlagConfig.getAllFlags().containsKey(field)) {
return ApiResponse.error(
ApiResponseCode.BAD_REQUEST,
"未知的 Feature Flag: " + flagName);
}
// 覆盖行存的是 canonical 键(CFG-001),删除按 canonical 键
featureFlagOverrideService.delete(featureFlagConfig.canonicalKey(field));
featureFlagConfig.resetToDefault(field);
return ApiResponse.ok(Map.of(
"flagName", flagName,
"value", featureFlagConfig.getAllFlags().get(field),
"reset", true));
}
}
@@ -10,6 +10,7 @@ import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import com.danyang.match.platform.shared.exception.ServerErrorException;
/**
* 敏感数据加密工具
@@ -56,7 +57,7 @@ public class EncryptionUtil {
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new RuntimeException("加密失败", e);
throw new ServerErrorException("加密失败", e);
}
}
@@ -87,7 +88,7 @@ public class EncryptionUtil {
byte[] plainBytes = cipher.doFinal(encrypted);
return new String(plainBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("解密失败", e);
throw new ServerErrorException("解密失败", e);
}
}
@@ -106,7 +107,7 @@ public class EncryptionUtil {
byte[] hashBytes = digest.digest(idNumber.getBytes(StandardCharsets.UTF_8));
return Hex.toHexString(hashBytes);
} catch (Exception e) {
throw new RuntimeException("身份证 Hash 生成失败", e);
throw new ServerErrorException("身份证 Hash 生成失败", e);
}
}
@@ -43,7 +43,8 @@ public enum ApiResponseCode implements BaseEnum {
CROSS_ORGANIZATION_DENIED("CROSS_ORGANIZATION_DENIED", "无权访问其他机构数据", HttpStatus.FORBIDDEN),
OPERATION_NOT_ALLOWED("OPERATION_NOT_ALLOWED", "当前操作不允许", HttpStatus.FORBIDDEN),
MATCHMAKER_CANNOT_LIKE("MATCHMAKER_CANNOT_LIKE", "红娘不能替用户操作喜欢", HttpStatus.FORBIDDEN),
MATCH_SUCCESS_NEED_BOTH_CONFIRM("MATCH_SUCCESS_NEED_BOTH_CONFIRM", "成功牵手需要双方确认", HttpStatus.CONFLICT);
MATCH_SUCCESS_NEED_BOTH_CONFIRM("MATCH_SUCCESS_NEED_BOTH_CONFIRM", "成功牵手需要双方确认", HttpStatus.CONFLICT),
PHOTO_LIMIT_EXCEEDED("PHOTO_LIMIT_EXCEEDED", "照片数量已达到当前允许上限", HttpStatus.CONFLICT);
private final String code;
private final String description;
@@ -1,23 +1,50 @@
package com.danyang.match.platform.shared.exception;
public class BusinessException extends RuntimeException {
private final String code;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
/**
* 兼容别名:以**字符串**业务码构造的业务异常。
*
* <p><b>2026-09-22 修复记录(不要改回独立继承 RuntimeException)。</b>
* 本类此前直接 {@code extends RuntimeException},与
* {@link GlobalExceptionHandler.BusinessException} 是两个互不相干的类。
* 而 {@code GlobalExceptionHandler} 里的 {@code @ExceptionHandler(BusinessException.class)}
* 按 Java 作用域解析到的是**自己的嵌套类**,因此本类的实例落到
* {@link GlobalExceptionHandler#handleUnknownException},一律变成 **HTTP 500**:
* 调用 {@code AnalyticsService.getDailySummary(9999)} 本意是「days 越界」的 400,
* 实际返回「服务端内部错误,请联系管理员」。
*
* <p>修复方式不是逐个改调用点,而是让本类**继承**被映射的那个类——
* 这样无论谁再用旧名字抛异常,HTTP 语义都由业务码决定,不可能再静默 500。
*
* <p>新代码请直接用 {@code GlobalExceptionHandler.BusinessException(ApiResponseCode, String)};
* 本类别名仅为兼容既有字符串码调用点保留。
*
* @deprecated 用 {@link GlobalExceptionHandler.BusinessException} + {@link ApiResponseCode}
*/
@Deprecated
public class BusinessException extends GlobalExceptionHandler.BusinessException {
/**
* @param code {@link ApiResponseCode} 的枚举名(如 {@code "VALIDATION_ERROR"})。
* 无法识别时退化为 500,并在异常首行标注原始码,避免静默变成「无信息的 400」。
*/
public BusinessException(String code) {
super(java.util.Arrays.stream(com.danyang.match.platform.shared.enums.ApiResponseCode.values())
.filter(value -> value.getCode().equals(code))
.map(com.danyang.match.platform.shared.enums.ApiResponseCode::getDescription)
.findFirst()
.orElse(code));
this.code = code;
super(resolve(code));
}
public BusinessException(String code, String message) {
super(message);
this.code = code;
super(resolve(code), message);
}
public String getCode() {
return code;
private static ApiResponseCode resolve(String code) {
if (code != null) {
for (ApiResponseCode candidate : ApiResponseCode.values()) {
if (candidate.getCode().equals(code) || candidate.name().equals(code)) {
return candidate;
}
}
}
return ApiResponseCode.SERVER_ERROR;
}
}
@@ -114,6 +114,20 @@ public class GlobalExceptionHandler {
return status(e.getCode()).body(ApiResponse.error(e.getCode(), e.getMessage()));
}
/**
* 显式标记的服务端故障 → 500。
*
* <p>与 {@link #handleUnknownException} 的区别只在**来源是否已知**:
* 这里是我们主动判定「这确实是服务端的问题」,因此不把 message 回给客户端
* (容器/IO/加密的报错常含路径与密钥线索),但按 ERROR 记全栈以便定位。
*/
@ExceptionHandler(ServerErrorException.class)
public ResponseEntity<ApiResponse<Void>> handleServerError(ServerErrorException e) {
log.error("Server-side failure (explicitly marked): {}", e.getMessage(), e);
return status(ApiResponseCode.SERVER_ERROR)
.body(ApiResponse.error(ApiResponseCode.SERVER_ERROR, "服务端内部错误,请联系管理员"));
}
/**
* 参数非法(例:活动类型不在白名单内)必须回 400。
*
@@ -0,0 +1,39 @@
package com.danyang.match.platform.shared.exception;
/**
* 服务端自身故障的**显式**标记。
*
* <p>适用范围:加解密失败、文件头读取失败、对象存储上传失败、签到码签名失败、
* 外部实名认证 Provider 不可用 —— 这些确实是我们这边的问题,HTTP 500 正确,
* 客户端重试也无害。
*
* <p><b>为什么需要一个专门的类,而不是继续用裸 {@code RuntimeException}:</b>
* 裸 {@code RuntimeException} 无法与「忘了给业务拒绝挑状态码」区分开 ——
* 两者在源码上长得一模一样,落到
* {@link GlobalExceptionHandler#handleUnknownException} 都是 500。
* 于是 2026-09-22 审计时发现:全仓 121 处裸抛里,「活动不存在」「无权取消他人预约」
* 这类明确的客户端错误被一并报成「服务端内部错误,请联系管理员」,
* 客户端据此判定为故障并重试,而重试必然再失败。
*
* <p>有了这个类,裸 {@code RuntimeException} 就只剩一个含义:**漏标了**。
* {@code RuntimeExceptionThrowGuardTest} 据此把「main 源码零裸抛」钉成不变量 ——
* 判据是扫描源码,因此不需要维护一份会过期的白名单副本。
*
* <p><b>为什么放在这里而不是 GlobalExceptionHandler 内部</b>:本异常需要从
* {@code shared.crypto.EncryptionUtil} 这类基础设施代码抛出,让它们依赖一个
* Web 层的 {@code @RestControllerAdvice} 是方向错误的依赖;放在异常包里,
* 基础设施只依赖异常语义本身。
*
* <p>对外文案刻意不回落异常的 message(容器/IO/加密的报错常含路径与密钥线索),
* 与兜底处理器保持一致;堆栈按 ERROR 级记录以便定位。
*/
public class ServerErrorException extends RuntimeException {
public ServerErrorException(String message) {
super(message);
}
public ServerErrorException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,28 @@
package com.danyang.match.platform.shared.featureflag;
import lombok.Data;
import java.time.OffsetDateTime;
/**
* Feature Flag 的运行期覆盖记录。对应 {@code feature_flag_overrides}(V048)。
*
* <p>刻意<b>不</b>继承项目 BaseEntity:本表无软删语义(删除即恢复默认),
* 也不需要 id / created_at —— 与 {@code IdempotencyRecord} 同一取舍。
*
* <p>一张表只有 14 行(每个 flag 至多一行),主键就是 flag_key。
*/
@Data
public class FeatureFlagOverride {
public static final String TYPE_BOOLEAN = "boolean";
public static final String TYPE_INT = "int";
public static final String TYPE_STRING = "string";
private String flagKey;
/** 一律以字符串存储;回读时按 FeatureFlagConfig 字段的声明类型解析。 */
private String flagValue;
private String valueType;
private String updatedBy;
private OffsetDateTime updatedAt;
}
@@ -0,0 +1,85 @@
package com.danyang.match.platform.shared.featureflag;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.OffsetDateTime;
import java.util.List;
/**
* {@code feature_flag_overrides} 落库操作。
*
* <p>与 {@code IdempotencyMapper} 同一约定:全部用显式 SQL 并给列起别名,
* 不依赖 {@code map-underscore-to-camel-case}(该配置一旦被改动,隐式映射会静默失效)。
* 刻意<b>不</b> extends BaseMapper —— 本表主键是 flag_key 而非 id,
* 且 MyBatis-Plus 对无 {@code @TableId} 的实体不会注入 selectById/updateById。
*/
@Mapper
public interface FeatureFlagOverrideMapper {
@Select("""
SELECT flag_key AS flagKey,
flag_value AS flagValue,
value_type AS valueType,
updated_by AS updatedBy,
updated_at AS updatedAt
FROM feature_flag_overrides
""")
List<FeatureFlagOverride> findAll();
@Select("""
SELECT flag_key AS flagKey,
flag_value AS flagValue,
value_type AS valueType,
updated_by AS updatedBy,
updated_at AS updatedAt
FROM feature_flag_overrides
WHERE flag_key = #{flagKey}
""")
FeatureFlagOverride findByKey(@Param("flagKey") String flagKey);
/**
* @return 更新行数;0 表示此前无覆盖行
*/
@Update("""
UPDATE feature_flag_overrides
SET flag_value = #{flagValue},
value_type = #{valueType},
updated_by = #{updatedBy},
updated_at = #{now}
WHERE flag_key = #{flagKey}
""")
int updateValue(@Param("flagKey") String flagKey,
@Param("flagValue") String flagValue,
@Param("valueType") String valueType,
@Param("updatedBy") String updatedBy,
@Param("now") OffsetDateTime now);
/**
* 新增覆盖行。
*
* <p>刻意不用 {@code ON CONFLICT (flag_key) DO UPDATE} —— 那是 PG 语法,
* H2 测试库不认(同 IdempotencyMapper 的取舍)。upsert 由
* {@link FeatureFlagOverrideService#save} 以「先 UPDATE、0 行再 INSERT」实现,
* 两个库行为一致。
*/
@Insert("""
INSERT INTO feature_flag_overrides
(flag_key, flag_value, value_type, updated_by, updated_at)
VALUES
(#{flagKey}, #{flagValue}, #{valueType}, #{updatedBy}, #{now})
""")
int insert(@Param("flagKey") String flagKey,
@Param("flagValue") String flagValue,
@Param("valueType") String valueType,
@Param("updatedBy") String updatedBy,
@Param("now") OffsetDateTime now);
/** 删除覆盖即恢复默认配置。 */
@Delete("DELETE FROM feature_flag_overrides WHERE flag_key = #{flagKey}")
int deleteByKey(@Param("flagKey") String flagKey);
}
@@ -0,0 +1,78 @@
package com.danyang.match.platform.shared.featureflag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Feature Flag 覆盖值的读写。
*
* <p>存在意义:让「运维在后台改过的开关」在重启后仍然生效(V048)。
* 读写都只针对 {@code feature_flag_overrides},不碰内存状态 —— 内存状态由
* {@code FeatureFlagConfig} 负责,两者职责分开,避免"改了内存没落库"这类半截修复。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FeatureFlagOverrideService {
private final FeatureFlagOverrideMapper mapper;
/**
* 读取全部覆盖值。
*
* @return flagKey → 原始字符串值(未解析)
*/
public Map<String, String> loadOverrides() {
List<FeatureFlagOverride> rows = mapper.findAll();
Map<String, String> out = new LinkedHashMap<>();
if (rows == null) {
return out;
}
for (FeatureFlagOverride row : rows) {
if (row.getFlagKey() == null || row.getFlagKey().isBlank()) {
continue;
}
out.put(row.getFlagKey(), row.getFlagValue());
}
return out;
}
/**
* 写入(或更新)一条覆盖。
*
* <p>upsert 走「先 UPDATE、0 行再 INSERT」而非 PG 的 {@code ON CONFLICT}:
* H2 测试库不认后者(同 IdempotencyMapper 的取舍)。并发下两个请求可能同时
* 得到 UPDATE=0 并都去 INSERT,其中一个会撞主键 —— 此处捕获后改走 UPDATE,
* 保证最终只留一行(而不是把并发写暴露成 500)。
*/
@Transactional
public void save(String flagKey, String flagValue, String valueType, String operatorId) {
Objects.requireNonNull(flagKey, "flagKey 不能为空");
Objects.requireNonNull(flagValue, "flagValue 不能为空");
OffsetDateTime now = OffsetDateTime.now();
int updated = mapper.updateValue(flagKey, flagValue, valueType, operatorId, now);
if (updated > 0) {
return;
}
try {
mapper.insert(flagKey, flagValue, valueType, operatorId, now);
} catch (DuplicateKeyException e) {
log.debug("并发写入 flag 覆盖,回退为更新: flagKey={}", flagKey);
mapper.updateValue(flagKey, flagValue, valueType, operatorId, now);
}
}
/** 删除覆盖(恢复默认配置)。 */
public void delete(String flagKey) {
mapper.deleteByKey(flagKey);
}
}
@@ -0,0 +1,29 @@
package com.danyang.match.platform.shared.policy;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* {@link IdentityPolicy} 默认实现:从 Feature Flag({@code livenessEnabled} /
* {@code livenessProvider})读取。
*
* <p>fail-safe:Flag 默认 false;配置缺失(无覆盖行)时沿用 Java 默认值,绝不自动开启。
* 覆盖值优先级见 {@link FeatureFlagConfig}(数据库覆盖 &gt; yml/环境变量 &gt; 字段默认)。
*/
@Component
@RequiredArgsConstructor
public class DefaultIdentityPolicy implements IdentityPolicy {
private final FeatureFlagConfig featureFlagConfig;
@Override
public boolean isLivenessRequired() {
return featureFlagConfig.isLivenessEnabled();
}
@Override
public String getLivenessProvider() {
return featureFlagConfig.getLivenessProvider();
}
}
@@ -0,0 +1,29 @@
package com.danyang.match.platform.shared.policy;
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* {@link MatchingPolicy} 默认实现:委托 {@code MatchRuleConfigProperties}
* (yml 前缀 {@code match})读取当前生效阈值与规则版本。
*
* <p>本类是「统一读取入口」,不新增配置源 —— 阈值/版本仍以
* {@code match.default-score-threshold} / {@code match.rule-version} 为唯一来源。
*/
@Component
@RequiredArgsConstructor
public class DefaultMatchingPolicy implements MatchingPolicy {
private final MatchRuleConfigProperties matchRuleConfigProperties;
@Override
public int getRecommendationMinScore() {
return matchRuleConfigProperties.getDefaultScoreThreshold();
}
@Override
public int getRuleVersion() {
return matchRuleConfigProperties.getRuleVersion();
}
}
@@ -0,0 +1,20 @@
package com.danyang.match.platform.shared.policy;
import com.danyang.match.platform.shared.config.ProfileProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* {@link ProfilePolicy} 默认实现:从 {@code profile.photo.max-count} 配置读取。
*/
@Component
@RequiredArgsConstructor
public class DefaultProfilePolicy implements ProfilePolicy {
private final ProfileProperties profileProperties;
@Override
public int getMaxPhotoCount() {
return profileProperties.getPhoto().getMaxCount();
}
}
@@ -0,0 +1,23 @@
package com.danyang.match.platform.shared.policy;
/**
* 实名 / 活体核验规则统一读取入口(CFG-001 + IDENTITY-001,V1.0.1 配置与规则纠偏)。
*
* <p><b>活体核验不是 V1 强制规则</b>:canonical 配置键 {@code identity.liveness.enabled}
* (实现键 {@code features.livenessEnabled},见 config-register)默认 {@code false}。
* 开关关闭时不得阻断实名主流程、不得要求用户完成活体(FF-LIVENESS-001/004)。
*
* <p>本接口是「假开关」纠偏后的<b>真实业务判定点</b>:实名链路经此决定是否需要活体,
* 开关打开时业务行为确实改变(FF-LIVENESS-002),不存在「配置打开但业务无变化」。
*/
public interface IdentityPolicy {
/**
* 是否强制活体核验。由 Feature Flag {@code livenessEnabled} 驱动(默认 false,fail-safe)。
* 配置缺失时保持默认 false,绝不自动开启。
*/
boolean isLivenessRequired();
/** 当前活体核验 Provider 标识(none / third-party)。none 表示尚未接入合规第三方。 */
String getLivenessProvider();
}
@@ -0,0 +1,24 @@
package com.danyang.match.platform.shared.policy;
/**
* 推荐 / 匹配规则统一读取入口。
*
* <p>业务代码(Controller 下发、推荐引擎、资格判定)不得各自写死阈值数值,
* 一律经本接口取「当前生效值」,使配置源唯一({@code MatchRuleConfigProperties},
* yml 键 {@code match.default-score-threshold} / {@code match.rule-version})。
*
* <p><b>阈值 60 仅为当前技术默认值,未获产品正式确认</b>(OQ-MATCH-001 / config-register
* 中 canonical 名 {@code matching.recommendation.minScore})。前端不得将其包装为
* 「匹配成功率 / 合适概率」等对外承诺。
*/
public interface MatchingPolicy {
/**
* 推荐最低评分阈值。技术默认 60,未获产品正式确认;可经
* {@code match.default-score-threshold} 配置覆盖。
*/
int getRecommendationMinScore();
/** 匹配规则版本号(写入推荐记录以支持历史追溯)。 */
int getRuleVersion();
}
@@ -0,0 +1,16 @@
package com.danyang.match.platform.shared.policy;
/**
* 个人资料规则统一读取入口。
*
* <p>业务代码(Controller / Service / 前端下发配置)不得各自写死照片上限数值,
* 一律经本接口取「当前生效值」,使配置源唯一({@code ProfileProperties})。
*/
public interface ProfilePolicy {
/**
* 个人照片最大数量(头像 + 生活照合计)。
* 技术默认 6,未获产品正式确认;可经 {@code profile.photo.max-count} 配置覆盖。
*/
int getMaxPhotoCount();
}
@@ -30,4 +30,12 @@ public class User extends BaseEntity {
@TableField("last_login_at")
private java.time.Instant lastLoginAt;
/**
* 注销时间(V049)。数据保留到期 = closed_at + {@code account.data-retention-months}
* (默认 36 个月,产品 2026-09-19 确认下限);到期后经人工审核执行匿名化清除。
* 仅 account_status = closed 的记录才有值。
*/
@TableField("closed_at")
private java.time.Instant closedAt;
}
@@ -22,4 +22,17 @@ public interface UserMapper extends BaseMapper<User> {
@Select("SELECT COUNT(*) FROM users WHERE id_number_hash = #{hash} AND verification_status = 'verified' AND deleted_at IS NULL")
int countByIdNumberHash(@Param("hash") String hash);
/**
* 锁住用户行({@code SELECT ... FOR UPDATE}),用于把同一用户的并发写串行化。
*
* <p>用法:在事务内先锁用户行、再用<b>新语句</b>读派生计数(如 active 照片数),
* 读到的是锁释放后的最新已提交数据(READ COMMITTED)。相比「锁照片行再计数」,
* 用户行恒存在 —— 0 张照片时也有锁可加,堵住「首张并发」突破上限的缺口
* (PHOTO-004,纠偏方案 §四并发上传要求)。参照 {@code ReviewTaskMapper} 的 FOR UPDATE 先例。
*
* @return 用户 id;用户不存在/已软删则返回 null(后续 insert 由 user_photos.user_id FK 兜底拒绝)
*/
@Select("SELECT id FROM users WHERE id = #{userId} FOR UPDATE")
String lockUserRow(@Param("userId") String userId);
}
@@ -26,6 +26,8 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
/**
* 用户管理服务
@@ -104,7 +106,7 @@ public class UserService {
public UserDetailDTO getUserDetail(String userId, String operatorId) {
Optional<User> userOpt = userMapper.findById(userId);
if (userOpt.isEmpty()) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
User user = userOpt.get();
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
@@ -139,7 +141,7 @@ public class UserService {
public void updateAccountStatus(String userId, String newStatus, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
}
String oldStatus = user.getAccountStatus();
user.setAccountStatus(newStatus);
@@ -62,6 +62,21 @@ public class IdentityVerificationController {
return ApiResponse.ok(identityVerificationService.getIdentityStatus(userId));
}
/**
* 查询活体核验状态(V1.0.1 配置纠偏 IDENTITY-001:真实业务判定点)。
* GET /api/v1/verification/liveness-status
*
* <p>返回 {@code livenessRequired}(由 Feature Flag 驱动,默认 false)、
* {@code livenessVerificationStatus}(服务器事实)、{@code enhancedVerified}。
* 开关关闭时不影响实名主流程;前端不得据此误提示「必须进行人脸认证」。
*/
@GetMapping("/liveness-status")
public ApiResponse<Map<String, Object>> getLivenessStatus(
@AuthenticationPrincipal UserPrincipal principal) {
String userId = principal.id();
return ApiResponse.ok(identityVerificationService.getLivenessStatus(userId));
}
/**
* 撤销实名(仅 failed 状态可撤销)
* POST /api/v1/verification/cancel
@@ -9,6 +9,7 @@ import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.verification.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import com.danyang.match.platform.verification.provider.IdentityVerificationProvider;
import com.danyang.match.platform.shared.policy.IdentityPolicy;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -20,6 +21,9 @@ import java.time.Period;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.Map;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import com.danyang.match.platform.shared.exception.ServerErrorException;
@Service
@RequiredArgsConstructor
@@ -33,6 +37,7 @@ public class IdentityVerificationService {
private final IdentityVerificationProvider identityProvider;
private final EncryptionUtil encryptionUtil;
private final AuditLogMapper auditLogMapper;
private final IdentityPolicy identityPolicy;
/**
* 提交实名认证
@@ -52,14 +57,14 @@ public class IdentityVerificationService {
// 3. 检查是否已实名
userIdentityMapper.findByUserId(userId).ifPresent(exists -> {
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(exists.getVerificationStatus())) {
throw new RuntimeException("已经实名认证,无需重复提交");
throw new BusinessException(ApiResponseCode.CONFLICT, "已经实名认证,无需重复提交");
}
});
// 4. 检查身份证是否已被其他账号使用
String idHash = encryptionUtil.hashIdNumber(idNumber);
if (userIdentityMapper.existsByIdNumberHash(idHash)) {
throw new RuntimeException("该身份证已被其他账号实名认证");
throw new BusinessException(ApiResponseCode.CONFLICT, "该身份证已被其他账号实名认证");
}
// 5. 调用实名认证 Provider
@@ -68,7 +73,7 @@ public class IdentityVerificationService {
providerResult = identityProvider.verify(realName, idNumber);
} catch (Exception e) {
log.error("实名认证 Provider 调用失败", e);
throw new RuntimeException("实名认证服务暂时不可用,请稍后重试");
throw new ServerErrorException("实名认证服务暂时不可用,请稍后重试");
}
// 6. 检查验证结果
@@ -76,7 +81,7 @@ public class IdentityVerificationService {
if (verified == null || !verified) {
// 保存失败记录
saveFailedIdentity(userId, idNumber, idHash, realName, "VERIFY_FAILED");
throw new RuntimeException("实名认证失败:姓名与身份证不匹配");
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "实名认证失败:姓名与身份证不匹配");
}
// 7. 成年校验(核心业务规则)
@@ -86,7 +91,7 @@ public class IdentityVerificationService {
if (age < MIN_AGE) {
saveFailedIdentity(userId, idNumber, idHash, realName, "UNDER_18");
throw new RuntimeException("未满18周岁,不能使用本婚恋服务");
throw new BusinessException(ApiResponseCode.USER_UNDER_18, "未满18周岁,不能使用本婚恋服务");
}
// 8. 从身份证解析性别(如果 Provider 未返回)
@@ -160,6 +165,47 @@ public class IdentityVerificationService {
});
}
/**
* 活体核验是否强制(IDENTITY-001 业务判定委托)。开关由 Feature Flag 驱动,默认 false。
*/
public boolean requiresLiveness() {
return identityPolicy.isLivenessRequired();
}
/**
* 活体核验状态(IDENTITY-001 真实业务判定点,V1.0.1 配置纠偏)。
*
* <p>开关({@code identity.liveness.enabled})打开时,<b>增强核验</b>需要活体通过;
* 活体状态取自服务器事实 {@code user_identities.liveness_verification_status},
* 前端伪造的「已通过」无效(FF-LIVENESS-005)。
*
* <ul>
* <li>FF-LIVENESS-001:开关关闭 → {@code livenessRequired=false},不阻断实名主流程;</li>
* <li>FF-LIVENESS-002:开关打开 → {@code livenessRequired=true},未活体通过则
* {@code enhancedVerified=false}(业务状态确实改变,非假开关);</li>
* <li>FF-LIVENESS-004:配置缺失 → 沿用默认 false,不自动开启。</li>
* </ul>
*/
public Map<String, Object> getLivenessStatus(String userId) {
boolean required = identityPolicy.isLivenessRequired();
UserIdentity identity = userIdentityMapper.findByUserId(userId).orElse(null);
String livenessStatus = identity != null ? identity.getLivenessVerificationStatus() : null;
if (livenessStatus == null) {
livenessStatus = "not_required";
}
boolean basicVerified = identity != null
&& Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus());
boolean livenessPassed = "passed".equals(livenessStatus);
Map<String, Object> status = new HashMap<>();
status.put("userId", userId);
status.put("livenessRequired", required);
status.put("livenessProvider", identityPolicy.getLivenessProvider());
status.put("livenessVerificationStatus", livenessStatus);
// 增强核验 = 基础实名通过 且(未要求活体 或 活体已通过)
status.put("enhancedVerified", basicVerified && (!required || livenessPassed));
return status;
}
/**
* 撤销实名(仅限 failed 状态)
*/
@@ -169,7 +215,7 @@ public class IdentityVerificationService {
.orElseThrow(() -> new RuntimeException("未找到实名认证记录"));
if (Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) {
throw new RuntimeException("已认证的实名记录不能撤销");
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "已认证的实名记录不能撤销");
}
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_NOT_VERIFIED);
@@ -28,6 +28,9 @@ jwt:
object-storage:
endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
# 对外可达的 S3 网关地址:presigned URL 下发给客户端直传用。
# 缺省回退到内网 endpoint(容器名),那时直传仍不可达 —— 运行环境必须显式配置。
public-endpoint: ${MINIO_PUBLIC_ENDPOINT:${MINIO_ENDPOINT}}
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
bucket-public: ${MINIO_BUCKET_PUBLIC:public}
@@ -47,6 +50,11 @@ match:
default-cooldown-days: 180
local-eligibility-required: ${MATCH_LOCAL_ELIGIBILITY_REQUIRED:false}
# Profile(个人照片上限:技术默认 6,未获产品正式确认;经环境变量可覆盖)
profile:
photo:
max-count: ${PROFILE_PHOTO_MAX_COUNT:6}
activity:
default-need-review: true
default-mutual-max-selection: 3
@@ -64,11 +72,6 @@ features:
chat-image-enabled: false
appeal-enabled: false
# V1.1
counseling-enabled: true
premarital-enabled: true
content-enabled: true
service-location-enabled: true
local-eligibility-enabled: true
referral-enabled: true
mutual-consent-enabled: true
service-program-enabled: true
@@ -79,5 +82,3 @@ features:
local-evidence-max-file-size-mb: 10
local-evidence-allowed-types: ${LOCAL_EVIDENCE_ALLOWED_TYPES:jpg,png}
local-evidence-retention-days: ${LOCAL_EVIDENCE_RETENTION_DAYS:365}
counseling-attachments-private: true
counseling-attachments-bucket: counseling-attachments
@@ -449,7 +449,6 @@ DROP TABLE IF EXISTS _conv_result;
-- V1.1 Feature Flag 初始化
-- ============================================================
INSERT INTO system_configs (config_key, config_value, description) VALUES
('livenessVerification.enabled', 'false', '人脸核验功能开关(V1.1预留,默认关闭)'),
('localEligibility.evidencePolicy', '"need_based"', '本地资格佐证策略:need_based=按需触发 / mandatory=全部强制'),
('counseling.enabled', 'true', '婚恋咨询功能开关'),
('premarital.enabled', 'true', '婚前辅导功能开关'),

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