Files
danyang-match/docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md
WorkBuddy 22c778b111 fix(analytics): close Phase 20 KEEP-VERIFY — 20 defects (fake metric definitions, hardcoded zeros, systemic soft-delete leak, module-wide 403, fake-green tests); add MetricCatalog + unified calculator + V043 grants + 51 guard tests; full regression 648 green
口径造假:
- D1 实名人数用 user_profiles.profile_status != 'rejected' 冒充 → in_progress/need_edit 被算成已实名
- D3 Chat Started 直接数会话行数 → system/risk_notice 系统通知计入
- D4 Match 不分来源 → 活动 10 对互选被当 10 对成功引用(Gate)
- D6 Banner 曝光/点击硬编码 0 → 改返回 null + NOT_INSTRUMENTED 标记

死代码 / 占位:
- D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码
- D20 getDailySummary(days) 无边界校验

系统性软删缺陷(D12):
- BaseEntity.deletedAt 无 @TableLogic 且全局无逻辑删除配置
  → selectCount 把软删数据全部计入统计且无任何报错
- 修复 45 处统计调用显式 deleted_at IS NULL(2 轮复验,首轮漏扫 5 处次级路径:
  服务用户数分域明细 / 服务站统计 / 性别与年龄分布)

结构类:
- D15 Dashboard/用户漏斗/专项页各实现一遍同名指标且互不相同 → 抽 AnalyticsMetricsCalculator 统一口径

权限(Gate ⑩):
- data_viewer 在 V001 定义却全仓从未授权(空壳角色)
- platform_admin 的 V015 扩展条件 id >= '20000000-…' 漏掉 V1.0 老权限段
  → 整个 Analytics 模块除 super_admin 外全员 403
- V043 授予两角色;刻意不授予 organization_admin(相关表无组织维度,无范围全量即越权)

假绿测试:
- 删除 AnalyticsServiceTest(5× assertTrue(true),零断言)

新增:
- 指标字典 MetricCatalog(36 指标 × 12 字段)+ MetricStatus/MetricDefinition + docs/ANALYTICS_METRICS.md
- 守卫 51 用例:MetricCatalogGuardTest(11) / AnalyticsMetricsPhase20IntegrationTest(21)
  / AnalyticsSoftDeleteGuardTest(4,结构性) / AnalyticsPermissionGrantTest(6)
  / LocalEligibilityStatusVocabularyGuardTest(9)

跨阶段修复(缺陷本体属账号生命周期模块,按"发现即修"处理):
- X1(Critical)注销写入 user_identities 状态域的 'failed' → 违反生产 V017 chk_elig_verify_status
  → 真实 PG 上 closeAccount 事务回滚、账号注销 100% 不可用(负向验证实测 DataIntegrityViolation)
- X2 checkCanWithdraw 资格拦截用 submitted/pending(该表永不可能出现)→ 死代码
- X3 测试库补 chk_elig_type/chk_elig_verify_status,暴露 ActivityPhase14Test 夹具违规

验证:
- 负向验证两轮共 7 处注入 → 7 个用例精准变红且无无关失败(一处被源码结构+运行时行为两层捕获)
- 还原后 md5sum 逐字节一致,NEG-VAL 残留 0
- 后端全量 648 tests / 0 failures / 0 errors / BUILD SUCCESS;admin-web vue-tsc EXIT=0
2026-09-17 23:25:31 +08:00

