口径造假: - 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
18 KiB
善缘丹阳 · 统计指标字典(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 / 用户漏斗 / 专项页各有一份实现且互不相同,
"改了其中一处忘了另两处"是必然发生的缺陷。
六、变更流程(强制)
- 新增指标:先在
MetricCatalog登记(含全部 12 个字段),再在本文件补表行,最后实现计算。MetricCatalogGuardTest会拦截"只写代码不登记"或"登记了但文档没写"。 - 修改口径:必须递增
version(如 v1 → v2),并在本文件记录变更原因。 未递增版本会让历史数据与新数据被误当成同一口径比较。 - 降级为 PROVISIONAL:接口响应必须带 provisional 标记, 且禁止出现在任何对外的成功率/宣传口径中。
- 不得返回硬编码
0掩盖缺失数据源:无数据源一律用NOT_INSTRUMENTED。
七、已知待办
| 项 | 说明 |
|---|---|
| Banner 曝光/点击埋点 | 需新建 home_banner_events 事件表 + 上报端点 + 小程序端埋点,闭环后才可把两个指标从 NOT_INSTRUMENTED 提升为 OFFICIAL |
match.effective 口径裁决 |
需业务方确认「有效」的判定标准(是否要求见面 / 持续 N 天),确认后提升为 OFFICIAL |
| 组织 / 站点维度拆分 | 当前统计为平台级;按组织或服务站拆分需叠加 DataScope(见 Phase 20 权限部分) |
| 大数据量优化 | service.user.unique 等跨域去重指标目前为内存去重;数据量达十万级时应改为数据库侧 UNION 聚合 |