feat(gift): unified GiftController over ViewerPresent + route aliases
This commit is contained in:
@@ -1,31 +1,29 @@
|
||||
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.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Mapping;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Tutorial-scoped gift endpoints. We do NOT implement a generic gift system here —
|
||||
/// only the /tutorial/gift_top and /tutorial/gift_receive aliases needed for the
|
||||
/// step 31 → 41 reward flow. A full gift inbox is future work; if/when needed,
|
||||
/// add /gift/top and /gift/receive_gift aliases to this controller.
|
||||
/// 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
|
||||
{
|
||||
/// <summary>The hardcoded tutorial gift bundle every fresh viewer sees at step 31.</summary>
|
||||
public static readonly IReadOnlyList<PresentDto> TutorialGifts = new[]
|
||||
{
|
||||
new PresentDto { PresentId = "71478626", RewardType = "1", RewardDetailId = "0", RewardCount = "400", Message = "For completing the tutorial" },
|
||||
new PresentDto { PresentId = "71478627", RewardType = "9", RewardDetailId = "0", RewardCount = "100", Message = "For completing the tutorial" },
|
||||
new PresentDto { PresentId = "71478628", RewardType = "4", RewardDetailId = "1", RewardCount = "3", Message = "For completing the tutorial", ItemType = 1 },
|
||||
new PresentDto { PresentId = "71478629", RewardType = "4", RewardDetailId = "80001", RewardCount = "40", Message = "For completing the tutorial", ItemType = 2 },
|
||||
new PresentDto { PresentId = "71478630", RewardType = "4", RewardDetailId = "90001", RewardCount = "1", Message = "For completing the tutorial", ItemType = 2 },
|
||||
};
|
||||
private const int PageSize = 30;
|
||||
private const int GiftReceiveTutorialStep = 41;
|
||||
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IInventoryService _inv;
|
||||
@@ -36,180 +34,153 @@ public class GiftController : SVSimController
|
||||
_inv = inv;
|
||||
}
|
||||
|
||||
[HttpPost("/gift/top")]
|
||||
[HttpPost("/tutorial/gift_top")]
|
||||
public async Task<ActionResult<GiftTopResponse>> TutorialGiftTop([FromBody] GiftTopRequest request)
|
||||
public async Task<ActionResult<GiftTopResponse>> Top([FromBody] GiftTopRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var claimedList = await _db.ViewerClaimedTutorialGifts
|
||||
.Where(g => g.ViewerId == viewerId)
|
||||
.Select(g => g.PresentId)
|
||||
.ToListAsync();
|
||||
var claimed = new HashSet<string>(claimedList);
|
||||
|
||||
var nowString = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
var presents = TutorialGifts
|
||||
.Where(p => !claimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
var history = TutorialGifts
|
||||
.Where(p => claimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, request.Page);
|
||||
|
||||
return new GiftTopResponse
|
||||
{
|
||||
PresentList = presents,
|
||||
PresentHistoryList = history,
|
||||
LimitOverPresentList = new(),
|
||||
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 async Task<ActionResult<GiftReceiveResponse>> TutorialGiftReceive([FromBody] GiftReceiveRequest request)
|
||||
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 requestedIds = request.PresentIdArray.ToHashSet();
|
||||
var requested = request.PresentIdArray.ToHashSet();
|
||||
var state = request.State; // 1 = MAIL_READ (claim), 3 = MAIL_DELETE
|
||||
|
||||
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
|
||||
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
|
||||
.Select(g => g.PresentId)
|
||||
// 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();
|
||||
var alreadyClaimed = new HashSet<string>(alreadyClaimedList);
|
||||
|
||||
var toClaim = TutorialGifts
|
||||
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
|
||||
.ToList();
|
||||
|
||||
// Open inventory tx with MissionData loaded for tutorial-step advance.
|
||||
await using var tx = await _inv.BeginAsync(viewerId, configure:
|
||||
cfg => cfg.WithInclude(v => v.MissionData));
|
||||
|
||||
// Apply grants via tx. Collect post-state per (type, detailId) for reward_list.
|
||||
// Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies
|
||||
// only one entry is returned; for cards the cascade may return more entries (card + cosmetics).
|
||||
// reward_list must carry post-state totals (client does direct assignment).
|
||||
var rewardListEntries = new List<GiftRewardListEntry>();
|
||||
foreach (var p in toClaim)
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var p in targets)
|
||||
{
|
||||
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
|
||||
var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
|
||||
// Use the first granted entry's post-state for the top-level gift reward_list entry.
|
||||
// Gift rewards are currencies and items only (no cards in TutorialGifts), so granted
|
||||
// always has exactly one element. The post-state total is already correct from tx.
|
||||
if (granted.Count > 0)
|
||||
if (state == 1)
|
||||
{
|
||||
rewardListEntries.Add(new GiftRewardListEntry
|
||||
var granted = await tx.GrantAsync(
|
||||
(UserGoodsType)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)
|
||||
{
|
||||
RewardType = p.RewardType,
|
||||
RewardId = p.RewardDetailId,
|
||||
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
});
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
|
||||
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
|
||||
// viewers who are already past step 41.
|
||||
const int GiftReceiveTutorialStep = 41;
|
||||
if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Persist claim receipts inside the same tx.
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var p in toClaim)
|
||||
{
|
||||
_db.ViewerClaimedTutorialGifts.Add(new SVSim.Database.Models.ViewerClaimedTutorialGift
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
PresentId = p.PresentId,
|
||||
ClaimedAt = now,
|
||||
});
|
||||
}
|
||||
await tx.CommitAsync();
|
||||
await tx.CommitAsync(); // throws DbUpdateConcurrencyException on RowVersion conflict
|
||||
|
||||
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
var allClaimedList = await _db.ViewerClaimedTutorialGifts
|
||||
.Where(g => g.ViewerId == viewerId)
|
||||
.Select(g => g.PresentId)
|
||||
.ToListAsync();
|
||||
var allClaimed = new HashSet<string>(allClaimedList);
|
||||
// Rebuild the inbox window (page 1) — the client wipes its local lists and rebuilds
|
||||
// from these.
|
||||
var (unclaimed, history) = await ReadTopWindowAsync(viewerId, page: 1);
|
||||
|
||||
// Derive presentList/historyList up front so IsUnreceivedPresent can read the count
|
||||
// without re-filtering. unclaimedPresents are the gifts still on offer after this call;
|
||||
// claimedPresents are everything the viewer has ever received (this call + prior calls).
|
||||
var unclaimedPresents = TutorialGifts
|
||||
.Where(p => !allClaimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
var claimedPresents = TutorialGifts
|
||||
.Where(p => allClaimed.Contains(p.PresentId))
|
||||
.Select(p => Clone(p, nowString))
|
||||
.ToList();
|
||||
// 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(),
|
||||
// Echo only the ids actually granted by THIS call. Building this from `requestedIds`
|
||||
// would falsely confirm a re-grant on idempotent retries: the client would re-show
|
||||
// the "received N gifts" popup and direct-assign the same post-state totals it already
|
||||
// applied, double-toasting the user. Sort ascending to match the prod-capture order.
|
||||
ReceivedIds = toClaim
|
||||
.Select(p => p.PresentId)
|
||||
.OrderBy(x => x)
|
||||
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(),
|
||||
// Same idempotency contract: only the gifts granted in THIS call belong in the
|
||||
// per-reward summary list. The client uses this to drive the +N popups.
|
||||
TotalReceiveCountList = toClaim
|
||||
.Select(p => new TotalReceiveCountDto
|
||||
|
||||
// Per-gift summary for the "+N received" popup. Empty on state=3.
|
||||
TotalReceiveCountList = (state == 1 ? targets : Enumerable.Empty<ViewerPresent>())
|
||||
.Select(t => new TotalReceiveCountDto
|
||||
{
|
||||
RewardType = int.Parse(p.RewardType),
|
||||
RewardDetailId = long.Parse(p.RewardDetailId),
|
||||
RewardCount = long.Parse(p.RewardCount),
|
||||
ItemType = p.ItemType ?? 0,
|
||||
IsUsable = true,
|
||||
RewardType = t.RewardType,
|
||||
RewardDetailId = t.RewardDetailId,
|
||||
RewardCount = t.RewardCount,
|
||||
ItemType = t.ItemType ?? 0,
|
||||
IsUsable = true,
|
||||
}).ToList(),
|
||||
PresentList = unclaimedPresents,
|
||||
PresentHistoryList = claimedPresents,
|
||||
// True when there are still unclaimed gifts on offer — drives the inbox badge state.
|
||||
// Hardcoding false hid the badge after partial claims even though present_list still
|
||||
// carried unclaimed entries.
|
||||
IsUnreceivedPresent = unclaimedPresents.Count > 0,
|
||||
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
|
||||
// See project memory: project_wire_reward_list_post_state.
|
||||
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
|
||||
|
||||
PresentList = unclaimed.Select(PresentMapper.ToWire).ToList(),
|
||||
PresentHistoryList = history.Select(PresentMapper.ToWire).ToList(),
|
||||
IsUnreceivedPresent = stillUnclaimed,
|
||||
RewardList = rewardListEntries,
|
||||
// Echo the persisted state, not a hardcoded 41. The state may already be past 41
|
||||
// for replay/edge-case calls (the Math.Max-preserve block above keeps it stable);
|
||||
// emitting 41 anyway would surface a regressed step to the client and desync the
|
||||
// tutorial-state machine.
|
||||
|
||||
// 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
|
||||
private async Task<(List<ViewerPresent> Unclaimed, List<ViewerPresent> History)> ReadTopWindowAsync(
|
||||
long viewerId, int page)
|
||||
{
|
||||
1 => UserGoodsType.Crystal,
|
||||
4 => UserGoodsType.Item,
|
||||
9 => UserGoodsType.Rupy,
|
||||
_ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"),
|
||||
};
|
||||
int pageOneIndexed = Math.Max(1, page);
|
||||
int skip = (pageOneIndexed - 1) * PageSize;
|
||||
|
||||
private static PresentDto Clone(PresentDto p, string createTime) => new()
|
||||
{
|
||||
PresentId = p.PresentId,
|
||||
RewardType = p.RewardType,
|
||||
RewardDetailId = p.RewardDetailId,
|
||||
RewardCount = p.RewardCount,
|
||||
ConditionNumber = p.ConditionNumber,
|
||||
PresentLimitType = p.PresentLimitType,
|
||||
RewardLimitTime = p.RewardLimitTime,
|
||||
CreateTime = createTime,
|
||||
ItemType = p.ItemType,
|
||||
Message = p.Message,
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user