[Timestamp] byte[] doesn't work under SQLite (the test backend) — EF expects the DB to populate it on insert, but SQLite has no equivalent of Postgres's xmin. The WHERE Status = Unclaimed filter plus IInventoryService's viewer-level concurrency is the practical defense; RowVersion was only a backstop. Regenerated the migration without the RowVersion column. Wire reward_type on the gift endpoint uses a gift-specific scheme that diverges from UserGoodsType for currencies: wire 1 = Crystal (enum=2), wire 9 = Rupy (enum=9), wire 4 = Item (enum=4). A naked cast resolves wire 1 to UserGoodsType.RedEther and silently grants the wrong wallet — restored the explicit WireRewardTypeToUserGoodsType map from the old tutorial controller. Retrofits existing GiftControllerTests to call SeedTutorialPresentsAsync on the new helper (RegisterViewer doesn't auto-seed; only the prod signup path does). All 7 existing tests pass.
199 lines
8.5 KiB
C#
199 lines
8.5 KiB
C#
using System.Globalization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SVSim.Database;
|
|
using SVSim.Database.Enums;
|
|
using SVSim.Database.Models;
|
|
using SVSim.Database.Services.Inventory;
|
|
using SVSim.EmulatedEntrypoint.Mapping;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
|
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
|
|
|
|
namespace SVSim.EmulatedEntrypoint.Controllers;
|
|
|
|
/// <summary>
|
|
/// Persistent gift inbox. /gift/top + /tutorial/gift_top are pure URL aliases over the
|
|
/// same ViewerPresent query; /gift/receive_gift + /tutorial/gift_receive share a single
|
|
/// ReceiveImpl whose only divergence is the route-gated tutorial-state bump.
|
|
///
|
|
/// Tutorial gifts are seeded as real ViewerPresent rows during /tool/signup
|
|
/// (see ViewerRepository.RegisterAnonymousViewer) — this controller carries no static
|
|
/// gift catalog.
|
|
/// </summary>
|
|
public class GiftController : SVSimController
|
|
{
|
|
private const int PageSize = 30;
|
|
private const int GiftReceiveTutorialStep = 41;
|
|
|
|
private readonly SVSimDbContext _db;
|
|
private readonly IInventoryService _inv;
|
|
|
|
public GiftController(SVSimDbContext db, IInventoryService inv)
|
|
{
|
|
_db = db;
|
|
_inv = inv;
|
|
}
|
|
|
|
[HttpPost("/gift/top")]
|
|
[HttpPost("/tutorial/gift_top")]
|
|
public async Task<ActionResult<GiftTopResponse>> Top([FromBody] GiftTopRequest request)
|
|
{
|
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
|
|
|
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, request.Page);
|
|
|
|
return new GiftTopResponse
|
|
{
|
|
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
|
|
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
|
|
LimitOverPresentList = new(), // expiration sweep deferred — always [] for now
|
|
};
|
|
}
|
|
|
|
[HttpPost("/gift/receive_gift")]
|
|
public Task<ActionResult<GiftReceiveResponse>> Receive([FromBody] GiftReceiveRequest r)
|
|
=> ReceiveImpl(r, advanceTutorial: false);
|
|
|
|
[HttpPost("/tutorial/gift_receive")]
|
|
public Task<ActionResult<GiftReceiveResponse>> TutorialReceive([FromBody] GiftReceiveRequest r)
|
|
=> ReceiveImpl(r, advanceTutorial: true);
|
|
|
|
private async Task<ActionResult<GiftReceiveResponse>> ReceiveImpl(
|
|
GiftReceiveRequest request, bool advanceTutorial)
|
|
{
|
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
|
|
|
var requested = request.PresentIdArray.ToHashSet();
|
|
var state = request.State; // 1 = MAIL_READ (claim), 3 = MAIL_DELETE
|
|
|
|
// Pull only currently-Unclaimed rows matching the request — already-Claimed /
|
|
// Deleted / Expired rows are silently ignored (idempotent retry semantics).
|
|
var targets = await _db.ViewerPresents
|
|
.Where(p => p.ViewerId == viewerId
|
|
&& p.Status == PresentStatus.Unclaimed
|
|
&& requested.Contains(p.PresentId))
|
|
.ToListAsync();
|
|
|
|
await using var tx = await _inv.BeginAsync(viewerId, configure:
|
|
cfg => cfg.WithInclude(v => v.MissionData));
|
|
|
|
var rewardListEntries = new List<GiftRewardListEntry>();
|
|
var now = DateTime.UtcNow;
|
|
|
|
foreach (var p in targets)
|
|
{
|
|
if (state == 1)
|
|
{
|
|
// Wire reward_type on the gift endpoint follows a gift-specific scheme that
|
|
// diverges from UserGoodsType for currencies: wire "1" means Crystal (enum=2),
|
|
// wire "9" means Rupy (enum=9), wire "4" means Item (enum=4). A naked cast would
|
|
// resolve wire 1 -> UserGoodsType.RedEther and silently grant the wrong wallet.
|
|
var granted = await tx.GrantAsync(
|
|
WireRewardTypeToUserGoodsType(p.RewardType),
|
|
p.RewardDetailId,
|
|
(int)p.RewardCount);
|
|
|
|
// reward_list carries POST-STATE TOTALS (client does direct assignment).
|
|
// See project_wire_reward_list_post_state. GrantAsync already returns post-state.
|
|
if (granted.Count > 0)
|
|
{
|
|
rewardListEntries.Add(new GiftRewardListEntry
|
|
{
|
|
RewardType = p.RewardType.ToString(CultureInfo.InvariantCulture),
|
|
RewardId = p.RewardDetailId.ToString(CultureInfo.InvariantCulture),
|
|
RewardNum = granted[0].RewardNum.ToString(CultureInfo.InvariantCulture),
|
|
});
|
|
}
|
|
|
|
p.Status = PresentStatus.Claimed;
|
|
p.ClaimedAt = now;
|
|
}
|
|
else if (state == 3)
|
|
{
|
|
// MAIL_DELETE: no grant, no reward_list entry, no history. Tombstone the
|
|
// row so re-deletes are idempotent under the same WHERE-Unclaimed filter.
|
|
p.Status = PresentStatus.Deleted;
|
|
p.ClaimedAt = now; // overload as "decided-at" — tombstone never reaches wire
|
|
}
|
|
}
|
|
|
|
// Tutorial step advance — route-gated, no Source/state checks. Preserve-max so
|
|
// replays don't downgrade viewers already past 41.
|
|
if (advanceTutorial && tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
|
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
|
|
|
|
await tx.CommitAsync(); // throws DbUpdateConcurrencyException on RowVersion conflict
|
|
|
|
// Rebuild the inbox window (page 1) — the client wipes its local lists and rebuilds
|
|
// from these.
|
|
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, page: 1);
|
|
|
|
// is_unreceived_present drives the home-screen inbox badge — must be the DB count
|
|
// post-commit, NOT hardcoded false (hiding the badge after partial claims).
|
|
var stillUnclaimed = await _db.ViewerPresents
|
|
.AnyAsync(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed);
|
|
|
|
return new GiftReceiveResponse
|
|
{
|
|
CardList = new(), // capture is []; reward_list carries the grants
|
|
|
|
// Echo only ids actually transitioned by THIS call — NOT requested ids, which
|
|
// would re-fire the "received N gifts" popup on replay.
|
|
ReceivedIds = targets
|
|
.Select(t => t.PresentId)
|
|
.OrderBy(x => x, StringComparer.Ordinal)
|
|
.ToList(),
|
|
|
|
// Per-gift summary for the "+N received" popup. Empty on state=3.
|
|
TotalReceiveCountList = (state == 1 ? targets : Enumerable.Empty<ViewerPresent>())
|
|
.Select(t => new TotalReceiveCountDto
|
|
{
|
|
RewardType = t.RewardType,
|
|
RewardDetailId = t.RewardDetailId,
|
|
RewardCount = t.RewardCount,
|
|
ItemType = t.ItemType ?? 0,
|
|
IsUsable = true,
|
|
}).ToList(),
|
|
|
|
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
|
|
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
|
|
IsUnreceivedPresent = stillUnclaimed,
|
|
RewardList = rewardListEntries,
|
|
|
|
// Echo persisted state, not a hardcoded 41 — preserve-max above keeps it stable.
|
|
TutorialStep = tx.Viewer.MissionData.TutorialState,
|
|
};
|
|
}
|
|
|
|
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
|
|
{
|
|
1 => UserGoodsType.Crystal,
|
|
4 => UserGoodsType.Item,
|
|
9 => UserGoodsType.Rupy,
|
|
_ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"),
|
|
};
|
|
|
|
private async Task<(List<ViewerPresent> Unclaimed, List<ViewerPresent> History)> ReadTopWindowAsync(
|
|
long viewerId, int page)
|
|
{
|
|
int pageOneIndexed = Math.Max(1, page);
|
|
int skip = (pageOneIndexed - 1) * PageSize;
|
|
|
|
// Unclaimed: chronological (oldest first — capture order matches this).
|
|
var unclaimed = await _db.ViewerPresents
|
|
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed)
|
|
.OrderBy(p => p.CreatedAt).ThenBy(p => p.Id)
|
|
.Skip(skip).Take(PageSize)
|
|
.ToListAsync();
|
|
|
|
// History: most-recent-first (standard inbox UX).
|
|
var history = await _db.ViewerPresents
|
|
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Claimed)
|
|
.OrderByDescending(p => p.ClaimedAt).ThenByDescending(p => p.Id)
|
|
.Skip(skip).Take(PageSize)
|
|
.ToListAsync();
|
|
|
|
return (unclaimed, history);
|
|
}
|
|
}
|