feat: 事件字典 v3 白名单扩充——community 域 19 事件 + experiment_exposed 字典先行(T3-20 后端,ADR-020)
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:
2026-09-09 11:51:16 +08:00
parent 0569585434
commit 8089c06a73
3 changed files with 238 additions and 11 deletions
@@ -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) {
@@ -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();
// 红线 2report 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"));
}
}
@@ -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 前置 #5report 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();
}
}