From 93d49b18b143e43b243246c8b566181f3fda674f Mon Sep 17 00:00:00 2001 From: malei <> Date: Sun, 2 Aug 2026 02:20:02 +0800 Subject: [PATCH] feat: add read-only pilot metrics query --- README.md | 2 + db/queries/pilot_metrics.sql | 171 ++++++++++++++++++ .../mapper/PilotMetricsQueryContractTest.java | 40 ++++ 3 files changed, 213 insertions(+) create mode 100644 db/queries/pilot_metrics.sql create mode 100644 src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java diff --git a/README.md b/README.md index aca987c..3160e20 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ 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 为数据门禁。 + 最小测试基线(P0 稳定化批次): - `SessionTokenServiceTest`:签发/校验/篡改/过期 - `ReportServiceTest`:appointmentId 必填、重复报告、doing→done diff --git a/db/queries/pilot_metrics.sql b/db/queries/pilot_metrics.sql new file mode 100644 index 0000000..839cd0f --- /dev/null +++ b/db/queries/pilot_metrics.sql @@ -0,0 +1,171 @@ +-- Petstore Pilot Metrics v1(MySQL 5.7+/8.x,只读) +-- 默认统计最近 30 天;发布/试点负责人可在当前 session 覆盖三个变量后逐段执行。 +-- 输出仅含 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; + +-- 1. 核心服务与报告漏斗。[from, to),各事实按稳定 aggregate_id 去重。 +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 = '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) + / 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) + / 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), + 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), + 1 + ) AS opened_to_lead_rate_pct, + ROUND( + 100 * COUNT(DISTINCT CASE WHEN e.event_type = 'rebook_created' THEN e.aggregate_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 +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) +GROUP BY e.store_id +ORDER BY e.store_id; + +-- 2. 报告发送时效:提交后 24 小时内是否形成首次显式发送事实。 +SELECT + submitted.store_id, + COUNT(*) AS submitted_report_count, + SUM(CASE + WHEN sent.occurred_at IS NOT NULL + 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) + THEN 1 ELSE 0 + END) / NULLIF(COUNT(*), 0), + 1 + ) AS sent_within_24h_rate_pct +FROM t_business_event submitted +LEFT JOIN t_business_event sent + ON sent.store_id = submitted.store_id + AND sent.event_type = 'report_sent' + AND sent.aggregate_type = 'report' + AND sent.aggregate_id = submitted.aggregate_id +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) +GROUP BY submitted.store_id +ORDER BY submitted.store_id; + +-- 3. 回访执行质量:以任务 due_date 落在窗口内为队列口径;退订不计员工完成。 +SELECT + t.store_id, + COUNT(*) AS due_follow_up_count, + SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) AS staff_completed_count, + SUM(CASE WHEN t.status = 'canceled' AND t.outcome = 'unsubscribed' THEN 1 ELSE 0 END) AS unsubscribed_count, + SUM(CASE WHEN t.status IN ('pending', 'in_progress') THEN 1 ELSE 0 END) AS still_open_count, + SUM(CASE + WHEN t.status IN ('pending', 'in_progress') AND t.due_date < CURRENT_DATE + THEN 1 ELSE 0 + END) AS overdue_open_count, + SUM(CASE + WHEN t.status = 'completed' + AND t.closed_at < DATE_ADD(t.due_date, INTERVAL 3 DAY) + THEN 1 ELSE 0 + END) AS completed_by_due_plus_2d_count, + ROUND( + 100 * SUM(CASE + WHEN t.status = 'completed' + AND t.closed_at < DATE_ADD(t.due_date, INTERVAL 3 DAY) + THEN 1 ELSE 0 + END) / NULLIF(SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END), 0), + 1 + ) AS on_time_completion_rate_pct, + ROUND(AVG(t.attempt_count), 2) AS average_attempt_count, + SUM(CASE WHEN t.outcome = 'rebooked' THEN 1 ELSE 0 END) AS rebooked_task_count, + SUM(CASE WHEN t.outcome = 'not_interested' THEN 1 ELSE 0 END) AS not_interested_count, + SUM(CASE WHEN t.outcome = 'invalid_contact' THEN 1 ELSE 0 END) AS invalid_contact_count +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) +GROUP BY t.store_id +ORDER BY t.store_id; + +-- 4. 约 200 次完整服务样本闸门:全历史按店 + 最后一行全店合计。 +SELECT + e.store_id, + COUNT(DISTINCT e.aggregate_id) AS complete_service_count, + MIN(e.occurred_at) AS first_complete_service_at, + MAX(e.occurred_at) AS latest_complete_service_at +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) +GROUP BY e.store_id WITH ROLLUP; + +-- 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 + ON r.appointment_id = completed.aggregate_id + AND r.store_id = completed.store_id + AND r.deleted = 0 +LEFT JOIN t_business_event submitted + ON submitted.store_id = completed.store_id + AND submitted.event_type = 'report_submitted' + AND submitted.aggregate_type = 'report' + AND submitted.aggregate_id = r.id +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); + +SELECT COUNT(*) AS invalid_rebook_attribution_count +FROM t_follow_up_task t +LEFT JOIN t_appointment a + ON a.id = t.rebooked_appointment_id + AND a.store_id = t.store_id + AND a.deleted = 0 +LEFT JOIN t_business_event e + ON e.idempotency_key = CONCAT('rebook_created:', t.rebooked_appointment_id) + AND e.event_type = 'rebook_created' + AND e.store_id = t.store_id + AND e.aggregate_type = 'appointment' + 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); + +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) + GROUP BY t.store_id, t.source_lead_id + HAVING COUNT(*) > 1 +) duplicated; diff --git a/src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java b/src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java new file mode 100644 index 0000000..892c13c --- /dev/null +++ b/src/test/java/com/petstore/mapper/PilotMetricsQueryContractTest.java @@ -0,0 +1,40 @@ +package com.petstore.mapper; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PilotMetricsQueryContractTest { + + private static final Pattern WRITE_STATEMENT = Pattern.compile( + "(?m)^\\s*(insert|update|delete|alter|drop|truncate|create|replace|call|grant|revoke)\\b" + ); + + @Test + void pilotMetricsScriptRemainsReadOnlyAndCarriesFrozenMeasures() throws Exception { + String sql = Files.readString(Path.of("db/queries/pilot_metrics.sql")); + String normalized = sql.toLowerCase(Locale.ROOT); + + assertFalse(WRITE_STATEMENT.matcher(normalized).find()); + assertTrue(normalized.contains("complete_service_count")); + 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")); + } + + @Test + void pilotMetricsOutputDoesNotSelectDirectIdentifiers() throws Exception { + String sql = Files.readString(Path.of("db/queries/pilot_metrics.sql")).toLowerCase(Locale.ROOT); + + assertFalse(Pattern.compile("select[\\s\\S]{0,300}\\b(phone|openid|unionid|token|client_ip|remark)\\b") + .matcher(sql) + .find()); + } +}