feat(item-purchase): /item_purchase/{info,purchase} + catalog
Schema: ItemPurchaseCatalogEntry (single table). Per-viewer quota tracked via existing ViewerEventCounter keyed by "item_purchase:<id>" with period JstPeriod.MonthKey when IsMonthlyReset else AllTime. Controller: - /info returns catalog + per-period rest (server-computed max(0, PurchaseLimit - counter)) + user_card_pack_ticket_list (every Items.Type==2 row joined to viewer count, zeros included — client unconditionally UpdateItemNum's each entry). - /purchase: sold_out check before currency check (no counter increment on currency failure), inline TryDebit covers RedEther/Crystal/Rupy/Item with post-state-total reward_list entry, grant via RewardGrantService. Request `rest` accepted but ignored (server counter is canonical). Importer mirrors PaymentItemImporter shape — idempotent find-or-create, seed-missing rows preserved. 3 entries from the prod capture. 486 tests pass (was 476; +10 item_purchase tests). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
215
SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
Normal file
215
SVSim.EmulatedEntrypoint/Controllers/ItemPurchaseController.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/* — the generic item shop where viewers spend item-or-currency to acquire
|
||||
/// other items (e.g. RedEther → Seer's Globe, Orb Shards → Seer's Globe). Per-viewer monthly
|
||||
/// or lifetime quota tracked via <see cref="ViewerEventCounter"/>.
|
||||
/// </summary>
|
||||
[Route("item_purchase")]
|
||||
public class ItemPurchaseController : SVSimController
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time)
|
||||
{
|
||||
_db = db;
|
||||
_rewards = rewards;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info()
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var catalog = await _db.ItemPurchaseCatalog
|
||||
.Where(c => c.IsEnabled)
|
||||
.OrderBy(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var monthKey = JstPeriod.MonthKey(now);
|
||||
var keys = catalog.Select(c => CounterKey(c.Id)).ToList();
|
||||
var counters = await _db.ViewerEventCounters
|
||||
.Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey))
|
||||
.ToListAsync();
|
||||
|
||||
var info = new List<ItemPurchaseEntryDto>(catalog.Count);
|
||||
foreach (var c in catalog)
|
||||
{
|
||||
int count = CounterCount(counters, c, monthKey);
|
||||
info.Add(new ItemPurchaseEntryDto
|
||||
{
|
||||
PurchaseId = c.Id,
|
||||
RequireItemType = c.RequireItemType,
|
||||
RequireItemId = c.RequireItemId,
|
||||
RequireItemNum = c.RequireItemNum,
|
||||
PurchaseItemType = c.PurchaseItemType,
|
||||
PurchaseItemId = c.PurchaseItemId,
|
||||
PurchaseItemNum = c.PurchaseItemNum,
|
||||
PurchaseName = c.PurchaseName,
|
||||
IsMonthlyReset = c.IsMonthlyReset ? 1 : 0,
|
||||
Rest = Math.Max(0, c.PurchaseLimit - count),
|
||||
});
|
||||
}
|
||||
|
||||
// user_card_pack_ticket_list: every item with Type == 2 paired with the viewer's count
|
||||
// (zero counts included — the client unconditionally calls UpdateItemNum per entry).
|
||||
var ticketItems = await _db.Items
|
||||
.Where(i => i.Type == 2)
|
||||
.OrderByDescending(i => i.Id)
|
||||
.ToListAsync();
|
||||
var ownedByItemId = (await _db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Items)
|
||||
.Select(oi => new { oi.Item.Id, oi.Count })
|
||||
.ToListAsync())
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
|
||||
var ticketList = ticketItems.Select(i => new UserCardPackTicketDto
|
||||
{
|
||||
ItemId = i.Id,
|
||||
Number = ownedByItemId.TryGetValue(i.Id, out var cnt) ? cnt : 0,
|
||||
}).ToList();
|
||||
|
||||
return new ItemPurchaseInfoResponse
|
||||
{
|
||||
ItemPurchaseInfo = info,
|
||||
UserCardPackTicketList = ticketList,
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("purchase")]
|
||||
public async Task<ActionResult<ItemPurchasePurchaseResponse>> Purchase(ItemPurchasePurchaseRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var entry = await _db.ItemPurchaseCatalog.FindAsync(request.PurchaseId);
|
||||
if (entry is null || !entry.IsEnabled)
|
||||
return BadRequest(new { error = "unknown_purchase" });
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime;
|
||||
var key = CounterKey(entry.Id);
|
||||
|
||||
var counter = await _db.ViewerEventCounters
|
||||
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.EventKey == key && c.Period == period);
|
||||
int currentCount = counter?.Count ?? 0;
|
||||
int rest = entry.PurchaseLimit - currentCount;
|
||||
if (rest <= 0)
|
||||
return BadRequest(new { error = "sold_out" });
|
||||
|
||||
var viewer = await LoadViewerGraphAsync(viewerId);
|
||||
var rewardList = new List<RewardListEntry>();
|
||||
|
||||
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
|
||||
var debit = TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
|
||||
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
|
||||
if (debit.PostState is not null) rewardList.Add(debit.PostState);
|
||||
|
||||
// Grant the purchase side through the central dispatcher.
|
||||
var granted = await _rewards.ApplyAsync(viewer,
|
||||
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
|
||||
foreach (var g in granted)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = g.RewardType,
|
||||
RewardId = g.RewardId,
|
||||
RewardNum = g.RewardNum,
|
||||
});
|
||||
}
|
||||
|
||||
// Increment the per-period counter.
|
||||
if (counter is null)
|
||||
{
|
||||
_db.ViewerEventCounters.Add(new ViewerEventCounter
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
EventKey = key,
|
||||
Period = period,
|
||||
Count = 1,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
counter.Count++;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
|
||||
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
|
||||
/// uses to refresh its cached count. Returns an error string on insufficient balance.
|
||||
/// </summary>
|
||||
private static (RewardListEntry? PostState, string? Error) TryDebit(
|
||||
Viewer viewer, UserGoodsType type, long detailId, int num)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case UserGoodsType.RedEther:
|
||||
if (viewer.Currency.RedEther < (ulong)num)
|
||||
return (null, "insufficient_red_ether");
|
||||
viewer.Currency.RedEther -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)viewer.Currency.RedEther }, null);
|
||||
|
||||
case UserGoodsType.Crystal:
|
||||
if (viewer.Currency.Crystals < (ulong)num)
|
||||
return (null, "insufficient_crystals");
|
||||
viewer.Currency.Crystals -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)viewer.Currency.Crystals }, null);
|
||||
|
||||
case UserGoodsType.Rupy:
|
||||
if (viewer.Currency.Rupees < (ulong)num)
|
||||
return (null, "insufficient_rupees");
|
||||
viewer.Currency.Rupees -= (ulong)num;
|
||||
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)viewer.Currency.Rupees }, null);
|
||||
|
||||
case UserGoodsType.Item:
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null || owned.Count < num)
|
||||
return (null, "insufficient_item");
|
||||
owned.Count -= num;
|
||||
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
|
||||
|
||||
default:
|
||||
return (null, $"debit_type_not_supported:{type}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
|
||||
|
||||
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
|
||||
{
|
||||
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
|
||||
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
|
||||
}
|
||||
|
||||
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card)
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/purchase request body. <c>rest</c> is the client's locally-cached remaining
|
||||
/// quota — used as an optional optimistic-concurrency check on the server. Not authoritative;
|
||||
/// the server's own counter is canonical.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ItemPurchasePurchaseRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("purchase_id")]
|
||||
[Key("purchase_id")]
|
||||
public int PurchaseId { get; set; }
|
||||
|
||||
[JsonPropertyName("rest")]
|
||||
[Key("rest")]
|
||||
public int Rest { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/info response.
|
||||
/// <para>
|
||||
/// <c>item_purchase_info</c> is an array of catalog entries with per-viewer <c>rest</c>
|
||||
/// (PurchaseLimit minus the viewer's counter for the relevant period).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>user_card_pack_ticket_list</c> is the FULL set of card-pack-ticket items (catalog
|
||||
/// Items.Type == 2) joined with the viewer's owned counts — even zero counts are emitted, as
|
||||
/// the client's parser unconditionally calls <c>PlayerStaticData.UpdateItemNum(item_id, number)</c>
|
||||
/// for every entry to refresh its in-memory mapping.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ItemPurchaseInfoResponse
|
||||
{
|
||||
[JsonPropertyName("item_purchase_info")]
|
||||
[Key("item_purchase_info")]
|
||||
public List<ItemPurchaseEntryDto> ItemPurchaseInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("user_card_pack_ticket_list")]
|
||||
[Key("user_card_pack_ticket_list")]
|
||||
public List<UserCardPackTicketDto> UserCardPackTicketList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class ItemPurchaseEntryDto
|
||||
{
|
||||
[JsonPropertyName("purchase_id")]
|
||||
[Key("purchase_id")]
|
||||
public int PurchaseId { get; set; }
|
||||
|
||||
[JsonPropertyName("require_item_type")]
|
||||
[Key("require_item_type")]
|
||||
public int RequireItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("require_item_id")]
|
||||
[Key("require_item_id")]
|
||||
public long RequireItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("require_item_num")]
|
||||
[Key("require_item_num")]
|
||||
public int RequireItemNum { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_name")]
|
||||
[Key("purchase_name")]
|
||||
public string PurchaseName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("purchase_item_type")]
|
||||
[Key("purchase_item_type")]
|
||||
public int PurchaseItemType { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_item_id")]
|
||||
[Key("purchase_item_id")]
|
||||
public long PurchaseItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("purchase_item_num")]
|
||||
[Key("purchase_item_num")]
|
||||
public int PurchaseItemNum { get; set; }
|
||||
|
||||
/// <summary>0 or 1 — client compares to int 0.</summary>
|
||||
[JsonPropertyName("is_monthly_reset")]
|
||||
[Key("is_monthly_reset")]
|
||||
public int IsMonthlyReset { get; set; }
|
||||
|
||||
[JsonPropertyName("rest")]
|
||||
[Key("rest")]
|
||||
public int Rest { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class UserCardPackTicketDto
|
||||
{
|
||||
[JsonPropertyName("item_id")]
|
||||
[Key("item_id")]
|
||||
public int ItemId { get; set; }
|
||||
|
||||
[JsonPropertyName("number")]
|
||||
[Key("number")]
|
||||
public int Number { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ItemPurchase;
|
||||
|
||||
/// <summary>
|
||||
/// /item_purchase/purchase response. <c>reward_list</c> uses the standard
|
||||
/// <see cref="RewardListEntry"/> shape: post-state totals for currencies, grant counts for
|
||||
/// items/cards. First entry is the debit-side post-state for the require_item; subsequent
|
||||
/// entries are the grant(s) from RewardGrantService.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ItemPurchasePurchaseResponse
|
||||
{
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
public List<RewardListEntry> RewardList { get; set; } = new();
|
||||
}
|
||||
Reference in New Issue
Block a user