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

18 KiB
Raw Permalink Blame 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 聚合