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:
gamer147
2026-05-27 22:41:02 -04:00
parent f237851e42
commit 559a170957
16 changed files with 4354 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row of the /item_purchase/info catalog — an exchange the user can perform N times per
/// period (monthly or lifetime) by spending <c>RequireItem*</c> to acquire <c>PurchaseItem*</c>.
/// PK = wire <c>purchase_id</c>.
/// <para>
/// Both sides reference <see cref="Enums.UserGoodsType"/>. Captures show the common shape is
/// currency-for-item (RedEther 5000 → Seer's Globe ×1) or item-for-item (Orb Shard ×5 →
/// Seer's Globe ×1). Per-viewer remaining quota lives in
/// <see cref="ViewerEventCounter"/> keyed by <c>"item_purchase:{Id}"</c>.
/// </para>
/// </summary>
public class ItemPurchaseCatalogEntry : BaseEntity<int>
{
public int RequireItemType { get; set; }
public long RequireItemId { get; set; }
public int RequireItemNum { get; set; }
public int PurchaseItemType { get; set; }
public long PurchaseItemId { get; set; }
public int PurchaseItemNum { get; set; }
/// <summary>
/// SystemText-ready display name. May be empty — the client falls back to a templated name
/// built from <c>UserGoods.getUserGoodsName + count</c> via SystemText key "Shop_0132".
/// </summary>
public string PurchaseName { get; set; } = string.Empty;
/// <summary>True → quota resets at the start of each JST month. False → lifetime quota.</summary>
public bool IsMonthlyReset { get; set; }
/// <summary>Per-period purchase cap. Wire <c>rest</c> = max(0, PurchaseLimit - counter).</summary>
public int PurchaseLimit { get; set; }
public bool IsEnabled { get; set; }
}