41148a87a6
AnomalyBrowsingService.TryProject fell back to `new SportCode(0)` when an anomaly's event was missing — but SportCode throws for 0, which would blow up the whole feed/dashboard for that row. Anomalies have an FK to events so it was dead in practice, but an orphaned row now degrades gracefully (skipped, like a row with unparseable evidence). Closes the flagged latent crash. - TryProject returns false when the event lookup misses; +1 test.
248 lines
8.7 KiB
C#
248 lines
8.7 KiB
C#
using System.Text.Json;
|
|
using Marathon.Application.Abstractions;
|
|
using Marathon.Domain.Entities;
|
|
using Marathon.Domain.Enums;
|
|
using Marathon.Domain.ValueObjects;
|
|
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
|
|
|
|
namespace Marathon.UI.Services;
|
|
|
|
/// <summary>
|
|
/// Repository-backed anomaly browsing service. Loads anomalies + their
|
|
/// originating events in a single pass, parses <c>EvidenceJson</c>, and shapes
|
|
/// <see cref="AnomalyListItem"/> / <see cref="AnomalyDetailVm"/> records for
|
|
/// the UI to consume directly.
|
|
/// </summary>
|
|
public sealed class AnomalyBrowsingService : IAnomalyBrowsingService
|
|
{
|
|
private readonly IAnomalyRepository _anomalies;
|
|
private readonly IEventRepository _events;
|
|
|
|
public AnomalyBrowsingService(IAnomalyRepository anomalies, IEventRepository events)
|
|
{
|
|
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
|
}
|
|
|
|
public async Task<IReadOnlyList<AnomalyListItem>> ListAsync(AnomalyFilter filter, CancellationToken ct)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
|
|
// Date filter pushed to SQL; severity needs the parsed score and sport needs
|
|
// the event join, so those two stay in memory over the smaller returned set.
|
|
var all = await _anomalies.ListByDateRangeAsync(filter.From, filter.To, ct).ConfigureAwait(false);
|
|
if (all.Count == 0) return Array.Empty<AnomalyListItem>();
|
|
|
|
// Resolve event metadata in one batched pass — distinct EventIds only.
|
|
var eventLookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
|
|
|
|
var items = new List<AnomalyListItem>(all.Count);
|
|
foreach (var anomaly in all)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
if (TryProject(anomaly, eventLookup, out var item))
|
|
{
|
|
items.Add(item);
|
|
}
|
|
}
|
|
|
|
// Remaining filters in-memory (page-sized set).
|
|
IEnumerable<AnomalyListItem> filtered = items;
|
|
|
|
if (filter.MinSeverity is { } minSeverity)
|
|
{
|
|
filtered = filtered.Where(i => AnomalySeverityRules.MeetsThreshold(i.Severity, minSeverity));
|
|
}
|
|
|
|
if (filter.SportCodes is { Count: > 0 } sports)
|
|
{
|
|
filtered = filtered.Where(i => sports.Contains(i.Sport.Value));
|
|
}
|
|
|
|
if (filter.Kinds is { Count: > 0 } kinds)
|
|
{
|
|
filtered = filtered.Where(i => kinds.Contains(i.Kind));
|
|
}
|
|
|
|
// DetectedAt is the tiebreak for the score/gap sorts so order stays stable.
|
|
var sorted = filter.Sort switch
|
|
{
|
|
AnomalySort.HighestScore =>
|
|
filtered.OrderByDescending(i => i.Score).ThenByDescending(i => i.DetectedAt),
|
|
AnomalySort.LongestGap =>
|
|
filtered.OrderByDescending(i => i.SuspensionGapSeconds).ThenByDescending(i => i.DetectedAt),
|
|
_ => filtered.OrderByDescending(i => i.DetectedAt),
|
|
};
|
|
|
|
return sorted.ToList();
|
|
}
|
|
|
|
public async Task<AnomalyDetailVm?> GetByIdAsync(Guid id, CancellationToken ct)
|
|
{
|
|
var anomaly = await _anomalies.GetAsync(id, ct).ConfigureAwait(false);
|
|
if (anomaly is null) return null;
|
|
|
|
var eventLookup = await BuildEventLookupAsync(new[] { anomaly }, ct).ConfigureAwait(false);
|
|
if (!TryProject(anomaly, eventLookup, out var item)) return null;
|
|
|
|
if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return null;
|
|
|
|
var pre = ToSnapshot(dto.PreSuspension);
|
|
var post = ToSnapshot(dto.PostSuspension);
|
|
|
|
return new AnomalyDetailVm(item, pre, post);
|
|
}
|
|
|
|
public Task<int> GetUnreadCountAsync(DateTimeOffset since, CancellationToken ct)
|
|
// Server-side COUNT(*) — no longer materialises the table to count.
|
|
=> _anomalies.CountSinceAsync(since, ct);
|
|
|
|
public async Task<IReadOnlyList<int>> ListKnownSportCodesAsync(CancellationToken ct)
|
|
{
|
|
var all = await _anomalies.ListAsync(ct).ConfigureAwait(false);
|
|
if (all.Count == 0) return Array.Empty<int>();
|
|
|
|
var lookup = await BuildEventLookupAsync(all, ct).ConfigureAwait(false);
|
|
return all
|
|
.Select(a => lookup.TryGetValue(a.EventId, out var ev) ? ev.Sport.Value : (int?)null)
|
|
.Where(static x => x.HasValue)
|
|
.Select(static x => x!.Value)
|
|
.Distinct()
|
|
.OrderBy(static x => x)
|
|
.ToList();
|
|
}
|
|
|
|
// ---------------- internals ----------------
|
|
|
|
private async Task<IReadOnlyDictionary<DomainEventId, Event>> BuildEventLookupAsync(
|
|
IReadOnlyCollection<Anomaly> anomalies,
|
|
CancellationToken ct)
|
|
{
|
|
var distinct = anomalies
|
|
.Select(a => a.EventId)
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
// Single batched query instead of one GetAsync per distinct event (N+1).
|
|
return await _events.GetManyAsync(distinct, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private static bool TryProject(
|
|
Anomaly anomaly,
|
|
IReadOnlyDictionary<DomainEventId, Event> events,
|
|
out AnomalyListItem item)
|
|
{
|
|
item = default!;
|
|
if (!TryParseEvidence(anomaly.EvidenceJson, out var dto)) return false;
|
|
|
|
var severity = AnomalySeverityRules.FromScore(anomaly.Score);
|
|
|
|
// Skip orphan anomalies whose event has been pruned — degrade gracefully rather
|
|
// than throwing on a sentinel SportCode(0). Anomalies have an FK to events so this
|
|
// is defensive; the feed already drops rows whose evidence won't parse.
|
|
if (!events.TryGetValue(anomaly.EventId, out var ev) || ev is null)
|
|
return false;
|
|
|
|
var sport = ev.Sport;
|
|
var country = ev.CountryCode;
|
|
var league = ev.LeagueId;
|
|
var title = ev.Title;
|
|
|
|
var preSnap = ToSnapshot(dto.PreSuspension);
|
|
var postSnap = ToSnapshot(dto.PostSuspension);
|
|
|
|
var twoWay = dto.PreSuspension.PDraw is null && dto.PostSuspension.PDraw is null;
|
|
|
|
item = new AnomalyListItem(
|
|
anomaly.Id,
|
|
anomaly.EventId,
|
|
title,
|
|
sport,
|
|
country,
|
|
league,
|
|
anomaly.DetectedAt,
|
|
anomaly.Score,
|
|
severity,
|
|
anomaly.Kind,
|
|
dto.SuspensionGapSeconds,
|
|
preSnap.Rate1,
|
|
preSnap.RateDraw,
|
|
preSnap.Rate2,
|
|
postSnap.Rate1,
|
|
postSnap.RateDraw,
|
|
postSnap.Rate2,
|
|
preSnap.Favourite,
|
|
postSnap.Favourite,
|
|
twoWay);
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool TryParseEvidence(string evidenceJson, out EvidenceDto dto)
|
|
{
|
|
dto = default!;
|
|
if (string.IsNullOrWhiteSpace(evidenceJson)) return false;
|
|
try
|
|
{
|
|
var parsed = JsonSerializer.Deserialize<EvidenceDto>(evidenceJson, JsonOptions);
|
|
if (parsed?.PreSuspension is null || parsed.PostSuspension is null) return false;
|
|
dto = parsed;
|
|
return true;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static AnomalyEvidenceSnapshot ToSnapshot(EvidenceSnapshotDto dto)
|
|
{
|
|
var fav = ResolveFavourite(dto.P1, dto.PDraw, dto.P2);
|
|
return new AnomalyEvidenceSnapshot(
|
|
dto.CapturedAt,
|
|
dto.Rate1,
|
|
dto.RateDraw,
|
|
dto.Rate2,
|
|
dto.P1,
|
|
dto.PDraw,
|
|
dto.P2,
|
|
fav);
|
|
}
|
|
|
|
private static AnomalyFavourite ResolveFavourite(decimal? p1, decimal? pDraw, decimal? p2)
|
|
{
|
|
// Favourite = side with the highest implied probability (lowest odds).
|
|
var best = AnomalyFavourite.None;
|
|
decimal bestValue = decimal.MinValue;
|
|
|
|
if (p1 is { } v1 && v1 > bestValue) { bestValue = v1; best = AnomalyFavourite.Side1; }
|
|
if (pDraw is { } vd && vd > bestValue) { bestValue = vd; best = AnomalyFavourite.Draw; }
|
|
if (p2 is { } v2 && v2 > bestValue) { best = AnomalyFavourite.Side2; }
|
|
|
|
return best;
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
};
|
|
|
|
private sealed class EvidenceDto
|
|
{
|
|
public int SuspensionGapSeconds { get; set; }
|
|
public EvidenceSnapshotDto PreSuspension { get; set; } = default!;
|
|
public EvidenceSnapshotDto PostSuspension { get; set; } = default!;
|
|
}
|
|
|
|
private sealed class EvidenceSnapshotDto
|
|
{
|
|
public DateTimeOffset CapturedAt { get; set; }
|
|
public decimal? P1 { get; set; }
|
|
public decimal? PDraw { get; set; }
|
|
public decimal? P2 { get; set; }
|
|
public decimal? Rate1 { get; set; }
|
|
public decimal? RateDraw { get; set; }
|
|
public decimal? Rate2 { get; set; }
|
|
}
|
|
}
|