dbd90ee52a
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.
83 lines
3.7 KiB
C#
83 lines
3.7 KiB
C#
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();
|
|
}
|
|
}
|