refactor(leader-skin): route shop through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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.LeaderSkin;
|
||||
@@ -29,19 +30,15 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class LeaderSkinController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ICurrencySpendService _spend;
|
||||
private readonly IViewerEntitlements _entitlements;
|
||||
private readonly ICollectionRepository _collection;
|
||||
|
||||
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
|
||||
public LeaderSkinController(SVSimDbContext db, IInventoryService inv, TimeProvider time, ICollectionRepository collection)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_inv = inv;
|
||||
_time = time;
|
||||
_spend = spend;
|
||||
_entitlements = entitlements;
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
@@ -69,7 +66,8 @@ public class LeaderSkinController : SVSimController
|
||||
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
|
||||
if (skin is null) return BadRequest(new { error = "unknown_skin" });
|
||||
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
|
||||
if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id))
|
||||
var cosmeticsForSet = await _inv.EffectiveCosmeticsAsync(viewer);
|
||||
if (!cosmeticsForSet.OwnedLeaderSkinIds.Contains(skin.Id))
|
||||
return BadRequest(new { error = "skin_not_owned" });
|
||||
|
||||
classData.LeaderSkin = skin;
|
||||
@@ -88,18 +86,13 @@ public class LeaderSkinController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
if (_entitlements.IsFreeplay)
|
||||
{
|
||||
var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList();
|
||||
return new LeaderSkinIdsResponse { UserLeaderSkinIds = all };
|
||||
}
|
||||
|
||||
var ids = await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||
.OrderBy(id => id)
|
||||
.ToListAsync();
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
if (viewer is null) return Unauthorized();
|
||||
|
||||
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer);
|
||||
var ids = cosmetics.OwnedLeaderSkinIds.OrderBy(id => id).ToList();
|
||||
return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids };
|
||||
}
|
||||
|
||||
@@ -108,12 +101,13 @@ public class LeaderSkinController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var ownedSkinIds = _entitlements.IsFreeplay
|
||||
? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet()
|
||||
: (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
|
||||
.ToListAsync()).ToHashSet();
|
||||
var viewerForProducts = await _db.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
if (viewerForProducts is null) return Unauthorized();
|
||||
|
||||
var cosmeticsForProducts = await _inv.EffectiveCosmeticsAsync(viewerForProducts);
|
||||
var ownedSkinIds = cosmeticsForProducts.OwnedLeaderSkinIds;
|
||||
|
||||
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
|
||||
.Where(c => c.ViewerId == viewerId)
|
||||
@@ -183,21 +177,41 @@ public class LeaderSkinController : SVSimController
|
||||
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
|
||||
return BadRequest(new { error = "product_not_available" });
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
|
||||
// Already-purchased = viewer owns the leader_skin this product grants.
|
||||
if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId))
|
||||
if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId))
|
||||
return BadRequest(new { error = "already_purchased" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var debit = await DebitProductPrice(viewer, product, request.SalesType);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
// Debit currency
|
||||
switch (request.SalesType)
|
||||
{
|
||||
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
|
||||
break; // free
|
||||
case 0:
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
case 1:
|
||||
if (product.SinglePriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.SinglePriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
|
||||
break;
|
||||
case 2:
|
||||
if (product.SinglePriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.SinglePriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
|
||||
break;
|
||||
default:
|
||||
return BadRequest(new { error = "invalid_sales_type" });
|
||||
}
|
||||
|
||||
await ApplyRewardsAsync(viewer, product.Rewards, rewardList);
|
||||
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("buy_set")]
|
||||
@@ -218,25 +232,44 @@ public class LeaderSkinController : SVSimController
|
||||
if (!series.IsEnabled || series.SetSalesStatus == 0)
|
||||
return BadRequest(new { error = "set_sale_not_active" });
|
||||
|
||||
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" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
var debit = await DebitSetPrice(viewer, series, request.SalesType);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
|
||||
// Grant every product's rewards; RewardGrantService is idempotent on already-owned
|
||||
// cosmetics, so partial-set buyers don't double-add.
|
||||
foreach (var p in series.Products.OrderBy(p => p.Id))
|
||||
// Debit set price
|
||||
switch (request.SalesType)
|
||||
{
|
||||
await ApplyRewardsAsync(viewer, p.Rewards, rewardList);
|
||||
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
|
||||
break; // free
|
||||
case 0:
|
||||
return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
case 1:
|
||||
if (series.SetPriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, series.SetPriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
|
||||
break;
|
||||
case 2:
|
||||
if (series.SetPriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
|
||||
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, series.SetPriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
|
||||
break;
|
||||
default:
|
||||
return BadRequest(new { error = "invalid_sales_type" });
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||
// Grant every product's rewards; tx.GrantAsync is idempotent on already-owned cosmetics.
|
||||
foreach (var p in series.Products.OrderBy(p => p.Id))
|
||||
{
|
||||
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
}
|
||||
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("buy_set_item")]
|
||||
@@ -257,16 +290,15 @@ public class LeaderSkinController : SVSimController
|
||||
if (existingClaim is not null)
|
||||
return new LeaderSkinBuyResponse { RewardList = new() };
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
|
||||
|
||||
// Must own every skin in the series to claim the bonus.
|
||||
var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet();
|
||||
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId));
|
||||
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId));
|
||||
if (!ownsAll)
|
||||
return BadRequest(new { error = "series_not_completed" });
|
||||
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList);
|
||||
foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
|
||||
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
|
||||
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
|
||||
{
|
||||
@@ -275,8 +307,13 @@ public class LeaderSkinController : SVSimController
|
||||
ClaimedAt = _time.GetUtcNow().UtcDateTime,
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new LeaderSkinBuyResponse { RewardList = rewardList };
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
return new LeaderSkinBuyResponse
|
||||
{
|
||||
RewardList = result.RewardList
|
||||
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -304,7 +341,7 @@ public class LeaderSkinController : SVSimController
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet<int> ownedSkinIds)
|
||||
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, IReadOnlySet<int> ownedSkinIds)
|
||||
{
|
||||
bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId);
|
||||
return new SkinProductDto
|
||||
@@ -339,7 +376,7 @@ public class LeaderSkinController : SVSimController
|
||||
/// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three
|
||||
/// bundle items are de-facto owned." Refine later if a capture shows independent state.
|
||||
/// </summary>
|
||||
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet<int> ownedSkinIds)
|
||||
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
|
||||
{
|
||||
// Skin reward: direct check.
|
||||
if (r.RewardType == (int)UserGoodsType.Skin)
|
||||
@@ -350,94 +387,4 @@ public class LeaderSkinController : SVSimController
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
|
||||
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
|
||||
{
|
||||
switch (salesType)
|
||||
{
|
||||
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
|
||||
return (null, null);
|
||||
case 0:
|
||||
return (null, "price_not_available_for_currency");
|
||||
case 1:
|
||||
if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitCrystal(viewer, product.SinglePriceCrystal.Value);
|
||||
case 2:
|
||||
if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitRupy(viewer, product.SinglePriceRupy.Value);
|
||||
default:
|
||||
return (null, "invalid_sales_type");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice(
|
||||
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
|
||||
{
|
||||
switch (salesType)
|
||||
{
|
||||
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
|
||||
return (null, null);
|
||||
case 0:
|
||||
return (null, "price_not_available_for_currency");
|
||||
case 1:
|
||||
if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitCrystal(viewer, series.SetPriceCrystal.Value);
|
||||
case 2:
|
||||
if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency");
|
||||
return await DebitRupy(viewer, series.SetPriceRupy.Value);
|
||||
default:
|
||||
return (null, "invalid_sales_type");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount)
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount);
|
||||
if (!r.Success) return (null, "insufficient_crystals");
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
|
||||
private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount)
|
||||
{
|
||||
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount);
|
||||
if (!r.Success) return (null, "insufficient_rupees");
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
||||
}
|
||||
|
||||
private async Task ApplyRewardsAsync<T>(
|
||||
Viewer viewer, IEnumerable<T> rewards, List<RewardListEntry> rewardList) where T : notnull
|
||||
{
|
||||
foreach (var r in rewards)
|
||||
{
|
||||
var (type, detailId, number) = ExtractTuple(r);
|
||||
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch
|
||||
{
|
||||
LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber),
|
||||
LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber),
|
||||
_ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"),
|
||||
};
|
||||
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user