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