From 64c9b72fd19cec916d964e2468330ede5fddfb81 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 11:30:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=8B=E4=BB=B6=E5=AD=97=E5=85=B8=20?= =?UTF-8?q?v2=20=E7=99=BD=E5=90=8D=E5=8D=95=E6=89=A9=E5=85=85=20pet/health?= =?UTF-8?q?=5Frecord=20=E5=9F=9F=2010=20=E4=BA=8B=E4=BB=B6=EF=BC=88T2-17?= =?UTF-8?q?=20=E5=90=8E=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../user/analytics/EventDictionary.java | 33 ++++- .../analytics/AnalyticsIntegrationTest.java | 135 ++++++++++++++++++ .../user/analytics/EventDictionaryTest.java | 57 +++++++- 3 files changed, 216 insertions(+), 9 deletions(-) diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java index ec513e0..3970dd2 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java @@ -5,12 +5,20 @@ import java.util.Set; import java.util.regex.Pattern; /** - * Event dictionary v1 (report 13 §4, auth funnel) plus page_viewed (report 19). - * ADR-013: health_record_action removed (client zero-reference, v2 events will - * be specific per action when M2 second wave lands). + * Event dictionary v2 (report 06 §1.4/§1.5): v1 auth funnel (report 13 §4) + * plus the M2 increment — pet domain (3 events) and health_record domain + * (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 * whitelist are stripped (kept event, counted warning); props whose KEY * 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 { @@ -35,8 +43,23 @@ public final class EventDictionary { Map.entry("auth_session_restore_succeeded", Set.of("durationMs", "usedRefresh")), Map.entry("auth_session_restore_failed", Set.of("failureReason", "errorCode", "httpStatus")), - // Report 19 遗留项 §2: - Map.entry("page_viewed", Set.of("pageName", "referrer")) + // v2 正稿(report 06 §5.2):pageName 归一化枚举(含 pet_form),referrer = 前一页 pageName + 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) { diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/analytics/AnalyticsIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/analytics/AnalyticsIntegrationTest.java index 9034e22..f389911 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/analytics/AnalyticsIntegrationTest.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/analytics/AnalyticsIntegrationTest.java @@ -225,4 +225,139 @@ class AnalyticsIntegrationTest { .andExpect(status().isBadRequest()) .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 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")); + } } diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/analytics/EventDictionaryTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/analytics/EventDictionaryTest.java index 7969445..fa02457 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/analytics/EventDictionaryTest.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/analytics/EventDictionaryTest.java @@ -5,10 +5,10 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; /** - * Locks the dictionary's whitelist boundaries: ADR-013 removed the - * health_record_action placeholder (client zero-reference); v1 auth funnel - * events plus page_viewed remain the complete set until dictionary v2 lands - * with M2's second wave. + * Locks the dictionary v2 whitelist boundaries (report 06 §1.4/§1.5): + * v1 auth funnel + page_viewed 正稿 + pet 域 3 事件 + health_record 域 7 事件. + * ADR-013's health_record_action stays removed — the per-action events + * below replace it. */ class EventDictionaryTest { @@ -28,4 +28,53 @@ class EventDictionaryTest { void unknownEventHasEmptyAllowedProps() { 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(); + } }