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 3970dd2..3a718d0 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,20 +5,32 @@ import java.util.Set; import java.util.regex.Pattern; /** - * 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). + * Event dictionary v3 (iteration-3 report 06 §1.4/§1.5, ADR-020): v1 auth + * funnel + v2 pet/health_record domains + page_viewed 正稿, plus the M3 + * community increment — post domain (8: publish funnel, draft, delete, and + * the per-file media upload funnel), feed domain (2: feed_viewed as an + * aggregated browse-segment exposure event per §1.2 — no per-card + * post_impression — and feed_load_failed), interactions (8: like/unlike, + * favorite/unfavorite, comment success/failure pair, follow/unfollow) — + * and experiment_exposed (platform domain, A/B prerequisite #5, dictionary + * ahead of its M4 first use). * 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. + * Community privacy red-lines (report 06 §1.3): no free text, no content or + * counterpart IDs (postId/commentId/topicId/target userId), no topic names, + * no file names/paths/URLs — only behavior counts and buckets + * (textLengthBucket, sizeBucket) ever reach props. * 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. + * reminder; failureReason incl. media_too_large/unsupported_format; + * pageName v3 family: login/register/home/profile/pet_list/pet_detail/ + * pet_form/record_form/record_detail + create/pet_archive/services/ + * post_detail (收编) + post_form/topic_list/topic_detail/user_profile/ + * follower_list/following_list/favorite_list/draft_list) is enforced + * client-side (compile-time enums) and patrolled offline (report 06 §6.4); + * ingest validates keys only — pageName growth needs no code change here. */ public final class EventDictionary { @@ -59,7 +71,36 @@ public final class EventDictionary { 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")) + Map.entry("health_record_deleted", Set.of("recordType")), + // v3 增量 post 域(iteration-3 报告 06 §1.4) + Map.entry("post_create_started", Set.of("entryPoint")), + Map.entry("post_draft_saved", Set.of("trigger", "mediaCount")), + Map.entry("post_publish_succeeded", + Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft")), + Map.entry("post_publish_failed", + Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")), + Map.entry("post_deleted", Set.of()), + Map.entry("post_media_upload_started", Set.of("mediaType", "sizeBucket")), + Map.entry("post_media_upload_succeeded", Set.of("mediaType", "sizeBucket", "durationMs")), + Map.entry("post_media_upload_failed", + Set.of("mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq")), + // v3 增量 feed 域(聚合曝光设计,§1.2 裁定) + Map.entry("feed_viewed", + Set.of("feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount")), + Map.entry("feed_load_failed", + Set.of("feedTab", "loadType", "failureReason", "errorCode", "httpStatus")), + // v3 增量互动 + Map.entry("post_liked", Set.of("source")), + Map.entry("post_unliked", Set.of("source")), + Map.entry("post_favorited", Set.of("source")), + Map.entry("post_unfavorited", Set.of("source")), + Map.entry("comment_create_succeeded", Set.of("durationMs", "isReply", "textLengthBucket")), + Map.entry("comment_create_failed", + Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")), + Map.entry("user_followed", Set.of("source")), + Map.entry("user_unfollowed", Set.of("source")), + // A/B 前置 #5:曝光事件字典先行,M4 启用(§1.4) + Map.entry("experiment_exposed", Set.of("experimentKey", "variant")) ); 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 f389911..85e3aa9 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 @@ -360,4 +360,115 @@ class AnalyticsIntegrationTest { .andExpect(jsonPath("$.data.rejected").value(1)) .andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name")); } + + @Test + void acceptsV3FeedViewedAggregateEvent() throws Exception { + String eventId = UUID.randomUUID().toString(); + String body = """ + { + "events": [{ + "eventId": "%s", + "eventName": "feed_viewed", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.2.0", + "platform": "android", + "osVersion": "android-14", + "props": { + "feedTab": "home", + "durationMs": 45000, + "impressionCount": 18, + "loadMoreCount": 2, + "refreshCount": 1 + } + }] + } + """.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("feed_viewed"); + } + + @Test + void stripsContentIdPropsFromV3InteractionEvent() throws Exception { + String eventId = UUID.randomUUID().toString(); + // 红线 2(report 06 §1.3):行为客体的内容标识不进 props——白名单外的 postId 必须被剥离 + String body = """ + { + "events": [{ + "eventId": "%s", + "eventName": "post_liked", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.2.0", + "platform": "ios", + "osVersion": "ios-17", + "props": {"source": "feed", "postId": "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("source", "feed"); + assertThat(storedProps).doesNotContainKey("postId"); + } + + @Test + void rejectedPerCardImpressionStaysOutOfDictionary() throws Exception { + // report 06 §1.2 裁定:逐卡曝光 post_impression 被否决,接收端按未知事件拒绝 + String body = """ + { + "events": [{ + "eventId": "019212aa-5555-7000-8000-000000000001", + "eventName": "post_impression", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.2.0", + "platform": "android", + "osVersion": "android-14" + }] + } + """.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 fa02457..48c49a7 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,8 +5,10 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; /** - * Locks the dictionary v2 whitelist boundaries (report 06 §1.4/§1.5): - * v1 auth funnel + page_viewed 正稿 + pet 域 3 事件 + health_record 域 7 事件. + * Locks the dictionary v3 whitelist boundaries (iteration-3 report 06 + * §1.4/§1.5, ADR-020): v1 auth funnel + page_viewed 正稿 + v2 pet/ + * health_record domains + M3 community increment (post 8, feed 2, + * interactions 8) + experiment_exposed (dictionary ahead of M4 use). * ADR-013's health_record_action stays removed — the per-action events * below replace it. */ @@ -77,4 +79,77 @@ class EventDictionaryTest { assertThat(EventDictionary.isKnownEvent("health_record_edit_started")).isFalse(); assertThat(EventDictionary.isKnownEvent("health_record_delete_failed")).isFalse(); } + + @Test + void v3PostPublishFunnelMatchesDictionary() { + assertThat(EventDictionary.allowedProps("post_create_started")) + .containsExactlyInAnyOrder("entryPoint"); + assertThat(EventDictionary.allowedProps("post_draft_saved")) + .containsExactlyInAnyOrder("trigger", "mediaCount"); + assertThat(EventDictionary.allowedProps("post_publish_succeeded")) + .containsExactlyInAnyOrder( + "durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft"); + assertThat(EventDictionary.allowedProps("post_publish_failed")) + .containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq"); + // post_deleted 单事件风格无专有属性(report 06 §1.4) + assertThat(EventDictionary.isKnownEvent("post_deleted")).isTrue(); + assertThat(EventDictionary.allowedProps("post_deleted")).isEmpty(); + } + + @Test + void v3MediaUploadFunnelMatchesDictionary() { + assertThat(EventDictionary.allowedProps("post_media_upload_started")) + .containsExactlyInAnyOrder("mediaType", "sizeBucket"); + assertThat(EventDictionary.allowedProps("post_media_upload_succeeded")) + .containsExactlyInAnyOrder("mediaType", "sizeBucket", "durationMs"); + assertThat(EventDictionary.allowedProps("post_media_upload_failed")) + .containsExactlyInAnyOrder( + "mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq"); + } + + @Test + void v3FeedDomainMatchesDictionary() { + // feed_viewed 是浏览段聚合曝光事件(report 06 §1.2):只有计数与时长,绝无 postId 类内容标识 + assertThat(EventDictionary.allowedProps("feed_viewed")) + .containsExactlyInAnyOrder( + "feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount"); + assertThat(EventDictionary.allowedProps("feed_load_failed")) + .containsExactlyInAnyOrder("feedTab", "loadType", "failureReason", "errorCode", "httpStatus"); + } + + @Test + void v3InteractionEventsMatchDictionary() { + // like/unlike、favorite/unfavorite、follow/unfollow 分立事件名(v2 废弃 action 属性同一逻辑) + assertThat(EventDictionary.allowedProps("post_liked")).containsExactlyInAnyOrder("source"); + assertThat(EventDictionary.allowedProps("post_unliked")).containsExactlyInAnyOrder("source"); + assertThat(EventDictionary.allowedProps("post_favorited")).containsExactlyInAnyOrder("source"); + assertThat(EventDictionary.allowedProps("post_unfavorited")).containsExactlyInAnyOrder("source"); + assertThat(EventDictionary.allowedProps("user_followed")).containsExactlyInAnyOrder("source"); + assertThat(EventDictionary.allowedProps("user_unfollowed")).containsExactlyInAnyOrder("source"); + assertThat(EventDictionary.allowedProps("comment_create_succeeded")) + .containsExactlyInAnyOrder("durationMs", "isReply", "textLengthBucket"); + assertThat(EventDictionary.allowedProps("comment_create_failed")) + .containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq"); + } + + @Test + void v3ExperimentExposedRegisteredAheadOfM4Use() { + // A/B 前置 #5(report 06 §1.4):M4 首实验才启用,字典与白名单本迭代一次进 + assertThat(EventDictionary.allowedProps("experiment_exposed")) + .containsExactlyInAnyOrder("experimentKey", "variant"); + } + + @Test + void v3DeliberatelyAbsentEventsStayUnknown() { + // report 06 §1.2:逐卡曝光被否决,不设 post_impression;§1.4 取舍: + // 帖子浏览由 page_viewed(post_detail) 覆盖、评论不设 started、单点互动不埋失败; + // §1.6 缺口 3:话题关注 UI 定稿前挂起 + assertThat(EventDictionary.isKnownEvent("post_impression")).isFalse(); + assertThat(EventDictionary.isKnownEvent("post_viewed")).isFalse(); + assertThat(EventDictionary.isKnownEvent("comment_create_started")).isFalse(); + assertThat(EventDictionary.isKnownEvent("post_like_failed")).isFalse(); + assertThat(EventDictionary.isKnownEvent("user_follow_failed")).isFalse(); + assertThat(EventDictionary.isKnownEvent("topic_followed")).isFalse(); + assertThat(EventDictionary.isKnownEvent("topic_unfollowed")).isFalse(); + } }