feat(phase-16): anonymous play & share telemetry substrate (wave 16.1)
Player-service play-session tracker (floor + 3-bucket classify), SharePopover share tracker with debounce, sendBeacon interop, proxied rate-limited POST api/event/{play,share}, append-only event logs + incremental play_counter with server-side release resolution. Migration authored, not applied. No anonId, no read surface.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
|
||||
/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
|
||||
/// validated to make casual inflation annoying (§2.5). Both endpoints return <c>202 Accepted</c>: these
|
||||
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) never reads the
|
||||
/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
|
||||
/// The controller is a thin HTTP boundary; all write logic lives in <see cref="IEventService"/>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/event")]
|
||||
[EnableRateLimiting("events")]
|
||||
public class EventController : ControllerBase
|
||||
{
|
||||
// Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
|
||||
// payloads are a track key + an enum, well under 1 KB.
|
||||
private const int MaxBodyBytes = 1024;
|
||||
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ILogger<EventController> _logger;
|
||||
|
||||
public EventController(IEventService eventService, ILogger<EventController> logger)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// POST api/event/play (unauthenticated, rate-limited)
|
||||
[HttpPost("play")]
|
||||
[RequestSizeLimit(MaxBodyBytes)]
|
||||
public async Task<ActionResult> RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
|
||||
{
|
||||
// Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
|
||||
// already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
|
||||
// keep the contract obvious and cover the empty-string key the model binder lets through.
|
||||
if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
|
||||
return BadRequest("trackEntryKey is required");
|
||||
if (!Enum.IsDefined(payload.Bucket))
|
||||
return BadRequest("bucket is invalid");
|
||||
|
||||
// Wave 16.1 writes no anonId — defend the substrate by dropping any the client sends early.
|
||||
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId: null, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
// A telemetry failure must never surface to the listener as an error they can act on, but
|
||||
// we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
|
||||
// beacon ignores the status either way.
|
||||
_logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500);
|
||||
}
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
// POST api/event/share (unauthenticated, rate-limited)
|
||||
[HttpPost("share")]
|
||||
[RequestSizeLimit(MaxBodyBytes)]
|
||||
public async Task<ActionResult> RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.TargetKey))
|
||||
return BadRequest("targetKey is required");
|
||||
if (!Enum.IsDefined(payload.TargetType))
|
||||
return BadRequest("targetType is invalid");
|
||||
if (!Enum.IsDefined(payload.Channel))
|
||||
return BadRequest("channel is invalid");
|
||||
|
||||
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId: null, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500);
|
||||
}
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user