From 21846dba8b2a58bebaea51a1d5ba6396ce3c5df5 Mon Sep 17 00:00:00 2001 From: malei <> Date: Sun, 2 Aug 2026 02:35:58 +0800 Subject: [PATCH] fix: scope pilot metrics to explicit cohort --- README.md | 2 +- db/queries/pilot_metrics.sql | 255 ++++++++++++++++-- .../mapper/PilotMetricsQueryContractTest.java | 7 +- 3 files changed, 236 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3160e20..4cd721d 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ BUILD_FIRST=1 deploy/release-preflight.sh mvn test ``` -试点期的聚合指标使用只读脚本 `db/queries/pilot_metrics.sql`。脚本默认统计最近 30 天,可在当前数据库 session 覆盖 `@pilot_from/@pilot_to/@pilot_store_id`;输出不包含客户或员工直接标识。只能由只读账号/只读副本执行,完整发布仍以 31 项 production preflight 为数据门禁。 +试点期的聚合指标使用只读脚本 `db/queries/pilot_metrics.sql`。执行前必须在当前数据库 session 显式填写 3~5 个 `@pilot_store_ids`,并确认统一的 `@pilot_from/@pilot_to`;默认门店集合为 `0`,不会意外汇总全部门店或历史。输出不包含客户或员工直接标识。只能由只读账号/只读副本执行,完整发布仍以 31 项 production preflight 为数据门禁。 最小测试基线(P0 稳定化批次): - `SessionTokenServiceTest`:签发/校验/篡改/过期 diff --git a/db/queries/pilot_metrics.sql b/db/queries/pilot_metrics.sql index 839cd0f..99760ec 100644 --- a/db/queries/pilot_metrics.sql +++ b/db/queries/pilot_metrics.sql @@ -1,67 +1,228 @@ --- Petstore Pilot Metrics v1(MySQL 5.7+/8.x,只读) --- 默认统计最近 30 天;发布/试点负责人可在当前 session 覆盖三个变量后逐段执行。 +-- Petstore Pilot Metrics v2(MySQL 5.7+/8.x,只读) +-- 默认时间窗为最近 30 天,但默认门店集合为 0(不返回业务数据)。 +-- 执行人必须把 @pilot_store_ids 显式改为 3~5 个试点门店内部 ID,例如 '101,205,309'。 -- 输出仅含 store_id、计数、比率和时效,不含客户/员工 PII。 SET @pilot_to = DATE_ADD(CURRENT_DATE, INTERVAL 1 DAY); SET @pilot_from = DATE_SUB(@pilot_to, INTERVAL 30 DAY); -SET @pilot_store_id = NULL; +SET @pilot_store_ids = '0'; -- 1. 核心服务与报告漏斗。[from, to),各事实按稳定 aggregate_id 去重。 +-- 比率只认同窗且时间顺序正确的 cohort,避免跨窗口事件把比率推到 100% 以上。 SELECT e.store_id, COUNT(DISTINCT CASE WHEN e.event_type = 'appointment_created' THEN e.aggregate_id END) AS appointment_created_count, COUNT(DISTINCT CASE WHEN e.event_type = 'service_started' THEN e.aggregate_id END) AS service_started_count, COUNT(DISTINCT CASE WHEN e.event_type = 'service_completed' THEN e.aggregate_id END) AS complete_service_count, COUNT(DISTINCT CASE WHEN e.event_type = 'report_submitted' THEN e.aggregate_id END) AS report_submitted_count, - COUNT(DISTINCT CASE WHEN e.event_type = 'report_sent' THEN e.aggregate_id END) AS report_sent_count, - COUNT(DISTINCT CASE WHEN e.event_type IN ('report_opened', 'report_reopened') THEN e.aggregate_id END) AS report_opened_count, + COUNT(DISTINCT CASE + WHEN e.event_type = 'report_sent' + AND EXISTS ( + SELECT 1 FROM t_business_event submitted + WHERE submitted.store_id = e.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = e.aggregate_id + AND submitted.occurred_at >= @pilot_from + AND submitted.occurred_at <= e.occurred_at + ) + THEN e.aggregate_id + END) AS report_sent_count, + COUNT(DISTINCT CASE + WHEN e.event_type IN ('report_opened', 'report_reopened') + AND EXISTS ( + SELECT 1 + FROM t_business_event sent + JOIN t_business_event submitted + ON submitted.store_id = sent.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = sent.aggregate_id + AND submitted.occurred_at >= @pilot_from + AND submitted.occurred_at <= sent.occurred_at + WHERE sent.store_id = e.store_id + AND sent.event_type = 'report_sent' + AND sent.aggregate_type = 'report' + AND sent.aggregate_id = e.aggregate_id + AND sent.occurred_at >= @pilot_from + AND sent.occurred_at <= e.occurred_at + ) + THEN e.aggregate_id + END) AS report_opened_count, COUNT(DISTINCT CASE WHEN e.event_type = 'lead_submitted' THEN e.aggregate_id END) AS lead_submitted_count, COUNT(DISTINCT CASE WHEN e.event_type = 'rebook_created' THEN e.aggregate_id END) AS rebook_created_count, ROUND( - 100 * COUNT(DISTINCT CASE WHEN e.event_type = 'service_completed' THEN e.aggregate_id END) + 100 * COUNT(DISTINCT CASE + WHEN e.event_type = 'service_completed' + AND EXISTS ( + SELECT 1 FROM t_business_event started + WHERE started.store_id = e.store_id + AND started.event_type = 'service_started' + AND started.aggregate_type = 'appointment' + AND started.aggregate_id = e.aggregate_id + AND started.occurred_at >= @pilot_from + AND started.occurred_at <= e.occurred_at + ) + THEN e.aggregate_id + END) / NULLIF(COUNT(DISTINCT CASE WHEN e.event_type = 'service_started' THEN e.aggregate_id END), 0), 1 ) AS service_completion_rate_pct, ROUND( - 100 * COUNT(DISTINCT CASE WHEN e.event_type = 'report_sent' THEN e.aggregate_id END) + 100 * COUNT(DISTINCT CASE + WHEN e.event_type = 'report_sent' + AND EXISTS ( + SELECT 1 FROM t_business_event submitted + WHERE submitted.store_id = e.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = e.aggregate_id + AND submitted.occurred_at >= @pilot_from + AND submitted.occurred_at <= e.occurred_at + ) + THEN e.aggregate_id + END) / NULLIF(COUNT(DISTINCT CASE WHEN e.event_type = 'report_submitted' THEN e.aggregate_id END), 0), 1 ) AS report_sent_rate_pct, ROUND( - 100 * COUNT(DISTINCT CASE WHEN e.event_type IN ('report_opened', 'report_reopened') THEN e.aggregate_id END) - / NULLIF(COUNT(DISTINCT CASE WHEN e.event_type = 'report_sent' THEN e.aggregate_id END), 0), + 100 * COUNT(DISTINCT CASE + WHEN e.event_type IN ('report_opened', 'report_reopened') + AND EXISTS ( + SELECT 1 + FROM t_business_event sent + JOIN t_business_event submitted + ON submitted.store_id = sent.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = sent.aggregate_id + AND submitted.occurred_at >= @pilot_from + AND submitted.occurred_at <= sent.occurred_at + WHERE sent.store_id = e.store_id + AND sent.event_type = 'report_sent' + AND sent.aggregate_type = 'report' + AND sent.aggregate_id = e.aggregate_id + AND sent.occurred_at >= @pilot_from + AND sent.occurred_at <= e.occurred_at + ) + THEN e.aggregate_id + END) + / NULLIF(COUNT(DISTINCT CASE + WHEN e.event_type = 'report_sent' + AND EXISTS ( + SELECT 1 FROM t_business_event submitted + WHERE submitted.store_id = e.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = e.aggregate_id + AND submitted.occurred_at >= @pilot_from + AND submitted.occurred_at <= e.occurred_at + ) + THEN e.aggregate_id + END), 0), 1 ) AS report_open_rate_pct, ROUND( - 100 * COUNT(DISTINCT CASE WHEN e.event_type = 'lead_submitted' THEN e.aggregate_id END) - / NULLIF(COUNT(DISTINCT CASE WHEN e.event_type IN ('report_opened', 'report_reopened') THEN e.aggregate_id END), 0), + 100 * COUNT(DISTINCT CASE + WHEN e.event_type = 'lead_submitted' + AND lead_fact.report_id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM t_business_event opened + JOIN t_business_event sent + ON sent.store_id = opened.store_id + AND sent.event_type = 'report_sent' + AND sent.aggregate_type = 'report' + AND sent.aggregate_id = opened.aggregate_id + AND sent.occurred_at >= @pilot_from + AND sent.occurred_at <= opened.occurred_at + JOIN t_business_event submitted + ON submitted.store_id = sent.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = sent.aggregate_id + AND submitted.occurred_at >= @pilot_from + AND submitted.occurred_at <= sent.occurred_at + WHERE opened.store_id = e.store_id + AND opened.event_type IN ('report_opened', 'report_reopened') + AND opened.aggregate_type = 'report' + AND opened.aggregate_id = lead_fact.report_id + AND opened.occurred_at >= @pilot_from + AND opened.occurred_at <= e.occurred_at + ) + THEN lead_fact.report_id + END) + / NULLIF(COUNT(DISTINCT CASE + WHEN e.event_type IN ('report_opened', 'report_reopened') + AND EXISTS ( + SELECT 1 + FROM t_business_event sent + JOIN t_business_event submitted + ON submitted.store_id = sent.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = sent.aggregate_id + AND submitted.occurred_at >= @pilot_from + AND submitted.occurred_at <= sent.occurred_at + WHERE sent.store_id = e.store_id + AND sent.event_type = 'report_sent' + AND sent.aggregate_type = 'report' + AND sent.aggregate_id = e.aggregate_id + AND sent.occurred_at >= @pilot_from + AND sent.occurred_at <= e.occurred_at + ) + THEN e.aggregate_id + END), 0), 1 ) AS opened_to_lead_rate_pct, ROUND( - 100 * COUNT(DISTINCT CASE WHEN e.event_type = 'rebook_created' THEN e.aggregate_id END) + 100 * COUNT(DISTINCT CASE + WHEN e.event_type = 'rebook_created' + AND rebook.source_lead_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM t_business_event submitted_lead + WHERE submitted_lead.store_id = e.store_id + AND submitted_lead.event_type = 'lead_submitted' + AND submitted_lead.aggregate_type = 'report_lead' + AND submitted_lead.aggregate_id = rebook.source_lead_id + AND submitted_lead.occurred_at >= @pilot_from + AND submitted_lead.occurred_at <= e.occurred_at + ) + THEN rebook.source_lead_id + END) / NULLIF(COUNT(DISTINCT CASE WHEN e.event_type = 'lead_submitted' THEN e.aggregate_id END), 0), 1 ) AS lead_to_rebook_rate_pct FROM t_business_event e +LEFT JOIN t_report_lead lead_fact + ON e.event_type = 'lead_submitted' + AND lead_fact.id = e.aggregate_id + AND lead_fact.store_id = e.store_id +LEFT JOIN t_follow_up_task rebook + ON e.event_type = 'rebook_created' + AND rebook.rebooked_appointment_id = e.aggregate_id + AND rebook.store_id = e.store_id WHERE e.occurred_at >= @pilot_from AND e.occurred_at < @pilot_to - AND (@pilot_store_id IS NULL OR e.store_id = @pilot_store_id) + AND FIND_IN_SET(CAST(e.store_id AS CHAR), @pilot_store_ids) > 0 GROUP BY e.store_id ORDER BY e.store_id; --- 2. 报告发送时效:提交后 24 小时内是否形成首次显式发送事实。 +-- 2. 报告发送时效:只纳入已经完整观察 24 小时的提交 cohort。 SELECT submitted.store_id, - COUNT(*) AS submitted_report_count, + COUNT(*) AS matured_submitted_report_count, SUM(CASE WHEN sent.occurred_at IS NOT NULL - AND sent.occurred_at < DATE_ADD(submitted.occurred_at, INTERVAL 24 HOUR) + AND sent.occurred_at >= submitted.occurred_at + AND sent.occurred_at <= DATE_ADD(submitted.occurred_at, INTERVAL 24 HOUR) THEN 1 ELSE 0 END) AS sent_within_24h_count, ROUND( 100 * SUM(CASE WHEN sent.occurred_at IS NOT NULL - AND sent.occurred_at < DATE_ADD(submitted.occurred_at, INTERVAL 24 HOUR) + AND sent.occurred_at >= submitted.occurred_at + AND sent.occurred_at <= DATE_ADD(submitted.occurred_at, INTERVAL 24 HOUR) THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 1 @@ -75,8 +236,8 @@ LEFT JOIN t_business_event sent WHERE submitted.event_type = 'report_submitted' AND submitted.aggregate_type = 'report' AND submitted.occurred_at >= @pilot_from - AND submitted.occurred_at < @pilot_to - AND (@pilot_store_id IS NULL OR submitted.store_id = @pilot_store_id) + AND submitted.occurred_at < DATE_SUB(@pilot_to, INTERVAL 24 HOUR) + AND FIND_IN_SET(CAST(submitted.store_id AS CHAR), @pilot_store_ids) > 0 GROUP BY submitted.store_id ORDER BY submitted.store_id; @@ -111,11 +272,11 @@ SELECT FROM t_follow_up_task t WHERE t.due_date >= DATE(@pilot_from) AND t.due_date < DATE(@pilot_to) - AND (@pilot_store_id IS NULL OR t.store_id = @pilot_store_id) + AND FIND_IN_SET(CAST(t.store_id AS CHAR), @pilot_store_ids) > 0 GROUP BY t.store_id ORDER BY t.store_id; --- 4. 约 200 次完整服务样本闸门:全历史按店 + 最后一行全店合计。 +-- 4. 约 200 次完整服务样本闸门:严格使用试点窗口,按店 + 最后一行试点店合计。 SELECT e.store_id, COUNT(DISTINCT e.aggregate_id) AS complete_service_count, @@ -124,10 +285,12 @@ SELECT FROM t_business_event e WHERE e.event_type = 'service_completed' AND e.aggregate_type = 'appointment' - AND (@pilot_store_id IS NULL OR e.store_id = @pilot_store_id) + AND e.occurred_at >= @pilot_from + AND e.occurred_at < @pilot_to + AND FIND_IN_SET(CAST(e.store_id AS CHAR), @pilot_store_ids) > 0 GROUP BY e.store_id WITH ROLLUP; --- 5. 指标可靠性反例:三项均须为 0;完整发布还必须通过 31 项 production preflight。 +-- 5. 指标可靠性反例:四项均须为 0;完整发布还必须通过 31 项 production preflight。 SELECT COUNT(*) AS completed_services_without_submitted_report_event_count FROM t_business_event completed LEFT JOIN t_report r @@ -142,7 +305,9 @@ LEFT JOIN t_business_event submitted WHERE completed.event_type = 'service_completed' AND completed.aggregate_type = 'appointment' AND (r.id IS NULL OR submitted.id IS NULL) - AND (@pilot_store_id IS NULL OR completed.store_id = @pilot_store_id); + AND completed.occurred_at >= @pilot_from + AND completed.occurred_at < @pilot_to + AND FIND_IN_SET(CAST(completed.store_id AS CHAR), @pilot_store_ids) > 0; SELECT COUNT(*) AS invalid_rebook_attribution_count FROM t_follow_up_task t @@ -158,14 +323,52 @@ LEFT JOIN t_business_event e AND e.aggregate_id = t.rebooked_appointment_id WHERE t.outcome = 'rebooked' AND (a.id IS NULL OR e.id IS NULL) - AND (@pilot_store_id IS NULL OR t.store_id = @pilot_store_id); + AND t.closed_at >= @pilot_from + AND t.closed_at < @pilot_to + AND FIND_IN_SET(CAST(t.store_id AS CHAR), @pilot_store_ids) > 0; SELECT COUNT(*) AS duplicate_open_follow_up_task_count FROM ( SELECT t.store_id, t.source_lead_id FROM t_follow_up_task t WHERE t.status IN ('pending', 'in_progress') - AND (@pilot_store_id IS NULL OR t.store_id = @pilot_store_id) + AND FIND_IN_SET(CAST(t.store_id AS CHAR), @pilot_store_ids) > 0 GROUP BY t.store_id, t.source_lead_id HAVING COUNT(*) > 1 ) duplicated; + +SELECT SUM(anomaly_count) AS invalid_metric_event_order_count +FROM ( + SELECT COUNT(*) AS anomaly_count + FROM t_business_event sent + JOIN t_business_event submitted + ON submitted.store_id = sent.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = sent.aggregate_id + WHERE sent.event_type = 'report_sent' + AND sent.aggregate_type = 'report' + AND sent.occurred_at < submitted.occurred_at + AND sent.occurred_at >= @pilot_from + AND sent.occurred_at < @pilot_to + AND FIND_IN_SET(CAST(sent.store_id AS CHAR), @pilot_store_ids) > 0 + + UNION ALL + + SELECT COUNT(*) AS anomaly_count + FROM t_business_event rebooked + JOIN t_follow_up_task task + ON task.store_id = rebooked.store_id + AND task.rebooked_appointment_id = rebooked.aggregate_id + JOIN t_business_event submitted_lead + ON submitted_lead.store_id = rebooked.store_id + AND submitted_lead.event_type = 'lead_submitted' + AND submitted_lead.aggregate_type = 'report_lead' + AND submitted_lead.aggregate_id = task.source_lead_id + WHERE rebooked.event_type = 'rebook_created' + AND rebooked.aggregate_type = 'appointment' + AND rebooked.occurred_at < submitted_lead.occurred_at + AND rebooked.occurred_at >= @pilot_from + AND rebooked.occurred_at < @pilot_to + AND FIND_IN_SET(CAST(rebooked.store_id AS CHAR), @pilot_store_ids) > 0 +) anomalies; diff --git a/src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java b/src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java index 892c13c..14fd7e0 100644 --- a/src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java +++ b/src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java @@ -26,7 +26,12 @@ class PilotMetricsQueryContractTest { assertTrue(normalized.contains("sent_within_24h_rate_pct")); assertTrue(normalized.contains("on_time_completion_rate_pct")); assertTrue(normalized.contains("rebook_created")); - assertTrue(normalized.contains("@pilot_store_id")); + assertTrue(normalized.contains("set @pilot_store_ids = '0'")); + assertTrue(normalized.contains("petstore pilot metrics v2")); + assertTrue(normalized.contains("find_in_set(cast(")); + assertTrue(normalized.contains("matured_submitted_report_count")); + assertTrue(normalized.contains("invalid_metric_event_order_count")); + assertFalse(normalized.contains("@pilot_store_id = null")); } @Test