From 9fd99dea0b781e8e32acf5cd9442c619bd1362c0 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 15:04:15 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20Player=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6 --- .../Player/Config/PlayerServiceConfig.cs | 30 ++ .../Player/Controller/PlayerController.cs | 43 +++ .../Controller/PlayerResourceController.cs | 29 ++ .../Controller/PlayerSessionController.cs | 29 ++ .../Controller/PlayerSkillController.cs | 29 ++ .../Controller/PlayerStageController.cs | 29 ++ .../Controller/PlayerWeaponController.cs | 29 ++ .../Dto/Request/EndPlayerSessionRequest.cs | 12 + .../Player/Dto/Request/InitPlayerRequest.cs | 8 + .../Player/Dto/Request/PlayerChangeItems.cs | 15 + .../Dto/Request/UpdatePlayerLevelRequest.cs | 9 + .../Request/UpdatePlayerResourceRequest.cs | 11 + .../Dto/Request/UpdatePlayerSkillRequest.cs | 9 + .../Dto/Request/UpdatePlayerStageRequest.cs | 9 + .../Dto/Request/UpdatePlayerWeaponRequest.cs | 9 + .../Player/Dto/Response/PlayerDataResponse.cs | 22 ++ .../Player/Entity/Config/PlayerConfig.cs | 43 +++ .../Entity/Config/PlayerResourceConfig.cs | 48 +++ .../Entity/Config/PlayerSessionConfig.cs | 38 +++ .../Player/Entity/Config/PlayerSkillConfig.cs | 36 ++ .../Player/Entity/Config/PlayerStageConfig.cs | 33 ++ .../Entity/Config/PlayerWeaponConfig.cs | 44 +++ .../Domain/Player/Entity/Player.cs | 36 ++ .../Domain/Player/Entity/PlayerResource.cs | 36 ++ .../Domain/Player/Entity/PlayerSession.cs | 25 ++ .../Domain/Player/Entity/PlayerSkill.cs | 21 ++ .../Domain/Player/Entity/PlayerStage.cs | 19 ++ .../Domain/Player/Entity/PlayerWeapon.cs | 27 ++ .../Domain/Player/Enum/ArcherSkillId.cs | 7 + .../Domain/Player/Enum/ArcherWeaponId.cs | 7 + .../Domain/Player/Enum/JobType.cs | 8 + .../Domain/Player/Enum/MageSkillId.cs | 7 + .../Domain/Player/Enum/MageWeaponId.cs | 7 + .../Domain/Player/Enum/WarriorSkillId.cs | 7 + .../Domain/Player/Enum/WarriorWeaponId.cs | 7 + .../Interface/IPlayerRedisRepository.cs | 11 + .../Repository/Interface/IPlayerRepository.cs | 11 + .../Interface/IPlayerResourceRepository.cs | 10 + .../Interface/IPlayerSessionRepository.cs | 10 + .../Interface/IPlayerSkillRepository.cs | 10 + .../Interface/IPlayerStageRepository.cs | 10 + .../Interface/IPlayerWeaponRepository.cs | 10 + .../Repository/PlayerRedisRepository.cs | 41 +++ .../Player/Repository/PlayerRepository.cs | 33 ++ .../Repository/PlayerResourceRepository.cs | 32 ++ .../Repository/PlayerSessionRepository.cs | 32 ++ .../Repository/PlayerSkillRepository.cs | 45 +++ .../Repository/PlayerStageRepository.cs | 32 ++ .../Repository/PlayerWeaponRepository.cs | 45 +++ .../Player/Service/EndPlayerSessionService.cs | 60 ++++ .../Player/Service/InitPlayerService.cs | 113 +++++++ .../Interface/IEndPlayerSessionService.cs | 8 + .../Service/Interface/IInitPlayerService.cs | 9 + .../Interface/IUpdatePlayerLevelService.cs | 8 + .../Interface/IUpdatePlayerResourceService.cs | 8 + .../Interface/IUpdatePlayerSkillService.cs | 8 + .../Interface/IUpdatePlayerStageService.cs | 8 + .../Interface/IUpdatePlayerWeaponService.cs | 8 + .../Service/UpdatePlayerLevelService.cs | 37 ++ .../Service/UpdatePlayerResourceService.cs | 43 +++ .../Service/UpdatePlayerSkillService.cs | 39 +++ .../Service/UpdatePlayerStageService.cs | 43 +++ .../Service/UpdatePlayerWeaponService.cs | 39 +++ .../Global/Infrastructure/AppDbContext.cs | 7 + .../Infrastructure/AppDbContextFactory.cs | 25 ++ ...20260406154118_AddPlayerTables.Designer.cs | 257 ++++++++++++++ .../20260406154118_AddPlayerTables.cs | 163 +++++++++ ...07052845_NormalizePlayerTables.Designer.cs | 316 ++++++++++++++++++ .../20260407052845_NormalizePlayerTables.cs | 156 +++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 245 ++++++++++++++ Fantasy-server/Fantasy.Server/Program.cs | 2 + .../Service/EndPlayerSessionServiceTests.cs | 162 +++++++++ .../Player/Service/InitPlayerServiceTests.cs | 225 +++++++++++++ .../Service/UpdatePlayerLevelServiceTests.cs | 76 +++++ .../UpdatePlayerResourceServiceTests.cs | 96 ++++++ .../Service/UpdatePlayerSkillServiceTests.cs | 84 +++++ .../Service/UpdatePlayerStageServiceTests.cs | 114 +++++++ .../Service/UpdatePlayerWeaponServiceTests.cs | 84 +++++ 78 files changed, 3553 insertions(+) create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Config/PlayerServiceConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerController.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerResourceController.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSessionController.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSkillController.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerStageController.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerWeaponController.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/EndPlayerSessionRequest.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/InitPlayerRequest.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/PlayerChangeItems.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerLevelRequest.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerResourceRequest.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerSkillRequest.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerStageRequest.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerWeaponRequest.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Dto/Response/PlayerDataResponse.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSessionConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSkillConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerWeaponConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/Player.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSession.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSkill.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerWeapon.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherSkillId.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherWeaponId.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Enum/JobType.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageSkillId.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageWeaponId.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorSkillId.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorWeaponId.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRedisRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerResourceRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSessionRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSkillRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerStageRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerWeaponRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRedisRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IEndPlayerSessionService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IInitPlayerService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerLevelService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerResourceService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerSkillService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerStageService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerWeaponService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerLevelService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerResourceService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerSkillService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerStageService.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerWeaponService.cs create mode 100644 Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContextFactory.cs create mode 100644 Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.Designer.cs create mode 100644 Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.cs create mode 100644 Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.Designer.cs create mode 100644 Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerLevelServiceTests.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerResourceServiceTests.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerSkillServiceTests.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerStageServiceTests.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerWeaponServiceTests.cs diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Config/PlayerServiceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Config/PlayerServiceConfig.cs new file mode 100644 index 0000000..fb243a3 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Config/PlayerServiceConfig.cs @@ -0,0 +1,30 @@ +using Fantasy.Server.Domain.Player.Repository; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Domain.Player.Service.Interface; + +namespace Fantasy.Server.Domain.Player.Config; + +public static class PlayerServiceConfig +{ + public static IServiceCollection AddPlayerServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerController.cs new file mode 100644 index 0000000..93d1cad --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerController.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerController : ControllerBase +{ + private readonly IInitPlayerService _initPlayerService; + private readonly IUpdatePlayerLevelService _updatePlayerLevelService; + + public PlayerController( + IInitPlayerService initPlayerService, + IUpdatePlayerLevelService updatePlayerLevelService) + { + _initPlayerService = initPlayerService; + _updatePlayerLevelService = updatePlayerLevelService; + } + + [HttpPost("init")] + public async Task> Init([FromBody] InitPlayerRequest request) + { + var (data, isNew) = await _initPlayerService.ExecuteAsync(request); + return isNew + ? CommonApiResponse.Created("플레이어가 생성되었습니다.", data) + : CommonApiResponse.Success("플레이어 데이터를 불러왔습니다.", data); + } + + [HttpPatch("level")] + public async Task UpdateLevel([FromBody] UpdatePlayerLevelRequest request) + { + await _updatePlayerLevelService.ExecuteAsync(request); + return CommonApiResponse.Success("레벨이 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerResourceController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerResourceController.cs new file mode 100644 index 0000000..eb6e01d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerResourceController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerResourceController : ControllerBase +{ + private readonly IUpdatePlayerResourceService _updatePlayerResourceService; + + public PlayerResourceController(IUpdatePlayerResourceService updatePlayerResourceService) + { + _updatePlayerResourceService = updatePlayerResourceService; + } + + [HttpPatch("resource")] + public async Task UpdateResource([FromBody] UpdatePlayerResourceRequest request) + { + await _updatePlayerResourceService.ExecuteAsync(request); + return CommonApiResponse.Success("재화가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSessionController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSessionController.cs new file mode 100644 index 0000000..26a6086 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSessionController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerSessionController : ControllerBase +{ + private readonly IEndPlayerSessionService _endPlayerSessionService; + + public PlayerSessionController(IEndPlayerSessionService endPlayerSessionService) + { + _endPlayerSessionService = endPlayerSessionService; + } + + [HttpPatch("session/end")] + public async Task EndSession([FromBody] EndPlayerSessionRequest request) + { + await _endPlayerSessionService.ExecuteAsync(request); + return CommonApiResponse.Success("게임 종료 데이터가 저장되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSkillController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSkillController.cs new file mode 100644 index 0000000..2ec6039 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerSkillController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerSkillController : ControllerBase +{ + private readonly IUpdatePlayerSkillService _updatePlayerSkillService; + + public PlayerSkillController(IUpdatePlayerSkillService updatePlayerSkillService) + { + _updatePlayerSkillService = updatePlayerSkillService; + } + + [HttpPatch("skill")] + public async Task UpdateSkill([FromBody] UpdatePlayerSkillRequest request) + { + await _updatePlayerSkillService.ExecuteAsync(request); + return CommonApiResponse.Success("스킬 정보가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerStageController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerStageController.cs new file mode 100644 index 0000000..109a393 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerStageController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerStageController : ControllerBase +{ + private readonly IUpdatePlayerStageService _updatePlayerStageService; + + public PlayerStageController(IUpdatePlayerStageService updatePlayerStageService) + { + _updatePlayerStageService = updatePlayerStageService; + } + + [HttpPatch("stage")] + public async Task UpdateStage([FromBody] UpdatePlayerStageRequest request) + { + await _updatePlayerStageService.ExecuteAsync(request); + return CommonApiResponse.Success("스테이지가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerWeaponController.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerWeaponController.cs new file mode 100644 index 0000000..47fd67b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Controller/PlayerWeaponController.cs @@ -0,0 +1,29 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Service.Interface; +using Gamism.SDK.Core.Network; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; + +namespace Fantasy.Server.Domain.Player.Controller; + +[ApiController] +[Route("v1/player")] +[Authorize] +[EnableRateLimiting("game")] +public class PlayerWeaponController : ControllerBase +{ + private readonly IUpdatePlayerWeaponService _updatePlayerWeaponService; + + public PlayerWeaponController(IUpdatePlayerWeaponService updatePlayerWeaponService) + { + _updatePlayerWeaponService = updatePlayerWeaponService; + } + + [HttpPatch("weapon")] + public async Task UpdateWeapon([FromBody] UpdatePlayerWeaponRequest request) + { + await _updatePlayerWeaponService.ExecuteAsync(request); + return CommonApiResponse.Success("무기 정보가 업데이트되었습니다."); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/EndPlayerSessionRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/EndPlayerSessionRequest.cs new file mode 100644 index 0000000..84d6635 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/EndPlayerSessionRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record EndPlayerSessionRequest( + [Required] JobType JobType, + [Required] int LastWeaponId, + [Required] int[] ActiveSkills, + [Range(0, long.MaxValue)] long? Gold, + [Range(0, long.MaxValue)] long? Exp +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/InitPlayerRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/InitPlayerRequest.cs new file mode 100644 index 0000000..fb29731 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/InitPlayerRequest.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record InitPlayerRequest( + [Required] JobType JobType +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/PlayerChangeItems.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/PlayerChangeItems.cs new file mode 100644 index 0000000..f5c416a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/PlayerChangeItems.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record WeaponChangeItem( + [Required] int WeaponId, + [Range(0, long.MaxValue)] long Count, + [Range(0, long.MaxValue)] long EnhancementLevel, + [Range(0, long.MaxValue)] long AwakeningCount +); + +public record SkillChangeItem( + [Required] int SkillId, + [Required] bool IsUnlocked +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerLevelRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerLevelRequest.cs new file mode 100644 index 0000000..806e492 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerLevelRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerLevelRequest( + [Required] JobType JobType, + [Range(1, long.MaxValue)] long Level +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerResourceRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerResourceRequest.cs new file mode 100644 index 0000000..f1bfc41 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerResourceRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerResourceRequest( + [Required] JobType JobType, + [Range(0, long.MaxValue)] long? EnhancementScroll, + [Range(0, long.MaxValue)] long? Mithril, + [Range(0, long.MaxValue)] long? Sp +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerSkillRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerSkillRequest.cs new file mode 100644 index 0000000..bf2ecd2 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerSkillRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerSkillRequest( + [Required] JobType JobType, + [Required] List Skills +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerStageRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerStageRequest.cs new file mode 100644 index 0000000..785db4c --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerStageRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerStageRequest( + [Required] JobType JobType, + [Range(1, long.MaxValue)] long MaxStage +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerWeaponRequest.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerWeaponRequest.cs new file mode 100644 index 0000000..c87f61c --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Request/UpdatePlayerWeaponRequest.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Request; + +public record UpdatePlayerWeaponRequest( + [Required] JobType JobType, + [Required] List Weapons +); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Response/PlayerDataResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Response/PlayerDataResponse.cs new file mode 100644 index 0000000..7a15a8d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Dto/Response/PlayerDataResponse.cs @@ -0,0 +1,22 @@ +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Dto.Response; + +public record PlayerDataResponse( + JobType JobType, + long Level, + long MaxStage, + int? LastWeaponId, + int[] ActiveSkills, + long Gold, + long Exp, + long EnhancementScroll, + long Mithril, + long Sp, + List Weapons, + List Skills +); + +public record WeaponInfoResponse(int WeaponId, long Count, long EnhancementLevel, long AwakeningCount); + +public record SkillInfoResponse(int SkillId, bool IsUnlocked); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerConfig.cs new file mode 100644 index 0000000..c121610 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerConfig.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player", "player"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .ValueGeneratedOnAdd(); + + builder.Property(p => p.AccountId) + .IsRequired(); + + builder.Property(p => p.JobType) + .IsRequired() + .HasConversion(); + + builder.Property(p => p.Level) + .IsRequired() + .HasDefaultValue(1L); + + builder.Property(p => p.Exp) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(p => p.CreatedAt) + .IsRequired(); + + builder.Property(p => p.UpdatedAt) + .IsRequired(); + + builder.HasIndex(p => new { p.AccountId, p.JobType }) + .IsUnique(); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs new file mode 100644 index 0000000..89e93cd --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerResourceConfig.cs @@ -0,0 +1,48 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerResourceConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_resource", "player"); + + builder.HasKey(r => r.Id); + + builder.Property(r => r.Id) + .ValueGeneratedOnAdd(); + + builder.Property(r => r.PlayerId) + .IsRequired(); + + builder.Property(r => r.Gold) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.EnhancementScroll) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.Mithril) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.Sp) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(r => r.UpdatedAt) + .IsRequired(); + + builder.HasIndex(r => r.PlayerId) + .IsUnique(); + + builder.HasOne() + .WithOne() + .HasForeignKey(r => r.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSessionConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSessionConfig.cs new file mode 100644 index 0000000..2d77755 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSessionConfig.cs @@ -0,0 +1,38 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerSessionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_session", "player"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .ValueGeneratedOnAdd(); + + builder.Property(s => s.PlayerId) + .IsRequired(); + + builder.Property(s => s.LastWeaponId); + + builder.Property(s => s.ActiveSkills) + .IsRequired() + .HasDefaultValueSql("ARRAY[]::integer[]"); + + builder.Property(s => s.UpdatedAt) + .IsRequired(); + + builder.HasIndex(s => s.PlayerId) + .IsUnique(); + + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSkillConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSkillConfig.cs new file mode 100644 index 0000000..bf4d031 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerSkillConfig.cs @@ -0,0 +1,36 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerSkillConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_skill", "player"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .ValueGeneratedOnAdd(); + + builder.Property(s => s.PlayerId) + .IsRequired(); + + builder.Property(s => s.SkillId) + .IsRequired(); + + builder.Property(s => s.IsUnlocked) + .IsRequired() + .HasDefaultValue(false); + + builder.HasIndex(s => new { s.PlayerId, s.SkillId }) + .IsUnique(); + + builder.HasOne() + .WithMany() + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs new file mode 100644 index 0000000..883934e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerStageConfig.cs @@ -0,0 +1,33 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerStageConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_stage", "player"); + + builder.HasKey(s => s.Id); + + builder.Property(s => s.Id) + .ValueGeneratedOnAdd(); + + builder.Property(s => s.PlayerId) + .IsRequired(); + + builder.Property(s => s.MaxStage) + .IsRequired() + .HasDefaultValue(1L); + + builder.HasIndex(s => s.PlayerId) + .IsUnique(); + + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerWeaponConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerWeaponConfig.cs new file mode 100644 index 0000000..679b0b3 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Config/PlayerWeaponConfig.cs @@ -0,0 +1,44 @@ +using Fantasy.Server.Domain.Player.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Fantasy.Server.Domain.Player.Entity.Config; + +public class PlayerWeaponConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_weapon", "player"); + + builder.HasKey(w => w.Id); + + builder.Property(w => w.Id) + .ValueGeneratedOnAdd(); + + builder.Property(w => w.PlayerId) + .IsRequired(); + + builder.Property(w => w.WeaponId) + .IsRequired(); + + builder.Property(w => w.Count) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(w => w.EnhancementLevel) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(w => w.AwakeningCount) + .IsRequired() + .HasDefaultValue(0L); + + builder.HasIndex(w => new { w.PlayerId, w.WeaponId }) + .IsUnique(); + + builder.HasOne() + .WithMany() + .HasForeignKey(w => w.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Player.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Player.cs new file mode 100644 index 0000000..4967c2a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/Player.cs @@ -0,0 +1,36 @@ +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Entity; + +public class Player +{ + public long Id { get; private set; } + public long AccountId { get; private set; } + public JobType JobType { get; private set; } + public long Level { get; private set; } + public long Exp { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime UpdatedAt { get; private set; } + + public static Player Create(long accountId, JobType jobType) => new() + { + AccountId = accountId, + JobType = jobType, + Level = 1, + Exp = 0, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + public void UpdateLevel(long level) + { + Level = level; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateExp(long exp) + { + Exp = exp; + UpdatedAt = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs new file mode 100644 index 0000000..f61a60c --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerResource.cs @@ -0,0 +1,36 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerResource +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public long Gold { get; private set; } + public long EnhancementScroll { get; private set; } + public long Mithril { get; private set; } + public long Sp { get; private set; } + public DateTime UpdatedAt { get; private set; } + + public static PlayerResource Create(long playerId) => new() + { + PlayerId = playerId, + Gold = 0, + EnhancementScroll = 0, + Mithril = 0, + Sp = 0, + UpdatedAt = DateTime.UtcNow + }; + + public void UpdateGold(long gold) + { + Gold = gold; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateChangeData(long? enhancementScroll, long? mithril, long? sp) + { + if (enhancementScroll.HasValue) EnhancementScroll = enhancementScroll.Value; + if (mithril.HasValue) Mithril = mithril.Value; + if (sp.HasValue) Sp = sp.Value; + UpdatedAt = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSession.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSession.cs new file mode 100644 index 0000000..fc58502 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSession.cs @@ -0,0 +1,25 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerSession +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public int? LastWeaponId { get; private set; } + public int[] ActiveSkills { get; private set; } = []; + public DateTime UpdatedAt { get; private set; } + + public static PlayerSession Create(long playerId) => new() + { + PlayerId = playerId, + LastWeaponId = null, + ActiveSkills = [], + UpdatedAt = DateTime.UtcNow + }; + + public void Update(int lastWeaponId, int[] activeSkills) + { + LastWeaponId = lastWeaponId; + ActiveSkills = activeSkills; + UpdatedAt = DateTime.UtcNow; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSkill.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSkill.cs new file mode 100644 index 0000000..4d5c7ee --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerSkill.cs @@ -0,0 +1,21 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerSkill +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public int SkillId { get; private set; } + public bool IsUnlocked { get; private set; } + + public static PlayerSkill Create(long playerId, int skillId, bool isUnlocked) => new() + { + PlayerId = playerId, + SkillId = skillId, + IsUnlocked = isUnlocked + }; + + public void Update(bool isUnlocked) + { + IsUnlocked = isUnlocked; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs new file mode 100644 index 0000000..6ba3064 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerStage.cs @@ -0,0 +1,19 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerStage +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public long MaxStage { get; private set; } + + public static PlayerStage Create(long playerId) => new() + { + PlayerId = playerId, + MaxStage = 1 + }; + + public void Update(long maxStage) + { + MaxStage = maxStage; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerWeapon.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerWeapon.cs new file mode 100644 index 0000000..8ec6fe0 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Entity/PlayerWeapon.cs @@ -0,0 +1,27 @@ +namespace Fantasy.Server.Domain.Player.Entity; + +public class PlayerWeapon +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public int WeaponId { get; private set; } + public long Count { get; private set; } + public long EnhancementLevel { get; private set; } + public long AwakeningCount { get; private set; } + + public static PlayerWeapon Create(long playerId, int weaponId, long count, long enhancementLevel, long awakeningCount) => new() + { + PlayerId = playerId, + WeaponId = weaponId, + Count = count, + EnhancementLevel = enhancementLevel, + AwakeningCount = awakeningCount + }; + + public void Update(long count, long enhancementLevel, long awakeningCount) + { + Count = count; + EnhancementLevel = enhancementLevel; + AwakeningCount = awakeningCount; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherSkillId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherSkillId.cs new file mode 100644 index 0000000..eb2dd89 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherSkillId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum ArcherSkillId +{ + MultiShot = 1, + Dodge = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherWeaponId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherWeaponId.cs new file mode 100644 index 0000000..100467d --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/ArcherWeaponId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum ArcherWeaponId +{ + Bow = 1, + Crossbow = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/JobType.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/JobType.cs new file mode 100644 index 0000000..a57b546 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/JobType.cs @@ -0,0 +1,8 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum JobType +{ + Warrior, + Archer, + Mage +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageSkillId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageSkillId.cs new file mode 100644 index 0000000..d437d87 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageSkillId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum MageSkillId +{ + Fireball = 1, + IceBolt = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageWeaponId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageWeaponId.cs new file mode 100644 index 0000000..f38c5e3 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/MageWeaponId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum MageWeaponId +{ + Staff = 1, + Wand = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorSkillId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorSkillId.cs new file mode 100644 index 0000000..11778fb --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorSkillId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum WarriorSkillId +{ + SlashAttack = 1, + ShieldBlock = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorWeaponId.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorWeaponId.cs new file mode 100644 index 0000000..0f294dc --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Enum/WarriorWeaponId.cs @@ -0,0 +1,7 @@ +namespace Fantasy.Server.Domain.Player.Enum; + +public enum WarriorWeaponId +{ + Sword = 1, + GreatSword = 2 +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRedisRepository.cs new file mode 100644 index 0000000..80e13a8 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRedisRepository.cs @@ -0,0 +1,11 @@ +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerRedisRepository +{ + Task SetPlayerDataAsync(long accountId, JobType jobType, PlayerDataResponse data); + Task GetPlayerDataAsync(long accountId, JobType jobType); + Task DeleteAsync(long accountId, JobType jobType); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRepository.cs new file mode 100644 index 0000000..ec56bca --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerRepository.cs @@ -0,0 +1,11 @@ +using Fantasy.Server.Domain.Player.Enum; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerRepository +{ + Task FindByAccountAndJobAsync(long accountId, JobType jobType); + Task SaveAsync(PlayerEntity player); + Task UpdateAsync(PlayerEntity player); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerResourceRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerResourceRepository.cs new file mode 100644 index 0000000..c851f05 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerResourceRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerResourceRepository +{ + Task FindByPlayerIdAsync(long playerId); + Task SaveAsync(PlayerResource resource); + Task UpdateAsync(PlayerResource resource); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSessionRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSessionRepository.cs new file mode 100644 index 0000000..4d37366 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSessionRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerSessionRepository +{ + Task FindByPlayerIdAsync(long playerId); + Task SaveAsync(PlayerSession session); + Task UpdateAsync(PlayerSession session); +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSkillRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSkillRepository.cs new file mode 100644 index 0000000..df828fc --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerSkillRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerSkillRepository +{ + Task> FindAllByPlayerIdAsync(long playerId); + Task UpsertRangeAsync(long playerId, List items); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerStageRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerStageRepository.cs new file mode 100644 index 0000000..6b7b849 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerStageRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerStageRepository +{ + Task FindByPlayerIdAsync(long playerId); + Task SaveAsync(PlayerStage stage); + Task UpdateAsync(PlayerStage stage); +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerWeaponRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerWeaponRepository.cs new file mode 100644 index 0000000..3c16d14 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/Interface/IPlayerWeaponRepository.cs @@ -0,0 +1,10 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; + +namespace Fantasy.Server.Domain.Player.Repository.Interface; + +public interface IPlayerWeaponRepository +{ + Task> FindAllByPlayerIdAsync(long playerId); + Task UpsertRangeAsync(long playerId, List items); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRedisRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRedisRepository.cs new file mode 100644 index 0000000..7cbd962 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRedisRepository.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using StackExchange.Redis; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerRedisRepository : IPlayerRedisRepository +{ + private const string Prefix = "fantasy:player:"; + + private readonly IDatabase _db; + + public PlayerRedisRepository(IConnectionMultiplexer multiplexer) + { + _db = multiplexer.GetDatabase(); + } + + private static string CacheKey(long accountId, JobType jobType) => + $"{Prefix}{accountId}:{jobType}"; + + public async Task SetPlayerDataAsync(long accountId, JobType jobType, PlayerDataResponse data) + { + var json = JsonSerializer.Serialize(data); + await _db.StringSetAsync(CacheKey(accountId, jobType), json, TimeSpan.FromMinutes(30)); + } + + public async Task GetPlayerDataAsync(long accountId, JobType jobType) + { + var json = await _db.StringGetAsync(CacheKey(accountId, jobType)); + if (!json.HasValue) + return null; + return JsonSerializer.Deserialize(json.ToString()); + } + + public async Task DeleteAsync(long accountId, JobType jobType) + { + await _db.KeyDeleteAsync(CacheKey(accountId, jobType)); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRepository.cs new file mode 100644 index 0000000..9815849 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerRepository.cs @@ -0,0 +1,33 @@ +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerRepository : IPlayerRepository +{ + private readonly AppDbContext _db; + + public PlayerRepository(AppDbContext db) => _db = db; + + public async Task FindByAccountAndJobAsync(long accountId, JobType jobType) + => await _db.Players + .AsNoTracking() + .FirstOrDefaultAsync(p => p.AccountId == accountId && p.JobType == jobType); + + public async Task SaveAsync(PlayerEntity player) + { + if (_db.Players.Entry(player).State == EntityState.Detached) + await _db.Players.AddAsync(player); + await _db.SaveChangesAsync(); + return player; + } + + public async Task UpdateAsync(PlayerEntity player) + { + _db.Players.Update(player); + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs new file mode 100644 index 0000000..f9e8b50 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs @@ -0,0 +1,32 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerResourceRepository : IPlayerResourceRepository +{ + private readonly AppDbContext _db; + + public PlayerResourceRepository(AppDbContext db) => _db = db; + + public async Task FindByPlayerIdAsync(long playerId) + => await _db.PlayerResources + .AsNoTracking() + .FirstOrDefaultAsync(r => r.PlayerId == playerId); + + public async Task SaveAsync(PlayerResource resource) + { + if (_db.PlayerResources.Entry(resource).State == EntityState.Detached) + await _db.PlayerResources.AddAsync(resource); + await _db.SaveChangesAsync(); + return resource; + } + + public async Task UpdateAsync(PlayerResource resource) + { + _db.PlayerResources.Update(resource); + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs new file mode 100644 index 0000000..70e2e6a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs @@ -0,0 +1,32 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerSessionRepository : IPlayerSessionRepository +{ + private readonly AppDbContext _db; + + public PlayerSessionRepository(AppDbContext db) => _db = db; + + public async Task FindByPlayerIdAsync(long playerId) + => await _db.PlayerSessions + .AsNoTracking() + .FirstOrDefaultAsync(s => s.PlayerId == playerId); + + public async Task SaveAsync(PlayerSession session) + { + if (_db.PlayerSessions.Entry(session).State == EntityState.Detached) + await _db.PlayerSessions.AddAsync(session); + await _db.SaveChangesAsync(); + return session; + } + + public async Task UpdateAsync(PlayerSession session) + { + _db.PlayerSessions.Update(session); + await _db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs new file mode 100644 index 0000000..6a2a73e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs @@ -0,0 +1,45 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerSkillRepository : IPlayerSkillRepository +{ + private readonly AppDbContext _db; + + public PlayerSkillRepository(AppDbContext db) => _db = db; + + public async Task> FindAllByPlayerIdAsync(long playerId) + => await _db.PlayerSkills + .AsNoTracking() + .Where(s => s.PlayerId == playerId) + .ToListAsync(); + + public async Task UpsertRangeAsync(long playerId, List items) + { + var skillIds = items.Select(i => i.SkillId).ToList(); + var existing = await _db.PlayerSkills + .Where(s => s.PlayerId == playerId && skillIds.Contains(s.SkillId)) + .ToListAsync(); + + foreach (var item in items) + { + var skill = existing.FirstOrDefault(s => s.SkillId == item.SkillId); + if (skill != null) + { + skill.Update(item.IsUnlocked); + _db.PlayerSkills.Update(skill); + } + else + { + await _db.PlayerSkills.AddAsync( + PlayerSkill.Create(playerId, item.SkillId, item.IsUnlocked)); + } + } + + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs new file mode 100644 index 0000000..dc0199a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs @@ -0,0 +1,32 @@ +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerStageRepository : IPlayerStageRepository +{ + private readonly AppDbContext _db; + + public PlayerStageRepository(AppDbContext db) => _db = db; + + public async Task FindByPlayerIdAsync(long playerId) + => await _db.PlayerStages + .AsNoTracking() + .FirstOrDefaultAsync(s => s.PlayerId == playerId); + + public async Task SaveAsync(PlayerStage stage) + { + if (_db.PlayerStages.Entry(stage).State == EntityState.Detached) + await _db.PlayerStages.AddAsync(stage); + await _db.SaveChangesAsync(); + return stage; + } + + public async Task UpdateAsync(PlayerStage stage) + { + _db.PlayerStages.Update(stage); + await _db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs new file mode 100644 index 0000000..1d73629 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs @@ -0,0 +1,45 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Player.Repository; + +public class PlayerWeaponRepository : IPlayerWeaponRepository +{ + private readonly AppDbContext _db; + + public PlayerWeaponRepository(AppDbContext db) => _db = db; + + public async Task> FindAllByPlayerIdAsync(long playerId) + => await _db.PlayerWeapons + .AsNoTracking() + .Where(w => w.PlayerId == playerId) + .ToListAsync(); + + public async Task UpsertRangeAsync(long playerId, List items) + { + var weaponIds = items.Select(i => i.WeaponId).ToList(); + var existing = await _db.PlayerWeapons + .Where(w => w.PlayerId == playerId && weaponIds.Contains(w.WeaponId)) + .ToListAsync(); + + foreach (var item in items) + { + var weapon = existing.FirstOrDefault(w => w.WeaponId == item.WeaponId); + if (weapon != null) + { + weapon.Update(item.Count, item.EnhancementLevel, item.AwakeningCount); + _db.PlayerWeapons.Update(weapon); + } + else + { + await _db.PlayerWeapons.AddAsync( + PlayerWeapon.Create(playerId, item.WeaponId, item.Count, item.EnhancementLevel, item.AwakeningCount)); + } + } + + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs new file mode 100644 index 0000000..c497aad --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs @@ -0,0 +1,60 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class EndPlayerSessionService : IEndPlayerSessionService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public EndPlayerSessionService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerSessionRepository = playerSessionRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(EndPlayerSessionRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + session.Update(request.LastWeaponId, request.ActiveSkills); + await _playerSessionRepository.UpdateAsync(session); + + if (request.Exp.HasValue) + { + player.UpdateExp(request.Exp.Value); + await _playerRepository.UpdateAsync(player); + } + + if (request.Gold.HasValue) + { + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + resource.UpdateGold(request.Gold.Value); + await _playerResourceRepository.UpdateAsync(resource); + } + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs new file mode 100644 index 0000000..323863b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs @@ -0,0 +1,113 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Player.Service; + +public class InitPlayerService : IInitPlayerService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public InitPlayerService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task<(PlayerDataResponse Data, bool IsNew)> ExecuteAsync(InitPlayerRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var cached = await _playerRedisRepository.GetPlayerDataAsync(accountId, request.JobType); + if (cached != null) + return (cached, false); + + var isNew = false; + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType); + PlayerResource resource; + PlayerStage stage; + PlayerSession session; + + if (player == null) + { + player = PlayerEntity.Create(accountId, request.JobType); + await _playerRepository.SaveAsync(player); + + resource = PlayerResource.Create(player.Id); + await _playerResourceRepository.SaveAsync(resource); + + stage = PlayerStage.Create(player.Id); + await _playerStageRepository.SaveAsync(stage); + + session = PlayerSession.Create(player.Id); + await _playerSessionRepository.SaveAsync(session); + + isNew = true; + } + else + { + resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + } + + var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + var response = BuildResponse(player, resource, stage, session, weapons, skills); + await _playerRedisRepository.SetPlayerDataAsync(accountId, request.JobType, response); + + return (response, isNew); + } + + private static PlayerDataResponse BuildResponse( + PlayerEntity player, + PlayerResource resource, + PlayerStage stage, + PlayerSession session, + List weapons, + List skills) => + new( + player.JobType, + player.Level, + stage.MaxStage, + session.LastWeaponId, + session.ActiveSkills, + resource.Gold, + player.Exp, + resource.EnhancementScroll, + resource.Mithril, + resource.Sp, + weapons.Select(w => new WeaponInfoResponse(w.WeaponId, w.Count, w.EnhancementLevel, w.AwakeningCount)).ToList(), + skills.Select(s => new SkillInfoResponse(s.SkillId, s.IsUnlocked)).ToList() + ); +} \ No newline at end of file diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IEndPlayerSessionService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IEndPlayerSessionService.cs new file mode 100644 index 0000000..ca542fd --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IEndPlayerSessionService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IEndPlayerSessionService +{ + Task ExecuteAsync(EndPlayerSessionRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IInitPlayerService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IInitPlayerService.cs new file mode 100644 index 0000000..74f92ff --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IInitPlayerService.cs @@ -0,0 +1,9 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IInitPlayerService +{ + Task<(PlayerDataResponse Data, bool IsNew)> ExecuteAsync(InitPlayerRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerLevelService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerLevelService.cs new file mode 100644 index 0000000..32b86fc --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerLevelService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerLevelService +{ + Task ExecuteAsync(UpdatePlayerLevelRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerResourceService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerResourceService.cs new file mode 100644 index 0000000..6b160d6 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerResourceService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerResourceService +{ + Task ExecuteAsync(UpdatePlayerResourceRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerSkillService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerSkillService.cs new file mode 100644 index 0000000..fb03012 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerSkillService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerSkillService +{ + Task ExecuteAsync(UpdatePlayerSkillRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerStageService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerStageService.cs new file mode 100644 index 0000000..6b07b21 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerStageService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerStageService +{ + Task ExecuteAsync(UpdatePlayerStageRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerWeaponService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerWeaponService.cs new file mode 100644 index 0000000..d5dadbf --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/Interface/IUpdatePlayerWeaponService.cs @@ -0,0 +1,8 @@ +using Fantasy.Server.Domain.Player.Dto.Request; + +namespace Fantasy.Server.Domain.Player.Service.Interface; + +public interface IUpdatePlayerWeaponService +{ + Task ExecuteAsync(UpdatePlayerWeaponRequest request); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerLevelService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerLevelService.cs new file mode 100644 index 0000000..fc2f195 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerLevelService.cs @@ -0,0 +1,37 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerLevelService : IUpdatePlayerLevelService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerLevelService( + IPlayerRepository playerRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerLevelRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + player.UpdateLevel(request.Level); + await _playerRepository.UpdateAsync(player); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerResourceService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerResourceService.cs new file mode 100644 index 0000000..04fe8f0 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerResourceService.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerResourceService : IUpdatePlayerResourceService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerResourceService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerResourceRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + resource.UpdateChangeData(request.EnhancementScroll, request.Mithril, request.Sp); + await _playerResourceRepository.UpdateAsync(resource); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerSkillService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerSkillService.cs new file mode 100644 index 0000000..7c1d805 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerSkillService.cs @@ -0,0 +1,39 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerSkillService : IUpdatePlayerSkillService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerSkillService( + IPlayerRepository playerRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerSkillRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + await _playerSkillRepository.UpsertRangeAsync(player.Id, request.Skills); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerStageService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerStageService.cs new file mode 100644 index 0000000..dffa3a2 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerStageService.cs @@ -0,0 +1,43 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerStageService : IUpdatePlayerStageService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerStageService( + IPlayerRepository playerRepository, + IPlayerStageRepository playerStageRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerStageRepository = playerStageRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerStageRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + stage.Update(request.MaxStage); + await _playerStageRepository.UpdateAsync(stage); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerWeaponService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerWeaponService.cs new file mode 100644 index 0000000..fb3d0f4 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/UpdatePlayerWeaponService.cs @@ -0,0 +1,39 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Player.Service; + +public class UpdatePlayerWeaponService : IUpdatePlayerWeaponService +{ + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly ICurrentUserProvider _currentUserProvider; + + public UpdatePlayerWeaponService( + IPlayerRepository playerRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerRedisRepository playerRedisRepository, + ICurrentUserProvider currentUserProvider) + { + _playerRepository = playerRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerRedisRepository = playerRedisRepository; + _currentUserProvider = currentUserProvider; + } + + public async Task ExecuteAsync(UpdatePlayerWeaponRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + await _playerWeaponRepository.UpsertRangeAsync(player.Id, request.Weapons); + + await _playerRedisRepository.DeleteAsync(accountId, request.JobType); + } +} diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs index 762632d..7a86382 100644 --- a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs @@ -1,4 +1,5 @@ using Fantasy.Server.Domain.Account.Entity; +using Fantasy.Server.Domain.Player.Entity; using Microsoft.EntityFrameworkCore; namespace Fantasy.Server.Global.Infrastructure; @@ -11,6 +12,12 @@ public AppDbContext(DbContextOptions options) } public DbSet Accounts => Set(); + public DbSet Players => Set(); + public DbSet PlayerResources => Set(); + public DbSet PlayerStages => Set(); + public DbSet PlayerSessions => Set(); + public DbSet PlayerWeapons => Set(); + public DbSet PlayerSkills => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContextFactory.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContextFactory.cs new file mode 100644 index 0000000..280a2e5 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Fantasy.Server.Global.Infrastructure; + +public class AppDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var connectionString = configuration.GetConnectionString("Database") + ?? throw new InvalidOperationException("데이터베이스 연결 문자열이 설정되지 않았습니다."); + + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(connectionString); + + return new AppDbContext(optionsBuilder.Options); + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.Designer.cs b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.Designer.cs new file mode 100644 index 0000000..c3bf5ef --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.Designer.cs @@ -0,0 +1,257 @@ +// +using System; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260406154118_AddPlayerTables")] + partial class AddPlayerTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fantasy.Server.Domain.Account.Entity.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsNewAccount") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("account", "account"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.PrimitiveCollection("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastWeaponId") + .HasColumnType("integer"); + + b.Property("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "JobType") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.cs b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.cs new file mode 100644 index 0000000..42e6a2f --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260406154118_AddPlayerTables.cs @@ -0,0 +1,163 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + /// + public partial class AddPlayerTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "player"); + + migrationBuilder.CreateTable( + name: "player", + schema: "player", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AccountId = table.Column(type: "bigint", nullable: false), + JobType = table.Column(type: "text", nullable: false), + Level = table.Column(type: "bigint", nullable: false, defaultValue: 1L), + MaxStage = table.Column(type: "bigint", nullable: false, defaultValue: 1L), + LastWeaponId = table.Column(type: "integer", nullable: true), + ActiveSkills = table.Column(type: "integer[]", nullable: false, defaultValueSql: "ARRAY[]::integer[]"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "player_resource", + schema: "player", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column(type: "bigint", nullable: false), + Gold = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + Exp = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + EnhancementScroll = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + Mithril = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + Sp = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player_resource", x => x.Id); + table.ForeignKey( + name: "FK_player_resource_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "player_skill", + schema: "player", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column(type: "bigint", nullable: false), + SkillId = table.Column(type: "integer", nullable: false), + IsUnlocked = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player_skill", x => x.Id); + table.ForeignKey( + name: "FK_player_skill_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "player_weapon", + schema: "player", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column(type: "bigint", nullable: false), + WeaponId = table.Column(type: "integer", nullable: false), + Count = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + EnhancementLevel = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + AwakeningCount = table.Column(type: "bigint", nullable: false, defaultValue: 0L) + }, + constraints: table => + { + table.PrimaryKey("PK_player_weapon", x => x.Id); + table.ForeignKey( + name: "FK_player_weapon_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_player_AccountId_JobType", + schema: "player", + table: "player", + columns: new[] { "AccountId", "JobType" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_player_resource_PlayerId", + schema: "player", + table: "player_resource", + column: "PlayerId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_player_skill_PlayerId_SkillId", + schema: "player", + table: "player_skill", + columns: new[] { "PlayerId", "SkillId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_player_weapon_PlayerId_WeaponId", + schema: "player", + table: "player_weapon", + columns: new[] { "PlayerId", "WeaponId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "player_resource", + schema: "player"); + + migrationBuilder.DropTable( + name: "player_skill", + schema: "player"); + + migrationBuilder.DropTable( + name: "player_weapon", + schema: "player"); + + migrationBuilder.DropTable( + name: "player", + schema: "player"); + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.Designer.cs b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.Designer.cs new file mode 100644 index 0000000..cde5c8b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.Designer.cs @@ -0,0 +1,316 @@ +// +using System; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260407052845_NormalizePlayerTables")] + partial class NormalizePlayerTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fantasy.Server.Domain.Account.Entity.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsNewAccount") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("account", "account"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "JobType") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property("LastWeaponId") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_session", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_stage", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerSession", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerStage", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.cs b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.cs new file mode 100644 index 0000000..d930d28 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260407052845_NormalizePlayerTables.cs @@ -0,0 +1,156 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + /// + public partial class NormalizePlayerTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // player 테이블에서 정규화 대상 컬럼 제거 + migrationBuilder.DropColumn( + name: "ActiveSkills", + schema: "player", + table: "player"); + + migrationBuilder.DropColumn( + name: "LastWeaponId", + schema: "player", + table: "player"); + + migrationBuilder.DropColumn( + name: "MaxStage", + schema: "player", + table: "player"); + + // player 테이블에 Exp 추가 + migrationBuilder.AddColumn( + name: "Exp", + schema: "player", + table: "player", + type: "bigint", + nullable: false, + defaultValue: 0L); + + // player_resource 테이블에서 Exp 제거 + migrationBuilder.DropColumn( + name: "Exp", + schema: "player", + table: "player_resource"); + + // player_stage 테이블 생성 + migrationBuilder.CreateTable( + name: "player_stage", + schema: "player", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column(type: "bigint", nullable: false), + MaxStage = table.Column(type: "bigint", nullable: false, defaultValue: 1L) + }, + constraints: table => + { + table.PrimaryKey("PK_player_stage", x => x.Id); + table.ForeignKey( + name: "FK_player_stage_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_player_stage_PlayerId", + schema: "player", + table: "player_stage", + column: "PlayerId", + unique: true); + + // player_session 테이블 생성 + migrationBuilder.CreateTable( + name: "player_session", + schema: "player", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column(type: "bigint", nullable: false), + LastWeaponId = table.Column(type: "integer", nullable: true), + ActiveSkills = table.Column(type: "integer[]", nullable: false, defaultValueSql: "ARRAY[]::integer[]"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player_session", x => x.Id); + table.ForeignKey( + name: "FK_player_session_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_player_session_PlayerId", + schema: "player", + table: "player_session", + column: "PlayerId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "player_session", + schema: "player"); + + migrationBuilder.DropTable( + name: "player_stage", + schema: "player"); + + migrationBuilder.DropColumn( + name: "Exp", + schema: "player", + table: "player"); + + migrationBuilder.AddColumn( + name: "Exp", + schema: "player", + table: "player_resource", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "MaxStage", + schema: "player", + table: "player", + type: "bigint", + nullable: false, + defaultValue: 1L); + + migrationBuilder.AddColumn( + name: "LastWeaponId", + schema: "player", + table: "player", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ActiveSkills", + schema: "player", + table: "player", + type: "integer[]", + nullable: false, + defaultValueSql: "ARRAY[]::integer[]"); + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs index 916a766..e8d4612 100644 --- a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs +++ b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs @@ -62,6 +62,251 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("account", "account"); }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId", "JobType") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property("LastWeaponId") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_session", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_stage", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerSession", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerStage", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/Fantasy-server/Fantasy.Server/Program.cs b/Fantasy-server/Fantasy.Server/Program.cs index 75fb8aa..c210e84 100644 --- a/Fantasy-server/Fantasy.Server/Program.cs +++ b/Fantasy-server/Fantasy.Server/Program.cs @@ -1,5 +1,6 @@ using Fantasy.Server.Domain.Account.Config; using Fantasy.Server.Domain.Auth.Config; +using Fantasy.Server.Domain.Player.Config; using Fantasy.Server.Global.Config; using Fantasy.Server.Global.Security.Config; using Gamism.SDK.Extensions.AspNetCore; @@ -22,6 +23,7 @@ builder.Services.AddAccountServices(); builder.Services.AddAuthServices(); +builder.Services.AddPlayerServices(); builder.Services.AddSecurityServices(); var app = builder.Build(); diff --git a/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs new file mode 100644 index 0000000..d43e1e3 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs @@ -0,0 +1,162 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; +using PlayerResourceEntity = Fantasy.Server.Domain.Player.Entity.PlayerResource; + +namespace Fantasy.Test.Player.Service; + +public class EndPlayerSessionServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly EndPlayerSessionService _sut; + private readonly EndPlayerSessionRequest _request = new(JobType.Warrior, 1, [1, 2], 5000L, 3000L); + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResourceEntity.Create(1L)); + + _sut = new EndPlayerSessionService( + _playerRepository, _playerResourceRepository, + _playerSessionRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 세션_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerSessionRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Exp가_플레이어에_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Gold가_재화에_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerResourceRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class Gold_Exp가_null일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly EndPlayerSessionService _sut; + private readonly EndPlayerSessionRequest _request = new(JobType.Archer, 1, [], null, null); + + public Gold_Exp가_null일_때() + { + _currentUserProvider.GetAccountId().Returns(2L); + _playerRepository.FindByAccountAndJobAsync(2L, JobType.Archer) + .Returns(PlayerEntity.Create(2L, JobType.Archer)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(2L)); + + _sut = new EndPlayerSessionService( + _playerRepository, _playerResourceRepository, + _playerSessionRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 세션_데이터는_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerSessionRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task 플레이어_Exp가_업데이트되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.DidNotReceive().UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task 재화가_업데이트되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerResourceRepository.DidNotReceive().UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(2L, JobType.Archer); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly EndPlayerSessionService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(99L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any(), Arg.Any()) + .Returns((PlayerEntity?)null); + + _sut = new EndPlayerSessionService( + _playerRepository, _playerResourceRepository, + _playerSessionRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new EndPlayerSessionRequest(JobType.Mage, 1, [], null, null); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs new file mode 100644 index 0000000..cb23837 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs @@ -0,0 +1,225 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; +using PlayerResourceEntity = Fantasy.Server.Domain.Player.Entity.PlayerResource; + +namespace Fantasy.Test.Player.Service; + +public class InitPlayerServiceTests +{ + public class 캐시가_있을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly InitPlayerService _sut; + private readonly InitPlayerRequest _request = new(JobType.Warrior); + private readonly PlayerDataResponse _cached = new( + JobType.Warrior, 5L, 3L, null, [], 1000L, 2000L, 0L, 0L, 0L, [], []); + + public 캐시가_있을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns(_cached); + + _sut = new InitPlayerService( + _playerRepository, _playerResourceRepository, + _playerStageRepository, _playerSessionRepository, + _playerWeaponRepository, _playerSkillRepository, + _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 캐시된_데이터가_반환된다() + { + var (data, _) = await _sut.ExecuteAsync(_request); + + data.Should().Be(_cached); + } + + [Fact] + public async Task DB_조회가_발생하지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.DidNotReceive().FindByAccountAndJobAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task isNew가_false로_반환된다() + { + var (_, isNew) = await _sut.ExecuteAsync(_request); + + isNew.Should().BeFalse(); + } + } + + public class 신규_플레이어일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly InitPlayerService _sut; + private readonly InitPlayerRequest _request = new(JobType.Warrior); + + public 신규_플레이어일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns((PlayerDataResponse?)null); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior).Returns((PlayerEntity?)null); + _playerRepository.SaveAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + _playerResourceRepository.SaveAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + _playerStageRepository.SaveAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + _playerSessionRepository.SaveAsync(Arg.Any()) + .Returns(callInfo => callInfo.Arg()); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + _sut = new InitPlayerService( + _playerRepository, _playerResourceRepository, + _playerStageRepository, _playerSessionRepository, + _playerWeaponRepository, _playerSkillRepository, + _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 플레이어_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.Received(1).SaveAsync(Arg.Any()); + } + + [Fact] + public async Task 재화_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerResourceRepository.Received(1).SaveAsync(Arg.Any()); + } + + [Fact] + public async Task 스테이지_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerStageRepository.Received(1).SaveAsync(Arg.Any()); + } + + [Fact] + public async Task 세션_데이터가_저장된다() + { + await _sut.ExecuteAsync(_request); + + await _playerSessionRepository.Received(1).SaveAsync(Arg.Any()); + } + + [Fact] + public async Task isNew가_true로_반환된다() + { + var (_, isNew) = await _sut.ExecuteAsync(_request); + + isNew.Should().BeTrue(); + } + + [Fact] + public async Task Redis에_플레이어_데이터가_캐싱된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1) + .SetPlayerDataAsync(1L, JobType.Warrior, Arg.Any()); + } + } + + public class 기존_플레이어일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly InitPlayerService _sut; + private readonly InitPlayerRequest _request = new(JobType.Warrior); + + public 기존_플레이어일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns((PlayerDataResponse?)null); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResourceEntity.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + _sut = new InitPlayerService( + _playerRepository, _playerResourceRepository, + _playerStageRepository, _playerSessionRepository, + _playerWeaponRepository, _playerSkillRepository, + _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 플레이어_데이터가_저장되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.DidNotReceive().SaveAsync(Arg.Any()); + } + + [Fact] + public async Task isNew가_false로_반환된다() + { + var (_, isNew) = await _sut.ExecuteAsync(_request); + + isNew.Should().BeFalse(); + } + + [Fact] + public async Task 기존_데이터가_반환된다() + { + var (data, _) = await _sut.ExecuteAsync(_request); + + data.JobType.Should().Be(JobType.Warrior); + data.Level.Should().Be(1L); + } + + [Fact] + public async Task Redis에_플레이어_데이터가_캐싱된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1) + .SetPlayerDataAsync(1L, JobType.Warrior, Arg.Any()); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerLevelServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerLevelServiceTests.cs new file mode 100644 index 0000000..7054b02 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerLevelServiceTests.cs @@ -0,0 +1,76 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerLevelServiceTests +{ + public class 플레이어가_존재할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerLevelService _sut; + private readonly UpdatePlayerLevelRequest _request = new(JobType.Warrior, 10L); + + public 플레이어가_존재할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + + _sut = new UpdatePlayerLevelService(_playerRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 플레이어_레벨이_업데이트된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerLevelService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any(), Arg.Any()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerLevelService(_playerRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerLevelRequest(JobType.Warrior, 5L); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerResourceServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerResourceServiceTests.cs new file mode 100644 index 0000000..e566237 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerResourceServiceTests.cs @@ -0,0 +1,96 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; +using PlayerResourceEntity = Fantasy.Server.Domain.Player.Entity.PlayerResource; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerResourceServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerResourceService _sut; + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResourceEntity.Create(1L)); + + _sut = new UpdatePlayerResourceService( + _playerRepository, _playerResourceRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 재화가_업데이트된다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, 10L, 5L, 20L); + + await _sut.ExecuteAsync(request); + + await _playerResourceRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task 일부_필드만_있어도_업데이트된다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, 10L, null, null); + + await _sut.ExecuteAsync(request); + + await _playerResourceRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, null, null, 5L); + + await _sut.ExecuteAsync(request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerResourceService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any(), Arg.Any()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerResourceService( + _playerRepository, _playerResourceRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerResourceRequest(JobType.Warrior, 10L, null, null); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerSkillServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerSkillServiceTests.cs new file mode 100644 index 0000000..8c5acff --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerSkillServiceTests.cs @@ -0,0 +1,84 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerSkillServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerSkillService _sut; + private readonly List _skills = [new(1, true)]; + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + + _sut = new UpdatePlayerSkillService( + _playerRepository, _playerSkillRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 스킬_Upsert가_호출된다() + { + var request = new UpdatePlayerSkillRequest(JobType.Warrior, _skills); + + await _sut.ExecuteAsync(request); + + await _playerSkillRepository.Received(1).UpsertRangeAsync(Arg.Any(), _skills); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var request = new UpdatePlayerSkillRequest(JobType.Warrior, _skills); + + await _sut.ExecuteAsync(request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerSkillService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any(), Arg.Any()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerSkillService( + _playerRepository, _playerSkillRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerSkillRequest(JobType.Warrior, [new(1, true)]); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerStageServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerStageServiceTests.cs new file mode 100644 index 0000000..cceb65f --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerStageServiceTests.cs @@ -0,0 +1,114 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerStageServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerStageService _sut; + private readonly UpdatePlayerStageRequest _request = new(JobType.Warrior, 5L); + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + + _sut = new UpdatePlayerStageService( + _playerRepository, _playerStageRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 스테이지가_업데이트된다() + { + await _sut.ExecuteAsync(_request); + + await _playerStageRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + await _sut.ExecuteAsync(_request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerStageService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any(), Arg.Any()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerStageService( + _playerRepository, _playerStageRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerStageRequest(JobType.Warrior, 3L); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync(); + } + } + + public class 스테이지_데이터가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerStageService _sut; + + public 스테이지_데이터가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns((PlayerStage?)null); + + _sut = new UpdatePlayerStageService( + _playerRepository, _playerStageRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerStageRequest(JobType.Warrior, 3L); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync(); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerWeaponServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerWeaponServiceTests.cs new file mode 100644 index 0000000..7054038 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Service/UpdatePlayerWeaponServiceTests.cs @@ -0,0 +1,84 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Security.Provider; +using FluentAssertions; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; +using NSubstitute; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Player.Service; + +public class UpdatePlayerWeaponServiceTests +{ + public class 정상_요청일_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerWeaponService _sut; + private readonly List _weapons = [new(1, 2L, 1L, 0L)]; + + public 정상_요청일_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + + _sut = new UpdatePlayerWeaponService( + _playerRepository, _playerWeaponRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task 무기_Upsert가_호출된다() + { + var request = new UpdatePlayerWeaponRequest(JobType.Warrior, _weapons); + + await _sut.ExecuteAsync(request); + + await _playerWeaponRepository.Received(1).UpsertRangeAsync(Arg.Any(), _weapons); + } + + [Fact] + public async Task Redis_캐시가_무효화된다() + { + var request = new UpdatePlayerWeaponRequest(JobType.Warrior, _weapons); + + await _sut.ExecuteAsync(request); + + await _playerRedisRepository.Received(1).DeleteAsync(1L, JobType.Warrior); + } + } + + public class 플레이어가_존재하지_않을_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly UpdatePlayerWeaponService _sut; + + public 플레이어가_존재하지_않을_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAndJobAsync(Arg.Any(), Arg.Any()) + .Returns((PlayerEntity?)null); + + _sut = new UpdatePlayerWeaponService( + _playerRepository, _playerWeaponRepository, _playerRedisRepository, _currentUserProvider); + } + + [Fact] + public async Task NotFoundException이_발생한다() + { + var request = new UpdatePlayerWeaponRequest(JobType.Warrior, [new(1, 1L, 0L, 0L)]); + + var act = async () => await _sut.ExecuteAsync(request); + + await act.Should().ThrowAsync(); + } + } +} From c99f18c952747fa6d4d8d8675e86877907b202e1 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 15:04:34 +0900 Subject: [PATCH 02/13] =?UTF-8?q?update:=20ICurrentUserProvider=EC=97=90?= =?UTF-8?q?=20GetAccountId=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6 --- .../Global/Security/Provider/CurrentUserProvider.cs | 11 +++++++++++ .../Global/Security/Provider/ICurrentUserProvider.cs | 1 + 2 files changed, 12 insertions(+) diff --git a/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs b/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs index 50c7d1f..9e66001 100644 --- a/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs +++ b/Fantasy-server/Fantasy.Server/Global/Security/Provider/CurrentUserProvider.cs @@ -33,6 +33,17 @@ public string GetEmail() ?? throw new UnauthorizedException("이메일 클레임을 찾을 수 없습니다."); } + public long GetAccountId() + { + var sub = GetUser().FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? throw new UnauthorizedException("사용자 ID 클레임을 찾을 수 없습니다."); + + if (!long.TryParse(sub, out var accountId)) + throw new UnauthorizedException("사용자 ID 클레임이 유효하지 않습니다."); + + return accountId; + } + private ClaimsPrincipal GetUser() { var context = _httpContextAccessor.HttpContext diff --git a/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs b/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs index e9ae290..36ffbda 100644 --- a/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs +++ b/Fantasy-server/Fantasy.Server/Global/Security/Provider/ICurrentUserProvider.cs @@ -6,4 +6,5 @@ public interface ICurrentUserProvider { Task GetAccountAsync(); string GetEmail(); + long GetAccountId(); } From 31e03cfa66dd59611b8b632bbf476a000a1eb047 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 15:04:41 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore:=20flows.md=EB=A5=BC=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EC=B1=85=EC=9E=84=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EB=A1=9C=20=EB=8B=A8=EC=88=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Fantasy-server/.claude/rules/flows.md | 76 ++++++++------------------- 1 file changed, 23 insertions(+), 53 deletions(-) diff --git a/Fantasy-server/.claude/rules/flows.md b/Fantasy-server/.claude/rules/flows.md index 97fdc8d..3be384f 100644 --- a/Fantasy-server/.claude/rules/flows.md +++ b/Fantasy-server/.claude/rules/flows.md @@ -1,64 +1,34 @@ -## API Flow Diagrams +## Request Flow -### POST /v1/account/signup — 회원가입 +All API endpoints follow this layer order: -```mermaid -sequenceDiagram - Client->>AccountController: POST /v1/account/signup - AccountController->>CreateAccountService: ExecuteAsync(request) - CreateAccountService->>AccountRepository: ExistsByEmailAsync(email) - AccountRepository-->>CreateAccountService: true → ConflictException - AccountRepository-->>CreateAccountService: false → 계속 - CreateAccountService->>CreateAccountService: BCrypt.HashPassword(password) - CreateAccountService->>AccountRepository: SaveAsync(account) - AccountRepository->>PostgreSQL: INSERT account - AccountController-->>Client: 201 Created +``` +Client → Controller → Service → Repository → PostgreSQL / Redis ``` -### DELETE /v1/account — 회원탈퇴 +### Layer Responsibilities -```mermaid -sequenceDiagram - Client->>AccountController: DELETE /v1/account (JWT) - AccountController->>DeleteAccountService: ExecuteAsync(request) - DeleteAccountService->>CurrentUserProvider: GetEmail() - CurrentUserProvider-->>DeleteAccountService: email (from JWT claims) - DeleteAccountService->>AccountRepository: FindByEmailAsync(email) - AccountRepository-->>DeleteAccountService: null → UnauthorizedException - AccountRepository-->>DeleteAccountService: account - DeleteAccountService->>DeleteAccountService: BCrypt.Verify(password) - DeleteAccountService->>AccountRepository: DeleteAsync(account) - AccountRepository->>PostgreSQL: DELETE account -``` +| Layer | Responsibility | +|---|---| +| Controller | Receive request, call service, return `CommonApiResponse` | +| Service | Business logic, exception handling (`NotFoundException`, `ConflictException`, etc.) | +| Repository | Exclusive DB / Redis access (`AppDbContext`, `IConnectionMultiplexer`) | + +### Authenticated Endpoints -### POST /v1/auth/login — 로그인 +Controllers or actions annotated with `[Authorize]` pass through `JwtAuthenticationFilter` on every request. +When the service needs the current user, it extracts claims via `ICurrentUserProvider`. -```mermaid -sequenceDiagram - Client->>AuthController: POST /v1/auth/login - Note over AuthController: RateLimit "login" (5 req/min) - AuthController->>LoginService: ExecuteAsync(request) - LoginService->>AccountRepository: FindByEmailAsync(email) - AccountRepository-->>LoginService: null → UnauthorizedException - AccountRepository-->>LoginService: account - LoginService->>LoginService: BCrypt.Verify(password) - LoginService->>JwtProvider: GenerateAccessToken(account) - LoginService->>JwtProvider: GenerateRefreshToken() - LoginService->>RefreshTokenRedisRepository: SaveAsync(id, refreshToken, 30d TTL) - RefreshTokenRedisRepository->>Redis: SET key value EX - AuthController-->>Client: TokenResponse (accessToken, refreshToken, expiresAt) ``` +Client → JwtAuthenticationFilter → Controller → Service → ICurrentUserProvider (claims) → Repository +``` + +### Redis Cache Pattern -### POST /v1/auth/logout — 로그아웃 +Always check Redis first on reads; only query the DB on a cache miss, then populate the cache. +After any write (update / delete), invalidate the relevant key. -```mermaid -sequenceDiagram - Client->>AuthController: POST /v1/auth/logout (JWT) - Note over AuthController: [Authorize] → JwtAuthenticationFilter - AuthController->>LogoutService: ExecuteAsync() - LogoutService->>CurrentUserProvider: GetAccountAsync() - CurrentUserProvider->>AccountRepository: FindByEmailAsync(email from claims) - AccountRepository-->>CurrentUserProvider: account - LogoutService->>RefreshTokenRedisRepository: DeleteAsync(account.Id) - RefreshTokenRedisRepository->>Redis: DEL key +``` +Read : Redis hit → return / miss → query DB → Redis SET → return +Write: update DB → Redis DEL ``` From 32d8e1f7fb0b02633a96c98e0de055a28cc99f34 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 15:05:09 +0900 Subject: [PATCH 04/13] =?UTF-8?q?chore:=20code-review,=20plan-deep-dive=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=20=EB=B0=8F=20specs=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.claude/skills/code-review/SKILL.md | 158 +++++ .../.claude/skills/plan-deep-dive/SKILL.md | 8 + .../specs/player-domain-refactoring.md | 578 ++++++++++++++++++ 3 files changed, 744 insertions(+) create mode 100644 Fantasy-server/.claude/skills/code-review/SKILL.md create mode 100644 Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md create mode 100644 Fantasy-server/.claude/specs/player-domain-refactoring.md diff --git a/Fantasy-server/.claude/skills/code-review/SKILL.md b/Fantasy-server/.claude/skills/code-review/SKILL.md new file mode 100644 index 0000000..d4901d0 --- /dev/null +++ b/Fantasy-server/.claude/skills/code-review/SKILL.md @@ -0,0 +1,158 @@ +--- +name: code-review +description: Run a structured checklist over changed files against project conventions (architecture, code style, EF Core, DI, testing). Produces a ✓/⚠/✗ report in Korean. +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Read, Glob, Grep +context: fork +--- + +# Code Review + +Review changed files against project conventions and produce a Korean report. + +## Step 1 — Determine Scope + +1. Get current branch: `git branch --show-current` +2. Determine base branch: + - If an argument is provided (e.g., `/code-review develop`) → use that branch as base + - Otherwise → use `main` as base +3. List changed files: `git diff {base}...HEAD --name-only` +4. Get detailed diff: `git diff {base}...HEAD` +5. Get commit list: `git log {base}..HEAD --oneline` + +## Step 2 — Read Changed Files + +- Read each changed `.cs` file with the Read tool. +- Read non-`.cs` files (`.json`, `.md`, `.csproj`) only if relevant to the checklist. + +## Step 3 — Apply Checklist + +Review only files that were actually changed. Skip categories with no relevant changes. + +--- + +### [ARCH] Architecture & Layering + +- [ ] Controllers depend only on service interfaces — no concrete service classes +- [ ] Services depend only on repository interfaces — no direct `AppDbContext` access +- [ ] Only repositories access `AppDbContext` +- [ ] New domain follows `Domain/{Name}/` structure (Config / Controller / Dto / Entity / Repository / Service) +- [ ] New domain DI extension method is called from `Program.cs` + +### [STYLE] C# Code Style + +- [ ] `var` used only when type is obvious from the right-hand side +- [ ] Private fields: `_camelCase`; everything else: `PascalCase` +- [ ] Dependencies injected via constructor into `private readonly` fields +- [ ] Constructors contain no logic — assignments only +- [ ] Single-expression methods use expression-body (`=>`) +- [ ] No XML doc comments (`///`) unless explicitly requested +- [ ] No `#region` blocks + +### [DTO] DTO Pattern + +- [ ] `record` types with positional parameters +- [ ] DataAnnotations applied directly on parameters (`[Required]`, `[MaxLength]`, etc.) +- [ ] Requests in `Dto/Request/`, Responses in `Dto/Response/` + +### [ENTITY] Entity Pattern + +- [ ] All setters are `private set` +- [ ] Static factory method `Create(...)` used instead of public constructor +- [ ] Timestamps use `DateTime.UtcNow` +- [ ] No DataAnnotations on entities — EF config via Fluent API only +- [ ] EF Fluent config implements `IEntityTypeConfiguration` in `Entity/Config/` +- [ ] `DbSet` registered in `AppDbContext` +- [ ] Table/column names: `snake_case`, schema-qualified (`"schema"."table"`) +- [ ] Enums use `HasConversion()` + +### [SERVICE] Service Pattern + +- [ ] One use case = one class + one interface +- [ ] Interface exposes exactly one `ExecuteAsync` method +- [ ] Business exceptions use Gamism.SDK types (`ConflictException`, `NotFoundException`, etc.) +- [ ] No empty catch blocks; no bare re-throw without added context + +### [REPO] Repository Pattern + +- [ ] Read-only queries use `AsNoTracking()` +- [ ] `SaveAsync` checks Detached state before calling `AddAsync` + +### [CONTROLLER] Controller Pattern + +- [ ] Return type is `CommonApiResponse` (Gamism.SDK) +- [ ] Only service interfaces injected — no concrete classes +- [ ] Rate limiting applied with `[EnableRateLimiting("login"|"game")]` where needed +- [ ] Authenticated endpoints annotated with `[Authorize]` + +### [ASYNC] Async Pattern + +- [ ] All I/O methods are `async Task` / `async Task` +- [ ] No `.Result` or `.Wait()` calls +- [ ] No unintentional fire-and-forget — every async call is awaited + +### [SECURITY] Security + +- [ ] No plain-text passwords — `BCrypt.Net.BCrypt.HashPassword` required +- [ ] No hardcoded secrets (API keys, passwords, connection strings) +- [ ] No sensitive data (passwords, tokens) written to logs + +### [REDIS] Redis Cache Pattern (only when Redis code changed) + +- [ ] Read flow: check Redis first → on miss query DB → SET cache → return +- [ ] Write flow: update DB → DEL cache key (invalidate) + +### [DI] DI Registration + +- [ ] Domain services registered via `{Name}ServiceConfig.cs` extension method +- [ ] Extension method called from `Program.cs` + +### [TEST] Tests (only when Fantasy.Test files changed) + +- [ ] Test class name: `{ServiceName}Test` +- [ ] Test method name: `{MethodName}_{Scenario}_{ExpectedResult}` +- [ ] No direct `AppDbContext` mocks — mock repository interfaces instead +- [ ] `NSubstitute` used for mocking +- [ ] Arrange / Act / Assert structure with blank line separators + +--- + +## Step 4 — Output Report + +Write the report in Korean using the following format: + +``` +## 코드 리뷰 리포트 + +### 변경 범위 +- 브랜치: {current} ← {base} +- 변경 파일 수: N개 +- 커밋: {commit summary} + +--- + +### [ARCH] 아키텍처 · 레이어링 +✓ ... +⚠ ... +✗ ... + +(repeat for each category that has changes; skip empty categories) + +--- + +### 종합 결과 +| 등급 | 건수 | +|------|------| +| ✓ 통과 | N | +| ⚠ 경고 | N | +| ✗ 오류 | N | + +총 N개 항목 검토 — 오류 N건, 경고 N건 +``` + +**Symbol meanings:** +- ✓ — convention followed +- ⚠ — recommendation (optional fix) +- ✗ — convention violation (fix required) + +For each ✗ item, include the file name, approximate line, and suggested fix. +Skip categories with no relevant changes. diff --git a/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md b/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md new file mode 100644 index 0000000..31577f5 --- /dev/null +++ b/Fantasy-server/.claude/skills/plan-deep-dive/SKILL.md @@ -0,0 +1,8 @@ +--- +name: plan-deep-dive +description: Conduct an in-depth structured interview with the user to uncover non-obvious requirements, tradeoffs, and constraints, then produce a detailed implementation spec file. +argument-hint: [instructions] +allowed-tools: AskUserQuestion, Write +--- + +Follow the user instructions and interview me in detail using the AskUserQuestionTool about literally anything: technical implementation, UI & UX, concerns, tradeoffs, etc. but make sure the questions are not obvious. be very in-depth and continue interviewing me continually until it's complete. then, write the spec to a file. $ARGUMENTS \ No newline at end of file diff --git a/Fantasy-server/.claude/specs/player-domain-refactoring.md b/Fantasy-server/.claude/specs/player-domain-refactoring.md new file mode 100644 index 0000000..a54d3c5 --- /dev/null +++ b/Fantasy-server/.claude/specs/player-domain-refactoring.md @@ -0,0 +1,578 @@ +# Player Domain Refactoring Spec + +## Summary + +Normalize the Player table and split the single `PATCH /v1/player/change` endpoint into domain-specific controllers and services. + +--- + +## 1. DB Schema Changes + +### 1-1. Player table (modified) + +Remove `MaxStage`, `LastWeaponId`, `ActiveSkills` — keep `Level` and `Exp` (move Exp here from PlayerResource). + +``` +player.player +├── id BIGSERIAL PK +├── account_id BIGINT NOT NULL +├── job_type VARCHAR NOT NULL +├── level BIGINT NOT NULL DEFAULT 1 +├── exp BIGINT NOT NULL DEFAULT 0 ← moved from PlayerResource +├── created_at TIMESTAMPTZ NOT NULL +└── updated_at TIMESTAMPTZ NOT NULL + +UNIQUE INDEX: (account_id, job_type) +``` + +### 1-2. PlayerResource table (modified) + +Remove `Gold`, `Exp` — Gold moves to PlayerResource for now? + +Wait: Gold and Exp are updated together in EndPlayerSession. User said Exp moves to Player. Gold stays in PlayerResource. + +``` +player.player_resource +├── id BIGSERIAL PK +├── player_id BIGINT NOT NULL UNIQUE (FK → player.player) +├── gold BIGINT NOT NULL DEFAULT 0 +├── enhancement_scroll BIGINT NOT NULL DEFAULT 0 +├── mithril BIGINT NOT NULL DEFAULT 0 +├── sp BIGINT NOT NULL DEFAULT 0 +└── updated_at TIMESTAMPTZ NOT NULL + +Note: Exp removed (moved to player table) +``` + +### 1-3. PlayerStage table (new) + +``` +player.player_stage +├── id BIGSERIAL PK +├── player_id BIGINT NOT NULL UNIQUE (FK → player.player, CASCADE DELETE) +└── max_stage BIGINT NOT NULL DEFAULT 1 +``` + +### 1-4. PlayerSession table (new) + +``` +player.player_session +├── id BIGSERIAL PK +├── player_id BIGINT NOT NULL UNIQUE (FK → player.player, CASCADE DELETE) +├── last_weapon_id INT NULL +├── active_skills INT[] NOT NULL DEFAULT '{}' +└── updated_at TIMESTAMPTZ NOT NULL +``` + +--- + +## 2. Entity Changes + +### 2-1. Player.cs (modified) + +**Add:** `Exp` property +**Remove:** `MaxStage`, `LastWeaponId`, `ActiveSkills` +**Modify:** `Create()`, `UpdateSessionEnd()` removed, add `UpdateExp()` + +```csharp +public class Player +{ + public long Id { get; private set; } + public long AccountId { get; private set; } + public JobType JobType { get; private set; } + public long Level { get; private set; } + public long Exp { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime UpdatedAt { get; private set; } + + public static Player Create(long accountId, JobType jobType) => new() + { + AccountId = accountId, + JobType = jobType, + Level = 1, + Exp = 0, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + public void UpdateLevel(long level) + { + Level = level; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateExpAndGold(long exp) // called from EndPlayerSession + { + Exp = exp; + UpdatedAt = DateTime.UtcNow; + } +} +``` + +### 2-2. PlayerResource.cs (modified) + +**Remove:** `Gold`, `Exp` properties and related methods +**Keep:** `EnhancementScroll`, `Mithril`, `Sp` +**Add:** `Gold` stays (only Exp removed) + +```csharp +public class PlayerResource +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public long Gold { get; private set; } + public long EnhancementScroll { get; private set; } + public long Mithril { get; private set; } + public long Sp { get; private set; } + public DateTime UpdatedAt { get; private set; } + + public static PlayerResource Create(long playerId) => new() + { + PlayerId = playerId, + Gold = 0, + EnhancementScroll = 0, + Mithril = 0, + Sp = 0, + UpdatedAt = DateTime.UtcNow + }; + + public void UpdateGold(long gold) + { + Gold = gold; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateChangeData(long? enhancementScroll, long? mithril, long? sp) + { + if (enhancementScroll.HasValue) EnhancementScroll = enhancementScroll.Value; + if (mithril.HasValue) Mithril = mithril.Value; + if (sp.HasValue) Sp = sp.Value; + UpdatedAt = DateTime.UtcNow; + } +} +``` + +### 2-3. PlayerStage.cs (new) + +```csharp +// Domain/Player/Entity/PlayerStage.cs +public class PlayerStage +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public long MaxStage { get; private set; } + + public static PlayerStage Create(long playerId) => new() + { + PlayerId = playerId, + MaxStage = 1 + }; + + public void Update(long maxStage) + { + MaxStage = maxStage; + } +} +``` + +### 2-4. PlayerSession.cs (new) + +```csharp +// Domain/Player/Entity/PlayerSession.cs +public class PlayerSession +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public int? LastWeaponId { get; private set; } + public int[] ActiveSkills { get; private set; } = []; + public DateTime UpdatedAt { get; private set; } + + public static PlayerSession Create(long playerId) => new() + { + PlayerId = playerId, + LastWeaponId = null, + ActiveSkills = [], + UpdatedAt = DateTime.UtcNow + }; + + public void Update(int lastWeaponId, int[] activeSkills) + { + LastWeaponId = lastWeaponId; + ActiveSkills = activeSkills; + UpdatedAt = DateTime.UtcNow; + } +} +``` + +--- + +## 3. EF Core Config Changes + +### 3-1. PlayerConfig.cs (modified) + +Remove: `MaxStage`, `LastWeaponId`, `ActiveSkills` properties +Add: `Exp` property + +### 3-2. PlayerResourceConfig.cs (modified) + +Remove: `Exp` property + +### 3-3. PlayerStageConfig.cs (new) + +```csharp +// Domain/Player/Entity/Config/PlayerStageConfig.cs +public class PlayerStageConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_stage", "player"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedOnAdd(); + builder.Property(s => s.PlayerId).IsRequired(); + builder.Property(s => s.MaxStage).IsRequired().HasDefaultValue(1L); + builder.HasIndex(s => s.PlayerId).IsUnique(); + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} +``` + +### 3-4. PlayerSessionConfig.cs (new) + +```csharp +// Domain/Player/Entity/Config/PlayerSessionConfig.cs +public class PlayerSessionConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_session", "player"); + builder.HasKey(s => s.Id); + builder.Property(s => s.Id).ValueGeneratedOnAdd(); + builder.Property(s => s.PlayerId).IsRequired(); + builder.Property(s => s.LastWeaponId); + builder.Property(s => s.ActiveSkills) + .IsRequired() + .HasDefaultValueSql("ARRAY[]::integer[]"); + builder.Property(s => s.UpdatedAt).IsRequired(); + builder.HasIndex(s => s.PlayerId).IsUnique(); + builder.HasOne() + .WithOne() + .HasForeignKey(s => s.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + } +} +``` + +--- + +## 4. AppDbContext Changes + +Add `DbSet` for new entities: + +```csharp +public DbSet PlayerStages { get; set; } +public DbSet PlayerSessions { get; set; } +``` + +--- + +## 5. Repository Changes + +### New Interfaces & Implementations + +| Interface | Implementation | Responsibility | +|---|---|---| +| `IPlayerStageRepository` | `PlayerStageRepository` | PlayerStage CRUD | +| `IPlayerSessionRepository` | `PlayerSessionRepository` | PlayerSession CRUD | + +### IPlayerStageRepository + +```csharp +public interface IPlayerStageRepository +{ + Task FindByPlayerIdAsync(long playerId); + Task SaveAsync(PlayerStage stage); + Task UpdateAsync(PlayerStage stage); +} +``` + +### IPlayerSessionRepository + +```csharp +public interface IPlayerSessionRepository +{ + Task FindByPlayerIdAsync(long playerId); + Task SaveAsync(PlayerSession session); + Task UpdateAsync(PlayerSession session); +} +``` + +### IPlayerRepository (modified) + +No interface change needed — `FindByAccountAndJobAsync`, `SaveAsync`, `UpdateAsync` remain. + +### IPlayerResourceRepository (unchanged) + +--- + +## 6. DTO Changes + +### Removed + +- `UpdatePlayerChangeRequest` — deleted entirely (split into separate requests) + +### New Request DTOs + +```csharp +// Dto/Request/UpdatePlayerLevelRequest.cs +public record UpdatePlayerLevelRequest( + [Required] JobType JobType, + [Range(1, long.MaxValue)] long Level +); + +// Dto/Request/UpdatePlayerStageRequest.cs +public record UpdatePlayerStageRequest( + [Required] JobType JobType, + [Range(1, long.MaxValue)] long MaxStage +); + +// Dto/Request/UpdatePlayerResourceRequest.cs +public record UpdatePlayerResourceRequest( + [Required] JobType JobType, + [Range(0, long.MaxValue)] long? EnhancementScroll, + [Range(0, long.MaxValue)] long? Mithril, + [Range(0, long.MaxValue)] long? Sp +); + +// Dto/Request/UpdatePlayerWeaponRequest.cs +public record UpdatePlayerWeaponRequest( + [Required] JobType JobType, + [Required] List Weapons +); + +// Dto/Request/UpdatePlayerSkillRequest.cs +public record UpdatePlayerSkillRequest( + [Required] JobType JobType, + [Required] List Skills +); +``` + +### Modified Request DTOs + +```csharp +// EndPlayerSessionRequest.cs (modified — remove Gold field, keep Exp) +public record EndPlayerSessionRequest( + [Required] JobType JobType, + [Required] int LastWeaponId, + [Required] int[] ActiveSkills, + [Range(0, long.MaxValue)] long? Gold, + [Range(0, long.MaxValue)] long? Exp +); +``` + +### Modified Response DTOs + +```csharp +// PlayerDataResponse.cs (modified — Exp field stays, sourced from Player now) +// Structure unchanged from client perspective — only data source changes internally +``` + +--- + +## 7. Service Changes + +### Removed + +- `IUpdatePlayerChangeService` / `UpdatePlayerChangeService` — deleted + +### New Services + +| Interface | Implementation | Endpoint | +|---|---|---| +| `IUpdatePlayerLevelService` | `UpdatePlayerLevelService` | PATCH /v1/player/level | +| `IUpdatePlayerStageService` | `UpdatePlayerStageService` | PATCH /v1/player/stage | +| `IUpdatePlayerResourceService` | `UpdatePlayerResourceService` | PATCH /v1/player/resource | +| `IUpdatePlayerWeaponService` | `UpdatePlayerWeaponService` | PATCH /v1/player/weapon | +| `IUpdatePlayerSkillService` | `UpdatePlayerSkillService` | PATCH /v1/player/skill | + +### Modified Services + +**InitPlayerService** — add creation of `PlayerStage` and `PlayerSession` records on new player, read from both for response building. + +**EndPlayerSessionService** — write `LastWeaponId`/`ActiveSkills` to `PlayerSession` (not `Player`), write `Exp` to `Player`, write `Gold` to `PlayerResource`. + +--- + +## 8. Controller Changes + +### PlayerController (modified) + +``` +POST /v1/player/init → IInitPlayerService +PATCH /v1/player/level → IUpdatePlayerLevelService +``` + +### PlayerResourceController (new) + +``` +PATCH /v1/player/resource → IUpdatePlayerResourceService +``` + +### PlayerStageController (new) + +``` +PATCH /v1/player/stage → IUpdatePlayerStageService +``` + +### PlayerWeaponController (new) + +``` +PATCH /v1/player/weapon → IUpdatePlayerWeaponService +``` + +### PlayerSkillController (new) + +``` +PATCH /v1/player/skill → IUpdatePlayerSkillService +``` + +### PlayerSessionController (new) + +``` +PATCH /v1/player/session/end → IEndPlayerSessionService +``` + +All controllers: `[ApiController]`, `[Route("v1/player/...")]`, `[Authorize]` + +--- + +## 9. Redis Cache Strategy + +**Key format unchanged:** `player:{accountId}:{jobType}` + +**Rules:** +- All 5 PATCH endpoints → `DEL player:{accountId}:{jobType}` after DB write +- `POST /v1/player/init` → GET on cache miss → query DB → SET cache → return +- `PlayerDataResponse` structure unchanged from client perspective (Exp now sourced from Player table, but response field stays the same) + +--- + +## 10. DI Registration (PlayerServiceConfig.cs) + +Add new service and repository registrations: + +```csharp +// Repositories +services.AddScoped(); +services.AddScoped(); + +// Services +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); +services.AddScoped(); + +// Remove +// services.AddScoped(); +``` + +--- + +## 11. Migration Strategy + +1. Add EF Core migration: `NormalizePlayerTables` + - Adds `player_stage`, `player_session` tables + - Adds `exp` column to `player` + - Removes `max_stage`, `last_weapon_id`, `active_skills` from `player` + - Removes `exp` from `player_resource` + - Data migration SQL in `Up()`: + ```sql + -- Copy existing data before dropping columns + INSERT INTO player.player_stage (player_id, max_stage) + SELECT id, max_stage FROM player.player; + + INSERT INTO player.player_session (player_id, last_weapon_id, active_skills, updated_at) + SELECT id, last_weapon_id, active_skills, updated_at FROM player.player; + + UPDATE player.player p + SET exp = pr.exp + FROM player.player_resource pr + WHERE pr.player_id = p.id; + ``` + +2. Run `/db-migrate update` to apply + +--- + +## 12. Implementation Order + +``` +Phase 1 — Entities & EF Config + 1. Modify Player.cs (add Exp, remove MaxStage/LastWeaponId/ActiveSkills) + 2. Modify PlayerResource.cs (remove Exp) + 3. Create PlayerStage.cs + 4. Create PlayerSession.cs + 5. Modify PlayerConfig.cs + 6. Modify PlayerResourceConfig.cs + 7. Create PlayerStageConfig.cs + 8. Create PlayerSessionConfig.cs + 9. Update AppDbContext (add DbSets) + +Phase 2 — Migration + 10. /db-migrate add NormalizePlayerTables (with data migration SQL) + 11. /db-migrate update + +Phase 3 — Repositories + 12. Create IPlayerStageRepository + PlayerStageRepository + 13. Create IPlayerSessionRepository + PlayerSessionRepository + +Phase 4 — DTOs + 14. Delete UpdatePlayerChangeRequest.cs + 15. Create UpdatePlayerLevelRequest, UpdatePlayerStageRequest, + UpdatePlayerResourceRequest, UpdatePlayerWeaponRequest, UpdatePlayerSkillRequest + 16. Modify EndPlayerSessionRequest (remove nothing — Gold/Exp stay) + 17. Modify PlayerDataResponse (Exp source changes internally only) + +Phase 5 — Services + 18. Modify InitPlayerService (create PlayerStage + PlayerSession, read from them) + 19. Modify EndPlayerSessionService (write to PlayerSession, Player.Exp, PlayerResource.Gold) + 20. Delete UpdatePlayerChangeService + 21. Create UpdatePlayerLevelService + 22. Create UpdatePlayerStageService + 23. Create UpdatePlayerResourceService + 24. Create UpdatePlayerWeaponService + 25. Create UpdatePlayerSkillService + +Phase 6 — Controllers + 26. Modify PlayerController (add PATCH / for level) + 27. Create PlayerResourceController + 28. Create PlayerStageController + 29. Create PlayerWeaponController + 30. Create PlayerSkillController + 31. Create PlayerSessionController + +Phase 7 — DI & Verify + 32. Update PlayerServiceConfig.cs + 33. /test (build + all tests) +``` + +--- + +## 13. Test Coverage + +For each new service, test: + +| Service | Cases | +|---|---| +| `UpdatePlayerLevelService` | happy path, player not found | +| `UpdatePlayerStageService` | happy path, stage not found | +| `UpdatePlayerResourceService` | happy path, partial update (nulls), player not found | +| `UpdatePlayerWeaponService` | happy path, player not found | +| `UpdatePlayerSkillService` | happy path, player not found | +| `InitPlayerService` | new player (creates stage+session), existing player (cache hit, cache miss) | +| `EndPlayerSessionService` | happy path, player not found | + +Existing tests for `UpdatePlayerChangeService` → delete. From cda4369ffe9947f1d084d13119c7e359f99edb08 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 15:08:12 +0900 Subject: [PATCH 05/13] =?UTF-8?q?chore:=20specs=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/player-domain-refactoring.md | 578 ------------------ 1 file changed, 578 deletions(-) delete mode 100644 Fantasy-server/.claude/specs/player-domain-refactoring.md diff --git a/Fantasy-server/.claude/specs/player-domain-refactoring.md b/Fantasy-server/.claude/specs/player-domain-refactoring.md deleted file mode 100644 index a54d3c5..0000000 --- a/Fantasy-server/.claude/specs/player-domain-refactoring.md +++ /dev/null @@ -1,578 +0,0 @@ -# Player Domain Refactoring Spec - -## Summary - -Normalize the Player table and split the single `PATCH /v1/player/change` endpoint into domain-specific controllers and services. - ---- - -## 1. DB Schema Changes - -### 1-1. Player table (modified) - -Remove `MaxStage`, `LastWeaponId`, `ActiveSkills` — keep `Level` and `Exp` (move Exp here from PlayerResource). - -``` -player.player -├── id BIGSERIAL PK -├── account_id BIGINT NOT NULL -├── job_type VARCHAR NOT NULL -├── level BIGINT NOT NULL DEFAULT 1 -├── exp BIGINT NOT NULL DEFAULT 0 ← moved from PlayerResource -├── created_at TIMESTAMPTZ NOT NULL -└── updated_at TIMESTAMPTZ NOT NULL - -UNIQUE INDEX: (account_id, job_type) -``` - -### 1-2. PlayerResource table (modified) - -Remove `Gold`, `Exp` — Gold moves to PlayerResource for now? - -Wait: Gold and Exp are updated together in EndPlayerSession. User said Exp moves to Player. Gold stays in PlayerResource. - -``` -player.player_resource -├── id BIGSERIAL PK -├── player_id BIGINT NOT NULL UNIQUE (FK → player.player) -├── gold BIGINT NOT NULL DEFAULT 0 -├── enhancement_scroll BIGINT NOT NULL DEFAULT 0 -├── mithril BIGINT NOT NULL DEFAULT 0 -├── sp BIGINT NOT NULL DEFAULT 0 -└── updated_at TIMESTAMPTZ NOT NULL - -Note: Exp removed (moved to player table) -``` - -### 1-3. PlayerStage table (new) - -``` -player.player_stage -├── id BIGSERIAL PK -├── player_id BIGINT NOT NULL UNIQUE (FK → player.player, CASCADE DELETE) -└── max_stage BIGINT NOT NULL DEFAULT 1 -``` - -### 1-4. PlayerSession table (new) - -``` -player.player_session -├── id BIGSERIAL PK -├── player_id BIGINT NOT NULL UNIQUE (FK → player.player, CASCADE DELETE) -├── last_weapon_id INT NULL -├── active_skills INT[] NOT NULL DEFAULT '{}' -└── updated_at TIMESTAMPTZ NOT NULL -``` - ---- - -## 2. Entity Changes - -### 2-1. Player.cs (modified) - -**Add:** `Exp` property -**Remove:** `MaxStage`, `LastWeaponId`, `ActiveSkills` -**Modify:** `Create()`, `UpdateSessionEnd()` removed, add `UpdateExp()` - -```csharp -public class Player -{ - public long Id { get; private set; } - public long AccountId { get; private set; } - public JobType JobType { get; private set; } - public long Level { get; private set; } - public long Exp { get; private set; } - public DateTime CreatedAt { get; private set; } - public DateTime UpdatedAt { get; private set; } - - public static Player Create(long accountId, JobType jobType) => new() - { - AccountId = accountId, - JobType = jobType, - Level = 1, - Exp = 0, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - public void UpdateLevel(long level) - { - Level = level; - UpdatedAt = DateTime.UtcNow; - } - - public void UpdateExpAndGold(long exp) // called from EndPlayerSession - { - Exp = exp; - UpdatedAt = DateTime.UtcNow; - } -} -``` - -### 2-2. PlayerResource.cs (modified) - -**Remove:** `Gold`, `Exp` properties and related methods -**Keep:** `EnhancementScroll`, `Mithril`, `Sp` -**Add:** `Gold` stays (only Exp removed) - -```csharp -public class PlayerResource -{ - public long Id { get; private set; } - public long PlayerId { get; private set; } - public long Gold { get; private set; } - public long EnhancementScroll { get; private set; } - public long Mithril { get; private set; } - public long Sp { get; private set; } - public DateTime UpdatedAt { get; private set; } - - public static PlayerResource Create(long playerId) => new() - { - PlayerId = playerId, - Gold = 0, - EnhancementScroll = 0, - Mithril = 0, - Sp = 0, - UpdatedAt = DateTime.UtcNow - }; - - public void UpdateGold(long gold) - { - Gold = gold; - UpdatedAt = DateTime.UtcNow; - } - - public void UpdateChangeData(long? enhancementScroll, long? mithril, long? sp) - { - if (enhancementScroll.HasValue) EnhancementScroll = enhancementScroll.Value; - if (mithril.HasValue) Mithril = mithril.Value; - if (sp.HasValue) Sp = sp.Value; - UpdatedAt = DateTime.UtcNow; - } -} -``` - -### 2-3. PlayerStage.cs (new) - -```csharp -// Domain/Player/Entity/PlayerStage.cs -public class PlayerStage -{ - public long Id { get; private set; } - public long PlayerId { get; private set; } - public long MaxStage { get; private set; } - - public static PlayerStage Create(long playerId) => new() - { - PlayerId = playerId, - MaxStage = 1 - }; - - public void Update(long maxStage) - { - MaxStage = maxStage; - } -} -``` - -### 2-4. PlayerSession.cs (new) - -```csharp -// Domain/Player/Entity/PlayerSession.cs -public class PlayerSession -{ - public long Id { get; private set; } - public long PlayerId { get; private set; } - public int? LastWeaponId { get; private set; } - public int[] ActiveSkills { get; private set; } = []; - public DateTime UpdatedAt { get; private set; } - - public static PlayerSession Create(long playerId) => new() - { - PlayerId = playerId, - LastWeaponId = null, - ActiveSkills = [], - UpdatedAt = DateTime.UtcNow - }; - - public void Update(int lastWeaponId, int[] activeSkills) - { - LastWeaponId = lastWeaponId; - ActiveSkills = activeSkills; - UpdatedAt = DateTime.UtcNow; - } -} -``` - ---- - -## 3. EF Core Config Changes - -### 3-1. PlayerConfig.cs (modified) - -Remove: `MaxStage`, `LastWeaponId`, `ActiveSkills` properties -Add: `Exp` property - -### 3-2. PlayerResourceConfig.cs (modified) - -Remove: `Exp` property - -### 3-3. PlayerStageConfig.cs (new) - -```csharp -// Domain/Player/Entity/Config/PlayerStageConfig.cs -public class PlayerStageConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("player_stage", "player"); - builder.HasKey(s => s.Id); - builder.Property(s => s.Id).ValueGeneratedOnAdd(); - builder.Property(s => s.PlayerId).IsRequired(); - builder.Property(s => s.MaxStage).IsRequired().HasDefaultValue(1L); - builder.HasIndex(s => s.PlayerId).IsUnique(); - builder.HasOne() - .WithOne() - .HasForeignKey(s => s.PlayerId) - .OnDelete(DeleteBehavior.Cascade); - } -} -``` - -### 3-4. PlayerSessionConfig.cs (new) - -```csharp -// Domain/Player/Entity/Config/PlayerSessionConfig.cs -public class PlayerSessionConfig : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("player_session", "player"); - builder.HasKey(s => s.Id); - builder.Property(s => s.Id).ValueGeneratedOnAdd(); - builder.Property(s => s.PlayerId).IsRequired(); - builder.Property(s => s.LastWeaponId); - builder.Property(s => s.ActiveSkills) - .IsRequired() - .HasDefaultValueSql("ARRAY[]::integer[]"); - builder.Property(s => s.UpdatedAt).IsRequired(); - builder.HasIndex(s => s.PlayerId).IsUnique(); - builder.HasOne() - .WithOne() - .HasForeignKey(s => s.PlayerId) - .OnDelete(DeleteBehavior.Cascade); - } -} -``` - ---- - -## 4. AppDbContext Changes - -Add `DbSet` for new entities: - -```csharp -public DbSet PlayerStages { get; set; } -public DbSet PlayerSessions { get; set; } -``` - ---- - -## 5. Repository Changes - -### New Interfaces & Implementations - -| Interface | Implementation | Responsibility | -|---|---|---| -| `IPlayerStageRepository` | `PlayerStageRepository` | PlayerStage CRUD | -| `IPlayerSessionRepository` | `PlayerSessionRepository` | PlayerSession CRUD | - -### IPlayerStageRepository - -```csharp -public interface IPlayerStageRepository -{ - Task FindByPlayerIdAsync(long playerId); - Task SaveAsync(PlayerStage stage); - Task UpdateAsync(PlayerStage stage); -} -``` - -### IPlayerSessionRepository - -```csharp -public interface IPlayerSessionRepository -{ - Task FindByPlayerIdAsync(long playerId); - Task SaveAsync(PlayerSession session); - Task UpdateAsync(PlayerSession session); -} -``` - -### IPlayerRepository (modified) - -No interface change needed — `FindByAccountAndJobAsync`, `SaveAsync`, `UpdateAsync` remain. - -### IPlayerResourceRepository (unchanged) - ---- - -## 6. DTO Changes - -### Removed - -- `UpdatePlayerChangeRequest` — deleted entirely (split into separate requests) - -### New Request DTOs - -```csharp -// Dto/Request/UpdatePlayerLevelRequest.cs -public record UpdatePlayerLevelRequest( - [Required] JobType JobType, - [Range(1, long.MaxValue)] long Level -); - -// Dto/Request/UpdatePlayerStageRequest.cs -public record UpdatePlayerStageRequest( - [Required] JobType JobType, - [Range(1, long.MaxValue)] long MaxStage -); - -// Dto/Request/UpdatePlayerResourceRequest.cs -public record UpdatePlayerResourceRequest( - [Required] JobType JobType, - [Range(0, long.MaxValue)] long? EnhancementScroll, - [Range(0, long.MaxValue)] long? Mithril, - [Range(0, long.MaxValue)] long? Sp -); - -// Dto/Request/UpdatePlayerWeaponRequest.cs -public record UpdatePlayerWeaponRequest( - [Required] JobType JobType, - [Required] List Weapons -); - -// Dto/Request/UpdatePlayerSkillRequest.cs -public record UpdatePlayerSkillRequest( - [Required] JobType JobType, - [Required] List Skills -); -``` - -### Modified Request DTOs - -```csharp -// EndPlayerSessionRequest.cs (modified — remove Gold field, keep Exp) -public record EndPlayerSessionRequest( - [Required] JobType JobType, - [Required] int LastWeaponId, - [Required] int[] ActiveSkills, - [Range(0, long.MaxValue)] long? Gold, - [Range(0, long.MaxValue)] long? Exp -); -``` - -### Modified Response DTOs - -```csharp -// PlayerDataResponse.cs (modified — Exp field stays, sourced from Player now) -// Structure unchanged from client perspective — only data source changes internally -``` - ---- - -## 7. Service Changes - -### Removed - -- `IUpdatePlayerChangeService` / `UpdatePlayerChangeService` — deleted - -### New Services - -| Interface | Implementation | Endpoint | -|---|---|---| -| `IUpdatePlayerLevelService` | `UpdatePlayerLevelService` | PATCH /v1/player/level | -| `IUpdatePlayerStageService` | `UpdatePlayerStageService` | PATCH /v1/player/stage | -| `IUpdatePlayerResourceService` | `UpdatePlayerResourceService` | PATCH /v1/player/resource | -| `IUpdatePlayerWeaponService` | `UpdatePlayerWeaponService` | PATCH /v1/player/weapon | -| `IUpdatePlayerSkillService` | `UpdatePlayerSkillService` | PATCH /v1/player/skill | - -### Modified Services - -**InitPlayerService** — add creation of `PlayerStage` and `PlayerSession` records on new player, read from both for response building. - -**EndPlayerSessionService** — write `LastWeaponId`/`ActiveSkills` to `PlayerSession` (not `Player`), write `Exp` to `Player`, write `Gold` to `PlayerResource`. - ---- - -## 8. Controller Changes - -### PlayerController (modified) - -``` -POST /v1/player/init → IInitPlayerService -PATCH /v1/player/level → IUpdatePlayerLevelService -``` - -### PlayerResourceController (new) - -``` -PATCH /v1/player/resource → IUpdatePlayerResourceService -``` - -### PlayerStageController (new) - -``` -PATCH /v1/player/stage → IUpdatePlayerStageService -``` - -### PlayerWeaponController (new) - -``` -PATCH /v1/player/weapon → IUpdatePlayerWeaponService -``` - -### PlayerSkillController (new) - -``` -PATCH /v1/player/skill → IUpdatePlayerSkillService -``` - -### PlayerSessionController (new) - -``` -PATCH /v1/player/session/end → IEndPlayerSessionService -``` - -All controllers: `[ApiController]`, `[Route("v1/player/...")]`, `[Authorize]` - ---- - -## 9. Redis Cache Strategy - -**Key format unchanged:** `player:{accountId}:{jobType}` - -**Rules:** -- All 5 PATCH endpoints → `DEL player:{accountId}:{jobType}` after DB write -- `POST /v1/player/init` → GET on cache miss → query DB → SET cache → return -- `PlayerDataResponse` structure unchanged from client perspective (Exp now sourced from Player table, but response field stays the same) - ---- - -## 10. DI Registration (PlayerServiceConfig.cs) - -Add new service and repository registrations: - -```csharp -// Repositories -services.AddScoped(); -services.AddScoped(); - -// Services -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); - -// Remove -// services.AddScoped(); -``` - ---- - -## 11. Migration Strategy - -1. Add EF Core migration: `NormalizePlayerTables` - - Adds `player_stage`, `player_session` tables - - Adds `exp` column to `player` - - Removes `max_stage`, `last_weapon_id`, `active_skills` from `player` - - Removes `exp` from `player_resource` - - Data migration SQL in `Up()`: - ```sql - -- Copy existing data before dropping columns - INSERT INTO player.player_stage (player_id, max_stage) - SELECT id, max_stage FROM player.player; - - INSERT INTO player.player_session (player_id, last_weapon_id, active_skills, updated_at) - SELECT id, last_weapon_id, active_skills, updated_at FROM player.player; - - UPDATE player.player p - SET exp = pr.exp - FROM player.player_resource pr - WHERE pr.player_id = p.id; - ``` - -2. Run `/db-migrate update` to apply - ---- - -## 12. Implementation Order - -``` -Phase 1 — Entities & EF Config - 1. Modify Player.cs (add Exp, remove MaxStage/LastWeaponId/ActiveSkills) - 2. Modify PlayerResource.cs (remove Exp) - 3. Create PlayerStage.cs - 4. Create PlayerSession.cs - 5. Modify PlayerConfig.cs - 6. Modify PlayerResourceConfig.cs - 7. Create PlayerStageConfig.cs - 8. Create PlayerSessionConfig.cs - 9. Update AppDbContext (add DbSets) - -Phase 2 — Migration - 10. /db-migrate add NormalizePlayerTables (with data migration SQL) - 11. /db-migrate update - -Phase 3 — Repositories - 12. Create IPlayerStageRepository + PlayerStageRepository - 13. Create IPlayerSessionRepository + PlayerSessionRepository - -Phase 4 — DTOs - 14. Delete UpdatePlayerChangeRequest.cs - 15. Create UpdatePlayerLevelRequest, UpdatePlayerStageRequest, - UpdatePlayerResourceRequest, UpdatePlayerWeaponRequest, UpdatePlayerSkillRequest - 16. Modify EndPlayerSessionRequest (remove nothing — Gold/Exp stay) - 17. Modify PlayerDataResponse (Exp source changes internally only) - -Phase 5 — Services - 18. Modify InitPlayerService (create PlayerStage + PlayerSession, read from them) - 19. Modify EndPlayerSessionService (write to PlayerSession, Player.Exp, PlayerResource.Gold) - 20. Delete UpdatePlayerChangeService - 21. Create UpdatePlayerLevelService - 22. Create UpdatePlayerStageService - 23. Create UpdatePlayerResourceService - 24. Create UpdatePlayerWeaponService - 25. Create UpdatePlayerSkillService - -Phase 6 — Controllers - 26. Modify PlayerController (add PATCH / for level) - 27. Create PlayerResourceController - 28. Create PlayerStageController - 29. Create PlayerWeaponController - 30. Create PlayerSkillController - 31. Create PlayerSessionController - -Phase 7 — DI & Verify - 32. Update PlayerServiceConfig.cs - 33. /test (build + all tests) -``` - ---- - -## 13. Test Coverage - -For each new service, test: - -| Service | Cases | -|---|---| -| `UpdatePlayerLevelService` | happy path, player not found | -| `UpdatePlayerStageService` | happy path, stage not found | -| `UpdatePlayerResourceService` | happy path, partial update (nulls), player not found | -| `UpdatePlayerWeaponService` | happy path, player not found | -| `UpdatePlayerSkillService` | happy path, player not found | -| `InitPlayerService` | new player (creates stage+session), existing player (cache hit, cache miss) | -| `EndPlayerSessionService` | happy path, player not found | - -Existing tests for `UpdatePlayerChangeService` → delete. From 55ec897e3949517652ccb617e157fb2c94464e1d Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 16:47:42 +0900 Subject: [PATCH 06/13] =?UTF-8?q?chore:=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=ED=82=AC=20=EB=AC=B8=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Fantasy-server/.agents/skills/build/SKILL.md | 18 ++ .../.agents/skills/code-review/SKILL.md | 158 ++++++++++++++++++ Fantasy-server/.agents/skills/commit/SKILL.md | 45 +++-- .../skills/commit/examples/type-guide.md | 82 +++++++++ .../.agents/skills/db-migrate/SKILL.md | 79 +++++++++ .../skills/db-migrate/examples/naming.md | 75 +++++++++ .../.agents/skills/plan-deep-dive/SKILL.md | 8 + Fantasy-server/.agents/skills/pr/SKILL.md | 71 +++----- .../skills/pr/examples/feature-to-develop.md | 49 ++++++ .../.agents/skills/pr/templates/pr-body.md | 18 ++ Fantasy-server/.agents/skills/test/SKILL.md | 60 +++++++ .../skills/test/examples/filter-patterns.md | 71 ++++++++ Fantasy-server/AGENTS.md | 26 +++ 13 files changed, 697 insertions(+), 63 deletions(-) create mode 100644 Fantasy-server/.agents/skills/build/SKILL.md create mode 100644 Fantasy-server/.agents/skills/code-review/SKILL.md create mode 100644 Fantasy-server/.agents/skills/commit/examples/type-guide.md create mode 100644 Fantasy-server/.agents/skills/db-migrate/SKILL.md create mode 100644 Fantasy-server/.agents/skills/db-migrate/examples/naming.md create mode 100644 Fantasy-server/.agents/skills/plan-deep-dive/SKILL.md create mode 100644 Fantasy-server/.agents/skills/pr/examples/feature-to-develop.md create mode 100644 Fantasy-server/.agents/skills/pr/templates/pr-body.md create mode 100644 Fantasy-server/.agents/skills/test/SKILL.md create mode 100644 Fantasy-server/.agents/skills/test/examples/filter-patterns.md create mode 100644 Fantasy-server/AGENTS.md diff --git a/Fantasy-server/.agents/skills/build/SKILL.md b/Fantasy-server/.agents/skills/build/SKILL.md new file mode 100644 index 0000000..460bd4f --- /dev/null +++ b/Fantasy-server/.agents/skills/build/SKILL.md @@ -0,0 +1,18 @@ +--- +name: build +description: Builds the Fantasy.Server project and reports errors. Use for checking build failures and debugging compile errors. +allowed-tools: Bash(dotnet build:*) +context: fork +--- + +Build the server project and report any errors. + +## Steps + +1. Run the build command: + ```bash + dotnet build Fantasy.Server/Fantasy.Server.csproj + ``` +2. Check the build output: + - If the build **succeeds**: confirm success and show the summary (warnings, if any) + - If the build **fails**: list each error with its file path and line number, then explain the likely cause and how to fix it diff --git a/Fantasy-server/.agents/skills/code-review/SKILL.md b/Fantasy-server/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000..d4901d0 --- /dev/null +++ b/Fantasy-server/.agents/skills/code-review/SKILL.md @@ -0,0 +1,158 @@ +--- +name: code-review +description: Run a structured checklist over changed files against project conventions (architecture, code style, EF Core, DI, testing). Produces a ✓/⚠/✗ report in Korean. +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Read, Glob, Grep +context: fork +--- + +# Code Review + +Review changed files against project conventions and produce a Korean report. + +## Step 1 — Determine Scope + +1. Get current branch: `git branch --show-current` +2. Determine base branch: + - If an argument is provided (e.g., `/code-review develop`) → use that branch as base + - Otherwise → use `main` as base +3. List changed files: `git diff {base}...HEAD --name-only` +4. Get detailed diff: `git diff {base}...HEAD` +5. Get commit list: `git log {base}..HEAD --oneline` + +## Step 2 — Read Changed Files + +- Read each changed `.cs` file with the Read tool. +- Read non-`.cs` files (`.json`, `.md`, `.csproj`) only if relevant to the checklist. + +## Step 3 — Apply Checklist + +Review only files that were actually changed. Skip categories with no relevant changes. + +--- + +### [ARCH] Architecture & Layering + +- [ ] Controllers depend only on service interfaces — no concrete service classes +- [ ] Services depend only on repository interfaces — no direct `AppDbContext` access +- [ ] Only repositories access `AppDbContext` +- [ ] New domain follows `Domain/{Name}/` structure (Config / Controller / Dto / Entity / Repository / Service) +- [ ] New domain DI extension method is called from `Program.cs` + +### [STYLE] C# Code Style + +- [ ] `var` used only when type is obvious from the right-hand side +- [ ] Private fields: `_camelCase`; everything else: `PascalCase` +- [ ] Dependencies injected via constructor into `private readonly` fields +- [ ] Constructors contain no logic — assignments only +- [ ] Single-expression methods use expression-body (`=>`) +- [ ] No XML doc comments (`///`) unless explicitly requested +- [ ] No `#region` blocks + +### [DTO] DTO Pattern + +- [ ] `record` types with positional parameters +- [ ] DataAnnotations applied directly on parameters (`[Required]`, `[MaxLength]`, etc.) +- [ ] Requests in `Dto/Request/`, Responses in `Dto/Response/` + +### [ENTITY] Entity Pattern + +- [ ] All setters are `private set` +- [ ] Static factory method `Create(...)` used instead of public constructor +- [ ] Timestamps use `DateTime.UtcNow` +- [ ] No DataAnnotations on entities — EF config via Fluent API only +- [ ] EF Fluent config implements `IEntityTypeConfiguration` in `Entity/Config/` +- [ ] `DbSet` registered in `AppDbContext` +- [ ] Table/column names: `snake_case`, schema-qualified (`"schema"."table"`) +- [ ] Enums use `HasConversion()` + +### [SERVICE] Service Pattern + +- [ ] One use case = one class + one interface +- [ ] Interface exposes exactly one `ExecuteAsync` method +- [ ] Business exceptions use Gamism.SDK types (`ConflictException`, `NotFoundException`, etc.) +- [ ] No empty catch blocks; no bare re-throw without added context + +### [REPO] Repository Pattern + +- [ ] Read-only queries use `AsNoTracking()` +- [ ] `SaveAsync` checks Detached state before calling `AddAsync` + +### [CONTROLLER] Controller Pattern + +- [ ] Return type is `CommonApiResponse` (Gamism.SDK) +- [ ] Only service interfaces injected — no concrete classes +- [ ] Rate limiting applied with `[EnableRateLimiting("login"|"game")]` where needed +- [ ] Authenticated endpoints annotated with `[Authorize]` + +### [ASYNC] Async Pattern + +- [ ] All I/O methods are `async Task` / `async Task` +- [ ] No `.Result` or `.Wait()` calls +- [ ] No unintentional fire-and-forget — every async call is awaited + +### [SECURITY] Security + +- [ ] No plain-text passwords — `BCrypt.Net.BCrypt.HashPassword` required +- [ ] No hardcoded secrets (API keys, passwords, connection strings) +- [ ] No sensitive data (passwords, tokens) written to logs + +### [REDIS] Redis Cache Pattern (only when Redis code changed) + +- [ ] Read flow: check Redis first → on miss query DB → SET cache → return +- [ ] Write flow: update DB → DEL cache key (invalidate) + +### [DI] DI Registration + +- [ ] Domain services registered via `{Name}ServiceConfig.cs` extension method +- [ ] Extension method called from `Program.cs` + +### [TEST] Tests (only when Fantasy.Test files changed) + +- [ ] Test class name: `{ServiceName}Test` +- [ ] Test method name: `{MethodName}_{Scenario}_{ExpectedResult}` +- [ ] No direct `AppDbContext` mocks — mock repository interfaces instead +- [ ] `NSubstitute` used for mocking +- [ ] Arrange / Act / Assert structure with blank line separators + +--- + +## Step 4 — Output Report + +Write the report in Korean using the following format: + +``` +## 코드 리뷰 리포트 + +### 변경 범위 +- 브랜치: {current} ← {base} +- 변경 파일 수: N개 +- 커밋: {commit summary} + +--- + +### [ARCH] 아키텍처 · 레이어링 +✓ ... +⚠ ... +✗ ... + +(repeat for each category that has changes; skip empty categories) + +--- + +### 종합 결과 +| 등급 | 건수 | +|------|------| +| ✓ 통과 | N | +| ⚠ 경고 | N | +| ✗ 오류 | N | + +총 N개 항목 검토 — 오류 N건, 경고 N건 +``` + +**Symbol meanings:** +- ✓ — convention followed +- ⚠ — recommendation (optional fix) +- ✗ — convention violation (fix required) + +For each ✗ item, include the file name, approximate line, and suggested fix. +Skip categories with no relevant changes. diff --git a/Fantasy-server/.agents/skills/commit/SKILL.md b/Fantasy-server/.agents/skills/commit/SKILL.md index e250c3d..cdb80b7 100644 --- a/Fantasy-server/.agents/skills/commit/SKILL.md +++ b/Fantasy-server/.agents/skills/commit/SKILL.md @@ -1,4 +1,4 @@ ---- +--- name: commit description: Creates Git commits by splitting changes into logical units. Use for staging files and writing commit messages. allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git log:*) @@ -6,18 +6,32 @@ allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git c Create Git commits following the project's commit conventions. +## Argument + +`$ARGUMENTS` — optional GitHub issue number (e.g. `/commit 42`) + +- If provided, add `#42` as the commit body (blank line after subject, then the reference). +- If omitted, commit without any issue reference. + ## Commit Message Format +Subject line only (no issue): ``` {type}: {Korean description} ``` +With issue number: +``` +{type}: {Korean description} + +#{issue} +``` + **Types**: - `feat` — new feature added - `fix` — bug fix, missing config, or missing DI registration - `update` — modification to existing code -- `docs` - documentation-only changes -- `cicd` — changes to CI/CD configuration, scripts, or workflows +- `chore` — tooling, CI/CD, dependency updates, config changes unrelated to app logic **Description rules**: - Written in **Korean** @@ -28,29 +42,30 @@ Create Git commits following the project's commit conventions. **Examples**: ``` feat: 로그인 로직 추가 +``` +``` fix: 세션 DI 누락 수정 -update: Account 엔터티 수정 -docs: API 명세서 업데이트 -cicd: GitHub Actions 워크플로우 수정 + +#12 ``` +See `.claude/skills/commit/examples/type-guide.md` for a boundary-rule table and real scenarios from this project. + **Do NOT**: - Add Claude as co-author - Write descriptions in English -- Add a commit body — subject line only ## Steps 1. Check all changes with `git status` and `git diff` 2. Categorize changes into logical units: - - New feature addition → `feat` - - Bug / missing registration fix → `fix` - - Modification to existing code → `update` + - New feature addition → `feat` + - Bug / missing registration fix → `fix` + - Modification to existing code → `update` 3. Group files by each logical unit 4. For each group: - - **Stage only the relevant files** with `git add ` - - Write a concise commit message following the format above - - **IMPORTANT: Display the staged files and the proposed commit message to the user.** - - **Ask the user: "이 내용으로 커밋하시겠습니까? (Y/n)"** - - **Only execute `git commit -m "message"` if the user approves.** + - Stage only the relevant files with `git add ` + - Write a concise commit message following the format above + - If `$ARGUMENTS` is provided: `git commit -m "{subject}" -m "#{issue}"` + - If `$ARGUMENTS` is omitted: `git commit -m "{subject}"` 5. Verify results with `git log --oneline -n {number of commits made}` diff --git a/Fantasy-server/.agents/skills/commit/examples/type-guide.md b/Fantasy-server/.agents/skills/commit/examples/type-guide.md new file mode 100644 index 0000000..140de16 --- /dev/null +++ b/Fantasy-server/.agents/skills/commit/examples/type-guide.md @@ -0,0 +1,82 @@ +# Commit Type Guide — Fantasy Server + +## feat — New capability added to the codebase + +Use when creating new files is the primary change. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Add LoginService.cs, LogoutService.cs | `feat: 로그인·로그아웃 서비스 추가` | +| Add AuthController.cs | `feat: Auth 컨트롤러 추가` | +| Add RefreshTokenRedisRepository.cs | `feat: 리프레시 토큰 Redis 레포지토리 추가` | +| Add new Entity class | `feat: {EntityName} 엔터티 추가` | +| Add new test class | `feat: {ServiceName} 테스트 추가` | +| Add new migration file | `feat: {MigrationName} 마이그레이션 추가` | + +--- + +## fix — Broken behavior or missing registration/config corrected + +Use when existing code is wrong, or a required wiring (DI, config key, middleware) is absent. +Adding only a DI registration line without adding the service file itself is also `fix`. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Add missing `services.AddScoped()` in `Program.cs` | `fix: IAccountRepository DI 누락 수정` | +| Fix wrong prefix on Redis key | `fix: Redis 리프레시 토큰 키 prefix 수정` | +| Remove misconfigured EF Core ValueGeneration | `fix: UpdatedAt 자동 생성 설정 제거` | +| Fix typo in port value in `appsettings.json` | `fix: 개발 환경 DB 포트 수정` | +| Fix password comparison using HashPassword instead of BCrypt.Verify | `fix: 비밀번호 검증 로직 수정` | + +--- + +## update — Existing code modified without adding a new capability + +Use when modifying files that already exist — renaming, restructuring, adjusting behavior, etc. + +**Examples from this project:** + +| Change | Commit message | +|---|---| +| Change response type from `T` to `CommonApiResponse` | `update: Auth 응답 타입을 CommonApiResponse로 변경` | +| Move JwtProvider to a different namespace | `update: JwtProvider를 Jwt 네임스페이스로 이동` | +| Update port/connection string in appsettings | `update: Docker 서비스 이름 통일 및 연결 문자열 수정` | +| Modify a property on an existing Entity | `update: Account 엔터티 수정` | +| Add a test method to an existing test class | `update: LoginService 테스트 추가` | +| Merge CI workflow steps | `update: CI 워크플로우 빌드·테스트 단계 통합` | + +--- + +## Boundary rules + +| Situation | Type | +|---|---| +| New `.cs` service/repository/controller file added | `feat` | +| New method added to an existing `.cs` file | `update` | +| DI registration line added alone, no new service file (`Program.cs`, `*Config.cs`) | `fix` | +| New service file + its DI registration added together | `feat` (same logical unit) | +| New migration file added | `feat` | +| Existing migration file corrected (column issue) | `fix` | +| New test class added | `feat` | +| Test method added to an existing test class | `update` | +| Refactoring without behavior change | `update` | + +--- + +## When to split into multiple commits + +If a branch mixes new features with unrelated bug fixes, split them: + +``` +# New service + its DI registration → one logical unit, commit together +git add Domain/Auth/Service/LoginService.cs Domain/Auth/Config/AuthServiceConfig.cs +git commit -m "feat: 로그인 서비스 추가" + +# Separate Redis key bug fix → independent fix +git add Domain/Auth/Repository/RefreshTokenRedisRepository.cs +git commit -m "fix: 리프레시 토큰 Redis 키 prefix 수정" +``` diff --git a/Fantasy-server/.agents/skills/db-migrate/SKILL.md b/Fantasy-server/.agents/skills/db-migrate/SKILL.md new file mode 100644 index 0000000..83e16e8 --- /dev/null +++ b/Fantasy-server/.agents/skills/db-migrate/SKILL.md @@ -0,0 +1,79 @@ +--- +name: db-migrate +description: Manages EF Core migrations. Supports add/update/list/remove subcommands. e.g. /db-migrate add CreateAccountTable +argument-hint: [add | update | list | remove] +allowed-tools: Bash(dotnet ef migrations:*), Bash(dotnet ef database:*), AskUserQuestion +context: fork +--- + +Manage EF Core migrations for Fantasy.Server. + +**Project**: `Fantasy.Server/Fantasy.Server.csproj` +**DbContext**: `AppDbContext` (`Global/Infrastructure/AppDbContext.cs`) + +## Current migrations state + +!`dotnet ef migrations list --project Fantasy.Server/Fantasy.Server.csproj 2>&1 || echo "(no migrations yet)"` + +--- + +## Dispatch on $ARGUMENTS + +Parse the first word of `$ARGUMENTS` as the subcommand. + +--- + +### `add ` + +1. Validate that a migration name was provided. If missing, use AskUserQuestion to ask: + > "마이그레이션 이름을 입력해주세요. (예: CreateAccountTable)" +2. Run: + ```bash + dotnet ef migrations add {MigrationName} --project Fantasy.Server/Fantasy.Server.csproj + ``` +3. Report the generated files under `Fantasy.Server/Migrations/`. +4. Remind the user to review the generated `Up()` / `Down()` methods before applying. + +--- + +### `update` + +1. Show pending migrations from the list above (migrations not yet applied). +2. If there are no pending migrations, report "적용할 마이그레이션이 없습니다." and stop. +3. If there are pending migrations, run: + ```bash + dotnet ef database update --project Fantasy.Server/Fantasy.Server.csproj + ``` +4. Confirm success or surface any connection/schema errors. + +--- + +### `list` + +Run: +```bash +dotnet ef migrations list --project Fantasy.Server/Fantasy.Server.csproj +``` +Display the output, marking applied migrations with ✓ and pending ones with ○. + +--- + +### `remove` + +1. Warn the user: + > "마지막 마이그레이션을 삭제합니다. DB에 이미 적용된 경우 먼저 rollback이 필요합니다. 계속할까요?" +2. Use AskUserQuestion to confirm. +3. If confirmed, run: + ```bash + dotnet ef migrations remove --project Fantasy.Server/Fantasy.Server.csproj + ``` +4. Report which files were deleted. + +--- + +### Unknown subcommand + +If `$ARGUMENTS` is empty or doesn't match any subcommand, show: +``` +사용법: /db-migrate [add | update | list | remove] +``` diff --git a/Fantasy-server/.agents/skills/db-migrate/examples/naming.md b/Fantasy-server/.agents/skills/db-migrate/examples/naming.md new file mode 100644 index 0000000..c1fb777 --- /dev/null +++ b/Fantasy-server/.agents/skills/db-migrate/examples/naming.md @@ -0,0 +1,75 @@ +# Migration Naming Guide — Fantasy Server + +## Format + +**PascalCase**, no spaces, clearly describes the schema operation being performed. + +--- + +## Naming patterns by operation + +### New table +``` +Create{TableName}Table +``` +e.g. `CreateAccountTable`, `CreateGameRoomTable`, `CreatePlayerTable` + +### Initial schema (multiple tables at once) +``` +CreateTables +InitialSchema +``` +e.g. `CreateTables` ← the actual name of the first migration in this project + +### Add column to existing table +``` +Add{ColumnName}To{TableName} +``` +e.g. `AddNicknameToAccount`, `AddStatusToGameRoom`, `AddRefreshTokenToAuth` + +### Remove column +``` +Remove{ColumnName}From{TableName} +``` +e.g. `RemoveDeprecatedFieldFromAccount` + +### Fix column type, constraint, or misconfiguration +``` +Fix{ColumnName}{Issue} +Change{ColumnName}In{TableName} +``` +e.g. +- `FixUpdatedAtValueGeneration` ← actual fix migration in this project +- `ChangeEmailMaxLengthInAccount` + +### Add index +``` +Add{Description}IndexTo{TableName} +``` +e.g. `AddEmailUniqueIndexToAccount`, `AddCreatedAtIndexToGameRoom` + +### Add foreign key +``` +Add{Relation}ForeignKeyTo{TableName} +``` +e.g. `AddAccountForeignKeyToGamePlayer` + +--- + +## Anti-patterns (avoid) + +| Bad | Good | +|---|---| +| `Migration1` | `CreateAccountTable` | +| `FixAccount` | `FixUpdatedAtValueGeneration` | +| `UpdateSchema` | `AddNicknameToAccount` | +| `Temp` | (describe what actually changed) | +| `Fix` | `FixEmailConstraintInAccount` | + +--- + +## Migration history in this project + +| Migration name | What it does | +|---|---| +| _(no migrations yet)_ | First migration should be named `CreateTables` | diff --git a/Fantasy-server/.agents/skills/plan-deep-dive/SKILL.md b/Fantasy-server/.agents/skills/plan-deep-dive/SKILL.md new file mode 100644 index 0000000..31577f5 --- /dev/null +++ b/Fantasy-server/.agents/skills/plan-deep-dive/SKILL.md @@ -0,0 +1,8 @@ +--- +name: plan-deep-dive +description: Conduct an in-depth structured interview with the user to uncover non-obvious requirements, tradeoffs, and constraints, then produce a detailed implementation spec file. +argument-hint: [instructions] +allowed-tools: AskUserQuestion, Write +--- + +Follow the user instructions and interview me in detail using the AskUserQuestionTool about literally anything: technical implementation, UI & UX, concerns, tradeoffs, etc. but make sure the questions are not obvious. be very in-depth and continue interviewing me continually until it's complete. then, write the spec to a file. $ARGUMENTS \ No newline at end of file diff --git a/Fantasy-server/.agents/skills/pr/SKILL.md b/Fantasy-server/.agents/skills/pr/SKILL.md index a675d48..ac40341 100644 --- a/Fantasy-server/.agents/skills/pr/SKILL.md +++ b/Fantasy-server/.agents/skills/pr/SKILL.md @@ -12,8 +12,11 @@ Generate a PR based on the current branch. Behavior differs depending on the bra ### Step 0. Initialize & Branch Discovery 1. Identify the current branch using `git branch --show-current`. 2. **Check for Arguments**: - - **If an argument is provided (e.g., `/pr {target}`)**: Set `{target}` as the **Base Branch** and proceed directly to **Case 3**. - - **If no argument is provided**: Follow the **Branch-Based Behavior** below. + - **If an argument is provided (e.g., `/pr {target}`)**: Set `{Base Branch}` = `{target}` and proceed directly to **Case 3**. + - **If no argument is provided**: Follow the **Branch-Based Behavior** below: + - Current branch is `develop` → **Case 1** + - Current branch matches `release/x.x.x` → **Case 2** + - Any other branch → **Case 3** with `{Base Branch}` = `develop` --- @@ -98,40 +101,27 @@ rm PR_BODY.md ### Case 3: Any other branch -**Step 1. Analyze changes from `develop`** +**Step 1. Analyze changes from `{Base Branch}`** -- Commits: `git log develop..HEAD --oneline` -- Diff stats: `git diff develop...HEAD --stat` -- Detailed diff: `git diff develop...HEAD` +- Commits: `git log {Base Branch}..HEAD --oneline` +- Diff stats: `git diff {Base Branch}...HEAD --stat` +- Detailed diff: `git diff {Base Branch}...HEAD` **Step 2. Suggest three PR titles** following the PR Title Convention below **Step 3. Write PR body** following the PR Body Template below - Save to `PR_BODY.md` -**Step 4. Output** in this format: -``` -## 추천 PR 제목 - -1. [title1] -2. [title2] -3. [title3] - -## PR 본문 (PR_BODY.md에 저장됨) - -[full body preview] -``` +**Step 4. Ask the user** using AskUserQuestion with a `choices` array: +- Options: the 3 generated titles + "직접 입력" as the last option +- If the user selects "직접 입력", ask a follow-up AskUserQuestion for the custom title -**Step 5. Ask the user** using AskUserQuestion: -> "어떤 제목을 사용할까요? (1 / 2 / 3 또는 직접 입력)" +**Step 6. Create PR to `{Base Branch}`** -**Step 6. Create PR to `develop`** - -- If the user answered 1, 2, or 3, use the corresponding suggested title -- If the user typed a custom title, use it as-is +- Use the selected title, or the custom title if the user chose "직접 입력" ```bash -gh pr create --title "{chosen title}" --body-file PR_BODY.md --base develop +gh pr create --title "{chosen title}" --body-file PR_BODY.md --base {Base Branch} ``` **Step 7. Delete PR_BODY.md** @@ -147,10 +137,13 @@ rm PR_BODY.md Format: `{type}: {Korean description}` **Types:** -- `feature` — new feature added +- `feat` — new feature added - `fix` — bug fix or missing configuration/DI registration - `update` — modification to existing code +- `docs` — documentation changes - `refactor` — refactoring without behavior change +- `test` — adding or updating tests +- `chore` — tooling, CI/CD, dependency updates, config changes unrelated to app logic **Rules:** - Description in Korean @@ -158,40 +151,22 @@ Format: `{type}: {Korean description}` - No trailing punctuation **Examples:** -- `feature: 방 생성 API 추가` +- `feat: 방 생성 API 추가` - `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` - `refactor: 로그인 로직 리팩토링` +See `.claude/skills/pr/examples/feature-to-develop.md` for a complete example (title options + filled body) of a feature → develop PR. + --- ## PR Body Template Follow this exact structure (keep the emoji headers as-is): -``` -## 📚작업 내용 - -- {change item 1} -- {change item 2} - -## ◀️참고 사항 - -{additional notes, context, before/after comparisons if relevant. Write "." if nothing to add.} - -## ✅체크리스트 - -> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. - -- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? -- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? - - -> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* -``` +!.claude/skills/pr/templates/pr-body.md **Rules:** - Analyze commits and diffs to fill in `작업 내용` with a concise bullet list -- Fill in `참고 사항` with any important context (architecture decisions, before/after, warnings). Write `.` if nothing relevant. - Keep the total body under 2500 characters - Write in Korean - No emojis in text content (keep the section header emojis) diff --git a/Fantasy-server/.agents/skills/pr/examples/feature-to-develop.md b/Fantasy-server/.agents/skills/pr/examples/feature-to-develop.md new file mode 100644 index 0000000..3150adc --- /dev/null +++ b/Fantasy-server/.agents/skills/pr/examples/feature-to-develop.md @@ -0,0 +1,49 @@ +# Example: Feature Branch PR (feature → develop) + +## Branch context + +- Current branch: `feat/auth-api` +- Base branch: `develop` + +## Suggested PR titles (3 options) + +1. `feat: JWT 기반 로그인·로그아웃 API 추가` +2. `feat: Auth 도메인 로그인·로그아웃 엔드포인트 구현` +3. `feat: 로그인·리프레시토큰·로그아웃 서비스 추가` + +## Completed PR body example + +--- + +## 📚작업 내용 + +- LoginService 구현 — 이메일·비밀번호 검증 및 JWT 발급 +- LogoutService 구현 — Redis 리프레시 토큰 삭제 +- RefreshTokenRedisRepository 추가 — Redis key: `refresh:{accountId}`, TTL 30일 +- AuthController 추가 — `POST /v1/auth/login`, `POST /v1/auth/logout` +- 로그인 엔드포인트에 `"login"` RateLimit 정책 적용 + +## ◀️참고 사항 + +액세스 토큰 만료 시간은 `appsettings.json`의 `Jwt:AccessTokenExpirationMinutes` 값을 사용합니다. +로그아웃은 리프레시 토큰만 삭제하며, 액세스 토큰은 만료 전까지 유효합니다. + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* + +--- + +## Writing rules + +- **작업 내용 bullets**: group by meaningful change, not by raw commit +- **참고 사항**: configuration notes, before/after comparisons, etc. Use `"."` if nothing to add +- Keep the total body under 2500 characters +- All text content in Korean (keep section header emojis as-is) +- No emojis in body text — section headers only diff --git a/Fantasy-server/.agents/skills/pr/templates/pr-body.md b/Fantasy-server/.agents/skills/pr/templates/pr-body.md new file mode 100644 index 0000000..4817475 --- /dev/null +++ b/Fantasy-server/.agents/skills/pr/templates/pr-body.md @@ -0,0 +1,18 @@ +## 📚작업 내용 + +- {change item 1} +- {change item 2} + +## ◀️참고 사항 + +{additional notes, context, before/after comparisons if relevant. Write "." if nothing to add.} + +## ✅체크리스트 + +> `[ ]`안에 x를 작성하면 체크박스를 체크할 수 있습니다. + +- [x] 현재 의도하고자 하는 기능이 정상적으로 작동하나요? +- [x] 변경한 기능이 다른 기능을 깨뜨리지 않나요? + + +> *추후 필요한 체크리스트는 업데이트 될 예정입니다.* diff --git a/Fantasy-server/.agents/skills/test/SKILL.md b/Fantasy-server/.agents/skills/test/SKILL.md new file mode 100644 index 0000000..6fea35d --- /dev/null +++ b/Fantasy-server/.agents/skills/test/SKILL.md @@ -0,0 +1,60 @@ +--- +name: test +description: Builds Fantasy.Server then runs all tests in Fantasy.Test. Reports pass/fail results. e.g. /test CreateAccountService +argument-hint: [ClassName or MethodName (optional)] +allowed-tools: Bash(dotnet build:*), Bash(dotnet test:*) +context: fork +--- + +Build the server project, then run the test suite and report results. + +## Steps + +### Step 1 — Build + +```bash +dotnet build Fantasy.Server/Fantasy.Server.csproj +``` + +- Build **fails**: list each error with its file path and line number, explain the likely cause, then stop. +- Build **succeeds**: continue to Step 2. + +### Step 2 — Run Tests + +If `$ARGUMENTS` is empty, run all tests: + +```bash +dotnet test Fantasy.Test/Fantasy.Test.csproj --no-build +``` + +If `$ARGUMENTS` is provided, filter by class or method name: + +```bash +dotnet test Fantasy.Test/Fantasy.Test.csproj --no-build --filter "FullyQualifiedName~$ARGUMENTS" +``` + +### Step 3 — Report Results + +Report in this format: + +``` +Test Results + +Passed: N +Failed: N +Skipped: N +``` + +If all tests pass, add: "All tests passed." + +If any tests fail, list each failure: + +``` +Failed Tests: + +- {FullyQualifiedTestName} + Error: {exception type and message} + Location: {file path and line number if available} +``` + +Do not truncate error messages. If the failure output contains an inner exception, include it. diff --git a/Fantasy-server/.agents/skills/test/examples/filter-patterns.md b/Fantasy-server/.agents/skills/test/examples/filter-patterns.md new file mode 100644 index 0000000..365f09a --- /dev/null +++ b/Fantasy-server/.agents/skills/test/examples/filter-patterns.md @@ -0,0 +1,71 @@ +# Test Filter Patterns — Fantasy Server + +## How filtering works + +```bash +dotnet test --filter "FullyQualifiedName~{value}" +``` + +The `~` operator matches any fully qualified test name that **contains** the value as a substring. + +Fully qualified name format: +``` +{Namespace}.{OuterClass}+{InnerClass}.{MethodName} +``` + +Example: +``` +Fantasy.Test.Account.Service.CreateAccountServiceTests+이메일이_존재하지_않을_때.회원가입_요청_시_계정이_저장된다 +``` + +**Korean class and method names work as-is. No escaping required.** + +--- + +## Current test classes in this project + +| `/test` argument | What runs | +|---|---| +| _(no argument)_ | All tests | +| `CreateAccountServiceTests` | All CreateAccountService tests | +| `DeleteAccountServiceTests` | All DeleteAccountService tests (currently empty) | +| `LoginServiceTests` | All LoginService tests | +| `이메일이_존재하지_않을_때` | Email-not-found scenario (CreateAccount) | +| `이미_사용중인_이메일일_때` | Duplicate email scenario (CreateAccount) | +| `유효한_자격증명일_때` | Valid credentials scenario (Login) | +| `존재하지_않는_이메일일_때` | Email-not-found scenario (Login) | +| `잘못된_비밀번호일_때` | Wrong password scenario (Login) | + +--- + +## Pattern examples + +### Filter by outer class — runs all tests for a service +``` +/test CreateAccountServiceTests +``` +→ Matches all of `Fantasy.Test.Account.Service.CreateAccountServiceTests+*.*` + +### Filter by Korean inner class — runs all tests in a scenario +``` +/test 이메일이_존재하지_않을_때 +``` +→ Matches all methods inside that inner class + +### Filter by method name — runs a single test +``` +/test 회원가입_요청_시_비밀번호가_해싱된다 +``` +→ Matches the single method by name substring + +### Filter by domain +``` +/test LoginService +``` +→ Matches all of `Fantasy.Test.Auth.Service.LoginServiceTests+*.*` + +### Filter by namespace — runs an entire domain +``` +/test Fantasy.Test.Account +``` +→ Matches everything under the Account namespace diff --git a/Fantasy-server/AGENTS.md b/Fantasy-server/AGENTS.md new file mode 100644 index 0000000..62ef78f --- /dev/null +++ b/Fantasy-server/AGENTS.md @@ -0,0 +1,26 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`Fantasy-server.sln` contains the API and test projects. Core application code lives in `Fantasy.Server/`, organized by domain under `Domain/Account`, `Domain/Auth`, and `Domain/Player`, with shared infrastructure in `Global/` and EF Core migrations in `Migrations/`. Local container setup lives in `Fantasy.Server/deploy/`. Tests live in `Fantasy.Test/`, mirroring the server by domain and layer, for example `Fantasy.Test/Auth/Service/`. + +## Build, Test, and Development Commands +Use the .NET CLI from the repository root. + +- `dotnet restore Fantasy-server.sln`: restore NuGet packages. +- `dotnet build Fantasy-server.sln`: compile the API and test project. +- `dotnet run --project Fantasy.Server`: start the API locally. +- `dotnet test Fantasy.Test`: run the xUnit suite. +- `dotnet test Fantasy.Test --collect:"XPlat Code Coverage"`: run tests with Coverlet coverage output. +- `docker compose -f Fantasy.Server/deploy/compose.dev.yaml up --build`: start PostgreSQL, Redis, and the API with the development settings. + +## Coding Style & Naming Conventions +Use 4-space indentation and nullable-enabled C# conventions. Prefer `PascalCase` for types, methods, and properties, `camelCase` for locals and parameters, `_camelCase` for private fields, and `IPascalCase` for interfaces. Use `var` only when the type is obvious. DTOs should be positional `record` types in `Dto/Request` or `Dto/Response`; entities should expose `private set` and use static `Create(...)` factories. EF Core table names use `snake_case`, and configuration classes belong in `Domain/{Name}/Entity/Config/`. + +## Testing Guidelines +The test stack is xUnit v3 with FluentAssertions, NSubstitute, and Coverlet. Place service tests under the matching domain folder and name files `*Tests.cs`, for example `CreateAccountServiceTests.cs`. Name test methods as `Method_Scenario_ExpectedResult`. Mock repository interfaces, not `AppDbContext`, and keep Arrange / Act / Assert blocks visually separated. + +## Commit & Pull Request Guidelines +Follow the repository commit convention: `{type}: {Korean imperative summary}`. Common prefixes are `feat`, `fix`, `update`, `docs`, `chore`, and `cicd`. Keep commits focused on one logical change and avoid mixing feature work with unrelated fixes. PRs should explain the behavior change, list validation steps such as `dotnet test Fantasy.Test`, reference the related issue, and include request/response samples when an API contract changes. + +## Security & Configuration Tips +Do not commit real secrets. Use `Fantasy.Server/appsettings.Development.json` only for local defaults, and override database, Redis, and JWT settings through environment variables or compose files for shared environments. From 302f87ddd0febe4c01dd9b3108768d7da00649e1 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 16:50:56 +0900 Subject: [PATCH 07/13] =?UTF-8?q?chore:=20pr=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Fantasy-server/.agents/skills/pr/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Fantasy-server/.agents/skills/pr/SKILL.md b/Fantasy-server/.agents/skills/pr/SKILL.md index ac40341..851fcbf 100644 --- a/Fantasy-server/.agents/skills/pr/SKILL.md +++ b/Fantasy-server/.agents/skills/pr/SKILL.md @@ -1,5 +1,5 @@ --- -name: pr +name: write-pr description: Generates a PR title suggestion and body based on the current branch, then creates a GitHub PR. Supports develop/release/feature branches. allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, AskUserQuestion context: fork @@ -12,7 +12,7 @@ Generate a PR based on the current branch. Behavior differs depending on the bra ### Step 0. Initialize & Branch Discovery 1. Identify the current branch using `git branch --show-current`. 2. **Check for Arguments**: - - **If an argument is provided (e.g., `/pr {target}`)**: Set `{Base Branch}` = `{target}` and proceed directly to **Case 3**. + - **If an argument is provided (e.g., `/write-pr {target}`)**: Set `{Base Branch}` = `{target}` and proceed directly to **Case 3**. - **If no argument is provided**: Follow the **Branch-Based Behavior** below: - Current branch is `develop` → **Case 1** - Current branch matches `release/x.x.x` → **Case 2** From 796f5730ec7f7e382587d6a996cb2161381f91e0 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Tue, 7 Apr 2026 17:19:01 +0900 Subject: [PATCH 08/13] =?UTF-8?q?chore:=20write-pr=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Fantasy-server/.agents/skills/pr/SKILL.md | 172 --------------- .../.agents/skills/write-pr/SKILL.md | 203 ++++++++++++++++++ .../examples/feature-to-develop.md | 0 .../skills/write-pr/references/label.md | 22 ++ .../skills/write-pr/scripts/create-pr.sh | 41 ++++ .../{pr => write-pr}/templates/pr-body.md | 0 6 files changed, 266 insertions(+), 172 deletions(-) delete mode 100644 Fantasy-server/.agents/skills/pr/SKILL.md create mode 100644 Fantasy-server/.agents/skills/write-pr/SKILL.md rename Fantasy-server/.agents/skills/{pr => write-pr}/examples/feature-to-develop.md (100%) create mode 100644 Fantasy-server/.agents/skills/write-pr/references/label.md create mode 100644 Fantasy-server/.agents/skills/write-pr/scripts/create-pr.sh rename Fantasy-server/.agents/skills/{pr => write-pr}/templates/pr-body.md (100%) diff --git a/Fantasy-server/.agents/skills/pr/SKILL.md b/Fantasy-server/.agents/skills/pr/SKILL.md deleted file mode 100644 index 851fcbf..0000000 --- a/Fantasy-server/.agents/skills/pr/SKILL.md +++ /dev/null @@ -1,172 +0,0 @@ ---- -name: write-pr -description: Generates a PR title suggestion and body based on the current branch, then creates a GitHub PR. Supports develop/release/feature branches. -allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, AskUserQuestion -context: fork ---- - -Generate a PR based on the current branch. Behavior differs depending on the branch. - -## Steps - -### Step 0. Initialize & Branch Discovery -1. Identify the current branch using `git branch --show-current`. -2. **Check for Arguments**: - - **If an argument is provided (e.g., `/write-pr {target}`)**: Set `{Base Branch}` = `{target}` and proceed directly to **Case 3**. - - **If no argument is provided**: Follow the **Branch-Based Behavior** below: - - Current branch is `develop` → **Case 1** - - Current branch matches `release/x.x.x` → **Case 2** - - Any other branch → **Case 3** with `{Base Branch}` = `develop` - ---- - -## Branch-Based Behavior (Default) - -### Case 1: Current branch is `develop` - -**Step 1. Check the current version** - -- Check git tags: `git tag --sort=-v:refname | head -10` -- Check existing release branches: `git branch -a | grep release` -- Determine the latest version (e.g., `1.0.0`) - -**Step 2. Analyze changes and recommend version bump** - -- Commits: `git log main..HEAD --oneline` -- Diff stats: `git diff main...HEAD --stat` -- Recommend one of: - - **Major** (x.0.0): Breaking changes, incompatible API changes - - **Minor** (0.x.0): New backward-compatible features - - **Patch** (0.0.x): Bug fixes only -- Briefly explain why you chose that level - -**Step 3. Ask the user for a version number** - -Use AskUserQuestion: -> "현재 버전: {current_version} -> 추천 버전 업: {Major/Minor/Patch} → {recommended_version} -> 이유: {brief reason} -> -> 사용할 버전 번호를 입력해주세요. (예: 1.0.1)" - -**Step 4. Create a release branch** - -```bash -git checkout -b release/{version} -``` - -**Step 5. Write PR body** following the PR Body Template below -- Analyze changes from `main` branch -- Save to `PR_BODY.md` - -**Step 6. Create PR to `main`** - -```bash -gh pr create --title "release/{version}" --body-file PR_BODY.md --base main -``` - -**Step 7. Delete PR_BODY.md** - -```bash -rm PR_BODY.md -``` - ---- - -### Case 2: Current branch is `release/x.x.x` - -**Step 1. Extract version** from branch name (e.g., `release/1.2.0` → `1.2.0`) - -**Step 2. Analyze changes from `main`** - -- Commits: `git log main..HEAD --oneline` -- Diff stats: `git diff main...HEAD --stat` - -**Step 3. Write PR body** following the PR Body Template below -- Save to `PR_BODY.md` - -**Step 4. Create PR to `main`** - -```bash -gh pr create --title "release/{version}" --body-file PR_BODY.md --base main -``` - -**Step 5. Delete PR_BODY.md** - -```bash -rm PR_BODY.md -``` - ---- - -### Case 3: Any other branch - -**Step 1. Analyze changes from `{Base Branch}`** - -- Commits: `git log {Base Branch}..HEAD --oneline` -- Diff stats: `git diff {Base Branch}...HEAD --stat` -- Detailed diff: `git diff {Base Branch}...HEAD` - -**Step 2. Suggest three PR titles** following the PR Title Convention below - -**Step 3. Write PR body** following the PR Body Template below -- Save to `PR_BODY.md` - -**Step 4. Ask the user** using AskUserQuestion with a `choices` array: -- Options: the 3 generated titles + "직접 입력" as the last option -- If the user selects "직접 입력", ask a follow-up AskUserQuestion for the custom title - -**Step 6. Create PR to `{Base Branch}`** - -- Use the selected title, or the custom title if the user chose "직접 입력" - -```bash -gh pr create --title "{chosen title}" --body-file PR_BODY.md --base {Base Branch} -``` - -**Step 7. Delete PR_BODY.md** - -```bash -rm PR_BODY.md -``` - ---- - -## PR Title Convention - -Format: `{type}: {Korean description}` - -**Types:** -- `feat` — new feature added -- `fix` — bug fix or missing configuration/DI registration -- `update` — modification to existing code -- `docs` — documentation changes -- `refactor` — refactoring without behavior change -- `test` — adding or updating tests -- `chore` — tooling, CI/CD, dependency updates, config changes unrelated to app logic - -**Rules:** -- Description in Korean -- Short and imperative (단문) -- No trailing punctuation - -**Examples:** -- `feat: 방 생성 API 추가` -- `fix: Key Vault 연동 방식을 AddAzureKeyVault으로 변경` -- `refactor: 로그인 로직 리팩토링` - -See `.claude/skills/pr/examples/feature-to-develop.md` for a complete example (title options + filled body) of a feature → develop PR. - ---- - -## PR Body Template - -Follow this exact structure (keep the emoji headers as-is): - -!.claude/skills/pr/templates/pr-body.md - -**Rules:** -- Analyze commits and diffs to fill in `작업 내용` with a concise bullet list -- Keep the total body under 2500 characters -- Write in Korean -- No emojis in text content (keep the section header emojis) diff --git a/Fantasy-server/.agents/skills/write-pr/SKILL.md b/Fantasy-server/.agents/skills/write-pr/SKILL.md new file mode 100644 index 0000000..59c7d9a --- /dev/null +++ b/Fantasy-server/.agents/skills/write-pr/SKILL.md @@ -0,0 +1,203 @@ +--- +name: write-pr +description: Generates a PR title suggestion and body based on the current branch, then creates a GitHub PR. Supports develop/release/feature branches. +allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(git tag:*), Bash(git checkout:*), Bash(gh pr create:*), Bash(rm:*), Write, AskUserQuestion +context: fork +--- + +Generate a PR based on the current branch. Behavior differs depending on the branch. + +Use `references/label.md` to select 1-2 PR labels before creating the PR. Apply the selected labels when running the PR creation step. + +## Steps + +### Step 0. Initialize & Branch Discovery +1. Identify the current branch using `git branch --show-current`. +2. Check for arguments: + - If an argument is provided, for example `/write-pr {target}`, set `{Base Branch}` = `{target}` and proceed directly to Case 3. + - If no argument is provided, follow the branch-based behavior below: + - Current branch is `develop` -> Case 1 + - Current branch matches `release/x.x.x` -> Case 2 + - Any other branch -> Case 3 with `{Base Branch}` = `develop` + +--- + +## Branch-Based Behavior + +### Case 1. Current branch is `develop` + +**Step 1. Check the current version** + +- Check git tags: `git tag --sort=-v:refname | head -10` +- Check existing release branches: `git branch -a | grep release` +- Determine the latest version, for example `1.0.0` + +**Step 2. Analyze changes and recommend version bump** + +- Commits: `git log main..HEAD --oneline` +- Diff stats: `git diff main...HEAD --stat` +- Recommend one of: + - Major (`x.0.0`): breaking changes or incompatible API changes + - Minor (`0.x.0`): new backward-compatible features + - Patch (`0.0.x`): bug fixes only +- Briefly explain why you chose that level + +**Step 3. Ask the user for a version number** + +Use AskUserQuestion: +> Current version: {current_version} +> Recommended bump: {Major/Minor/Patch} -> {recommended_version} +> Reason: {brief reason} +> +> Enter the release version. Example: `1.0.1` + +**Step 4. Create a release branch** + +```bash +git checkout -b release/{version} +``` + +**Step 5. Write PR body** + +- Analyze changes from `main` +- Follow the PR Body Template below +- Save to `PR_BODY.md` + +**Step 6. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 7. Create PR to `main`** + +```bash +./scripts/create-pr.sh "release/{version}" PR_BODY.md "{label1,label2}" +``` + +**Step 8. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +### Case 2. Current branch is `release/x.x.x` + +**Step 1. Extract version** + +- Extract the version from the branch name, for example `release/1.2.0` -> `1.2.0` + +**Step 2. Analyze changes from `main`** + +- Commits: `git log main..HEAD --oneline` +- Diff stats: `git diff main...HEAD --stat` + +**Step 3. Write PR body** + +- Follow the PR Body Template below +- Save to `PR_BODY.md` + +**Step 4. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 5. Create PR to `main`** + +```bash +./scripts/create-pr.sh "release/{version}" PR_BODY.md "{label1,label2}" +``` + +**Step 6. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +### Case 3. Any other branch + +**Step 1. Analyze changes from `{Base Branch}`** + +- Commits: `git log {Base Branch}..HEAD --oneline` +- Diff stats: `git diff {Base Branch}...HEAD --stat` +- Detailed diff: `git diff {Base Branch}...HEAD` + +**Step 2. Suggest three PR titles** + +- Follow the PR Title Convention below + +**Step 3. Write PR body** + +- Follow the PR Body Template below +- Save to `PR_BODY.md` + +**Step 4. Ask the user** + +Use AskUserQuestion with a `choices` array: +- Options: the 3 generated titles plus `직접 입력` as the last option +- If the user selects `직접 입력`, ask a follow-up AskUserQuestion for the custom title + +**Step 5. Select labels** + +- Follow `references/label.md` +- Select 1-2 PR-eligible labels that match the change + +**Step 6. Create PR to `{Base Branch}`** + +- Use the selected title, or the custom title if the user chose `직접 입력` + +```bash +./scripts/create-pr.sh "{chosen title}" PR_BODY.md "{label1,label2}" +``` + +**Step 7. Delete PR_BODY.md** + +```bash +rm PR_BODY.md +``` + +--- + +## PR Title Convention + +Format: `{type}: {Korean description}` + +**Types:** +- `feat`: new feature added +- `fix`: bug fix or missing configuration or DI registration +- `update`: modification to existing code +- `docs`: documentation changes +- `refactor`: refactoring without behavior change +- `test`: adding or updating tests +- `chore`: tooling, CI/CD, dependency updates, or config changes unrelated to app logic + +**Rules:** +- Description in Korean +- Short and imperative +- No trailing punctuation + +**Examples:** +- `feat: 계정 생성 API 추가` +- `fix: Key Vault 연동 방식을 AddAzureKeyVault로 변경` +- `refactor: 로그 처리 로직 분리` + +See `examples/feature-to-develop.md` for a complete example of a feature -> develop PR. + +## Labels + +Follow `references/label.md` and select 1-2 labels before the PR creation step. + +## PR Body Template + +Follow this exact structure: + +`templates/pr-body.md` + +**Rules:** +- Analyze commits and diffs to fill in the work summary with concise bullet points +- Keep the total body under 2500 characters +- Write in Korean +- Do not add emojis in the body text diff --git a/Fantasy-server/.agents/skills/pr/examples/feature-to-develop.md b/Fantasy-server/.agents/skills/write-pr/examples/feature-to-develop.md similarity index 100% rename from Fantasy-server/.agents/skills/pr/examples/feature-to-develop.md rename to Fantasy-server/.agents/skills/write-pr/examples/feature-to-develop.md diff --git a/Fantasy-server/.agents/skills/write-pr/references/label.md b/Fantasy-server/.agents/skills/write-pr/references/label.md new file mode 100644 index 0000000..ea788a8 --- /dev/null +++ b/Fantasy-server/.agents/skills/write-pr/references/label.md @@ -0,0 +1,22 @@ +# GitHub Labels Reference + +Select **1–2 labels** from the PR-eligible list below. Do NOT use issue-only or manual labels. + +## PR-Eligible Labels (auto-selectable) + +| Label | When to use | +|-------------------|-----------------------------------------------------------| +| `enhancement:개선사항` | New feature, improvement to existing feature, refactoring | +| `bug:버그` | Bug fix | +| `documentation:문서` | Docs-only changes (README, CONTRIBUTING, comments) | + | + + +## Quick Decision + +``` +Bug fix? → bug:버그 +New feature or improvement? → enhancement:개선사항 +Docs only? → documentation:문서 +Unsure? → enhancement:개선사항 +``` \ No newline at end of file diff --git a/Fantasy-server/.agents/skills/write-pr/scripts/create-pr.sh b/Fantasy-server/.agents/skills/write-pr/scripts/create-pr.sh new file mode 100644 index 0000000..23b4415 --- /dev/null +++ b/Fantasy-server/.agents/skills/write-pr/scripts/create-pr.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +TITLE="${1:?Error: PR title is required. Usage: create-pr.sh <body-file> [label1,label2,...]}" +BODY_FILE="${2:?Error: Body file is required. Usage: create-pr.sh <title> <body-file> [label1,label2,...]}" +LABELS="${3:-}" + +if [ ! -f "$BODY_FILE" ]; then + echo "ERROR: Body file not found: $BODY_FILE" >&2 + exit 1 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: GitHub CLI (gh) is not installed." >&2 + exit 1 +fi + +CURRENT=$(git branch --show-current) +case "$CURRENT" in + feature/*) BASE="develop" ;; + develop) BASE="main" ;; + *) BASE=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo "develop") ;; +esac + +ARGS=(gh pr create --title "$TITLE" --body-file "$BODY_FILE" --base "$BASE") + +if [ -n "$LABELS" ]; then + IFS=',' read -ra LABEL_ARRAY <<< "$LABELS" + for label in "${LABEL_ARRAY[@]}"; do + trimmed=$(echo "$label" | xargs) + [ -n "$trimmed" ] && ARGS+=(--label "$trimmed") + done +fi + +echo "Creating PR..." +echo " Title : $TITLE" +echo " Base : $BASE" +[ -n "$LABELS" ] && echo " Labels: $LABELS" +echo "" + +"${ARGS[@]}" diff --git a/Fantasy-server/.agents/skills/pr/templates/pr-body.md b/Fantasy-server/.agents/skills/write-pr/templates/pr-body.md similarity index 100% rename from Fantasy-server/.agents/skills/pr/templates/pr-body.md rename to Fantasy-server/.agents/skills/write-pr/templates/pr-body.md From 675363ef5b67809cb0eeefe1a3e822e7c4ff4719 Mon Sep 17 00:00:00 2001 From: Sean-mn <b01080080329@gmail.com> Date: Tue, 7 Apr 2026 17:47:27 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=EC=84=B8=EC=85=98=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EA=B0=B1=EC=8B=A0=EC=9D=84=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Player/Service/EndPlayerSessionService.cs | 37 +++--- .../Global/Config/DatabaseConfig.cs | 1 + .../Infrastructure/AppDbTransactionRunner.cs | 48 ++++++++ .../Infrastructure/IAppDbTransactionRunner.cs | 9 ++ .../Fantasy.Test/Fantasy.Test.csproj | 3 +- .../AppDbTransactionRunnerTests.cs | 113 ++++++++++++++++++ .../Service/EndPlayerSessionServiceTests.cs | 16 ++- 7 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbTransactionRunner.cs create mode 100644 Fantasy-server/Fantasy.Server/Global/Infrastructure/IAppDbTransactionRunner.cs create mode 100644 Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs index c497aad..b0a62ef 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/EndPlayerSessionService.cs @@ -1,6 +1,7 @@ using Fantasy.Server.Domain.Player.Dto.Request; using Fantasy.Server.Domain.Player.Repository.Interface; using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Infrastructure; using Fantasy.Server.Global.Security.Provider; using Gamism.SDK.Extensions.AspNetCore.Exceptions; @@ -13,19 +14,22 @@ public class EndPlayerSessionService : IEndPlayerSessionService private readonly IPlayerSessionRepository _playerSessionRepository; private readonly IPlayerRedisRepository _playerRedisRepository; private readonly ICurrentUserProvider _currentUserProvider; + private readonly IAppDbTransactionRunner _transactionRunner; public EndPlayerSessionService( IPlayerRepository playerRepository, IPlayerResourceRepository playerResourceRepository, IPlayerSessionRepository playerSessionRepository, IPlayerRedisRepository playerRedisRepository, - ICurrentUserProvider currentUserProvider) + ICurrentUserProvider currentUserProvider, + IAppDbTransactionRunner transactionRunner) { _playerRepository = playerRepository; _playerResourceRepository = playerResourceRepository; _playerSessionRepository = playerSessionRepository; _playerRedisRepository = playerRedisRepository; _currentUserProvider = currentUserProvider; + _transactionRunner = transactionRunner; } public async Task ExecuteAsync(EndPlayerSessionRequest request) @@ -38,22 +42,25 @@ public async Task ExecuteAsync(EndPlayerSessionRequest request) var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); - session.Update(request.LastWeaponId, request.ActiveSkills); - await _playerSessionRepository.UpdateAsync(session); - - if (request.Exp.HasValue) + await _transactionRunner.ExecuteAsync(async () => { - player.UpdateExp(request.Exp.Value); - await _playerRepository.UpdateAsync(player); - } + session.Update(request.LastWeaponId, request.ActiveSkills); + await _playerSessionRepository.UpdateAsync(session); - if (request.Gold.HasValue) - { - var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) - ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); - resource.UpdateGold(request.Gold.Value); - await _playerResourceRepository.UpdateAsync(resource); - } + if (request.Exp.HasValue) + { + player.UpdateExp(request.Exp.Value); + await _playerRepository.UpdateAsync(player); + } + + if (request.Gold.HasValue) + { + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + resource.UpdateGold(request.Gold.Value); + await _playerResourceRepository.UpdateAsync(resource); + } + }); await _playerRedisRepository.DeleteAsync(accountId, request.JobType); } diff --git a/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs b/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs index 654e235..b23e613 100644 --- a/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs +++ b/Fantasy-server/Fantasy.Server/Global/Config/DatabaseConfig.cs @@ -14,6 +14,7 @@ public static IServiceCollection AddDatabase( services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString)); + services.AddScoped<IAppDbTransactionRunner, AppDbTransactionRunner>(); return services; } diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbTransactionRunner.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbTransactionRunner.cs new file mode 100644 index 0000000..be11b76 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbTransactionRunner.cs @@ -0,0 +1,48 @@ +using System.Data; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Global.Infrastructure; + +public class AppDbTransactionRunner : IAppDbTransactionRunner +{ + private readonly AppDbContext _dbContext; + + public AppDbTransactionRunner(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task ExecuteAsync(Func<Task> action, IsolationLevel? isolationLevel = null) + { + await ExecuteAsync<object?>(async () => + { + await action(); + return null; + }, isolationLevel); + } + + public async Task<T> ExecuteAsync<T>(Func<Task<T>> action, IsolationLevel? isolationLevel = null) + { + if (_dbContext.Database.CurrentTransaction != null) + return await action(); + + var transaction = isolationLevel.HasValue + ? await _dbContext.Database.BeginTransactionAsync(isolationLevel.Value) + : await _dbContext.Database.BeginTransactionAsync(); + + await using (transaction) + { + try + { + var result = await action(); + await transaction.CommitAsync(); + return result; + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/IAppDbTransactionRunner.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/IAppDbTransactionRunner.cs new file mode 100644 index 0000000..fa18f84 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/IAppDbTransactionRunner.cs @@ -0,0 +1,9 @@ +using System.Data; + +namespace Fantasy.Server.Global.Infrastructure; + +public interface IAppDbTransactionRunner +{ + Task ExecuteAsync(Func<Task> action, IsolationLevel? isolationLevel = null); + Task<T> ExecuteAsync<T>(Func<Task<T>> action, IsolationLevel? isolationLevel = null); +} diff --git a/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj b/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj index 7b6b7f4..c6e5b8b 100644 --- a/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj +++ b/Fantasy-server/Fantasy.Test/Fantasy.Test.csproj @@ -25,6 +25,7 @@ <PackageReference Include="FluentAssertions" Version="8.9.0" /> <PackageReference Include="Gamism.SDK.Extensions.AspNetCore" Version="0.2.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" /> <PackageReference Include="NSubstitute" Version="5.3.0" /> </ItemGroup> @@ -32,4 +33,4 @@ <ProjectReference Include="..\Fantasy.Server\Fantasy.Server.csproj" /> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs b/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs new file mode 100644 index 0000000..169250d --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Global/Infrastructure/AppDbTransactionRunnerTests.cs @@ -0,0 +1,113 @@ +using System.Data; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Enum; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Test.Global.Infrastructure; + +public class AppDbTransactionRunnerTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly AppDbTransactionRunner _sut; + + public AppDbTransactionRunnerTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder<AppDbContext>() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new AppDbTransactionRunner(_dbContext); + } + + [Fact] + public async Task ExecuteAsync_예외가_발생하면_롤백한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + var act = async () => await _sut.ExecuteAsync(async () => + { + await _dbContext.Players.AddAsync(PlayerEntity.Create(1L, JobType.Warrior), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + throw new InvalidOperationException("rollback"); + }); + + await act.Should().ThrowAsync<InvalidOperationException>() + .WithMessage("rollback"); + var count = await _dbContext.Players.CountAsync(cancellationToken); + count.Should().Be(0); + } + + [Fact] + public async Task ExecuteAsyncT_결과를_반환한다() + { + var result = await _sut.ExecuteAsync(async () => + { + await Task.CompletedTask; + return 42; + }); + + result.Should().Be(42); + } + + [Fact] + public async Task ExecuteAsync_중첩_호출이면_기존_트랜잭션을_재사용한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await _sut.ExecuteAsync(async () => + { + var outerTransaction = _dbContext.Database.CurrentTransaction; + + await _sut.ExecuteAsync(async () => + { + _dbContext.Database.CurrentTransaction.Should().BeSameAs(outerTransaction); + await _dbContext.Players.AddAsync(PlayerEntity.Create(2L, JobType.Archer), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + }, IsolationLevel.Serializable); + }); + + var count = await _dbContext.Players.CountAsync(cancellationToken); + count.Should().Be(1); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions<AppDbContext> options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PlayerEntity>(entity => + { + entity.ToTable("players"); + entity.HasKey(player => player.Id); + entity.Property(player => player.Id).ValueGeneratedOnAdd(); + entity.Property(player => player.AccountId).IsRequired(); + entity.Property(player => player.JobType).HasConversion<string>().IsRequired(); + entity.Property(player => player.Level).IsRequired(); + entity.Property(player => player.Exp).IsRequired(); + entity.Property(player => player.CreatedAt).IsRequired(); + entity.Property(player => player.UpdatedAt).IsRequired(); + }); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs index d43e1e3..ace85c5 100644 --- a/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs @@ -3,6 +3,7 @@ using Fantasy.Server.Domain.Player.Enum; using Fantasy.Server.Domain.Player.Repository.Interface; using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Infrastructure; using Fantasy.Server.Global.Security.Provider; using FluentAssertions; using Gamism.SDK.Extensions.AspNetCore.Exceptions; @@ -22,11 +23,14 @@ public class 정상_요청일_때 private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); private readonly EndPlayerSessionService _sut; private readonly EndPlayerSessionRequest _request = new(JobType.Warrior, 1, [1, 2], 5000L, 3000L); public 정상_요청일_때() { + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); _currentUserProvider.GetAccountId().Returns(1L); _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior) .Returns(PlayerEntity.Create(1L, JobType.Warrior)); @@ -37,7 +41,7 @@ public 정상_요청일_때() _sut = new EndPlayerSessionService( _playerRepository, _playerResourceRepository, - _playerSessionRepository, _playerRedisRepository, _currentUserProvider); + _playerSessionRepository, _playerRedisRepository, _currentUserProvider, _transactionRunner); } [Fact] @@ -80,11 +84,14 @@ public class Gold_Exp가_null일_때 private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); private readonly EndPlayerSessionService _sut; private readonly EndPlayerSessionRequest _request = new(JobType.Archer, 1, [], null, null); public Gold_Exp가_null일_때() { + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); _currentUserProvider.GetAccountId().Returns(2L); _playerRepository.FindByAccountAndJobAsync(2L, JobType.Archer) .Returns(PlayerEntity.Create(2L, JobType.Archer)); @@ -93,7 +100,7 @@ public Gold_Exp가_null일_때() _sut = new EndPlayerSessionService( _playerRepository, _playerResourceRepository, - _playerSessionRepository, _playerRedisRepository, _currentUserProvider); + _playerSessionRepository, _playerRedisRepository, _currentUserProvider, _transactionRunner); } [Fact] @@ -136,17 +143,20 @@ public class 플레이어가_존재하지_않을_때 private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For<IPlayerSessionRepository>(); private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); private readonly EndPlayerSessionService _sut; public 플레이어가_존재하지_않을_때() { + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) + .Returns(callInfo => callInfo.Arg<Func<Task>>()()); _currentUserProvider.GetAccountId().Returns(99L); _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) .Returns((PlayerEntity?)null); _sut = new EndPlayerSessionService( _playerRepository, _playerResourceRepository, - _playerSessionRepository, _playerRedisRepository, _currentUserProvider); + _playerSessionRepository, _playerRedisRepository, _currentUserProvider, _transactionRunner); } [Fact] From ec70faa9873360d293afc2632e3d66248aaec21e Mon Sep 17 00:00:00 2001 From: Sean-mn <b01080080329@gmail.com> Date: Tue, 7 Apr 2026 17:55:20 +0900 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=9B=90=EC=9E=90=EC=84=B1=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/PlayerSkillRepository.cs | 29 +++--- .../Repository/PlayerWeaponRepository.cs | 27 +++--- .../Player/Service/InitPlayerService.cs | 86 +++++++++++------- .../Repository/PlayerSkillRepositoryTests.cs | 86 ++++++++++++++++++ .../Repository/PlayerWeaponRepositoryTests.cs | 90 +++++++++++++++++++ .../Service/EndPlayerSessionServiceTests.cs | 34 +++++-- .../Player/Service/InitPlayerServiceTests.cs | 72 ++++++++++++--- 7 files changed, 346 insertions(+), 78 deletions(-) create mode 100644 Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs create mode 100644 Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs index 6a2a73e..523a597 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSkillRepository.cs @@ -15,29 +15,32 @@ public class PlayerSkillRepository : IPlayerSkillRepository public async Task<List<PlayerSkill>> FindAllByPlayerIdAsync(long playerId) => await _db.PlayerSkills .AsNoTracking() - .Where(s => s.PlayerId == playerId) + .Where(skill => skill.PlayerId == playerId) .ToListAsync(); public async Task UpsertRangeAsync(long playerId, List<SkillChangeItem> items) { - var skillIds = items.Select(i => i.SkillId).ToList(); - var existing = await _db.PlayerSkills - .Where(s => s.PlayerId == playerId && skillIds.Contains(s.SkillId)) - .ToListAsync(); + List<SkillChangeItem> normalizedItems = items + .GroupBy(item => item.SkillId) + .Select(group => group.Last()) + .ToList(); + + List<int> skillIds = normalizedItems.Select(item => item.SkillId).ToList(); + Dictionary<int, PlayerSkill> existing = await _db.PlayerSkills + .Where(skill => skill.PlayerId == playerId && skillIds.Contains(skill.SkillId)) + .ToDictionaryAsync(skill => skill.SkillId); - foreach (var item in items) + foreach (SkillChangeItem item in normalizedItems) { - var skill = existing.FirstOrDefault(s => s.SkillId == item.SkillId); - if (skill != null) + if (existing.TryGetValue(item.SkillId, out PlayerSkill? skill)) { skill.Update(item.IsUnlocked); _db.PlayerSkills.Update(skill); + continue; } - else - { - await _db.PlayerSkills.AddAsync( - PlayerSkill.Create(playerId, item.SkillId, item.IsUnlocked)); - } + + await _db.PlayerSkills.AddAsync( + PlayerSkill.Create(playerId, item.SkillId, item.IsUnlocked)); } await _db.SaveChangesAsync(); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs index 1d73629..3acb2a8 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerWeaponRepository.cs @@ -20,24 +20,27 @@ public async Task<List<PlayerWeapon>> FindAllByPlayerIdAsync(long playerId) public async Task UpsertRangeAsync(long playerId, List<WeaponChangeItem> items) { - var weaponIds = items.Select(i => i.WeaponId).ToList(); - var existing = await _db.PlayerWeapons - .Where(w => w.PlayerId == playerId && weaponIds.Contains(w.WeaponId)) - .ToListAsync(); + List<WeaponChangeItem> normalizedItems = items + .GroupBy(item => item.WeaponId) + .Select(group => group.Last()) + .ToList(); + + List<int> weaponIds = normalizedItems.Select(item => item.WeaponId).ToList(); + Dictionary<int, PlayerWeapon> existing = await _db.PlayerWeapons + .Where(weapon => weapon.PlayerId == playerId && weaponIds.Contains(weapon.WeaponId)) + .ToDictionaryAsync(weapon => weapon.WeaponId); - foreach (var item in items) + foreach (WeaponChangeItem item in normalizedItems) { - var weapon = existing.FirstOrDefault(w => w.WeaponId == item.WeaponId); - if (weapon != null) + if (existing.TryGetValue(item.WeaponId, out PlayerWeapon? weapon)) { weapon.Update(item.Count, item.EnhancementLevel, item.AwakeningCount); _db.PlayerWeapons.Update(weapon); + continue; } - else - { - await _db.PlayerWeapons.AddAsync( - PlayerWeapon.Create(playerId, item.WeaponId, item.Count, item.EnhancementLevel, item.AwakeningCount)); - } + + await _db.PlayerWeapons.AddAsync( + PlayerWeapon.Create(playerId, item.WeaponId, item.Count, item.EnhancementLevel, item.AwakeningCount)); } await _db.SaveChangesAsync(); diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs index 323863b..bd29aac 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs @@ -3,6 +3,7 @@ using Fantasy.Server.Domain.Player.Entity; using Fantasy.Server.Domain.Player.Repository.Interface; using Fantasy.Server.Domain.Player.Service.Interface; +using Fantasy.Server.Global.Infrastructure; using Fantasy.Server.Global.Security.Provider; using Gamism.SDK.Extensions.AspNetCore.Exceptions; using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; @@ -19,6 +20,7 @@ public class InitPlayerService : IInitPlayerService private readonly IPlayerSkillRepository _playerSkillRepository; private readonly IPlayerRedisRepository _playerRedisRepository; private readonly ICurrentUserProvider _currentUserProvider; + private readonly IAppDbTransactionRunner _transactionRunner; public InitPlayerService( IPlayerRepository playerRepository, @@ -28,7 +30,8 @@ public InitPlayerService( IPlayerWeaponRepository playerWeaponRepository, IPlayerSkillRepository playerSkillRepository, IPlayerRedisRepository playerRedisRepository, - ICurrentUserProvider currentUserProvider) + ICurrentUserProvider currentUserProvider, + IAppDbTransactionRunner transactionRunner) { _playerRepository = playerRepository; _playerResourceRepository = playerResourceRepository; @@ -38,55 +41,72 @@ public InitPlayerService( _playerSkillRepository = playerSkillRepository; _playerRedisRepository = playerRedisRepository; _currentUserProvider = currentUserProvider; + _transactionRunner = transactionRunner; } public async Task<(PlayerDataResponse Data, bool IsNew)> ExecuteAsync(InitPlayerRequest request) { - var accountId = _currentUserProvider.GetAccountId(); + long accountId = _currentUserProvider.GetAccountId(); - var cached = await _playerRedisRepository.GetPlayerDataAsync(accountId, request.JobType); + PlayerDataResponse? cached = await _playerRedisRepository.GetPlayerDataAsync(accountId, request.JobType); if (cached != null) return (cached, false); - var isNew = false; - var player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType); - PlayerResource resource; - PlayerStage stage; - PlayerSession session; - + PlayerEntity? player = await _playerRepository.FindByAccountAndJobAsync(accountId, request.JobType); if (player == null) { - player = PlayerEntity.Create(accountId, request.JobType); - await _playerRepository.SaveAsync(player); + var created = await _transactionRunner.ExecuteAsync(async () => + { + PlayerEntity newPlayer = PlayerEntity.Create(accountId, request.JobType); + await _playerRepository.SaveAsync(newPlayer); - resource = PlayerResource.Create(player.Id); - await _playerResourceRepository.SaveAsync(resource); + PlayerResource resource = PlayerResource.Create(newPlayer.Id); + await _playerResourceRepository.SaveAsync(resource); - stage = PlayerStage.Create(player.Id); - await _playerStageRepository.SaveAsync(stage); + PlayerStage stage = PlayerStage.Create(newPlayer.Id); + await _playerStageRepository.SaveAsync(stage); - session = PlayerSession.Create(player.Id); - await _playerSessionRepository.SaveAsync(session); + PlayerSession session = PlayerSession.Create(newPlayer.Id); + await _playerSessionRepository.SaveAsync(session); - isNew = true; - } - else - { - resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) - ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); - stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) - ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); - session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) - ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + return (Player: newPlayer, Resource: resource, Stage: stage, Session: session); + }); + + List<Entity.PlayerWeapon> createdWeapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(created.Player.Id); + List<Entity.PlayerSkill> createdSkills = await _playerSkillRepository.FindAllByPlayerIdAsync(created.Player.Id); + + PlayerDataResponse createdResponse = BuildResponse( + created.Player, + created.Resource, + created.Stage, + created.Session, + createdWeapons, + createdSkills); + + await _playerRedisRepository.SetPlayerDataAsync(accountId, request.JobType, createdResponse); + return (createdResponse, true); } - var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); - var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + PlayerResource existingResource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("?뚮젅?댁뼱 ?ы솕 ?곗씠?곕? 李얠쓣 ???놁뒿?덈떎."); + PlayerStage existingStage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("?뚮젅?댁뼱 ?ㅽ뀒?댁? ?곗씠?곕? 李얠쓣 ???놁뒿?덈떎."); + PlayerSession existingSession = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("?뚮젅?댁뼱 ?몄뀡 ?곗씠?곕? 李얠쓣 ???놁뒿?덈떎."); - var response = BuildResponse(player, resource, stage, session, weapons, skills); - await _playerRedisRepository.SetPlayerDataAsync(accountId, request.JobType, response); + List<Entity.PlayerWeapon> weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + List<Entity.PlayerSkill> skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); - return (response, isNew); + PlayerDataResponse response = BuildResponse( + player, + existingResource, + existingStage, + existingSession, + weapons, + skills); + + await _playerRedisRepository.SetPlayerDataAsync(accountId, request.JobType, response); + return (response, false); } private static PlayerDataResponse BuildResponse( @@ -110,4 +130,4 @@ private static PlayerDataResponse BuildResponse( weapons.Select(w => new WeaponInfoResponse(w.WeaponId, w.Count, w.EnhancementLevel, w.AwakeningCount)).ToList(), skills.Select(s => new SkillInfoResponse(s.SkillId, s.IsUnlocked)).ToList() ); -} \ No newline at end of file +} diff --git a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs new file mode 100644 index 0000000..d5b4008 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerSkillRepositoryTests.cs @@ -0,0 +1,86 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Fantasy.Test.Player.Repository; + +public class PlayerSkillRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly PlayerSkillRepository _sut; + + public PlayerSkillRepositoryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder<AppDbContext>() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new PlayerSkillRepository(_dbContext); + } + + [Fact] + public async Task UpsertRangeAsync_중복_스킬_ID가_있으면_마지막_값으로_저장한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await _dbContext.PlayerSkills.AddAsync(PlayerSkill.Create(1L, 1, false), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + List<SkillChangeItem> items = + [ + new(1, false), + new(1, true), + new(2, true) + ]; + + await _sut.UpsertRangeAsync(1L, items); + + List<PlayerSkill> saved = await _dbContext.PlayerSkills + .OrderBy(skill => skill.SkillId) + .ToListAsync(cancellationToken); + + saved.Should().HaveCount(2); + saved[0].SkillId.Should().Be(1); + saved[0].IsUnlocked.Should().BeTrue(); + saved[1].SkillId.Should().Be(2); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions<AppDbContext> options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PlayerSkill>(entity => + { + entity.ToTable("player_skills"); + entity.HasKey(skill => skill.Id); + entity.Property(skill => skill.Id).ValueGeneratedOnAdd(); + entity.Property(skill => skill.PlayerId).IsRequired(); + entity.Property(skill => skill.SkillId).IsRequired(); + entity.Property(skill => skill.IsUnlocked).IsRequired(); + entity.HasIndex(skill => new { skill.PlayerId, skill.SkillId }).IsUnique(); + }); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs new file mode 100644 index 0000000..138d52b --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Player/Repository/PlayerWeaponRepositoryTests.cs @@ -0,0 +1,90 @@ +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Entity; +using Fantasy.Server.Domain.Player.Repository; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Fantasy.Test.Player.Repository; + +public class PlayerWeaponRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly PlayerWeaponRepository _sut; + + public PlayerWeaponRepositoryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder<AppDbContext>() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new PlayerWeaponRepository(_dbContext); + } + + [Fact] + public async Task UpsertRangeAsync_중복_무기_ID가_있으면_마지막_값으로_저장한다() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await _dbContext.PlayerWeapons.AddAsync(PlayerWeapon.Create(1L, 1, 2L, 1L, 0L), cancellationToken); + await _dbContext.SaveChangesAsync(cancellationToken); + + List<WeaponChangeItem> items = + [ + new(1, 3L, 2L, 0L), + new(1, 7L, 4L, 1L), + new(2, 1L, 0L, 0L) + ]; + + await _sut.UpsertRangeAsync(1L, items); + + List<PlayerWeapon> saved = await _dbContext.PlayerWeapons + .OrderBy(weapon => weapon.WeaponId) + .ToListAsync(cancellationToken); + + saved.Should().HaveCount(2); + saved[0].WeaponId.Should().Be(1); + saved[0].Count.Should().Be(7L); + saved[0].EnhancementLevel.Should().Be(4L); + saved[0].AwakeningCount.Should().Be(1L); + saved[1].WeaponId.Should().Be(2); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions<AppDbContext> options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PlayerWeapon>(entity => + { + entity.ToTable("player_weapons"); + entity.HasKey(weapon => weapon.Id); + entity.Property(weapon => weapon.Id).ValueGeneratedOnAdd(); + entity.Property(weapon => weapon.PlayerId).IsRequired(); + entity.Property(weapon => weapon.WeaponId).IsRequired(); + entity.Property(weapon => weapon.Count).IsRequired(); + entity.Property(weapon => weapon.EnhancementLevel).IsRequired(); + entity.Property(weapon => weapon.AwakeningCount).IsRequired(); + entity.HasIndex(weapon => new { weapon.PlayerId, weapon.WeaponId }).IsUnique(); + }); + } + } +} diff --git a/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs index ace85c5..7b6292b 100644 --- a/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Player/Service/EndPlayerSessionServiceTests.cs @@ -40,8 +40,20 @@ public 정상_요청일_때() .Returns(PlayerResourceEntity.Create(1L)); _sut = new EndPlayerSessionService( - _playerRepository, _playerResourceRepository, - _playerSessionRepository, _playerRedisRepository, _currentUserProvider, _transactionRunner); + _playerRepository, + _playerResourceRepository, + _playerSessionRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task 트랜잭션_안에서_세션_종료를_처리한다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.Received(1).ExecuteAsync(Arg.Any<Func<Task>>()); } [Fact] @@ -99,8 +111,12 @@ public Gold_Exp가_null일_때() .Returns(PlayerSession.Create(2L)); _sut = new EndPlayerSessionService( - _playerRepository, _playerResourceRepository, - _playerSessionRepository, _playerRedisRepository, _currentUserProvider, _transactionRunner); + _playerRepository, + _playerResourceRepository, + _playerSessionRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); } [Fact] @@ -148,15 +164,17 @@ public class 플레이어가_존재하지_않을_때 public 플레이어가_존재하지_않을_때() { - _transactionRunner.ExecuteAsync(Arg.Any<Func<Task>>()) - .Returns(callInfo => callInfo.Arg<Func<Task>>()()); _currentUserProvider.GetAccountId().Returns(99L); _playerRepository.FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()) .Returns((PlayerEntity?)null); _sut = new EndPlayerSessionService( - _playerRepository, _playerResourceRepository, - _playerSessionRepository, _playerRedisRepository, _currentUserProvider, _transactionRunner); + _playerRepository, + _playerResourceRepository, + _playerSessionRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); } [Fact] diff --git a/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs b/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs index cb23837..aa2707d 100644 --- a/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Player/Service/InitPlayerServiceTests.cs @@ -4,6 +4,7 @@ using Fantasy.Server.Domain.Player.Enum; using Fantasy.Server.Domain.Player.Repository.Interface; using Fantasy.Server.Domain.Player.Service; +using Fantasy.Server.Global.Infrastructure; using Fantasy.Server.Global.Security.Provider; using FluentAssertions; using NSubstitute; @@ -25,6 +26,7 @@ public class 캐시가_있을_때 private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); private readonly InitPlayerService _sut; private readonly InitPlayerRequest _request = new(JobType.Warrior); private readonly PlayerDataResponse _cached = new( @@ -36,10 +38,15 @@ public 캐시가_있을_때() _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns(_cached); _sut = new InitPlayerService( - _playerRepository, _playerResourceRepository, - _playerStageRepository, _playerSessionRepository, - _playerWeaponRepository, _playerSkillRepository, - _playerRedisRepository, _currentUserProvider); + _playerRepository, + _playerResourceRepository, + _playerStageRepository, + _playerSessionRepository, + _playerWeaponRepository, + _playerSkillRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); } [Fact] @@ -58,6 +65,15 @@ public async Task DB_조회가_발생하지_않는다() await _playerRepository.DidNotReceive().FindByAccountAndJobAsync(Arg.Any<long>(), Arg.Any<JobType>()); } + [Fact] + public async Task 트랜잭션이_실행되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default(Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>)!); + } + [Fact] public async Task isNew가_false로_반환된다() { @@ -77,11 +93,14 @@ public class 신규_플레이어일_때 private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); private readonly InitPlayerService _sut; private readonly InitPlayerRequest _request = new(JobType.Warrior); public 신규_플레이어일_때() { + _transactionRunner.ExecuteAsync(Arg.Any<Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>>()) + .Returns(callInfo => callInfo.Arg<Func<Task<(PlayerEntity, PlayerResource, PlayerStage, PlayerSession)>>>()()); _currentUserProvider.GetAccountId().Returns(1L); _playerRedisRepository.GetPlayerDataAsync(1L, JobType.Warrior).Returns((PlayerDataResponse?)null); _playerRepository.FindByAccountAndJobAsync(1L, JobType.Warrior).Returns((PlayerEntity?)null); @@ -97,10 +116,24 @@ public 신규_플레이어일_때() _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); _sut = new InitPlayerService( - _playerRepository, _playerResourceRepository, - _playerStageRepository, _playerSessionRepository, - _playerWeaponRepository, _playerSkillRepository, - _playerRedisRepository, _currentUserProvider); + _playerRepository, + _playerResourceRepository, + _playerStageRepository, + _playerSessionRepository, + _playerWeaponRepository, + _playerSkillRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); + } + + [Fact] + public async Task 트랜잭션_안에서_플레이어를_초기_생성한다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.Received(1) + .ExecuteAsync(Arg.Any<Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>>()); } [Fact] @@ -163,6 +196,7 @@ public class 기존_플레이어일_때 private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For<IPlayerSkillRepository>(); private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For<IPlayerRedisRepository>(); private readonly ICurrentUserProvider _currentUserProvider = Substitute.For<ICurrentUserProvider>(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For<IAppDbTransactionRunner>(); private readonly InitPlayerService _sut; private readonly InitPlayerRequest _request = new(JobType.Warrior); @@ -182,10 +216,15 @@ public 기존_플레이어일_때() _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any<long>()).Returns([]); _sut = new InitPlayerService( - _playerRepository, _playerResourceRepository, - _playerStageRepository, _playerSessionRepository, - _playerWeaponRepository, _playerSkillRepository, - _playerRedisRepository, _currentUserProvider); + _playerRepository, + _playerResourceRepository, + _playerStageRepository, + _playerSessionRepository, + _playerWeaponRepository, + _playerSkillRepository, + _playerRedisRepository, + _currentUserProvider, + _transactionRunner); } [Fact] @@ -196,6 +235,15 @@ public async Task 플레이어_데이터가_저장되지_않는다() await _playerRepository.DidNotReceive().SaveAsync(Arg.Any<PlayerEntity>()); } + [Fact] + public async Task 트랜잭션이_실행되지_않는다() + { + await _sut.ExecuteAsync(_request); + + await _transactionRunner.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default(Func<Task<(PlayerEntity Player, PlayerResource Resource, PlayerStage Stage, PlayerSession Session)>>)!); + } + [Fact] public async Task isNew가_false로_반환된다() { From 425c647b5c242afb6954bf65c6559dd900089bbf Mon Sep 17 00:00:00 2001 From: Sean-mn <b01080080329@gmail.com> Date: Tue, 7 Apr 2026 18:12:34 +0900 Subject: [PATCH 11/13] =?UTF-8?q?chore:=20review-pr=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.agents/skills/review-pr/SKILL.md | 98 ++++++++++++++++ .../references/github-reply-formats.md | 39 +++++++ .../review-pr/scripts/collect-pr-data.sh | 39 +++++++ .../.claude/skills/review-pr/SKILL.md | 109 ++++++++++++++++++ .../references/github-reply-formats.md | 39 +++++++ .../review-pr/scripts/collect-pr-data.sh | 39 +++++++ Fantasy-server/.gitignore | 4 +- 7 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 Fantasy-server/.agents/skills/review-pr/SKILL.md create mode 100644 Fantasy-server/.agents/skills/review-pr/references/github-reply-formats.md create mode 100644 Fantasy-server/.agents/skills/review-pr/scripts/collect-pr-data.sh create mode 100644 Fantasy-server/.claude/skills/review-pr/SKILL.md create mode 100644 Fantasy-server/.claude/skills/review-pr/references/github-reply-formats.md create mode 100644 Fantasy-server/.claude/skills/review-pr/scripts/collect-pr-data.sh diff --git a/Fantasy-server/.agents/skills/review-pr/SKILL.md b/Fantasy-server/.agents/skills/review-pr/SKILL.md new file mode 100644 index 0000000..2878760 --- /dev/null +++ b/Fantasy-server/.agents/skills/review-pr/SKILL.md @@ -0,0 +1,98 @@ +--- +name: review-pr +description: Review a pull request or branch diff against a base branch and produce a Korean PR review report focused on bugs, regressions, missing tests, API or schema risks, and project convention violations. Use when asked to review a PR before merge, inspect branch changes, or give merge-readiness feedback. +allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Read, Glob, Grep +context: fork +--- + +# Review PR + +Review the branch as a pull request and report only meaningful findings. + +If `scripts/collect-pr-data.sh` exists and `gh` is available, run it first to collect PR metadata, comments, commit list, changed files, and diff into `.pr-tmp/{pr-number}/`. Use those artifacts as the primary review input before falling back to manual `git` and `gh` commands. + +## Step 1 - Determine Scope + +1. Get the current branch with `git branch --show-current`. +2. Determine the base branch: + - If an argument is provided, for example `/review-pr develop`, use that branch as base. + - Otherwise, use `main` when reviewing `develop`. + - Otherwise, use `develop`. +3. Collect review material: + - Changed files: `git diff {base}...HEAD --name-only` + - Full diff: `git diff {base}...HEAD` + - Commit list: `git log {base}..HEAD --oneline` + - Diff stat: `git diff {base}...HEAD --stat` + - If available, prefer artifacts from `.pr-tmp/{pr-number}/changed_files.txt`, `.pr-tmp/{pr-number}/diff.txt`, `.pr-tmp/{pr-number}/commits.txt`, `.pr-tmp/{pr-number}/review_comments.json`, and `.pr-tmp/{pr-number}/issue_comments.json` + +## Step 2 - Load Review Context + +Before commenting on conventions, read the repository guidance that applies to the changed files. + +- Always read `AGENTS.md`. +- Read `.claude/rules/architecture.md`, `.claude/rules/code-style.md`, `.claude/rules/conventions.md`, `.claude/rules/domain-patterns.md`, `.claude/rules/testing.md`, and `.claude/rules/verify.md`. +- Read `.claude/rules/flows.md` when controller, service, auth, or request/response behavior changed. +- Read only the files that actually changed, plus nearby files if needed to validate behavior. + +## Step 3 - Review With PR Mindset + +Prioritize correctness over style. Ignore untouched code unless the change relies on it. + +Look for: + +- Functional bugs and behavioral regressions +- Missing null handling, validation, authorization, or transaction boundaries +- API contract changes without corresponding DTO, controller, or test updates +- EF Core or persistence risks such as missing config, migration mismatch, tracking issues, or cache invalidation gaps +- DI registration omissions +- Async misuse, swallowed exceptions, or incorrect error mapping +- Security issues such as secrets, token leakage, weak password handling, or sensitive logging +- Missing or insufficient tests for newly introduced behavior +- Maintainability issues only when they create clear follow-up risk + +Do not pad the review with low-signal style nits. Report a finding only when you can explain the concrete risk. + +## Step 4 - Output Format + +Write the review in Korean. Present findings first, ordered by severity. + +Use this structure: + +```md +## PR 리뷰 +### 범위 +- 브랜치: {current} -> {base} +- 변경 파일: {count}개 +- 커밋: {short summary} + +### 주요 발견사항 +1. [심각도] 제목 + - 위치: {file}:{line} + - 문제: {what is wrong} + - 영향: {why it matters} + - 제안: {specific fix} + +### 확인 필요 +- 가정이나 누락된 정보가 있으면 작성 + +### 요약 +- 머지 전 수정 필요: {count}건 +- 권장 확인 사항: {count}건 +``` + +Severity labels: + +- `치명적`: merge blocker, production failure, security issue, data corruption risk +- `높음`: likely bug or regression that should be fixed before merge +- `보통`: real risk or missing coverage worth addressing soon + +If there are no findings, say that explicitly under `주요 발견사항` and include any residual test or verification gaps in `확인 필요`. + +## Step 5 - Reply To GitHub Comments + +When replying to inline review comments, read `references/github-reply-formats.md` and follow it exactly. + +- Always write replies in Korean. +- Always quote `comment_id` when building shell commands or `gh api` payloads. +- Use only the approved reply formats for `VALID`, `INVALID`, and `PARTIAL` cases. +- Keep the reply short and decisive. Do not add extra argument unless the format explicitly allows it. diff --git a/Fantasy-server/.agents/skills/review-pr/references/github-reply-formats.md b/Fantasy-server/.agents/skills/review-pr/references/github-reply-formats.md new file mode 100644 index 0000000..89219dd --- /dev/null +++ b/Fantasy-server/.agents/skills/review-pr/references/github-reply-formats.md @@ -0,0 +1,39 @@ +# GitHub Reply Formats + +Use these templates when posting inline replies in Step 5. +Always quote `comment_id` to prevent shell injection. +All replies must be written in Korean. + +## VALID - fix succeeded + +```text +<abc1234> 에서 반영했습니다. (근거: <출처>) +``` + +## VALID - fix failed + +```text +지적 사항이 타당합니다. 직접 수정이 필요하여 별도 처리하겠습니다. +``` + +## INVALID + +Do not use a long rebuttal or quote repository rules verbatim. + +## PARTIAL - accepted + +```text +부분적으로 타당하다고 판단하여 <abc1234> 에서 반영했습니다. +``` + +## PARTIAL - rejected + +```text +검토 결과 이 방향으로는 적용하지 않기로 결정했습니다. +``` + +## PARTIAL - pending + +```text +검토 중입니다. 추후 답변드리겠습니다. +``` diff --git a/Fantasy-server/.agents/skills/review-pr/scripts/collect-pr-data.sh b/Fantasy-server/.agents/skills/review-pr/scripts/collect-pr-data.sh new file mode 100644 index 0000000..7bbf144 --- /dev/null +++ b/Fantasy-server/.agents/skills/review-pr/scripts/collect-pr-data.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh is required." >&2 + exit 1 +fi + +PR_NUMBER=$(gh pr view --json number -q .number 2>/dev/null || true) +if [ -z "${PR_NUMBER:-}" ]; then + echo "ERROR: No open PR found for current branch." >&2 + exit 1 +fi + +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) + +OUT_DIR=".pr-tmp/$PR_NUMBER" +mkdir -p "$OUT_DIR" + +git fetch origin "$BASE" --quiet || true + +gh pr view "$PR_NUMBER" --json number,title,url,baseRefName,headRefName,author \ + > "$OUT_DIR/pr_meta.json" + +gh api "repos/$REPO/pulls/$PR_NUMBER/comments" \ + --jq '[.[] | {id, path, line, side, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/review_comments.json" + +gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | {id, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/issue_comments.json" + +git log "origin/$BASE..HEAD" --pretty=format:"%H %h %s" > "$OUT_DIR/commits.txt" +git diff "origin/$BASE...HEAD" --name-only > "$OUT_DIR/changed_files.txt" +git diff "origin/$BASE...HEAD" > "$OUT_DIR/diff.txt" + +echo "PR #$PR_NUMBER | Repo: $REPO | Base: $BASE | Output: $OUT_DIR" +echo "Review comments: $(gh api --method GET "repos/$REPO/pulls/$PR_NUMBER/comments" --jq 'length'), Issue comments: $(gh api --method GET "repos/$REPO/issues/$PR_NUMBER/comments" --jq 'length'), Changed files: $(wc -l < "$OUT_DIR/changed_files.txt" | tr -d ' ')" diff --git a/Fantasy-server/.claude/skills/review-pr/SKILL.md b/Fantasy-server/.claude/skills/review-pr/SKILL.md new file mode 100644 index 0000000..45c416f --- /dev/null +++ b/Fantasy-server/.claude/skills/review-pr/SKILL.md @@ -0,0 +1,109 @@ +--- +name: review-pr +description: Collect PR review comments, critically assess each one against project conventions, auto-apply valid ones, post refutation replies for invalid ones, and prompt for partial ones. Replaces resolve-pr-comments. +compatibility: Requires git, gh (GitHub CLI), and jq +--- + +## Step 1 — Collect PR Data + +```bash +bash .claude/skills/review-pr/scripts/collect-pr-data.sh +``` + +Output directory: `.pr-tmp/<PR_NUMBER>/` + +Output files: +- `pr_meta.json` — PR metadata (number, title, url, base/head branch, author) +- `review_comments.json` — inline review comments (id, path, line, side, body, user, createdAt) +- `issue_comments.json` — PR-level (non-inline) comments (id, body, user, createdAt) +- `commits.txt` — commits in this PR +- `changed_files.txt` — changed file paths +- `diff.txt` — full diff + +## Step 2 — Assess Each Comment + +For each comment in `review_comments.json` (and `issue_comments.json` if it references code), apply the following **layered judgment criteria**: + +### Judgment criteria (priority order) + +1. **Project conventions** (primary): cross-reference the following rule files + - `CLAUDE.md` — tech stack, Context7 usage + - `.claude/rules/architecture.md` — directory structure, layering rules + - `.claude/rules/code-style.md` — C# naming, DTO/entity patterns, async rules + - `.claude/rules/conventions.md` — DB naming, EF Core Fluent API, DI registration, password hashing + - `.claude/rules/domain-patterns.md` — service, repository, controller implementation patterns + - `.claude/rules/global-patterns.md` — JWT, Redis, rate limiting infrastructure patterns + - `.claude/rules/testing.md` — test naming, mocking with NSubstitute, FluentAssertions +2. **Language/framework best practices** (secondary): C# / ASP.NET Core / .NET 10 official guidelines + - Apply only when no matching project rule exists + +### Verdicts + +- **VALID**: reviewer is correct → attempt auto code fix +- **INVALID**: reviewer is wrong with a clear refutation → skip, post refutation reply +- **PARTIAL**: intent is correct but application method or scope is ambiguous → confirm with AskUserQuestion + +Always cite a specific source in the rationale (e.g. `code-style.md §Naming`, `conventions.md §Entity Configuration`). + +## Step 3 — Act on Each Verdict + +### VALID → Auto fix + +1. Read the target file with the Read tool +2. Apply the reviewer's concern with the Edit tool +3. Run `/test` to verify the build and tests pass; fix any failures before continuing +4. Commit the change +5. Record the short commit hash for use in Step 5: + ```bash + git rev-parse --short HEAD + ``` + +On failure: record the reason and fall back to PARTIAL. + +### INVALID → Skip + +Do not modify any code. Record the refutation rationale for Step 5. + +### PARTIAL → Confirm with AskUserQuestion + +Use the AskUserQuestion tool to ask: + +``` +⚠️ PARTIAL: [file:line] (reviewer) +Review: "..." +Rationale: ... +Accept? (y / n / s = skip for now) +``` + +- `y`: treat as VALID, attempt code fix +- `n`: treat as INVALID, skip +- `s` / other: record as PENDING + +## Step 4 — Print Report + +``` +## review-pr Results + +| # | Reviewer | File | Verdict | Rationale | Action | +|---|----------|------|---------|-----------|--------| +| 1 | alice | Foo.cs:12 | ✅ VALID | code-style.md §Naming | Auto-fixed (abc1234) | +| 2 | bob | Bar.cs:34 | ❌ INVALID | conventions.md §Entity Configuration | Skipped | +| 3 | alice | Baz.cs:56 | ⚠️ PARTIAL | - | PENDING | +``` + +## Step 5 — Post GitHub Replies + +Post an inline reply for each `review_comments.json` entry. Always quote `comment_id` to prevent shell injection. + +```bash +gh api "repos/<owner>/<repo>/pulls/<pr_number>/comments/<comment_id>/replies" \ + -f body="<reply_body>" +``` + +For reply body templates, read `.claude/skills/review-pr/references/github-reply-formats.md`. + +## Step 6 — Cleanup + +```bash +rm -rf .pr-tmp +``` \ No newline at end of file diff --git a/Fantasy-server/.claude/skills/review-pr/references/github-reply-formats.md b/Fantasy-server/.claude/skills/review-pr/references/github-reply-formats.md new file mode 100644 index 0000000..89219dd --- /dev/null +++ b/Fantasy-server/.claude/skills/review-pr/references/github-reply-formats.md @@ -0,0 +1,39 @@ +# GitHub Reply Formats + +Use these templates when posting inline replies in Step 5. +Always quote `comment_id` to prevent shell injection. +All replies must be written in Korean. + +## VALID - fix succeeded + +```text +<abc1234> 에서 반영했습니다. (근거: <출처>) +``` + +## VALID - fix failed + +```text +지적 사항이 타당합니다. 직접 수정이 필요하여 별도 처리하겠습니다. +``` + +## INVALID + +Do not use a long rebuttal or quote repository rules verbatim. + +## PARTIAL - accepted + +```text +부분적으로 타당하다고 판단하여 <abc1234> 에서 반영했습니다. +``` + +## PARTIAL - rejected + +```text +검토 결과 이 방향으로는 적용하지 않기로 결정했습니다. +``` + +## PARTIAL - pending + +```text +검토 중입니다. 추후 답변드리겠습니다. +``` diff --git a/Fantasy-server/.claude/skills/review-pr/scripts/collect-pr-data.sh b/Fantasy-server/.claude/skills/review-pr/scripts/collect-pr-data.sh new file mode 100644 index 0000000..7bbf144 --- /dev/null +++ b/Fantasy-server/.claude/skills/review-pr/scripts/collect-pr-data.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: gh is required." >&2 + exit 1 +fi + +PR_NUMBER=$(gh pr view --json number -q .number 2>/dev/null || true) +if [ -z "${PR_NUMBER:-}" ]; then + echo "ERROR: No open PR found for current branch." >&2 + exit 1 +fi + +REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) +BASE=$(gh pr view "$PR_NUMBER" --json baseRefName -q .baseRefName) + +OUT_DIR=".pr-tmp/$PR_NUMBER" +mkdir -p "$OUT_DIR" + +git fetch origin "$BASE" --quiet || true + +gh pr view "$PR_NUMBER" --json number,title,url,baseRefName,headRefName,author \ + > "$OUT_DIR/pr_meta.json" + +gh api "repos/$REPO/pulls/$PR_NUMBER/comments" \ + --jq '[.[] | {id, path, line, side, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/review_comments.json" + +gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq '[.[] | {id, body, user: .user.login, createdAt: .created_at}]' \ + > "$OUT_DIR/issue_comments.json" + +git log "origin/$BASE..HEAD" --pretty=format:"%H %h %s" > "$OUT_DIR/commits.txt" +git diff "origin/$BASE...HEAD" --name-only > "$OUT_DIR/changed_files.txt" +git diff "origin/$BASE...HEAD" > "$OUT_DIR/diff.txt" + +echo "PR #$PR_NUMBER | Repo: $REPO | Base: $BASE | Output: $OUT_DIR" +echo "Review comments: $(gh api --method GET "repos/$REPO/pulls/$PR_NUMBER/comments" --jq 'length'), Issue comments: $(gh api --method GET "repos/$REPO/issues/$PR_NUMBER/comments" --jq 'length'), Changed files: $(wc -l < "$OUT_DIR/changed_files.txt" | tr -d ' ')" diff --git a/Fantasy-server/.gitignore b/Fantasy-server/.gitignore index 9a0f124..20a70b1 100644 --- a/Fantasy-server/.gitignore +++ b/Fantasy-server/.gitignore @@ -35,4 +35,6 @@ Thumbs.db Desktop.ini # Claude Code -PR_BODY.md \ No newline at end of file +PR_BODY.md +.dotnet/ +.pr-tmp/ From c9f2eb6c35d8d5f75939c52b16d688c3fa886a44 Mon Sep 17 00:00:00 2001 From: Sean-mn <b01080080329@gmail.com> Date: Tue, 7 Apr 2026 18:13:00 +0900 Subject: [PATCH 12/13] =?UTF-8?q?chore:=20setting.local.json=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Fantasy-server/.claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Fantasy-server/.claude/settings.local.json b/Fantasy-server/.claude/settings.local.json index 344db70..c5ed720 100644 --- a/Fantasy-server/.claude/settings.local.json +++ b/Fantasy-server/.claude/settings.local.json @@ -24,7 +24,8 @@ "Skill(test)", "Bash(cat /c/Users/USER/Documents/GitHub/fantasy-server/Fantasy-server/.claude/rules/*)", "Skill(commit)", - "Bash(grep -r \"0.2.8\\\\|Gamism\" . --include=*.cs --include=*.csproj --include=*.json)" + "Bash(grep -r \"0.2.8\\\\|Gamism\" . --include=*.cs --include=*.csproj --include=*.json)", + "Bash(gh api:*)" ] } } From 669cc9caf245de75888e64cb8f119b048d0ffae1 Mon Sep 17 00:00:00 2001 From: Sean-mn <b01080080329@gmail.com> Date: Wed, 8 Apr 2026 00:07:03 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20InitPlayerService=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EA=B9=A8=EC=A7=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Player/Service/InitPlayerService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs index bd29aac..42b3710 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Service/InitPlayerService.cs @@ -88,11 +88,11 @@ public InitPlayerService( } PlayerResource existingResource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) - ?? throw new NotFoundException("?뚮젅?댁뼱 ?ы솕 ?곗씠?곕? 李얠쓣 ???놁뒿?덈떎."); + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); PlayerStage existingStage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) - ?? throw new NotFoundException("?뚮젅?댁뼱 ?ㅽ뀒?댁? ?곗씠?곕? 李얠쓣 ???놁뒿?덈떎."); + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); PlayerSession existingSession = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) - ?? throw new NotFoundException("?뚮젅?댁뼱 ?몄뀡 ?곗씠?곕? 李얠쓣 ???놁뒿?덈떎."); + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); List<Entity.PlayerWeapon> weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); List<Entity.PlayerSkill> skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id);