feat(import): import owned card collection with unknown-card skip
Extends POST /admin/import_viewer to accept owned_cards (snapshot full-replace). Unknown card_ids are skipped, counted in skipped_card_count on the response, and logged server-side. Count is clamped to OwnedCardEntry.MaxCopies (3). Also injects ILogger into AdminController and adds Cards/Items/Decks to the viewer reload graph. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
using SVSim.Database.Models;
|
using SVSim.Database.Models;
|
||||||
@@ -20,11 +21,14 @@ public class AdminController : SVSimController
|
|||||||
{
|
{
|
||||||
private readonly IViewerRepository _viewerRepository;
|
private readonly IViewerRepository _viewerRepository;
|
||||||
private readonly SVSimDbContext _dbContext;
|
private readonly SVSimDbContext _dbContext;
|
||||||
|
private readonly ILogger<AdminController> _logger;
|
||||||
|
|
||||||
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext)
|
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
|
||||||
|
ILogger<AdminController> logger)
|
||||||
{
|
{
|
||||||
_viewerRepository = viewerRepository;
|
_viewerRepository = viewerRepository;
|
||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -81,6 +85,9 @@ public class AdminController : SVSimController
|
|||||||
.Include(v => v.Degrees)
|
.Include(v => v.Degrees)
|
||||||
.Include(v => v.LeaderSkins)
|
.Include(v => v.LeaderSkins)
|
||||||
.Include(v => v.MyPageBackgrounds)
|
.Include(v => v.MyPageBackgrounds)
|
||||||
|
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||||
|
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||||
|
.Include(v => v.Decks).ThenInclude(d => d.Cards)
|
||||||
.FirstAsync(v => v.Id == viewerId);
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
|
|
||||||
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
|
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
|
||||||
@@ -124,6 +131,38 @@ public class AdminController : SVSimController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulates distinct card_ids referenced by the import (owned list + deck lists)
|
||||||
|
// that aren't in our card master. Surfaced in the response and logged after save.
|
||||||
|
var skippedCardIds = new HashSet<long>();
|
||||||
|
|
||||||
|
if (request.OwnedCards is not null)
|
||||||
|
{
|
||||||
|
var wanted = request.OwnedCards
|
||||||
|
.GroupBy(c => c.CardId)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
var ids = wanted.Select(c => c.CardId).ToList();
|
||||||
|
var cardMaster = await _dbContext.Cards
|
||||||
|
.Where(c => ids.Contains(c.Id))
|
||||||
|
.ToDictionaryAsync(c => c.Id);
|
||||||
|
|
||||||
|
viewer.Cards.Clear();
|
||||||
|
foreach (var c in wanted)
|
||||||
|
{
|
||||||
|
if (!cardMaster.TryGetValue(c.CardId, out var card))
|
||||||
|
{
|
||||||
|
skippedCardIds.Add(c.CardId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
viewer.Cards.Add(new OwnedCardEntry
|
||||||
|
{
|
||||||
|
Card = card,
|
||||||
|
Count = Math.Clamp(c.Count, 1, OwnedCardEntry.MaxCopies),
|
||||||
|
IsProtected = c.IsProtected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clone the 8 starter decks into the viewer when freshly created — workaround for a
|
// Clone the 8 starter decks into the viewer when freshly created — workaround for a
|
||||||
// client-side NRE in the deck-edit menu (DeckListUI.IsVisibleCreateNewButton at
|
// client-side NRE in the deck-edit menu (DeckListUI.IsVisibleCreateNewButton at
|
||||||
// decompile Wizard/DeckListUI.cs:316 unconditionally reads `_deckGroup.DeckFormat`, but
|
// decompile Wizard/DeckListUI.cs:316 unconditionally reads `_deckGroup.DeckFormat`, but
|
||||||
@@ -138,11 +177,21 @@ public class AdminController : SVSimController
|
|||||||
|
|
||||||
await _dbContext.SaveChangesAsync();
|
await _dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (skippedCardIds.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"ImportViewer (steam_id={SteamId}, viewer_id={ViewerId}): skipped {Count} unknown " +
|
||||||
|
"card_id(s) not present in the card master. Sample: [{Sample}]",
|
||||||
|
request.SteamId, viewer.Id, skippedCardIds.Count,
|
||||||
|
string.Join(", ", skippedCardIds.Take(20)));
|
||||||
|
}
|
||||||
|
|
||||||
return new ImportViewerResponse
|
return new ImportViewerResponse
|
||||||
{
|
{
|
||||||
ViewerId = viewer.Id,
|
ViewerId = viewer.Id,
|
||||||
ShortUdid = viewer.ShortUdid,
|
ShortUdid = viewer.ShortUdid,
|
||||||
WasCreated = wasCreated
|
WasCreated = wasCreated,
|
||||||
|
SkippedCardCount = skippedCardIds.Count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public class ImportViewerRequest
|
|||||||
[JsonPropertyName("owned_mypage_background_ids")] public List<int>? OwnedMyPageBackgroundIds { get; set; }
|
[JsonPropertyName("owned_mypage_background_ids")] public List<int>? OwnedMyPageBackgroundIds { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("classes")] public List<ImportClassData>? Classes { get; set; }
|
[JsonPropertyName("classes")] public List<ImportClassData>? Classes { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("owned_cards")] public List<ImportCard>? OwnedCards { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ImportCurrency
|
public class ImportCurrency
|
||||||
@@ -41,3 +43,10 @@ public class ImportClassData
|
|||||||
[JsonPropertyName("level")] public int Level { get; set; }
|
[JsonPropertyName("level")] public int Level { get; set; }
|
||||||
[JsonPropertyName("exp")] public int Exp { get; set; }
|
[JsonPropertyName("exp")] public int Exp { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ImportCard
|
||||||
|
{
|
||||||
|
[JsonPropertyName("card_id")] public long CardId { get; set; }
|
||||||
|
[JsonPropertyName("count")] public int Count { get; set; }
|
||||||
|
[JsonPropertyName("is_protected")] public bool IsProtected { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ public class ImportViewerResponse
|
|||||||
[JsonPropertyName("viewer_id")] public long ViewerId { get; set; }
|
[JsonPropertyName("viewer_id")] public long ViewerId { get; set; }
|
||||||
[JsonPropertyName("short_udid")] public long ShortUdid { get; set; }
|
[JsonPropertyName("short_udid")] public long ShortUdid { get; set; }
|
||||||
[JsonPropertyName("was_created")] public bool WasCreated { get; set; }
|
[JsonPropertyName("was_created")] public bool WasCreated { get; set; }
|
||||||
|
[JsonPropertyName("skipped_card_count")] public int SkippedCardCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,4 +115,84 @@ public class AdminControllerTests
|
|||||||
|
|
||||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_imports_owned_cards_and_skips_unknown()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
// 10001001 is in the minimal test card set; 99999999 is not.
|
||||||
|
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = 76_561_198_111_222_333UL,
|
||||||
|
OwnedCards = new List<ImportCard>
|
||||||
|
{
|
||||||
|
new() { CardId = 10001001L, Count = 2, IsProtected = true },
|
||||||
|
new() { CardId = 99999999L, Count = 1, IsProtected = false },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||||
|
await response.Content.ReadAsStringAsync());
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
Assert.That(body!.SkippedCardCount, Is.EqualTo(1), "Unknown 99999999 must be skipped and counted.");
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||||
|
.FirstAsync(v => v.Id == body.ViewerId);
|
||||||
|
|
||||||
|
Assert.That(stored.Cards.Count, Is.EqualTo(1), "Only the known card should be stored.");
|
||||||
|
var owned = stored.Cards.Single();
|
||||||
|
Assert.That(owned.Card.Id, Is.EqualTo(10001001L));
|
||||||
|
Assert.That(owned.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(owned.IsProtected, Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_clamps_card_count_to_max_copies()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = 76_561_198_111_222_334UL,
|
||||||
|
OwnedCards = new List<ImportCard> { new() { CardId = 10001002L, Count = 5 } }
|
||||||
|
});
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<ImportViewerResponse>(JsonOptions);
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||||
|
.FirstAsync(v => v.Id == body!.ViewerId);
|
||||||
|
Assert.That(stored.Cards.Single().Count, Is.EqualTo(3),
|
||||||
|
"Count must clamp to OwnedCardEntry.MaxCopies (3).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportViewer_replaces_existing_card_collection()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
const ulong steamId = 76_561_198_111_222_335UL;
|
||||||
|
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||||
|
await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3);
|
||||||
|
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||||
|
{
|
||||||
|
SteamId = steamId,
|
||||||
|
OwnedCards = new List<ImportCard> { new() { CardId = 10001002L, Count = 1 } }
|
||||||
|
});
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||||
|
await response.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var stored = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||||
|
.FirstAsync(v => v.Id == viewerId);
|
||||||
|
Assert.That(stored.Cards.Select(c => c.Card.Id), Is.EquivalentTo(new[] { 10001002L }),
|
||||||
|
"Full replace: the pre-seeded 10001001 must be gone, only 10001002 present.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user