feat: 事件字典 v3 白名单扩充——community 域 19 事件 + experiment_exposed 字典先行(T3-20 后端,ADR-020)
CI / backend-test (push) Successful in 5m26s
CI / backend-test (push) Successful in 5m26s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
+111
@@ -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<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("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"));
|
||||
}
|
||||
}
|
||||
|
||||
+77
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user