From 8089c06a7386022e6676373a80eddd892591732e Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Wed, 9 Sep 2026 11:51:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=8B=E4=BB=B6=E5=AD=97=E5=85=B8=20?= =?UTF-8?q?v3=20=E7=99=BD=E5=90=8D=E5=8D=95=E6=89=A9=E5=85=85=E2=80=94?= =?UTF-8?q?=E2=80=94community=20=E5=9F=9F=2019=20=E4=BA=8B=E4=BB=B6=20+=20?= =?UTF-8?q?experiment=5Fexposed=20=E5=AD=97=E5=85=B8=E5=85=88=E8=A1=8C?= =?UTF-8?q?=EF=BC=88T3-20=20=E5=90=8E=E7=AB=AF=EF=BC=8CADR-020=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventDictionary 增补 20 个事件(iteration-3 报告 06 §1.5 白名单原样落地): post 域 8(发布漏斗 started/draft_saved/publish_succeeded/failed、post_deleted、 媒体上传三段逐文件漏斗);feed 域 2(feed_viewed 浏览段聚合曝光 + feed_load_failed); 互动 8(like/unlike、favorite/unfavorite、comment 成败对、follow/unfollow); platform 域 experiment_exposed(A/B 前置 #5,M4 启用字典先行) - 字典 javadoc 同步 v3:社区隐私红线增量(§1.3 无正文/无内容 ID/无话题名/无媒体线索) 与 pageName v3 页面族(§6.1 收编 4 + 新增 9,键级校验不变、后端零代码) - EventDictionaryTest:每个新事件键集边界锁定 + §1.2/§1.4/§1.6 故意不设事件 (post_impression/post_viewed/comment_create_started/单点互动 _failed/topic_follow) - AnalyticsIntegrationTest:feed_viewed 聚合事件入库、post_liked 白名单外 postId 剥离(红线 2)、post_impression 拒绝(§1.2 裁定 ingest 侧锁死) - 测试 325 → 334(+9),openapi.yaml 无需变更(events 契约对事件名开放) Co-Authored-By: Claude Fable 5 --- .../user/analytics/EventDictionary.java | 59 ++++++++-- .../analytics/AnalyticsIntegrationTest.java | 111 ++++++++++++++++++ .../user/analytics/EventDictionaryTest.java | 79 ++++++++++++- 3 files changed, 238 insertions(+), 11 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 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(); + } }