chore(med): mapping culture-safe parse, dead-code, scope comparer, UA rotator, parser cache
Six MEDIUM-tier review items:
* Mapping.cs — DateTimeOffset.Parse now passes CultureInfo.InvariantCulture
+ DateTimeStyles.RoundtripKind so a non-en-US thread culture cannot
corrupt round-tripped ScheduledAt / CapturedAt / DetectedAt / CompletedAt.
Also replaces the magic 0/1 BetScope discriminator with named constants.
* Delete dead Placeholder.cs files in Marathon.Application and
Marathon.Infrastructure — they were stubs from Phase 1 to satisfy
"non-empty project" and have been dead since Phase 2/3.
* EventBrowsingService — drop the bespoke ScopeEqualityComparer; BetScope
is a record hierarchy, .GroupBy uses value equality natively.
* UserAgentRotatorHandler — counter promoted to private static int with
Interlocked.Increment so rotation is round-robin across the process.
HttpClientFactory builds the handler Transient, so the previous instance
field reset to zero on every new client and broke rotation.
* EventOddsParser — added a parallel "selection-key → IElement" index
alongside the existing price index. Handicap extraction (6 call sites
per event detail page) used to do a fresh document.QuerySelector("span[
data-selection-key='...']") for every key — full-document CSS traversal.
Now it's a dictionary lookup, with the pair-emit logic factored into a
shared TryEmitHandicapPair helper.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Marathon.Domain.Entities;
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
@@ -11,6 +12,15 @@ namespace Marathon.Infrastructure.Persistence;
|
||||
/// </summary>
|
||||
internal static class Mapping
|
||||
{
|
||||
// ScheduledAt / CapturedAt / DetectedAt / CompletedAt are written via
|
||||
// DateTimeOffset.ToString("O") — round-trip ISO 8601. Parse with the
|
||||
// invariant culture and RoundtripKind so a non-en-US thread culture
|
||||
// (or a future locale change) cannot corrupt the round-trip.
|
||||
private const DateTimeStyles RoundtripStyles = DateTimeStyles.RoundtripKind;
|
||||
|
||||
// ─── Bet scope discriminator constants ────────────────────────────────────
|
||||
private const int ScopeMatch = 0;
|
||||
private const int ScopePeriod = 1;
|
||||
// ─── Event ───────────────────────────────────────────────────────────────
|
||||
|
||||
public static EventEntity ToEntity(Event domain) =>
|
||||
@@ -34,7 +44,7 @@ internal static class Mapping
|
||||
CountryCode: entity.CountryCode,
|
||||
LeagueId: entity.LeagueId,
|
||||
Category: entity.Category,
|
||||
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt),
|
||||
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
||||
Side1Name: entity.Side1Name,
|
||||
Side2Name: entity.Side2Name)
|
||||
{
|
||||
@@ -55,7 +65,7 @@ internal static class Mapping
|
||||
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
|
||||
new(
|
||||
eventId: new EventId(entity.EventCode),
|
||||
capturedAt: DateTimeOffset.Parse(entity.CapturedAt),
|
||||
capturedAt: DateTimeOffset.Parse(entity.CapturedAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
||||
source: (OddsSource)entity.Source,
|
||||
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
|
||||
|
||||
@@ -64,7 +74,7 @@ internal static class Mapping
|
||||
public static BetEntity ToEntity(Bet domain) =>
|
||||
new()
|
||||
{
|
||||
Scope = domain.Scope is MatchScope ? 0 : 1,
|
||||
Scope = domain.Scope is MatchScope ? ScopeMatch : ScopePeriod,
|
||||
PeriodNumber = domain.Scope is PeriodScope ps ? ps.Number : null,
|
||||
Type = (int)domain.Type,
|
||||
Side = (int)domain.Side,
|
||||
@@ -76,8 +86,8 @@ internal static class Mapping
|
||||
{
|
||||
var scope = entity.Scope switch
|
||||
{
|
||||
0 => (BetScope)MatchScope.Instance,
|
||||
1 => new PeriodScope(entity.PeriodNumber!.Value),
|
||||
ScopeMatch => (BetScope)MatchScope.Instance,
|
||||
ScopePeriod => new PeriodScope(entity.PeriodNumber!.Value),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown BetScope discriminator: {entity.Scope}"),
|
||||
};
|
||||
@@ -108,7 +118,7 @@ internal static class Mapping
|
||||
Side1Score: entity.Side1Score,
|
||||
Side2Score: entity.Side2Score,
|
||||
WinnerSide: (Side)entity.WinnerSide,
|
||||
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt));
|
||||
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt, CultureInfo.InvariantCulture, RoundtripStyles));
|
||||
|
||||
// ─── Anomaly ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -127,7 +137,7 @@ internal static class Mapping
|
||||
new(
|
||||
Id: Guid.Parse(entity.Id),
|
||||
EventId: new EventId(entity.EventCode),
|
||||
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt),
|
||||
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt, CultureInfo.InvariantCulture, RoundtripStyles),
|
||||
Kind: (AnomalyKind)entity.Kind,
|
||||
Score: entity.Score,
|
||||
EvidenceJson: entity.EvidenceJson);
|
||||
|
||||
Reference in New Issue
Block a user