feat: 事件字典 v2 白名单扩充 pet/health_record 域 10 事件(T2-17 后端)
CI / backend-test (push) Successful in 5m18s
CI / backend-test (push) Successful in 5m18s
- EventDictionary 增补 pet 域 3 事件(pet_create_started/succeeded/failed) 与 health_record 域 7 事件(create_started/succeeded/failed、viewed、 edit_succeeded/failed、deleted),props 键集按 06 号报告 §1.5 直抄 - page_viewed 转正为 v2 正稿:props 键集 pageName/referrer 与正稿一致, pageName 枚举(含 pet_form)在客户端编译期约束 + §6.4 值级巡检兜底 - 字典边界测试 5 例:三域 props 键集全矩阵、page_viewed 正稿键集、 刻意不设事件(pet_viewed/edit_started/delete_failed)保持 unknown - 接收端集成测试 4 例:v2 事件端到端接受、v2 白名单外 props 剥离、 枚举外 recordType 值过 ingest 交巡检、废弃 health_record_action 仍拒绝 - 测试 182 → 191 全绿 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,20 @@ import java.util.Set;
|
|||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event dictionary v1 (report 13 §4, auth funnel) plus page_viewed (report 19).
|
* Event dictionary v2 (report 06 §1.4/§1.5): v1 auth funnel (report 13 §4)
|
||||||
* ADR-013: health_record_action removed (client zero-reference, v2 events will
|
* plus the M2 increment — pet domain (3 events) and health_record domain
|
||||||
* be specific per action when M2 second wave lands).
|
* (7 events) — and page_viewed formalized (report 06 §5.2, was report 19
|
||||||
|
* ad-hoc addition; same props keys pageName/referrer).
|
||||||
|
* ADR-013: health_record_action removed (client zero-reference), replaced by
|
||||||
|
* the per-action health_record_* events below.
|
||||||
* Unknown event names reject the whole event; props outside the per-event
|
* Unknown event names reject the whole event; props outside the per-event
|
||||||
* whitelist are stripped (kept event, counted warning); props whose KEY
|
* whitelist are stripped (kept event, counted warning); props whose KEY
|
||||||
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
|
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
|
||||||
|
* Value-level enum conformance (recordType: weight/vaccine/health_event/
|
||||||
|
* reminder; failureReason incl. permission_denied/conflict/not_found;
|
||||||
|
* pageName: login/register/home/profile/pet_list/pet_detail/pet_form/
|
||||||
|
* record_form/record_detail) is enforced client-side (compile-time enums)
|
||||||
|
* and patrolled offline (report 06 §6.4); ingest validates keys only.
|
||||||
*/
|
*/
|
||||||
public final class EventDictionary {
|
public final class EventDictionary {
|
||||||
|
|
||||||
@@ -35,8 +43,23 @@ public final class EventDictionary {
|
|||||||
Map.entry("auth_session_restore_succeeded", Set.of("durationMs", "usedRefresh")),
|
Map.entry("auth_session_restore_succeeded", Set.of("durationMs", "usedRefresh")),
|
||||||
Map.entry("auth_session_restore_failed",
|
Map.entry("auth_session_restore_failed",
|
||||||
Set.of("failureReason", "errorCode", "httpStatus")),
|
Set.of("failureReason", "errorCode", "httpStatus")),
|
||||||
// Report 19 遗留项 §2:
|
// v2 正稿(report 06 §5.2):pageName 归一化枚举(含 pet_form),referrer = 前一页 pageName
|
||||||
Map.entry("page_viewed", Set.of("pageName", "referrer"))
|
Map.entry("page_viewed", Set.of("pageName", "referrer")),
|
||||||
|
// v2 增量 pet 域(report 06 §1.4)
|
||||||
|
Map.entry("pet_create_started", Set.of("entryPoint")),
|
||||||
|
Map.entry("pet_create_succeeded", Set.of("durationMs", "species", "petIndex")),
|
||||||
|
Map.entry("pet_create_failed",
|
||||||
|
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
|
||||||
|
// v2 增量 health_record 域(report 06 §1.4),recordType 枚举 weight/vaccine/health_event/reminder
|
||||||
|
Map.entry("health_record_create_started", Set.of("recordType", "entryPoint")),
|
||||||
|
Map.entry("health_record_create_succeeded", Set.of("recordType", "durationMs", "photoCount")),
|
||||||
|
Map.entry("health_record_create_failed",
|
||||||
|
Set.of("recordType", "failureReason", "errorCode", "httpStatus", "attemptSeq")),
|
||||||
|
Map.entry("health_record_viewed", Set.of("recordType", "source")),
|
||||||
|
Map.entry("health_record_edit_succeeded", Set.of("recordType", "fieldCount")),
|
||||||
|
Map.entry("health_record_edit_failed",
|
||||||
|
Set.of("recordType", "failureReason", "errorCode", "httpStatus")),
|
||||||
|
Map.entry("health_record_deleted", Set.of("recordType"))
|
||||||
);
|
);
|
||||||
|
|
||||||
public static boolean isKnownEvent(String eventName) {
|
public static boolean isKnownEvent(String eventName) {
|
||||||
|
|||||||
+135
@@ -225,4 +225,139 @@ class AnalyticsIntegrationTest {
|
|||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value(40000));
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsV2HealthRecordFunnelEvent() throws Exception {
|
||||||
|
String eventId = UUID.randomUUID().toString();
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"events": [{
|
||||||
|
"eventId": "%s",
|
||||||
|
"eventName": "health_record_create_succeeded",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "019212aa-0000-7000-8000-000000000001",
|
||||||
|
"sessionId": "019212aa-1111-7000-8000-000000000001",
|
||||||
|
"clientTs": "%s",
|
||||||
|
"appVersion": "1.1.0",
|
||||||
|
"platform": "android",
|
||||||
|
"osVersion": "android-14",
|
||||||
|
"props": {"recordType": "weight", "durationMs": 4200, "photoCount": 0}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(eventId, OffsetDateTime.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.data.accepted").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.results[0].status").value("accepted"));
|
||||||
|
|
||||||
|
String storedName = jdbcClient.sql(
|
||||||
|
"SELECT event_name FROM platform.product_events WHERE event_id = :id")
|
||||||
|
.param("id", UUID.fromString(eventId))
|
||||||
|
.query(String.class)
|
||||||
|
.single();
|
||||||
|
assertThat(storedName).isEqualTo("health_record_create_succeeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripsPropsOutsideV2Whitelist() throws Exception {
|
||||||
|
String eventId = UUID.randomUUID().toString();
|
||||||
|
// health_record_deleted 白名单只有 recordType,混入的 note 类内容字段必须被剥离
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"events": [{
|
||||||
|
"eventId": "%s",
|
||||||
|
"eventName": "health_record_deleted",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "019212aa-0000-7000-8000-000000000001",
|
||||||
|
"sessionId": "019212aa-1111-7000-8000-000000000001",
|
||||||
|
"clientTs": "%s",
|
||||||
|
"appVersion": "1.1.0",
|
||||||
|
"platform": "ios",
|
||||||
|
"osVersion": "ios-17",
|
||||||
|
"props": {"recordType": "vaccine", "recordTitle": "should_be_stripped"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(eventId, OffsetDateTime.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.data.accepted").value(1));
|
||||||
|
|
||||||
|
Map<String, Object> storedProps = jdbcClient.sql(
|
||||||
|
"SELECT props::text FROM platform.product_events WHERE event_id = :id")
|
||||||
|
.param("id", UUID.fromString(eventId))
|
||||||
|
.query((rs, rowNum) -> {
|
||||||
|
try {
|
||||||
|
return new com.fasterxml.jackson.databind.ObjectMapper()
|
||||||
|
.readValue(rs.getString(1), Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.single();
|
||||||
|
assertThat(storedProps).containsEntry("recordType", "vaccine");
|
||||||
|
assertThat(storedProps).doesNotContainKey("recordTitle");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumOutRecordTypeValuePassesIngestForOfflinePatrol() throws Exception {
|
||||||
|
// 字典 v2 的 recordType 枚举(weight/vaccine/health_event/reminder)在客户端编译期约束,
|
||||||
|
// 接收端只做键级白名单校验;枚举外的值按 report 06 §6.4 由值级巡检兜底,不在 ingest 拒绝。
|
||||||
|
String eventId = UUID.randomUUID().toString();
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"events": [{
|
||||||
|
"eventId": "%s",
|
||||||
|
"eventName": "health_record_viewed",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "019212aa-0000-7000-8000-000000000001",
|
||||||
|
"sessionId": "019212aa-1111-7000-8000-000000000001",
|
||||||
|
"clientTs": "%s",
|
||||||
|
"appVersion": "1.1.0",
|
||||||
|
"platform": "android",
|
||||||
|
"osVersion": "android-14",
|
||||||
|
"props": {"recordType": "grooming", "source": "pet_detail"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(eventId, OffsetDateTime.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.data.accepted").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.results[0].status").value("accepted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retiredHealthRecordActionStaysRejected() throws Exception {
|
||||||
|
String body = """
|
||||||
|
{
|
||||||
|
"events": [{
|
||||||
|
"eventId": "019212aa-4444-7000-8000-000000000001",
|
||||||
|
"eventName": "health_record_action",
|
||||||
|
"eventVersion": 1,
|
||||||
|
"anonymousId": "019212aa-0000-7000-8000-000000000001",
|
||||||
|
"sessionId": "019212aa-1111-7000-8000-000000000001",
|
||||||
|
"clientTs": "%s",
|
||||||
|
"appVersion": "1.1.0",
|
||||||
|
"platform": "android",
|
||||||
|
"osVersion": "android-14",
|
||||||
|
"props": {"recordType": "weight", "actionType": "create"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""".formatted(OffsetDateTime.now());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/events")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(body))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.data.rejected").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-4
@@ -5,10 +5,10 @@ import org.junit.jupiter.api.Test;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locks the dictionary's whitelist boundaries: ADR-013 removed the
|
* Locks the dictionary v2 whitelist boundaries (report 06 §1.4/§1.5):
|
||||||
* health_record_action placeholder (client zero-reference); v1 auth funnel
|
* v1 auth funnel + page_viewed 正稿 + pet 域 3 事件 + health_record 域 7 事件.
|
||||||
* events plus page_viewed remain the complete set until dictionary v2 lands
|
* ADR-013's health_record_action stays removed — the per-action events
|
||||||
* with M2's second wave.
|
* below replace it.
|
||||||
*/
|
*/
|
||||||
class EventDictionaryTest {
|
class EventDictionaryTest {
|
||||||
|
|
||||||
@@ -28,4 +28,53 @@ class EventDictionaryTest {
|
|||||||
void unknownEventHasEmptyAllowedProps() {
|
void unknownEventHasEmptyAllowedProps() {
|
||||||
assertThat(EventDictionary.allowedProps("health_record_action")).isEmpty();
|
assertThat(EventDictionary.allowedProps("health_record_action")).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v2PetDomainEventsMatchDictionary() {
|
||||||
|
assertThat(EventDictionary.allowedProps("pet_create_started"))
|
||||||
|
.containsExactlyInAnyOrder("entryPoint");
|
||||||
|
assertThat(EventDictionary.allowedProps("pet_create_succeeded"))
|
||||||
|
.containsExactlyInAnyOrder("durationMs", "species", "petIndex");
|
||||||
|
assertThat(EventDictionary.allowedProps("pet_create_failed"))
|
||||||
|
.containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v2HealthRecordCreateFunnelMatchesDictionary() {
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_create_started"))
|
||||||
|
.containsExactlyInAnyOrder("recordType", "entryPoint");
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_create_succeeded"))
|
||||||
|
.containsExactlyInAnyOrder("recordType", "durationMs", "photoCount");
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_create_failed"))
|
||||||
|
.containsExactlyInAnyOrder(
|
||||||
|
"recordType", "failureReason", "errorCode", "httpStatus", "attemptSeq");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v2HealthRecordLifecycleEventsMatchDictionary() {
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_viewed"))
|
||||||
|
.containsExactlyInAnyOrder("recordType", "source");
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_edit_succeeded"))
|
||||||
|
.containsExactlyInAnyOrder("recordType", "fieldCount");
|
||||||
|
// edit_failed 无 attemptSeq(编辑不设重试序号,report 06 §1.4)
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_edit_failed"))
|
||||||
|
.containsExactlyInAnyOrder("recordType", "failureReason", "errorCode", "httpStatus");
|
||||||
|
assertThat(EventDictionary.allowedProps("health_record_deleted"))
|
||||||
|
.containsExactlyInAnyOrder("recordType");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pageViewedFormalizedPropsAreExactlyPageNameAndReferrer() {
|
||||||
|
// report 06 §5.2:v2 正稿键集不变,pageName 枚举化在客户端编译期与 §6.4 值级巡检保障
|
||||||
|
assertThat(EventDictionary.allowedProps("page_viewed"))
|
||||||
|
.containsExactlyInAnyOrder("pageName", "referrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deliberatelyAbsentEventsStayUnknown() {
|
||||||
|
// report 06 §1.4:pet 浏览由 page_viewed 覆盖,不设 pet_viewed;编辑不设 started;删除不埋失败
|
||||||
|
assertThat(EventDictionary.isKnownEvent("pet_viewed")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("health_record_edit_started")).isFalse();
|
||||||
|
assertThat(EventDictionary.isKnownEvent("health_record_delete_failed")).isFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user