Files
danyang-match/docs/ANALYTICS_METRICS.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

229 lines
18 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.
# 善缘丹阳 · 统计指标字典(ANALYTICS_METRICS)
> 版本:v1 | 时区:`Asia/Shanghai` | 生效日期:2026-09-17
> 代码载体:`backend/src/main/java/com/danyang/match/platform/analytics/metric/MetricCatalog.java`
> 一致性守卫:`MetricCatalogGuardTest`(文档缺任何已登记指标即失败)
---
## 一、这份文档解决什么问题
Phase 20 之前,平台的统计数字**没有口径**。具体表现为:
| 现象 | 后果 |
|---|---|
| 「实名人数」实际算的是 `user_profiles.profile_status != 'rejected'` | 把「资料还在填」(in_progress)、「被退回待改」(need_edit) 都算成已实名,对外汇报失真 |
| 「开启聊天数」直接数会话行数,从不看消息类型 | 一条系统通知(「你们已互相喜欢」)就让一个会话计入,指标被系统性高估 |
| 「Match 数」不分来源 | 用户自主匹配、活动互选、红娘促成的量混成一个数字,「活动 10 对互选」被当成「10 对成功」引用 |
| 「服务用户数」与「服务人次」只有一个概念 | 无法回答"服务了多少人"与"承载了多少次服务"这两个不同问题 |
| Dashboard / 漏斗 / 专项页各算一遍同名指标 | 同一指标在不同页面显示不同的值,且没有任何测试能发现 |
| 活动漏斗硬编码返回 `0` | 后台永远显示 0,不是没数据而是没查询 |
| Banner 曝光/点击硬编码返回 `0` | 被读成"真的没人看到",而实际是"根本没有记录的地方" |
**本文档 + `MetricCatalog` 的作用**:让每个指标有唯一的、可被机器校验的定义,
消除"同名不同义"和"同义不同值"。
---
## 二、口径状态(`MetricStatus`)
| 状态 | 含义 | 使用约束 |
|---|---|---|
| `OFFICIAL` | 口径已确认 | 可作为正式统计口径使用与对外引用 |
| `PROVISIONAL` | 口径**未**确认 | **禁止**当作正式成功口径,**禁止**对外宣传;接口响应中必须带 provisional 标记 |
| `NOT_INSTRUMENTED` | 数据源尚未埋点 | **禁止**返回 `0` 冒充真实值;必须显式返回不可用并说明原因 |
> **为什么状态本身要建模**:`0` 与 `不可用` 对决策的含义完全相反。
> 「Banner 点击 0」读起来是"投了没人点",而真相是"没有任何地方记录点击"。
> 把这两种情况混为一谈,比算错一个数字更危险。
---
## 三、指标总表
> 分子 / 分母为口径的可执行定义;`—` 表示该指标不是比率、无分母。
> 去重键说明该指标的「1 个单位」是什么,**是防止口径混淆的关键字段**。
### 3.1 用户域
| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 |
|---|---|---|---|---|---|---|---|---|---|
| `user.registered.total` | 累计注册用户数 | `COUNT(users.id)` | — | `users.id` | `users.created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `user.identity.verified` | 实名认证通过用户数 | `COUNT(DISTINCT user_identities.user_id) WHERE verification_status='verified'` | — | `user_identities.user_id` | `user_identities.verified_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `user.profile.approved` | 资料审核通过人数 | `COUNT(DISTINCT user_profiles.user_id) WHERE profile_status='approved'` | — | `user_profiles.user_id` | `user_profiles.updated_at` | Asia/Shanghai | v1 | 审核员 | OFFICIAL |
| `user.active.7d` | 近 7 日活跃用户数 | `COUNT(users.id) WHERE last_login_at >= NOW() - 7d` | — | `users.id` | `users.last_login_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
> ⚠️ **`user.identity.verified` 与 `user.profile.approved` 是两件事**:
> 前者是**实名认证**(身份证核验,来源 `user_identities`),
> 后者是**资料审核**(来源 `user_profiles`)。
> 一个人可以已实名但资料被打回,也可以资料通过但尚未实名。二者不可互相替代。
> Gate「注册10 / 实名7 / 审核通过4」正是要求这三者分别正确且可同时成立。
### 3.2 推荐 / 匹配域
| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 |
|---|---|---|---|---|---|---|---|---|---|
| `recommendation.shown.total` | 已展示推荐总数 | `COUNT(recommendation_records.id) WHERE shown_at IS NOT NULL` | — | `recommendation_records.id` | `recommendation_records.shown_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `recommendation.shown.matchmaker` | 红娘发出的推荐数 | `COUNT(...) WHERE source='matchmaker' AND shown_at IS NOT NULL` | — | `recommendation_records.id` | `recommendation_records.shown_at` | Asia/Shanghai | v1 | 红娘主管 | OFFICIAL |
| `like.total` | 用户 Like 总数 | `COUNT(...) WHERE action='liked'` | — | `recommendation_records.id` | `recommendation_records.action_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `match.total` | Match 总数 | `COUNT(matches.id)` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `match.source.user_like` | 用户互相 Like 形成的 Match 数 | `COUNT(matches.id) WHERE source='user_like'` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `match.source.activity_mutual` | 活动互选形成的 Match 数 | `COUNT(matches.id) WHERE source='activity_mutual'` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
| `match.source.matchmaker` | 红娘促成的 Match 数 | `COUNT(matches.id) WHERE source='matchmaker'` | — | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 红娘主管 | OFFICIAL |
| `match.successful` | 成功牵手数 | `COUNT(matches.id) WHERE status='successful'` | — | `matches.id` | `matches.successful_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `match.effective` | 有效交友匹配数 | `COUNT(matches.id) WHERE status IN ('active','getting_to_know','successful') AND EXISTS(真人消息)` | `match.total` | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 平台运营 | **PROVISIONAL** |
> ⚠️ **Gate:「2 个 Like = 1 个 Mutual Match」**。
> `like.total` 是**单向**行为(A 喜欢 B 记 1 条),而 `match.total` 是**双向**结果。
> 因此 Like 数天然约为 Match 数的 2 倍量级,两者**不可直接相除当作转化率的分母分子组合**去解读"效率"。
> 若某天 Like 数与 Match 数接近 1:1,说明存在单向 Like 直接建 Match 的路径(缺陷信号)。
> ⚠️ **`match.effective` 处于 PROVISIONAL**:「有效」是否要求线下见面、是否要求关系持续 N 天,
> 业务方尚未裁决。当前实现是保守近似(真人会话数与 Match 数取较小值)。
> **禁止**将其作为正式成功率口径,**禁止**写入对外宣传材料。
### 3.3 聊天域
| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 |
|---|---|---|---|---|---|---|---|---|---|
| `chat.started` | 开启聊天会话数 | `COUNT(DISTINCT messages.conversation_id) WHERE message_type NOT IN ('system','risk_notice')` | — | `messages.conversation_id` | `messages.created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `chat.conversation.total` | 会话总数 | `COUNT(conversations.id)` | — | `conversations.id` | `conversations.created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
> ⚠️ **Gate:「System Message 不算 Chat Started」**。
> `message_type` 的值域由生产 `chk_msg_type` 限定为 `text / image / system / risk_notice`。
> 后两者是平台下发的通知(含风险提示),用户本人没有任何发言,**不计入**开启聊天。
> `chat.conversation.total` 与 `chat.started` 的差额,即「建了会话但双方从未真正开口」的数量。
### 3.4 活动域
| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 |
|---|---|---|---|---|---|---|---|---|---|
| `activity.total` | 活动总数 | `COUNT(activities.id)` | — | `activities.id` | `activities.created_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
| `activity.registration.total` | 活动报名人次 | `COUNT(activity_registrations.id)` | — | `activity_registrations.id` | `activity_registrations.registered_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
| `activity.registration.approved` | 活动报名通过人次 | `COUNT(...) WHERE status='approved'` | — | `activity_registrations.id` | `activity_registrations.reviewed_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
| `activity.registration.checked_in` | 活动实际签到人次 | `COUNT(...) WHERE checkin_status='checked_in'` | — | `activity_registrations.id` | `activity_registrations.checkin_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
| `activity.mutual.both_confirmed` | 活动互选成立对数 | `COUNT(...) WHERE user_a_status='accepted' AND user_b_status='accepted'` | — | `activity_mutual_connection_consents.id` | `both_confirmed_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
| `activity.mutual.match` | 互选后建立 Match 数 | `COUNT(matches.id) WHERE source='activity_mutual'` | `activity.mutual.both_confirmed` | `matches.id` | `matches.matched_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
| `activity.mutual.successful` | 互选后成功牵手数 | `COUNT(matches.id) WHERE source='activity_mutual' AND status='successful'` | `activity.mutual.both_confirmed` | `matches.id` | `matches.successful_at` | Asia/Shanghai | v1 | 活动管理员 | OFFICIAL |
> ⚠️ **Gate:「活动 10 对互选 ≠ 10 对 Success」**。
> `activity.mutual.both_confirmed` 是**互选环节**的产出(严格双确认,不是"有人点了同意");
> `activity.mutual.successful` 是**关系推进**的产出。
> 中间还要经过聊天、相处、确认关系。两者绝不可互相替代引用。
### 3.5 公益服务域
| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 |
|---|---|---|---|---|---|---|---|---|---|
| `service.visit.total` | 公益服务人次 | `Σ(预约 + 咨询 + 项目参与 + 资格办理) 记录数` | — | 各域记录 id(**不去重**) | 各域 `created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `service.user.unique` | 服务用户数 | `COUNT(DISTINCT user_id)` 跨全部服务域 | — | `user_id`(**跨域去重**) | 各域 `created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `service.appointment.total` | 服务预约人次 | `COUNT(...) WHERE status<>'cancelled'` | — | `service_appointments.id` | `created_at` | Asia/Shanghai | v1 | 服务站运营 | OFFICIAL |
| `counseling.total` | 婚恋咨询受理量 | `COUNT(counseling_services.id)` | — | `counseling_services.id` | `created_at` | Asia/Shanghai | v1 | 咨询管理员 | OFFICIAL |
| `service.program.member.approved` | 专项服务项目在册成员数 | `COUNT(...) WHERE status='approved'` | — | `service_program_members.id` | `created_at` | Asia/Shanghai | v1 | 平台运营 | OFFICIAL |
| `content.published.total` | 已发布内容数 | `COUNT(...) WHERE status='published'` | — | `content_articles.id` | `published_at` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL |
| `content.views.total` | 内容累计阅读量 | `SUM(view_count) WHERE status='published'` | — | `content_articles.id` | `published_at` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL |
> ⚠️ **`service.visit.total` 与 `service.user.unique` 必须分别汇报**(Phase 20 计划明确要求「分离服务用户数与服务人次」):
> 前者衡量**服务承载量**(同一用户来 3 次算 3 人次),后者衡量**服务覆盖面**(同一用户只算 1 人)。
> 两者比值即人均服务次数。把两者混用会同时高估覆盖面和低估承载量。
### 3.6 Banner 域
| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 |
|---|---|---|---|---|---|---|---|---|---|
| `banner.total` | Banner 总数 | `COUNT(home_banners.id) WHERE deleted_at IS NULL` | — | `home_banners.id` | `created_at` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL |
| `banner.active` | 生效中的 Banner 数 | `COUNT(...) WHERE status='active' AND NOW() BETWEEN start_at AND end_at` | — | `home_banners.id` | `NOW()` | Asia/Shanghai | v1 | 内容编辑 | OFFICIAL |
| `banner.impression` | Banner 曝光量 | —(无数据源) | — | —(尚未埋点) | — | Asia/Shanghai | v1 | 内容编辑 | **NOT_INSTRUMENTED** |
| `banner.click` | Banner 点击量 | —(无数据源) | — | —(尚未埋点) | — | Asia/Shanghai | v1 | 内容编辑 | **NOT_INSTRUMENTED** |
> ⚠️ `banner.impression` / `banner.click` **当前不可计算**:
> 项目内不存在曝光/点击的上报端点,也不存在事件表。
> 接口返回 `impressions: null` + `impressionsAvailable: false` + 原因说明,
> **不返回 `0`**。要启用这两个指标,需要先建事件表与上报端点(见"待办")。
### 3.7 安全域
| 指标编码 | 名称 | 分子 | 分母 | 去重键 | 时间字段 | 时区 | 版本 | 负责人 | 状态 |
|---|---|---|---|---|---|---|---|---|---|
| `risk.report.pending` | 待处理举报数 | `COUNT(reports.id) WHERE status='pending'` | — | `reports.id` | `reports.created_at` | Asia/Shanghai | v1 | 风控人员 | OFFICIAL |
| `risk.report.resolved` | 已处理举报数 | `COUNT(reports.id) WHERE status='resolved'` | — | `reports.id` | `reports.resolved_at` | Asia/Shanghai | v1 | 风控人员 | OFFICIAL |
| `risk.user.high_risk` | 高风险用户数 | `COUNT(risk_actions.id) WHERE action_type IN ('ban','temporary_suspend') AND status='active'` | — | `risk_actions.id` | `risk_actions.created_at` | Asia/Shanghai | v1 | 风控人员 | OFFICIAL |
---
## 四、验收用的固定数据集
Phase 20 Gate 要求「用固定测试数据人工复算一致」。对应守卫测试 `AnalyticsMetricsPhase20IntegrationTest`:
| 场景 | 构造数据 | 期望 |
|---|---|---|
| 用户漏斗 | 注册 10、实名 7、资料审核通过 4 | 三个数字分别显示 10 / 7 / 4,且**互不干扰** |
| Like 与 Match | A→B、B→A 各一条 Like | `like.total` = 2,`match.total` = 1 |
| Chat Started | 会话内仅有 `system` 消息 | 不计入 `chat.started` |
| 活动互选 | 10 对双确认,其中 3 对 `successful` | `activity.mutual.both_confirmed` = 10,`activity.mutual.successful` = 3,**不相等** |
| 红娘推荐 | 候选池 20 人,实际发出 3 条 | `recommendation.shown.matchmaker` = 3(不是 20) |
| 服务去重 | 同一用户 3 次预约、1 次咨询 | `service.user.unique` 计 1 人,`service.visit.total` 计 4 人次 |
| 跨视图一致 | 同一数据集 | Dashboard、用户漏斗、专项页的同一指标值完全相等 |
---
## 五、软删口径(所有指标的强制前置条件)
**每一个指标都隐含一个 WHERE 条件:`deleted_at IS NULL`。** 这不是可选项。
### 为什么必须显式写
`BaseEntity.deletedAt` **没有** `@TableLogic` 注解,项目也没有配置 MyBatis-Plus 的全局逻辑删除字段。
因此 `selectCount(new LambdaQueryWrapper<T>())` 生成的 SQL 是 `SELECT COUNT(*) FROM t` ——
**软删除的数据会被完整计入统计,且不会产生任何报错或警告**。
后果是隐蔽而持续的:
| 场景 | 不写软删过滤的结果 |
|---|---|
| 用户被删除 | 「累计注册用户数」永久只增不减 |
| Banner 下架删除 | 「Banner 总数」把已删的算在内 |
| 活动软删 | 活动数、报名数偏高 |
| 服务记录软删 | 服务用户数与分域明细对不上(总数已过滤、明细未过滤) |
这类偏差**不会有任何异常**,只会让运营看到的数字系统性偏高。
### 如何被强制
| 守卫 | 作用 |
|---|---|
| `AnalyticsSoftDeleteGuardTest` | 结构性约束:统计包内每一处 `*Mapper.selectCount(..)` / `selectList(..)` 的实参中**必须**出现 `isNull(..)`。覆盖尚未被想到的新查询,而不只是当前这几个。 |
| `AnalyticsMetricsPhase20IntegrationTest` | 行为断言:软删的用户 / 实名记录 / 专项成员 / 本地资格 / 预约,均不得改变对应指标。 |
> 结构性守卫比逐条数字断言更有效:数字断言只能覆盖"被构造出来的固定数据集",
> 而漏写软删过滤的高发区恰恰是**新写的、还没被想到的**统计查询。
### 唯一口径来源
所有指标计算集中在 `AnalyticsMetricsCalculator`(以及 `AnalyticsService` 中少量按状态拆分的查询),
**不允许**在别处再实现一遍同名指标 —— 修复前 Dashboard / 用户漏斗 / 专项页各有一份实现且互不相同,
"改了其中一处忘了另两处"是必然发生的缺陷。
---
## 六、变更流程(强制)
1. **新增指标**:先在 `MetricCatalog` 登记(含全部 12 个字段),再在本文件补表行,最后实现计算。
`MetricCatalogGuardTest` 会拦截"只写代码不登记"或"登记了但文档没写"。
2. **修改口径**:必须递增 `version`(如 v1 → v2),并在本文件记录变更原因。
未递增版本会让历史数据与新数据被误当成同一口径比较。
3. **降级为 PROVISIONAL**:接口响应必须带 provisional 标记,
且**禁止**出现在任何对外的成功率/宣传口径中。
4. **不得返回硬编码 `0` 掩盖缺失数据源**:无数据源一律用 `NOT_INSTRUMENTED`。
---
## 七、已知待办
| 项 | 说明 |
|---|---|
| Banner 曝光/点击埋点 | 需新建 `home_banner_events` 事件表 + 上报端点 + 小程序端埋点,闭环后才可把两个指标从 `NOT_INSTRUMENTED` 提升为 `OFFICIAL` |
| `match.effective` 口径裁决 | 需业务方确认「有效」的判定标准(是否要求见面 / 持续 N 天),确认后提升为 `OFFICIAL` |
| 组织 / 站点维度拆分 | 当前统计为平台级;按组织或服务站拆分需叠加 `DataScope`(见 Phase 20 权限部分) |
| 大数据量优化 | `service.user.unique` 等跨域去重指标目前为内存去重;数据量达十万级时应改为数据库侧 `UNION` 聚合 |