From 6d47c5a2b607eadba82064718e4034903fa6c8ca Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 4 Sep 2026 17:09:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9F=8B=E7=82=B9=E6=8E=A5=E6=94=B6?= =?UTF-8?q?=E7=AB=AF=E8=90=BD=E5=9C=B0=E2=80=94=E2=80=94V2=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20+=20POST=20/api/v1/events=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=EF=BC=88=E6=8A=A5=E5=91=8A=2013=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flyway V2:platform.product_events(客户端 UUIDv7 主键即幂等键,user_id 不设外键,client_ts 合理性约束,三索引),DDL 照报告 13 §2.2 - POST /api/v1/events(报告 13 §1 OpenAPI):批量 1-50 条、202 逐条结果(accepted/duplicate/rejected)、eventId ON CONFLICT 去重、字典 v1 白名单(11 个 auth_* + 工单增补 page_viewed/health_record_action)、字典外 props 剥离计数、隐私红线字段整条拒绝(forbidden_field)、认证请求 userId 与 token subject 不一致拒绝(identity_mismatch) - BearerAuthFilter 对 /api/v1/events 改为可选鉴权(规范:唯一允许匿名的写端点;带 token 仍严格验签 401/40101) - 日志红线:props 内容不落日志(仅计数与字段名告警) - 门禁:./mvnw clean test → BUILD SUCCESS,82 测试 0 失败(新增 AnalyticsIntegrationTest 7 例:V2 生效/匿名落库/未知事件拒绝/去重/剥离/红线拒绝/参数 40000) Co-Authored-By: Claude Fable 5 --- .../user/analytics/AnalyticsController.java | 38 +++ .../user/analytics/AnalyticsRepository.java | 60 +++++ .../user/analytics/AnalyticsService.java | 122 ++++++++++ .../user/analytics/EventDictionary.java | 56 +++++ .../user/analytics/TrackEventsRequest.java | 154 ++++++++++++ .../user/analytics/TrackEventsResponse.java | 39 +++ .../user/security/BearerAuthFilter.java | 13 + .../V2__create_platform_product_events.sql | 45 ++++ .../analytics/AnalyticsIntegrationTest.java | 228 ++++++++++++++++++ 9 files changed, 755 insertions(+) create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsController.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsRepository.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsService.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsRequest.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsResponse.java create mode 100644 patbond-user/src/main/resources/db/migration/V2__create_platform_product_events.sql create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/analytics/AnalyticsIntegrationTest.java diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsController.java b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsController.java new file mode 100644 index 0000000..7478bbe --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsController.java @@ -0,0 +1,38 @@ +package com.patbond.patbond.user.analytics; + +import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.user.security.BearerAuthFilter; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * Batch analytics ingestion (report 13 §1): the only anonymous-allowed write + * endpoint under /api/v1. BearerAuthFilter treats this path as + * optional-auth — a present Authorization header is still fully verified + * (401/40101 on failure), an absent one lets the request through with no + * user attribute. Valid batches always answer 202 with per-event results. + */ +@RestController +public class AnalyticsController { + + private final AnalyticsService analyticsService; + + public AnalyticsController(AnalyticsService analyticsService) { + this.analyticsService = analyticsService; + } + + @PostMapping("/api/v1/events") + public ResponseEntity> trackEvents( + @Valid @RequestBody TrackEventsRequest request, + @RequestAttribute(value = BearerAuthFilter.USER_ID_ATTRIBUTE, required = false) UUID userId) { + TrackEventsResponse response = analyticsService.trackEvents(request.getEvents(), userId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(response)); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsRepository.java b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsRepository.java new file mode 100644 index 0000000..dbd6fdb --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsRepository.java @@ -0,0 +1,60 @@ +package com.patbond.patbond.user.analytics; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +@Repository +public class AnalyticsRepository { + + private final JdbcClient jdbcClient; + private final ObjectMapper objectMapper; + + public AnalyticsRepository(JdbcClient jdbcClient, ObjectMapper objectMapper) { + this.jdbcClient = jdbcClient; + this.objectMapper = objectMapper; + } + + /** + * Inserts event with ON CONFLICT DO NOTHING; returns true if inserted + * (accepted), false if duplicate (eventId already exists). + */ + public boolean insertEvent(UUID eventId, String eventName, int eventVersion, + UUID anonymousId, UUID userId, UUID sessionId, + OffsetDateTime clientTs, String appVersion, + String platform, String osVersion, Map props) { + String propsJson; + try { + propsJson = props == null || props.isEmpty() + ? "{}" : objectMapper.writeValueAsString(props); + } catch (Exception e) { + throw new IllegalArgumentException("props 序列化失败", e); + } + + int rows = jdbcClient.sql(""" + INSERT INTO platform.product_events + (event_id, event_name, event_version, anonymous_id, user_id, session_id, + client_ts, app_version, platform, os_version, props) + VALUES (:eventId, :eventName, :eventVersion, :anonymousId, :userId, :sessionId, + :clientTs, :appVersion, :platform, :osVersion, CAST(:props AS jsonb)) + ON CONFLICT (event_id) DO NOTHING + """) + .param("eventId", eventId) + .param("eventName", eventName) + .param("eventVersion", eventVersion) + .param("anonymousId", anonymousId) + .param("userId", userId) + .param("sessionId", sessionId) + .param("clientTs", clientTs) + .param("appVersion", appVersion) + .param("platform", platform) + .param("osVersion", osVersion) + .param("props", propsJson) + .update(); + return rows > 0; + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsService.java b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsService.java new file mode 100644 index 0000000..02443a6 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/AnalyticsService.java @@ -0,0 +1,122 @@ +package com.patbond.patbond.user.analytics; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +public class AnalyticsService { + + private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class); + + private final AnalyticsRepository repository; + + public AnalyticsService(AnalyticsRepository repository) { + this.repository = repository; + } + + public TrackEventsResponse trackEvents(List events, + UUID authenticatedUserId) { + int accepted = 0; + int duplicated = 0; + int rejected = 0; + List results = new ArrayList<>(events.size()); + + for (TrackEventsRequest.TrackedEvent event : events) { + EventResult result = processEvent(event, authenticatedUserId); + results.add(result); + switch (result.status()) { + case "accepted" -> accepted++; + case "duplicate" -> duplicated++; + default -> rejected++; + } + } + + return new TrackEventsResponse(accepted, duplicated, rejected, results); + } + + private EventResult processEvent(TrackEventsRequest.TrackedEvent event, UUID authenticatedUserId) { + // 1. Unknown event name → reject + if (!EventDictionary.isKnownEvent(event.getEventName())) { + return EventResult.rejected(event.getEventId(), "unknown_event_name"); + } + + // 2. Identity mismatch (authenticated request with mismatched userId) → reject + if (authenticatedUserId != null && event.getUserId() != null + && !authenticatedUserId.equals(event.getUserId())) { + return EventResult.rejected(event.getEventId(), "identity_mismatch"); + } + + // 3. Forbidden field pattern in props → reject + Map props = event.getProps(); + if (props != null) { + for (String key : props.keySet()) { + if (EventDictionary.isForbiddenField(key)) { + log.warn("Event {} rejected: forbidden field pattern '{}'", + event.getEventId(), key); + return EventResult.rejected(event.getEventId(), "forbidden_field"); + } + } + } + + // 4. Strip props outside whitelist (log count, keep event) + Map sanitized = sanitizeProps(event.getEventName(), props); + + // 5. Insert with ON CONFLICT DO NOTHING + boolean inserted; + try { + inserted = repository.insertEvent( + event.getEventId(), event.getEventName(), event.getEventVersion(), + event.getAnonymousId(), event.getUserId(), event.getSessionId(), + event.getClientTs(), event.getAppVersion(), event.getPlatform(), + event.getOsVersion(), sanitized); + } catch (Exception e) { + log.error("Failed to insert event {}: {}", event.getEventId(), e.getMessage()); + return EventResult.rejected(event.getEventId(), "schema_invalid"); + } + + return inserted ? EventResult.accepted(event.getEventId()) + : EventResult.duplicate(event.getEventId()); + } + + private Map sanitizeProps(String eventName, Map props) { + if (props == null || props.isEmpty()) { + return Map.of(); + } + Set allowed = EventDictionary.allowedProps(eventName); + Map sanitized = new HashMap<>(); + int stripped = 0; + for (Map.Entry entry : props.entrySet()) { + if (allowed.contains(entry.getKey())) { + sanitized.put(entry.getKey(), entry.getValue()); + } else { + stripped++; + } + } + if (stripped > 0) { + log.warn("Event {} stripped {} props outside whitelist", eventName, stripped); + } + return sanitized; + } + + public record EventResult(UUID eventId, String status, String reason) { + public static EventResult accepted(UUID eventId) { + return new EventResult(eventId, "accepted", null); + } + + public static EventResult duplicate(UUID eventId) { + return new EventResult(eventId, "duplicate", null); + } + + public static EventResult rejected(UUID eventId, String reason) { + return new EventResult(eventId, "rejected", reason); + } + } +} 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 new file mode 100644 index 0000000..e97bdce --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/EventDictionary.java @@ -0,0 +1,56 @@ +package com.patbond.patbond.user.analytics; + +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Event dictionary v1 (report 13 §4, auth funnel) plus the two additions this + * ticket requires (page_viewed / health_record_action — flagged in report 19). + * 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. + */ +public final class EventDictionary { + + /** Privacy red-line key patterns — matches report 13 §5.2.4 scan regex. */ + private static final Pattern FORBIDDEN_FIELD_PATTERN = Pattern.compile( + "(?i).*(password|token|secret|phone|mobile|email|credential|idfa|gaid).*"); + + /** Event name → allowed props whitelist (empty set = props must be empty after stripping). */ + private static final Map> WHITELIST = Map.ofEntries( + Map.entry("auth_register_started", Set.of("entryPoint")), + Map.entry("auth_register_succeeded", Set.of("durationMs")), + Map.entry("auth_register_failed", + Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")), + Map.entry("auth_login_succeeded", Set.of("identifierType", "durationMs")), + Map.entry("auth_login_failed", + Set.of("identifierType", "failureReason", "errorCode", "httpStatus", "attemptSeq")), + Map.entry("auth_token_refresh_succeeded", Set.of("trigger")), + Map.entry("auth_token_refresh_failed", + Set.of("trigger", "failureReason", "errorCode", "httpStatus")), + Map.entry("auth_logout", Set.of("serverRevoked")), + Map.entry("auth_session_restore_started", Set.of()), + Map.entry("auth_session_restore_succeeded", Set.of("durationMs", "usedRefresh")), + Map.entry("auth_session_restore_failed", + Set.of("failureReason", "errorCode", "httpStatus")), + // Ticket additions beyond dictionary v1 (see report 19): + Map.entry("page_viewed", Set.of("pageName", "referrer")), + Map.entry("health_record_action", Set.of("recordType", "actionType")) + ); + + public static boolean isKnownEvent(String eventName) { + return WHITELIST.containsKey(eventName); + } + + public static Set allowedProps(String eventName) { + return WHITELIST.getOrDefault(eventName, Set.of()); + } + + public static boolean isForbiddenField(String fieldName) { + return FORBIDDEN_FIELD_PATTERN.matcher(fieldName).matches(); + } + + private EventDictionary() { + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsRequest.java b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsRequest.java new file mode 100644 index 0000000..6ae5f4b --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsRequest.java @@ -0,0 +1,154 @@ +package com.patbond.patbond.user.analytics; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class TrackEventsRequest { + + @Valid + @NotNull(message = "events 不能为空") + @Size(min = 1, max = 50, message = "events 长度必须在 1-50 之间") + private List events; + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + public static class TrackedEvent { + + @NotNull(message = "eventId 不能为空") + private UUID eventId; + + @NotNull(message = "eventName 不能为空") + @Pattern(regexp = "^[a-z][a-z0-9_]{1,63}$", message = "eventName 格式不合法") + private String eventName; + + @NotNull(message = "eventVersion 不能为空") + private Integer eventVersion; + + @NotNull(message = "anonymousId 不能为空") + private UUID anonymousId; + + private UUID userId; + + @NotNull(message = "sessionId 不能为空") + private UUID sessionId; + + @NotNull(message = "clientTs 不能为空") + private OffsetDateTime clientTs; + + @NotNull(message = "appVersion 不能为空") + @Size(min = 1, max = 32, message = "appVersion 长度必须在 1-32 之间") + private String appVersion; + + @NotNull(message = "platform 不能为空") + @Pattern(regexp = "^(android|ios)$", message = "platform 必须为 android 或 ios") + private String platform; + + @NotNull(message = "osVersion 不能为空") + @Size(min = 1, max = 32, message = "osVersion 长度必须在 1-32 之间") + private String osVersion; + + private Map props; + + public UUID getEventId() { + return eventId; + } + + public void setEventId(UUID eventId) { + this.eventId = eventId; + } + + public String getEventName() { + return eventName; + } + + public void setEventName(String eventName) { + this.eventName = eventName; + } + + public Integer getEventVersion() { + return eventVersion; + } + + public void setEventVersion(Integer eventVersion) { + this.eventVersion = eventVersion; + } + + public UUID getAnonymousId() { + return anonymousId; + } + + public void setAnonymousId(UUID anonymousId) { + this.anonymousId = anonymousId; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public UUID getSessionId() { + return sessionId; + } + + public void setSessionId(UUID sessionId) { + this.sessionId = sessionId; + } + + public OffsetDateTime getClientTs() { + return clientTs; + } + + public void setClientTs(OffsetDateTime clientTs) { + this.clientTs = clientTs; + } + + public String getAppVersion() { + return appVersion; + } + + public void setAppVersion(String appVersion) { + this.appVersion = appVersion; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getOsVersion() { + return osVersion; + } + + public void setOsVersion(String osVersion) { + this.osVersion = osVersion; + } + + public Map getProps() { + return props; + } + + public void setProps(Map props) { + this.props = props; + } + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsResponse.java b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsResponse.java new file mode 100644 index 0000000..2ab52c0 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/analytics/TrackEventsResponse.java @@ -0,0 +1,39 @@ +package com.patbond.patbond.user.analytics; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; + +/** Per-batch outcome (report 13 §1.2): counts plus per-event results in request order. */ +public class TrackEventsResponse { + + private final int accepted; + private final int duplicated; + private final int rejected; + @JsonInclude(JsonInclude.Include.NON_NULL) + private final List results; + + public TrackEventsResponse(int accepted, int duplicated, int rejected, + List results) { + this.accepted = accepted; + this.duplicated = duplicated; + this.rejected = rejected; + this.results = results; + } + + public int getAccepted() { + return accepted; + } + + public int getDuplicated() { + return duplicated; + } + + public int getRejected() { + return rejected; + } + + public List getResults() { + return results; + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java b/patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java index e6d046c..1036d06 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java @@ -15,6 +15,7 @@ import org.springframework.http.MediaType; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Set; import java.util.UUID; /** @@ -30,6 +31,14 @@ public class BearerAuthFilter extends OncePerRequestFilter { /** Request attribute holding the authenticated user's UUID. */ public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId"; + /** + * Paths where authentication is optional (report 13 §1.1: analytics + * ingestion must accept anonymous uploads for pre-login events). A + * present Authorization header is still fully verified — only a missing + * one is waved through. + */ + private static final Set OPTIONAL_AUTH_PATHS = Set.of("/api/v1/events"); + private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class); private final JwtVerifier jwtVerifier; @@ -50,6 +59,10 @@ public class BearerAuthFilter extends OncePerRequestFilter { FilterChain filterChain) throws ServletException, IOException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { + if (OPTIONAL_AUTH_PATHS.contains(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } reject(response, ErrorCode.TOKEN_INVALID); return; } diff --git a/patbond-user/src/main/resources/db/migration/V2__create_platform_product_events.sql b/patbond-user/src/main/resources/db/migration/V2__create_platform_product_events.sql new file mode 100644 index 0000000..28804b1 --- /dev/null +++ b/patbond-user/src/main/resources/db/migration/V2__create_platform_product_events.sql @@ -0,0 +1,45 @@ +-- Client-side product analytics events (auth funnel, dictionary v1). +-- Append-only side channel: eventId is generated by the client (UUIDv7) +-- and doubles as the idempotency key for at-least-once upload, so the +-- primary key must NOT default to a server-generated uuid. user_id is +-- intentionally not a foreign key: analytics rows may outlive or precede +-- identity.users rows and must never block account lifecycle operations. + +CREATE TABLE platform.product_events ( + event_id uuid PRIMARY KEY, + event_name varchar(64) NOT NULL, + event_version smallint NOT NULL DEFAULT 1, + anonymous_id uuid NOT NULL, + user_id uuid, + session_id uuid NOT NULL, + client_ts timestamptz NOT NULL, + server_ts timestamptz NOT NULL DEFAULT now(), + app_version varchar(32) NOT NULL, + platform varchar(16) NOT NULL, + os_version varchar(32) NOT NULL, + props jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT ck_product_events_name CHECK (event_name ~ '^[a-z][a-z0-9_]{1,63}$'), + CONSTRAINT ck_product_events_version CHECK (event_version > 0), + CONSTRAINT ck_product_events_platform CHECK (platform IN ('android', 'ios')), + CONSTRAINT ck_product_events_app_version CHECK (char_length(btrim(app_version)) BETWEEN 1 AND 32), + CONSTRAINT ck_product_events_os_version CHECK (char_length(btrim(os_version)) BETWEEN 1 AND 32), + CONSTRAINT ck_product_events_props CHECK (jsonb_typeof(props) = 'object'), + CONSTRAINT ck_product_events_client_ts CHECK ( + client_ts >= server_ts - interval '30 days' + AND client_ts <= server_ts + interval '1 day' + ) +); + +COMMENT ON TABLE platform.product_events IS + 'Client analytics events (auth funnel v1); dedup by client-generated event_id, metrics windows use server_ts'; + +-- Funnel/metric queries: daily counts per event name. +CREATE INDEX ix_product_events_name_server_ts + ON platform.product_events (event_name, server_ts); + +-- Per-subject dedup for conversion metrics (userId after login, anonymousId before). +CREATE INDEX ix_product_events_user_server_ts + ON platform.product_events (user_id, server_ts) + WHERE user_id IS NOT NULL; +CREATE INDEX ix_product_events_anon_server_ts + ON platform.product_events (anonymous_id, server_ts); 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 new file mode 100644 index 0000000..9034e22 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/analytics/AnalyticsIntegrationTest.java @@ -0,0 +1,228 @@ +package com.patbond.patbond.user.analytics; + +import com.patbond.patbond.user.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Analytics ingestion end-to-end: Flyway V2 DDL, anonymous/authenticated + * uploads, event dictionary validation, props sanitization, deduplication, + * privacy red-line checks (report 13). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class AnalyticsIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcClient jdbcClient; + + @Test + void v2MigrationCreatesProductEventsTable() { + int count = jdbcClient.sql( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema='platform' AND table_name='product_events'") + .query(Integer.class) + .single(); + assertThat(count).isEqualTo(1); + } + + @Test + void acceptsAnonymousEventBatch() throws Exception { + String eventId = UUID.randomUUID().toString(); + String body = """ + { + "events": [{ + "eventId": "%s", + "eventName": "auth_register_started", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.0.0+1", + "platform": "android", + "osVersion": "android-14", + "props": {"entryPoint": "splash"} + }] + } + """.formatted(eventId, OffsetDateTime.now()); + + mockMvc.perform(post("/api/v1/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.code").value(0)) + .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("auth_register_started"); + } + + @Test + void rejectsBatchWithUnknownEventName() throws Exception { + String body = """ + { + "events": [{ + "eventId": "019212aa-2222-7000-8000-000000000001", + "eventName": "unknown_event", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.0.0", + "platform": "ios", + "osVersion": "ios-17" + }] + } + """.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].status").value("rejected")) + .andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name")); + } + + @Test + void deduplicationReturnsDuplicate() throws Exception { + String eventId = UUID.randomUUID().toString(); + String body = """ + { + "events": [{ + "eventId": "%s", + "eventName": "auth_login_succeeded", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.0.0", + "platform": "android", + "osVersion": "android-14", + "props": {"identifierType": "username", "durationMs": 123} + }] + } + """.formatted(eventId, OffsetDateTime.now()); + + // First upload: accepted + mockMvc.perform(post("/api/v1/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.accepted").value(1)); + + // Second upload (same eventId): duplicate + mockMvc.perform(post("/api/v1/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.duplicated").value(1)) + .andExpect(jsonPath("$.data.results[0].status").value("duplicate")); + } + + @Test + void stripsPropsOutsideWhitelist() throws Exception { + String eventId = UUID.randomUUID().toString(); + String body = """ + { + "events": [{ + "eventId": "%s", + "eventName": "auth_login_succeeded", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.0.0", + "platform": "android", + "osVersion": "android-14", + "props": { + "identifierType": "username", + "durationMs": 123, + "forbiddenExtraField": "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).containsKeys("identifierType", "durationMs"); + assertThat(storedProps).doesNotContainKey("forbiddenExtraField"); + } + + @Test + void rejectsEventWithForbiddenFieldPattern() throws Exception { + String body = """ + { + "events": [{ + "eventId": "019212aa-3333-7000-8000-000000000001", + "eventName": "auth_login_succeeded", + "eventVersion": 1, + "anonymousId": "019212aa-0000-7000-8000-000000000001", + "sessionId": "019212aa-1111-7000-8000-000000000001", + "clientTs": "%s", + "appVersion": "1.0.0", + "platform": "android", + "osVersion": "android-14", + "props": {"userPassword": "leak"} + }] + } + """.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("forbidden_field")); + } + + @Test + void validationRejects400OnEmptyBatch() throws Exception { + mockMvc.perform(post("/api/v1/events") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"events\":[]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } +}