feat: add read-only pilot metrics query
This commit is contained in:
parent
c0771b34ff
commit
93d49b18b1
@ -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
|
||||
|
||||
171
db/queries/pilot_metrics.sql
Normal file
171
db/queries/pilot_metrics.sql
Normal file
@ -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;
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user