feat: 埋点接收端落地——V2 迁移 + POST /api/v1/events 批量上报(报告 13)
CI / backend-test (push) Failing after 1m31s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 17:09:00 +08:00
parent b22eaede28
commit 6d47c5a2b6
9 changed files with 755 additions and 0 deletions
@@ -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<ApiResponse<TrackEventsResponse>> 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));
}
}
@@ -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<String, Object> 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;
}
}
@@ -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<TrackEventsRequest.TrackedEvent> events,
UUID authenticatedUserId) {
int accepted = 0;
int duplicated = 0;
int rejected = 0;
List<EventResult> 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<String, Object> 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<String, Object> 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<String, Object> sanitizeProps(String eventName, Map<String, Object> props) {
if (props == null || props.isEmpty()) {
return Map.of();
}
Set<String> allowed = EventDictionary.allowedProps(eventName);
Map<String, Object> sanitized = new HashMap<>();
int stripped = 0;
for (Map.Entry<String, Object> 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);
}
}
}
@@ -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<String, Set<String>> 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<String> allowedProps(String eventName) {
return WHITELIST.getOrDefault(eventName, Set.of());
}
public static boolean isForbiddenField(String fieldName) {
return FORBIDDEN_FIELD_PATTERN.matcher(fieldName).matches();
}
private EventDictionary() {
}
}
@@ -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<TrackedEvent> events;
public List<TrackedEvent> getEvents() {
return events;
}
public void setEvents(List<TrackedEvent> 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<String, Object> 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<String, Object> getProps() {
return props;
}
public void setProps(Map<String, Object> props) {
this.props = props;
}
}
}
@@ -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<AnalyticsService.EventResult> results;
public TrackEventsResponse(int accepted, int duplicated, int rejected,
List<AnalyticsService.EventResult> 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<AnalyticsService.EventResult> getResults() {
return results;
}
}
@@ -15,6 +15,7 @@ import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -30,6 +31,14 @@ public class BearerAuthFilter extends OncePerRequestFilter {
/** Request attribute holding the authenticated user's UUID. */ /** Request attribute holding the authenticated user's UUID. */
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId"; 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<String> OPTIONAL_AUTH_PATHS = Set.of("/api/v1/events");
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class); private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
private final JwtVerifier jwtVerifier; private final JwtVerifier jwtVerifier;
@@ -50,6 +59,10 @@ public class BearerAuthFilter extends OncePerRequestFilter {
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization"); String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) { if (header == null || !header.startsWith("Bearer ")) {
if (OPTIONAL_AUTH_PATHS.contains(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
reject(response, ErrorCode.TOKEN_INVALID); reject(response, ErrorCode.TOKEN_INVALID);
return; return;
} }
@@ -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);
@@ -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<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).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));
}
}