refactor(sleeve): route buy through InventoryService

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 16:25:10 -04:00
parent 45fa3d75bf
commit 9436a0d21b

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
@@ -20,17 +21,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class SleeveController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly IInventoryService _inv;
private readonly ICollectionRepository _collection;
public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
public SleeveController(SVSimDbContext db, IInventoryService inv, ICollectionRepository collection)
{
_db = db;
_rewards = rewards;
_spend = spend;
_entitlements = entitlements;
_inv = inv;
_collection = collection;
}
@@ -42,12 +39,13 @@ public class SleeveController : SVSimController
// is_purchased_product is "viewer owns at least one sleeve granted by this product".
// Loading the viewer's sleeve-id set once and checking each product against it avoids
// an N+1 over products.
var ownedSleeveIds = _entitlements.IsFreeplay
? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet()
: (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
.ToListAsync()).ToHashSet();
var viewerForInfo = await _db.Viewers
.Include(v => v.Sleeves)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewerForInfo is null) return Unauthorized();
var cosmeticsForInfo = await _inv.EffectiveCosmeticsAsync(viewerForInfo);
var ownedSleeveIds = cosmeticsForInfo.SleeveIds.Select(id => (long)id).ToHashSet();
var series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled)
@@ -113,18 +111,17 @@ public class SleeveController : SVSimController
if (product.SeriesId != request.SeriesId)
return BadRequest(new { error = "series_product_mismatch" });
var viewer = await LoadViewerGraphAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
if (_entitlements.IsFreeplay)
if (tx.IsFreeplay)
return BadRequest(new { error = "already_purchased" });
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
if (IsProductPurchased(product, tx.Viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
return BadRequest(new { error = "already_purchased" });
// Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers
// like BuildDeck). At least one of crystal/rupy must match the chosen sales_type;
// sales_type==0 means "free", which requires both prices == 0.
var rewardList = new List<RewardListEntry>();
switch (request.SalesType)
{
case 0: // free
@@ -134,39 +131,27 @@ public class SleeveController : SVSimController
case 1: // crystal
if (product.PriceCrystal is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value);
if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.PriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
break;
case 2: // rupy
if (product.PriceRupy is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value);
if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.PriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
break;
}
// Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem
// (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries
// suitable for emission as-is.
// Grant each catalog reward through the central dispatcher.
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new SleeveBuyResponse
{
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
await _db.SaveChangesAsync();
return new SleeveBuyResponse { RewardList = rewardList };
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
/// <summary>
@@ -185,14 +170,4 @@ public class SleeveController : SVSimController
return false;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}