chore(release): 冻结 v1.1.0 版本

- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0
- 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md):
  - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast)
  - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto)
  - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天)
  - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入
  - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置
- 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖)

未验证项(打标签 ≠ 生产发布):
- 后端编译 / 单测未在本环境执行(Maven 慢、Docker DNS 受限)
- 管理端 登录→刷新→登出 端到端、小程序真机走查待人工验收
- 生产部署需注入 WECHAT_APP_ID / WECHAT_APP_SECRET、VERIFICATION_API_URL / VERIFICATION_API_KEY 与小程序生产域名
This commit is contained in:
WorkBuddy
2026-09-30 12:10:00 +08:00
parent 1ad9793ea2
commit ca0db57f24
53 changed files with 1256 additions and 83 deletions
+178
View File
@@ -0,0 +1,178 @@
# 善缘丹阳 代码审查报告
> 审查日期:2026-09-24
> 审查范围:后端(Java 21 / Spring Boot 3)、微信小程序(原生 + TS)、管理端(Vue 3 + Vite + Pinia)
> 审查视角:CodeReviewExpert — 正确性 / 安全 / 可维护 / 性能 / 测试
> 核验声明:下方 🔴 阻断 1–4 与 🟡 刷新 Token TTL 均由主审查员**直接读源码 + grep 复核**确认;其余条目为子代理带文件路径核查,结论可信但建议落地前再回读一次原文件。
---
## 一、总览
**总体印象**:本项目的工程纪律落地得相当扎实——幂等(`@Idempotent` + 唯一索引)、数据权限守卫(`DataScopeGuard` 真实接入多处)、WXML 结构守卫、请求层"先 resolve 再收尾"的时序守卫,都不是文档承诺而是真实代码。这是少见的"纪律工程化"水平。
**但存在 2 个已确认的上线阻塞**(与 UAT 走查结论一致):微信登录无真实实现、照片直传不落库;此外发现若干安全/正确性缺陷,其中管理端 token 存储、小程序红娘接单鉴权头错位、刷新 Token TTL 误用会直接影响可用性与安全。
**优先级**:上线前必须关闭 🔴 1–4;🟡 建议同批修复。
---
## 二、🔴 阻断(必须修,否则不能上线)
### 1. 微信登录生产默认走 Mock —— 任意 code 可无限建号
`backend/.../auth/provider/MockWechatLoginProvider.java:19`
```java
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
public class MockWechatLoginProvider implements WechatLoginProvider {
private final Map<String, String> codeToOpenid = new ConcurrentHashMap<>();
// :35
String openid = codeToOpenid.computeIfAbsent(code, k -> "mock_openid_" + UUID.randomUUID()...);
```
**为什么是问题**:`matchIfMissing = true` 意味着**未显式配置即启用 Mock**。`application-production.yml` 中并无 `verification.provider`(只有 logging/springdoc/security),故生产环境默认走 Mock。Mock 实现对任意 `code` 都生成随机 openid 并自动建号——攻击者可凭任意字符串无限注册假账号,且 `wechat.app-id` 默认 `dev-app-id`。这是实名/风控链路的根基性缺口。
**修复**:
- 新增 `RealWechatLoginProvider`(`@ConditionalOnProperty(... havingValue = "real")`),调用 `https://api.weixin.qq.com/sns/jscode2session` 用真实 `code` 换取 openid/session_key,并校验 appid/secret。
- 生产 `application-production.yml` 显式置 `verification.provider=real`,并**删除 `matchIfMissing=true`**(或仅留给本地 dev profile)。
- 接入 `code2session` 失败、空 openid、appid 不匹配的拒绝分支。
### 2. 照片直传不落库 —— 直传后无"确认/落库"端点
`backend/.../me/controller/MeController.java:227` 与 `photo/controller/PhotoController.java:32` 均暴露 `POST /.../photos/upload-token`,调用 `PhotoService.getUploadToken` 返回 MinIO **预签名 PUT URL + fileKey**(`PhotoService.java:75-86`)。但全仓**没有任何端点接收直传的 `fileKey` 并写 `user_photos`**:
- `savePhoto(...)` 仅被 `PhotoService.java:233-245` 的多段上传 `POST /photos/upload`(`MultipartFile` 后端代传)调用;
- `getSignedUrl`(`:96-99`)按 `user_photos` 校验归属:`boolean owned = userPhotos.stream().anyMatch(p -> fileKey.equals(p.getFileKey()))`。
**为什么是问题**:走直传路径时,对象进了 MinIO,但 `user_photos` 无记录 → `getPhotos` 不展示,**且 `getSignedUrl` 归属校验直接失败,连访问鉴权都过不了**。直传等于"传了白传"。这是已知阻塞的真实根因。
**修复**:新增 `POST /me/photos/confirm`(或 `POST /photos/confirm`),入参 `fileKey + photoType`,服务端校验 fileKey 属当前用户 + 对象已存在于 MinIO 后调用 `savePhoto`,并触发人工审核状态机。小程序侧直传成功后调用该确认端点。
### 3. 小程序红娘"接单"鉴权头错位 —— 请求实际无鉴权
`miniapp/utils/api.js:272`
```js
post(url, data, headers, idempotencyKey) { ... }
```
`miniapp/pages/matchmaker-workbench/matchmaker-workbench.js:85`
```js
api.post(`/operator/matchmaker/services/${serviceId}/claim`, {}, {}, { Authorization: `Bearer ${token}` })
```
**为什么是问题**:第 4 个参数是 `idempotencyKey`,第 3 个 `headers` 被传成 `{}`。于是 `Authorization` 对象被塞进幂等键位,而请求头里**没有任何鉴权头**。且 operator 流程用的是 `operatorToken`(非用户 `accessToken`),`api.getToken()` 读不到它——所以本请求必然 401,红娘"接单"必失败。同文件 `:50-51` 用 `api.get(url, {}, {Authorization})` 三参形式才正确,两处不一致即证据。
**修复**:改为 `api.post(url, {}, { Authorization: \`Bearer ${token}\` })`(去掉第 4 参)。
### 4. 管理端 token 存 localStorage + 权限清单可被本地篡改
`admin-web/src/stores/auth.ts:26,45-46`
```ts
const token = ref<string>(... localStorage.getItem('accessToken') || '')
localStorage.setItem('accessToken', res.data.accessToken)
localStorage.setItem('operator', JSON.stringify(operator.value)) // 含 permissions
```
`admin-web/src/api/client.ts:11` 拦截器统一注入 `Bearer`,但另有 30+ 处 `.vue` 直接 `localStorage.getItem('accessToken')` 手工注入(如 `ReportCenterView.vue:127/137/155/165`、`OperatorManagementView.vue` 多处、`ActivityCheckinView.vue:114`)。
**为什么是问题**:
- 令牌落 localStorage + 项目内大量 `any` 与裸 `console.error(e)`,任意 XSS 即可 `localStorage.getItem('accessToken')` 取走令牌直连后端,**账号接管**。
- `operator`(含 `permissions`)序列化进 localStorage,前端 `router/index.ts` 的 `hasPermission` 与 `MainLayout.vue` 菜单过滤只比对本地产物——攻击者改 `localStorage.operator.permissions` 即可在前端解锁菜单/按钮(虽服务端仍 403,但**可见性完全由可被篡改的本地数据决定**,违反"前端不是安全边界")。
**修复**:
- 短期:令牌改内存态 + 短时效,刷新令牌走 httpOnly Cookie;删除各 `.vue` 的手工 `localStorage.getItem('accessToken')`,统一依赖 `client.ts` 拦截器。
- 权限可见性由服务端下发 capability 信封驱动(见 🟡 #4),前端不再以本地 `permissions` 数组作为显隐的唯一依据。
---
## 三、🟡 建议(应优先修,真实缺陷)
### 5. 刷新 Token 的 Redis TTL 用错 → 用户每 4 小时被强制登出
`backend/.../auth/service/AuthService.java:94`(登出同 :177-178)
```java
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken,
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
```
**为什么是问题**:`getExpirationMs()` 返回 **access** 过期(默认 2h),`*2 = 4h`;而 refresh JWT 自身有效期是 `refreshExpirationMs`(默认 14 天,`JwtTokenService.java:39/81`)。`refreshToken()` 第 3 步(:167-169)用 Redis 记录做可撤销校验,4h 后记录消失即抛 `UnauthorizedException` → **用户每 4h 被迫重新登录**。
**修复**:改为 `Duration.ofMillis(jwtTokenService.getRefreshExpirationMs())`(或新增对应 getter),使 Redis 记录生命周期与 refresh JWT 一致。
### 6. 手机号绑定无真实校验(TODO 残留)
`AuthService.java:113`:`// TODO: Phase 03 对接真实短信验证码`,`bindPhone` 仅查重后 `user.setPhone(phoneCode)`。结合 #1 Mock 登录,任何人可绑定任意手机号,属实名链路缺口。需补短信验证码校验。
### 7. 小程序 `bind-phone` 空桩 + operator 登录双轨清 token
- `pages/bind-phone/bind-phone.js` 为 `Page({ data:{}, onLoad(){}, onShow(){} })` 空实现,用户进页空白。
- `pages/matchmaker-login/matchmaker-login.js:39-51` 自建 `operatorToken` 并裸用 `wx.request` 绕过 `utils/api.js`;当 operator 接口回 401 时,`api.onSessionExpired` → `api.clearToken()` 清的是**用户** `accessToken/refreshToken`(api.js:113-120),会**误清用户登录态**。建议 operator 通道也走 `api` 层并隔离失效回调。
### 8. 小程序生产域名是占位符
`miniapp/env.js:27` / `env.ts:24`:`: 'https://api.danyang-match.example.com/api/v1'`。`example.com` 为保留占位域名,生产构建连不上真实后端。需按环境注入真实域名(构建变量 / 配置中心)。
### 9. 小程序 `serviceType` 枚举直渲未映射中文
`pages/matchmaker-workbench/matchmaker-workbench.wxml:59`
```xml
<view class="service-tag">{{item.serviceType === 'recommend_partner' ? '推荐对象' : item.serviceType}}</view>
```
除 `recommend_partner` 外,其它 `serviceType`(如 `red_line_prevention`)会把英文枚举码直接显示给用户,违反 `constants/profileLabels.js`"界面不得出现英文枚举码"铁律。需补 `serviceType` 中文映射表。
### 10. 管理端敏感处置缺二次确认
`src/modules/risk/ReportCenterView.vue:91` 对封禁/暂停(30天)等直接 `post(.../process)`,无 `ElMessageBox.confirm`;而 `UserDetailView.vue:113` 改账号状态有确认。同类高危操作应统一二次确认 + 审计提示。
### 11. 管理端取数/显隐仍由前端权限启发式决定(Capability 应服务端驱动)
`src/services/dashboard.ts:66-67,93-119` 用 `permissions.includes('analytics.view')` 决定请求接口并打 `available` 控制显隐;`MainLayout.vue:209-222` 用权限拼"角色名"。能力虽源自服务端权限串,但映射仍在前端硬编码。理想做法是由服务端下发 capability 信封驱动可见性与取数。
### 12. 管理端 `any` 滥用、跨端字段漂移、举报列表未分页
- `UserDetailView.vue:90`、`UserManagementView.vue:112`、`ReportCenterView.vue` 多处 `ref<any>`、`catch(e)` 未标注类型;跨端字段如 `phoneMasked/sensitiveMasked`、`totalLimit↔capacity` 缺共享 TS 接口,易产生字段名不一致。
- `ReportCenterView.vue:129` 列表 `res.data || []` 直接渲染、未分页,大举报量有性能隐患。
---
## 四、💭 细节(已良好 / 留意)
**✅ 已落实良好的部分(值得肯定)**
- 请求层时序守卫:`utils/api.js:180-198` 已实现"先 `resolve(body.data)` 再做 `releaseIdempotencyKey` 收尾",正是工程纪律中"私有方法放 resolve 前致所有 Promise 永不 settle"坑的工程化规避;`:140-142` 的 `isAuthEndpoint` 豁免 `/auth/*` 自我递归。
- 数据权限守卫真实落地:`DataScopeGuard.deny` 已接入 `CheckinService`、`CounselingService`(Counselor 仅看 assigned + 敏感访问审计)、`ActivityService`、`ServiceAppointmentService`,非注释。
- 幂等机制扎实:`@Idempotent` 覆盖所有写端点,`IdempotencyInterceptor` 用 DB 唯一索引 `uk_idempotency_scope` 占位防并发重复。
- 配置可配置化良好:`MatchRuleConfigProperties` 权重/阈值/冷却期全部外部化(`match.*`),符合"不得写死业务规则"。
- 管理端设计系统 token 收敛良好(`theme/index.css` / `tokens.ts` 为唯一色源,输入框已含 `Microsoft YaHei`)。
**⚠️ 仍需留意 / 复核**
- SQL 注入:`.java` 内 `grep \${` 命中均为 `@Value` 占位符,mapper 走 MyBatis-Plus `LambdaQueryWrapper`,未见 `${}` 拼接 SQL。**但**项目含 MyBatis,需再扫 `src/main/resources` 下 `*Mapper.xml` 原生 SQL(`@Select` 等)确认无 `${}`。
- `SecurityConfig` 的 `requestMatchers` 相对 context-path `/api/v1` 是否被正确剥离(已有 `SecurityPathCoverageTest` 守护,风险低,建议回读确认)。
- `HomeBannerOperatorController:153` 仅拦截 `http(s)` 外链,需确认是否只允许 `/pages/` 内部跳转而非任意 http 内部域(INV-016 Banner 不能任意外链)。
- 跨端字段名靠运行时契约(tsc 不保护):`stores/authStore.js` 注释自曝已三次踩坑(`accessToken` 非 `token`、`isNewUser` 非 `!needPhone`、`regionCode/createdAt` 等 camelCase)。建议对关键响应用 `result.accessToken || result.token` 兜底断言。
---
## 五、表扬
- **纪律工程化**做到位:把"WXML 结构、私有方法调用、组件选择器、请求层时序、幂等"等易踩坑点都做成了守护测试与行为契约,而不是靠人肉 review。这是本项目最值得保留的资产。
- **文档与代码分离意识强**:AGENTS.md 明确"文档✅不是需求,只有代码/测试/验收才证明完成",且本审查中也确实发现若干文档声明与代码现实不符(微信登录、照片落库),说明团队有"不信绿"的底线。
---
## 六、下一步建议(优先级排序)
1. **上线前必须关闭**:🔴 #1 微信真实登录(已修复)、🔴 #2 照片直传落库端点(已修复)、🔴 #3 红娘接单鉴权头(已修复)、🔴 #4 管理端 token/权限存储(已修复,见第二节)。
2. **同批修复**:🟡 #5 刷新 Token TTL(影响所有用户登录体验)、#6 手机号真实校验。
3. **排期修复**:#7–#12。
4. **补一轮针对性测试**:直传落库端到端、刷新 Token 生命周期、operator 通道失效不误伤用户登录态、枚举中文映射契约测试。
> 说明:本审查以"证据优先"为原则,🔴 与 #5 均经主审查员直接读源码复核;落地修复前建议对每个文件再回读一次原文,确认行号与上下文未漂移。
---
## 七、第二轮修复(原延后项已落地,2026-09-24 续)
> 以下两项为上一轮"刻意未改/建议下一轮"的延后项,本轮已直接动手修复,均经源码级核验(本机 bash 不可用,未做编译/运行验证)。
### A. 生产实名核验 Mock → Real(对应 AGENTS.md 硬规则:本地资格/实名认证分离、敏感数据默认拒绝)
- 新增 `verification/provider/RealIdentityVerificationProvider.java`(`@ConditionalOnProperty(name="identity.verification.provider", havingValue="real")`):
- 本地强校验身份证号 **GB 11643-1999 校验位(mod 11-2)**,伪造证件号在外部调用前即拒绝;
- 第三方核验走可配置 `identity.verification.real.api-url` + `api-key`(环境变量 `VERIFICATION_API_URL` / `VERIFICATION_API_KEY`);
- **fail-closed**:任何无法明确判定为"通过"的响应一律视为未通过;**缺第三方配置即 fail-fast**(不静默放行任意证件)。
- `MockIdentityVerificationProvider` 属性从 `verification.provider` **解耦**为 `identity.verification.provider`(`matchIfMissing=true` 保留本地默认)。
- `application-production.yml` 显式置 `identity.verification.provider=real` + 注入 api-url/api-key;`application-dev.yml` / `application-docker.yml` 保持 mock 默认。
- **契约对接提示**:第三方请求/响应字段(如厂商选公安实名或运营商三要素)需在接入时最终确认;当前以 `success/match/verified/passed` 或 `code==0` 判定通过。
### B. 管理端 token 改 HttpOnly Cookie(对应原 🔴 #4)
- 后端 `OperatorCookieWriter`:登录/刷新成功下发 **HttpOnly + SameSite=Lax** 的 `dy_op_rt`(refresh)/ `dy_op_at`(access)Cookie;登出清除。`Secure` 由 `app.auth-cookie-secure` 控制(dev/docker false、production true)。
- `OperatorAuthController`:新增 `/auth/operator/refresh`(cookie 优先于 body,支持 silent-refresh)、`/auth/operator/logout`(清 Cookie + 黑名单);登录/刷新均重下发 Cookie。
- `JwtAuthFilter`:取 token 改为 **Authorization 头优先、缺则回退 `dy_op_at` Cookie**(小程序/用户端仍走头,不受影响)。
- 前端 `stores/auth.ts`:**移除 localStorage 存 token/权限**,改内存态;新增 `initializeSession()` 凭 HttpOnly refresh Cookie 静默恢复会话(刷新页面免重登);`client.ts` 拦截器 401 时自动静默刷新一次后重试(带防重入守卫);9 个 `.vue` 中的 `localStorage.getItem('accessToken')` 全部改为内存态 `getAccessToken()`。
- **安全收益**:XSS 无法直接读取/外泄令牌,也无法改写本地权限清单伪造菜单(服务端 `@PreAuthorize` 仍是真边界)。
- **验证缺口(需你确认)**:本机未编译/运行;建议后端补编译 + 管理端登录→刷新→登出端到端走查;dev 跨端口代理与 prod 同源场景均依赖 Cookie(Cookie 不按端口划分作用域,dev 同样可用)。
### 仍延后(非本轮范围 / 需外部凭证)
- **手机号绑定真实校验**:需接短信验证码网关,无凭证无法真实实现,保留 TODO,未伪造。
- 上一轮"仍需留意"项(MyBatis xml `${}` 原生 SQL、SecurityConfig 路径剥离、Banner 外链范围、跨端字段名契约)维持原结论,未改动。
+196
View File
@@ -0,0 +1,196 @@
# 代码审查标准与流程 — 善缘丹阳公益婚恋平台
> **版本**:V1.0 · **生效**:2026-09-23 · **owner**:技术负责人 / 全体提交者
> **配套**:`PLAYBOOK.md`(操作手法) · `known-risks.md`(已知债务) · `decision-register.md`(已确认决策) · `.github/workflows/ci.yml`(自动门禁)
> **目的**:本仓库是多端单体仓库(Spring Boot 后端 + 微信小程序 + Vue3 管理端 + 迁移脚本 + E2E),历史上已多次出现"本地/CI 全绿但线上失效"的**假绿**陷阱。本文档把已踩过的坑固化成**人在合并前必须执行的审查纪律**,与 CI 自动门禁形成互补——CI 是底线兜底,人工审查是质量上限。
---
## 0. 总原则
1. **证据优先,信任代码而非措辞**:PR 描述里的"✅ 已完成 / 已修复 / 已验证"**不是证据**,只是主张。审查者只认:可读的代码改动 + 能证明判别力的测试 + 运行时实测结论。文档/PPT/截图里写的"已完成"一律按"未证实"处理。
2. **审查是协作质量门,不是找茬**:目标是让合并后的代码比提交时更稳。区分"必须拦下"(Blocker/Major) 与"建议改进"(Minor/Nit)。
3. **清单是地板不是天花板**:本文清单覆盖的是本仓库**反复出过事**的点;未列出的通用工程规范(命名、复杂度、可读性)同样适用。
4. **任何放行都需可追溯**:审查意见、拒绝原因、已知债务豁免都留在 PR 或 `known-risks.md`,不靠口头"应该没问题"。
---
## 1. 适用范围与仓库分层
| 层 | 路径 | 技术栈 | 本仓库最高风险点 |
|----|------|--------|------------------|
| 后端 | `backend/` | Spring Boot / Java 21 / Maven / Flyway / JWT | 裸 `RuntimeException`→500、弱断言、业务不变量被绕过 |
| 小程序 | `miniapp/` | 原生 WXML/WXSS/TS,`app.json`/`pages.json` 声明式 | `tsc` 不保跨端字段名;`ignoreDevUnusedFiles` 吞资源;WXSS 编译静默失效 |
| 管理端 | `admin-web/` | Vue3 / TS / pnpm / Element Plus | 非响应式状态不重渲染;设计 token 编译期生成;`vite build` 清 `dist/` |
| 迁移 | `backend/src/main/resources/db/migration/V*.sql` | Flyway | 版本缺号/重复、CHECK 约束与 Java 枚举漂移 |
| 测试 | `tests/e2e/`(pytest)、`ui-tests/*.mjs`(契约守卫)、`backend/src/test/` | 多框架 | 空壳测试、缺负向注入、守卫数下限被悄悄删 |
| 脚本/基建 | `scripts/`、`infrastructure/` | Node/Python/Compose | 依赖声明与实际不符(本地绿/CI 红) |
---
## 2. 审查分级(按变更风险)
| 级 | 名称 | 触发条件 | 最少审查人数 | 审查深度 |
|----|------|----------|--------------|----------|
| **T0** | 琐碎 | 纯文案/注释/文档、`*.md`、与逻辑无关的格式化、资源重命名(无引用变更) | 1(可自审) | 仅看是否误伤其他引用 |
| **T1** | 常规 | 单端功能开发、Bug 修复、UI 调整、单个 API 增改 | 1 | 走 §4 对应技术栈清单 + §5 判据纪律 |
| **T2** | 高危 | 涉及:鉴权/登录链路、支付/资金、数据隐私(PII)、跨端契约字段、数据库 schema 变更、并发/幂等、未接真实现的已知阻塞项(见 §6.4) | 2(含 1 名对应该端熟悉者) | 全清单 + 运行时实测结论 + 回滚方案 |
> 判定不清时**就高不就低**。一人团队下"第 2 人"可由技术负责人交叉复核,或在 PR 中显式标注"已自审 + 已跑实测"并附命令与输出。
---
## 3. 严重级别定义
| 级 | 含义 | 合并策略 | 本项目真实示例 |
|----|------|----------|----------------|
| **Blocker** | 合并后会破坏生产或导致数据错误/安全泄露 | **必须修**,否则拒绝合并 | 裸 `RuntimeException` 掩盖业务拒绝(→500);`updateById` 对 `user_id` 主键静默 no-op;跨端字段名写错使请求永不落库 |
| **Major** | 重要功能不可用或存在明显技术债放大风险 | 必须修或可书面豁免(登记到 `known-risks.md`) | `tsc` 通过但 WXML 多一个 `</view>` 编译失败;守卫被删导致"假绿";新增死表/死配置 |
| **Minor** | 不影响功能但降低可维护性/一致性 | 建议修,可不阻塞 | 魔法数字未抽常量;日志缺失;字段命名与 `decision-register` 不一致 |
| **Nit** | 风格/偏好层面 | 不阻塞,鼓励修 | 换行、注释措辞、导入顺序 |
**原则**:Blocker/Major 必须在该 PR 闭环或明确转移到 `known-risks.md`;Minor/Nit 不得作为合并阻塞,避免"审查变成挑刺"。
---
## 4. 各技术栈审查清单(核心)
> 每项审查时**逐项打勾**(在 PR 模板里),打勾 = 已实际验证,不是"应该没问题"。
### 4.1 后端 Java / Spring Boot
- [ ] **异常分类正确**:业务拒绝抛 `BusinessException` + `ApiResponseCode`,HTTP 落在 `400 ≤ code < 500`;**不得**用裸 `RuntimeException`(`orElseThrow(() -> new RuntimeException(...))` 形态也要改,历史上漏过 17 处)。
- [ ] **断言有判别力**:测试对"业务拒绝"断言 `400 ≤ status < 500`,**不得**只断言 `!= 200`(会把 500 洗成"业务拒绝"假绿)。
- [ ] **不变量守住**:匹配/聊天/活动互选等核心规则(见 `decision-register` 的 LOCKED 项,如 INV-014/015 两阶段同意、Required/Mutual/Block)未被绕过;无"直连绕过"或"单方直建"的捷径。
- [ ] **数据落库正确**:主键/唯一键用法正确(如 `UserPreference` 真实主键是 `user_id`,不能用 `updateById` 对其 no-op);NOT NULL / CHECK 约束与 Java 枚举一致。
- [ ] **幂等/并发**:写接口有幂等键或乐观锁;无裸 `synchronized` 跨实例假设。
- [ ] **配置可信源**:新增开关走活键(`application*.yml` 的 `match.*`/`features.*`),**不得**新增 `system_configs`/`match_rule_configs` 这类"死表"(见 KR-02/KR-03)。
- [ ] **敏感信息**:密钥/口令不入库、不进日志;JWT Secret 来自环境变量。
### 4.2 小程序 miniapp(原生)
- [ ] **`app.json` / `pages.json` 一致性**:tabBar、页面路径、资源路径与磁盘真实文件一致;路径**不带前导 `/`**;引用的图标/图片文件确实存在。
- [ ] **资源不被吞**:`project.config.json` 的 `ignoreDevUnusedFiles` / `ignoreUploadUnusedFiles` 必须为 `false`(否则只被 `app.json` 声明、不被任何 WXML 引用的图标会被打包/上传剔除)。
- [ ] **跨端字段名**:小程序发出的字段名与后端规范名完全一致(历史漂移:`hasChildren`/`occupationCategory`/`workRegion`/`ageMin`/`educationAccepted`/`maritalStatusAccepted` 等);**tsc 不保护 JS 层字段名**,必须看值。
- [ ] **WXSS 合法性**:无通配符 `*`(编译期致命);不用属性选择器 `[attr]`(静默失效);注释不嵌套;`display:grid` 在 Skyline 不支持→用 flex。
- [ ] **胶囊安全区**:自定义导航让出 `--nav-right`(`utils/nav-metrics.js` 唯一事实源),不遮挡右上角胶囊。
- [ ] **`<image>` 高度**:只写 `width` 不写 `height` 会按默认 240px 撑高(卡片异常高大真因),必须显式高度或 `mode`。
### 4.3 管理端 admin-web(Vue3 / TS)
- [ ] **响应式状态**:涉及渲染的数组/对象改动走 `ref`/`reactive` 或 `reactive()` 包裹,**不**用普通声明数组当状态写且出现在模板(曾致"静态守卫全绿但页面不重渲染");改完必须补**渲染态实测**。
- [ ] **设计 token**:颜色/字号收敛到 `src/theme/index.css` + `src/theme/tokens.ts`(同名取值双向校验);Element Plus 主色阶是编译期静态生成,`--el-color-primary` 改了必须补全 `-light-N`/`-dark-2`/`-rgb`,否则残留默认蓝。
- [ ] **类型安全**:跨组件 props/接口用 TS 类型;不靠 `any` 绕过;表单校验用 Zod/类型安全方案,错误内联且**保留用户输入**(出错不清空)。
- [ ] **触摸目标**:交互元素 ≥ 44×44px,间距 ≥ 8px;尊重 `prefers-reduced-motion`。
### 4.4 数据库迁移 SQL(Flyway)
- [ ] **版本连续唯一**:`V<序号>__<描述>.sql` 命名,无缺号/重复(CI 的 `Migration Validation` job 会校验,但人也要看变更意图)。
- [ ] **向后兼容**:不破坏现有列/约束;NOT NULL 新列须有默认值或配套数据迁移。
- [ ] **CHECK 约束与枚举一致**:新增/修改 CHECK(如 `chk_profile_gender`、`chk_profile_edu`)必须与 Java 枚举值逐一对应,避免 `DataIntegrityViolationException`(500)。
- [ ] **可逆性**:破坏性变更(DROP/重命名)必须有专门的回滚迁移或明确登记为一次性。
### 4.5 测试(单测 / E2E / 契约守卫)
- [ ] **无空壳**:无 `skip`/`pass` 空测试(CI `Test Shell Guard` 会拦,但人也要看测试是否真在断言什么)。
- [ ] **负向注入**:新增或修改测试后**必须做负向注入**证明测试有判别力(故意改坏被测代码,测试应转红);不做负向注入 = 视为未验证。
- [ ] **守卫数下限**:`scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 **23**)是硬下限,增删守卫必须同步改该常量;审查时确认守卫数量未偷偷下降。
- [ ] **E2E 真实执行**:`tests/e2e` 用例在本机用真实栈跑过(非 import 期即崩的"假绿");依赖 `docker` 在 PATH(否则 `FileNotFoundError` 误判)。
### 4.6 脚本与基础设施
- [ ] **依赖声明即真实依赖**:脚本 import 的包(如 `ui-tests` 守卫依赖 `miniapp/node_modules/typescript`)必须在 CI 对应 job 里安装——"本地能跑"不等于"CI 能跑"。
- [ ] **幂等与可重入**:脚本可重复执行、不产生副作用累积;密钥仅在运行期、不落盘。
- [ ] **Compose 隔离**:本地栈固定 `-p danyang`,不与邻项目撞名(KR-09 教训:`-p infra` 互删容器)。
---
## 5. 判据纪律:审查必须拒收的"假绿"模式(重点)
> 以下每一条都来自本仓库真实事故。审查者看到对应改动,**必须主动排查**,不能因为 CI 绿就放行。
| # | 假绿模式 | 为什么是缺陷 | 审查动作 |
|---|----------|--------------|----------|
| 1 | **文档/PPT 写"✅ 已完成"** | 措辞不是证据 | 要求贴代码 diff / 测试输出 / 实测命令,否则按未证实处理 |
| 2 | **空数据假绿** | 列表/推荐接口返回空数组也能"通过",掩盖查询逻辑失效(推荐曾恒空) | 审查测试是否用**真实种子数据**断言非空结果 |
| 3 | **直连绕过假绿** | 测试直连 Service 而非走 Controller,跳过鉴权/序列化/HTTP 语义 | 关键路径至少有一个经 HTTP 层的端到端断言 |
| 4 | **测试库缺约束假绿** | 测试库没建 CHECK/外键,生产库的约束会在运行时炸成 500 | 约束相关改动必须在带约束环境下验证 |
| 5 | **弱断言 `!= 200`** | 把 500 洗成"业务拒绝",假绿 | 业务拒绝一律断言 `400 ≤ code < 500` |
| 6 | **裸 `RuntimeException`** | 属于 500 家族缺陷,掩盖真实错误 | grep `new RuntimeException` 与 `orElseThrow(() -> new RuntimeException`,要求改 `BusinessException` |
| 7 | **代码合法 ≠ 渲染正确** | Vue 普通声明数组当状态、WXML 结构错误,静态守卫全绿仍不渲染 | 涉及渲染的改动必须补**渲染态实测**(CDP/截图) |
| 8 | **job 名 ≠ 实际行为** | CI `Mini Program Compile Check` 只跑 `tsc --noEmit`,**不真编译** WXML/WXSS | 小程序改动不能只看该 job 绿;真编译走 `scripts/miniapp-compile-check.mjs`(本地) |
| 9 | **跨端字段名漂移** | JS 层字段名不受 tsc 保护,写错使请求字段落空/不落库 | 跨端契约改动逐字段核对 `miniapp` ↔ `backend` 规范名 |
| 10 | **同一文件并行 Edit** | 后者覆盖前者,改动丢失 | 确认 PR 内对同一文件无冲突编辑;合并后回读该文件 |
| 11 | **缺负向注入** | 测试必然全绿,无判别力 | 要求演示"改坏后测试转红" |
---
## 6. 合并准入(Definition of Done / 合并门槛)
### 6.1 硬性门槛(全部满足才可合并)
1. CI 7 道门禁全绿(backend / admin-web / miniapp / migration / test-shell-guard / ui-contracts / e2e)。
2. PR 模板所有 **T 级对应清单项**已逐项打勾并附验证说明。
3. 无未闭环的 Blocker/Major(或已书面转移到 `known-risks.md` 并取得 owner 确认)。
4. 新增/修改的测试已做负向注入。
5. 跨端契约字段、数据库约束、设计 token 已对齐各自事实源。
### 6.2 与现有 CI 门禁的分工(人审补 CI 之不能)
| CI job | 它验证了什么 | 它验证不了的(必须由人审) |
|--------|--------------|----------------------------|
| Backend Build & Test | `mvn verify` 编译+单测 | 断言是否有判别力、是否直连绕过、业务不变量是否被逻辑绕过 |
| Admin Web Build | `pnpm build` 通过 | 运行时渲染正确性、token 是否补全色阶、响应式是否真重渲染 |
| Mini Program Compile Check | 仅 `tsc --noEmit` | **真编译** WXML/WXSS、资源是否被 `ignoreDevUnusedFiles` 吞、胶囊安全区 |
| Migration Validation | 序列/命名/非空 | CHECK 与枚举语义一致、向后兼容、可逆性 |
| Test Shell Guard | 无空壳测试 | 测试是否真在断言、是否缺负向注入 |
| UI Contracts | 23 守卫结构门 | 守卫数下限是否被偷偷删、守卫是否覆盖新契约 |
| E2E (live stack) | 活栈 pytest | 隐私/资金链路、已知阻塞项是否被正确处置 |
### 6.3 合并后验证(回读纪律)
- 合并/推送后**必须回读**:`git rev-parse HEAD` / `git log -1` / `git status --porcelain`,不只用退出码判断。
- 远端真值只认 `git ls-remote origin refs/heads/main`(本机 `origin/main` 会卡旧值谎报 ahead)。
- 推送后轮询 CI(`.workbuddy/poll_ci.py`),确认本次 head 的 run 真正 success,而非"上次绿的缓存印象"。
### 6.4 已知上线阻塞(触碰即须显式处置)
以下两项为当前**上线阻塞**(见 `PLAYBOOK.md §E`),任何相关 PR 必须:要么本次解决,要么在 PR 中明确说明"本次未解决,仍依赖 Mock/未落库,已登记 `known-risks`"。
- **微信登录无真实实现**:全库仅 `MockWechatLoginProvider`,`WECHAT_APP_SECRET` 无人读。
- **照片直传不落库**:无直传确认端点;小程序用 `photoId`、后端返 `fileKey`,契约不符。
---
## 7. 审查流程(端到端)
```
作者自检 ──▶ 提 PR(用 .github/PULL_REQUEST_TEMPLATE.md)──▶ Reviewer 按 T 级执行 §4+§5
│
▼
有 Blocker/Major? ──是──▶ 退回修改(登记若豁免)
│否
CI 7 门禁触发 ──失败──▶ 修复并重跑
│全绿
合并(保护分支)──▶ 合并后回读 + 轮询 CI
```
- **作者自检**:提交前本地跑对应测试 + 契约守卫(`scripts/run-ui-contracts.mjs`),自认 T 级。
- **提 PR**:必须填 PR 模板,含"改了什么 / 怎么验证的(命令+输出)/ 风险级别 / 相关决策或已知风险"。
- **Reviewer**:先判 T 级,再走清单;对 Blocker/Major 给**可执行**的修改建议(具体到文件:行),不只写"这里有问题"。
- **CI 回退**:CI 红必须修复或证明是环境问题(如 KR-07 的 `docker` PATH、KR-08 的镜像源),不得"重试蒙过"。
- **合并**:保护分支禁止直接 push `main`;合并即视为已承担该 PR 的审查结论。
- **合并后**:回读 + 轮询 CI(§6.3)。
---
## 8. Reviewer 职责与礼仪
- **区分 blocker 与 nit**:Blocker/Major 拦下并给方案;Minor/Nit 用"建议"语气,不阻塞。
- **给可执行的反馈**:"建议把这里的 `RuntimeException` 换成 `BusinessException(ApiResponseCode.USER_NOT_FOUND)`" 优于 "异常处理不规范"。
- **不自以为替代测试**:审查不豁免作者写测试与负向注入的责任。
- **尊重决策源**:与 `decision-register` 的 LOCKED 项冲突的改动,先要求回到决策层确认,不擅自放行。
- **留痕**:拒绝理由、豁免转移都留在 PR 或 `known-risks.md`,便于复盘"逃逸"。
---
## 9. 度量与持续改进
- **逃逸率**:合并后在生产/实测发现的缺陷 ÷ 合并总数,按月统计,目标递减。
- **审查时延**:PR 从开到合并的中位时长,避免积压。
- **假绿复发计数**:§5 的 11 类模式再次出现的次数,归零为目标。
- **季度回看**:结合 `known-risks.md` 与 `PLAYBOOK.md`,把新踩的坑补充进 §4/§5,把已解决的债务从 `known-risks` 移除。
- **守卫同步**:每次新增契约守卫,同步上调 `MIN_GUARDS`;审查时核对守卫数。
---
*最后更新: 2026-09-23 — V1.0 初版。基于 PLAYBOOK 判据纪律、known-risks、decision-register 与 ci.yml 门禁,建立人工审查分级/清单/严重级别/合并准入/端到端流程。*