refactor(bootstrap): migrate payment items to seed file

Lifts ImportPaymentItems out of GlobalsImporter into a dedicated
PaymentItemImporter driven by Data/seeds/payment-items.json. Wired
into Program.cs and SVSimTestFactory.SeedGlobalsAsync after
PracticeOpponentImporter. Drops the prod-capture file in favor of
the extractor pipeline.

Canonical 4-test suite (basic, idempotent, leave-untouched, skip-zero)
keeps the dict-in-sync upsert pattern Task 2 established.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-26 13:59:50 -04:00
parent 0b41474968
commit c23c56d46c
8 changed files with 363 additions and 255 deletions

View File

@@ -1,201 +0,0 @@
{
"data_headers": {
"sid": "ac631c29b5f5d07ed5fb6712ad8623c31779553960",
"short_udid": 411054851,
"viewer_id": 906243102,
"servertime": 1779553960,
"result_code": 1
},
"data": {
"10011": {
"record_id": "21",
"id": "8",
"store_product_id": "10011",
"name": "60-crystal set",
"text": "Purchase 60 Crystals",
"price": "0.99",
"charge_crystal_num": "60",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"30011": {
"record_id": "26",
"id": "10",
"store_product_id": "30011",
"name": "670-crystal set",
"text": "Purchase 670 Crystals",
"price": "10.99",
"charge_crystal_num": "670",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"40000": {
"record_id": "27",
"id": "4",
"store_product_id": "40000",
"name": "1200-crystal set",
"text": "Purchase 1200 Crystals",
"price": "20.99",
"charge_crystal_num": "1200",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"50000": {
"record_id": "28",
"id": "5",
"store_product_id": "50000",
"name": "2400-crystal set",
"text": "Purchase 2400 Crystals",
"price": "39.99",
"charge_crystal_num": "2400",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"60000": {
"record_id": "29",
"id": "6",
"store_product_id": "60000",
"name": "5000-crystal set",
"text": "Purchase 5000 Crystals",
"price": "79.99",
"charge_crystal_num": "5000",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"70011": {
"record_id": "24",
"id": "9",
"store_product_id": "70011",
"name": "350-crystal set",
"text": "Purchase 350 Crystals",
"price": "5.99",
"charge_crystal_num": "350",
"free_crystal_num": "0",
"purchase_limit": "999999999",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"80000": {
"record_id": "30",
"id": "800",
"store_product_id": "80000",
"name": "1200-crystal and Legendary set",
"text": "Purchase 1200 Crystals and Legendary set",
"price": "20.99",
"charge_crystal_num": "1200",
"free_crystal_num": "0",
"purchase_limit": "3",
"special_shop_flag": "1",
"image_name": "thumbnail_crystal_strong",
"start_time": "2018-01-01 00:00:00",
"end_time": "2019-03-19 16:15:17",
"remaining_time": "604800",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"98900": {
"record_id": "19",
"id": "989",
"store_product_id": "98900",
"name": "[b]1-Time Deal![/b] 1000-crystal set",
"text": "Purchase 1000 Crystals",
"price": "15.99",
"charge_crystal_num": "1000",
"free_crystal_num": "0",
"purchase_limit": "1",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal_strong",
"start_time": "2026-04-01 02:00:00",
"end_time": "2026-07-01 01:59:59",
"remaining_time": "0",
"is_resale_product": "1",
"resale_start_date": "2026-04-01 02:00:00",
"purchase_num_current": 0
},
"99200": {
"record_id": "3",
"id": "992",
"store_product_id": "99200",
"name": "[b]One-time Deal![/b] 800-crystal set",
"text": "Purchase 800 Crystals",
"price": "7.99",
"charge_crystal_num": "800",
"free_crystal_num": "0",
"purchase_limit": "1",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal_strong",
"start_time": "2018-01-30 04:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 0
},
"99400": {
"record_id": "10",
"id": "994",
"store_product_id": "99400",
"name": "[b]Special Offer![/b] 7500-crystal set (3 times per person)",
"text": "Purchase 7500 Crystals",
"price": "79.99",
"charge_crystal_num": "7500",
"free_crystal_num": "0",
"purchase_limit": "3",
"special_shop_flag": "0",
"image_name": "thumbnail_crystal_strong",
"start_time": "2017-06-01 06:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": "0",
"is_resale_product": "0",
"resale_start_date": "",
"purchase_num_current": 2
}
}
}

View File

@@ -0,0 +1,182 @@
[
{
"record_id": 3,
"product_id": 992,
"store_product_id": 99200,
"name": "[b]One-time Deal![/b] 800-crystal set",
"text": "Purchase 800 Crystals",
"price": "7.99",
"charge_crystal_num": 800,
"free_crystal_num": 0,
"purchase_limit": 1,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal_strong",
"start_time": "2018-01-30 04:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 10,
"product_id": 994,
"store_product_id": 99400,
"name": "[b]Special Offer![/b] 7500-crystal set (3 times per person)",
"text": "Purchase 7500 Crystals",
"price": "79.99",
"charge_crystal_num": 7500,
"free_crystal_num": 0,
"purchase_limit": 3,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal_strong",
"start_time": "2017-06-01 06:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 19,
"product_id": 989,
"store_product_id": 98900,
"name": "[b]1-Time Deal![/b] 1000-crystal set",
"text": "Purchase 1000 Crystals",
"price": "15.99",
"charge_crystal_num": 1000,
"free_crystal_num": 0,
"purchase_limit": 1,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal_strong",
"start_time": "2026-04-01 02:00:00",
"end_time": "2026-07-01 01:59:59",
"remaining_time": 0,
"is_resale_product": 1,
"resale_start_date": "2026-04-01 02:00:00"
},
{
"record_id": 21,
"product_id": 8,
"store_product_id": 10011,
"name": "60-crystal set",
"text": "Purchase 60 Crystals",
"price": "0.99",
"charge_crystal_num": 60,
"free_crystal_num": 0,
"purchase_limit": 999999999,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 24,
"product_id": 9,
"store_product_id": 70011,
"name": "350-crystal set",
"text": "Purchase 350 Crystals",
"price": "5.99",
"charge_crystal_num": 350,
"free_crystal_num": 0,
"purchase_limit": 999999999,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 26,
"product_id": 10,
"store_product_id": 30011,
"name": "670-crystal set",
"text": "Purchase 670 Crystals",
"price": "10.99",
"charge_crystal_num": 670,
"free_crystal_num": 0,
"purchase_limit": 999999999,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal",
"start_time": "2022-10-05 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 27,
"product_id": 4,
"store_product_id": 40000,
"name": "1200-crystal set",
"text": "Purchase 1200 Crystals",
"price": "20.99",
"charge_crystal_num": 1200,
"free_crystal_num": 0,
"purchase_limit": 999999999,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 28,
"product_id": 5,
"store_product_id": 50000,
"name": "2400-crystal set",
"text": "Purchase 2400 Crystals",
"price": "39.99",
"charge_crystal_num": 2400,
"free_crystal_num": 0,
"purchase_limit": 999999999,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 29,
"product_id": 6,
"store_product_id": 60000,
"name": "5000-crystal set",
"text": "Purchase 5000 Crystals",
"price": "79.99",
"charge_crystal_num": 5000,
"free_crystal_num": 0,
"purchase_limit": 999999999,
"special_shop_flag": 0,
"image_name": "thumbnail_crystal",
"start_time": "2015-03-01 15:00:00",
"end_time": "2030-03-01 14:59:59",
"remaining_time": 0,
"is_resale_product": 0,
"resale_start_date": ""
},
{
"record_id": 30,
"product_id": 800,
"store_product_id": 80000,
"name": "1200-crystal and Legendary set",
"text": "Purchase 1200 Crystals and Legendary set",
"price": "20.99",
"charge_crystal_num": 1200,
"free_crystal_num": 0,
"purchase_limit": 3,
"special_shop_flag": 1,
"image_name": "thumbnail_crystal_strong",
"start_time": "2018-01-01 00:00:00",
"end_time": "2019-03-19 16:15:17",
"remaining_time": 604800,
"is_resale_product": 0,
"resale_start_date": ""
}
]

View File

@@ -30,7 +30,6 @@ public class GlobalsImporter
JsonElement? loadIndex = LoadCapture(capturesDir, "load-index");
JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index");
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
JsonElement? packInfo = LoadCapture(capturesDir, "pack-info");
JsonElement? basicPuzzleInfo = LoadCapture(capturesDir, "basic-puzzle-info");
JsonElement? basicPuzzleMission = LoadCapture(capturesDir, "basic-puzzle-mission");
@@ -69,11 +68,6 @@ public class GlobalsImporter
total += await ImportDefaultDecks(context, deckInfo.Value);
}
if (paymentItemList.HasValue)
{
total += await ImportPaymentItems(context, paymentItemList.Value);
}
if (packInfo.HasValue)
{
total += await ImportPacks(context, packInfo.Value);
@@ -709,54 +703,6 @@ public class GlobalsImporter
return created + updated;
}
// ---------- Payment: Item list (Steam/PC storefront, dict-keyed by store_product_id) ----------
private async Task<int> ImportPaymentItems(SVSimDbContext context, JsonElement payment)
{
// The payment-item-list capture's `data` IS the product dict (no nested key like banner/colosseum).
// LoadCapture already unwrapped `data` for us, so iterate the dict directly.
if (payment.ValueKind != JsonValueKind.Object) return 0;
var existing = await context.PaymentItems.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var kv in payment.EnumerateObject())
{
var v = kv.Value;
if (v.ValueKind != JsonValueKind.Object) continue;
int recordId = GetInt(v, "record_id");
if (recordId == 0) continue;
var entry = existing.TryGetValue(recordId, out var ex) ? ex : new PaymentItemEntry { Id = recordId };
entry.ProductId = GetInt(v, "id");
entry.StoreProductId = GetLong(v, "store_product_id");
entry.Name = GetString(v, "name");
entry.Text = GetString(v, "text");
entry.Price = ParseDecimal(GetString(v, "price"));
entry.ChargeCrystalNum = GetInt(v, "charge_crystal_num");
entry.FreeCrystalNum = GetInt(v, "free_crystal_num");
entry.PurchaseLimit = GetInt(v, "purchase_limit");
entry.SpecialShopFlag = GetInt(v, "special_shop_flag");
entry.ImageName = GetString(v, "image_name");
entry.StartTime = ParseWireDateTime(GetString(v, "start_time"));
entry.EndTime = ParseWireDateTime(GetString(v, "end_time"));
entry.RemainingTime = GetInt(v, "remaining_time");
entry.IsResaleProduct = GetInt(v, "is_resale_product");
// resale_start_date is "" when unset — store null rather than DateTime.MinValue so the
// controller can decide whether to emit "" or a real date string.
string resaleRaw = GetString(v, "resale_start_date");
entry.ResaleStartDate = string.IsNullOrWhiteSpace(resaleRaw) ? null : ParseWireDateTime(resaleRaw);
if (ex is null) { context.PaymentItems.Add(entry); created++; }
else updated++;
}
Console.WriteLine($"[GlobalsImporter] PaymentItems: +{created}/~{updated}");
return created + updated;
}
private static decimal ParseDecimal(string s) =>
decimal.TryParse(s, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0m;
// ---------- Pack catalog ----------
/// <summary>

View File

@@ -0,0 +1,69 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of Steam payment items from <c>seeds/payment-items.json</c>.
/// Rows missing from the seed are LEFT INTACT.
/// </summary>
public class PaymentItemImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "payment-items.json");
var seed = SeedLoader.LoadList<PaymentItemSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[PaymentItemImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.PaymentItems.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.RecordId == 0) continue;
var entry = existing.TryGetValue(s.RecordId, out var ex)
? ex : new PaymentItemEntry { Id = s.RecordId };
entry.ProductId = s.ProductId;
entry.StoreProductId = s.StoreProductId;
entry.Name = s.Name;
entry.Text = s.Text;
entry.Price = decimal.TryParse(s.Price, NumberStyles.Number, CultureInfo.InvariantCulture, out var d) ? d : 0m;
entry.ChargeCrystalNum = s.ChargeCrystalNum;
entry.FreeCrystalNum = s.FreeCrystalNum;
entry.PurchaseLimit = s.PurchaseLimit;
entry.SpecialShopFlag = s.SpecialShopFlag;
entry.ImageName = s.ImageName;
entry.StartTime = ParseDate(s.StartTime);
entry.EndTime = ParseDate(s.EndTime);
entry.RemainingTime = s.RemainingTime;
entry.IsResaleProduct = s.IsResaleProduct;
entry.ResaleStartDate = string.IsNullOrWhiteSpace(s.ResaleStartDate) ? null : ParseDate(s.ResaleStartDate);
if (ex is null)
{
context.PaymentItems.Add(entry);
existing[s.RecordId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[PaymentItemImporter] +{created}/~{updated}");
return created + updated;
}
private static DateTime ParseDate(string s) =>
DateTime.TryParse(s, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var dt) ? dt : DateTime.MinValue;
}

View File

@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class PaymentItemSeed
{
[JsonPropertyName("record_id")] public int RecordId { get; set; }
[JsonPropertyName("product_id")] public int ProductId { get; set; }
[JsonPropertyName("store_product_id")] public long StoreProductId { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("text")] public string Text { get; set; } = "";
[JsonPropertyName("price")] public string Price { get; set; } = "0";
[JsonPropertyName("charge_crystal_num")] public int ChargeCrystalNum { get; set; }
[JsonPropertyName("free_crystal_num")] public int FreeCrystalNum { get; set; }
[JsonPropertyName("purchase_limit")] public int PurchaseLimit { get; set; }
[JsonPropertyName("special_shop_flag")] public int SpecialShopFlag { get; set; }
[JsonPropertyName("image_name")] public string ImageName { get; set; } = "";
[JsonPropertyName("start_time")] public string StartTime { get; set; } = "";
[JsonPropertyName("end_time")] public string EndTime { get; set; } = "";
[JsonPropertyName("remaining_time")] public int RemainingTime { get; set; }
[JsonPropertyName("is_resale_product")] public int IsResaleProduct { get; set; }
[JsonPropertyName("resale_start_date")] public string ResaleStartDate { get; set; } = "";
}

View File

@@ -77,6 +77,7 @@ public static class Program
{
await new GlobalsImporter().ImportAllAsync(context, opts.CapturesDir);
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
// BuildDeck pipeline: series CSV → catalog JSON → package CSV. Catalog must run after
// series CSV (FK on products → series) and before package CSV (so the catalog-side

View File

@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Bootstrap.Importers;
using SVSim.Database;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Importers;
public class PaymentItemImporterTests
{
private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
[Test]
public async Task Imports_items_from_seed_file()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PaymentItemImporter().ImportAsync(db, SeedDir);
var items = await db.PaymentItems.OrderBy(p => p.Id).ToListAsync();
Assert.That(items.Count, Is.GreaterThan(0), "seed file must contain items");
Assert.That(items.All(i => i.Price >= 0m), Is.True);
}
[Test]
public async Task Is_idempotent_on_rerun()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await new PaymentItemImporter().ImportAsync(db, SeedDir);
int before = await db.PaymentItems.CountAsync();
await new PaymentItemImporter().ImportAsync(db, SeedDir);
int after = await db.PaymentItems.CountAsync();
Assert.That(after, Is.EqualTo(before));
}
[Test]
public async Task Leaves_existing_rows_untouched_when_missing_from_seed()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
const int legacyId = 99999;
db.PaymentItems.Add(new SVSim.Database.Models.PaymentItemEntry
{
Id = legacyId,
ProductId = 0,
Name = "legacy",
Price = 0m,
});
await db.SaveChangesAsync();
await new PaymentItemImporter().ImportAsync(db, SeedDir);
var legacy = await db.PaymentItems.FindAsync(legacyId);
Assert.That(legacy, Is.Not.Null, "seed-missing row must be left intact");
Assert.That(legacy!.Name, Is.EqualTo("legacy"));
}
[Test]
public async Task Skips_rows_with_zero_record_id()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
string tmp = Path.Combine(Path.GetTempPath(), $"seed-{Guid.NewGuid()}");
Directory.CreateDirectory(tmp);
try
{
File.WriteAllText(Path.Combine(tmp, "payment-items.json"),
"[{\"record_id\":0,\"product_id\":1,\"name\":\"junk\",\"price\":\"0\"}]");
await new PaymentItemImporter().ImportAsync(db, tmp);
int count = await db.PaymentItems.CountAsync();
Assert.That(count, Is.EqualTo(0), "rows with record_id=0 must not be inserted");
}
finally { Directory.Delete(tmp, true); }
}
}

View File

@@ -193,6 +193,7 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
// Wired here so SeedGlobalsAsync callers (e.g. PracticeControllerTests) still see
// practice-opponent rows after the corresponding block was lifted out of GlobalsImporter.
await new PracticeOpponentImporter().ImportAsync(ctx, seedDir);
await new PaymentItemImporter().ImportAsync(ctx, seedDir);
}
/// <summary>Convenience: bake the X-Test-Viewer-Id header into a fresh client.</summary>