import_viewer: round-trip ViewerAchievement + counter
This commit is contained in:
@@ -81,6 +81,7 @@ public class AdminController : SVSimController
|
|||||||
.Include(v => v.Currency)
|
.Include(v => v.Currency)
|
||||||
.Include(v => v.MissionData)
|
.Include(v => v.MissionData)
|
||||||
.Include(v => v.Missions)
|
.Include(v => v.Missions)
|
||||||
|
.Include(v => v.Achievements)
|
||||||
.Include(v => v.Classes).ThenInclude(c => c.Class)
|
.Include(v => v.Classes).ThenInclude(c => c.Class)
|
||||||
.Include(v => v.Sleeves)
|
.Include(v => v.Sleeves)
|
||||||
.Include(v => v.Emblems)
|
.Include(v => v.Emblems)
|
||||||
@@ -341,6 +342,81 @@ public class AdminController : SVSimController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass C: Achievements + ViewerEventCounter
|
||||||
|
var skippedAchievementTypes = new HashSet<int>();
|
||||||
|
var skippedAchievementCounterTypes = new HashSet<int>();
|
||||||
|
if (request.Achievements is { } achievements)
|
||||||
|
{
|
||||||
|
viewer.Achievements.Clear();
|
||||||
|
var types = achievements.Select(a => a.AchievementType).Distinct().ToList();
|
||||||
|
var catalogs = await _dbContext.AchievementCatalog
|
||||||
|
.Where(c => types.Contains(c.AchievementType))
|
||||||
|
.GroupBy(c => c.AchievementType)
|
||||||
|
.ToDictionaryAsync(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
// Resolve counter keys for all achievements up-front so we can load existing counters
|
||||||
|
// in one query and avoid duplicate-Add races when two achievements share the same
|
||||||
|
// EventType (same (EventKey, AllTime) cache key) within the same import batch.
|
||||||
|
var resolvedAchievementCounterKeys = new Dictionary<int, (string EventKey, string Period)>();
|
||||||
|
foreach (var a in achievements)
|
||||||
|
{
|
||||||
|
if (!catalogs.TryGetValue(a.AchievementType, out var cat)) continue;
|
||||||
|
var r = ResolveAchievementCounter(cat);
|
||||||
|
if (r is not null)
|
||||||
|
resolvedAchievementCounterKeys[a.AchievementType] = r.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all distinct (EventKey, Period) pairs we will touch.
|
||||||
|
var allAchEventKeys = resolvedAchievementCounterKeys.Values.Select(v => v.EventKey).Distinct().ToList();
|
||||||
|
var allAchPeriods = resolvedAchievementCounterKeys.Values.Select(v => v.Period).Distinct().ToList();
|
||||||
|
|
||||||
|
// Load existing counters in one query.
|
||||||
|
var existingAchCounters = await _dbContext.ViewerEventCounters
|
||||||
|
.Where(c => c.ViewerId == viewer.Id
|
||||||
|
&& allAchEventKeys.Contains(c.EventKey)
|
||||||
|
&& allAchPeriods.Contains(c.Period))
|
||||||
|
.ToListAsync();
|
||||||
|
var achievementCounterCache = existingAchCounters
|
||||||
|
.ToDictionary(c => (c.EventKey, c.Period));
|
||||||
|
|
||||||
|
foreach (var a in achievements)
|
||||||
|
{
|
||||||
|
if (!catalogs.TryGetValue(a.AchievementType, out var cat))
|
||||||
|
{
|
||||||
|
skippedAchievementTypes.Add(a.AchievementType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
viewer.Achievements.Add(new ViewerAchievement
|
||||||
|
{
|
||||||
|
ViewerId = viewer.Id,
|
||||||
|
AchievementType = a.AchievementType,
|
||||||
|
Level = a.Level,
|
||||||
|
NowAchievedLevel = a.NowAchievedLevel,
|
||||||
|
ResultAnnounceSawLevel = a.ResultAnnounceSawLevel,
|
||||||
|
AchievementStatus = 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resolvedAchievementCounterKeys.TryGetValue(a.AchievementType, out var resolved))
|
||||||
|
{
|
||||||
|
skippedAchievementCounterTypes.Add(a.AchievementType);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var (eventKey, period) = resolved;
|
||||||
|
if (!achievementCounterCache.TryGetValue((eventKey, period), out var counter))
|
||||||
|
{
|
||||||
|
counter = new ViewerEventCounter
|
||||||
|
{
|
||||||
|
ViewerId = viewer.Id,
|
||||||
|
EventKey = eventKey,
|
||||||
|
Period = period,
|
||||||
|
};
|
||||||
|
_dbContext.ViewerEventCounters.Add(counter);
|
||||||
|
achievementCounterCache[(eventKey, period)] = counter;
|
||||||
|
}
|
||||||
|
counter.Count = a.TotalCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
if (skippedCardIds.Count > 0)
|
if (skippedCardIds.Count > 0)
|
||||||
@@ -365,6 +441,20 @@ public class AdminController : SVSimController
|
|||||||
request.SteamId, viewer.Id, skippedMissionCounterIds.Count,
|
request.SteamId, viewer.Id, skippedMissionCounterIds.Count,
|
||||||
string.Join(", ", skippedMissionCounterIds.Take(10)));
|
string.Join(", ", skippedMissionCounterIds.Take(10)));
|
||||||
}
|
}
|
||||||
|
if (skippedAchievementTypes.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown achievement_type(s). Sample: [{Sample}]",
|
||||||
|
request.SteamId, viewer.Id, skippedAchievementTypes.Count,
|
||||||
|
string.Join(", ", skippedAchievementTypes.Take(10)));
|
||||||
|
}
|
||||||
|
if (skippedAchievementCounterTypes.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): {Count} achievement(s) had unresolvable EventKey, counter not written. Sample: [{Sample}]",
|
||||||
|
request.SteamId, viewer.Id, skippedAchievementCounterTypes.Count,
|
||||||
|
string.Join(", ", skippedAchievementCounterTypes.Take(10)));
|
||||||
|
}
|
||||||
|
|
||||||
return new ImportViewerResponse
|
return new ImportViewerResponse
|
||||||
{
|
{
|
||||||
@@ -374,6 +464,8 @@ public class AdminController : SVSimController
|
|||||||
SkippedCardCount = skippedCardIds.Count,
|
SkippedCardCount = skippedCardIds.Count,
|
||||||
SkippedMissionCount = skippedMissionIds.Count,
|
SkippedMissionCount = skippedMissionIds.Count,
|
||||||
SkippedMissionCounterCount = skippedMissionCounterIds.Count,
|
SkippedMissionCounterCount = skippedMissionCounterIds.Count,
|
||||||
|
SkippedAchievementCount = skippedAchievementTypes.Count,
|
||||||
|
SkippedAchievementCounterCount = skippedAchievementCounterTypes.Count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +498,13 @@ public class AdminController : SVSimController
|
|||||||
return (catalog.EventType!, period);
|
return (catalog.EventType!, period);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: unify with MissionAssembler.cs — same logic duplicated here.
|
||||||
|
private static (string EventKey, string Period)? ResolveAchievementCounter(AchievementCatalogEntry catalog)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(catalog.EventType)) return null;
|
||||||
|
return (catalog.EventType!, JstPeriod.AllTime);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fallback sleeve id used when an imported deck has no resolvable <c>sleeve_id</c>.
|
/// Fallback sleeve id used when an imported deck has no resolvable <c>sleeve_id</c>.
|
||||||
/// 3000011 is prod's default deck sleeve.
|
/// 3000011 is prod's default deck sleeve.
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class ImportViewerRequest
|
|||||||
[JsonPropertyName("mission_meta")] public ImportMissionMeta? MissionMeta { get; set; }
|
[JsonPropertyName("mission_meta")] public ImportMissionMeta? MissionMeta { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("missions")] public List<ImportMission>? Missions { get; set; }
|
[JsonPropertyName("missions")] public List<ImportMission>? Missions { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("achievements")]
|
||||||
|
public List<ImportAchievement>? Achievements { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImportDeck
|
public class ImportDeck
|
||||||
@@ -98,6 +101,15 @@ public class ImportMission
|
|||||||
public int? Slot { get; set; }
|
public int? Slot { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ImportAchievement
|
||||||
|
{
|
||||||
|
[JsonPropertyName("achievement_type")] public int AchievementType { get; set; }
|
||||||
|
[JsonPropertyName("level")] public int Level { get; set; }
|
||||||
|
[JsonPropertyName("now_achieved_level")] public int NowAchievedLevel { get; set; }
|
||||||
|
[JsonPropertyName("result_announce_saw_level")] public int ResultAnnounceSawLevel { get; set; }
|
||||||
|
[JsonPropertyName("total_count")] public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class ImportMissionMeta
|
public class ImportMissionMeta
|
||||||
{
|
{
|
||||||
[JsonPropertyName("has_received_pick_two_mission")]
|
[JsonPropertyName("has_received_pick_two_mission")]
|
||||||
|
|||||||
@@ -637,6 +637,125 @@ public class AdminControllerTests
|
|||||||
Is.EqualTo(10));
|
Is.EqualTo(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_Achievements_RoundTripsWithCounter()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using (var seedScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
db.AchievementCatalog.Add(new AchievementCatalogEntry
|
||||||
|
{
|
||||||
|
AchievementType = 501, Level = 3, EventType = "cards_owned_total", EventArg = null
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
ulong steamId = 70000000000000007UL;
|
||||||
|
|
||||||
|
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = steamId,
|
||||||
|
Achievements = new List<ImportAchievement>
|
||||||
|
{
|
||||||
|
new() { AchievementType = 501, Level = 3, NowAchievedLevel = 3, ResultAnnounceSawLevel = 2, TotalCount = 42 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
Assert.That(body!.SkippedAchievementCount, Is.EqualTo(0));
|
||||||
|
Assert.That(body.SkippedAchievementCounterCount, Is.EqualTo(0));
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db2 = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var viewerId = await db2.Viewers
|
||||||
|
.Where(v => v.SocialAccountConnections
|
||||||
|
.Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId))
|
||||||
|
.Select(v => v.Id).SingleAsync();
|
||||||
|
var ach = await db2.ViewerAchievements
|
||||||
|
.SingleAsync(a => a.ViewerId == viewerId && a.AchievementType == 501);
|
||||||
|
Assert.That(ach.Level, Is.EqualTo(3));
|
||||||
|
Assert.That(ach.NowAchievedLevel, Is.EqualTo(3));
|
||||||
|
Assert.That(ach.ResultAnnounceSawLevel, Is.EqualTo(2));
|
||||||
|
Assert.That(db2.ViewerEventCounters
|
||||||
|
.Count(c => c.ViewerId == viewerId && c.EventKey == "cards_owned_total" && c.Count == 42), Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_Achievements_UnknownTypeSkipped()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
ulong steamId = 70000000000000008UL;
|
||||||
|
|
||||||
|
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = steamId,
|
||||||
|
Achievements = new List<ImportAchievement>
|
||||||
|
{
|
||||||
|
new() { AchievementType = 999999, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
Assert.That(body!.SkippedAchievementCount, Is.EqualTo(1));
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
Assert.That(db.ViewerAchievements.Any(), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_Achievements_TwoAchievementsSharingEventType_SingleCounterUpsert()
|
||||||
|
{
|
||||||
|
// Two achievements with different (AchievementType, Level) but the same EventType — same
|
||||||
|
// (EventKey, AllTime) cache key. Without the pre-materialized achievementCounterCache the
|
||||||
|
// second DB read won't see the first achievement's pending Add → duplicate insert /
|
||||||
|
// unique-constraint failure. With the fix both achievements upsert the SAME counter,
|
||||||
|
// resulting in exactly one ViewerEventCounter row.
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using (var seedScope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var seedDb = seedScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
seedDb.AchievementCatalog.AddRange(
|
||||||
|
new AchievementCatalogEntry { AchievementType = 601, Level = 1, EventType = "battle_win_total", EventArg = null },
|
||||||
|
new AchievementCatalogEntry { AchievementType = 602, Level = 1, EventType = "battle_win_total", EventArg = null });
|
||||||
|
await seedDb.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
ulong steamId = 70000000000000009UL;
|
||||||
|
|
||||||
|
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = steamId,
|
||||||
|
Achievements = new List<ImportAchievement>
|
||||||
|
{
|
||||||
|
new() { AchievementType = 601, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 20 },
|
||||||
|
new() { AchievementType = 602, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 20 },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
Assert.That(body!.SkippedAchievementCount, Is.EqualTo(0));
|
||||||
|
Assert.That(body.SkippedAchievementCounterCount, Is.EqualTo(0));
|
||||||
|
|
||||||
|
using var verifyScope = factory.Services.CreateScope();
|
||||||
|
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var viewerId = await verifyDb.Viewers
|
||||||
|
.Where(v => v.SocialAccountConnections
|
||||||
|
.Any(s => s.AccountType == SocialAccountType.Steam && s.AccountId == steamId))
|
||||||
|
.Select(v => v.Id).SingleAsync();
|
||||||
|
|
||||||
|
// Must be exactly ONE counter row for (EventKey="battle_win_total"), not two.
|
||||||
|
Assert.That(
|
||||||
|
verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId && c.EventKey == "battle_win_total"),
|
||||||
|
Is.EqualTo(1));
|
||||||
|
Assert.That(
|
||||||
|
verifyDb.ViewerEventCounters
|
||||||
|
.Single(c => c.ViewerId == viewerId && c.EventKey == "battle_win_total").Count,
|
||||||
|
Is.EqualTo(20));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task ImportViewer_Missions_ExplicitSlotRoundTrips()
|
public async Task ImportViewer_Missions_ExplicitSlotRoundTrips()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user