- 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:
+38
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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);
|
||||||
+228
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user