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,121 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
|
||||
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
|
||||
/// incremental-on-write bump of the <c>play_counter</c> rollup (D6). Server-side release resolution
|
||||
/// (§2.3 / D4) lives here: a play event carries only the track key, and this repository joins
|
||||
/// track→release at write time and stamps the release id on the row.
|
||||
///
|
||||
/// <para>
|
||||
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
|
||||
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
|
||||
/// BlazorBlocks <c>Repository<></c> base. It holds the same scoped <see cref="DeepDrftContext"/>
|
||||
/// the rest of the SQL layer uses, never a service locator.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EventRepository
|
||||
{
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public EventRepository(DeepDrftContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append one play event and bump the track's counter in a single transaction (D6). The release id
|
||||
/// is resolved here from the track key (§2.3 / D4): a live track contributes its release id (null
|
||||
/// for a loose track); an unknown key records the event with a null release and no counter bump
|
||||
/// (there is no track to roll up against). Returns true when the event was written.
|
||||
/// </summary>
|
||||
public async Task<bool> RecordPlayAsync(
|
||||
string trackEntryKey, PlayBucket bucket, string? anonId, CancellationToken ct = default)
|
||||
{
|
||||
// Resolve the track→release link server-side. Soft-deleted tracks resolve to null so a play of
|
||||
// a since-removed track still logs (with no counter bump) rather than throwing.
|
||||
var track = await _context.Tracks
|
||||
.Where(t => t.EntryKey == trackEntryKey && !t.IsDeleted)
|
||||
.Select(t => new { t.Id, t.ReleaseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// The append and the counter bump must commit together — wrap them in one transaction so a
|
||||
// counter that drifts from the log is impossible. Reuse an ambient transaction if the caller
|
||||
// already opened one.
|
||||
var ownsTransaction = _context.Database.CurrentTransaction is null;
|
||||
var transaction = ownsTransaction
|
||||
? await _context.Database.BeginTransactionAsync(ct)
|
||||
: null;
|
||||
try
|
||||
{
|
||||
_context.PlayEvents.Add(new PlayEvent
|
||||
{
|
||||
TrackEntryKey = trackEntryKey,
|
||||
ReleaseId = track?.ReleaseId,
|
||||
Bucket = bucket,
|
||||
AnonId = anonId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
if (track is not null)
|
||||
await BumpCounterAsync(track.Id, bucket, ct);
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
|
||||
public async Task RecordShareAsync(
|
||||
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_context.ShareEvents.Add(new ShareEvent
|
||||
{
|
||||
TargetType = targetType,
|
||||
TargetKey = targetKey,
|
||||
Channel = channel,
|
||||
AnonId = anonId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Bump the matching bucket column on the track's counter row, creating the row on first play. The
|
||||
// row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists
|
||||
// it inside the same transaction as the event append.
|
||||
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
|
||||
{
|
||||
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct);
|
||||
if (counter is null)
|
||||
{
|
||||
counter = new PlayCounter { TrackId = trackId };
|
||||
_context.PlayCounters.Add(counter);
|
||||
}
|
||||
|
||||
switch (bucket)
|
||||
{
|
||||
case PlayBucket.Partial: counter.PartialCount++; break;
|
||||
case PlayBucket.Sampled: counter.SampledCount++; break;
|
||||
case PlayBucket.Complete: counter.CompleteCount++; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user