Files
danyang-match/docs/ANALYTICS_METRICS.md
T

229 lines
18 KiB
Markdown
Raw Normal View History

# 善缘丹阳 · 统计指标字典(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` 聚合 |