From 03ebe93c46740978be33a7736b338a6662ce3ee4 Mon Sep 17 00:00:00 2001 From: Atralupus Date: Wed, 15 Jan 2025 22:24:37 +0900 Subject: [PATCH] Use cached block index, season, round --- .../ParticipantControllerTests.cs | 0 ArenaService/Constants/BattleStatus.cs | 8 + ArenaService/Constants/TxStatus.cs | 10 ++ .../AvailableOpponentController.cs | 59 ++++--- ArenaService/Controllers/BattleController.cs | 59 ++++--- .../Controllers/LeaderboardController.cs | 18 +- ArenaService/Controllers/SeasonController.cs | 9 + ArenaService/Dtos/BattleLogResponse.cs | 2 + .../Extensions/BattleLogExtensions.cs | 6 + ArenaService/Extensions/TxStatusExtensions.cs | 1 + ArenaService/Models/BattleLog.cs | 10 +- ArenaService/Processor/BattleTaskProcessor.cs | 19 ++- .../CalcAvailableOpponentsProcessor.cs | 18 +- .../Repositories/BattleLogRepository.cs | 1 + .../Repositories/RankingRepository.cs | 78 +++------ .../Repositories/SeasonCacheRepository.cs | 113 +++++++++++++ .../RegistrationService.cs} | 45 ++--- ArenaService/Setup.cs | 4 + ArenaService/Worker/SeasonCachingWorker.cs | 160 ++++++++++++++++++ 19 files changed, 452 insertions(+), 168 deletions(-) rename ArenaService.Tests/{Controllers => Services}/ParticipantControllerTests.cs (100%) create mode 100644 ArenaService/Constants/BattleStatus.cs create mode 100644 ArenaService/Constants/TxStatus.cs create mode 100644 ArenaService/Repositories/SeasonCacheRepository.cs rename ArenaService/{Controllers/ParticipantController.cs => Services/RegistrationService.cs} (51%) create mode 100644 ArenaService/Worker/SeasonCachingWorker.cs diff --git a/ArenaService.Tests/Controllers/ParticipantControllerTests.cs b/ArenaService.Tests/Services/ParticipantControllerTests.cs similarity index 100% rename from ArenaService.Tests/Controllers/ParticipantControllerTests.cs rename to ArenaService.Tests/Services/ParticipantControllerTests.cs diff --git a/ArenaService/Constants/BattleStatus.cs b/ArenaService/Constants/BattleStatus.cs new file mode 100644 index 0000000..d39e374 --- /dev/null +++ b/ArenaService/Constants/BattleStatus.cs @@ -0,0 +1,8 @@ +namespace ArenaService.Constants; + +public enum BattleStatus +{ + PENDING, + TRACKING, + COMPLETED, +} diff --git a/ArenaService/Constants/TxStatus.cs b/ArenaService/Constants/TxStatus.cs new file mode 100644 index 0000000..6d954bc --- /dev/null +++ b/ArenaService/Constants/TxStatus.cs @@ -0,0 +1,10 @@ +namespace ArenaService.Constants; + +public enum TxStatus +{ + INVALID, + STAGING, + SUCCESS, + FAILURE, + INCLUDED, +} diff --git a/ArenaService/Controllers/AvailableOpponentController.cs b/ArenaService/Controllers/AvailableOpponentController.cs index b34bdad..11ec4a4 100644 --- a/ArenaService/Controllers/AvailableOpponentController.cs +++ b/ArenaService/Controllers/AvailableOpponentController.cs @@ -11,25 +11,25 @@ namespace ArenaService.Controllers; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -[Route("seasons/{seasonId}/available-opponents")] +[Route("available-opponents")] [ApiController] public class AvailableOpponentController : ControllerBase { private readonly IBackgroundJobClient _jobClient; private readonly IAvailableOpponentRepository _availableOpponentRepo; private readonly IParticipantRepository _participantRepo; - private readonly ISeasonRepository _seasonRepo; + private readonly ISeasonCacheRepository _seasonCacheRepo; public AvailableOpponentController( IAvailableOpponentRepository availableOpponentRepo, IParticipantRepository participantRepo, - ISeasonRepository seasonRepo, + ISeasonCacheRepository seasonCacheRepo, IBackgroundJobClient jobClient ) { _availableOpponentRepo = availableOpponentRepo; _participantRepo = participantRepo; - _seasonRepo = seasonRepo; + _seasonCacheRepo = seasonCacheRepo; _jobClient = jobClient; } @@ -37,40 +37,35 @@ IBackgroundJobClient jobClient [Authorize(Roles = "User", AuthenticationSchemes = "ES256K")] [ProducesResponseType(typeof(AvailableOpponentsResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] public async Task< - Results, Ok> - > GetAvailableOpponents(int seasonId, long blockIndex) + Results< + UnauthorizedHttpResult, + NotFound, + StatusCodeHttpResult, + Ok + > + > GetAvailableOpponents() { var avatarAddress = HttpContext.User.RequireAvatarAddress(); - var season = await _seasonRepo.GetSeasonAsync(seasonId); + var currentSeason = await _seasonCacheRepo.GetSeasonAsync(); + var currentRound = await _seasonCacheRepo.GetRoundAsync(); - if (season == null) + if (currentSeason is null || currentRound is null) { - return TypedResults.NotFound("No season found."); - } - - var currentRound = season.Rounds.FirstOrDefault(ai => - ai.StartBlock <= blockIndex && ai.EndBlock >= blockIndex - ); - - if (currentRound == null) - { - return TypedResults.NotFound( - $"No active arena interval found for block index {blockIndex}." - ); + return TypedResults.StatusCode(StatusCodes.Status503ServiceUnavailable); } var availableOpponents = await _availableOpponentRepo.GetAvailableOpponents( avatarAddress, - seasonId, - currentRound.Id + currentSeason.Value.Id, + currentRound.Value.Id ); if (availableOpponents == null) { - return TypedResults.NotFound($"Available opponents not found."); + return TypedResults.NotFound("No available opponents found for the current round."); } var opponents = new List(); @@ -96,13 +91,23 @@ public async Task< [Authorize(Roles = "User", AuthenticationSchemes = "ES256K")] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(typeof(UnauthorizedHttpResult), StatusCodes.Status401Unauthorized)] - [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] - public Results, Ok> RequestResetOpponents(int seasonId) + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task< + Results, StatusCodeHttpResult, Ok> + > RequestResetOpponents() { var avatarAddress = HttpContext.User.RequireAvatarAddress(); + var currentSeason = await _seasonCacheRepo.GetSeasonAsync(); + var currentRound = await _seasonCacheRepo.GetRoundAsync(); + + if (currentSeason is null || currentRound is null) + { + return TypedResults.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + _jobClient.Enqueue(processor => - processor.ProcessAsync(avatarAddress, seasonId) + processor.ProcessAsync(avatarAddress, currentSeason.Value.Id, currentRound.Value.Id) ); return TypedResults.Ok(); diff --git a/ArenaService/Controllers/BattleController.cs b/ArenaService/Controllers/BattleController.cs index 55ce7c5..5de0e6f 100644 --- a/ArenaService/Controllers/BattleController.cs +++ b/ArenaService/Controllers/BattleController.cs @@ -10,42 +10,53 @@ namespace ArenaService.Controllers; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; -[Route("seasons/{seasonId}/battle")] +[Route("battle")] [ApiController] public class BattleController : ControllerBase { private readonly IBackgroundJobClient _jobClient; - private readonly IAvailableOpponentRepository _availableOpponentRepo; - private readonly IParticipantRepository _participantRepo; private readonly IBattleLogRepository _battleLogRepo; + private readonly ISeasonCacheRepository _seasonCacheRepo; public BattleController( - IAvailableOpponentRepository availableOpponentService, - IParticipantRepository participantService, IBattleLogRepository battleLogRepo, + ISeasonCacheRepository seasonCacheRepo, IBackgroundJobClient jobClient ) { - _availableOpponentRepo = availableOpponentService; - _participantRepo = participantService; _battleLogRepo = battleLogRepo; _jobClient = jobClient; + _seasonCacheRepo = seasonCacheRepo; } [HttpGet("token")] [Authorize(Roles = "User", AuthenticationSchemes = "ES256K")] [ProducesResponseType(typeof(BattleTokenResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(UnauthorizedHttpResult), StatusCodes.Status401Unauthorized)] - [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] public async Task< - Results, Ok> - > CreateBattleToken(int seasonId, string opponentAvatarAddress) + Results< + UnauthorizedHttpResult, + NotFound, + StatusCodeHttpResult, + Ok + > + > CreateBattleToken(string opponentAvatarAddress) { var avatarAddress = HttpContext.User.RequireAvatarAddress(); + + var currentSeason = await _seasonCacheRepo.GetSeasonAsync(); + var currentRound = await _seasonCacheRepo.GetRoundAsync(); + + if (currentSeason is null || currentRound is null) + { + return TypedResults.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + var defenderAvatarAddress = new Address(opponentAvatarAddress); var battleLog = await _battleLogRepo.AddBattleLogAsync( - seasonId, + currentSeason.Value.Id, avatarAddress, defenderAvatarAddress, "token" @@ -59,15 +70,23 @@ public async Task< [HttpPost("request")] [Authorize(Roles = "User", AuthenticationSchemes = "ES256K")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(typeof(UnauthorizedHttpResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] - public Results, Ok> RequestBattle( - string txId, - int logId, - int seasonId + public async Task, Ok>> RequestBattle( + [FromBody] string txId, + [FromBody] int battleLogId ) { - _jobClient.Enqueue(processor => processor.ProcessAsync(txId, logId)); + var battleLog = await _battleLogRepo.GetBattleLogAsync(battleLogId); + + if (battleLog is null) + { + return TypedResults.NotFound($"Battle log with ID {battleLogId} not found."); + } + + _jobClient.Enqueue(processor => + processor.ProcessAsync(txId, battleLogId) + ); return TypedResults.Ok(); } @@ -75,17 +94,17 @@ int seasonId [HttpGet("{battleLogId}")] [Authorize(Roles = "User", AuthenticationSchemes = "ES256K")] [ProducesResponseType(typeof(BattleLogResponse), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(UnauthorizedHttpResult), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] public async Task< Results, Ok> - > GetBattleLog(int battleLogId, int seasonId) + > GetBattleLog(int battleLogId) { var battleLog = await _battleLogRepo.GetBattleLogAsync(battleLogId); if (battleLog is null) { - return TypedResults.NotFound("Not battleLog."); + return TypedResults.NotFound($"Battle log with ID {battleLogId} not found."); } return TypedResults.Ok(battleLog.ToResponse()); diff --git a/ArenaService/Controllers/LeaderboardController.cs b/ArenaService/Controllers/LeaderboardController.cs index 3038f68..3683049 100644 --- a/ArenaService/Controllers/LeaderboardController.cs +++ b/ArenaService/Controllers/LeaderboardController.cs @@ -23,16 +23,6 @@ IRankingRepository rankingRepository _rankingRepository = rankingRepository; } - // [HttpGet] - // public async Task, Ok>> GetLeaderboard( - // int seasonId, - // int offset, - // int limit - // ) - // { - // var leaderboard = await _leaderboardRepository.GetLeaderboard(seasonId, offset, limit); - // } - [HttpGet("participants/{avatarAddress}")] [ProducesResponseType(typeof(LeaderboardEntryResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -52,16 +42,12 @@ string avatarAddress return TypedResults.NotFound("Not participant user."); } - var rankingKey = $"ranking:season:{seasonId}"; - var rank = await _rankingRepository.GetRankAsync( - rankingKey, - participant.AvatarAddress, + new Address(participant.AvatarAddress), seasonId ); var score = await _rankingRepository.GetScoreAsync( - rankingKey, - participant.AvatarAddress, + new Address(participant.AvatarAddress), seasonId ); diff --git a/ArenaService/Controllers/SeasonController.cs b/ArenaService/Controllers/SeasonController.cs index c19820a..98b5879 100644 --- a/ArenaService/Controllers/SeasonController.cs +++ b/ArenaService/Controllers/SeasonController.cs @@ -36,4 +36,13 @@ int blockIndex return TypedResults.Ok(season?.ToResponse()); } + + [HttpGet] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task>> GetSeasonByBlock() + { + var seasons = await _seasonRepo.GetAllSeasonsAsync(); + + return TypedResults.Ok(seasons.Select(s => s.ToResponse()).ToList()); + } } diff --git a/ArenaService/Dtos/BattleLogResponse.cs b/ArenaService/Dtos/BattleLogResponse.cs index 93ead55..bc64270 100644 --- a/ArenaService/Dtos/BattleLogResponse.cs +++ b/ArenaService/Dtos/BattleLogResponse.cs @@ -1,3 +1,4 @@ +using ArenaService.Constants; using ArenaService.Models; namespace ArenaService.Dtos; @@ -11,6 +12,7 @@ public class BattleLogResponse public required string DefenderAvatarAddress { get; set; } + public BattleStatus BattleStatus { get; set; } public string? TxId { get; set; } public string? TxStatus { get; set; } public bool? IsVictory { get; set; } diff --git a/ArenaService/Extensions/BattleLogExtensions.cs b/ArenaService/Extensions/BattleLogExtensions.cs index 1954d84..3ec8ce8 100644 --- a/ArenaService/Extensions/BattleLogExtensions.cs +++ b/ArenaService/Extensions/BattleLogExtensions.cs @@ -1,5 +1,6 @@ namespace ArenaService.Extensions; +using ArenaService.Constants; using ArenaService.Dtos; using ArenaService.Models; @@ -14,6 +15,11 @@ public static BattleLogResponse ToResponse(this BattleLog battleLog) AttackerAvatarAddress = battleLog.AttackerAvatarAddress, DefenderAvatarAddress = battleLog.DefenderAvatarAddress, TxId = battleLog.TxId, + BattleStatus = battleLog.TxStatus is null + ? BattleStatus.PENDING + : battleLog.IsVictory is null + ? BattleStatus.TRACKING + : BattleStatus.COMPLETED, TxStatus = battleLog.TxStatus.ToString(), IsVictory = battleLog.IsVictory, ParticipantScore = battleLog.Attacker.Score, diff --git a/ArenaService/Extensions/TxStatusExtensions.cs b/ArenaService/Extensions/TxStatusExtensions.cs index 9532724..d0dad63 100644 --- a/ArenaService/Extensions/TxStatusExtensions.cs +++ b/ArenaService/Extensions/TxStatusExtensions.cs @@ -1,5 +1,6 @@ namespace ArenaService.Extensions; +using ArenaService.Constants; using ArenaService.Models; public static class TxStatusExtensions diff --git a/ArenaService/Models/BattleLog.cs b/ArenaService/Models/BattleLog.cs index 9abdabe..d78744d 100644 --- a/ArenaService/Models/BattleLog.cs +++ b/ArenaService/Models/BattleLog.cs @@ -1,17 +1,9 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using ArenaService.Constants; namespace ArenaService.Models; -public enum TxStatus -{ - INVALID, - STAGING, - SUCCESS, - FAILURE, - INCLUDED, -} - [Table("battle_logs")] public class BattleLog { diff --git a/ArenaService/Processor/BattleTaskProcessor.cs b/ArenaService/Processor/BattleTaskProcessor.cs index c5f815d..8343082 100644 --- a/ArenaService/Processor/BattleTaskProcessor.cs +++ b/ArenaService/Processor/BattleTaskProcessor.cs @@ -16,18 +16,21 @@ public class BattleTaskProcessor private readonly ILogger _logger; private readonly IHeadlessClient _client; private readonly IBattleLogRepository _battleLogRepo; + private readonly IRankingRepository _rankingRepo; private readonly IParticipantRepository _participantRepo; public BattleTaskProcessor( ILogger logger, IHeadlessClient client, IBattleLogRepository battleLogRepo, + IRankingRepository rankingRepo, IParticipantRepository participantRepo ) { _logger = logger; _client = client; _battleLogRepo = battleLogRepo; + _rankingRepo = rankingRepo; _participantRepo = participantRepo; } @@ -94,14 +97,26 @@ await _battleLogRepo.UpdateBattleResultAsync( enemyScoreChange, 1 ); + var attackerAddress = new Address(battleLog.Attacker.AvatarAddress); + var defenderAddress = new Address(battleLog.Defender.AvatarAddress); await _participantRepo.UpdateScoreAsync( battleLog.SeasonId, - new Address(battleLog.Attacker.AvatarAddress), + attackerAddress, myScoreChange ); await _participantRepo.UpdateScoreAsync( battleLog.SeasonId, - new Address(battleLog.Defender.AvatarAddress), + defenderAddress, + enemyScoreChange + ); + await _rankingRepo.UpdateScoreAsync( + attackerAddress, + battleLog.SeasonId, + myScoreChange + ); + await _rankingRepo.UpdateScoreAsync( + defenderAddress, + battleLog.SeasonId, enemyScoreChange ); return; diff --git a/ArenaService/Processor/CalcAvailableOpponentsProcessor.cs b/ArenaService/Processor/CalcAvailableOpponentsProcessor.cs index d4f3cf9..0b00886 100644 --- a/ArenaService/Processor/CalcAvailableOpponentsProcessor.cs +++ b/ArenaService/Processor/CalcAvailableOpponentsProcessor.cs @@ -30,9 +30,9 @@ IRankingRepository rankingRepository _seasonRepo = seasonRepo; } - public async Task ProcessAsync(Address participantAvatarAddress, int seasonId) + public async Task ProcessAsync(Address avatarAddress, int seasonId, int roundId) { - _logger.LogInformation($"Calc ao: {participantAvatarAddress}, {seasonId}"); + _logger.LogInformation($"Calc ao: {avatarAddress}, {seasonId}"); var tipResponse = await _client.GetTipIndex.ExecuteAsync(); @@ -62,25 +62,19 @@ public async Task ProcessAsync(Address participantAvatarAddress, int seasonId) return; } - var rankingKey = $"ranking:season:{seasonId}"; - var myScore = await _rankingRepository.GetScoreAsync( - rankingKey, - participantAvatarAddress.ToHex(), - seasonId - ); + var myScore = await _rankingRepository.GetScoreAsync(avatarAddress, seasonId); var opponents = await _rankingRepository.GetRandomParticipantsTempAsync( - rankingKey, - participantAvatarAddress.ToHex(), + avatarAddress, seasonId, myScore.Value, 5 ); await _availableOpponentRepository.AddAvailableOpponents( - participantAvatarAddress, + avatarAddress, seasonId, currentRound.Id, - opponents.Select(o => o.ParticipantAvatarAddress).ToList() + opponents.Select(o => o.AvatarAddress).ToList() ); } } diff --git a/ArenaService/Repositories/BattleLogRepository.cs b/ArenaService/Repositories/BattleLogRepository.cs index a059aad..95a9c8c 100644 --- a/ArenaService/Repositories/BattleLogRepository.cs +++ b/ArenaService/Repositories/BattleLogRepository.cs @@ -1,5 +1,6 @@ namespace ArenaService.Repositories; +using ArenaService.Constants; using ArenaService.Data; using ArenaService.Models; using Libplanet.Crypto; diff --git a/ArenaService/Repositories/RankingRepository.cs b/ArenaService/Repositories/RankingRepository.cs index e2d3ff0..ea2769c 100644 --- a/ArenaService/Repositories/RankingRepository.cs +++ b/ArenaService/Repositories/RankingRepository.cs @@ -5,26 +5,18 @@ namespace ArenaService.Repositories; public interface IRankingRepository { - Task UpdateScoreAsync( - string leaderboardKey, - string participantAvatarAddress, - int seasonId, - int scoreChange - ); + Task UpdateScoreAsync(Address avatarAddress, int seasonId, int scoreChange); - Task GetRankAsync(string leaderboardKey, string participantAvatarAddress, int seasonId); + Task GetRankAsync(Address avatarAddress, int seasonId); - Task GetScoreAsync(string leaderboardKey, string participantAvatarAddress, int seasonId); + Task GetScoreAsync(Address avatarAddress, int seasonId); Task< - List<(int Rank, Address ParticipantAvatarAddress, int SeasonId, int Score)> - > GetRankingsWithPaginationAsync(string rankingKey, int pageNumber, int pageSize); + List<(int Rank, Address AvatarAddress, int SeasonId, int Score)> + > GetRankingsWithPaginationAsync(int seasonId, int pageNumber, int pageSize); - Task< - List<(Address ParticipantAvatarAddress, int SeasonId, int Score)> - > GetRandomParticipantsTempAsync( - string rankingKey, - string participantAvatarAddress, + Task> GetRandomParticipantsTempAsync( + Address avatarAddress, int seasonId, int score, int count @@ -33,6 +25,7 @@ int count public class RankingRepository : IRankingRepository { + private const string RankingKeyPrefix = "ranking:season:"; private readonly IDatabase _redis; public RankingRepository(IConnectionMultiplexer redis) @@ -40,56 +33,43 @@ public RankingRepository(IConnectionMultiplexer redis) _redis = redis.GetDatabase(); } - public async Task UpdateScoreAsync( - string leaderboardKey, - string participantAvatarAddress, - int seasonId, - int scoreChange - ) + public async Task UpdateScoreAsync(Address avatarAddress, int seasonId, int scoreChange) { await _redis.SortedSetIncrementAsync( - leaderboardKey, - $"participant:{participantAvatarAddress}:{seasonId}", + $"{RankingKeyPrefix}:{seasonId}", + $"participant:{avatarAddress.ToHex()}:{seasonId}", scoreChange ); } - public async Task GetRankAsync( - string rankingKey, - string participantAvatarAddress, - int seasonId - ) + public async Task GetRankAsync(Address avatarAddress, int seasonId) { var rank = await _redis.SortedSetRankAsync( - rankingKey, - $"participant:{participantAvatarAddress}:{seasonId}", + $"{RankingKeyPrefix}:{seasonId}", + $"participant:{avatarAddress.ToHex()}:{seasonId}", Order.Descending ); return rank.HasValue ? (int)rank.Value + 1 : null; } - public async Task GetScoreAsync( - string rankingKey, - string participantAvatarAddress, - int seasonId - ) + public async Task GetScoreAsync(Address avatarAddress, int seasonId) { var score = await _redis.SortedSetScoreAsync( - rankingKey, - $"participant:{participantAvatarAddress}:{seasonId}" + $"{RankingKeyPrefix}:{seasonId}", + $"participant:{avatarAddress.ToHex()}:{seasonId}" ); return score.HasValue ? (int)score.Value : null; } public async Task< - List<(int Rank, Address ParticipantAvatarAddress, int SeasonId, int Score)> - > GetRankingsWithPaginationAsync(string rankingKey, int pageNumber, int pageSize) + List<(int Rank, Address AvatarAddress, int SeasonId, int Score)> + > GetRankingsWithPaginationAsync(int seasonId, int pageNumber, int pageSize) { int start = (pageNumber - 1) * pageSize; int end = start + pageSize - 1; var rankedParticipants = await _redis.SortedSetRangeByRankWithScoresAsync( - rankingKey, + $"{RankingKeyPrefix}:{seasonId}", start, end, Order.Descending @@ -99,11 +79,11 @@ public async Task< .Select( (entry, index) => { - var participantAvatarAddress = entry.Element.ToString().Split(':')[1]; + var avatarAddress = entry.Element.ToString().Split(':')[1]; var seasonId = int.Parse(entry.Element.ToString().Split(':')[2]); return ( Rank: start + index + 1, - ParticipantAvatarAddress: new Address(participantAvatarAddress), + AvatarAddress: new Address(avatarAddress), SeasonId: seasonId, Score: (int)entry.Score ); @@ -113,20 +93,14 @@ public async Task< } public async Task< - List<(Address ParticipantAvatarAddress, int SeasonId, int Score)> - > GetRandomParticipantsTempAsync( - string rankingKey, - string participantAvatarAddress, - int seasonId, - int score, - int count - ) + List<(Address AvatarAddress, int SeasonId, int Score)> + > GetRandomParticipantsTempAsync(Address avatarAddress, int seasonId, int score, int count) { double minScore = score - 100; double maxScore = score + 100; var participants = await _redis.SortedSetRangeByScoreWithScoresAsync( - rankingKey, + $"{RankingKeyPrefix}:{seasonId}", minScore, maxScore, Exclude.None, @@ -140,7 +114,7 @@ int count var address = parts[1]; var participantSeasonId = int.Parse(parts[2]); - return address != participantAvatarAddress && participantSeasonId == seasonId; + return address != avatarAddress.ToHex() && participantSeasonId == seasonId; }) .Select(entry => { diff --git a/ArenaService/Repositories/SeasonCacheRepository.cs b/ArenaService/Repositories/SeasonCacheRepository.cs new file mode 100644 index 0000000..9ad45b8 --- /dev/null +++ b/ArenaService/Repositories/SeasonCacheRepository.cs @@ -0,0 +1,113 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using StackExchange.Redis; + +namespace ArenaService.Repositories; + +public interface ISeasonCacheRepository +{ + Task GetBlockIndexAsync(); + Task<(int Id, long StartBlock, long EndBlock)?> GetSeasonAsync(); + Task<(int Id, long StartBlock, long EndBlock)?> GetRoundAsync(); + Task SetBlockIndexAsync(long blockIndex); + Task SetSeasonAsync(int seasonId, long startBlock, long endBlock); + Task SetRoundAsync(int roundId, long startBlock, long endBlock); +} + +public class SeasonCacheRepository : ISeasonCacheRepository +{ + private readonly IDatabase _redis; + private const string PREFIX = "season_cache"; + private const string BlockIndexKey = "block_index"; + private const string SeasonKey = "season"; + private const string RoundKey = "round"; + + public SeasonCacheRepository(IConnectionMultiplexer redis) + { + _redis = redis.GetDatabase(); + } + + public async Task GetBlockIndexAsync() + { + var value = await _redis.StringGetAsync($"{PREFIX}:{BlockIndexKey}"); + return value.HasValue ? long.Parse(value) : null; + } + + public async Task<(int Id, long StartBlock, long EndBlock)?> GetSeasonAsync() + { + var value = await _redis.StringGetAsync($"{PREFIX}:{SeasonKey}"); + if (!value.HasValue) + { + return null; + } + + var seasonData = JsonSerializer.Deserialize(value); + return seasonData != null + ? (seasonData.Id, seasonData.StartBlock, seasonData.EndBlock) + : null; + } + + public async Task<(int Id, long StartBlock, long EndBlock)?> GetRoundAsync() + { + var value = await _redis.StringGetAsync($"{PREFIX}:{RoundKey}"); + if (!value.HasValue) + { + return null; + } + + var roundData = JsonSerializer.Deserialize(value); + return roundData != null ? (roundData.Id, roundData.StartBlock, roundData.EndBlock) : null; + } + + public async Task SetBlockIndexAsync(long blockIndex) + { + await _redis.StringSetAsync( + $"{PREFIX}:{BlockIndexKey}", + blockIndex.ToString(), + TimeSpan.FromMinutes(10) + ); + } + + public async Task SetSeasonAsync(int seasonId, long startBlock, long endBlock) + { + var seasonData = new CachedSeason + { + Id = seasonId, + StartBlock = startBlock, + EndBlock = endBlock + }; + + var json = JsonSerializer.Serialize(seasonData); + + await _redis.StringSetAsync($"{PREFIX}:{SeasonKey}", json, TimeSpan.FromDays(31)); + } + + public async Task SetRoundAsync(int roundId, long startBlock, long endBlock) + { + var roundData = new CachedRound + { + Id = roundId, + StartBlock = startBlock, + EndBlock = endBlock + }; + + var json = JsonSerializer.Serialize(roundData); + + await _redis.StringSetAsync($"{PREFIX}:{RoundKey}", json, TimeSpan.FromHours(12)); + } + + private class CachedSeason + { + public int Id { get; set; } + public long StartBlock { get; set; } + public long EndBlock { get; set; } + } + + private class CachedRound + { + public int Id { get; set; } + public long StartBlock { get; set; } + public long EndBlock { get; set; } + } +} diff --git a/ArenaService/Controllers/ParticipantController.cs b/ArenaService/Services/RegistrationService.cs similarity index 51% rename from ArenaService/Controllers/ParticipantController.cs rename to ArenaService/Services/RegistrationService.cs index 83d4ad9..491d455 100644 --- a/ArenaService/Controllers/ParticipantController.cs +++ b/ArenaService/Services/RegistrationService.cs @@ -1,22 +1,18 @@ -namespace ArenaService.Controllers; +namespace ArenaService.Services; +using System.Threading.Tasks; using ArenaService.Dtos; -using ArenaService.Extensions; using ArenaService.Repositories; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; +using Libplanet.Crypto; -[Route("seasons/{seasonId}/participants")] -[ApiController] -public class ParticipantController : ControllerBase +public class RegistrationService { private readonly IParticipantRepository _participantRepo; private readonly ISeasonRepository _seasonRepo; private readonly IUserRepository _userRepo; private readonly IRankingRepository _rankingRepo; - public ParticipantController( + public RegistrationService( IParticipantRepository participantRepo, ISeasonRepository seasonRepo, IUserRepository userRepo, @@ -29,25 +25,17 @@ IRankingRepository rankingRepo _rankingRepo = rankingRepo; } - [HttpPost] - [Authorize(Roles = "User", AuthenticationSchemes = "ES256K")] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(typeof(string), StatusCodes.Status404NotFound)] - [ProducesResponseType(typeof(string), StatusCodes.Status409Conflict)] - public async Task, Conflict, Created>> Participate( + public async Task EnsureUserRegisteredAsync( int seasonId, - [FromBody] ParticipateRequest participateRequest + Address avatarAddress, + Address agentAddress, + ParticipateRequest participateRequest ) { - var avatarAddress = HttpContext.User.RequireAvatarAddress(); - var agentAddress = HttpContext.User.RequireAgentAddress(); - var season = await _seasonRepo.GetSeasonAsync(seasonId); - if (season is null) { - return TypedResults.NotFound("No season found."); + throw new KeyNotFoundException($"Season {seasonId} not found."); } var existingParticipant = await _participantRepo.GetParticipantAsync( @@ -56,9 +44,7 @@ [FromBody] ParticipateRequest participateRequest ); if (existingParticipant is not null) { - return TypedResults.Conflict( - $"User with AvatarAddress {avatarAddress} is already participating in this season." - ); + return false; } await _userRepo.AddOrGetUserAsync( @@ -69,16 +55,15 @@ await _userRepo.AddOrGetUserAsync( participateRequest.Cp, participateRequest.Level ); - var participant = await _participantRepo.AddParticipantAsync(seasonId, avatarAddress); - var rankingKey = $"ranking:season:{seasonId}"; + var participant = await _participantRepo.AddParticipantAsync(seasonId, avatarAddress); await _rankingRepo.UpdateScoreAsync( - rankingKey, - participant.AvatarAddress, + new Address(participant.AvatarAddress), seasonId, participant.Score ); - return TypedResults.Created(); + + return true; } } diff --git a/ArenaService/Setup.cs b/ArenaService/Setup.cs index 84cfad6..a67aee8 100644 --- a/ArenaService/Setup.cs +++ b/ArenaService/Setup.cs @@ -4,6 +4,7 @@ namespace ArenaService; using ArenaService.Data; using ArenaService.Options; using ArenaService.Repositories; +using ArenaService.Worker; using Hangfire; using Hangfire.Redis.StackExchange; using Microsoft.AspNetCore.Authentication; @@ -132,6 +133,9 @@ public void ConfigureServices(IServiceCollection services) ); services.AddSingleton(); + services + .AddSingleton() + .AddHostedService(provider => provider.GetRequiredService()); services.AddHangfireServer(); } diff --git a/ArenaService/Worker/SeasonCachingWorker.cs b/ArenaService/Worker/SeasonCachingWorker.cs new file mode 100644 index 0000000..e7fe58f --- /dev/null +++ b/ArenaService/Worker/SeasonCachingWorker.cs @@ -0,0 +1,160 @@ +using ArenaService.Client; +using ArenaService.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace ArenaService.Worker; + +public class SeasonCachingWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + + public SeasonCachingWorker( + ILogger logger, + IServiceProvider serviceProvider + ) + { + _logger = logger; + _serviceProvider = serviceProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting BlockIndexCachingWorker..."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using (var scope = _serviceProvider.CreateScope()) + { + var client = scope.ServiceProvider.GetRequiredService(); + var seasonRepo = scope.ServiceProvider.GetRequiredService(); + var seasonCacheRepo = + scope.ServiceProvider.GetRequiredService(); + + await ProcessBlockIndexAsync( + client, + seasonRepo, + seasonCacheRepo, + stoppingToken + ); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred in BlockIndexCachingWorker."); + } + + await Task.Delay(TimeSpan.FromSeconds(4), stoppingToken); + } + } + + private async Task ProcessBlockIndexAsync( + IHeadlessClient client, + ISeasonRepository seasonRepo, + ISeasonCacheRepository seasonCacheRepo, + CancellationToken stoppingToken + ) + { + var blockIndex = await GetCurrentBlockIndexAsync(client, stoppingToken); + if (blockIndex == null) + { + _logger.LogWarning("Failed to fetch current block index."); + return; + } + + await seasonCacheRepo.SetBlockIndexAsync(blockIndex.Value); + + if (await IsBlockWithinCachedRoundAsync(blockIndex.Value, seasonCacheRepo)) + { + _logger.LogInformation( + "Block index is within the cached season and round. No updates needed." + ); + return; + } + + await UpdateSeasonAndRoundCacheAsync(blockIndex.Value, seasonRepo, seasonCacheRepo); + } + + private async Task GetCurrentBlockIndexAsync( + IHeadlessClient client, + CancellationToken stoppingToken + ) + { + var tipResponse = await client.GetTipIndex.ExecuteAsync(stoppingToken); + return tipResponse.Data?.NodeStatus.Tip.Index; + } + + private async Task IsBlockWithinCachedRoundAsync( + long blockIndex, + ISeasonCacheRepository seasonCacheRepo + ) + { + var cachedSeason = await seasonCacheRepo.GetSeasonAsync(); + var cachedRound = await seasonCacheRepo.GetRoundAsync(); + + if (cachedSeason == null || cachedRound == null) + { + return false; + } + + if (blockIndex < cachedSeason.Value.StartBlock || blockIndex > cachedSeason.Value.EndBlock) + { + return false; + } + + if (blockIndex < cachedRound.Value.StartBlock || blockIndex > cachedRound.Value.EndBlock) + { + return false; + } + + return true; + } + + private async Task UpdateSeasonAndRoundCacheAsync( + long blockIndex, + ISeasonRepository seasonRepo, + ISeasonCacheRepository seasonCacheRepo + ) + { + var seasons = await seasonRepo.GetAllSeasonsAsync(); + var currentSeason = seasons.FirstOrDefault(s => + s.StartBlock <= blockIndex && s.EndBlock >= blockIndex + ); + + if (currentSeason == null) + { + _logger.LogWarning("No matching season found for the current block index."); + return; + } + + var currentRound = currentSeason.Rounds.FirstOrDefault(ai => + ai.StartBlock <= blockIndex && ai.EndBlock >= blockIndex + ); + + if (currentRound == null) + { + _logger.LogWarning("No matching round found for the current block index."); + return; + } + + await seasonCacheRepo.SetSeasonAsync( + currentSeason.Id, + currentSeason.StartBlock, + currentSeason.EndBlock + ); + await seasonCacheRepo.SetRoundAsync( + currentRound.Id, + currentRound.StartBlock, + currentRound.EndBlock + ); + + _logger.LogInformation( + "Updated cache: BlockIndex={BlockIndex}, SeasonId={SeasonId}, RoundId={RoundId}", + blockIndex, + currentSeason.Id, + currentRound.Id + ); + } +}