219 lines
8.7 KiB
C#
219 lines
8.7 KiB
C#
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;
|
|
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;
|
|
private readonly ICurrencySpendService _spend;
|
|
|
|
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
|
|
{
|
|
_db = db;
|
|
_rewards = rewards;
|
|
_time = time;
|
|
_spend = spend;
|
|
}
|
|
|
|
[HttpPost("info")]
|
|
public async Task<ActionResult<ItemPurchaseInfoResponse>> Info(BaseRequest _)
|
|
{
|
|
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 = await 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 async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
|
|
Viewer viewer, UserGoodsType type, long detailId, int num)
|
|
{
|
|
switch (type)
|
|
{
|
|
case UserGoodsType.RedEther:
|
|
{
|
|
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num);
|
|
if (!r.Success) return (null, "insufficient_red_ether");
|
|
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
|
}
|
|
case UserGoodsType.Crystal:
|
|
{
|
|
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num);
|
|
if (!r.Success) return (null, "insufficient_crystals");
|
|
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
|
|
}
|
|
case UserGoodsType.Rupy:
|
|
{
|
|
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num);
|
|
if (!r.Success) return (null, "insufficient_rupees");
|
|
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, 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);
|
|
}
|