339 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 20 V1.1 Recovery Completion Report — Analytics 数据统计与管理驾驶舱
> 日期:2026-09-17
> 阶段:Phase 20(Analytics — EXTEND)
> 分支:`main` | Tag:`phase20-recovery-2026-09-17`
> Bundle:`.workbuddy/backup/phase20-recovery-2026-09-17.bundle`(`complete history`,回读通过)
> 结论:**CLOSED(Gate 全项通过)**
---
## 一、计划目标与 Gate
官方计划(`WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件.md` 第 1777 行起)
定义 `EPIC-P20 数据统计与管理驾驶舱`,任务 `P20-T01 ~ P20-T14`(指标字典 / User / Review / Recommendation /
Match / Chat Started / Matchmaker / Activity / Risk Metrics / Daily Summary / Rebuild / Platform Dashboard /
Organization Dashboard / Analytics 权限)。
### Gate 逐项结论
| Gate | 要求 | 结论 | 证据 |
|---|---|---|---|
| Gate ① | 每个指标登记名称、定义、分子分母、去重键、时间字段、时区、版本、负责人 | ✅ | `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;`MetricCatalogGuardTest` 11 用例双向校验 |
| Gate ② | 固定数据集「注册10 / 实名7 / 审核通过4」显示一致 | ✅ | `AnalyticsMetricsPhase20IntegrationTest.gateUserFunnelCountsThreeDistinctConcepts` |
| Gate ③ | 2 个 Like = 1 个 Mutual Match | ✅ | `gateTwoLikesProduceOneMutualMatch` |
| Gate ④ | System Message 不算 Chat Started | ✅ | `gateSystemMessageDoesNotCountAsChatStarted` / `riskNoticeDoesNotCountAsChatStarted` |
| Gate ⑤ | 活动 10 对互选 ≠ 10 对 Success | ✅ | `gateMutualConfirmationDoesNotEqualSuccess`(互选 10 / 成功 3,且显式 `assertNotEquals`) |
| Gate ⑥ | 红娘看 20 候选、发 3 个 → Recommendation = 3 | ✅ | `gateMatchmakerRecommendationCountsOnlySent` |
| Gate ⑦ | Dashboard 与专项页相同指标必须一致 | ✅ | `gateDashboardAgreesWithDetailViews`;且由**统一口径层**从结构上保证(见 §三 D15) |
| Gate ⑧ | 服务用户数与服务人次分离 | ✅ | `gateServiceUsersDeduplicatedButVisitsAreNot`(1 人 / 4 人次) |
| Gate ⑨ | 有效匹配在口径确认前标 provisional,禁止对外宣传 | ✅ | `MetricCatalog` 标 `PROVISIONAL`;响应带 `effectiveMatchesProvisional` + 说明;`effectiveConnectionCarriesProvisionalFlag` |
| Gate ⑩ | 所有统计接口受 `analytics.view` 与 DataScope 约束,低权限 403 | ✅ | `V043` 授权 + `AnalyticsPermissionGrantTest` 6 用例(含低权限清单、`organization_admin` 刻意排除) |
| Gate ⑪ | 相同用户多次服务不污染去重人数 | ✅ | `gateServiceUsersDeduplicatedButVisitsAreNot` |
| Gate ⑫ | 软删数据不得计入统计 | ✅ | `AnalyticsSoftDeleteGuardTest`(结构性)+ 5 个行为用例 |
---
## 二、复验方法(KEEP-VERIFY)
本轮不采信任何「历史 ✅」。Phase 20 在 `STATUS.md` 里早已标注「✅ 已完成」,但复验起点是:
`AnalyticsController` 15 个端点、`AnalyticsService` 546 行**确实存在**,
其中却同时含有**硬编码 0**、**占位死代码**、**字母级口径造假**、**全员 403**、**假绿测试**。
逐项核对顺序:
1. **运行时路径**:Controller → Service → Mapper → DDL 逐跳确认无断点、无死路。
2. **生产约束**:核对迁移里的 `NOT NULL` / `CHECK` / `UNIQUE`,确认代码写入值合法。
3. **测试库保真度**:测试库是否与生产同约束(缺约束 = 该非法路径零覆盖 = 假绿)。
4. **守卫有效性**:守卫是否解析**真实 DDL/真实 SQL**、是否会**精准变红**。
5. **前端落点**:后端能力是否有真实前端入口。
---
## 三、复验查出的真实缺陷与修复
### 3.1 口径造假类(数字是"算"出来的,但算错了概念)
| 编号 | 指标 | 缺陷 | 修复 |
|---|---|---|---|
| **D1** | 实名认证通过用户数 | 用 `user_profiles.profile_status != 'rejected'`("资料没被退回")当作"已实名"。取值域里的 `in_progress`(资料还在填)、`need_edit`(被退回待改)全被算成已实名 | `UserIdentityMapper.countVerifiedUsers()`:`COUNT(DISTINCT user_id) FROM user_identities WHERE verification_status='verified'` |
| **D3** | 开启聊天会话数 | 直接数会话行数,从不看消息类型 —— 一条系统通知(「你们已互相喜欢」)就让会话计入 | `MessageMapper.countConversationsWithRealMessages()`:`COUNT(DISTINCT conversation_id) WHERE message_type NOT IN ('system','risk_notice')` |
| **D4** | Match 数 | 全来源混成一个数字,用户自主匹配 / 活动互选 / 红娘促成不可分。Gate「10 对互选 ≠ 10 对成功」在数据上无法体现 | 按 `source` 分离:`match.source.user_like` / `.activity_mutual` / `.matchmaker`,响应增 `matchesBySource` |
| **D6** | Banner 曝光量 / 点击量 | **硬编码 0**。0 被读成"投了没人点",真相是"项目里根本没有记录点击的地方" | 返回 `impressions: null` + `impressionsAvailable: false` + 原因说明;`MetricCatalog` 标 `NOT_INSTRUMENTED` |
> D1 是最危险的一条:对外汇报"实名 X 人"时,把"资料还在填"的人也算进去了,数字系统性偏高且无法自查。
### 3.2 死代码 / 占位类(后台永远显示 0)
| 编号 | 缺陷 | 修复 |
|---|---|---|
| **D5** | 活动漏斗**所有指标硬编码为 0**,并留着一行把「用户数」当「活动数」的死代码。后台"活动漏斗"不是没数据,是**根本没有查询** | 接入真实查询:`totalActivities` / `totalRegistrations` / `approvedRegistrations` / `checkedInCount`,并**三分离** `mutualBothConfirmed` / `mutualMatches` / `mutualSuccessful` |
| **D20** | `getDailySummary(days)` 无入参边界,越界静默返回 | 1~365 校验 → `BusinessException(VALIDATION_ERROR)` |
### 3.3 系统性缺陷(D12:软删数据全部计入统计)
**根因**:`BaseEntity.deletedAt` **没有** `@TableLogic` 注解,项目**也没有**配置 MyBatis-Plus 的全局逻辑删除字段。
因此 `selectCount(new LambdaQueryWrapper<T>())` 生成的 SQL 是 `SELECT COUNT(*) FROM t` ——
**软删除的数据被完整计入统计,且不产生任何报错或警告**。
| 影响 | 说明 |
|---|---|
| 累计注册用户数 | 用户删除后只增不减 |
| Banner / 活动 / 服务记录 | 软删后仍计入总数 |
| 每日汇总 | "新增用户/新增 Match"把已删记录继续算进当天 |
**修复**:`AnalyticsService` + `AnalyticsMetricsCalculator` 内**全部**统计查询显式 `deleted_at IS NULL`(共 45 处直接 Mapper 统计调用,0 处遗漏)。
**补扫(本轮第 2 轮复验发现,属 D12 未扫净)**:首轮只修了"统一口径层"这条主路径,以下 **5 处**次级路径仍在漏:
| 位置 | 后果 |
|---|---|
| `getUniqueServiceUsersStats` → `programUniqueUsers` | 分域明细与**已过滤的**总数同屏展示,各部分之和对不上总数 |
| `getUniqueServiceUsersStats` → `eligibilityUniqueUsers` | 同上 |
| `getServiceStationStats` → `totalAppointments` / `activeStations` | 服务站点统计偏高 |
| `getUserFunnel` → `genderRatio` | 分布与 `totalRegistered`(已过滤)对不上账 |
| `getUserFunnel` → `ageDistribution` | 同上 |
### 3.4 结构类(三套实现 → 无单一事实来源)
| 编号 | 缺陷 | 修复 |
|---|---|---|
| **D15** | Dashboard / 用户漏斗 / V11 专项仪表盘**各自实现了一遍**同名指标,且**互不相同**;字段名还不一致(`verifiedUsers` vs `verifiedCount`)。只要有一处漏改就永久不一致,且没有任何测试能发现 | 抽出 `AnalyticsMetricsCalculator` 作为**唯一口径来源**;`AnalyticsService` 全面委托,只保留组装与前端字段名兼容 |
### 3.5 权限授予脱节(Gate ⑩:全员 403)
| 缺陷 | 证据 | 修复 |
|---|---|---|
| V001 第 149 行定义了专看统计的角色 `data_viewer`(描述:"仅查看统计数据"),但**全仓迁移从未给它授予任何权限** —— 是个**空壳角色** | 角色存在 ≠ 角色可用 | `V043` 授予 `analytics.view` |
| `platform_admin` 的权限扩展在 V015 以 `id >= '20000000-...'`(V1.1 新增段)为条件,而 `analytics.view` 的 id 是 `10000000-...060`(**V1.0 老权限段**)→ 被漏掉 | 权限段划分造成的静默遗漏 | `V043` 一并授予 |
| **净效果**:整个 Analytics 模块(15 端点)除 `super_admin` 外**全员 403**,包括专门为它设计的 `data_viewer`。前端"数据统计"菜单对谁都是点了报错 | — | 见下 |
> 两个既有守卫(`AnalyticsController` 的 `@PreAuthorize`、`OperatorEndpointPermissionCoverageTest`)**都是绿的** ——
> 前者检查"端点有没有声明权限",后者检查"端点有没有权限注解",**都不检查"这个权限有没有授予任何角色"**。
> 这是"守卫覆盖不到的组合缺陷",故新增 `AnalyticsPermissionGrantTest` 补上「权限 → 角色」这一环。
**刻意不授予 `organization_admin`**:平台级统计接口所依赖的表(`users` / `matches` / `messages`)**没有 `organization_id` 维度**,
无法把机构管理员的数据范围限制在"本机构"。在没有范围限制的情况下授予全局统计权限 = 越权可见全平台数据。
**宁可返回 403,也不给无范围的全量数据**;待统计口径支持组织维度后再评估。
### 3.6 测试假绿(本轮替换)
| 缺陷 | 说明 |
|---|---|
| `AnalyticsServiceTest` | 由 **5 个 `assertTrue(true)`** 组成 —— **没有任何断言**,却让"Phase 20 已完成"看起来成立。测试通过不代表功能存在,只代表测试什么都没检查 | 已**删除**,替换为 `AnalyticsMetricsPhase20IntegrationTest`(21 用例,全部构造真实数据行、在 H2 上真正执行 SQL) |
### 3.7 前端落点
| 缺陷 | 修复 |
|---|---|
| `AnalyticsView.vue` 未接 `/v11-dashboard` 综合端点;公益服务数据区、活动互选vs成功、按来源分离 Match、provisional / 未埋点标注**全部缺失** | 补接端点 + 新增上述区块 + 对 `PROVISIONAL` / `NOT_INSTRUMENTED` 显式标注(避免运营把 provisional 当正式口径引用) |
### 3.8 其它修正
| 项 | 说明 |
|---|---|
| `CounselingServiceRecord` 注释过时 | 注释写 `counseling / premarital`,实际服务类型是 `relationship_guidance / emotional_support / premarital_guidance / anti_fraud_guidance` |
| `MigrationValidationTest` 基线 | `LATEST_MIGRATION` 42 → 43(新增 V043) |
---
## 四、跨阶段发现(复验 Phase 20 时发现,缺陷本体属账号生命周期模块)
按纪律「发现真实缺陷即修」,以下问题在核对**生产约束 vs 测试库**时暴露。其模块归属为 Phase 21,故单独列出。
### X1(**Critical**):账号注销写入了越界状态 → 生产必然 500
`AccountLifecycleService.invalidateLocalEligibility` 写入
`verification_status = 'failed'`(`Constants.VERIFICATION_STATUS_FAILED`)。
但生产 `V017 chk_elig_verify_status` 只允许
`declared / needs_evidence / pending_review / verified / rejected` —— **没有 `failed`**,
且**没有任何后续迁移放宽它**(已全量核对迁移目录)。
`failed` 实际属于 `user_identities` 的状态域 —— 生产 `V002` 的 CHECK 里才有它,
且 `IdentityVerificationService` 也正是这样用的。这是**跨状态域常量误用**。
**后果**:真实 PostgreSQL 上 CHECK 违约 → `closeAccount` **整个事务回滚** →
**账号注销 100% 不可用**(报 500)。而测试库此前**没有这两条 CHECK**,所以该必崩缺陷在测试期完全不可见。
> **负向验证实证**:把该行改回 `failed` 后,守卫报出的异常正是 **`DataIntegrityViolation`** ——
> 即数据库层真实拒绝了这次写入,直接证实了生产的失败模式。
**修复**:改用 `VERIFICATION_STATUS_REJECTED`(白名单内,语义为"资格失效")。
**未**选择"新增迁移把 `failed` 加进白名单" —— 那是把一个跨域误用的值固化进契约,
与本项目 Phase 19 D16「收敛权限膨胀」的处理原则一致:优先收敛,不扩契约。
### X2:`checkCanWithdraw` 的资格拦截是**死代码**
该方法用 `'submitted'` / `'pending'` 判断"本地资格审核进行中",
但这两个值在 `user_local_eligibilities` 里**永不可能出现**(同 X1 的状态域混用)
→ 拦截条件恒为 `false`,**从未生效过**:审核中的用户可以直接注销。
**修复**:改用 `needs_evidence` / `pending_review`(抽为 `LOCAL_ELIGIBILITY_IN_REVIEW` 常量并注明取值域来源)。
### X3:测试库约束漂移
测试库缺 `chk_elig_type` / `chk_elig_verify_status`。
补齐后**立即暴露出**一处既有夹具违规:`ActivityPhase14Test` 使用
`eligibility_type = "household"`(正确值是 `household_registration`)—— 已修正。
### X4 / X5:Phase 21 遗留(本轮**未**修,仅记录)
| 项 | 说明 |
|---|---|
| X4 测试假绿 | `AccountLifecycleServiceTest` 由 **7 个 `assertTrue(true)`** 组成(仅有的真实断言是几个常量字符串)。零真实覆盖,这正是 X1/X2 长期隐形的原因之一 |
| X5 未实现 | `notifyActiveMatches` / `notifyMatchmaker` / `cancelPendingActivityRegistrations` 均为 TODO 桩(只写日志,无任何查询);`checkCanWithdraw` 的 `activeMatchmakerServices` 硬编码 `0`(注释:"需要 MatchmakerServiceMapper")→ 该阻塞项永不生效 |
> X4/X5 属 Phase 21 交付范围(需产品确认通知语义与红娘服务口径),不在本轮静默扩权修复,留待 Phase 21 处理。
---
## 五、本轮新增能力(此前完全不存在)
### 5.1 指标字典代码化
| 文件 | 作用 |
|---|---|
| `MetricStatus` | `OFFICIAL` / `PROVISIONAL` / `NOT_INSTRUMENTED` —— **把"口径是否可用"本身建模** |
| `MetricDefinition` | 12 字段 record:code / name / definition / numerator / denominator / dedupeKey / timeField / timezone / version / owner / status / sourceTable |
| `MetricCatalog` | 36 个指标注册表;`of()` 未登记即抛异常;`provisional()` / `notInstrumented()` 检索 |
| `docs/ANALYTICS_METRICS.md` | 指标字典文档,与代码逐条对应;含软删强制约定、变更流程、已知待办 |
> **为什么状态本身要建模**:`0` 与「不可用」对决策的含义完全相反。
> 「Banner 点击 0」读起来是"投了没人点",真相是"没有任何地方记录点击"。
### 5.2 统一口径计算层
`AnalyticsMetricsCalculator` —— 消除三套独立实现,所有查询显式过滤软删,
方法上的 javadoc 标注其对应 `MetricCatalog` 指标编码。
### 5.3 新增指标与方法
| 层 | 新增 |
|---|---|
| `UserIdentityMapper` | `countVerifiedUsers()` |
| `MessageMapper` | `countConversationsWithRealMessages()` |
| `MatchMapper` | `countAllMatches` / `countBySource` / `countBySourceAndStatus` / `countByStatus` |
| `RecommendationRecordMapper` | `countShown` / `countShownBySource` / `countBySourceAndAction` / `countByAction` |
| `ActivityRegistrationMapper` | `countAllRegistrations` / `countRegistrationsByStatus` / `countCheckedIn` / `countRegistrationsBySource` / `countDistinctRegisteredUsers` |
| `ActivityMutualConsentMapper` | `countBothConfirmed` / `countOneSidedAccepted` |
| `V043` | `analytics.view` → `data_viewer` + `platform_admin` |
---
## 六、守卫与测试保真度(本轮新增)
| 守卫 | 用例数 | 作用 |
|---|---|---|
| `MetricCatalogGuardTest` | 11 | 代码 ↔ 文档**双向**一致;12 项元数据完整;状态诚实性(provisional/not-instrumented 必须真的被标注);去重键差异;未登记编码抛异常 |
| `AnalyticsMetricsPhase20IntegrationTest` | 21 | Gate 全部 8 类 + 软删 5 例 + 入参校验 + 可用性诚实性。**真实 DB、真实 SQL**,非 mock |
| `AnalyticsSoftDeleteGuardTest` | 4 | **结构性**约束:统计包内每处 `*Mapper.selectCount/selectList` 实参必须含 `isNull(..)`。含"防假绿"下界(扫不到足够调用点即失败) |
| `AnalyticsPermissionGrantTest` | 6 | 「权限 → 角色」授权链;低权限角色清单;`organization_admin` 刻意排除;授予的权限码必须真实存在;`super_admin` 兜底路径 |
| `LocalEligibilityStatusVocabularyGuardTest` | 9 | X1/X2 守卫:生产 DDL 白名单解析、测试库**一致性**(防漂移)、越界值被 DB 真实拒绝、注销后状态落在白名单内、审核中确实能拦截注销 |
`AnalyticsServiceTest`(5×`assertTrue(true)`)在本轮被**清除**;
`AnalyticsSoftDeleteGuardTest` 的"防假绿下界"与 `MetricCatalogGuardTest` 的解析自检,
共同保证守卫**不会因为解析失效而静默变绿**。
---
## 七、负向验证(证明守卫是承重的,而非装饰)
一次性注入缺陷(均以 `NEG-VAL` 标记),跑定向回归,确认**精准变红且无任何无关失败**,再还原。
### 第 1 轮
| 注入缺陷 | 精准变红的用例 | 断言信息 |
|---|---|---|
| 把实名口径改回 `user_profiles.profile_status <> 'rejected'` | `realNameIsNotProfileStatusProxy` | `expected: <0> but was: <5>` |
| 移除 `registeredUsers()` 的 `isNull(DeletedAt)` | `softDeletedUserIsExcludedFromRegisteredCount` | `expected: <0> but was: <1>` |
| 从 V043 移除 `data_viewer` 授权 | `dataViewerRoleIsActuallyUsable` | `当前实际授予: []` |
> `MetricCatalogGuardTest` 11/11 **保持绿** —— 口径守卫在 DB 层,目录守卫正确地**不**误报。
### 第 2 轮(针对本轮新增守卫)
| 注入缺陷 | 精准变红的用例 | 断言信息 |
|---|---|---|
| `invalidateLocalEligibility` 改回 `failed` | `closeAccountLeavesEligibilityStatusWithinWhitelist` | **`DataIntegrityViolation`** —— DB 真实拒绝 |
| `checkCanWithdraw` 改回 `submitted`/`pending` | `pendingEligibilityReviewBlocksWithdrawal` | `expected: <false> but was: <true>` |
| 移除 `programUsers` 的 `isNull(DeletedAt)` | `everyAnalyticsMapperQueryFiltersSoftDeletes`(结构性) | 列出未过滤的调用点 |
| 同上 | `softDeletedServiceRowsAreExcludedFromServiceMetrics`(行为) | `expected: <1> but was: <2>` |
> 同一注入被**两层守卫**同时捕获(源码结构 + 运行时行为),说明结构性守卫对新代码同样生效 ——
> 这正是它存在的意义:数字断言只能覆盖"被构造出来的固定数据集"。
**还原后**:`grep -rc "NEG-VAL" backend/src` = **0**;`md5sum` 与注入前**逐字节一致**。
---
## 八、回归证据
```
后端全量:Tests run: 648, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS (约 6.5 分)
(复验起点 602 → 本轮 648,净 +46;其中新增守卫 9+4+6+11 = 30,集成用例 21)
前端 admin-web:vue-tsc --noEmit EXIT=0
负向验证:2 轮、7 处注入 → 7 个用例精准变红;还原后 0 残留
```
---
## 九、明确记录的设计决策(非缺陷,但需知情)
1. **`organization_admin` 不授予全局统计权限**(见 §3.5)。要在机构维度开放统计,
必须先给 `users` / `matches` / `messages` 等表建立组织维度,否则任何授予都是越权。
2. **`match.effective` 标 `PROVISIONAL`**:「有效」是否要求线下见面 / 持续 N 天,业务方未裁决。
当前是保守近似(真人会话数与 Match 数取较小值)。**禁止**作为正式成功率或写入对外宣传材料。
3. **Banner 曝光/点击保持 `NOT_INSTRUMENTED`**:不返回 `0`。要启用需先建事件表 + 上报端点 + 小程序埋点。
4. **软删过滤是每个指标的隐含前置条件**:`BaseEntity.deletedAt` 无 `@TableLogic`,
全局也无逻辑删除配置。本项目**不**建议为此加全局配置(会改变所有既有查询语义、风险外溢),
而是用结构性守卫强制显式过滤。
5. **功能开关关闭时的注册口径**:`user.registered.total` 统计**全部**未软删用户,
不按 `account_status` 过滤 —— 「注销用户」不是「从未注册用户」,两者对"累计注册"含义不同。
---
## 十、遗留与下一阶段
| 项 | 状态 |
|---|---|
| X4 `AccountLifecycleServiceTest` 的 7 个假绿断言 | ⏳ Phase 21(本轮已就资格失效路径补真实覆盖) |
| X5 通知/活动报名取消的 TODO 桩、`activeMatchmakerServices` 硬编码 0 | ⏳ Phase 21,需产品确认语义 |
| Banner 曝光/点击埋点闭环 | ⏳ 需新建事件表 + 上报端点 |
| `match.effective` 口径裁决 | ⏳ 需业务方确认 |
| `service.user.unique` 等跨域去重改为 DB 侧聚合 | ⏳ 数据量达十万级时 |
| 组织 / 站点维度统计拆分 | ⏳ 依赖组织维度建模 |
| 下一阶段 | Phase 21(等用户确认) |
---
## 附:本轮改动文件
**后端(新增)**
`analytics/metric/MetricStatus.java`、`analytics/metric/MetricDefinition.java`、
`analytics/metric/MetricCatalog.java`、`analytics/metric/AnalyticsMetricsCalculator.java`、
`db/migration/V043__grant_analytics_view_to_data_roles.sql`
**后端(修改)**
`analytics/AnalyticsService.java`(重写为委托模式)、
`account/service/AccountLifecycleService.java`(X1/X2)、
`verification/mapper/UserIdentityMapper.java`、`chat/mapper/MessageMapper.java`、
`match/mapper/MatchMapper.java`、`match/mapper/RecommendationRecordMapper.java`、
`activity/mapper/ActivityRegistrationMapper.java`、`activity_mutual/mapper/ActivityMutualConsentMapper.java`、
`counseling/entity/CounselingServiceRecord.java`
**前端**
`admin-web/src/modules/analytics/AnalyticsView.vue`
**测试**
`analytics/AnalyticsMetricsPhase20IntegrationTest.java`(新增,21)、
`analytics/MetricCatalogGuardTest.java`(新增,11)、
`analytics/AnalyticsSoftDeleteGuardTest.java`(新增,4)、
`security/AnalyticsPermissionGrantTest.java`(新增,6)、
`account/LocalEligibilityStatusVocabularyGuardTest.java`(新增,9)、
`activity/ActivityPhase14Test.java`、`deployment/MigrationValidationTest.java`、
`src/test/resources/schema-h2.sql`、~~`analytics/AnalyticsServiceTest.java`~~(删除)
**文档**
`docs/ANALYTICS_METRICS.md`(新增)