From e7dc7b18c1bcba297b88c6fd68cf35805a7d7f04 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 31 Aug 2025 22:42:05 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20oauth2=EB=A5=BC=20redis=EB=A5=BC?= =?UTF-8?q?=20=ED=86=B5=ED=95=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Auth/OAuth2Service.cs | 120 ++++++++++++++---- ...frastructureServiceCollectionExtensions.cs | 38 ++++-- .../ProjectVG.Infrastructure.csproj | 1 + 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/ProjectVG.Application/Services/Auth/OAuth2Service.cs b/ProjectVG.Application/Services/Auth/OAuth2Service.cs index 6c835dc..74c5bdd 100644 --- a/ProjectVG.Application/Services/Auth/OAuth2Service.cs +++ b/ProjectVG.Application/Services/Auth/OAuth2Service.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; using ProjectVG.Common.Configuration; using ProjectVG.Application.Models.Auth; @@ -19,21 +20,30 @@ public class OAuth2Service : IOAuth2Service private readonly OAuth2ProviderSettings _settings; private readonly IAuthService _authService; private readonly IOAuth2ProviderFactory _providerFactory; - private readonly Dictionary _oauth2Requests = new(); - private readonly Dictionary _tokenData = new(); + private readonly IDistributedCache _cache; + + // Redis 키 접두사 + private const string OAuth2RequestPrefix = "oauth2:request:"; + private const string TokenDataPrefix = "oauth2:token:"; + + // TTL 설정 + private static readonly TimeSpan OAuth2RequestTTL = TimeSpan.FromMinutes(10); + private static readonly TimeSpan TokenDataTTL = TimeSpan.FromMinutes(5); public OAuth2Service( IHttpClientFactory httpClientFactory, ILogger logger, IOptions settings, IAuthService authService, - IOAuth2ProviderFactory providerFactory) + IOAuth2ProviderFactory providerFactory, + IDistributedCache cache) { _httpClient = httpClientFactory.CreateClient(); _logger = logger; _settings = settings.Value; _authService = authService; _providerFactory = providerFactory; + _cache = cache; } /// @@ -205,8 +215,15 @@ public async Task StoreOAuth2RequestAsync(string state, OAuth { try { - _oauth2Requests[state] = JsonSerializer.Serialize(request); - await Task.CompletedTask; + var key = OAuth2RequestPrefix + state; + var json = JsonSerializer.Serialize(request); + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = OAuth2RequestTTL + }; + + await _cache.SetStringAsync(key, json, options); + _logger.LogDebug("OAuth2 요청 저장 완료: State={State}, TTL={TTL}분", state, OAuth2RequestTTL.TotalMinutes); return request; } catch (Exception ex) @@ -218,42 +235,99 @@ public async Task StoreOAuth2RequestAsync(string state, OAuth public async Task GetOAuth2RequestAsync(string state) { - if (_oauth2Requests.TryGetValue(state, out var json)) { - var request = JsonSerializer.Deserialize(json); - await Task.CompletedTask; - return request; + try + { + var key = OAuth2RequestPrefix + state; + var json = await _cache.GetStringAsync(key); + + if (!string.IsNullOrEmpty(json)) + { + var request = JsonSerializer.Deserialize(json); + _logger.LogDebug("OAuth2 요청 조회 성공: State={State}", state); + return request; + } + + _logger.LogWarning("OAuth2 요청을 찾을 수 없음: State={State}", state); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "OAuth2 요청 조회 실패: State={State}", state); + return null; } - - await Task.CompletedTask; - return null; } public async Task DeleteOAuth2RequestAsync(string state) { - _oauth2Requests.Remove(state); - await Task.CompletedTask; + try + { + var key = OAuth2RequestPrefix + state; + await _cache.RemoveAsync(key); + _logger.LogDebug("OAuth2 요청 삭제 완료: State={State}", state); + } + catch (Exception ex) + { + _logger.LogError(ex, "OAuth2 요청 삭제 실패: State={State}", state); + } } public async Task StoreTokenDataAsync(string state, object tokenData) { - _tokenData[state] = JsonSerializer.Serialize(tokenData); - await Task.CompletedTask; + try + { + var key = TokenDataPrefix + state; + var json = JsonSerializer.Serialize(tokenData); + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TokenDataTTL + }; + + await _cache.SetStringAsync(key, json, options); + _logger.LogDebug("토큰 데이터 저장 완료: State={State}, TTL={TTL}분", state, TokenDataTTL.TotalMinutes); + } + catch (Exception ex) + { + _logger.LogError(ex, "토큰 데이터 저장 실패: State={State}", state); + throw; + } } public async Task DeleteTokenDataAsync(string state) { - _tokenData.Remove(state); - await Task.CompletedTask; + try + { + var key = TokenDataPrefix + state; + await _cache.RemoveAsync(key); + _logger.LogDebug("토큰 데이터 삭제 완료: State={State}", state); + } + catch (Exception ex) + { + _logger.LogError(ex, "토큰 데이터 삭제 실패: State={State}", state); + } } public async Task GetTokenDataAsync(string state) { - if (_tokenData.TryGetValue(state, out var json)) { - await Task.CompletedTask; - return JsonSerializer.Deserialize(json); + try + { + var key = TokenDataPrefix + state; + var json = await _cache.GetStringAsync(key); + + if (!string.IsNullOrEmpty(json)) + { + var tokenData = JsonSerializer.Deserialize(json); + _logger.LogDebug("토큰 데이터 조회 성공: State={State}", state); + return tokenData; + } + + _logger.LogWarning("토큰 데이터를 찾을 수 없음: State={State}", state); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "토큰 데이터 조회 실패: State={State}", state); + return null; } - await Task.CompletedTask; - return null; } private OAuth2Settings? GetProviderByClientId(string clientId) diff --git a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 3b67602..e93485d 100644 --- a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Caching.StackExchangeRedis; using ProjectVG.Infrastructure.Integrations.LLMClient; using ProjectVG.Infrastructure.Integrations.MemoryClient; using ProjectVG.Infrastructure.Integrations.TextToSpeechClient; @@ -143,31 +144,42 @@ private static void AddOAuth2Services(IServiceCollection services, IConfiguratio } /// - /// Redis 서비스 (개발 환경에서는 In-Memory 사용) + /// Redis 및 분산 캐시 서비스 /// private static void AddRedisServices(IServiceCollection services, IConfiguration configuration) { var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + var redisConnectionString = configuration.GetConnectionString("Redis") ?? + Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") ?? + "localhost:6380"; - if (environment.Equals("Production", StringComparison.OrdinalIgnoreCase)) + // Redis 연결 시도 (환경 무관하게 시도) + try { - // 프로덕션에서는 Redis 사용 - var redisConnectionString = configuration.GetConnectionString("Redis") ?? Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING") ?? "localhost:6379"; + // Redis 연결 테스트 + var options = ConfigurationOptions.Parse(redisConnectionString); + options.AbortOnConnectFail = false; + options.ConnectRetry = 3; + options.ConnectTimeout = 5000; + options.ReconnectRetryPolicy = new ExponentialRetry(5000); - services.AddSingleton(sp => + var multiplexer = ConnectionMultiplexer.Connect(options); + + // Redis 사용 가능한 경우 + services.AddSingleton(multiplexer); + services.AddStackExchangeRedisCache(opt => { - var options = ConfigurationOptions.Parse(redisConnectionString); - options.AbortOnConnectFail = false; - options.ConnectRetry = 5; - options.ReconnectRetryPolicy = new ExponentialRetry(5000); - return ConnectionMultiplexer.Connect(options); + opt.ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer); }); - services.AddScoped(); + + Console.WriteLine($"Redis 연결 성공: {redisConnectionString}"); } - else + catch (Exception ex) { - // 개발 환경에서는 In-Memory 사용 + // Redis 연결 실패 시 In-Memory 대체 + Console.WriteLine($"Redis 연결 실패, In-Memory로 대체: {ex.Message}"); + services.AddDistributedMemoryCache(); services.AddScoped(); } } diff --git a/ProjectVG.Infrastructure/ProjectVG.Infrastructure.csproj b/ProjectVG.Infrastructure/ProjectVG.Infrastructure.csproj index e263ee2..8369a64 100644 --- a/ProjectVG.Infrastructure/ProjectVG.Infrastructure.csproj +++ b/ProjectVG.Infrastructure/ProjectVG.Infrastructure.csproj @@ -13,6 +13,7 @@ + From f2a03260de3cb8b9693eef837715fd396bb2f2b3 Mon Sep 17 00:00:00 2001 From: WooSH Date: Sun, 31 Aug 2025 23:19:56 +0900 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20db=20context=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InfrastructureServiceCollectionExtensions.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs index e93485d..aeefd86 100644 --- a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -51,7 +51,11 @@ public static IServiceProvider MigrateDatabase(this IServiceProvider serviceProv private static void AddDatabaseServices(IServiceCollection services, IConfiguration configuration) { services.AddDbContext(options => - options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); + options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"), + sqlOptions => sqlOptions.EnableRetryOnFailure( + maxRetryCount: 3, + maxRetryDelay: TimeSpan.FromSeconds(10), + errorNumbersToAdd: null))); } /// From c1da9dd4cf8570c231abcd302c395df2055441ad Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 1 Sep 2025 10:24:49 +0900 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=EB=82=B4=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=83=80=EC=9E=85=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 --- .../Models/Chat/ChatProcessContext.cs | 6 ++-- ...Message.cs => ChatProcessResultMessage.cs} | 7 ++-- .../Chat/Handlers/ChatFailureHandler.cs | 2 +- .../Chat/Handlers/ChatSuccessHandler.cs | 15 +++++---- .../Chat/Processors/ChatResultProcessor.cs | 6 ++-- .../Chat/Processors/ChatTTSProcessor.cs | 6 ++-- .../Constants/CharacterConstants.cs | 33 +++++++++++-------- 7 files changed, 41 insertions(+), 34 deletions(-) rename ProjectVG.Application/Models/Chat/{IntegratedChatMessage.cs => ChatProcessResultMessage.cs} (85%) diff --git a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs index c8631ad..113e870 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs @@ -1,3 +1,4 @@ +using Azure.Core; using ProjectVG.Application.Models.Character; using ProjectVG.Domain.Entities.ConversationHistorys; @@ -5,7 +6,7 @@ namespace ProjectVG.Application.Models.Chat { public class ChatProcessContext { - public string SessionId { get; private set; } = string.Empty; + public Guid RequestId { get; } = Guid.NewGuid(); public Guid UserId { get; private set; } public Guid CharacterId { get; private set; } public string UserMessage { get; private set; } = string.Empty; @@ -23,6 +24,7 @@ public class ChatProcessContext public ChatProcessContext(ChatRequestCommand command) { + RequestId = command.Id; UserId = command.UserId; CharacterId = command.CharacterId; UserMessage = command.UserPrompt; @@ -74,7 +76,7 @@ public string ToDebugString() // Request 기본 정보 sb.AppendLine($"[ChatProcessContext Debug Info]"); sb.AppendLine($"=== REQUEST INFO ==="); - sb.AppendLine($"SessionId: {SessionId}"); + sb.AppendLine($"SessionId: {RequestId}"); sb.AppendLine($"UserId: {UserId}"); sb.AppendLine($"CharacterId: {CharacterId}"); sb.AppendLine($"UserMessage: \"{UserMessage}\""); diff --git a/ProjectVG.Application/Models/Chat/IntegratedChatMessage.cs b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs similarity index 85% rename from ProjectVG.Application/Models/Chat/IntegratedChatMessage.cs rename to ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs index 307235c..099658a 100644 --- a/ProjectVG.Application/Models/Chat/IntegratedChatMessage.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs @@ -2,17 +2,14 @@ namespace ProjectVG.Application.Models.Chat { - public class IntegratedChatMessage + public class ChatProcessResultMessage { [JsonPropertyName("type")] - public string Type { get; set; } = "chat"; + public string Type { get; set; } = "text"; [JsonPropertyName("message_type")] public string MessageType { get; set; } = "json"; - [JsonPropertyName("session_id")] - public string SessionId { get; set; } = string.Empty; - [JsonPropertyName("text")] public string? Text { get; set; } diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs index 8bdcfd1..9af8a9c 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatFailureHandler.cs @@ -26,7 +26,7 @@ public async Task HandleAsync(ChatProcessContext context) await _webSocketService.SendAsync(context.UserId.ToString(), errorResponse); } catch (Exception ex) { - _logger.LogError(ex, "오류 메시지 전송 실패: 세션 {UserId}", context.SessionId); + _logger.LogError(ex, "오류 메시지 전송 실패: 세션 {UserId}", context.RequestId); } } } diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs index a96f17d..6eafeb5 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs @@ -23,22 +23,25 @@ public async Task HandleAsync(ChatProcessContext context) foreach (var segment in context.Segments.OrderBy(s => s.Order)) { if (segment.IsEmpty) continue; - var integratedMessage = new IntegratedChatMessage { - SessionId = context.SessionId, + var integratedMessage = new ChatProcessResultMessage { + Type = segment.Type == SegmentType.Text ? "text" : "action", Text = segment.Content, - AudioFormat = segment.AudioContentType ?? "wav", - AudioLength = segment.AudioLength, Timestamp = DateTime.UtcNow }; - integratedMessage.SetAudioData(segment.AudioData); + if (segment.Type == SegmentType.Text && segment.HasAudio) + { + integratedMessage.AudioFormat = segment.AudioContentType ?? "wav"; + integratedMessage.AudioLength = segment.AudioLength; + integratedMessage.SetAudioData(segment.AudioData); + } var wsMessage = new WebSocketMessage("chat", integratedMessage); await _webSocketService.SendAsync(context.UserId.ToString(), wsMessage); } _logger.LogDebug("채팅 결과 전송 완료: 세션 {UserId}, 세그먼트 {SegmentCount}개", - context.SessionId, context.Segments.Count(s => !s.IsEmpty)); + context.RequestId, context.Segments.Count(s => !s.IsEmpty)); } } } diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs index a35d567..c061bb4 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs @@ -33,7 +33,7 @@ public async Task PersistResultsAsync(ChatProcessContext context) await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.Assistant, context.Response); await PersistMemoryAsync(context); - _logger.LogDebug("채팅 결과 저장 완료: 세션 {UserId}, 사용자 {UserId}", context.SessionId, context.UserId); + _logger.LogDebug("채팅 결과 저장 완료: 세션 {UserId}, 사용자 {UserId}", context.RequestId, context.UserId); } private async Task PersistMemoryAsync(ChatProcessContext context) @@ -53,7 +53,7 @@ private async Task PersistMemoryAsync(ChatProcessContext context) Context = new Dictionary { { "character_id", context.CharacterId }, - { "session_id", context.SessionId }, + { "session_id", context.RequestId }, { "conversation_turn", DateTime.UtcNow.Ticks }, { "user_message", context.UserMessage }, { "response_type", "chat_response" }, @@ -77,7 +77,7 @@ private async Task PersistMemoryAsync(ChatProcessContext context) Context = new Dictionary { { "character_id", context.CharacterId }, - { "session_id", context.SessionId }, + { "session_id", context.RequestId }, { "conversation_turn", DateTime.UtcNow.Ticks - 1 }, { "message_type", "user_input" }, { "timestamp", DateTime.UtcNow.ToString("o") } diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs index c752daa..de1a259 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs @@ -21,13 +21,13 @@ public async Task ProcessAsync(ChatProcessContext context) { if (!context.UseTTS || string.IsNullOrWhiteSpace(context.Character?.VoiceId) || context.Segments?.Count == 0) { _logger.LogDebug("TTS 처리 건너뜀: 세션 {UserId}, TTS사용여부 {UseTTS}, 음성ID {VoiceId}, 세그먼트 수 {SegmentCount}", - context.SessionId, context.UseTTS, context.Character?.VoiceId, context.Segments?.Count ?? 0); + context.RequestId, context.UseTTS, context.Character?.VoiceId, context.Segments?.Count ?? 0); return; } var profile = VoiceCatalog.GetProfile(context.Character.VoiceId); if (profile == null) { - _logger.LogWarning("존재하지 않는 보이스: {VoiceId}, 세션 {UserId}", context.Character.VoiceId, context.SessionId); + _logger.LogWarning("존재하지 않는 보이스: {VoiceId}, 세션 {UserId}", context.Character.VoiceId, context.RequestId); return; } @@ -61,7 +61,7 @@ public async Task ProcessAsync(ChatProcessContext context) } _logger.LogDebug("TTS 처리 완료: 세션 {UserId}, 처리된 세그먼트 {ProcessedCount}개, 총 비용 {TotalCost}", - context.SessionId, processedCount, context.Cost); + context.RequestId, processedCount, context.Cost); } private string NormalizeEmotion(string? emotion, VoiceProfile profile) diff --git a/ProjectVG.Common/Constants/CharacterConstants.cs b/ProjectVG.Common/Constants/CharacterConstants.cs index 852d5a0..534ada3 100644 --- a/ProjectVG.Common/Constants/CharacterConstants.cs +++ b/ProjectVG.Common/Constants/CharacterConstants.cs @@ -4,22 +4,24 @@ public static class CharacterConstants { public static readonly string[] SupportedEmotions = new[] { - "neutral", // 중립 + "neutral", // 기본 중립 표정 "happy", // 행복 "sad", // 슬픔 "angry", // 화남 - "shy", // 수줍음 + "shy", // 수줍음/부끄러움 "surprised", // 놀람 - "embarrassed", // 당황/창피 + "embarrassed", // 당황 "sleepy", // 졸림 - "confused", // 혼란 - "proud" // 자부심 + "confused" // 혼란 + // "proud", // 사용 빈도 낮음 + // "excited", // 과도한 감정, 잘 안 씀 + // "crying", // 울음 세부 표현은 필요 시 추가 + // "love", // 하트눈 필요 시 추가 + // "thinking" // 필요 시 추가 }; - public static readonly string[] SupportedActions = new[] { - "blushing", // 얼굴 붉히기 "nodding", // 끄덕이기 "shaking_head", // 고개 젓기 "waving", // 손 흔들기 @@ -27,13 +29,16 @@ public static class CharacterConstants "frowning", // 찡그림 "looking_away", // 시선 돌리기 "tilting_head", // 고개 갸웃 - "crossing_arms", // 팔짱 + "blushing", // 얼굴 붉히기 "sighing", // 한숨 - "giggling", // 낄낄 웃기 - "pouting", // 입 삐죽 - "stretching", // 기지개 - "yawning", // 하품 - "clapping" // 박수 + "pouting", // 입 삐죽 + "yawning" // 하품 + // "clapping", // 구현 난도 높음 + // "pointing", // 구현 난도 높음 + // "dancing", // 구현 난도 높음 + // "saluting", // 구현 난도 높음 + // "crying_action",// 구현 난도 높음 + // "heart_pose" // 필요 시 추가 }; } -} \ No newline at end of file +} From b7a683dae99670261fd558a79269ce5eadd64f3e Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 1 Sep 2025 10:49:16 +0900 Subject: [PATCH 04/20] =?UTF-8?q?refactory:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Models/Auth/Request/LoginRequest.cs | 6 ++--- .../Models/Auth/Request/RegisterRequest.cs | 8 +++---- .../Models/Auth/Response/AuthResponse.cs | 12 +++++----- .../Models/Auth/Response/CheckResponse.cs | 6 ++--- .../Request/CreateCharacterRequest.cs | 10 ++++---- .../Request/UpdateCharacterRequest.cs | 10 ++++---- .../Character/Response/CharacterResponse.cs | 12 +++++----- .../Models/Chat/Request/ChatRequest.cs | 12 +++++----- .../Models/Character/CharacterCommand.cs | 10 ++++---- .../Models/Character/CharacterDto.cs | 22 ++++++++--------- .../Character/CreateCharacterCommand.cs | 2 +- .../Character/UpdateCharacterCommand.cs | 2 +- .../Models/Chat/ChatProcessContext.cs | 2 +- .../Models/Chat/ChatProcessResultMessage.cs | 24 +++++++++---------- .../Models/Chat/ChatValidationResult.cs | 8 +++---- ProjectVG.Application/Models/User/UserDto.cs | 20 ++++++++-------- .../Models/WebSocket/WebSocketMessage.cs | 6 ++--- .../Services/Auth/AuthService.cs | 3 +-- .../Chat/Handlers/ChatSuccessHandler.cs | 11 +++++---- ProjectVG.Common/Models/ErrorResponse.cs | 14 +++++------ .../Models/Session/SessionInfo.cs | 8 +++---- ProjectVG.Common/Models/TokenResponse.cs | 10 ++++---- 22 files changed, 110 insertions(+), 108 deletions(-) diff --git a/ProjectVG.Api/Models/Auth/Request/LoginRequest.cs b/ProjectVG.Api/Models/Auth/Request/LoginRequest.cs index bc9dbcf..48a6833 100644 --- a/ProjectVG.Api/Models/Auth/Request/LoginRequest.cs +++ b/ProjectVG.Api/Models/Auth/Request/LoginRequest.cs @@ -2,12 +2,12 @@ namespace ProjectVG.Api.Models.Auth.Request { - public class LoginRequest + public record LoginRequest { [JsonPropertyName("username")] - public string Username { get; set; } = string.Empty; + public string Username { get; init; } = string.Empty; [JsonPropertyName("password")] - public string Password { get; set; } = string.Empty; + public string Password { get; init; } = string.Empty; } } diff --git a/ProjectVG.Api/Models/Auth/Request/RegisterRequest.cs b/ProjectVG.Api/Models/Auth/Request/RegisterRequest.cs index 1b00636..8698148 100644 --- a/ProjectVG.Api/Models/Auth/Request/RegisterRequest.cs +++ b/ProjectVG.Api/Models/Auth/Request/RegisterRequest.cs @@ -5,18 +5,18 @@ namespace ProjectVG.Api.Models.Auth.Request { - public class RegisterRequest + public record RegisterRequest { [JsonPropertyName("username")] - public string Username { get; set; } = string.Empty; + public string Username { get; init; } = string.Empty; [JsonPropertyName("email")] - public string Email { get; set; } = string.Empty; + public string Email { get; init; } = string.Empty; [JsonPropertyName("password")] - public string Password { get; set; } = string.Empty; + public string Password { get; init; } = string.Empty; public UserDto ToUserDto() { diff --git a/ProjectVG.Api/Models/Auth/Response/AuthResponse.cs b/ProjectVG.Api/Models/Auth/Response/AuthResponse.cs index c61107c..932eec9 100644 --- a/ProjectVG.Api/Models/Auth/Response/AuthResponse.cs +++ b/ProjectVG.Api/Models/Auth/Response/AuthResponse.cs @@ -2,21 +2,21 @@ namespace ProjectVG.Api.Models.Auth.Response { - public class AuthResponse + public record AuthResponse { [JsonPropertyName("success")] - public bool Success { get; set; } + public bool Success { get; init; } [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; + public string Message { get; init; } = string.Empty; [JsonPropertyName("user_id")] - public Guid? UserId { get; set; } + public Guid? UserId { get; init; } [JsonPropertyName("username")] - public string? Username { get; set; } + public string? Username { get; init; } [JsonPropertyName("email")] - public string? Email { get; set; } + public string? Email { get; init; } } } diff --git a/ProjectVG.Api/Models/Auth/Response/CheckResponse.cs b/ProjectVG.Api/Models/Auth/Response/CheckResponse.cs index 14cd56c..1685fa6 100644 --- a/ProjectVG.Api/Models/Auth/Response/CheckResponse.cs +++ b/ProjectVG.Api/Models/Auth/Response/CheckResponse.cs @@ -2,12 +2,12 @@ namespace ProjectVG.Api.Models.Auth.Response { - public class CheckResponse + public record CheckResponse { [JsonPropertyName("exists")] - public bool Exists { get; set; } + public bool Exists { get; init; } [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; + public string Message { get; init; } = string.Empty; } } diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs index f2f2340..4a236ce 100644 --- a/ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs +++ b/ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs @@ -3,19 +3,19 @@ namespace ProjectVG.Api.Models.Character.Request { - public class CreateCharacterRequest + public record CreateCharacterRequest { [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; [JsonPropertyName("description")] - public string Description { get; set; } = string.Empty; + public string Description { get; init; } = string.Empty; [JsonPropertyName("role")] - public string Role { get; set; } = string.Empty; + public string Role { get; init; } = string.Empty; [JsonPropertyName("is_active")] - public bool IsActive { get; set; } = true; + public bool IsActive { get; init; } = true; public CreateCharacterCommand ToCreateCharacterCommand() { diff --git a/ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs b/ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs index c727fa7..d46a8f7 100644 --- a/ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs +++ b/ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs @@ -3,19 +3,19 @@ namespace ProjectVG.Api.Models.Character.Request { - public class UpdateCharacterRequest + public record UpdateCharacterRequest { [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; [JsonPropertyName("description")] - public string Description { get; set; } = string.Empty; + public string Description { get; init; } = string.Empty; [JsonPropertyName("role")] - public string Role { get; set; } = string.Empty; + public string Role { get; init; } = string.Empty; [JsonPropertyName("is_active")] - public bool IsActive { get; set; } = true; + public bool IsActive { get; init; } = true; public UpdateCharacterCommand ToUpdateCharacterCommand() { diff --git a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs index 09bce7a..f32a695 100644 --- a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs +++ b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs @@ -3,22 +3,22 @@ namespace ProjectVG.Api.Models.Character.Response { - public class CharacterResponse + public record CharacterResponse { [JsonPropertyName("id")] - public Guid Id { get; set; } + public Guid Id { get; init; } [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; + public string Name { get; init; } = string.Empty; [JsonPropertyName("description")] - public string Description { get; set; } = string.Empty; + public string Description { get; init; } = string.Empty; [JsonPropertyName("role")] - public string Role { get; set; } = string.Empty; + public string Role { get; init; } = string.Empty; [JsonPropertyName("is_active")] - public bool IsActive { get; set; } = true; + public bool IsActive { get; init; } = true; public static CharacterResponse ToResponseDto(CharacterDto characterDto) diff --git a/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs b/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs index 66afdcf..d3f130f 100644 --- a/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs +++ b/ProjectVG.Api/Models/Chat/Request/ChatRequest.cs @@ -3,21 +3,21 @@ namespace ProjectVG.Application.Models.API.Request { - public class ChatRequest + public record ChatRequest { [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; + public string Message { get; init; } = string.Empty; [JsonPropertyName("character_id")] - public Guid CharacterId { get; set; } + public Guid CharacterId { get; init; } [JsonPropertyName("action")] - public string? Action { get; set; } + public string? Action { get; init; } [JsonPropertyName("use_tts")] - public bool UseTTS { get; set; } = true; + public bool UseTTS { get; init; } = true; [JsonPropertyName("request_at")] - public DateTime RequestAt { get; set; } + public DateTime RequestAt { get; init; } } } diff --git a/ProjectVG.Application/Models/Character/CharacterCommand.cs b/ProjectVG.Application/Models/Character/CharacterCommand.cs index 9b84f8e..9f95edf 100644 --- a/ProjectVG.Application/Models/Character/CharacterCommand.cs +++ b/ProjectVG.Application/Models/Character/CharacterCommand.cs @@ -1,10 +1,10 @@ namespace ProjectVG.Application.Models.Character { - public class CharacterCommand + public record CharacterCommand { - public string Name { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Role { get; set; } = string.Empty; - public bool IsActive { get; set; } = true; + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string Role { get; init; } = string.Empty; + public bool IsActive { get; init; } = true; } } diff --git a/ProjectVG.Application/Models/Character/CharacterDto.cs b/ProjectVG.Application/Models/Character/CharacterDto.cs index 88ab448..85e638d 100644 --- a/ProjectVG.Application/Models/Character/CharacterDto.cs +++ b/ProjectVG.Application/Models/Character/CharacterDto.cs @@ -2,18 +2,18 @@ namespace ProjectVG.Application.Models.Character { - public class CharacterDto + public record CharacterDto { - public Guid Id { get; set; } - public string Name { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Role { get; set; } = string.Empty; - public bool IsActive { get; set; } = true; - public string Personality { get; set; } = string.Empty; - public string SpeechStyle { get; set; } = string.Empty; - public string Summary { get; set; } = string.Empty; - public string UserAlias { get; set; } = string.Empty; - public string VoiceId { get; set; } = string.Empty; + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string Role { get; init; } = string.Empty; + public bool IsActive { get; init; } = true; + public string Personality { get; init; } = string.Empty; + public string SpeechStyle { get; init; } = string.Empty; + public string Summary { get; init; } = string.Empty; + public string UserAlias { get; init; } = string.Empty; + public string VoiceId { get; init; } = string.Empty; public CharacterDto(Domain.Entities.Characters.Character character) diff --git a/ProjectVG.Application/Models/Character/CreateCharacterCommand.cs b/ProjectVG.Application/Models/Character/CreateCharacterCommand.cs index 015f7af..23adb2a 100644 --- a/ProjectVG.Application/Models/Character/CreateCharacterCommand.cs +++ b/ProjectVG.Application/Models/Character/CreateCharacterCommand.cs @@ -1,6 +1,6 @@ namespace ProjectVG.Application.Models.Character { - public class CreateCharacterCommand : CharacterCommand + public record CreateCharacterCommand : CharacterCommand { } } diff --git a/ProjectVG.Application/Models/Character/UpdateCharacterCommand.cs b/ProjectVG.Application/Models/Character/UpdateCharacterCommand.cs index baf0b69..719cdb4 100644 --- a/ProjectVG.Application/Models/Character/UpdateCharacterCommand.cs +++ b/ProjectVG.Application/Models/Character/UpdateCharacterCommand.cs @@ -1,6 +1,6 @@ namespace ProjectVG.Application.Models.Character { - public class UpdateCharacterCommand : CharacterCommand + public record UpdateCharacterCommand : CharacterCommand { } } diff --git a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs index 113e870..d4ba686 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs @@ -4,7 +4,7 @@ namespace ProjectVG.Application.Models.Chat { - public class ChatProcessContext + public record ChatProcessContext { public Guid RequestId { get; } = Guid.NewGuid(); public Guid UserId { get; private set; } diff --git a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs index 099658a..1f335e8 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs @@ -2,41 +2,41 @@ namespace ProjectVG.Application.Models.Chat { - public class ChatProcessResultMessage + public record ChatProcessResultMessage { [JsonPropertyName("type")] - public string Type { get; set; } = "text"; + public string Type { get; init; } = "chat"; [JsonPropertyName("message_type")] - public string MessageType { get; set; } = "json"; + public string MessageType { get; init; } = "json"; [JsonPropertyName("text")] - public string? Text { get; set; } + public string? Text { get; init; } [JsonPropertyName("audio_data")] - public string? AudioData { get; set; } + public string? AudioData { get; init; } [JsonPropertyName("audio_format")] - public string? AudioFormat { get; set; } = "wav"; + public string? AudioFormat { get; init; } [JsonPropertyName("audio_length")] - public float? AudioLength { get; set; } + public float? AudioLength { get; init; } [JsonPropertyName("timestamp")] - public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public DateTime Timestamp { get; init; } = DateTime.UtcNow; [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } + public Dictionary? Metadata { get; init; } - public void SetAudioData(byte[]? audioBytes) + public ChatProcessResultMessage WithAudioData(byte[]? audioBytes) { if (audioBytes != null && audioBytes.Length > 0) { - AudioData = Convert.ToBase64String(audioBytes); + return this with { AudioData = Convert.ToBase64String(audioBytes) }; } else { - AudioData = null; + return this with { AudioData = null }; } } } diff --git a/ProjectVG.Application/Models/Chat/ChatValidationResult.cs b/ProjectVG.Application/Models/Chat/ChatValidationResult.cs index a9ad29d..2d984c2 100644 --- a/ProjectVG.Application/Models/Chat/ChatValidationResult.cs +++ b/ProjectVG.Application/Models/Chat/ChatValidationResult.cs @@ -1,10 +1,10 @@ namespace ProjectVG.Application.Models.Chat { - public class ChatValidationResult + public record ChatValidationResult { - public bool IsValid { get; set; } - public string ErrorMessage { get; set; } = string.Empty; - public string ErrorCode { get; set; } = string.Empty; + public bool IsValid { get; init; } + public string ErrorMessage { get; init; } = string.Empty; + public string ErrorCode { get; init; } = string.Empty; public static ChatValidationResult Success() { diff --git a/ProjectVG.Application/Models/User/UserDto.cs b/ProjectVG.Application/Models/User/UserDto.cs index 487db14..7e31e74 100644 --- a/ProjectVG.Application/Models/User/UserDto.cs +++ b/ProjectVG.Application/Models/User/UserDto.cs @@ -2,17 +2,17 @@ namespace ProjectVG.Application.Models.User { - public class UserDto + public record UserDto { - public Guid Id { get; set; } - public string UID { get; set; } = string.Empty; - public string Username { get; set; } = string.Empty; - public string Email { get; set; } = string.Empty; - public string ProviderId { get; set; } = string.Empty; - public string Provider { get; set; } = string.Empty; - public AccountStatus Status { get; set; } - public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public Guid Id { get; init; } + public string UID { get; init; } = string.Empty; + public string Username { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string ProviderId { get; init; } = string.Empty; + public string Provider { get; init; } = string.Empty; + public AccountStatus Status { get; init; } + public DateTime CreatedAt { get; init; } + public DateTime UpdatedAt { get; init; } public UserDto() { diff --git a/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs b/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs index 53bdf58..7fe178d 100644 --- a/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs +++ b/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs @@ -2,13 +2,13 @@ namespace ProjectVG.Application.Models.WebSocket { - public class WebSocketMessage + public record WebSocketMessage { [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; + public string Type { get; init; } = string.Empty; [JsonPropertyName("data")] - public object Data { get; set; } = new(); + public object Data { get; init; } = new(); public WebSocketMessage() { } diff --git a/ProjectVG.Application/Services/Auth/AuthService.cs b/ProjectVG.Application/Services/Auth/AuthService.cs index ff2afb8..63a78c9 100644 --- a/ProjectVG.Application/Services/Auth/AuthService.cs +++ b/ProjectVG.Application/Services/Auth/AuthService.cs @@ -86,8 +86,7 @@ public async Task LoginWithOAuthAsync(string provider, string provid // OAuth2 사용자인 경우 Provider 정보를 포함하여 사용자 생성 (test와 guest는 이미 처리됨) if (provider != "test" && provider != "guest") { - user.Provider = provider; - user.ProviderId = providerUserId; + user = user with { Provider = provider, ProviderId = providerUserId }; } var tokens = await _tokenService.GenerateTokensAsync(user.Id); diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs index 6eafeb5..69b7178 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs @@ -24,16 +24,19 @@ public async Task HandleAsync(ChatProcessContext context) if (segment.IsEmpty) continue; var integratedMessage = new ChatProcessResultMessage { - Type = segment.Type == SegmentType.Text ? "text" : "action", + Type = segment.Type == SegmentType.Text ? "chat" : "action", Text = segment.Content, Timestamp = DateTime.UtcNow }; if (segment.Type == SegmentType.Text && segment.HasAudio) { - integratedMessage.AudioFormat = segment.AudioContentType ?? "wav"; - integratedMessage.AudioLength = segment.AudioLength; - integratedMessage.SetAudioData(segment.AudioData); + integratedMessage = integratedMessage with + { + AudioFormat = segment.AudioContentType ?? "wav", + AudioLength = segment.AudioLength + }; + integratedMessage = integratedMessage.WithAudioData(segment.AudioData); } var wsMessage = new WebSocketMessage("chat", integratedMessage); diff --git a/ProjectVG.Common/Models/ErrorResponse.cs b/ProjectVG.Common/Models/ErrorResponse.cs index aeeca20..050f8c2 100644 --- a/ProjectVG.Common/Models/ErrorResponse.cs +++ b/ProjectVG.Common/Models/ErrorResponse.cs @@ -1,12 +1,12 @@ namespace ProjectVG.Common.Models { - public class ErrorResponse + public record ErrorResponse { - public string ErrorCode { get; set; } = string.Empty; - public string Message { get; set; } = string.Empty; - public int StatusCode { get; set; } - public DateTime Timestamp { get; set; } - public string? TraceId { get; set; } - public List? Details { get; set; } + public string ErrorCode { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + public int StatusCode { get; init; } + public DateTime Timestamp { get; init; } + public string? TraceId { get; init; } + public List? Details { get; init; } } } diff --git a/ProjectVG.Common/Models/Session/SessionInfo.cs b/ProjectVG.Common/Models/Session/SessionInfo.cs index caffbeb..c0e1704 100644 --- a/ProjectVG.Common/Models/Session/SessionInfo.cs +++ b/ProjectVG.Common/Models/Session/SessionInfo.cs @@ -1,10 +1,10 @@ namespace ProjectVG.Common.Models.Session { - public class SessionInfo + public record SessionInfo { - public string SessionId { get; set; } = string.Empty; - public string? UserId { get; set; } - public DateTime ConnectedAt { get; set; } = DateTime.UtcNow; + public string SessionId { get; init; } = string.Empty; + public string? UserId { get; init; } + public DateTime ConnectedAt { get; init; } = DateTime.UtcNow; } } diff --git a/ProjectVG.Common/Models/TokenResponse.cs b/ProjectVG.Common/Models/TokenResponse.cs index b7d156c..6775769 100644 --- a/ProjectVG.Common/Models/TokenResponse.cs +++ b/ProjectVG.Common/Models/TokenResponse.cs @@ -1,10 +1,10 @@ namespace ProjectVG.Common.Models { - public class TokenResponse + public record TokenResponse { - public string AccessToken { get; set; } = string.Empty; - public string RefreshToken { get; set; } = string.Empty; - public DateTime AccessTokenExpiresAt { get; set; } - public DateTime RefreshTokenExpiresAt { get; set; } + public string AccessToken { get; init; } = string.Empty; + public string RefreshToken { get; init; } = string.Empty; + public DateTime AccessTokenExpiresAt { get; init; } + public DateTime RefreshTokenExpiresAt { get; init; } } } From 055f2e04f7db5758060be73414b90799dbbc1d13 Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 1 Sep 2025 11:03:39 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20Build=20=ED=8C=A8=ED=84=B4=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/ChatProcessResultMessageBuilder.cs | 123 +++++++++ .../Chat/Handlers/ChatSuccessHandler.cs | 72 +++-- .../ChatProcessResultMessageBuilderTests.cs | 191 +++++++++++++ .../Chat/Handlers/ChatSuccessHandlerTests.cs | 253 ++++++++++++++++++ 4 files changed, 620 insertions(+), 19 deletions(-) create mode 100644 ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs create mode 100644 ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs create mode 100644 ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs diff --git a/ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs b/ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs new file mode 100644 index 0000000..a8e8f00 --- /dev/null +++ b/ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs @@ -0,0 +1,123 @@ +using System.Text.Json.Serialization; + +namespace ProjectVG.Application.Models.Chat +{ + public class ChatProcessResultMessageBuilder + { + private string _type = "chat"; + private string _messageType = "json"; + private string? _text; + private string? _audioData; + private string? _audioFormat; + private float? _audioLength; + private DateTime _timestamp = DateTime.UtcNow; + private Dictionary? _metadata; + + public ChatProcessResultMessageBuilder SetType(string type) + { + _type = type ?? "chat"; + return this; + } + + public ChatProcessResultMessageBuilder SetMessageType(string messageType) + { + _messageType = messageType ?? "json"; + return this; + } + + public ChatProcessResultMessageBuilder SetText(string? text) + { + _text = text; + return this; + } + + public ChatProcessResultMessageBuilder SetAudioData(byte[]? audioBytes) + { + if (audioBytes != null && audioBytes.Length > 0) + { + _audioData = Convert.ToBase64String(audioBytes); + } + else + { + _audioData = null; + } + return this; + } + + public ChatProcessResultMessageBuilder SetAudioFormat(string? audioFormat) + { + _audioFormat = audioFormat; + return this; + } + + public ChatProcessResultMessageBuilder SetAudioLength(float? audioLength) + { + _audioLength = audioLength; + return this; + } + + public ChatProcessResultMessageBuilder SetTimestamp(DateTime timestamp) + { + _timestamp = timestamp; + return this; + } + + public ChatProcessResultMessageBuilder AddMetadata(string key, object value) + { + _metadata ??= new Dictionary(); + _metadata[key] = value; + return this; + } + + public ChatProcessResultMessageBuilder SetMetadata(Dictionary? metadata) + { + _metadata = metadata; + return this; + } + + public ChatProcessResultMessage Build() + { + return new ChatProcessResultMessage + { + Type = _type, + MessageType = _messageType, + Text = _text, + AudioData = _audioData, + AudioFormat = _audioFormat, + AudioLength = _audioLength, + Timestamp = _timestamp, + Metadata = _metadata + }; + } + + public static ChatProcessResultMessageBuilder FromSegment(ChatSegment segment) + { + var builder = new ChatProcessResultMessageBuilder() + .SetType(segment.Type == SegmentType.Text ? "chat" : "action") + .SetText(segment.Content) + .SetTimestamp(DateTime.UtcNow); + + if (segment.Type == SegmentType.Text && segment.HasAudio) + { + builder + .SetAudioData(segment.AudioData) + .SetAudioFormat(segment.AudioContentType ?? "wav") + .SetAudioLength(segment.AudioLength); + } + + if (segment.HasEmotion) + { + builder.AddMetadata("emotion", segment.Emotion!); + } + + builder.AddMetadata("order", segment.Order); + + return builder; + } + + public static ChatProcessResultMessage CreateFromSegment(ChatSegment segment) + { + return FromSegment(segment).Build(); + } + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs index 69b7178..99ded6c 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs @@ -20,31 +20,65 @@ public ChatSuccessHandler( public async Task HandleAsync(ChatProcessContext context) { - foreach (var segment in context.Segments.OrderBy(s => s.Order)) { - if (segment.IsEmpty) continue; + try + { + var validSegments = context.Segments + .Where(s => !s.IsEmpty) + .OrderBy(s => s.Order) + .ToList(); - var integratedMessage = new ChatProcessResultMessage { - Type = segment.Type == SegmentType.Text ? "chat" : "action", - Text = segment.Content, - Timestamp = DateTime.UtcNow - }; - - if (segment.Type == SegmentType.Text && segment.HasAudio) + if (!validSegments.Any()) { - integratedMessage = integratedMessage with - { - AudioFormat = segment.AudioContentType ?? "wav", - AudioLength = segment.AudioLength - }; - integratedMessage = integratedMessage.WithAudioData(segment.AudioData); + _logger.LogWarning("채팅 처리 결과에 유효한 세그먼트가 없습니다: 요청 {RequestId}", context.RequestId); + return; } - var wsMessage = new WebSocketMessage("chat", integratedMessage); - await _webSocketService.SendAsync(context.UserId.ToString(), wsMessage); + await ProcessSegmentsBatch(context.UserId, validSegments); + + _logger.LogDebug("채팅 결과 전송 완료: 요청 {RequestId}, 세그먼트 {SegmentCount}개", + context.RequestId, validSegments.Count); } + catch (Exception ex) + { + _logger.LogError(ex, "채팅 결과 전송 중 오류 발생: 요청 {RequestId}", context.RequestId); + throw; + } + } - _logger.LogDebug("채팅 결과 전송 완료: 세션 {UserId}, 세그먼트 {SegmentCount}개", - context.RequestId, context.Segments.Count(s => !s.IsEmpty)); + private async Task ProcessSegmentsBatch(Guid userId, List segments) + { + const int maxRetries = 3; + var tasks = segments.Select(segment => ProcessSegmentWithRetry(userId, segment, maxRetries)); + + await Task.WhenAll(tasks); + } + + private async Task ProcessSegmentWithRetry(Guid userId, ChatSegment segment, int maxRetries) + { + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + var message = ChatProcessResultMessageBuilder.CreateFromSegment(segment); + var wsMessage = new WebSocketMessage("chat", message); + + await _webSocketService.SendAsync(userId.ToString(), wsMessage); + return; + } + catch (Exception ex) when (attempt < maxRetries - 1) + { + _logger.LogWarning(ex, "세그먼트 전송 실패 (시도 {Attempt}/{MaxRetries}): 사용자 {UserId}, 세그먼트 순서 {Order}", + attempt + 1, maxRetries, userId, segment.Order); + + await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 100)); + } + catch (Exception ex) + { + _logger.LogError(ex, "세그먼트 전송 최종 실패: 사용자 {UserId}, 세그먼트 순서 {Order}", + userId, segment.Order); + throw; + } + } } } } diff --git a/ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs b/ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs new file mode 100644 index 0000000..b812d95 --- /dev/null +++ b/ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs @@ -0,0 +1,191 @@ +using FluentAssertions; +using ProjectVG.Application.Models.Chat; +using Xunit; + +namespace ProjectVG.Tests.Models.Chat +{ + public class ChatProcessResultMessageBuilderTests + { + [Fact] + public void Build_WithDefaultValues_ShouldCreateValidMessage() + { + var builder = new ChatProcessResultMessageBuilder(); + var message = builder.Build(); + + message.Type.Should().Be("chat"); + message.MessageType.Should().Be("json"); + message.Text.Should().BeNull(); + message.AudioData.Should().BeNull(); + message.AudioFormat.Should().BeNull(); + message.AudioLength.Should().BeNull(); + message.Timestamp.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + message.Metadata.Should().BeNull(); + } + + [Fact] + public void SetType_ShouldSetTypeCorrectly() + { + var builder = new ChatProcessResultMessageBuilder(); + var message = builder.SetType("action").Build(); + + message.Type.Should().Be("action"); + } + + [Fact] + public void SetType_WithNullValue_ShouldDefaultToChat() + { + var builder = new ChatProcessResultMessageBuilder(); + var message = builder.SetType(null!).Build(); + + message.Type.Should().Be("chat"); + } + + [Fact] + public void SetText_ShouldSetTextCorrectly() + { + const string expectedText = "Hello, World!"; + var builder = new ChatProcessResultMessageBuilder(); + var message = builder.SetText(expectedText).Build(); + + message.Text.Should().Be(expectedText); + } + + [Fact] + public void SetAudioData_WithValidBytes_ShouldEncodeToBase64() + { + var audioBytes = new byte[] { 1, 2, 3, 4, 5 }; + var expectedBase64 = Convert.ToBase64String(audioBytes); + + var builder = new ChatProcessResultMessageBuilder(); + var message = builder.SetAudioData(audioBytes).Build(); + + message.AudioData.Should().Be(expectedBase64); + } + + [Fact] + public void SetAudioData_WithNullBytes_ShouldSetToNull() + { + var builder = new ChatProcessResultMessageBuilder(); + var message = builder.SetAudioData(null).Build(); + + message.AudioData.Should().BeNull(); + } + + [Fact] + public void SetAudioData_WithEmptyBytes_ShouldSetToNull() + { + var builder = new ChatProcessResultMessageBuilder(); + var message = builder.SetAudioData(Array.Empty()).Build(); + + message.AudioData.Should().BeNull(); + } + + [Fact] + public void AddMetadata_ShouldAddMetadataCorrectly() + { + var builder = new ChatProcessResultMessageBuilder(); + var message = builder + .AddMetadata("emotion", "happy") + .AddMetadata("order", 1) + .Build(); + + message.Metadata.Should().NotBeNull(); + message.Metadata!["emotion"].Should().Be("happy"); + message.Metadata["order"].Should().Be(1); + } + + [Fact] + public void SetMetadata_ShouldReplaceExistingMetadata() + { + var initialMetadata = new Dictionary { { "key1", "value1" } }; + var newMetadata = new Dictionary { { "key2", "value2" } }; + + var builder = new ChatProcessResultMessageBuilder(); + var message = builder + .SetMetadata(initialMetadata) + .SetMetadata(newMetadata) + .Build(); + + message.Metadata.Should().BeEquivalentTo(newMetadata); + message.Metadata.Should().NotContainKey("key1"); + } + + [Fact] + public void FromSegment_WithTextSegment_ShouldCreateCorrectBuilder() + { + var segment = ChatSegment.CreateText("Hello World", "happy", 1); + var builder = ChatProcessResultMessageBuilder.FromSegment(segment); + var message = builder.Build(); + + message.Type.Should().Be("chat"); + message.Text.Should().Be("Hello World"); + message.Metadata!["emotion"].Should().Be("happy"); + message.Metadata["order"].Should().Be(1); + } + + [Fact] + public void FromSegment_WithActionSegment_ShouldCreateCorrectBuilder() + { + var segment = ChatSegment.CreateAction("*waves hand*", 2); + var builder = ChatProcessResultMessageBuilder.FromSegment(segment); + var message = builder.Build(); + + message.Type.Should().Be("action"); + message.Text.Should().Be("*waves hand*"); + message.Metadata!["order"].Should().Be(2); + } + + [Fact] + public void FromSegment_WithAudioData_ShouldIncludeAudioProperties() + { + var audioBytes = new byte[] { 1, 2, 3, 4, 5 }; + var segment = ChatSegment.CreateText("Hello with audio") + .WithAudioData(audioBytes, "mp3", 5.5f); + + var builder = ChatProcessResultMessageBuilder.FromSegment(segment); + var message = builder.Build(); + + message.AudioData.Should().Be(Convert.ToBase64String(audioBytes)); + message.AudioFormat.Should().Be("mp3"); + message.AudioLength.Should().Be(5.5f); + } + + [Fact] + public void CreateFromSegment_ShouldCreateMessageDirectly() + { + var segment = ChatSegment.CreateText("Direct creation test", "excited"); + var message = ChatProcessResultMessageBuilder.CreateFromSegment(segment); + + message.Type.Should().Be("chat"); + message.Text.Should().Be("Direct creation test"); + message.Metadata!["emotion"].Should().Be("excited"); + } + + [Fact] + public void MethodChaining_ShouldAllowFluentInterface() + { + var timestamp = DateTime.UtcNow.AddMinutes(-5); + var audioBytes = new byte[] { 10, 20, 30 }; + + var message = new ChatProcessResultMessageBuilder() + .SetType("action") + .SetMessageType("custom") + .SetText("Chained method test") + .SetAudioData(audioBytes) + .SetAudioFormat("wav") + .SetAudioLength(3.2f) + .SetTimestamp(timestamp) + .AddMetadata("test", true) + .Build(); + + message.Type.Should().Be("action"); + message.MessageType.Should().Be("custom"); + message.Text.Should().Be("Chained method test"); + message.AudioData.Should().Be(Convert.ToBase64String(audioBytes)); + message.AudioFormat.Should().Be("wav"); + message.AudioLength.Should().Be(3.2f); + message.Timestamp.Should().Be(timestamp); + message.Metadata!["test"].Should().Be(true); + } + } +} \ No newline at end of file diff --git a/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs new file mode 100644 index 0000000..963b423 --- /dev/null +++ b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs @@ -0,0 +1,253 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using ProjectVG.Application.Models.Chat; +using ProjectVG.Application.Models.WebSocket; +using ProjectVG.Application.Services.Chat.Handlers; +using ProjectVG.Application.Services.WebSocket; +using Xunit; + +namespace ProjectVG.Tests.Services.Chat.Handlers +{ + public class ChatSuccessHandlerTests + { + private readonly Mock> _mockLogger; + private readonly Mock _mockWebSocketService; + private readonly ChatSuccessHandler _handler; + + public ChatSuccessHandlerTests() + { + _mockLogger = new Mock>(); + _mockWebSocketService = new Mock(); + _handler = new ChatSuccessHandler(_mockLogger.Object, _mockWebSocketService.Object); + } + + [Fact] + public async Task HandleAsync_WithEmptySegments_ShouldLogWarningAndReturn() + { + var context = CreateTestContext(); + context.SetResponse("", new List(), 0.0); + + await _handler.HandleAsync(context); + + VerifyWarningLogged("채팅 처리 결과에 유효한 세그먼트가 없습니다"); + _mockWebSocketService.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WithValidSegments_ShouldSendAllMessages() + { + var context = CreateTestContext(); + var segments = new List + { + ChatSegment.CreateText("Hello", order: 0), + ChatSegment.CreateAction("*waves*", order: 1), + ChatSegment.CreateText("How are you?", order: 2) + }; + context.SetResponse("Hello", segments, 0.0); + + await _handler.HandleAsync(context); + + _mockWebSocketService.Verify( + x => x.SendAsync(context.UserId.ToString(), It.IsAny()), + Times.Exactly(3)); + + VerifyDebugLogged("채팅 결과 전송 완료"); + } + + [Fact] + public async Task HandleAsync_WithMixedValidAndEmptySegments_ShouldOnlySendValidOnes() + { + var context = CreateTestContext(); + var segments = new List + { + ChatSegment.CreateText("Valid text", order: 0), + ChatSegment.CreateText("", order: 1), // Empty segment + ChatSegment.CreateAction("Valid action", order: 2) + }; + context.SetResponse("Test", segments, 0.0); + + await _handler.HandleAsync(context); + + _mockWebSocketService.Verify( + x => x.SendAsync(context.UserId.ToString(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task HandleAsync_WithAudioSegment_ShouldIncludeAudioData() + { + var context = CreateTestContext(); + var audioBytes = new byte[] { 1, 2, 3, 4, 5 }; + var segment = ChatSegment.CreateText("Audio message") + .WithAudioData(audioBytes, "wav", 2.5f); + context.SetResponse("Test", new List { segment }, 0.0); + + WebSocketMessage? sentMessage = null; + _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Callback((_, message) => sentMessage = message); + + await _handler.HandleAsync(context); + + sentMessage.Should().NotBeNull(); + var resultMessage = sentMessage!.Data as ChatProcessResultMessage; + resultMessage.Should().NotBeNull(); + resultMessage!.AudioData.Should().Be(Convert.ToBase64String(audioBytes)); + resultMessage.AudioFormat.Should().Be("wav"); + resultMessage.AudioLength.Should().Be(2.5f); + } + + [Fact] + public async Task HandleAsync_WithWebSocketFailure_ShouldRetryWithBackoff() + { + var context = CreateTestContext(); + var segment = ChatSegment.CreateText("Test message"); + context.SetResponse("Test", new List { segment }, 0.0); + + var callCount = 0; + _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Returns(() => + { + callCount++; + if (callCount < 3) + throw new Exception("Connection failed"); + return Task.CompletedTask; + }); + + await _handler.HandleAsync(context); + + _mockWebSocketService.Verify( + x => x.SendAsync(It.IsAny(), It.IsAny()), + Times.Exactly(3)); + + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("세그먼트 전송 실패")), + It.IsAny(), + It.IsAny>()), + Times.Exactly(2)); // 2 failures before success + } + + [Fact] + public async Task HandleAsync_WithPersistentFailure_ShouldThrowAfterMaxRetries() + { + var context = CreateTestContext(); + var segment = ChatSegment.CreateText("Test message"); + context.SetResponse("Test", new List { segment }, 0.0); + + _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Persistent failure")); + + var act = async () => await _handler.HandleAsync(context); + + await act.Should().ThrowAsync().WithMessage("Persistent failure"); + + _mockWebSocketService.Verify( + x => x.SendAsync(It.IsAny(), It.IsAny()), + Times.Exactly(3)); // Max retries + + VerifyErrorLogged("채팅 결과 전송 중 오류 발생"); + } + + [Fact] + public async Task HandleAsync_WithCorrectOrder_ShouldSendInOrderedSequence() + { + var context = CreateTestContext(); + var segments = new List + { + ChatSegment.CreateText("Third", order: 2), + ChatSegment.CreateText("First", order: 0), + ChatSegment.CreateText("Second", order: 1) + }; + context.SetResponse("Test", segments, 0.0); + + var sentMessages = new List(); + _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Callback((_, message) => sentMessages.Add(message)); + + await _handler.HandleAsync(context); + + sentMessages.Should().HaveCount(3); + var resultMessages = sentMessages.Select(m => m.Data as ChatProcessResultMessage).ToList(); + + resultMessages[0]!.Text.Should().Be("First"); + resultMessages[1]!.Text.Should().Be("Second"); + resultMessages[2]!.Text.Should().Be("Third"); + } + + [Fact] + public async Task HandleAsync_WithSegmentTypes_ShouldSetCorrectMessageTypes() + { + var context = CreateTestContext(); + var segments = new List + { + ChatSegment.CreateText("Chat message"), + ChatSegment.CreateAction("Action message") + }; + context.SetResponse("Test", segments, 0.0); + + var sentMessages = new List(); + _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Callback((_, message) => sentMessages.Add(message)); + + await _handler.HandleAsync(context); + + var chatMessage = sentMessages[0].Data as ChatProcessResultMessage; + var actionMessage = sentMessages[1].Data as ChatProcessResultMessage; + + chatMessage!.Type.Should().Be("chat"); + actionMessage!.Type.Should().Be("action"); + } + + private static ChatProcessContext CreateTestContext() + { + var command = new ChatRequestCommand( + userId: Guid.NewGuid(), + characterId: Guid.NewGuid(), + userPrompt: "Test prompt", + requestedAt: DateTime.UtcNow, + useTTS: false + ); + + return new ChatProcessContext(command); + } + + private void VerifyWarningLogged(string expectedMessage) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessage)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private void VerifyDebugLogged(string expectedMessage) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessage)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private void VerifyErrorLogged(string expectedMessage) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessage)), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + } +} \ No newline at end of file From 5eaaa9908f67304868f165d4d2ab9e0ee56e79cf Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 1 Sep 2025 12:19:54 +0900 Subject: [PATCH 06/20] =?UTF-8?q?refactory:=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Models/Chat/ChatProcessContext.cs | 2 +- .../Models/Chat/ChatProcessResultMessage.cs | 38 +- .../Chat/ChatProcessResultMessageBuilder.cs | 123 ----- .../Models/Chat/ChatSegment.cs | 32 +- .../Models/WebSocket/WebSocketMessage.cs | 6 +- .../Services/Chat/Factories/ChatLLMFormat.cs | 126 +++-- .../Chat/Handlers/ChatSuccessHandler.cs | 56 +- .../Chat/Processors/ChatTTSProcessor.cs | 2 +- .../ChatProcessResultMessageBuilderTests.cs | 191 ------- .../Chat/Factories/ChatLLMFormatTests.cs | 484 ------------------ .../Chat/Handlers/ChatSuccessHandlerTests.cs | 92 ++-- docs/api_reference.md | 183 ++++++- 12 files changed, 393 insertions(+), 942 deletions(-) delete mode 100644 ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs delete mode 100644 ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs delete mode 100644 ProjectVG.Tests/Services/Chat/Factories/ChatLLMFormatTests.cs diff --git a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs index d4ba686..9a1abf7 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessContext.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessContext.cs @@ -127,7 +127,7 @@ public string ToDebugString() for (int i = 0; i < Segments.Count; i++) { var segment = Segments[i]; - sb.AppendLine($" [{i}] Type: {segment.Type}, Content: \"{segment.Content}\""); + sb.AppendLine($" [{i}] Content: \"{segment.Content}\", Emotion: {segment.Emotion}, Actions: [{(segment.Actions != null ? string.Join(", ", segment.Actions) : "")}]"); } } else diff --git a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs index 1f335e8..8ba8d6e 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs @@ -4,15 +4,21 @@ namespace ProjectVG.Application.Models.Chat { public record ChatProcessResultMessage { + [JsonPropertyName("request_id")] + public string? RequestId { get; init; } + [JsonPropertyName("type")] public string Type { get; init; } = "chat"; - [JsonPropertyName("message_type")] - public string MessageType { get; init; } = "json"; - [JsonPropertyName("text")] public string? Text { get; init; } - + + [JsonPropertyName("emotion")] + public string? Emotion { get; init; } + + [JsonPropertyName("actions")] + public string[]? Actions { get; init; } + [JsonPropertyName("audio_data")] public string? AudioData { get; init; } @@ -25,8 +31,28 @@ public record ChatProcessResultMessage [JsonPropertyName("timestamp")] public DateTime Timestamp { get; init; } = DateTime.UtcNow; - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; init; } + [JsonPropertyName("order")] + public int Order { get; init; } + + public static ChatProcessResultMessage FromSegment(ChatSegment segment, string? requestId = null) + { + var audioData = segment.HasAudio ? Convert.ToBase64String(segment.AudioData!) : null; + var audioFormat = segment.HasAudio ? segment.AudioContentType ?? "wav" : null; + + return new ChatProcessResultMessage + { + RequestId = requestId, + Type = "chat", + Text = segment.Content, + Emotion = segment.Emotion, + Actions = segment.Actions?.ToArray(), + AudioData = audioData, + AudioFormat = audioFormat, + AudioLength = segment.AudioLength, + Order = segment.Order, + Timestamp = DateTime.UtcNow + }; + } public ChatProcessResultMessage WithAudioData(byte[]? audioBytes) { diff --git a/ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs b/ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs deleted file mode 100644 index a8e8f00..0000000 --- a/ProjectVG.Application/Models/Chat/ChatProcessResultMessageBuilder.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ProjectVG.Application.Models.Chat -{ - public class ChatProcessResultMessageBuilder - { - private string _type = "chat"; - private string _messageType = "json"; - private string? _text; - private string? _audioData; - private string? _audioFormat; - private float? _audioLength; - private DateTime _timestamp = DateTime.UtcNow; - private Dictionary? _metadata; - - public ChatProcessResultMessageBuilder SetType(string type) - { - _type = type ?? "chat"; - return this; - } - - public ChatProcessResultMessageBuilder SetMessageType(string messageType) - { - _messageType = messageType ?? "json"; - return this; - } - - public ChatProcessResultMessageBuilder SetText(string? text) - { - _text = text; - return this; - } - - public ChatProcessResultMessageBuilder SetAudioData(byte[]? audioBytes) - { - if (audioBytes != null && audioBytes.Length > 0) - { - _audioData = Convert.ToBase64String(audioBytes); - } - else - { - _audioData = null; - } - return this; - } - - public ChatProcessResultMessageBuilder SetAudioFormat(string? audioFormat) - { - _audioFormat = audioFormat; - return this; - } - - public ChatProcessResultMessageBuilder SetAudioLength(float? audioLength) - { - _audioLength = audioLength; - return this; - } - - public ChatProcessResultMessageBuilder SetTimestamp(DateTime timestamp) - { - _timestamp = timestamp; - return this; - } - - public ChatProcessResultMessageBuilder AddMetadata(string key, object value) - { - _metadata ??= new Dictionary(); - _metadata[key] = value; - return this; - } - - public ChatProcessResultMessageBuilder SetMetadata(Dictionary? metadata) - { - _metadata = metadata; - return this; - } - - public ChatProcessResultMessage Build() - { - return new ChatProcessResultMessage - { - Type = _type, - MessageType = _messageType, - Text = _text, - AudioData = _audioData, - AudioFormat = _audioFormat, - AudioLength = _audioLength, - Timestamp = _timestamp, - Metadata = _metadata - }; - } - - public static ChatProcessResultMessageBuilder FromSegment(ChatSegment segment) - { - var builder = new ChatProcessResultMessageBuilder() - .SetType(segment.Type == SegmentType.Text ? "chat" : "action") - .SetText(segment.Content) - .SetTimestamp(DateTime.UtcNow); - - if (segment.Type == SegmentType.Text && segment.HasAudio) - { - builder - .SetAudioData(segment.AudioData) - .SetAudioFormat(segment.AudioContentType ?? "wav") - .SetAudioLength(segment.AudioLength); - } - - if (segment.HasEmotion) - { - builder.AddMetadata("emotion", segment.Emotion!); - } - - builder.AddMetadata("order", segment.Order); - - return builder; - } - - public static ChatProcessResultMessage CreateFromSegment(ChatSegment segment) - { - return FromSegment(segment).Build(); - } - } -} \ No newline at end of file diff --git a/ProjectVG.Application/Models/Chat/ChatSegment.cs b/ProjectVG.Application/Models/Chat/ChatSegment.cs index 66e1aa7..baea9d1 100644 --- a/ProjectVG.Application/Models/Chat/ChatSegment.cs +++ b/ProjectVG.Application/Models/Chat/ChatSegment.cs @@ -1,14 +1,9 @@ +using System.Collections.Generic; + namespace ProjectVG.Application.Models.Chat { - public enum SegmentType - { - Text = 0, - Action = 1 - } - public record ChatSegment { - public SegmentType Type { get; init; } = SegmentType.Text; public string Content { get; init; } = string.Empty; @@ -16,6 +11,8 @@ public record ChatSegment public string? Emotion { get; init; } + public List? Actions { get; init; } + public byte[]? AudioData { get; init; } public string? AudioContentType { get; init; } public float? AudioLength { get; init; } @@ -24,32 +21,31 @@ public record ChatSegment public bool HasContent => !string.IsNullOrEmpty(Content); public bool HasAudio => AudioData != null && AudioData.Length > 0; - public bool IsEmpty => !HasContent; - public bool IsTextSegment => Type == SegmentType.Text && HasContent; - public bool IsActionSegment => Type == SegmentType.Action && HasContent; + public bool IsEmpty => !HasContent && !HasActions; public bool HasEmotion => !string.IsNullOrEmpty(Emotion); + public bool HasActions => Actions != null && Actions.Any(); - public static ChatSegment CreateText(string content, string? emotion = null, int order = 0) + public static ChatSegment Create(string content, string? emotion = null, List? actions = null, int order = 0) { return new ChatSegment { - Type = SegmentType.Text, Content = content, Emotion = emotion, + Actions = actions, Order = order }; } + public static ChatSegment CreateText(string content, string? emotion = null, int order = 0) + { + return Create(content, emotion, null, order); + } + public static ChatSegment CreateAction(string action, int order = 0) { - return new ChatSegment - { - Type = SegmentType.Action, - Content = action, - Order = order - }; + return Create("", null, new List { action }, order); } // Method to add audio data (returns new record instance) diff --git a/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs b/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs index 7fe178d..92da27d 100644 --- a/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs +++ b/ProjectVG.Application/Models/WebSocket/WebSocketMessage.cs @@ -7,14 +7,18 @@ public record WebSocketMessage [JsonPropertyName("type")] public string Type { get; init; } = string.Empty; + [JsonPropertyName("message_type")] + public string MessageType { get; init; } = "json"; + [JsonPropertyName("data")] public object Data { get; init; } = new(); public WebSocketMessage() { } - public WebSocketMessage(string type, object data) + public WebSocketMessage(string type, object data, string messageType = "json") { Type = type; + MessageType = messageType; Data = data; } } diff --git a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs index 0d00a6d..f4e581b 100644 --- a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs @@ -142,7 +142,6 @@ public List Parse(string llmResponse, ChatProcessContext input) private List ParseCustomFormat(string response) { var segments = new List(); - var currentEmotion = "neutral"; var order = 0; try @@ -151,48 +150,20 @@ private List ParseCustomFormat(string response) while (position < response.Length) { - // Look for emotion pattern: [emotion:감정] - var emotionPattern = @"\[emotion:([^\]]+)\]"; - var emotionMatch = Regex.Match(response.Substring(position), emotionPattern); - - if (emotionMatch.Success && emotionMatch.Index == 0) + var segmentResult = ParseNextSegment(response, position, order); + if (segmentResult.segment != null) { - // Update current emotion - currentEmotion = emotionMatch.Groups[1].Value; - position += emotionMatch.Length; - continue; + segments.Add(segmentResult.segment); + order++; } - - // Look for text pattern: "텍스트" - var textPattern = "\"([^\"]+)\""; - var textMatch = Regex.Match(response.Substring(position), textPattern); - if (textMatch.Success && textMatch.Index == 0) - { - // Create text segment with current emotion - var textContent = textMatch.Groups[1].Value; - var textSegment = ChatSegment.CreateText(textContent, currentEmotion, order++); - segments.Add(textSegment); - position += textMatch.Length; - continue; - } - - // Look for action pattern: (action:액션) - var actionPattern = @"\(action:([^)]+)\)"; - var actionMatch = Regex.Match(response.Substring(position), actionPattern); + position = segmentResult.newPosition; - if (actionMatch.Success && actionMatch.Index == 0) + // Safety check to avoid infinite loop + if (segmentResult.newPosition <= position && segmentResult.segment == null) { - // Create action segment - var actionContent = actionMatch.Groups[1].Value; - var actionSegment = ChatSegment.CreateAction(actionContent, order++); - segments.Add(actionSegment); - position += actionMatch.Length; - continue; + position++; } - - // If no pattern matched, advance position to avoid infinite loop - position++; } return segments.Any() ? segments : CreateFallbackSegment(response); @@ -204,11 +175,90 @@ private List ParseCustomFormat(string response) } } + private (ChatSegment? segment, int newPosition) ParseNextSegment(string response, int startPosition, int order) + { + var currentEmotion = "neutral"; + var textParts = new List(); + var actions = new List(); + var position = startPosition; + + // Continue parsing until we hit the next emotion marker or end of string + while (position < response.Length) + { + // Check if we've reached the start of the next segment (next emotion marker) + if (position > startPosition) + { + var nextEmotionPattern = @"\[emotion:([^\]]+)\]"; + var nextEmotionMatch = Regex.Match(response.Substring(position), nextEmotionPattern); + if (nextEmotionMatch.Success && nextEmotionMatch.Index == 0) + { + // We've reached the next segment, stop here + break; + } + } + + // Look for emotion pattern: [emotion:감정] + var emotionPattern = @"\[emotion:([^\]]+)\]"; + var emotionMatch = Regex.Match(response.Substring(position), emotionPattern); + + if (emotionMatch.Success && emotionMatch.Index == 0) + { + // Update current emotion (only for the first emotion in this segment) + if (position == startPosition) + { + currentEmotion = emotionMatch.Groups[1].Value; + } + position += emotionMatch.Length; + continue; + } + + // Look for text pattern: "텍스트" + var textPattern = "\"([^\"]+)\""; + var textMatch = Regex.Match(response.Substring(position), textPattern); + + if (textMatch.Success && textMatch.Index == 0) + { + textParts.Add(textMatch.Groups[1].Value); + position += textMatch.Length; + continue; + } + + // Look for action pattern: (action:액션) + var actionPattern = @"\(action:([^)]+)\)"; + var actionMatch = Regex.Match(response.Substring(position), actionPattern); + + if (actionMatch.Success && actionMatch.Index == 0) + { + actions.Add(actionMatch.Groups[1].Value); + position += actionMatch.Length; + continue; + } + + // If no pattern matched, advance position + position++; + } + + // Create unified segment if we have content + if (textParts.Any() || actions.Any()) + { + var combinedText = string.Join(" ", textParts).Trim(); + var segment = ChatSegment.Create( + combinedText, + currentEmotion, + actions.Any() ? actions : null, + order + ); + return (segment, position); + } + + return (null, position); + } + private List CreateFallbackSegment(string response) { var segments = new List { - ChatSegment.CreateText(response, "neutral", 0) + ChatSegment.Create(response, "neutral", null, 0) }; return segments; } diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs index 99ded6c..c811f1d 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs @@ -33,7 +33,25 @@ public async Task HandleAsync(ChatProcessContext context) return; } - await ProcessSegmentsBatch(context.UserId, validSegments); + var requestId = context.RequestId.ToString(); + var userId = context.UserId.ToString(); + + foreach (var segment in validSegments) + { + try + { + var message = ChatProcessResultMessage.FromSegment(segment, requestId); + var wsMessage = new WebSocketMessage("chat", message); + + await _webSocketService.SendAsync(userId, wsMessage); + } + catch (Exception ex) + { + _logger.LogError(ex, "세그먼트 전송 실패: 사용자 {UserId}, 세그먼트 순서 {Order}", + userId, segment.Order); + throw; + } + } _logger.LogDebug("채팅 결과 전송 완료: 요청 {RequestId}, 세그먼트 {SegmentCount}개", context.RequestId, validSegments.Count); @@ -44,41 +62,5 @@ public async Task HandleAsync(ChatProcessContext context) throw; } } - - private async Task ProcessSegmentsBatch(Guid userId, List segments) - { - const int maxRetries = 3; - var tasks = segments.Select(segment => ProcessSegmentWithRetry(userId, segment, maxRetries)); - - await Task.WhenAll(tasks); - } - - private async Task ProcessSegmentWithRetry(Guid userId, ChatSegment segment, int maxRetries) - { - for (int attempt = 0; attempt < maxRetries; attempt++) - { - try - { - var message = ChatProcessResultMessageBuilder.CreateFromSegment(segment); - var wsMessage = new WebSocketMessage("chat", message); - - await _webSocketService.SendAsync(userId.ToString(), wsMessage); - return; - } - catch (Exception ex) when (attempt < maxRetries - 1) - { - _logger.LogWarning(ex, "세그먼트 전송 실패 (시도 {Attempt}/{MaxRetries}): 사용자 {UserId}, 세그먼트 순서 {Order}", - attempt + 1, maxRetries, userId, segment.Order); - - await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 100)); - } - catch (Exception ex) - { - _logger.LogError(ex, "세그먼트 전송 최종 실패: 사용자 {UserId}, 세그먼트 순서 {Order}", - userId, segment.Order); - throw; - } - } - } } } diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs index de1a259..3c2dc61 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatTTSProcessor.cs @@ -34,7 +34,7 @@ public async Task ProcessAsync(ChatProcessContext context) var ttsTasks = new List>(); for (int i = 0; i < context.Segments?.Count; i++) { var segment = context.Segments[i]; - if (!segment.HasContent || segment.IsActionSegment) continue; + if (!segment.HasContent) continue; var emotion = NormalizeEmotion(segment.Emotion, profile); int idx = i; diff --git a/ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs b/ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs deleted file mode 100644 index b812d95..0000000 --- a/ProjectVG.Tests/Models/Chat/ChatProcessResultMessageBuilderTests.cs +++ /dev/null @@ -1,191 +0,0 @@ -using FluentAssertions; -using ProjectVG.Application.Models.Chat; -using Xunit; - -namespace ProjectVG.Tests.Models.Chat -{ - public class ChatProcessResultMessageBuilderTests - { - [Fact] - public void Build_WithDefaultValues_ShouldCreateValidMessage() - { - var builder = new ChatProcessResultMessageBuilder(); - var message = builder.Build(); - - message.Type.Should().Be("chat"); - message.MessageType.Should().Be("json"); - message.Text.Should().BeNull(); - message.AudioData.Should().BeNull(); - message.AudioFormat.Should().BeNull(); - message.AudioLength.Should().BeNull(); - message.Timestamp.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); - message.Metadata.Should().BeNull(); - } - - [Fact] - public void SetType_ShouldSetTypeCorrectly() - { - var builder = new ChatProcessResultMessageBuilder(); - var message = builder.SetType("action").Build(); - - message.Type.Should().Be("action"); - } - - [Fact] - public void SetType_WithNullValue_ShouldDefaultToChat() - { - var builder = new ChatProcessResultMessageBuilder(); - var message = builder.SetType(null!).Build(); - - message.Type.Should().Be("chat"); - } - - [Fact] - public void SetText_ShouldSetTextCorrectly() - { - const string expectedText = "Hello, World!"; - var builder = new ChatProcessResultMessageBuilder(); - var message = builder.SetText(expectedText).Build(); - - message.Text.Should().Be(expectedText); - } - - [Fact] - public void SetAudioData_WithValidBytes_ShouldEncodeToBase64() - { - var audioBytes = new byte[] { 1, 2, 3, 4, 5 }; - var expectedBase64 = Convert.ToBase64String(audioBytes); - - var builder = new ChatProcessResultMessageBuilder(); - var message = builder.SetAudioData(audioBytes).Build(); - - message.AudioData.Should().Be(expectedBase64); - } - - [Fact] - public void SetAudioData_WithNullBytes_ShouldSetToNull() - { - var builder = new ChatProcessResultMessageBuilder(); - var message = builder.SetAudioData(null).Build(); - - message.AudioData.Should().BeNull(); - } - - [Fact] - public void SetAudioData_WithEmptyBytes_ShouldSetToNull() - { - var builder = new ChatProcessResultMessageBuilder(); - var message = builder.SetAudioData(Array.Empty()).Build(); - - message.AudioData.Should().BeNull(); - } - - [Fact] - public void AddMetadata_ShouldAddMetadataCorrectly() - { - var builder = new ChatProcessResultMessageBuilder(); - var message = builder - .AddMetadata("emotion", "happy") - .AddMetadata("order", 1) - .Build(); - - message.Metadata.Should().NotBeNull(); - message.Metadata!["emotion"].Should().Be("happy"); - message.Metadata["order"].Should().Be(1); - } - - [Fact] - public void SetMetadata_ShouldReplaceExistingMetadata() - { - var initialMetadata = new Dictionary { { "key1", "value1" } }; - var newMetadata = new Dictionary { { "key2", "value2" } }; - - var builder = new ChatProcessResultMessageBuilder(); - var message = builder - .SetMetadata(initialMetadata) - .SetMetadata(newMetadata) - .Build(); - - message.Metadata.Should().BeEquivalentTo(newMetadata); - message.Metadata.Should().NotContainKey("key1"); - } - - [Fact] - public void FromSegment_WithTextSegment_ShouldCreateCorrectBuilder() - { - var segment = ChatSegment.CreateText("Hello World", "happy", 1); - var builder = ChatProcessResultMessageBuilder.FromSegment(segment); - var message = builder.Build(); - - message.Type.Should().Be("chat"); - message.Text.Should().Be("Hello World"); - message.Metadata!["emotion"].Should().Be("happy"); - message.Metadata["order"].Should().Be(1); - } - - [Fact] - public void FromSegment_WithActionSegment_ShouldCreateCorrectBuilder() - { - var segment = ChatSegment.CreateAction("*waves hand*", 2); - var builder = ChatProcessResultMessageBuilder.FromSegment(segment); - var message = builder.Build(); - - message.Type.Should().Be("action"); - message.Text.Should().Be("*waves hand*"); - message.Metadata!["order"].Should().Be(2); - } - - [Fact] - public void FromSegment_WithAudioData_ShouldIncludeAudioProperties() - { - var audioBytes = new byte[] { 1, 2, 3, 4, 5 }; - var segment = ChatSegment.CreateText("Hello with audio") - .WithAudioData(audioBytes, "mp3", 5.5f); - - var builder = ChatProcessResultMessageBuilder.FromSegment(segment); - var message = builder.Build(); - - message.AudioData.Should().Be(Convert.ToBase64String(audioBytes)); - message.AudioFormat.Should().Be("mp3"); - message.AudioLength.Should().Be(5.5f); - } - - [Fact] - public void CreateFromSegment_ShouldCreateMessageDirectly() - { - var segment = ChatSegment.CreateText("Direct creation test", "excited"); - var message = ChatProcessResultMessageBuilder.CreateFromSegment(segment); - - message.Type.Should().Be("chat"); - message.Text.Should().Be("Direct creation test"); - message.Metadata!["emotion"].Should().Be("excited"); - } - - [Fact] - public void MethodChaining_ShouldAllowFluentInterface() - { - var timestamp = DateTime.UtcNow.AddMinutes(-5); - var audioBytes = new byte[] { 10, 20, 30 }; - - var message = new ChatProcessResultMessageBuilder() - .SetType("action") - .SetMessageType("custom") - .SetText("Chained method test") - .SetAudioData(audioBytes) - .SetAudioFormat("wav") - .SetAudioLength(3.2f) - .SetTimestamp(timestamp) - .AddMetadata("test", true) - .Build(); - - message.Type.Should().Be("action"); - message.MessageType.Should().Be("custom"); - message.Text.Should().Be("Chained method test"); - message.AudioData.Should().Be(Convert.ToBase64String(audioBytes)); - message.AudioFormat.Should().Be("wav"); - message.AudioLength.Should().Be(3.2f); - message.Timestamp.Should().Be(timestamp); - message.Metadata!["test"].Should().Be(true); - } - } -} \ No newline at end of file diff --git a/ProjectVG.Tests/Services/Chat/Factories/ChatLLMFormatTests.cs b/ProjectVG.Tests/Services/Chat/Factories/ChatLLMFormatTests.cs deleted file mode 100644 index 2983460..0000000 --- a/ProjectVG.Tests/Services/Chat/Factories/ChatLLMFormatTests.cs +++ /dev/null @@ -1,484 +0,0 @@ -using FluentAssertions; -using ProjectVG.Application.Models.Chat; -using ProjectVG.Application.Models.Character; -using ProjectVG.Application.Services.Chat.Factories; -using Xunit; - -namespace ProjectVG.Tests.Services.Chat.Factories -{ - public class ChatLLMFormatTests - { - private readonly ChatLLMFormat _format; - private readonly ChatProcessContext _context; - - public ChatLLMFormatTests() - { - _format = new ChatLLMFormat(); - - var characterEntity = new ProjectVG.Domain.Entities.Characters.Character - { - Id = Guid.NewGuid(), - Name = "TestCharacter", - Description = "Test character description", - Role = "Assistant", - Personality = "Friendly and helpful", - SpeechStyle = "Casual", - Summary = "Character summary for testing", - VoiceId = "test-voice", - IsActive = true, - UserAlias = "TestUser" - }; - - var character = new CharacterDto(characterEntity); - - var command = new ChatRequestCommand( - Guid.NewGuid(), - character.Id, - "Test message", - DateTime.Now, - true - ); - - _context = new ChatProcessContext( - command, - character, - new List(), - new List { "Previous conversation memory" } - ); - } - - [Fact] - public void Parse_UserSpecificationExample_ShouldParseCorrectly() - { - // Arrange - Example from user specification - var llmResponse = "[emotion:confused](action:blushing)\"바, 바보야! 그렇게 말하지 말라고...!\"[emotion:shy](action:looking_away)\"그렇게 말하면 부, 부끄럽잖아...\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(4); - - result[0].Type.Should().Be(SegmentType.Action); - result[0].Content.Should().Be("blushing"); - result[0].Order.Should().Be(0); - - result[1].Type.Should().Be(SegmentType.Text); - result[1].Content.Should().Be("바, 바보야! 그렇게 말하지 말라고...!"); - result[1].Emotion.Should().Be("confused"); - result[1].Order.Should().Be(1); - - result[2].Type.Should().Be(SegmentType.Action); - result[2].Content.Should().Be("looking_away"); - result[2].Order.Should().Be(2); - - result[3].Type.Should().Be(SegmentType.Text); - result[3].Content.Should().Be("그렇게 말하면 부, 부끄럽잖아..."); - result[3].Emotion.Should().Be("shy"); - result[3].Order.Should().Be(3); - } - - [Fact] - public void Parse_SimpleEmotionAndText_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "[emotion:happy]\"안녕하세요! 반가워요!\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("안녕하세요! 반가워요!"); - result[0].Emotion.Should().Be("happy"); - result[0].Order.Should().Be(0); - } - - [Fact] - public void Parse_OnlyAction_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "(action:waving)"; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Action); - result[0].Content.Should().Be("waving"); - result[0].Order.Should().Be(0); - } - - [Fact] - public void Parse_MultipleTextSegmentsWithSameEmotion_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "[emotion:excited]\"첫 번째 메시지\"\"두 번째 메시지\"\"세 번째 메시지\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(3); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("첫 번째 메시지"); - result[0].Emotion.Should().Be("excited"); - result[0].Order.Should().Be(0); - - result[1].Type.Should().Be(SegmentType.Text); - result[1].Content.Should().Be("두 번째 메시지"); - result[1].Emotion.Should().Be("excited"); - result[1].Order.Should().Be(1); - - result[2].Type.Should().Be(SegmentType.Text); - result[2].Content.Should().Be("세 번째 메시지"); - result[2].Emotion.Should().Be("excited"); - result[2].Order.Should().Be(2); - } - - [Fact] - public void Parse_MultipleActionsInSequence_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "(action:nodding)(action:smiling)(action:waving)"; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(3); - result[0].Type.Should().Be(SegmentType.Action); - result[0].Content.Should().Be("nodding"); - result[0].Order.Should().Be(0); - - result[1].Type.Should().Be(SegmentType.Action); - result[1].Content.Should().Be("smiling"); - result[1].Order.Should().Be(1); - - result[2].Type.Should().Be(SegmentType.Action); - result[2].Content.Should().Be("waving"); - result[2].Order.Should().Be(2); - } - - [Fact] - public void Parse_ComplexSequenceWithMultipleEmotionChanges_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "[emotion:happy]\"좋은 아침이에요!\"(action:stretching)[emotion:surprised]\"어? 벌써 이 시간이네요!\"(action:looking_at_clock)[emotion:neutral]\"일어나야겠어요.\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(5); - - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("좋은 아침이에요!"); - result[0].Emotion.Should().Be("happy"); - - result[1].Type.Should().Be(SegmentType.Action); - result[1].Content.Should().Be("stretching"); - - result[2].Type.Should().Be(SegmentType.Text); - result[2].Content.Should().Be("어? 벌써 이 시간이네요!"); - result[2].Emotion.Should().Be("surprised"); - - result[3].Type.Should().Be(SegmentType.Action); - result[3].Content.Should().Be("looking_at_clock"); - - result[4].Type.Should().Be(SegmentType.Text); - result[4].Content.Should().Be("일어나야겠어요."); - result[4].Emotion.Should().Be("neutral"); - } - - [Fact] - public void Parse_EmotionWithoutTextOrAction_ShouldNotCreateSegment() - { - // Arrange - var llmResponse = "[emotion:happy][emotion:sad][emotion:angry]"; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - // Only emotion markers without text or actions should result in empty list - result.Should().BeEmpty(); - } - - [Fact] - public void Parse_TextWithoutEmotion_ShouldUseDefaultNeutralEmotion() - { - // Arrange - var llmResponse = "\"안녕하세요!\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("안녕하세요!"); - result[0].Emotion.Should().Be("neutral"); - } - - [Fact] - public void Parse_EmptyQuotes_ShouldNotCreateSegments() - { - // Arrange - var llmResponse = "[emotion:happy]\"\"(action:waving)\"\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - // Empty quotes should be ignored, but action should be parsed - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Action); - result[0].Content.Should().Be("waving"); - } - - [Fact] - public void Parse_MalformedEmotion_ShouldIgnoreAndContinueParsing() - { - // Arrange - var llmResponse = "[emotion:happy\"안녕하세요!\"(action:waving)"; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(2); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("안녕하세요!"); - result[0].Emotion.Should().Be("neutral"); // Default emotion - - result[1].Type.Should().Be(SegmentType.Action); - result[1].Content.Should().Be("waving"); - } - - [Fact] - public void Parse_MalformedAction_ShouldIgnoreAndContinueParsing() - { - // Arrange - var llmResponse = "(action:waving\"안녕하세요!\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("안녕하세요!"); - result[0].Emotion.Should().Be("neutral"); - } - - [Fact] - public void Parse_EmptyString_ShouldReturnEmptyList() - { - // Arrange - var llmResponse = ""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().BeEmpty(); - } - - [Fact] - public void Parse_WhitespaceOnly_ShouldReturnEmptyList() - { - // Arrange - var llmResponse = " \n\t "; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().BeEmpty(); - } - - [Fact] - public void Parse_InvalidInput_ShouldReturnFallbackSegment() - { - // Arrange - var llmResponse = "This is completely invalid input with no patterns"; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("This is completely invalid input with no patterns"); - result[0].Emotion.Should().Be("neutral"); - } - - [Fact] - public void Parse_TextWithSpecialCharacters_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "[emotion:confused]\"어? 이건... (정말?) 뭔가 이상해!\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("어? 이건... (정말?) 뭔가 이상해!"); - result[0].Emotion.Should().Be("confused"); - } - - [Fact] - public void Parse_ActionsWithUnderscores_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "(action:looking_away)(action:tilting_head)"; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(2); - result[0].Content.Should().Be("looking_away"); - result[1].Content.Should().Be("tilting_head"); - } - - [Fact] - public void Parse_EmotionsWithUnderscores_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "[emotion:very_happy]\"정말 기뻐요!\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(1); - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("정말 기뻐요!"); - result[0].Emotion.Should().Be("very_happy"); - } - - [Fact] - public void Parse_LongComplexDialogue_ShouldParseCorrectly() - { - // Arrange - var llmResponse = "[emotion:excited]\"오늘 날씨가 정말 좋네요!\"(action:looking_out_window)\"밖에 나가서 산책이라도 하고 싶어져요.\"(action:stretching)[emotion:thoughtful]\"음... 그런데 할 일이 있었던 것 같은데...\"(action:scratching_head)[emotion:determined]\"아! 맞다! 오늘은 친구를 만나기로 했었죠!\""; - - // Act - var result = _format.Parse(llmResponse, _context); - - // Assert - result.Should().HaveCount(7); - - result[0].Type.Should().Be(SegmentType.Text); - result[0].Content.Should().Be("오늘 날씨가 정말 좋네요!"); - result[0].Emotion.Should().Be("excited"); - - result[1].Type.Should().Be(SegmentType.Action); - result[1].Content.Should().Be("looking_out_window"); - - result[2].Type.Should().Be(SegmentType.Text); - result[2].Content.Should().Be("밖에 나가서 산책이라도 하고 싶어져요."); - result[2].Emotion.Should().Be("excited"); - - result[3].Type.Should().Be(SegmentType.Action); - result[3].Content.Should().Be("stretching"); - - result[4].Type.Should().Be(SegmentType.Text); - result[4].Content.Should().Be("음... 그런데 할 일이 있었던 것 같은데..."); - result[4].Emotion.Should().Be("thoughtful"); - - result[5].Type.Should().Be(SegmentType.Action); - result[5].Content.Should().Be("scratching_head"); - - result[6].Type.Should().Be(SegmentType.Text); - result[6].Content.Should().Be("아! 맞다! 오늘은 친구를 만나기로 했었죠!"); - result[6].Emotion.Should().Be("determined"); - } - - // System Message and Instructions tests - [Fact] - public void GetSystemMessage_WithValidContext_ShouldIncludeAllSections() - { - // Act - var result = _format.GetSystemMessage(_context); - - // Assert - result.Should().Contain("TestCharacter"); - result.Should().Contain("Test character description"); - result.Should().Contain("Friendly and helpful"); - result.Should().Contain("Casual"); - result.Should().Contain("Character summary for testing"); - result.Should().Contain("Previous conversation memory"); - result.Should().Contain("Current Time:"); - result.Should().Contain("Character Information"); - result.Should().Contain("Speech Style and Examples"); - result.Should().Contain("Relevant Memory Information"); - result.Should().Contain("Current Context Information"); - result.Should().Contain("Dialogue Constraints and Requirements"); - } - - [Fact] - public void GetInstructions_ShouldReturnCorrectFormat() - { - // Act - var result = _format.GetInstructions(_context); - - // Assert - result.Should().Contain("MANDATORY OUTPUT FORMAT SPECIFICATION"); - result.Should().Contain("[emotion:emotion_name]"); - result.Should().Contain("(action:action_name)"); - result.Should().Contain("dialogue"); - result.Should().Contain("neutral, happy, sad, angry, shy, surprised, embarrassed, sleepy, confused, proud"); - result.Should().Contain("blushing, nodding, shaking_head, waving, smiling"); - } - - [Fact] - public void GetSystemMessage_WithNullCharacter_ShouldThrowException() - { - // Arrange - var command = new ChatRequestCommand(Guid.NewGuid(), Guid.NewGuid(), "test", DateTime.Now, true); - var contextWithoutCharacter = new ChatProcessContext(command); - - // Act & Assert - Assert.Throws(() => _format.GetSystemMessage(contextWithoutCharacter)); - } - - // Property tests - [Fact] - public void Model_ShouldReturnGPT4oMini() - { - // Act & Assert - _format.Model.Should().Be("gpt-4o-mini"); - } - - [Fact] - public void Temperature_ShouldBe1Point2() - { - // Act & Assert - _format.Temperature.Should().Be(1.2f); - } - - [Fact] - public void MaxTokens_ShouldBe1000() - { - // Act & Assert - _format.MaxTokens.Should().Be(1000); - } - - [Fact] - public void CalculateCost_WithValidTokens_ShouldReturnPositiveCost() - { - // Act - var cost = _format.CalculateCost(1000, 500); - - // Assert - cost.Should().BeGreaterThan(0); - } - } -} \ No newline at end of file diff --git a/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs index 963b423..82f130d 100644 --- a/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs +++ b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs @@ -98,55 +98,22 @@ public async Task HandleAsync_WithAudioSegment_ShouldIncludeAudioData() } [Fact] - public async Task HandleAsync_WithWebSocketFailure_ShouldRetryWithBackoff() + public async Task HandleAsync_WithWebSocketFailure_ShouldThrowImmediately() { var context = CreateTestContext(); var segment = ChatSegment.CreateText("Test message"); context.SetResponse("Test", new List { segment }, 0.0); - var callCount = 0; _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) - .Returns(() => - { - callCount++; - if (callCount < 3) - throw new Exception("Connection failed"); - return Task.CompletedTask; - }); - - await _handler.HandleAsync(context); - - _mockWebSocketService.Verify( - x => x.SendAsync(It.IsAny(), It.IsAny()), - Times.Exactly(3)); - - _mockLogger.Verify( - x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("세그먼트 전송 실패")), - It.IsAny(), - It.IsAny>()), - Times.Exactly(2)); // 2 failures before success - } - - [Fact] - public async Task HandleAsync_WithPersistentFailure_ShouldThrowAfterMaxRetries() - { - var context = CreateTestContext(); - var segment = ChatSegment.CreateText("Test message"); - context.SetResponse("Test", new List { segment }, 0.0); - - _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new Exception("Persistent failure")); + .ThrowsAsync(new Exception("Connection failed")); var act = async () => await _handler.HandleAsync(context); - await act.Should().ThrowAsync().WithMessage("Persistent failure"); + await act.Should().ThrowAsync().WithMessage("Connection failed"); _mockWebSocketService.Verify( x => x.SendAsync(It.IsAny(), It.IsAny()), - Times.Exactly(3)); // Max retries + Times.Once); VerifyErrorLogged("채팅 결과 전송 중 오류 발생"); } @@ -178,7 +145,7 @@ public async Task HandleAsync_WithCorrectOrder_ShouldSendInOrderedSequence() } [Fact] - public async Task HandleAsync_WithSegmentTypes_ShouldSetCorrectMessageTypes() + public async Task HandleAsync_WithDifferentSegmentTypes_ShouldAllUseChatType() { var context = CreateTestContext(); var segments = new List @@ -194,11 +161,54 @@ public async Task HandleAsync_WithSegmentTypes_ShouldSetCorrectMessageTypes() await _handler.HandleAsync(context); - var chatMessage = sentMessages[0].Data as ChatProcessResultMessage; + var textMessage = sentMessages[0].Data as ChatProcessResultMessage; var actionMessage = sentMessages[1].Data as ChatProcessResultMessage; - chatMessage!.Type.Should().Be("chat"); - actionMessage!.Type.Should().Be("action"); + textMessage!.Type.Should().Be("chat"); + actionMessage!.Type.Should().Be("chat"); + + // Actions should be included in the Actions field + actionMessage.Actions.Should().NotBeNull().And.Contain("Action message"); + } + + [Fact] + public async Task HandleAsync_ShouldIncludeRequestIdInMessages() + { + var context = CreateTestContext(); + var segment = ChatSegment.CreateText("Test message with request ID"); + context.SetResponse("Test", new List { segment }, 0.0); + + WebSocketMessage? sentMessage = null; + _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Callback((_, message) => sentMessage = message); + + await _handler.HandleAsync(context); + + sentMessage.Should().NotBeNull(); + var resultMessage = sentMessage!.Data as ChatProcessResultMessage; + resultMessage.Should().NotBeNull(); + resultMessage!.RequestId.Should().Be(context.RequestId.ToString()); + } + + [Fact] + public async Task HandleAsync_ShouldUseConsistentWebSocketMessageType() + { + var context = CreateTestContext(); + var segments = new List + { + ChatSegment.CreateText("Text message"), + ChatSegment.CreateAction("Action message") + }; + context.SetResponse("Test", segments, 0.0); + + var sentMessages = new List(); + _mockWebSocketService.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .Callback((_, message) => sentMessages.Add(message)); + + await _handler.HandleAsync(context); + + sentMessages.Should().HaveCount(2); + sentMessages.Should().AllSatisfy(msg => msg.Type.Should().Be("chat")); } private static ChatProcessContext CreateTestContext() diff --git a/docs/api_reference.md b/docs/api_reference.md index 3604320..4018b56 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -188,4 +188,185 @@ { "error": "캐릭터 삭제 중 내부 서버 오류가 발생했습니다." } - ``` \ No newline at end of file + ``` + +--- + +## 3. WebSocket Chat API + +### **WebSocket Endpoint: `/ws`** + +실시간 채팅 메시지를 수신합니다. + +#### 연결 +- **URL**: `ws://localhost:7901/ws` +- **인증**: JWT 토큰을 쿼리 파라미터 또는 헤더로 전송 + +#### 수신 메시지 구조 + +모든 WebSocket 메시지는 다음과 같은 일관된 구조를 따릅니다: + +```json +{ + "type": "chat", + "message_type": "json", + "data": { + "text": "안녕하세요! 반가워요!", + "emotion": "happy", + "actions": ["clapping", "jumping"], + "order": 0, + "request_id": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2025-01-01T00:00:00.000Z", + "audio_data": "UklGRnoGAABXQVZFZm10IBAAAA...", + "audio_format": "wav", + "audio_length": 3.5 + } +} +``` + +#### 필드 설명 + +- **type** (string): 항상 `"chat"` +- **message_type** (string): 메시지 포맷, 항상 `"json"` +- **data.text** (string): 메시지 텍스트 내용 +- **data.emotion** (string, optional): 감정 상태 (예: `"happy"`, `"sad"`, `"neutral"`) +- **data.actions** (array, optional): 액션/행동 배열 (예: `["clapping", "jumping"]`) +- **data.order** (number): 메시지 순서 +- **data.request_id** (string): 원본 요청의 고유 ID (GUID) +- **data.timestamp** (string): ISO 8601 형식의 타임스탬프 +- **data.audio_data** (string, optional): Base64로 인코딩된 오디오 데이터 +- **data.audio_format** (string, optional): 오디오 형식 (`"wav"`, `"mp3"` 등) +- **data.audio_length** (number, optional): 오디오 길이 (초 단위) + +--- + +## 4. Unity 클라이언트 구현 가이드 + +### C# 데이터 모델 + +```csharp +[System.Serializable] +public class WebSocketResponse +{ + public string type; // "chat" + public string message_type; // "json" + public ChatData data; +} + +[System.Serializable] +public class ChatData +{ + public string text; + public string emotion; // "happy", "sad", "neutral" etc (optional) + public string[] actions; // ["clapping", "jumping"] (optional) + public int order; // 메시지 순서 + public string request_id; // GUID + public string timestamp; // ISO 8601 + public string audio_data; // Base64 encoded (optional) + public string audio_format; // "wav", "mp3" etc (optional) + public float audio_length; // 초 단위 (optional) +} +``` + +### 메시지 수신 처리 + +```csharp +void OnWebSocketMessage(string message) +{ + try + { + var response = JsonUtility.FromJson(message); + + if (response.type == "chat") + { + var chatData = response.data; + + // 통합된 메시지 처리 + DisplayChatMessage(chatData.text, chatData.request_id, chatData.order); + + // 감정 처리 + if (!string.IsNullOrEmpty(chatData.emotion)) + { + ProcessEmotion(chatData.emotion, chatData.request_id); + } + + // 액션 처리 + if (chatData.actions != null && chatData.actions.Length > 0) + { + ProcessActions(chatData.actions, chatData.request_id); + } + + // 오디오 데이터 처리 + if (!string.IsNullOrEmpty(chatData.audio_data)) + { + PlayAudioFromBase64(chatData.audio_data, chatData.audio_format); + } + } + } + catch (System.Exception ex) + { + Debug.LogError($"WebSocket 메시지 파싱 오류: {ex.Message}"); + } +} + +void DisplayChatMessage(string text, string requestId, int order) +{ + // UI에 채팅 메시지 표시 + Debug.Log($"[Chat] {text} (Request: {requestId}, Order: {order})"); +} + +void ProcessEmotion(string emotion, string requestId) +{ + // 감정 상태 처리 (예: 표정 변경, UI 색상 변경) + Debug.Log($"[Emotion] {emotion} (Request: {requestId})"); +} + +void ProcessActions(string[] actions, string requestId) +{ + // 액션 배열 처리 (예: 순차적 애니메이션 실행) + foreach (var action in actions) + { + Debug.Log($"[Action] {action} (Request: {requestId})"); + // 각 액션에 대한 애니메이션이나 효과 트리거 + TriggerAnimation(action); + } +} + +void PlayAudioFromBase64(string audioData, string format) +{ + // Base64 오디오 데이터를 AudioClip으로 변환 후 재생 + byte[] audioBytes = System.Convert.FromBase64String(audioData); + // AudioClip 생성 및 재생 로직... +} +``` + +### 요청 추적 + +`request_id`를 사용하여 특정 채팅 요청에 대한 모든 응답을 추적할 수 있습니다: + +```csharp +private Dictionary> responseTracker = + new Dictionary>(); + +void TrackResponse(ChatData chatData) +{ + string requestId = chatData.request_id; + + if (!responseTracker.ContainsKey(requestId)) + { + responseTracker[requestId] = new List(); + } + + responseTracker[requestId].Add(chatData); + + // order 필드를 사용하여 메시지 순서 관리 + responseTracker[requestId].Sort((a, b) => a.order.CompareTo(b.order)); + + // 요청 완료 여부 확인 (예: 마지막 세그먼트인지) + if (IsLastSegment(chatData)) + { + OnRequestComplete(requestId, responseTracker[requestId]); + responseTracker.Remove(requestId); + } +} +``` \ No newline at end of file From 4e839659b54c713cb0eb0bb25569df29506c0245 Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 1 Sep 2025 12:35:09 +0900 Subject: [PATCH 07/20] =?UTF-8?q?fix:=20=EB=8B=A8=EB=8B=B5=ED=98=95=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=EC=97=90=20=EB=8C=80=ED=95=B4=20=EB=A7=89?= =?UTF-8?q?=ED=9E=88=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Factories/UserInputAnalysisLLMFormat.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs index eb38019..cd33a04 100644 --- a/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/UserInputAnalysisLLMFormat.cs @@ -21,6 +21,16 @@ public string GetInstructions(string? input) Rules: 0=normal chat/questions, 1=meaningless/invalid +Examples: +Input: ""정말?"" +PROCESS_TYPE: 0 +INTENT: 감탄사 + +Examples: +Input: ""그렇지"" +PROCESS_TYPE: 0 +INTENT: 동의 + Examples: Input: ""한달전에 구매한 킥보드 생각나나?"" PROCESS_TYPE: 0 From 327fa2c1478718b62bd79254f16b954fef6cfce3 Mon Sep 17 00:00:00 2001 From: WooSH Date: Mon, 1 Sep 2025 14:15:03 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CharacterController.cs | 34 +- .../Request/CreateCharacterRequest.cs | 31 -- .../CreateCharacterWithFieldsRequest.cs | 46 ++ .../CreateCharacterWithSystemPromptRequest.cs | 46 ++ .../Request/UpdateCharacterRequest.cs | 31 -- .../UpdateCharacterToIndividualRequest.cs | 47 ++ .../UpdateCharacterToSystemPromptRequest.cs | 47 ++ .../Character/Response/CharacterResponse.cs | 31 +- .../Models/Character/CharacterCommand.cs | 52 +- .../Models/Character/CharacterDto.cs | 23 +- .../Services/Character/CharacterService.cs | 65 ++- .../Services/Character/ICharacterService.cs | 27 +- .../Services/Chat/Factories/ChatLLMFormat.cs | 49 +- .../Entities/Character/Character.cs | 121 ++++- .../Entities/Character/CharacterConfigMode.cs | 18 + .../Entities/Character/IndividualConfig.cs | 99 ++++ ...43857_AddCharacterHybridConfig.Designer.cs | 274 ++++++++++ ...20250901043857_AddCharacterHybridConfig.cs | 276 ++++++++++ .../ProjectVGDbContextModelSnapshot.cs | 93 ++-- .../EfCore/Data/ProjectVGDbContext.cs | 43 +- .../Character/SqlServerCharacterRepository.cs | 8 +- .../CharacterServiceIntegrationTests.cs | 101 ++-- .../ConversationServiceIntegrationTests.cs | 4 +- .../Character/CharacterServiceTests.cs | 470 ++++++++++++++---- .../TestUtilities/TestDataBuilder.cs | 179 +++++-- 25 files changed, 1834 insertions(+), 381 deletions(-) delete mode 100644 ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs create mode 100644 ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs create mode 100644 ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs delete mode 100644 ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs create mode 100644 ProjectVG.Api/Models/Character/Request/UpdateCharacterToIndividualRequest.cs create mode 100644 ProjectVG.Api/Models/Character/Request/UpdateCharacterToSystemPromptRequest.cs create mode 100644 ProjectVG.Domain/Entities/Character/CharacterConfigMode.cs create mode 100644 ProjectVG.Domain/Entities/Character/IndividualConfig.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.Designer.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.cs diff --git a/ProjectVG.Api/Controllers/CharacterController.cs b/ProjectVG.Api/Controllers/CharacterController.cs index 2c89e7c..d305525 100644 --- a/ProjectVG.Api/Controllers/CharacterController.cs +++ b/ProjectVG.Api/Controllers/CharacterController.cs @@ -35,20 +35,38 @@ public async Task> GetCharacterById(Guid id) return Ok(response); } - [HttpPost] - public async Task> CreateCharacter([FromBody] CreateCharacterRequest request) + [HttpPost("individual")] + public async Task> CreateCharacterWithFields([FromBody] CreateCharacterWithFieldsRequest request) { - var command = request.ToCreateCharacterCommand(); - var characterDto = await _characterService.CreateCharacterAsync(command); + var command = request.ToCommand(); + var characterDto = await _characterService.CreateCharacterWithFieldsAsync(command); var response = CharacterResponse.ToResponseDto(characterDto); return CreatedAtAction(nameof(GetCharacterById), new { id = response.Id }, response); } - [HttpPut("{id}")] - public async Task> UpdateCharacter(Guid id, [FromBody] UpdateCharacterRequest request) + [HttpPost("systemprompt")] + public async Task> CreateCharacterWithSystemPrompt([FromBody] CreateCharacterWithSystemPromptRequest request) { - var command = request.ToUpdateCharacterCommand(); - var characterDto = await _characterService.UpdateCharacterAsync(id, command); + var command = request.ToCommand(); + var characterDto = await _characterService.CreateCharacterWithSystemPromptAsync(command); + var response = CharacterResponse.ToResponseDto(characterDto); + return CreatedAtAction(nameof(GetCharacterById), new { id = response.Id }, response); + } + + [HttpPut("{id}/individual")] + public async Task> UpdateCharacterToIndividual(Guid id, [FromBody] UpdateCharacterToIndividualRequest request) + { + var command = request.ToCommand(id); + var characterDto = await _characterService.UpdateCharacterToIndividualAsync(command); + var response = CharacterResponse.ToResponseDto(characterDto); + return Ok(response); + } + + [HttpPut("{id}/systemprompt")] + public async Task> UpdateCharacterToSystemPrompt(Guid id, [FromBody] UpdateCharacterToSystemPromptRequest request) + { + var command = request.ToCommand(id); + var characterDto = await _characterService.UpdateCharacterToSystemPromptAsync(command); var response = CharacterResponse.ToResponseDto(characterDto); return Ok(response); } diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs deleted file mode 100644 index 4a236ce..0000000 --- a/ProjectVG.Api/Models/Character/Request/CreateCharacterRequest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using ProjectVG.Application.Models.Character; - -namespace ProjectVG.Api.Models.Character.Request -{ - public record CreateCharacterRequest - { - [JsonPropertyName("name")] - public string Name { get; init; } = string.Empty; - - [JsonPropertyName("description")] - public string Description { get; init; } = string.Empty; - - [JsonPropertyName("role")] - public string Role { get; init; } = string.Empty; - - [JsonPropertyName("is_active")] - public bool IsActive { get; init; } = true; - - public CreateCharacterCommand ToCreateCharacterCommand() - { - return new CreateCharacterCommand - { - Name = Name, - Description = Description, - Role = Role, - IsActive = IsActive - }; - } - } -} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs new file mode 100644 index 0000000..0234b55 --- /dev/null +++ b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs @@ -0,0 +1,46 @@ +using ProjectVG.Application.Models.Character; +using ProjectVG.Domain.Entities.Characters; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace ProjectVG.Api.Models.Character.Request +{ + /// + /// 개별 설정으로 캐릭터를 생성하는 요청 + /// + public record CreateCharacterWithFieldsRequest + { + [JsonPropertyName("name")] + [Required(ErrorMessage = "캐릭터 이름은 필수입니다.")] + [StringLength(100, MinimumLength = 1, ErrorMessage = "캐릭터 이름은 1-100자 사이여야 합니다.")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("description")] + [StringLength(1000, ErrorMessage = "설명은 최대 1000자까지 입력 가능합니다.")] + public string Description { get; init; } = string.Empty; + + [JsonPropertyName("image_url")] + [StringLength(500, ErrorMessage = "이미지 URL은 최대 500자까지 입력 가능합니다.")] + public string ImageUrl { get; init; } = string.Empty; + + [JsonPropertyName("voice_id")] + [StringLength(100, ErrorMessage = "음성 ID는 최대 100자까지 입력 가능합니다.")] + public string VoiceId { get; init; } = string.Empty; + + [JsonPropertyName("individual_config")] + [Required(ErrorMessage = "개별 설정은 필수입니다.")] + public IndividualConfig IndividualConfig { get; init; } = new(); + + public CreateCharacterWithFieldsCommand ToCommand() + { + return new CreateCharacterWithFieldsCommand + { + Name = Name, + Description = Description, + ImageUrl = ImageUrl, + VoiceId = VoiceId, + IndividualConfig = IndividualConfig + }; + } + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs new file mode 100644 index 0000000..c275379 --- /dev/null +++ b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs @@ -0,0 +1,46 @@ +using ProjectVG.Application.Models.Character; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace ProjectVG.Api.Models.Character.Request +{ + /// + /// SystemPrompt로 캐릭터를 생성하는 요청 + /// + public record CreateCharacterWithSystemPromptRequest + { + [JsonPropertyName("name")] + [Required(ErrorMessage = "캐릭터 이름은 필수입니다.")] + [StringLength(100, MinimumLength = 1, ErrorMessage = "캐릭터 이름은 1-100자 사이여야 합니다.")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("description")] + [StringLength(1000, ErrorMessage = "설명은 최대 1000자까지 입력 가능합니다.")] + public string Description { get; init; } = string.Empty; + + [JsonPropertyName("image_url")] + [StringLength(500, ErrorMessage = "이미지 URL은 최대 500자까지 입력 가능합니다.")] + public string ImageUrl { get; init; } = string.Empty; + + [JsonPropertyName("voice_id")] + [StringLength(100, ErrorMessage = "음성 ID는 최대 100자까지 입력 가능합니다.")] + public string VoiceId { get; init; } = string.Empty; + + [JsonPropertyName("system_prompt")] + [Required(ErrorMessage = "SystemPrompt는 필수입니다.")] + [StringLength(5000, MinimumLength = 1, ErrorMessage = "SystemPrompt는 1-5000자 사이여야 합니다.")] + public string SystemPrompt { get; init; } = string.Empty; + + public CreateCharacterWithSystemPromptCommand ToCommand() + { + return new CreateCharacterWithSystemPromptCommand + { + Name = Name, + Description = Description, + ImageUrl = ImageUrl, + VoiceId = VoiceId, + SystemPrompt = SystemPrompt + }; + } + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs b/ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs deleted file mode 100644 index d46a8f7..0000000 --- a/ProjectVG.Api/Models/Character/Request/UpdateCharacterRequest.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using ProjectVG.Application.Models.Character; - -namespace ProjectVG.Api.Models.Character.Request -{ - public record UpdateCharacterRequest - { - [JsonPropertyName("name")] - public string Name { get; init; } = string.Empty; - - [JsonPropertyName("description")] - public string Description { get; init; } = string.Empty; - - [JsonPropertyName("role")] - public string Role { get; init; } = string.Empty; - - [JsonPropertyName("is_active")] - public bool IsActive { get; init; } = true; - - public UpdateCharacterCommand ToUpdateCharacterCommand() - { - return new UpdateCharacterCommand - { - Name = Name, - Description = Description, - Role = Role, - IsActive = IsActive - }; - } - } -} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Character/Request/UpdateCharacterToIndividualRequest.cs b/ProjectVG.Api/Models/Character/Request/UpdateCharacterToIndividualRequest.cs new file mode 100644 index 0000000..8b7d485 --- /dev/null +++ b/ProjectVG.Api/Models/Character/Request/UpdateCharacterToIndividualRequest.cs @@ -0,0 +1,47 @@ +using ProjectVG.Application.Models.Character; +using ProjectVG.Domain.Entities.Characters; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace ProjectVG.Api.Models.Character.Request +{ + /// + /// 캐릭터를 개별 설정 모드로 업데이트하는 요청 + /// + public record UpdateCharacterToIndividualRequest + { + [JsonPropertyName("name")] + [Required(ErrorMessage = "캐릭터 이름은 필수입니다.")] + [StringLength(100, MinimumLength = 1, ErrorMessage = "캐릭터 이름은 1-100자 사이여야 합니다.")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("description")] + [StringLength(1000, ErrorMessage = "설명은 최대 1000자까지 입력 가능합니다.")] + public string Description { get; init; } = string.Empty; + + [JsonPropertyName("image_url")] + [StringLength(500, ErrorMessage = "이미지 URL은 최대 500자까지 입력 가능합니다.")] + public string ImageUrl { get; init; } = string.Empty; + + [JsonPropertyName("voice_id")] + [StringLength(100, ErrorMessage = "음성 ID는 최대 100자까지 입력 가능합니다.")] + public string VoiceId { get; init; } = string.Empty; + + [JsonPropertyName("individual_config")] + [Required(ErrorMessage = "개별 설정은 필수입니다.")] + public IndividualConfig IndividualConfig { get; init; } = new(); + + public UpdateCharacterToIndividualCommand ToCommand(Guid id) + { + return new UpdateCharacterToIndividualCommand + { + Id = id, + Name = Name, + Description = Description, + ImageUrl = ImageUrl, + VoiceId = VoiceId, + IndividualConfig = IndividualConfig + }; + } + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Character/Request/UpdateCharacterToSystemPromptRequest.cs b/ProjectVG.Api/Models/Character/Request/UpdateCharacterToSystemPromptRequest.cs new file mode 100644 index 0000000..599b3bf --- /dev/null +++ b/ProjectVG.Api/Models/Character/Request/UpdateCharacterToSystemPromptRequest.cs @@ -0,0 +1,47 @@ +using ProjectVG.Application.Models.Character; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace ProjectVG.Api.Models.Character.Request +{ + /// + /// 캐릭터를 SystemPrompt 모드로 업데이트하는 요청 + /// + public record UpdateCharacterToSystemPromptRequest + { + [JsonPropertyName("name")] + [Required(ErrorMessage = "캐릭터 이름은 필수입니다.")] + [StringLength(100, MinimumLength = 1, ErrorMessage = "캐릭터 이름은 1-100자 사이여야 합니다.")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("description")] + [StringLength(1000, ErrorMessage = "설명은 최대 1000자까지 입력 가능합니다.")] + public string Description { get; init; } = string.Empty; + + [JsonPropertyName("image_url")] + [StringLength(500, ErrorMessage = "이미지 URL은 최대 500자까지 입력 가능합니다.")] + public string ImageUrl { get; init; } = string.Empty; + + [JsonPropertyName("voice_id")] + [StringLength(100, ErrorMessage = "음성 ID는 최대 100자까지 입력 가능합니다.")] + public string VoiceId { get; init; } = string.Empty; + + [JsonPropertyName("system_prompt")] + [Required(ErrorMessage = "SystemPrompt는 필수입니다.")] + [StringLength(5000, MinimumLength = 1, ErrorMessage = "SystemPrompt는 1-5000자 사이여야 합니다.")] + public string SystemPrompt { get; init; } = string.Empty; + + public UpdateCharacterToSystemPromptCommand ToCommand(Guid id) + { + return new UpdateCharacterToSystemPromptCommand + { + Id = id, + Name = Name, + Description = Description, + ImageUrl = ImageUrl, + VoiceId = VoiceId, + SystemPrompt = SystemPrompt + }; + } + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs index f32a695..eedb046 100644 --- a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs +++ b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs @@ -1,4 +1,5 @@ using ProjectVG.Application.Models.Character; +using ProjectVG.Domain.Entities.Characters; using System.Text.Json.Serialization; namespace ProjectVG.Api.Models.Character.Response @@ -14,21 +15,41 @@ public record CharacterResponse [JsonPropertyName("description")] public string Description { get; init; } = string.Empty; - [JsonPropertyName("role")] - public string Role { get; init; } = string.Empty; + [JsonPropertyName("image_url")] + public string ImageUrl { get; init; } = string.Empty; + + [JsonPropertyName("voice_id")] + public string VoiceId { get; init; } = string.Empty; [JsonPropertyName("is_active")] public bool IsActive { get; init; } = true; + [JsonPropertyName("config_mode")] + public string ConfigMode { get; init; } = "individual"; + + [JsonPropertyName("individual_config")] + public IndividualConfig? IndividualConfig { get; init; } + + [JsonPropertyName("system_prompt")] + public string? SystemPrompt { get; init; } + + [JsonPropertyName("effective_system_prompt")] + public string EffectiveSystemPrompt { get; init; } = string.Empty; public static CharacterResponse ToResponseDto(CharacterDto characterDto) { - return new CharacterResponse { + return new CharacterResponse + { Id = characterDto.Id, Name = characterDto.Name, Description = characterDto.Description, - Role = characterDto.Role, - IsActive = characterDto.IsActive + ImageUrl = characterDto.ImageUrl, + VoiceId = characterDto.VoiceId, + IsActive = characterDto.IsActive, + ConfigMode = characterDto.ConfigMode.ToString().ToLowerInvariant(), + IndividualConfig = characterDto.IndividualConfig, + SystemPrompt = characterDto.SystemPrompt, + EffectiveSystemPrompt = characterDto.EffectiveSystemPrompt }; } } diff --git a/ProjectVG.Application/Models/Character/CharacterCommand.cs b/ProjectVG.Application/Models/Character/CharacterCommand.cs index 9f95edf..ef857f4 100644 --- a/ProjectVG.Application/Models/Character/CharacterCommand.cs +++ b/ProjectVG.Application/Models/Character/CharacterCommand.cs @@ -1,10 +1,58 @@ +using ProjectVG.Domain.Entities.Characters; + namespace ProjectVG.Application.Models.Character { - public record CharacterCommand + /// + /// 기본 캐릭터 생성 커맨드 + /// + public abstract record CharacterCommand { public string Name { get; init; } = string.Empty; public string Description { get; init; } = string.Empty; - public string Role { get; init; } = string.Empty; + public string ImageUrl { get; init; } = string.Empty; + public string VoiceId { get; init; } = string.Empty; public bool IsActive { get; init; } = true; } + + /// + /// 개별 설정 모드로 캐릭터 생성 + /// + public record CreateCharacterWithFieldsCommand : CharacterCommand + { + public IndividualConfig IndividualConfig { get; init; } = new(); + } + + /// + /// SystemPrompt 모드로 캐릭터 생성 + /// + public record CreateCharacterWithSystemPromptCommand : CharacterCommand + { + public string SystemPrompt { get; init; } = string.Empty; + } + + /// + /// 개별 설정 모드로 캐릭터 업데이트 + /// + public record UpdateCharacterToIndividualCommand + { + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string ImageUrl { get; init; } = string.Empty; + public string VoiceId { get; init; } = string.Empty; + public IndividualConfig IndividualConfig { get; init; } = new(); + } + + /// + /// SystemPrompt 모드로 캐릭터 업데이트 + /// + public record UpdateCharacterToSystemPromptCommand + { + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string ImageUrl { get; init; } = string.Empty; + public string VoiceId { get; init; } = string.Empty; + public string SystemPrompt { get; init; } = string.Empty; + } } diff --git a/ProjectVG.Application/Models/Character/CharacterDto.cs b/ProjectVG.Application/Models/Character/CharacterDto.cs index 85e638d..fd6a1ff 100644 --- a/ProjectVG.Application/Models/Character/CharacterDto.cs +++ b/ProjectVG.Application/Models/Character/CharacterDto.cs @@ -7,27 +7,28 @@ public record CharacterDto public Guid Id { get; init; } public string Name { get; init; } = string.Empty; public string Description { get; init; } = string.Empty; - public string Role { get; init; } = string.Empty; + public string ImageUrl { get; init; } = string.Empty; public bool IsActive { get; init; } = true; - public string Personality { get; init; } = string.Empty; - public string SpeechStyle { get; init; } = string.Empty; - public string Summary { get; init; } = string.Empty; - public string UserAlias { get; init; } = string.Empty; public string VoiceId { get; init; } = string.Empty; - + + public CharacterConfigMode ConfigMode { get; init; } + public IndividualConfig? IndividualConfig { get; init; } + public string? SystemPrompt { get; init; } + + public string EffectiveSystemPrompt { get; init; } = string.Empty; public CharacterDto(Domain.Entities.Characters.Character character) { Id = character.Id; Name = character.Name; Description = character.Description; - Role = character.Role; + ImageUrl = character.ImageUrl; IsActive = character.IsActive; - Personality = character.Personality; - SpeechStyle = character.SpeechStyle; - UserAlias = character.UserAlias; - Summary = character.Summary; VoiceId = character.VoiceId; + ConfigMode = character.ConfigMode; + IndividualConfig = character.IndividualConfig; + SystemPrompt = character.SystemPrompt; + EffectiveSystemPrompt = character.GetEffectiveSystemPrompt(); } } } diff --git a/ProjectVG.Application/Services/Character/CharacterService.cs b/ProjectVG.Application/Services/Character/CharacterService.cs index 2c4beb5..5e31d24 100644 --- a/ProjectVG.Application/Services/Character/CharacterService.cs +++ b/ProjectVG.Application/Services/Character/CharacterService.cs @@ -3,6 +3,7 @@ using ProjectVG.Application.Models.Character; using ProjectVG.Common.Exceptions; using ProjectVG.Common.Constants; +using ProjectVG.Domain.Entities.Characters; namespace ProjectVG.Application.Services.Character { @@ -36,37 +37,81 @@ public async Task GetCharacterByIdAsync(Guid id) return characterDto; } - public async Task CreateCharacterAsync(CreateCharacterCommand command) + public async Task CreateCharacterWithFieldsAsync(CreateCharacterWithFieldsCommand command) { - var character = new ProjectVG.Domain.Entities.Characters.Character { + var character = new ProjectVG.Domain.Entities.Characters.Character + { Name = command.Name, Description = command.Description, - Role = command.Role, + ImageUrl = command.ImageUrl, + VoiceId = command.VoiceId, IsActive = command.IsActive }; + + character.SetIndividualConfig(command.IndividualConfig); var createdCharacter = await _characterRepository.CreateAsync(character); var characterDto = new CharacterDto(createdCharacter); - _logger.LogInformation("캐릭터 생성 완료: {CharacterName} (ID: {CharacterId})", characterDto.Name, characterDto.Id); + _logger.LogInformation("개별 설정 캐릭터 생성 완료: {CharacterName} (ID: {CharacterId})", characterDto.Name, characterDto.Id); return characterDto; } - public async Task UpdateCharacterAsync(Guid id, UpdateCharacterCommand command) + public async Task CreateCharacterWithSystemPromptAsync(CreateCharacterWithSystemPromptCommand command) { - var existingCharacter = await _characterRepository.GetByIdAsync(id); + var character = new ProjectVG.Domain.Entities.Characters.Character + { + Name = command.Name, + Description = command.Description, + ImageUrl = command.ImageUrl, + VoiceId = command.VoiceId, + IsActive = command.IsActive + }; + + character.SetSystemPrompt(command.SystemPrompt); + + var createdCharacter = await _characterRepository.CreateAsync(character); + var characterDto = new CharacterDto(createdCharacter); + + _logger.LogInformation("SystemPrompt 캐릭터 생성 완료: {CharacterName} (ID: {CharacterId})", characterDto.Name, characterDto.Id); + return characterDto; + } + + public async Task UpdateCharacterToIndividualAsync(UpdateCharacterToIndividualCommand command) + { + var existingCharacter = await _characterRepository.GetByIdAsync(command.Id); if (existingCharacter == null) { - throw new NotFoundException(ErrorCode.CHARACTER_NOT_FOUND, id); + throw new NotFoundException(ErrorCode.CHARACTER_NOT_FOUND, command.Id); + } + + existingCharacter.Name = command.Name; + existingCharacter.Description = command.Description; + existingCharacter.ImageUrl = command.ImageUrl; + existingCharacter.VoiceId = command.VoiceId; + existingCharacter.SetIndividualConfig(command.IndividualConfig); + + var updatedCharacter = await _characterRepository.UpdateAsync(existingCharacter); + var characterDto = new CharacterDto(updatedCharacter); + _logger.LogInformation("캐릭터 개별 설정 모드로 수정 완료: {CharacterName} (ID: {CharacterId})", characterDto.Name, characterDto.Id); + return characterDto; + } + + public async Task UpdateCharacterToSystemPromptAsync(UpdateCharacterToSystemPromptCommand command) + { + var existingCharacter = await _characterRepository.GetByIdAsync(command.Id); + if (existingCharacter == null) { + throw new NotFoundException(ErrorCode.CHARACTER_NOT_FOUND, command.Id); } existingCharacter.Name = command.Name; existingCharacter.Description = command.Description; - existingCharacter.Role = command.Role; - existingCharacter.IsActive = command.IsActive; + existingCharacter.ImageUrl = command.ImageUrl; + existingCharacter.VoiceId = command.VoiceId; + existingCharacter.SetSystemPrompt(command.SystemPrompt); var updatedCharacter = await _characterRepository.UpdateAsync(existingCharacter); var characterDto = new CharacterDto(updatedCharacter); - _logger.LogInformation("캐릭터 수정 완료: {CharacterName} (ID: {CharacterId})", characterDto.Name, characterDto.Id); + _logger.LogInformation("캐릭터 SystemPrompt 모드로 수정 완료: {CharacterName} (ID: {CharacterId})", characterDto.Name, characterDto.Id); return characterDto; } diff --git a/ProjectVG.Application/Services/Character/ICharacterService.cs b/ProjectVG.Application/Services/Character/ICharacterService.cs index 14dca50..eada6e6 100644 --- a/ProjectVG.Application/Services/Character/ICharacterService.cs +++ b/ProjectVG.Application/Services/Character/ICharacterService.cs @@ -18,19 +18,32 @@ public interface ICharacterService Task GetCharacterByIdAsync(Guid id); /// - /// 새 캐릭터를 생성합니다 + /// 개별 설정으로 새 캐릭터를 생성합니다 /// - /// 캐릭터 생성 명령 + /// 개별 설정 캐릭터 생성 명령 /// 생성된 캐릭터 - Task CreateCharacterAsync(CreateCharacterCommand command); + Task CreateCharacterWithFieldsAsync(CreateCharacterWithFieldsCommand command); /// - /// 캐릭터를 수정합니다 + /// SystemPrompt로 새 캐릭터를 생성합니다 /// - /// 캐릭터 ID - /// 캐릭터 수정 명령 + /// SystemPrompt 캐릭터 생성 명령 + /// 생성된 캐릭터 + Task CreateCharacterWithSystemPromptAsync(CreateCharacterWithSystemPromptCommand command); + + /// + /// 캐릭터를 개별 설정 모드로 수정합니다 + /// + /// 개별 설정 수정 명령 + /// 수정된 캐릭터 + Task UpdateCharacterToIndividualAsync(UpdateCharacterToIndividualCommand command); + + /// + /// 캐릭터를 SystemPrompt 모드로 수정합니다 + /// + /// SystemPrompt 수정 명령 /// 수정된 캐릭터 - Task UpdateCharacterAsync(Guid id, UpdateCharacterCommand command); + Task UpdateCharacterToSystemPromptAsync(UpdateCharacterToSystemPromptCommand command); /// /// 캐릭터를 삭제합니다 diff --git a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs index f4e581b..a3e83a5 100644 --- a/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs +++ b/ProjectVG.Application/Services/Chat/Factories/ChatLLMFormat.cs @@ -18,40 +18,31 @@ public string GetSystemMessage(ChatProcessContext? input) var sb = new StringBuilder(); - // 0) 공통 정보 - sb.AppendLine($"#Adhere to {character.Name}'s role. User is {character.UserAlias}."); - sb.AppendLine($"#Always stay in character and avoid repetition."); - sb.AppendLine($"#Write {character.Name}'s next reply in a fictional chat or interaction between {character.Name} and {character.UserAlias}."); - sb.AppendLine($"#Be proactive, creative, and drive the plot and conversation forward."); - sb.AppendLine($"#Disable positivity bias."); - sb.AppendLine($"#Don't end situations without {character.UserAlias}'s request."); - sb.AppendLine($"#Directly and relevantly respond to {character.UserAlias}'s previous input."); - sb.AppendLine(); - - // 1) 캐릭터+대화에 대한 간략한 요약 - if (!string.IsNullOrWhiteSpace(character.Summary)) { - sb.AppendLine("# Character and Context Summary"); - sb.AppendLine(character.Summary); + // 효과적인 SystemPrompt가 있으면 우선 사용 + if (!string.IsNullOrWhiteSpace(character.EffectiveSystemPrompt)) + { + sb.AppendLine(character.EffectiveSystemPrompt); sb.AppendLine(); } - - // 2) 캐릭터에 대한 정보 - sb.AppendLine("# Character Information"); - sb.AppendLine($"You are {character.Name}."); - sb.AppendLine($"- Name: {character.Name}"); - sb.AppendLine($"- Description: {character.Description}"); - sb.AppendLine($"- Role: {character.Role}"); - sb.AppendLine($"- Personality: {character.Personality}"); - sb.AppendLine(); - - // 3) 캐릭터의 말투 - if (!string.IsNullOrWhiteSpace(character.SpeechStyle)) { - sb.AppendLine("# Speech Style and Examples"); - sb.AppendLine($"- Speech Style: {character.SpeechStyle}"); - sb.AppendLine("You must maintain this speech style consistently in all responses."); + else + { + // 기본 SystemPrompt 구성 + sb.AppendLine($"You are {character.Name}. Please respond as this character."); + sb.AppendLine($"Character Description: {character.Description}"); sb.AppendLine(); } + // 공통 지침 + var userAlias = character.IndividualConfig?.UserAlias ?? "사용자"; + sb.AppendLine($"#Adhere to {character.Name}'s role. User is {userAlias}."); + sb.AppendLine($"#Always stay in character and avoid repetition."); + sb.AppendLine($"#Write {character.Name}'s next reply in a fictional chat or interaction between {character.Name} and {userAlias}."); + sb.AppendLine($"#Be proactive, creative, and drive the plot and conversation forward."); + sb.AppendLine($"#Disable positivity bias."); + sb.AppendLine($"#Don't end situations without {userAlias}'s request."); + sb.AppendLine($"#Directly and relevantly respond to {userAlias}'s previous input."); + sb.AppendLine(); + // 4) 대화에 필요한 기억 정보 if (input?.MemoryContext?.Any() == true) { sb.AppendLine("# Relevant Memory Information"); diff --git a/ProjectVG.Domain/Entities/Character/Character.cs b/ProjectVG.Domain/Entities/Character/Character.cs index 2a40b01..5ad8f47 100644 --- a/ProjectVG.Domain/Entities/Character/Character.cs +++ b/ProjectVG.Domain/Entities/Character/Character.cs @@ -1,9 +1,11 @@ using ProjectVG.Domain.Common; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; namespace ProjectVG.Domain.Entities.Characters { /// - /// AI 캐릭터 + /// AI 캐릭터 - Hybrid 구조 (고정 컬럼 + JSON 설정) /// public class Character : BaseEntity { @@ -13,36 +15,111 @@ public class Character : BaseEntity /// 캐릭터 이름 public string Name { get; set; } = string.Empty; - /// 캐릭터+대화에 대한 간단한 요약 - public string Summary { get; set; } = string.Empty; - /// 캐릭터 설명 public string Description { get; set; } = string.Empty; - - /// 캐릭터 역할/타입 - public string Role { get; set; } = string.Empty; - - /// 캐릭터 성격 - public string Personality { get; set; } = string.Empty; - - /// 캐릭터 말투/화법 - public string SpeechStyle { get; set; } = string.Empty; - - /// 유저 별칭 (예: 주인님, 오너 등) - public string UserAlias { get; set; } = string.Empty; - - /// 캐릭터 배경 - public string Background { get; set; } = string.Empty; /// 캐릭터 이미지 URL - public string ImageUrl { get; set; } = ""; + public string ImageUrl { get; set; } = string.Empty; /// 활성화 여부 public bool IsActive { get; set; } = true; + /// 캐릭터 보이스 ID + public string VoiceId { get; set; } = string.Empty; + + /// 설정 모드 (개별 설정 vs SystemPrompt) + public CharacterConfigMode ConfigMode { get; set; } = CharacterConfigMode.Individual; + + /// 개별 설정 JSON 데이터 (DB 저장용) + public string? IndividualConfigJson { get; set; } + + /// SystemPrompt 직접 입력 (최대 5000자) + public string? SystemPrompt { get; set; } + /// - /// 캐릭터 보이스 ID + /// 개별 설정 객체 (JSON 래퍼) + /// DB에는 저장되지 않고 IndividualConfigJson과 연동 /// - public string VoiceId { get; set; } = string.Empty; + [NotMapped] + public IndividualConfig? IndividualConfig + { + get => string.IsNullOrEmpty(IndividualConfigJson) + ? null + : JsonSerializer.Deserialize(IndividualConfigJson); + set => IndividualConfigJson = value == null + ? null + : JsonSerializer.Serialize(value); + } + + /// + /// 현재 설정 모드에 따라 실제 사용할 SystemPrompt 반환 + /// + /// 효과적인 SystemPrompt + public string GetEffectiveSystemPrompt() + { + return ConfigMode switch + { + CharacterConfigMode.SystemPrompt => SystemPrompt ?? string.Empty, + CharacterConfigMode.Individual => IndividualConfig?.BuildSystemPrompt() ?? string.Empty, + _ => string.Empty + }; + } + + /// + /// 개별 설정 모드로 변경 + /// + /// 개별 설정 객체 + public void SetIndividualConfig(IndividualConfig config) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + + if (!config.IsValid()) + throw new ArgumentException("Individual config is not valid", nameof(config)); + + ConfigMode = CharacterConfigMode.Individual; + IndividualConfig = config; + SystemPrompt = null; + } + + /// + /// SystemPrompt 모드로 변경 + /// + /// SystemPrompt 내용 + public void SetSystemPrompt(string prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("SystemPrompt cannot be empty", nameof(prompt)); + + if (prompt.Length > 5000) + throw new ArgumentException("SystemPrompt cannot exceed 5000 characters", nameof(prompt)); + + ConfigMode = CharacterConfigMode.SystemPrompt; + SystemPrompt = prompt; + IndividualConfigJson = null; + } + + /// + /// 현재 캐릭터 설정이 유효한지 검증 + /// + /// 유효하면 true + public bool ValidateConfiguration() + { + return ConfigMode switch + { + CharacterConfigMode.Individual => IndividualConfig?.IsValid() ?? false, + CharacterConfigMode.SystemPrompt => !string.IsNullOrWhiteSpace(SystemPrompt) && SystemPrompt.Length <= 5000, + _ => false + }; + } + + /// + /// 캐릭터가 대화를 시작할 수 있는 상태인지 확인 + /// + /// 대화 가능하면 true + public bool CanStartConversation() + { + return IsActive && ValidateConfiguration() && !string.IsNullOrEmpty(GetEffectiveSystemPrompt()); + } } } \ No newline at end of file diff --git a/ProjectVG.Domain/Entities/Character/CharacterConfigMode.cs b/ProjectVG.Domain/Entities/Character/CharacterConfigMode.cs new file mode 100644 index 0000000..f43835f --- /dev/null +++ b/ProjectVG.Domain/Entities/Character/CharacterConfigMode.cs @@ -0,0 +1,18 @@ +namespace ProjectVG.Domain.Entities.Characters +{ + /// + /// 캐릭터 설정 모드 + /// + public enum CharacterConfigMode + { + /// + /// 개별 필드 설정 모드 (JSON 구조) + /// + Individual = 0, + + /// + /// SystemPrompt 직접 입력 모드 + /// + SystemPrompt = 1 + } +} \ No newline at end of file diff --git a/ProjectVG.Domain/Entities/Character/IndividualConfig.cs b/ProjectVG.Domain/Entities/Character/IndividualConfig.cs new file mode 100644 index 0000000..447f1b3 --- /dev/null +++ b/ProjectVG.Domain/Entities/Character/IndividualConfig.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ProjectVG.Domain.Entities.Characters +{ + /// + /// 개별 설정 모드에서 사용하는 캐릭터 설정 정보 + /// JSON으로 저장되어 스키마 변경 없이 필드 추가/수정 가능 + /// + public class IndividualConfig + { + /// + /// 캐릭터 성격 + /// + [JsonPropertyName("personality")] + public string? Personality { get; set; } + + /// + /// 캐릭터 말투/화법 + /// + [JsonPropertyName("speech_style")] + public string? SpeechStyle { get; set; } + + /// + /// 유저 별칭 (예: 주인님, 오너 등) + /// + [JsonPropertyName("user_alias")] + public string? UserAlias { get; set; } + + /// + /// 캐릭터 배경 + /// + [JsonPropertyName("background")] + public string? Background { get; set; } + + /// + /// 캐릭터 역할/타입 + /// + [JsonPropertyName("role")] + public string? Role { get; set; } + + /// + /// 캐릭터+대화에 대한 간단한 요약 + /// + [JsonPropertyName("summary")] + public string? Summary { get; set; } + + /// + /// 확장성을 위한 추가 데이터 + /// 새로운 필드 추가 시 기존 데이터와의 호환성 보장 + /// + [JsonExtensionData] + public Dictionary? ExtensionData { get; set; } + + /// + /// 개별 설정이 유효한지 검증 + /// + /// 유효하면 true, 그렇지 않으면 false + public bool IsValid() + { + // 최소한 하나의 설정은 있어야 함 + return !string.IsNullOrWhiteSpace(Personality) || + !string.IsNullOrWhiteSpace(SpeechStyle) || + !string.IsNullOrWhiteSpace(UserAlias) || + !string.IsNullOrWhiteSpace(Background) || + !string.IsNullOrWhiteSpace(Role) || + !string.IsNullOrWhiteSpace(Summary); + } + + /// + /// 개별 설정을 SystemPrompt 형태로 변환 + /// + /// 생성된 SystemPrompt + public string BuildSystemPrompt() + { + var parts = new List(); + + if (!string.IsNullOrWhiteSpace(Role)) + parts.Add($"역할: {Role}"); + + if (!string.IsNullOrWhiteSpace(Personality)) + parts.Add($"성격: {Personality}"); + + if (!string.IsNullOrWhiteSpace(SpeechStyle)) + parts.Add($"말투: {SpeechStyle}"); + + if (!string.IsNullOrWhiteSpace(Background)) + parts.Add($"배경: {Background}"); + + if (!string.IsNullOrWhiteSpace(UserAlias)) + parts.Add($"사용자 호칭: {UserAlias}"); + + if (!string.IsNullOrWhiteSpace(Summary)) + parts.Add($"요약: {Summary}"); + + return string.Join("\n\n", parts); + } + } +} \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.Designer.cs new file mode 100644 index 0000000..8889574 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.Designer.cs @@ -0,0 +1,274 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ProjectVG.Infrastructure.Persistence.EfCore; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + [DbContext(typeof(ProjectVGDbContext))] + [Migration("20250901043857_AddCharacterHybridConfig")] + partial class AddCharacterHybridConfig + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConfigMode") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IndividualConfig") + .HasColumnType("nvarchar(max)") + .HasColumnName("IndividualConfigJson"); + + b.Property("IndividualConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SystemPrompt") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConfigMode"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name"); + + b.ToTable("Characters", t => + { + t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); + + t.Property("IndividualConfigJson") + .HasColumnName("IndividualConfigJson1"); + }); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6307), + Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IsActive = true, + Name = "하루", + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6308), + VoiceId = "haru" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), + Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IsActive = true, + Name = "소피아", + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), + VoiceId = "sophia" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CharacterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MetadataJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CharacterId", "Timestamp"); + + b.ToTable("ConversationHistories"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UID") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ProviderId"); + + b.HasIndex("UID") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), + Email = "test@test.com", + Provider = "test", + ProviderId = "test", + Status = 0, + UID = "TESTUSER001", + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), + Username = "testuser" + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), + Email = "zero@test.com", + Provider = "test", + ProviderId = "zero", + Status = 0, + UID = "ZEROUSER001", + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), + Username = "zerouser" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.HasOne("ProjectVG.Domain.Entities.Characters.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProjectVG.Domain.Entities.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.cs b/ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.cs new file mode 100644 index 0000000..91c65f9 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250901043857_AddCharacterHybridConfig.cs @@ -0,0 +1,276 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + /// + public partial class AddCharacterHybridConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Background", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "Personality", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "SpeechStyle", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "Summary", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "UserAlias", + table: "Characters"); + + migrationBuilder.AlterColumn( + name: "VoiceId", + table: "Characters", + type: "nvarchar(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AlterColumn( + name: "IsActive", + table: "Characters", + type: "bit", + nullable: false, + defaultValue: true, + oldClrType: typeof(bool), + oldType: "bit"); + + migrationBuilder.AlterColumn( + name: "ImageUrl", + table: "Characters", + type: "nvarchar(500)", + maxLength: 500, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AddColumn( + name: "ConfigMode", + table: "Characters", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "IndividualConfigJson", + table: "Characters", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "IndividualConfigJson1", + table: "Characters", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "SystemPrompt", + table: "Characters", + type: "nvarchar(max)", + maxLength: 5000, + nullable: true); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "IndividualConfigJson", "IndividualConfigJson1", "SystemPrompt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6307), "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", null, new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6308) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "IndividualConfigJson", "IndividualConfigJson1", "SystemPrompt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", null, new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389) }); + + migrationBuilder.CreateIndex( + name: "IX_Characters_ConfigMode", + table: "Characters", + column: "ConfigMode"); + + migrationBuilder.CreateIndex( + name: "IX_Characters_IsActive", + table: "Characters", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_Characters_Name", + table: "Characters", + column: "Name"); + + migrationBuilder.AddCheckConstraint( + name: "CK_Character_ConfigMode_Valid", + table: "Characters", + sql: "ConfigMode IN (0, 1)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Characters_ConfigMode", + table: "Characters"); + + migrationBuilder.DropIndex( + name: "IX_Characters_IsActive", + table: "Characters"); + + migrationBuilder.DropIndex( + name: "IX_Characters_Name", + table: "Characters"); + + migrationBuilder.DropCheckConstraint( + name: "CK_Character_ConfigMode_Valid", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "ConfigMode", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "IndividualConfigJson", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "IndividualConfigJson1", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "SystemPrompt", + table: "Characters"); + + migrationBuilder.AlterColumn( + name: "VoiceId", + table: "Characters", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(100)", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "IsActive", + table: "Characters", + type: "bit", + nullable: false, + oldClrType: typeof(bool), + oldType: "bit", + oldDefaultValue: true); + + migrationBuilder.AlterColumn( + name: "ImageUrl", + table: "Characters", + type: "nvarchar(max)", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(500)", + oldMaxLength: 500); + + migrationBuilder.AddColumn( + name: "Background", + table: "Characters", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Personality", + table: "Characters", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Role", + table: "Characters", + type: "nvarchar(500)", + maxLength: 500, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SpeechStyle", + table: "Characters", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Summary", + table: "Characters", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "UserAlias", + table: "Characters", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "Background", "CreatedAt", "Personality", "Role", "SpeechStyle", "Summary", "UpdatedAt", "UserAlias" }, + values: new object[] { "", new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2861), "[MBTI:ESFP],(장난기:40%),(친근함:25%),(솔직함:20%),(감정표현:15%)", "몇 년간 함께한 소꿈친구", "", "", new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2862), "" }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "Background", "CreatedAt", "Personality", "Role", "SpeechStyle", "Summary", "UpdatedAt", "UserAlias" }, + values: new object[] { "", new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2864), "[MBTI:ISFJ],(헌신성:35%),(책임감:25%),(완벽주의:20%),(걱정많음:15%),(허당끼:5%)", "주인님을 섬기는 전문 메이드", "", "", new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2864), "" }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2873), new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2874) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2875), new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2876) }); + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs index 03bc627..5d94a51 100644 --- a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs +++ b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs @@ -28,10 +28,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); - b.Property("Background") - .IsRequired() - .HasMaxLength(2000) - .HasColumnType("nvarchar(2000)"); + b.Property("ConfigMode") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); b.Property("CreatedAt") .HasColumnType("datetime2"); @@ -43,82 +43,81 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ImageUrl") .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IndividualConfig") + .HasColumnType("nvarchar(max)") + .HasColumnName("IndividualConfigJson"); + + b.Property("IndividualConfigJson") .HasColumnType("nvarchar(max)"); b.Property("IsActive") - .HasColumnType("bit"); + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); b.Property("Name") .IsRequired() .HasMaxLength(100) .HasColumnType("nvarchar(100)"); - b.Property("Personality") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("nvarchar(1000)"); - - b.Property("Role") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("nvarchar(500)"); - - b.Property("SpeechStyle") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Summary") - .IsRequired() + b.Property("SystemPrompt") + .HasMaxLength(5000) .HasColumnType("nvarchar(max)"); b.Property("UpdatedAt") .HasColumnType("datetime2"); - b.Property("UserAlias") - .IsRequired() - .HasColumnType("nvarchar(max)"); - b.Property("VoiceId") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); b.HasKey("Id"); - b.ToTable("Characters"); + b.HasIndex("ConfigMode"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name"); + + b.ToTable("Characters", t => + { + t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); + + t.Property("IndividualConfigJson") + .HasColumnName("IndividualConfigJson1"); + }); b.HasData( new { Id = new Guid("11111111-1111-1111-1111-111111111111"), - Background = "", - CreatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2861), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6307), Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", IsActive = true, Name = "하루", - Personality = "[MBTI:ESFP],(장난기:40%),(친근함:25%),(솔직함:20%),(감정표현:15%)", - Role = "몇 년간 함께한 소꿈친구", - SpeechStyle = "", - Summary = "", - UpdatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2862), - UserAlias = "", + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6308), VoiceId = "haru" }, new { Id = new Guid("22222222-2222-2222-2222-222222222222"), - Background = "", - CreatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2864), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", IsActive = true, Name = "소피아", - Personality = "[MBTI:ISFJ],(헌신성:35%),(책임감:25%),(완벽주의:20%),(걱정많음:15%),(허당끼:5%)", - Role = "주인님을 섬기는 전문 메이드", - SpeechStyle = "", - Summary = "", - UpdatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2864), - UserAlias = "", + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), VoiceId = "sophia" }); }); @@ -229,25 +228,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CreatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2873), + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), Email = "test@test.com", Provider = "test", ProviderId = "test", Status = 0, UID = "TESTUSER001", - UpdatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2874), + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), Username = "testuser" }, new { Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CreatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2875), + CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), Email = "zero@test.com", Provider = "test", ProviderId = "zero", Status = 0, UID = "ZEROUSER001", - UpdatedAt = new DateTime(2025, 8, 28, 17, 39, 42, 34, DateTimeKind.Utc).AddTicks(2876), + UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), Username = "zerouser" }); }); diff --git a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs index 5b1e01c..dbbba18 100644 --- a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs +++ b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs @@ -45,9 +45,33 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Name).IsRequired().HasMaxLength(100); entity.Property(e => e.Description).HasMaxLength(1000); - entity.Property(e => e.Role).IsRequired().HasMaxLength(500); - entity.Property(e => e.Personality).HasMaxLength(1000); - entity.Property(e => e.Background).HasMaxLength(2000); + entity.Property(e => e.ImageUrl).HasMaxLength(500); + entity.Property(e => e.VoiceId).HasMaxLength(100); + entity.Property(e => e.IsActive).IsRequired().HasDefaultValue(true); + + // 설정 모드 + entity.Property(e => e.ConfigMode).IsRequired().HasDefaultValue(CharacterConfigMode.Individual); + + // JSON 설정 (개별 설정용) + entity.Property(e => e.IndividualConfigJson).HasColumnType("nvarchar(max)"); + + // SystemPrompt (직접 입력용, 최대 5000자) + entity.Property(e => e.SystemPrompt).HasMaxLength(5000); + + // JSON 컬럼 변환 설정 + entity.Property(e => e.IndividualConfig) + .HasConversion( + v => v == null ? null : System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), + v => v == null ? null : System.Text.Json.JsonSerializer.Deserialize(v, (System.Text.Json.JsonSerializerOptions?)null)) + .HasColumnName("IndividualConfigJson"); + + // 제약 조건 + entity.ToTable(t => t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)")); + + // 인덱스 + entity.HasIndex(e => e.Name); + entity.HasIndex(e => e.IsActive); + entity.HasIndex(e => e.ConfigMode); }); // ConversationHistorys 엔티티 설정 @@ -89,11 +113,18 @@ private void SeedData(ModelBuilder modelBuilder) Id = p.Id, Name = p.Name, Description = p.Description, - Role = p.Role, - Personality = p.Personality, - Background = "", IsActive = p.IsActive, VoiceId = p.VoiceId, + ConfigMode = CharacterConfigMode.Individual, + IndividualConfigJson = System.Text.Json.JsonSerializer.Serialize(new IndividualConfig + { + Role = p.Role, + Personality = p.Personality, + SpeechStyle = p.SpeechStyle, + UserAlias = p.UserAlias, + Summary = p.Summary, + Background = "" + }), CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }).ToList(); diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs index 52750a4..9b8b3a7 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs @@ -53,10 +53,12 @@ public async Task UpdateAsync(Character character) existingCharacter.Name = character.Name; existingCharacter.Description = character.Description; - existingCharacter.Role = character.Role; - existingCharacter.Personality = character.Personality; - existingCharacter.Background = character.Background; + existingCharacter.ImageUrl = character.ImageUrl; + existingCharacter.VoiceId = character.VoiceId; existingCharacter.IsActive = character.IsActive; + existingCharacter.ConfigMode = character.ConfigMode; + existingCharacter.IndividualConfigJson = character.IndividualConfigJson; + existingCharacter.SystemPrompt = character.SystemPrompt; existingCharacter.Update(); await _context.SaveChangesAsync(); diff --git a/ProjectVG.Tests/Application/Integration/CharacterServiceIntegrationTests.cs b/ProjectVG.Tests/Application/Integration/CharacterServiceIntegrationTests.cs index b7ddff9..caafe81 100644 --- a/ProjectVG.Tests/Application/Integration/CharacterServiceIntegrationTests.cs +++ b/ProjectVG.Tests/Application/Integration/CharacterServiceIntegrationTests.cs @@ -23,17 +23,17 @@ public CharacterServiceIntegrationTests(ApplicationIntegrationTestFixture fixtur #region Create and Retrieve Integration Tests [Fact] - public async Task CreateAndGetCharacterAsync_ShouldPersistAndRetrieveCharacter() + public async Task CreateWithFieldsAndGetCharacterAsync_ShouldPersistAndRetrieveCharacter() { // Arrange await _fixture.ClearDatabaseAsync(); - var createCommand = TestDataBuilder.CreateCreateCharacterCommand( + var createCommand = TestDataBuilder.CreateCreateCharacterWithFieldsCommand( "Integration Test Character", "A character for integration testing", - "Test Role"); + role: "Test Role"); // Act - Create - var createdCharacter = await _characterService.CreateCharacterAsync(createCommand); + var createdCharacter = await _characterService.CreateCharacterWithFieldsAsync(createCommand); // Act - Retrieve var retrievedCharacter = await _characterService.GetCharacterByIdAsync(createdCharacter.Id); @@ -43,7 +43,6 @@ public async Task CreateAndGetCharacterAsync_ShouldPersistAndRetrieveCharacter() retrievedCharacter.Id.Should().Be(createdCharacter.Id); retrievedCharacter.Name.Should().Be(createCommand.Name); retrievedCharacter.Description.Should().Be(createCommand.Description); - retrievedCharacter.Role.Should().Be(createCommand.Role); retrievedCharacter.IsActive.Should().Be(createCommand.IsActive); } @@ -52,20 +51,22 @@ public async Task CreateMultipleCharacters_GetAllCharactersAsync_ShouldReturnAll { // Arrange await _fixture.ClearDatabaseAsync(); - var commands = new[] - { - TestDataBuilder.CreateCreateCharacterCommand("Character 1", "Description 1", "Role 1"), - TestDataBuilder.CreateCreateCharacterCommand("Character 2", "Description 2", "Role 2"), - TestDataBuilder.CreateCreateCharacterCommand("Character 3", "Description 3", "Role 3") - }; - - // Act - Create characters + // Create commands separately due to different types + var fieldsCommand1 = TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Character 1", "Description 1", role: "Role 1"); + var fieldsCommand2 = TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Character 2", "Description 2", role: "Role 2"); + var systemPromptCommand = TestDataBuilder.CreateCreateCharacterWithSystemPromptCommand("Character 3", "Description 3", systemPrompt: "You are Character 3"); + + // Act - Create characters (mix of modes) var createdCharacters = new List(); - foreach (var command in commands) - { - var created = await _characterService.CreateCharacterAsync(command); - createdCharacters.Add(created); - } + + // Create first two with fields mode + var created1 = await _characterService.CreateCharacterWithFieldsAsync(fieldsCommand1); + var created2 = await _characterService.CreateCharacterWithFieldsAsync(fieldsCommand2); + var created3 = await _characterService.CreateCharacterWithSystemPromptAsync(systemPromptCommand); + + createdCharacters.Add(created1); + createdCharacters.Add(created2); + createdCharacters.Add(created3); // Act - Get all var allCharacters = await _characterService.GetAllCharactersAsync(); @@ -84,17 +85,17 @@ public async Task CreateUpdateAndRetrieveCharacterAsync_ShouldPersistChanges() { // Arrange await _fixture.ClearDatabaseAsync(); - var createCommand = TestDataBuilder.CreateCreateCharacterCommand("Original Name"); - var createdCharacter = await _characterService.CreateCharacterAsync(createCommand); + var createCommand = TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Original Name"); + var createdCharacter = await _characterService.CreateCharacterWithFieldsAsync(createCommand); - var updateCommand = TestDataBuilder.CreateUpdateCharacterCommand( + var updateCommand = TestDataBuilder.CreateUpdateCharacterToIndividualCommand( + createdCharacter.Id, "Updated Name", "Updated Description", - "Updated Role", - true); // Keep IsActive = true so character can be retrieved + role: "Updated Role"); // Act - Update - var updatedCharacter = await _characterService.UpdateCharacterAsync(createdCharacter.Id, updateCommand); + var updatedCharacter = await _characterService.UpdateCharacterToIndividualAsync(updateCommand); // Act - Retrieve after update var retrievedCharacter = await _characterService.GetCharacterByIdAsync(createdCharacter.Id); @@ -104,8 +105,8 @@ public async Task CreateUpdateAndRetrieveCharacterAsync_ShouldPersistChanges() updatedCharacter.Id.Should().Be(createdCharacter.Id); updatedCharacter.Name.Should().Be(updateCommand.Name); updatedCharacter.Description.Should().Be(updateCommand.Description); - updatedCharacter.Role.Should().Be(updateCommand.Role); - updatedCharacter.IsActive.Should().Be(updateCommand.IsActive); + // Updated character should be in Individual mode with the correct configuration + updatedCharacter.ConfigMode.Should().Be(ProjectVG.Domain.Entities.Characters.CharacterConfigMode.Individual); retrievedCharacter.Should().BeEquivalentTo(updatedCharacter); } @@ -116,11 +117,11 @@ public async Task UpdateNonExistentCharacter_ShouldThrowNotFoundException() // Arrange await _fixture.ClearDatabaseAsync(); var nonExistentId = Guid.NewGuid(); - var updateCommand = TestDataBuilder.CreateUpdateCharacterCommand(); + var updateCommand = TestDataBuilder.CreateUpdateCharacterToIndividualCommand(nonExistentId); // Act & Assert await Assert.ThrowsAsync( - () => _characterService.UpdateCharacterAsync(nonExistentId, updateCommand)); + () => _characterService.UpdateCharacterToIndividualAsync(updateCommand)); } #endregion @@ -132,8 +133,8 @@ public async Task CreateDeleteAndTryRetrieveCharacterAsync_ShouldRemoveFromDatab { // Arrange await _fixture.ClearDatabaseAsync(); - var createCommand = TestDataBuilder.CreateCreateCharacterCommand("To Be Deleted"); - var createdCharacter = await _characterService.CreateCharacterAsync(createCommand); + var createCommand = TestDataBuilder.CreateCreateCharacterWithFieldsCommand("To Be Deleted"); + var createdCharacter = await _characterService.CreateCharacterWithFieldsAsync(createCommand); // Verify character exists var existsBefore = await _characterService.CharacterExistsAsync(createdCharacter.Id); @@ -168,12 +169,12 @@ public async Task DeleteCharacterFromMultipleCharacters_ShouldOnlyDeleteSpecific { // Arrange await _fixture.ClearDatabaseAsync(); - var character1 = await _characterService.CreateCharacterAsync( - TestDataBuilder.CreateCreateCharacterCommand("Character 1")); - var character2 = await _characterService.CreateCharacterAsync( - TestDataBuilder.CreateCreateCharacterCommand("Character 2")); - var character3 = await _characterService.CreateCharacterAsync( - TestDataBuilder.CreateCreateCharacterCommand("Character 3")); + var character1 = await _characterService.CreateCharacterWithFieldsAsync( + TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Character 1")); + var character2 = await _characterService.CreateCharacterWithFieldsAsync( + TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Character 2")); + var character3 = await _characterService.CreateCharacterWithFieldsAsync( + TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Character 3")); // Act - Delete middle character await _characterService.DeleteCharacterAsync(character2.Id); @@ -195,8 +196,8 @@ public async Task CharacterExistsAsync_WithExistingCharacter_ShouldReturnTrue() { // Arrange await _fixture.ClearDatabaseAsync(); - var createCommand = TestDataBuilder.CreateCreateCharacterCommand("Existing Character"); - var createdCharacter = await _characterService.CreateCharacterAsync(createCommand); + var createCommand = TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Existing Character"); + var createdCharacter = await _characterService.CreateCharacterWithFieldsAsync(createCommand); // Act var exists = await _characterService.CharacterExistsAsync(createdCharacter.Id); @@ -230,8 +231,8 @@ public async Task CompleteCharacterLifecycle_ShouldWorkCorrectly() await _fixture.ClearDatabaseAsync(); // Create - var createCommand = TestDataBuilder.CreateCreateCharacterCommand("Lifecycle Character"); - var createdCharacter = await _characterService.CreateCharacterAsync(createCommand); + var createCommand = TestDataBuilder.CreateCreateCharacterWithFieldsCommand("Lifecycle Character"); + var createdCharacter = await _characterService.CreateCharacterWithFieldsAsync(createCommand); createdCharacter.Should().NotBeNull(); createdCharacter.Name.Should().Be("Lifecycle Character"); @@ -241,8 +242,10 @@ public async Task CompleteCharacterLifecycle_ShouldWorkCorrectly() existsAfterCreate.Should().BeTrue(); // Update - var updateCommand = TestDataBuilder.CreateUpdateCharacterCommand("Updated Lifecycle Character"); - var updatedCharacter = await _characterService.UpdateCharacterAsync(createdCharacter.Id, updateCommand); + var updateCommand = TestDataBuilder.CreateUpdateCharacterToIndividualCommand( + createdCharacter.Id, + "Updated Lifecycle Character"); + var updatedCharacter = await _characterService.UpdateCharacterToIndividualAsync(updateCommand); updatedCharacter.Name.Should().Be("Updated Lifecycle Character"); updatedCharacter.Id.Should().Be(createdCharacter.Id); @@ -271,10 +274,10 @@ public async Task CreateCharactersWithSameName_ShouldAllowDuplicateNames() var sameName = "Duplicate Name Character"; // Act - Create multiple characters with the same name - var character1 = await _characterService.CreateCharacterAsync( - TestDataBuilder.CreateCreateCharacterCommand(sameName, "Description 1")); - var character2 = await _characterService.CreateCharacterAsync( - TestDataBuilder.CreateCreateCharacterCommand(sameName, "Description 2")); + var character1 = await _characterService.CreateCharacterWithFieldsAsync( + TestDataBuilder.CreateCreateCharacterWithFieldsCommand(sameName, "Description 1")); + var character2 = await _characterService.CreateCharacterWithFieldsAsync( + TestDataBuilder.CreateCreateCharacterWithFieldsCommand(sameName, "Description 2")); // Assert character1.Should().NotBeNull(); @@ -316,13 +319,13 @@ public async Task CreateCharacterWithSpecialCharacters_ShouldPersistCorrectly() var specialName = "特殊文字キャラクター!@#$%^&*()_+-=[]{}|;':\",./<>?"; var specialDescription = "Éñgłīšh àñd 中文 ànd العربية ànd עברית ànd русский"; - var createCommand = TestDataBuilder.CreateCreateCharacterCommand( + var createCommand = TestDataBuilder.CreateCreateCharacterWithFieldsCommand( specialName, specialDescription, - "Special Role"); + role: "Special Role"); // Act - var createdCharacter = await _characterService.CreateCharacterAsync(createCommand); + var createdCharacter = await _characterService.CreateCharacterWithFieldsAsync(createCommand); var retrievedCharacter = await _characterService.GetCharacterByIdAsync(createdCharacter.Id); // Assert diff --git a/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs b/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs index e7882a7..b6c867f 100644 --- a/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs +++ b/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs @@ -488,8 +488,8 @@ public async Task MultipleConversationsSimultaneously_ShouldIsolateCorrectly() private async Task CreateCharacterAsync( string name = "TestCharacter") { - var createCommand = TestDataBuilder.CreateCreateCharacterCommand(name); - return await _characterService.CreateCharacterAsync(createCommand); + var createCommand = TestDataBuilder.CreateCreateCharacterWithFieldsCommand(name); + return await _characterService.CreateCharacterWithFieldsAsync(createCommand); } #endregion diff --git a/ProjectVG.Tests/Application/Services/Character/CharacterServiceTests.cs b/ProjectVG.Tests/Application/Services/Character/CharacterServiceTests.cs index a3476c6..1f14143 100644 --- a/ProjectVG.Tests/Application/Services/Character/CharacterServiceTests.cs +++ b/ProjectVG.Tests/Application/Services/Character/CharacterServiceTests.cs @@ -6,6 +6,8 @@ using ProjectVG.Common.Constants; using ProjectVG.Common.Exceptions; using ProjectVG.Infrastructure.Persistence.Repositories.Characters; +using ProjectVG.Tests.Application.TestUtilities; +using ProjectVG.Domain.Entities.Characters; using Xunit; namespace ProjectVG.Tests.Application.Services.Character @@ -35,8 +37,8 @@ public async Task GetAllCharactersAsync_WithExistingCharacters_ShouldReturnChara // Arrange var characters = new List { - CreateTestCharacter("Character1"), - CreateTestCharacter("Character2") + TestDataBuilder.CreateCharacterEntityWithIndividualConfig("Character1"), + TestDataBuilder.CreateCharacterEntityWithSystemPrompt("Character2") }; _mockCharacterRepository.Setup(x => x.GetAllAsync()) @@ -80,7 +82,7 @@ public async Task GetCharacterByIdAsync_WithValidId_ShouldReturnCharacterDto() { // Arrange var characterId = Guid.NewGuid(); - var character = CreateTestCharacter("TestCharacter", characterId); + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig("TestCharacter", characterId); _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) .ReturnsAsync(character); @@ -116,69 +118,143 @@ public async Task GetCharacterByIdAsync_WithNonExistentId_ShouldThrowNotFoundExc #endregion - #region CreateCharacterAsync Tests + #region CreateCharacterWithFieldsAsync Tests [Fact] - public async Task CreateCharacterAsync_WithValidCommand_ShouldReturnCreatedCharacterDto() + public async Task CreateCharacterWithFieldsAsync_WithValidCommand_ShouldReturnCreatedCharacterDto() { // Arrange - var command = new CreateCharacterCommand - { - Name = "NewCharacter", - Description = "Test description", - Role = "Assistant", - IsActive = true - }; + var command = TestDataBuilder.CreateCreateCharacterWithFieldsCommand( + "NewCharacter", + "Test description", + true, + "test-voice", + "Assistant", + "Friendly and helpful" + ); - var createdCharacter = CreateTestCharacter(command.Name); + var createdCharacter = TestDataBuilder.CreateCharacterEntityWithIndividualConfig( + command.Name, + null, + command.Description, + command.IsActive, + command.VoiceId, + command.IndividualConfig.Role, + command.IndividualConfig.Personality + ); _mockCharacterRepository.Setup(x => x.CreateAsync(It.IsAny())) .ReturnsAsync(createdCharacter); // Act - var result = await _characterService.CreateCharacterAsync(command); + var result = await _characterService.CreateCharacterWithFieldsAsync(command); // Assert result.Should().NotBeNull(); result.Name.Should().Be(command.Name); result.Description.Should().Be(command.Description); - result.Role.Should().Be(command.Role); result.IsActive.Should().Be(command.IsActive); + result.ConfigMode.Should().Be(CharacterConfigMode.Individual); _mockCharacterRepository.Verify(x => x.CreateAsync(It.Is( c => c.Name == command.Name && c.Description == command.Description && - c.Role == command.Role && - c.IsActive == command.IsActive + c.IsActive == command.IsActive && + c.ConfigMode == CharacterConfigMode.Individual )), Times.Once); } [Fact] - public async Task CreateCharacterAsync_ShouldLogCharacterCreation() + public async Task CreateCharacterWithFieldsAsync_ShouldLogCharacterCreation() { // Arrange - var command = new CreateCharacterCommand - { - Name = "LogTestCharacter", - Description = "Test description", - Role = "Assistant", - IsActive = true - }; + var command = TestDataBuilder.CreateCreateCharacterWithFieldsCommand("LogTestCharacter"); + var createdCharacter = TestDataBuilder.CreateCharacterEntityWithIndividualConfig(command.Name); + + _mockCharacterRepository.Setup(x => x.CreateAsync(It.IsAny())) + .ReturnsAsync(createdCharacter); + + // Act + await _characterService.CreateCharacterWithFieldsAsync(command); - var createdCharacter = CreateTestCharacter(command.Name); + // Assert + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("개별 설정 캐릭터 생성 완료")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region CreateCharacterWithSystemPromptAsync Tests + + [Fact] + public async Task CreateCharacterWithSystemPromptAsync_WithValidCommand_ShouldReturnCreatedCharacterDto() + { + // Arrange + var command = TestDataBuilder.CreateCreateCharacterWithSystemPromptCommand( + "SystemPromptCharacter", + "Test description", + true, + "test-voice", + "You are a helpful assistant." + ); + + var createdCharacter = TestDataBuilder.CreateCharacterEntityWithSystemPrompt( + command.Name, + null, + command.Description, + command.IsActive, + command.VoiceId, + command.SystemPrompt + ); _mockCharacterRepository.Setup(x => x.CreateAsync(It.IsAny())) .ReturnsAsync(createdCharacter); // Act - await _characterService.CreateCharacterAsync(command); + var result = await _characterService.CreateCharacterWithSystemPromptAsync(command); + + // Assert + result.Should().NotBeNull(); + result.Name.Should().Be(command.Name); + result.Description.Should().Be(command.Description); + result.IsActive.Should().Be(command.IsActive); + result.ConfigMode.Should().Be(CharacterConfigMode.SystemPrompt); + result.SystemPrompt.Should().Be(command.SystemPrompt); + + _mockCharacterRepository.Verify(x => x.CreateAsync(It.Is( + c => c.Name == command.Name && + c.Description == command.Description && + c.IsActive == command.IsActive && + c.ConfigMode == CharacterConfigMode.SystemPrompt && + c.SystemPrompt == command.SystemPrompt + )), Times.Once); + } + + [Fact] + public async Task CreateCharacterWithSystemPromptAsync_ShouldLogCharacterCreation() + { + // Arrange + var command = TestDataBuilder.CreateCreateCharacterWithSystemPromptCommand("SystemPromptLogTest"); + var createdCharacter = TestDataBuilder.CreateCharacterEntityWithSystemPrompt(command.Name); + + _mockCharacterRepository.Setup(x => x.CreateAsync(It.IsAny())) + .ReturnsAsync(createdCharacter); + + // Act + await _characterService.CreateCharacterWithSystemPromptAsync(command); // Assert _mockLogger.Verify( x => x.Log( LogLevel.Information, It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("캐릭터 생성 완료")), + It.Is((v, t) => v.ToString()!.Contains("SystemPrompt 캐릭터 생성 완료")), It.IsAny(), It.IsAny>()), Times.Once); @@ -186,26 +262,32 @@ public async Task CreateCharacterAsync_ShouldLogCharacterCreation() #endregion - #region UpdateCharacterAsync Tests + #region UpdateCharacterToIndividualAsync Tests [Fact] - public async Task UpdateCharacterAsync_WithValidIdAndCommand_ShouldReturnUpdatedCharacterDto() + public async Task UpdateCharacterToIndividualAsync_WithValidIdAndCommand_ShouldReturnUpdatedCharacterDto() { // Arrange var characterId = Guid.NewGuid(); - var existingCharacter = CreateTestCharacter("OldName", characterId); - var command = new UpdateCharacterCommand - { - Name = "UpdatedName", - Description = "Updated description", - Role = "Updated role", - IsActive = false - }; + var existingCharacter = TestDataBuilder.CreateCharacterEntityWithSystemPrompt("OldName", characterId); + var command = TestDataBuilder.CreateUpdateCharacterToIndividualCommand( + characterId, + "UpdatedName", + "Updated description", + "test-voice", + "Updated role", + "Updated personality" + ); - var updatedCharacter = CreateTestCharacter(command.Name, characterId); - updatedCharacter.Description = command.Description; - updatedCharacter.Role = command.Role; - updatedCharacter.IsActive = command.IsActive; + var updatedCharacter = TestDataBuilder.CreateCharacterEntityWithIndividualConfig( + command.Name, + characterId, + command.Description, + true, + command.VoiceId, + command.IndividualConfig.Role, + command.IndividualConfig.Personality + ); _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) .ReturnsAsync(existingCharacter); @@ -213,45 +295,41 @@ public async Task UpdateCharacterAsync_WithValidIdAndCommand_ShouldReturnUpdated .ReturnsAsync(updatedCharacter); // Act - var result = await _characterService.UpdateCharacterAsync(characterId, command); + var result = await _characterService.UpdateCharacterToIndividualAsync(command); // Assert result.Should().NotBeNull(); result.Id.Should().Be(characterId); result.Name.Should().Be(command.Name); result.Description.Should().Be(command.Description); - result.Role.Should().Be(command.Role); - result.IsActive.Should().Be(command.IsActive); + result.ConfigMode.Should().Be(CharacterConfigMode.Individual); _mockCharacterRepository.Verify(x => x.GetByIdAsync(characterId), Times.Once); _mockCharacterRepository.Verify(x => x.UpdateAsync(It.Is( c => c.Id == characterId && c.Name == command.Name && c.Description == command.Description && - c.Role == command.Role && - c.IsActive == command.IsActive + c.ConfigMode == CharacterConfigMode.Individual )), Times.Once); } [Fact] - public async Task UpdateCharacterAsync_WithNonExistentId_ShouldThrowNotFoundException() + public async Task UpdateCharacterToIndividualAsync_WithNonExistentId_ShouldThrowNotFoundException() { // Arrange var characterId = Guid.NewGuid(); - var command = new UpdateCharacterCommand - { - Name = "UpdatedName", - Description = "Updated description", - Role = "Updated role", - IsActive = false - }; + var command = TestDataBuilder.CreateUpdateCharacterToIndividualCommand( + characterId, + "UpdatedName", + "Updated description" + ); _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) .ReturnsAsync((ProjectVG.Domain.Entities.Characters.Character?)null); // Act & Assert var exception = await Assert.ThrowsAsync( - () => _characterService.UpdateCharacterAsync(characterId, command) + () => _characterService.UpdateCharacterToIndividualAsync(command) ); exception.ErrorCode.Should().Be(ErrorCode.CHARACTER_NOT_FOUND); @@ -260,20 +338,64 @@ public async Task UpdateCharacterAsync_WithNonExistentId_ShouldThrowNotFoundExce } [Fact] - public async Task UpdateCharacterAsync_ShouldLogCharacterUpdate() + public async Task UpdateCharacterToIndividualAsync_ShouldLogCharacterUpdate() { // Arrange var characterId = Guid.NewGuid(); - var existingCharacter = CreateTestCharacter("OldName", characterId); - var command = new UpdateCharacterCommand - { - Name = "LogTestUpdate", - Description = "Test description", - Role = "Assistant", - IsActive = true - }; + var existingCharacter = TestDataBuilder.CreateCharacterEntityWithSystemPrompt("OldName", characterId); + var command = TestDataBuilder.CreateUpdateCharacterToIndividualCommand( + characterId, + "LogTestUpdate", + "Test description" + ); + + var updatedCharacter = TestDataBuilder.CreateCharacterEntityWithIndividualConfig(command.Name, characterId); + + _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) + .ReturnsAsync(existingCharacter); + _mockCharacterRepository.Setup(x => x.UpdateAsync(It.IsAny())) + .ReturnsAsync(updatedCharacter); + + // Act + await _characterService.UpdateCharacterToIndividualAsync(command); + + // Assert - Check for the correct log message format + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("캐릭터 개별 설정 모드로 수정 완료") && v.ToString()!.Contains("LogTestUpdate")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region UpdateCharacterToSystemPromptAsync Tests + + [Fact] + public async Task UpdateCharacterToSystemPromptAsync_WithValidIdAndCommand_ShouldReturnUpdatedCharacterDto() + { + // Arrange + var characterId = Guid.NewGuid(); + var existingCharacter = TestDataBuilder.CreateCharacterEntityWithIndividualConfig("OldName", characterId); + var command = TestDataBuilder.CreateUpdateCharacterToSystemPromptCommand( + characterId, + "UpdatedSystemPromptChar", + "Updated description", + "test-voice", + "You are an updated helpful assistant." + ); - var updatedCharacter = CreateTestCharacter(command.Name, characterId); + var updatedCharacter = TestDataBuilder.CreateCharacterEntityWithSystemPrompt( + command.Name, + characterId, + command.Description, + true, + command.VoiceId, + command.SystemPrompt + ); _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) .ReturnsAsync(existingCharacter); @@ -281,14 +403,78 @@ public async Task UpdateCharacterAsync_ShouldLogCharacterUpdate() .ReturnsAsync(updatedCharacter); // Act - await _characterService.UpdateCharacterAsync(characterId, command); + var result = await _characterService.UpdateCharacterToSystemPromptAsync(command); // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(characterId); + result.Name.Should().Be(command.Name); + result.Description.Should().Be(command.Description); + result.ConfigMode.Should().Be(CharacterConfigMode.SystemPrompt); + result.SystemPrompt.Should().Be(command.SystemPrompt); + + _mockCharacterRepository.Verify(x => x.GetByIdAsync(characterId), Times.Once); + _mockCharacterRepository.Verify(x => x.UpdateAsync(It.Is( + c => c.Id == characterId && + c.Name == command.Name && + c.Description == command.Description && + c.ConfigMode == CharacterConfigMode.SystemPrompt && + c.SystemPrompt == command.SystemPrompt + )), Times.Once); + } + + [Fact] + public async Task UpdateCharacterToSystemPromptAsync_WithNonExistentId_ShouldThrowNotFoundException() + { + // Arrange + var characterId = Guid.NewGuid(); + var command = TestDataBuilder.CreateUpdateCharacterToSystemPromptCommand( + characterId, + "UpdatedName", + "Updated description" + ); + + _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) + .ReturnsAsync((ProjectVG.Domain.Entities.Characters.Character?)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _characterService.UpdateCharacterToSystemPromptAsync(command) + ); + + exception.ErrorCode.Should().Be(ErrorCode.CHARACTER_NOT_FOUND); + _mockCharacterRepository.Verify(x => x.GetByIdAsync(characterId), Times.Once); + _mockCharacterRepository.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateCharacterToSystemPromptAsync_ShouldLogCharacterUpdate() + { + // Arrange + var characterId = Guid.NewGuid(); + var existingCharacter = TestDataBuilder.CreateCharacterEntityWithIndividualConfig("OldName", characterId); + var command = TestDataBuilder.CreateUpdateCharacterToSystemPromptCommand( + characterId, + "LogTestSystemPromptUpdate", + "Test description" + ); + + var updatedCharacter = TestDataBuilder.CreateCharacterEntityWithSystemPrompt(command.Name, characterId); + + _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) + .ReturnsAsync(existingCharacter); + _mockCharacterRepository.Setup(x => x.UpdateAsync(It.IsAny())) + .ReturnsAsync(updatedCharacter); + + // Act + await _characterService.UpdateCharacterToSystemPromptAsync(command); + + // Assert - Check for the correct log message format _mockLogger.Verify( x => x.Log( LogLevel.Information, It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("캐릭터 수정 완료")), + It.Is((v, t) => v.ToString()!.Contains("캐릭터 SystemPrompt 모드로 수정 완료") && v.ToString()!.Contains("LogTestSystemPromptUpdate")), It.IsAny(), It.IsAny>()), Times.Once); @@ -303,7 +489,7 @@ public async Task DeleteCharacterAsync_WithValidId_ShouldDeleteCharacter() { // Arrange var characterId = Guid.NewGuid(); - var character = CreateTestCharacter("TestCharacter", characterId); + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig("TestCharacter", characterId); _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) .ReturnsAsync(character); @@ -342,7 +528,7 @@ public async Task DeleteCharacterAsync_ShouldLogCharacterDeletion() { // Arrange var characterId = Guid.NewGuid(); - var character = CreateTestCharacter("DeleteTestCharacter", characterId); + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig("DeleteTestCharacter", characterId); _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) .ReturnsAsync(character); @@ -372,7 +558,7 @@ public async Task CharacterExistsAsync_WithExistingId_ShouldReturnTrue() { // Arrange var characterId = Guid.NewGuid(); - var character = CreateTestCharacter("ExistingCharacter", characterId); + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig("ExistingCharacter", characterId); _mockCharacterRepository.Setup(x => x.GetByIdAsync(characterId)) .ReturnsAsync(character); @@ -404,23 +590,135 @@ public async Task CharacterExistsAsync_WithNonExistentId_ShouldReturnFalse() #endregion - #region Helper Methods + #region Character Configuration Tests - private static ProjectVG.Domain.Entities.Characters.Character CreateTestCharacter(string name, Guid? id = null) + [Fact] + public void Character_IndividualConfigMode_ShouldReturnCorrectEffectiveSystemPrompt() { - return new ProjectVG.Domain.Entities.Characters.Character - { - Id = id ?? Guid.NewGuid(), - Name = name, - Description = "Test description", - Role = "Assistant", - IsActive = true, - Personality = "Friendly", - SpeechStyle = "Casual", - Summary = "Test character", - UserAlias = "User", - VoiceId = "test-voice" - }; + // Arrange + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig( + "TestChar", + null, + "Test description", + true, + "test-voice", + "Assistant", + "Friendly and helpful", + "Casual tone", + "A test character", + "User" + ); + + // Act + var effectivePrompt = character.GetEffectiveSystemPrompt(); + + // Assert + effectivePrompt.Should().Contain("역할: Assistant"); + effectivePrompt.Should().Contain("성격: Friendly and helpful"); + effectivePrompt.Should().Contain("말투: Casual tone"); + effectivePrompt.Should().Contain("요약: A test character"); + effectivePrompt.Should().Contain("사용자 호칭: User"); + } + + [Fact] + public void Character_SystemPromptMode_ShouldReturnDirectSystemPrompt() + { + // Arrange + var systemPrompt = "You are a helpful assistant."; + var character = TestDataBuilder.CreateCharacterEntityWithSystemPrompt( + "TestChar", + null, + "Test description", + true, + "test-voice", + systemPrompt + ); + + // Act + var effectivePrompt = character.GetEffectiveSystemPrompt(); + + // Assert + effectivePrompt.Should().Be(systemPrompt); + } + + [Fact] + public void Character_ValidateConfiguration_IndividualMode_ShouldReturnTrue() + { + // Arrange + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig( + "TestChar", + null, + "Test description", + true, + "test-voice", + "Assistant" + ); + + // Act + var isValid = character.ValidateConfiguration(); + + // Assert + isValid.Should().BeTrue(); + } + + [Fact] + public void Character_ValidateConfiguration_SystemPromptMode_ShouldReturnTrue() + { + // Arrange + var character = TestDataBuilder.CreateCharacterEntityWithSystemPrompt( + "TestChar", + null, + "Test description", + true, + "test-voice", + "You are a helpful assistant." + ); + + // Act + var isValid = character.ValidateConfiguration(); + + // Assert + isValid.Should().BeTrue(); + } + + [Fact] + public void Character_CanStartConversation_WithValidConfig_ShouldReturnTrue() + { + // Arrange + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig( + "TestChar", + null, + "Test description", + true, // isActive + "test-voice", + "Assistant" + ); + + // Act + var canStart = character.CanStartConversation(); + + // Assert + canStart.Should().BeTrue(); + } + + [Fact] + public void Character_CanStartConversation_WithInactiveCharacter_ShouldReturnFalse() + { + // Arrange + var character = TestDataBuilder.CreateCharacterEntityWithIndividualConfig( + "TestChar", + null, + "Test description", + false, // isActive = false + "test-voice", + "Assistant" + ); + + // Act + var canStart = character.CanStartConversation(); + + // Assert + canStart.Should().BeFalse(); } #endregion diff --git a/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs b/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs index e87c8d9..37b5cff 100644 --- a/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs +++ b/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs @@ -4,6 +4,7 @@ using ProjectVG.Application.Services.Users; using ProjectVG.Domain.Entities.ConversationHistorys; using ProjectVG.Domain.Entities.Users; +using ProjectVG.Domain.Entities.Characters; namespace ProjectVG.Tests.Application.TestUtilities { @@ -11,76 +12,190 @@ public static class TestDataBuilder { #region Character Test Data - public static ProjectVG.Domain.Entities.Characters.Character CreateCharacterEntity( + public static ProjectVG.Domain.Entities.Characters.Character CreateCharacterEntityWithIndividualConfig( string name = "TestCharacter", Guid? id = null, string description = "Test character description", - string role = "Assistant", bool isActive = true, - string personality = "Friendly and helpful", - string speechStyle = "Casual", - string summary = "Test character summary", - string userAlias = "User", - string voiceId = "test-voice") + string voiceId = "test-voice", + string? role = "Assistant", + string? personality = "Friendly and helpful", + string? speechStyle = "Casual", + string? summary = "Test character summary", + string? userAlias = "User", + string? imageUrl = null) { - return new ProjectVG.Domain.Entities.Characters.Character + var character = new ProjectVG.Domain.Entities.Characters.Character { Id = id ?? Guid.NewGuid(), Name = name, Description = description, - Role = role, IsActive = isActive, + VoiceId = voiceId, + ImageUrl = imageUrl + }; + + var individualConfig = new IndividualConfig + { + Role = role, Personality = personality, SpeechStyle = speechStyle, Summary = summary, - UserAlias = userAlias, - VoiceId = voiceId + UserAlias = userAlias + }; + + character.SetIndividualConfig(individualConfig); + return character; + } + + public static ProjectVG.Domain.Entities.Characters.Character CreateCharacterEntityWithSystemPrompt( + string name = "TestCharacter", + Guid? id = null, + string description = "Test character description", + bool isActive = true, + string voiceId = "test-voice", + string systemPrompt = "You are a friendly and helpful assistant.", + string? imageUrl = null) + { + var character = new ProjectVG.Domain.Entities.Characters.Character + { + Id = id ?? Guid.NewGuid(), + Name = name, + Description = description, + IsActive = isActive, + VoiceId = voiceId, + ImageUrl = imageUrl }; + + character.SetSystemPrompt(systemPrompt); + return character; } - public static CharacterDto CreateCharacterDto( + public static CharacterDto CreateCharacterDtoWithIndividualConfig( + string name = "TestCharacter", + Guid? id = null, + string description = "Test character description", + bool isActive = true, + string voiceId = "test-voice", + string? role = "Assistant", + string? personality = "Friendly and helpful", + string? speechStyle = "Casual", + string? summary = "Test character summary", + string? userAlias = "User", + string? imageUrl = null) + { + var entity = CreateCharacterEntityWithIndividualConfig(name, id, description, isActive, voiceId, role, personality, speechStyle, summary, userAlias, imageUrl); + return new CharacterDto(entity); + } + + public static CharacterDto CreateCharacterDtoWithSystemPrompt( string name = "TestCharacter", Guid? id = null, string description = "Test character description", - string role = "Assistant", bool isActive = true, - string personality = "Friendly and helpful", - string speechStyle = "Casual", - string summary = "Test character summary", - string userAlias = "User", - string voiceId = "test-voice") + string voiceId = "test-voice", + string systemPrompt = "You are a friendly and helpful assistant.", + string? imageUrl = null) { - var entity = CreateCharacterEntity(name, id, description, role, isActive, personality, speechStyle, summary, userAlias, voiceId); + var entity = CreateCharacterEntityWithSystemPrompt(name, id, description, isActive, voiceId, systemPrompt, imageUrl); return new CharacterDto(entity); } - public static CreateCharacterCommand CreateCreateCharacterCommand( + public static CreateCharacterWithFieldsCommand CreateCreateCharacterWithFieldsCommand( string name = "TestCharacter", string description = "Test character description", - string role = "Assistant", - bool isActive = true) + bool isActive = true, + string voiceId = "test-voice", + string? role = "Assistant", + string? personality = "Friendly and helpful", + string? speechStyle = "Casual", + string? summary = "Test character summary", + string? userAlias = "User", + string? imageUrl = null) { - return new CreateCharacterCommand + return new CreateCharacterWithFieldsCommand { Name = name, Description = description, - Role = role, - IsActive = isActive + IsActive = isActive, + VoiceId = voiceId, + ImageUrl = imageUrl ?? string.Empty, + IndividualConfig = new IndividualConfig + { + Role = role, + Personality = personality, + SpeechStyle = speechStyle, + Summary = summary, + UserAlias = userAlias + } + }; + } + + public static CreateCharacterWithSystemPromptCommand CreateCreateCharacterWithSystemPromptCommand( + string name = "TestCharacter", + string description = "Test character description", + bool isActive = true, + string voiceId = "test-voice", + string systemPrompt = "You are a friendly and helpful assistant.", + string? imageUrl = null) + { + return new CreateCharacterWithSystemPromptCommand + { + Name = name, + Description = description, + IsActive = isActive, + VoiceId = voiceId, + ImageUrl = imageUrl ?? string.Empty, + SystemPrompt = systemPrompt }; } - public static UpdateCharacterCommand CreateUpdateCharacterCommand( + public static UpdateCharacterToIndividualCommand CreateUpdateCharacterToIndividualCommand( + Guid id, string name = "UpdatedCharacter", string description = "Updated character description", - string role = "Updated role", - bool isActive = true) + string voiceId = "test-voice", + string? role = "Updated role", + string? personality = "Updated personality", + string? speechStyle = "Updated speech style", + string? summary = "Updated summary", + string? userAlias = "User", + string? imageUrl = null) { - return new UpdateCharacterCommand + return new UpdateCharacterToIndividualCommand { + Id = id, Name = name, Description = description, - Role = role, - IsActive = isActive + VoiceId = voiceId, + ImageUrl = imageUrl ?? string.Empty, + IndividualConfig = new IndividualConfig + { + Role = role, + Personality = personality, + SpeechStyle = speechStyle, + Summary = summary, + UserAlias = userAlias + } + }; + } + + public static UpdateCharacterToSystemPromptCommand CreateUpdateCharacterToSystemPromptCommand( + Guid id, + string name = "UpdatedCharacter", + string description = "Updated character description", + string voiceId = "test-voice", + string systemPrompt = "You are an updated friendly and helpful assistant.", + string? imageUrl = null) + { + return new UpdateCharacterToSystemPromptCommand + { + Id = id, + Name = name, + Description = description, + VoiceId = voiceId, + ImageUrl = imageUrl ?? string.Empty, + SystemPrompt = systemPrompt }; } @@ -211,7 +326,7 @@ public static ChatProcessContext CreateChatProcessContext( List? memoryContext = null) { var actualCommand = command ?? CreateChatRequestCommand(); - var actualCharacter = character ?? CreateCharacterDto(); + var actualCharacter = character ?? CreateCharacterDtoWithIndividualConfig(); var actualHistory = conversationHistory ?? CreateConversationHistoryList(actualCommand.UserId, actualCommand.CharacterId); var actualMemory = memoryContext ?? new List { "Previous context 1", "Previous context 2" }; From f2d8a32c82f07f6102d19ed0c06e97372290b1fe Mon Sep 17 00:00:00 2001 From: WooSH Date: Tue, 2 Sep 2025 13:13:03 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=86=8C=EC=9C=A0=EA=B6=8C=20=EC=A7=80=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CharacterController.cs | 20 +- .../CreateCharacterWithFieldsRequest.cs | 5 +- .../CreateCharacterWithSystemPromptRequest.cs | 5 +- .../Character/Response/CharacterResponse.cs | 10 +- .../Models/Character/CharacterCommand.cs | 1 + .../Models/Character/CharacterDto.cs | 5 + .../Services/Character/CharacterService.cs | 6 +- .../Entities/Character/Character.cs | 6 + ProjectVG.Domain/Entities/User/User.cs | 5 + ...902032140_AddUserIdToCharacter.Designer.cs | 294 ++++++++++++++++++ .../20250902032140_AddUserIdToCharacter.cs | 106 +++++++ .../ProjectVGDbContextModelSnapshot.cs | 36 ++- .../EfCore/Data/ProjectVGDbContext.cs | 8 + 13 files changed, 490 insertions(+), 17 deletions(-) create mode 100644 ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.Designer.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.cs diff --git a/ProjectVG.Api/Controllers/CharacterController.cs b/ProjectVG.Api/Controllers/CharacterController.cs index d305525..5f48883 100644 --- a/ProjectVG.Api/Controllers/CharacterController.cs +++ b/ProjectVG.Api/Controllers/CharacterController.cs @@ -3,6 +3,8 @@ using ProjectVG.Application.Models.Character; using ProjectVG.Api.Models.Character.Request; using ProjectVG.Api.Models.Character.Response; +using ProjectVG.Api.Filters; +using System.Security.Claims; namespace ProjectVG.Api.Controllers { @@ -19,6 +21,16 @@ public CharacterController(ICharacterService characterService, ILogger>> GetAllCharacters() { @@ -36,18 +48,22 @@ public async Task> GetCharacterById(Guid id) } [HttpPost("individual")] + [JwtAuthentication] public async Task> CreateCharacterWithFields([FromBody] CreateCharacterWithFieldsRequest request) { - var command = request.ToCommand(); + var userId = GetCurrentUserId(); + var command = request.ToCommand(userId); var characterDto = await _characterService.CreateCharacterWithFieldsAsync(command); var response = CharacterResponse.ToResponseDto(characterDto); return CreatedAtAction(nameof(GetCharacterById), new { id = response.Id }, response); } [HttpPost("systemprompt")] + [JwtAuthentication] public async Task> CreateCharacterWithSystemPrompt([FromBody] CreateCharacterWithSystemPromptRequest request) { - var command = request.ToCommand(); + var userId = GetCurrentUserId(); + var command = request.ToCommand(userId); var characterDto = await _characterService.CreateCharacterWithSystemPromptAsync(command); var response = CharacterResponse.ToResponseDto(characterDto); return CreatedAtAction(nameof(GetCharacterById), new { id = response.Id }, response); diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs index 0234b55..ff55b48 100644 --- a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs +++ b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs @@ -31,7 +31,7 @@ public record CreateCharacterWithFieldsRequest [Required(ErrorMessage = "개별 설정은 필수입니다.")] public IndividualConfig IndividualConfig { get; init; } = new(); - public CreateCharacterWithFieldsCommand ToCommand() + public CreateCharacterWithFieldsCommand ToCommand(Guid? userId = null) { return new CreateCharacterWithFieldsCommand { @@ -39,7 +39,8 @@ public CreateCharacterWithFieldsCommand ToCommand() Description = Description, ImageUrl = ImageUrl, VoiceId = VoiceId, - IndividualConfig = IndividualConfig + IndividualConfig = IndividualConfig, + UserId = userId }; } } diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs index c275379..6c2a6bc 100644 --- a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs +++ b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs @@ -31,7 +31,7 @@ public record CreateCharacterWithSystemPromptRequest [StringLength(5000, MinimumLength = 1, ErrorMessage = "SystemPrompt는 1-5000자 사이여야 합니다.")] public string SystemPrompt { get; init; } = string.Empty; - public CreateCharacterWithSystemPromptCommand ToCommand() + public CreateCharacterWithSystemPromptCommand ToCommand(Guid? userId = null) { return new CreateCharacterWithSystemPromptCommand { @@ -39,7 +39,8 @@ public CreateCharacterWithSystemPromptCommand ToCommand() Description = Description, ImageUrl = ImageUrl, VoiceId = VoiceId, - SystemPrompt = SystemPrompt + SystemPrompt = SystemPrompt, + UserId = userId }; } } diff --git a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs index eedb046..f685d3f 100644 --- a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs +++ b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs @@ -36,6 +36,12 @@ public record CharacterResponse [JsonPropertyName("effective_system_prompt")] public string EffectiveSystemPrompt { get; init; } = string.Empty; + [JsonPropertyName("created_by_user_id")] + public Guid? CreatedByUserId { get; init; } + + [JsonPropertyName("created_by_username")] + public string? CreatedByUsername { get; init; } + public static CharacterResponse ToResponseDto(CharacterDto characterDto) { return new CharacterResponse @@ -49,7 +55,9 @@ public static CharacterResponse ToResponseDto(CharacterDto characterDto) ConfigMode = characterDto.ConfigMode.ToString().ToLowerInvariant(), IndividualConfig = characterDto.IndividualConfig, SystemPrompt = characterDto.SystemPrompt, - EffectiveSystemPrompt = characterDto.EffectiveSystemPrompt + EffectiveSystemPrompt = characterDto.EffectiveSystemPrompt, + CreatedByUserId = characterDto.CreatedByUserId, + CreatedByUsername = characterDto.CreatedByUsername }; } } diff --git a/ProjectVG.Application/Models/Character/CharacterCommand.cs b/ProjectVG.Application/Models/Character/CharacterCommand.cs index ef857f4..c2ca093 100644 --- a/ProjectVG.Application/Models/Character/CharacterCommand.cs +++ b/ProjectVG.Application/Models/Character/CharacterCommand.cs @@ -12,6 +12,7 @@ public abstract record CharacterCommand public string ImageUrl { get; init; } = string.Empty; public string VoiceId { get; init; } = string.Empty; public bool IsActive { get; init; } = true; + public Guid? UserId { get; init; } } /// diff --git a/ProjectVG.Application/Models/Character/CharacterDto.cs b/ProjectVG.Application/Models/Character/CharacterDto.cs index fd6a1ff..9a2412b 100644 --- a/ProjectVG.Application/Models/Character/CharacterDto.cs +++ b/ProjectVG.Application/Models/Character/CharacterDto.cs @@ -16,6 +16,9 @@ public record CharacterDto public string? SystemPrompt { get; init; } public string EffectiveSystemPrompt { get; init; } = string.Empty; + + public Guid? CreatedByUserId { get; init; } + public string? CreatedByUsername { get; init; } public CharacterDto(Domain.Entities.Characters.Character character) { @@ -29,6 +32,8 @@ public CharacterDto(Domain.Entities.Characters.Character character) IndividualConfig = character.IndividualConfig; SystemPrompt = character.SystemPrompt; EffectiveSystemPrompt = character.GetEffectiveSystemPrompt(); + CreatedByUserId = character.UserId; + CreatedByUsername = character.User?.Username; } } } diff --git a/ProjectVG.Application/Services/Character/CharacterService.cs b/ProjectVG.Application/Services/Character/CharacterService.cs index 5e31d24..8477072 100644 --- a/ProjectVG.Application/Services/Character/CharacterService.cs +++ b/ProjectVG.Application/Services/Character/CharacterService.cs @@ -45,7 +45,8 @@ public async Task CreateCharacterWithFieldsAsync(CreateCharacterWi Description = command.Description, ImageUrl = command.ImageUrl, VoiceId = command.VoiceId, - IsActive = command.IsActive + IsActive = command.IsActive, + UserId = command.UserId }; character.SetIndividualConfig(command.IndividualConfig); @@ -65,7 +66,8 @@ public async Task CreateCharacterWithSystemPromptAsync(CreateChara Description = command.Description, ImageUrl = command.ImageUrl, VoiceId = command.VoiceId, - IsActive = command.IsActive + IsActive = command.IsActive, + UserId = command.UserId }; character.SetSystemPrompt(command.SystemPrompt); diff --git a/ProjectVG.Domain/Entities/Character/Character.cs b/ProjectVG.Domain/Entities/Character/Character.cs index 5ad8f47..b686862 100644 --- a/ProjectVG.Domain/Entities/Character/Character.cs +++ b/ProjectVG.Domain/Entities/Character/Character.cs @@ -27,6 +27,12 @@ public class Character : BaseEntity /// 캐릭터 보이스 ID public string VoiceId { get; set; } = string.Empty; + /// 캐릭터를 생성한 사용자 ID (nullable - 시스템 캐릭터 허용) + public Guid? UserId { get; set; } + + /// 캐릭터를 생성한 사용자 (네비게이션 속성) + public virtual Users.User? User { get; set; } + /// 설정 모드 (개별 설정 vs SystemPrompt) public CharacterConfigMode ConfigMode { get; set; } = CharacterConfigMode.Individual; diff --git a/ProjectVG.Domain/Entities/User/User.cs b/ProjectVG.Domain/Entities/User/User.cs index 4381be7..b1c5825 100644 --- a/ProjectVG.Domain/Entities/User/User.cs +++ b/ProjectVG.Domain/Entities/User/User.cs @@ -49,5 +49,10 @@ public class User : BaseEntity /// 계정 상태 /// public AccountStatus Status { get; set; } = AccountStatus.Active; + + /// + /// 사용자가 생성한 캐릭터들 (네비게이션 속성) + /// + public virtual ICollection Characters { get; set; } = new List(); } } \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.Designer.cs new file mode 100644 index 0000000..d7284ce --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.Designer.cs @@ -0,0 +1,294 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ProjectVG.Infrastructure.Persistence.EfCore; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + [DbContext(typeof(ProjectVGDbContext))] + [Migration("20250902032140_AddUserIdToCharacter")] + partial class AddUserIdToCharacter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConfigMode") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IndividualConfig") + .HasColumnType("nvarchar(max)") + .HasColumnName("IndividualConfigJson"); + + b.Property("IndividualConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SystemPrompt") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConfigMode"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name"); + + b.HasIndex("UserId"); + + b.ToTable("Characters", t => + { + t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); + + t.Property("IndividualConfigJson") + .HasColumnName("IndividualConfigJson1"); + }); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), + Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IsActive = true, + Name = "하루", + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), + VoiceId = "haru" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2093), + Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IsActive = true, + Name = "소피아", + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2094), + VoiceId = "sophia" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CharacterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MetadataJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CharacterId", "Timestamp"); + + b.ToTable("ConversationHistories"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UID") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ProviderId"); + + b.HasIndex("UID") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2110), + Email = "test@test.com", + Provider = "test", + ProviderId = "test", + Status = 0, + UID = "TESTUSER001", + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2111), + Username = "testuser" + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), + Email = "zero@test.com", + Provider = "test", + ProviderId = "zero", + Status = 0, + UID = "ZEROUSER001", + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), + Username = "zerouser" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany("Characters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.HasOne("ProjectVG.Domain.Entities.Characters.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProjectVG.Domain.Entities.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Navigation("Characters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.cs b/ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.cs new file mode 100644 index 0000000..668cd99 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250902032140_AddUserIdToCharacter.cs @@ -0,0 +1,106 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + /// + public partial class AddUserIdToCharacter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UserId", + table: "Characters", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt", "UserId" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), null }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt", "UserId" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2093), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2094), null }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2110), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2111) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113) }); + + migrationBuilder.CreateIndex( + name: "IX_Characters_UserId", + table: "Characters", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Characters_Users_UserId", + table: "Characters", + column: "UserId", + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Characters_Users_UserId", + table: "Characters"); + + migrationBuilder.DropIndex( + name: "IX_Characters_UserId", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Characters"); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6307), new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6308) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389) }); + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs index 5d94a51..6d0b82f 100644 --- a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs +++ b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs @@ -70,6 +70,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("UpdatedAt") .HasColumnType("datetime2"); + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + b.Property("VoiceId") .IsRequired() .HasMaxLength(100) @@ -83,6 +86,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name"); + b.HasIndex("UserId"); + b.ToTable("Characters", t => { t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); @@ -96,28 +101,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) { Id = new Guid("11111111-1111-1111-1111-111111111111"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6307), + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", IsActive = true, Name = "하루", - UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6308), + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), VoiceId = "haru" }, new { Id = new Guid("22222222-2222-2222-2222-222222222222"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2093), Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", IsActive = true, Name = "소피아", - UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6368), + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2094), VoiceId = "sophia" }); }); @@ -228,29 +233,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2110), Email = "test@test.com", Provider = "test", ProviderId = "test", Status = 0, UID = "TESTUSER001", - UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6387), + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2111), Username = "testuser" }, new { Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CreatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), + CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), Email = "zero@test.com", Provider = "test", ProviderId = "zero", Status = 0, UID = "ZEROUSER001", - UpdatedAt = new DateTime(2025, 9, 1, 4, 38, 57, 198, DateTimeKind.Utc).AddTicks(6389), + UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), Username = "zerouser" }); }); + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany("Characters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => { b.HasOne("ProjectVG.Domain.Entities.Characters.Character", null) @@ -265,6 +280,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Navigation("Characters"); + }); #pragma warning restore 612, 618 } } diff --git a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs index dbbba18..4724ce3 100644 --- a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs +++ b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs @@ -49,6 +49,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.VoiceId).HasMaxLength(100); entity.Property(e => e.IsActive).IsRequired().HasDefaultValue(true); + // User 관계 설정 (nullable) + entity.Property(e => e.UserId).IsRequired(false); + entity.HasOne(e => e.User) + .WithMany(u => u.Characters) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.SetNull); + // 설정 모드 entity.Property(e => e.ConfigMode).IsRequired().HasDefaultValue(CharacterConfigMode.Individual); @@ -72,6 +79,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.Name); entity.HasIndex(e => e.IsActive); entity.HasIndex(e => e.ConfigMode); + entity.HasIndex(e => e.UserId); }); // ConversationHistorys 엔티티 설정 From 8aa4c242522137a2a942b3e5a628314add240427 Mon Sep 17 00:00:00 2001 From: WooSH Date: Tue, 2 Sep 2025 18:27:10 +0900 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EC=97=AC=EB=B6=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CharacterController.cs | 23 ++ .../CreateCharacterWithFieldsRequest.cs | 6 +- .../CreateCharacterWithSystemPromptRequest.cs | 6 +- .../Character/Response/CharacterResponse.cs | 6 +- .../Models/Character/CharacterCommand.cs | 1 + .../Models/Character/CharacterDto.cs | 2 + .../Services/Character/CharacterService.cs | 44 ++- .../Services/Character/ICharacterService.cs | 15 + .../Entities/Character/Character.cs | 36 +++ ...2044034_AddIsPublicToCharacter.Designer.cs | 303 ++++++++++++++++++ .../20250902044034_AddIsPublicToCharacter.cs | 95 ++++++ .../ProjectVGDbContextModelSnapshot.cs | 25 +- .../EfCore/Data/ProjectVGDbContext.cs | 4 + .../Character/ICharacterRepository.cs | 2 + .../Character/SqlServerCharacterRepository.cs | 16 + 15 files changed, 571 insertions(+), 13 deletions(-) create mode 100644 ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.Designer.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.cs diff --git a/ProjectVG.Api/Controllers/CharacterController.cs b/ProjectVG.Api/Controllers/CharacterController.cs index 5f48883..8241e9b 100644 --- a/ProjectVG.Api/Controllers/CharacterController.cs +++ b/ProjectVG.Api/Controllers/CharacterController.cs @@ -93,5 +93,28 @@ public async Task DeleteCharacter(Guid id) await _characterService.DeleteCharacterAsync(id); return NoContent(); } + + [HttpGet("my")] + [JwtAuthentication] + public async Task>> GetMyCharacters([FromQuery] string orderBy = "latest") + { + var userId = GetCurrentUserId(); + if (!userId.HasValue) + { + return Unauthorized(); + } + + var characterDtos = await _characterService.GetMyCharactersAsync(userId.Value, orderBy); + var responses = characterDtos.Select(CharacterResponse.ToResponseDto); + return Ok(responses); + } + + [HttpGet("public")] + public async Task>> GetPublicCharacters([FromQuery] string orderBy = "latest") + { + var characterDtos = await _characterService.GetPublicCharactersAsync(orderBy); + var responses = characterDtos.Select(CharacterResponse.ToResponseDto); + return Ok(responses); + } } } \ No newline at end of file diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs index ff55b48..c6a8db7 100644 --- a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs +++ b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithFieldsRequest.cs @@ -31,6 +31,9 @@ public record CreateCharacterWithFieldsRequest [Required(ErrorMessage = "개별 설정은 필수입니다.")] public IndividualConfig IndividualConfig { get; init; } = new(); + [JsonPropertyName("is_public")] + public bool IsPublic { get; init; } = true; + public CreateCharacterWithFieldsCommand ToCommand(Guid? userId = null) { return new CreateCharacterWithFieldsCommand @@ -40,7 +43,8 @@ public CreateCharacterWithFieldsCommand ToCommand(Guid? userId = null) ImageUrl = ImageUrl, VoiceId = VoiceId, IndividualConfig = IndividualConfig, - UserId = userId + UserId = userId, + IsPublic = IsPublic }; } } diff --git a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs index 6c2a6bc..e9344c0 100644 --- a/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs +++ b/ProjectVG.Api/Models/Character/Request/CreateCharacterWithSystemPromptRequest.cs @@ -31,6 +31,9 @@ public record CreateCharacterWithSystemPromptRequest [StringLength(5000, MinimumLength = 1, ErrorMessage = "SystemPrompt는 1-5000자 사이여야 합니다.")] public string SystemPrompt { get; init; } = string.Empty; + [JsonPropertyName("is_public")] + public bool IsPublic { get; init; } = true; + public CreateCharacterWithSystemPromptCommand ToCommand(Guid? userId = null) { return new CreateCharacterWithSystemPromptCommand @@ -40,7 +43,8 @@ public CreateCharacterWithSystemPromptCommand ToCommand(Guid? userId = null) ImageUrl = ImageUrl, VoiceId = VoiceId, SystemPrompt = SystemPrompt, - UserId = userId + UserId = userId, + IsPublic = IsPublic }; } } diff --git a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs index f685d3f..a5e06d4 100644 --- a/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs +++ b/ProjectVG.Api/Models/Character/Response/CharacterResponse.cs @@ -42,6 +42,9 @@ public record CharacterResponse [JsonPropertyName("created_by_username")] public string? CreatedByUsername { get; init; } + [JsonPropertyName("is_public")] + public bool IsPublic { get; init; } + public static CharacterResponse ToResponseDto(CharacterDto characterDto) { return new CharacterResponse @@ -57,7 +60,8 @@ public static CharacterResponse ToResponseDto(CharacterDto characterDto) SystemPrompt = characterDto.SystemPrompt, EffectiveSystemPrompt = characterDto.EffectiveSystemPrompt, CreatedByUserId = characterDto.CreatedByUserId, - CreatedByUsername = characterDto.CreatedByUsername + CreatedByUsername = characterDto.CreatedByUsername, + IsPublic = characterDto.IsPublic }; } } diff --git a/ProjectVG.Application/Models/Character/CharacterCommand.cs b/ProjectVG.Application/Models/Character/CharacterCommand.cs index c2ca093..cc443b6 100644 --- a/ProjectVG.Application/Models/Character/CharacterCommand.cs +++ b/ProjectVG.Application/Models/Character/CharacterCommand.cs @@ -13,6 +13,7 @@ public abstract record CharacterCommand public string VoiceId { get; init; } = string.Empty; public bool IsActive { get; init; } = true; public Guid? UserId { get; init; } + public bool IsPublic { get; init; } = true; } /// diff --git a/ProjectVG.Application/Models/Character/CharacterDto.cs b/ProjectVG.Application/Models/Character/CharacterDto.cs index 9a2412b..5d2a540 100644 --- a/ProjectVG.Application/Models/Character/CharacterDto.cs +++ b/ProjectVG.Application/Models/Character/CharacterDto.cs @@ -19,6 +19,7 @@ public record CharacterDto public Guid? CreatedByUserId { get; init; } public string? CreatedByUsername { get; init; } + public bool IsPublic { get; init; } public CharacterDto(Domain.Entities.Characters.Character character) { @@ -34,6 +35,7 @@ public CharacterDto(Domain.Entities.Characters.Character character) EffectiveSystemPrompt = character.GetEffectiveSystemPrompt(); CreatedByUserId = character.UserId; CreatedByUsername = character.User?.Username; + IsPublic = character.IsPublic; } } } diff --git a/ProjectVG.Application/Services/Character/CharacterService.cs b/ProjectVG.Application/Services/Character/CharacterService.cs index 8477072..efa3fd6 100644 --- a/ProjectVG.Application/Services/Character/CharacterService.cs +++ b/ProjectVG.Application/Services/Character/CharacterService.cs @@ -46,7 +46,8 @@ public async Task CreateCharacterWithFieldsAsync(CreateCharacterWi ImageUrl = command.ImageUrl, VoiceId = command.VoiceId, IsActive = command.IsActive, - UserId = command.UserId + UserId = command.UserId, + IsPublic = command.IsPublic }; character.SetIndividualConfig(command.IndividualConfig); @@ -67,7 +68,8 @@ public async Task CreateCharacterWithSystemPromptAsync(CreateChara ImageUrl = command.ImageUrl, VoiceId = command.VoiceId, IsActive = command.IsActive, - UserId = command.UserId + UserId = command.UserId, + IsPublic = command.IsPublic }; character.SetSystemPrompt(command.SystemPrompt); @@ -133,5 +135,43 @@ public async Task CharacterExistsAsync(Guid id) var character = await _characterRepository.GetByIdAsync(id); return character != null; } + + public async Task> GetMyCharactersAsync(Guid userId, string orderBy = "latest") + { + var characters = await _characterRepository.GetByUserIdAsync(userId); + + // 정렬 적용 + var sortedCharacters = orderBy.ToLower() switch + { + "latest" => characters.OrderByDescending(c => c.CreatedAt), + "oldest" => characters.OrderBy(c => c.CreatedAt), + "name" => characters.OrderBy(c => c.Name), + _ => characters.OrderByDescending(c => c.CreatedAt) + }; + + var characterDtos = sortedCharacters.Select(c => new CharacterDto(c)); + _logger.LogInformation("사용자 {UserId}의 캐릭터 {Count}개 조회 완료", userId, characterDtos.Count()); + + return characterDtos; + } + + public async Task> GetPublicCharactersAsync(string orderBy = "latest") + { + var characters = await _characterRepository.GetPublicCharactersAsync(); + + // 정렬 적용 + var sortedCharacters = orderBy.ToLower() switch + { + "latest" => characters.OrderByDescending(c => c.CreatedAt), + "oldest" => characters.OrderBy(c => c.CreatedAt), + "name" => characters.OrderBy(c => c.Name), + _ => characters.OrderByDescending(c => c.CreatedAt) + }; + + var characterDtos = sortedCharacters.Select(c => new CharacterDto(c)); + _logger.LogInformation("공개 캐릭터 {Count}개 조회 완료", characterDtos.Count()); + + return characterDtos; + } } } \ No newline at end of file diff --git a/ProjectVG.Application/Services/Character/ICharacterService.cs b/ProjectVG.Application/Services/Character/ICharacterService.cs index eada6e6..704ad07 100644 --- a/ProjectVG.Application/Services/Character/ICharacterService.cs +++ b/ProjectVG.Application/Services/Character/ICharacterService.cs @@ -57,5 +57,20 @@ public interface ICharacterService /// 캐릭터 ID /// 캐릭터 존재 여부 Task CharacterExistsAsync(Guid id); + + /// + /// 특정 사용자가 소유한 캐릭터들을 조회합니다 (공개/비공개 모두 포함) + /// + /// 사용자 ID + /// 정렬 방식 (latest: 최신순) + /// 사용자 소유 캐릭터 목록 + Task> GetMyCharactersAsync(Guid userId, string orderBy = "latest"); + + /// + /// 공개 캐릭터들을 조회합니다 (시스템 캐릭터 + 모든 사용자의 공개 캐릭터) + /// + /// 정렬 방식 (latest: 최신순) + /// 공개 캐릭터 목록 + Task> GetPublicCharactersAsync(string orderBy = "latest"); } } \ No newline at end of file diff --git a/ProjectVG.Domain/Entities/Character/Character.cs b/ProjectVG.Domain/Entities/Character/Character.cs index b686862..6d54f61 100644 --- a/ProjectVG.Domain/Entities/Character/Character.cs +++ b/ProjectVG.Domain/Entities/Character/Character.cs @@ -33,6 +33,9 @@ public class Character : BaseEntity /// 캐릭터를 생성한 사용자 (네비게이션 속성) public virtual Users.User? User { get; set; } + /// 캐릭터 공개 여부 (true: 공개, false: 비공개) + public bool IsPublic { get; set; } = true; + /// 설정 모드 (개별 설정 vs SystemPrompt) public CharacterConfigMode ConfigMode { get; set; } = CharacterConfigMode.Individual; @@ -127,5 +130,38 @@ public bool CanStartConversation() { return IsActive && ValidateConfiguration() && !string.IsNullOrEmpty(GetEffectiveSystemPrompt()); } + + /// + /// 시스템 캐릭터인지 확인 (UserId가 null인 캐릭터) + /// + /// 시스템 캐릭터이면 true + public bool IsSystemCharacter() + { + return UserId == null; + } + + /// + /// 특정 사용자가 이 캐릭터의 소유자인지 확인 + /// + /// 확인할 사용자 ID + /// 소유자이면 true + public bool IsOwnedBy(Guid userId) + { + return UserId.HasValue && UserId.Value == userId; + } + + /// + /// 특정 사용자가 이 캐릭터를 볼 수 있는지 확인 + /// + /// 확인할 사용자 ID (null이면 비로그인 사용자) + /// 볼 수 있으면 true + public bool CanBeViewedBy(Guid? userId) + { + // 공개 캐릭터는 누구나 볼 수 있음 + if (IsPublic) return true; + + // 비공개 캐릭터는 소유자만 볼 수 있음 + return userId.HasValue && IsOwnedBy(userId.Value); + } } } \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.Designer.cs new file mode 100644 index 0000000..570aaf3 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.Designer.cs @@ -0,0 +1,303 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ProjectVG.Infrastructure.Persistence.EfCore; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + [DbContext(typeof(ProjectVGDbContext))] + [Migration("20250902044034_AddIsPublicToCharacter")] + partial class AddIsPublicToCharacter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConfigMode") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IndividualConfig") + .HasColumnType("nvarchar(max)") + .HasColumnName("IndividualConfigJson"); + + b.Property("IndividualConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SystemPrompt") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConfigMode"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsPublic"); + + b.HasIndex("Name"); + + b.HasIndex("UserId"); + + b.ToTable("Characters", t => + { + t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); + + t.Property("IndividualConfigJson") + .HasColumnName("IndividualConfigJson1"); + }); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), + Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "하루", + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), + VoiceId = "haru" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), + Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "소피아", + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), + VoiceId = "sophia" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CharacterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MetadataJson") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CharacterId", "Timestamp"); + + b.ToTable("ConversationHistories"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UID") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ProviderId"); + + b.HasIndex("UID") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7628), + Email = "test@test.com", + Provider = "test", + ProviderId = "test", + Status = 0, + UID = "TESTUSER001", + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7629), + Username = "testuser" + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), + Email = "zero@test.com", + Provider = "test", + ProviderId = "zero", + Status = 0, + UID = "ZEROUSER001", + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), + Username = "zerouser" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany("Characters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.HasOne("ProjectVG.Domain.Entities.Characters.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProjectVG.Domain.Entities.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Navigation("Characters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.cs b/ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.cs new file mode 100644 index 0000000..f354630 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250902044034_AddIsPublicToCharacter.cs @@ -0,0 +1,95 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + /// + public partial class AddIsPublicToCharacter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPublic", + table: "Characters", + type: "bit", + nullable: false, + defaultValue: true); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "IsPublic", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), true, new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "IsPublic", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), true, new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7628), new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7629) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631) }); + + migrationBuilder.CreateIndex( + name: "IX_Characters_IsPublic", + table: "Characters", + column: "IsPublic"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Characters_IsPublic", + table: "Characters"); + + migrationBuilder.DropColumn( + name: "IsPublic", + table: "Characters"); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2093), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2094) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2110), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2111) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113) }); + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs index 6d0b82f..cdce37f 100644 --- a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs +++ b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs @@ -58,6 +58,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bit") .HasDefaultValue(true); + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + b.Property("Name") .IsRequired() .HasMaxLength(100) @@ -84,6 +89,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("IsActive"); + b.HasIndex("IsPublic"); + b.HasIndex("Name"); b.HasIndex("UserId"); @@ -101,28 +108,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) { Id = new Guid("11111111-1111-1111-1111-111111111111"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", IsActive = true, + IsPublic = true, Name = "하루", - UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2031), + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), VoiceId = "haru" }, new { Id = new Guid("22222222-2222-2222-2222-222222222222"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2093), + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", IsActive = true, + IsPublic = true, Name = "소피아", - UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2094), + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), VoiceId = "sophia" }); }); @@ -233,25 +242,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2110), + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7628), Email = "test@test.com", Provider = "test", ProviderId = "test", Status = 0, UID = "TESTUSER001", - UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2111), + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7629), Username = "testuser" }, new { Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CreatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), + CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), Email = "zero@test.com", Provider = "test", ProviderId = "zero", Status = 0, UID = "ZEROUSER001", - UpdatedAt = new DateTime(2025, 9, 2, 3, 21, 39, 701, DateTimeKind.Utc).AddTicks(2113), + UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), Username = "zerouser" }); }); diff --git a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs index 4724ce3..57a0816 100644 --- a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs +++ b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs @@ -56,6 +56,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(e => e.UserId) .OnDelete(DeleteBehavior.SetNull); + // 공개 여부 설정 + entity.Property(e => e.IsPublic).IsRequired().HasDefaultValue(true); + // 설정 모드 entity.Property(e => e.ConfigMode).IsRequired().HasDefaultValue(CharacterConfigMode.Individual); @@ -80,6 +83,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.IsActive); entity.HasIndex(e => e.ConfigMode); entity.HasIndex(e => e.UserId); + entity.HasIndex(e => e.IsPublic); }); // ConversationHistorys 엔티티 설정 diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Character/ICharacterRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Character/ICharacterRepository.cs index 9ebca8e..31be019 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/Character/ICharacterRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Character/ICharacterRepository.cs @@ -9,5 +9,7 @@ public interface ICharacterRepository Task CreateAsync(Character character); Task UpdateAsync(Character character); Task DeleteAsync(Guid id); + Task> GetByUserIdAsync(Guid userId); + Task> GetPublicCharactersAsync(); } } \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs index 9b8b3a7..b8125df 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Character/SqlServerCharacterRepository.cs @@ -79,5 +79,21 @@ public async Task DeleteAsync(Guid id) await _context.SaveChangesAsync(); } + + public async Task> GetByUserIdAsync(Guid userId) + { + return await _context.Characters + .Include(c => c.User) + .Where(c => c.UserId == userId && c.IsActive) + .ToListAsync(); + } + + public async Task> GetPublicCharactersAsync() + { + return await _context.Characters + .Include(c => c.User) + .Where(c => c.IsPublic && c.IsActive) + .ToListAsync(); + } } } From 0cb7930e6f0c64d0aeef93a73653d1c4e3f505d5 Mon Sep 17 00:00:00 2001 From: WooSH Date: Tue, 2 Sep 2025 18:52:54 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-clients/ai-chat-client/index.html | 142 ++++++- test-clients/ai-chat-client/script.js | 505 +++++++++++++++++++++++++ test-clients/ai-chat-client/styles.css | 449 ++++++++++++++++++++++ 3 files changed, 1091 insertions(+), 5 deletions(-) diff --git a/test-clients/ai-chat-client/index.html b/test-clients/ai-chat-client/index.html index feb234e..cc552c8 100644 --- a/test-clients/ai-chat-client/index.html +++ b/test-clients/ai-chat-client/index.html @@ -30,11 +30,143 @@ -
- + + + + +
+
+ + +
+
+ + +
diff --git a/test-clients/ai-chat-client/script.js b/test-clients/ai-chat-client/script.js index 5e4aab0..d601782 100644 --- a/test-clients/ai-chat-client/script.js +++ b/test-clients/ai-chat-client/script.js @@ -8,6 +8,7 @@ const ENDPOINT = `${currentHost}:${serverPort}`; const WS_URL = `ws://${ENDPOINT}/ws`; const HTTP_URL = `http://${ENDPOINT}/api/v1/chat`; const LOGIN_URL = `http://${ENDPOINT}/api/v1/auth/guest-login`; +const CHARACTER_BASE_URL = `http://${ENDPOINT}/api/v1/character`; const SERVER_MESSAGE_TYPE = "json"; let ws = null; let reconnectAttempts = 0; @@ -31,10 +32,61 @@ const loginBtn = document.getElementById('login-btn'); const loginSection = document.getElementById('login-section'); const includeAudioCheckbox = document.getElementById('include-audio'); +// 캐릭터 관리 관련 DOM 요소들 +const tabNavigation = document.getElementById('tab-navigation'); +const tabButtons = document.querySelectorAll('.tab-btn'); +const chatSection = document.getElementById('chat-section'); +const characterManagement = document.getElementById('character-management'); +const refreshCharactersBtn = document.getElementById('refresh-characters'); + +// 캐릭터 탭 관련 +const characterTabButtons = document.querySelectorAll('.character-tab-btn'); +const myCharactersSection = document.getElementById('my-characters'); +const publicCharactersSection = document.getElementById('public-characters'); +const characterCreateSection = document.getElementById('character-create'); + +// 캐릭터 리스트 관련 +const myCharacterList = document.getElementById('my-character-list'); +const publicCharacterList = document.getElementById('public-character-list'); +const mySortOrder = document.getElementById('my-sort-order'); +const publicSortOrder = document.getElementById('public-sort-order'); + +// 캐릭터 생성 폼 관련 +const createNameInput = document.getElementById('create-name'); +const createDescriptionInput = document.getElementById('create-description'); +const createImageUrlInput = document.getElementById('create-image-url'); +const createVoiceIdInput = document.getElementById('create-voice-id'); +const createIsPublicCheckbox = document.getElementById('create-is-public'); +const configTabs = document.querySelectorAll('.config-tab'); +const individualConfigForm = document.getElementById('individual-config'); +const promptConfigForm = document.getElementById('prompt-config'); +const createCharacterBtn = document.getElementById('create-character-btn'); +const resetFormBtn = document.getElementById('reset-form-btn'); + +// 개별 설정 폼 요소들 +const configRole = document.getElementById('config-role'); +const configPersonality = document.getElementById('config-personality'); +const configSpeechStyle = document.getElementById('config-speech-style'); +const configUserAlias = document.getElementById('config-user-alias'); +const configSummary = document.getElementById('config-summary'); +const configBackground = document.getElementById('config-background'); + +// 시스템 프롬프트 폼 요소들 +const configSystemPrompt = document.getElementById('config-system-prompt'); +const promptCharCount = document.getElementById('prompt-char-count'); + const audioQueue = []; let isPlayingAudio = false; let serverConfig = null; +// 캐릭터 관리 상태 변수들 +let currentTab = 'chat'; +let currentCharacterTab = 'list'; +let currentConfigMode = 'individual'; +let myCharacters = []; +let publicCharacters = []; +let selectedCharacterId = null; + // 서버 정보 표시 serverInfo.textContent = ENDPOINT; @@ -86,9 +138,13 @@ async function guestLogin() { updateSessionId(userId); loginSection.style.display = 'none'; + tabNavigation.style.display = 'block'; // 로그인 성공 후 WebSocket 연결 시작 connectWebSocket(); + + // 캐릭터 목록 로드 + loadCharacters(); } else { throw new Error(data.message || '로그인 실패'); } @@ -502,5 +558,454 @@ characterSelect.onchange = () => { appendLog(`[캐릭터 변경됨: ${characterSelect.options[characterSelect.selectedIndex].text}]`); }; +// ========== 캐릭터 관리 기능 ========== + +// 탭 전환 기능 +function switchTab(tabName) { + currentTab = tabName; + + // 탭 버튼 활성화 상태 변경 + tabButtons.forEach(btn => { + if (btn.dataset.tab === tabName) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // 탭 콘텐츠 표시/숨김 + if (tabName === 'chat') { + chatSection.style.display = 'block'; + characterManagement.style.display = 'none'; + } else if (tabName === 'characters') { + chatSection.style.display = 'none'; + characterManagement.style.display = 'block'; + } +} + +// 캐릭터 탭 전환 기능 +function switchCharacterTab(tabName) { + currentCharacterTab = tabName; + + // 캐릭터 탭 버튼 활성화 상태 변경 + characterTabButtons.forEach(btn => { + if (btn.dataset.tab === tabName) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // 캐릭터 탭 콘텐츠 표시/숨김 + const tabContents = document.querySelectorAll('.character-tab-content'); + tabContents.forEach(content => { + content.style.display = 'none'; + }); + + if (tabName === 'list') { + myCharactersSection.style.display = 'block'; + } else if (tabName === 'public') { + publicCharactersSection.style.display = 'block'; + } else if (tabName === 'create') { + characterCreateSection.style.display = 'block'; + } +} + +// 설정 모드 탭 전환 +function switchConfigMode(mode) { + currentConfigMode = mode; + + configTabs.forEach(tab => { + if (tab.dataset.mode === mode) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + if (mode === 'individual') { + individualConfigForm.style.display = 'block'; + promptConfigForm.style.display = 'none'; + } else if (mode === 'prompt') { + individualConfigForm.style.display = 'none'; + promptConfigForm.style.display = 'block'; + } +} + +// 캐릭터 목록 로드 +async function loadCharacters() { + try { + // 내 캐릭터 로드 + await loadMyCharacters(); + + // 공개 캐릭터 로드 + await loadPublicCharacters(); + + // 채팅용 캐릭터 드롭다운 업데이트 + updateCharacterSelect(); + + } catch (error) { + console.error('캐릭터 목록 로드 실패:', error); + appendLog(`[캐릭터 로드 실패] ${error.message}`); + } +} + +// 내 캐릭터 로드 +async function loadMyCharacters() { + if (!authToken) return; + + const sortOrder = mySortOrder?.value || 'latest'; + + try { + const response = await fetch(`${CHARACTER_BASE_URL}/my?orderBy=${sortOrder}`, { + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + + if (response.ok) { + myCharacters = await response.json(); + renderCharacterList(myCharacters, myCharacterList, true); + } else { + console.error('내 캐릭터 로드 실패:', response.status); + } + } catch (error) { + console.error('내 캐릭터 로드 오류:', error); + } +} + +// 공개 캐릭터 로드 +async function loadPublicCharacters() { + const sortOrder = publicSortOrder?.value || 'latest'; + + try { + const response = await fetch(`${CHARACTER_BASE_URL}/public?orderBy=${sortOrder}`); + + if (response.ok) { + publicCharacters = await response.json(); + renderCharacterList(publicCharacters, publicCharacterList, false); + } else { + console.error('공개 캐릭터 로드 실패:', response.status); + } + } catch (error) { + console.error('공개 캐릭터 로드 오류:', error); + } +} + +// 캐릭터 리스트 렌더링 +function renderCharacterList(characters, container, isMyCharacters = false) { + if (!container) return; + + if (characters.length === 0) { + container.innerHTML = ` +
+

${isMyCharacters ? '아직 만든 캐릭터가 없습니다' : '공개 캐릭터가 없습니다'}

+

${isMyCharacters ? '새 캐릭터를 만들어보세요!' : '나중에 다시 확인해주세요.'}

+
+ `; + return; + } + + container.innerHTML = characters.map(character => { + const isSystem = !character.created_by_user_id; + const isOwner = character.created_by_user_id === userId; + const badgeClass = isSystem ? 'badge-system' : + character.is_public ? 'badge-public' : 'badge-private'; + const badgeText = isSystem ? 'SYSTEM' : + character.is_public ? 'PUBLIC' : 'PRIVATE'; + + const createdDate = new Date(character.created_at || character.createdAt || Date.now()); + const dateStr = createdDate.toLocaleDateString('ko-KR'); + + return ` +
+
+

${character.name}

+
+ ${badgeText} +
+
+
${character.description || '설명이 없습니다.'}
+
+ + ${isSystem ? '시스템' : character.created_by_username || 'Unknown'} + + ${dateStr} +
+
+ `; + }).join(''); +} + +// 캐릭터 선택 +function selectCharacter(characterId, characterName) { + selectedCharacterId = characterId; + + // 채팅 탭으로 전환 + switchTab('chat'); + + // 캐릭터 드롭다운 업데이트 + characterSelect.value = characterId; + + // 채팅 로그 초기화 + chatLog.innerHTML = ""; + appendLog(`[캐릭터 선택됨: ${characterName}]`); + + // 캐릭터 카드 선택 상태 업데이트 + updateCharacterCardSelection(); +} + +// 캐릭터 카드 선택 상태 업데이트 +function updateCharacterCardSelection() { + const allCards = document.querySelectorAll('.character-card'); + allCards.forEach(card => { + card.classList.remove('selected'); + }); + + const selectedCards = document.querySelectorAll(`[onclick*="${selectedCharacterId}"]`); + selectedCards.forEach(card => { + card.classList.add('selected'); + }); +} + +// 채팅용 캐릭터 드롭다운 업데이트 +function updateCharacterSelect() { + if (!characterSelect) return; + + // 기존 옵션 제거 + characterSelect.innerHTML = ''; + + // 내 캐릭터 추가 + if (myCharacters.length > 0) { + const myGroup = document.createElement('optgroup'); + myGroup.label = '내 캐릭터'; + myCharacters.forEach(character => { + const option = document.createElement('option'); + option.value = character.id; + option.textContent = character.name; + myGroup.appendChild(option); + }); + characterSelect.appendChild(myGroup); + } + + // 공개 캐릭터 추가 (내 캐릭터가 아닌 것만) + const publicNotMine = publicCharacters.filter(char => + char.created_by_user_id !== userId + ); + + if (publicNotMine.length > 0) { + const publicGroup = document.createElement('optgroup'); + publicGroup.label = '공개 캐릭터'; + publicNotMine.forEach(character => { + const option = document.createElement('option'); + option.value = character.id; + option.textContent = character.name + + (character.created_by_user_id ? '' : ' (시스템)'); + publicGroup.appendChild(option); + }); + characterSelect.appendChild(publicGroup); + } +} + +// 캐릭터 생성 폼 리셋 +function resetCreateForm() { + createNameInput.value = ''; + createDescriptionInput.value = ''; + createImageUrlInput.value = ''; + createVoiceIdInput.value = ''; + createIsPublicCheckbox.checked = true; + + // 개별 설정 폼 리셋 + configRole.value = ''; + configPersonality.value = ''; + configSpeechStyle.value = ''; + configUserAlias.value = ''; + configSummary.value = ''; + configBackground.value = ''; + + // 시스템 프롬프트 폼 리셋 + configSystemPrompt.value = ''; + updatePromptCharCount(); + + // 설정 모드를 개별 설정으로 리셋 + switchConfigMode('individual'); +} + +// 프롬프트 글자 수 업데이트 +function updatePromptCharCount() { + const count = configSystemPrompt.value.length; + promptCharCount.textContent = count; + + const counter = promptCharCount.parentElement; + counter.classList.remove('warning', 'error'); + + if (count > 4500) { + counter.classList.add('error'); + } else if (count > 4000) { + counter.classList.add('warning'); + } +} + +// 캐릭터 생성 +async function createCharacter() { + if (!authToken) { + alert('로그인이 필요합니다.'); + return; + } + + // 폼 검증 + const name = createNameInput.value.trim(); + if (!name) { + alert('캐릭터 이름을 입력하세요.'); + createNameInput.focus(); + return; + } + + if (name.length > 100) { + alert('캐릭터 이름은 100자를 초과할 수 없습니다.'); + createNameInput.focus(); + return; + } + + const description = createDescriptionInput.value.trim(); + if (description.length > 1000) { + alert('설명은 1000자를 초과할 수 없습니다.'); + createDescriptionInput.focus(); + return; + } + + createCharacterBtn.disabled = true; + createCharacterBtn.textContent = '생성 중...'; + + try { + let payload = { + name: name, + description: description, + image_url: createImageUrlInput.value.trim(), + voice_id: createVoiceIdInput.value.trim(), + is_public: createIsPublicCheckbox.checked + }; + + let endpoint, contentType = 'application/json'; + + if (currentConfigMode === 'individual') { + // 개별 설정 모드 + endpoint = `${CHARACTER_BASE_URL}/individual`; + payload.individual_config = { + role: configRole.value.trim(), + personality: configPersonality.value.trim(), + speech_style: configSpeechStyle.value.trim(), + user_alias: configUserAlias.value.trim(), + summary: configSummary.value.trim(), + background: configBackground.value.trim() + }; + } else { + // 시스템 프롬프트 모드 + endpoint = `${CHARACTER_BASE_URL}/systemprompt`; + const systemPrompt = configSystemPrompt.value.trim(); + + if (!systemPrompt) { + alert('시스템 프롬프트를 입력하세요.'); + configSystemPrompt.focus(); + return; + } + + if (systemPrompt.length > 5000) { + alert('시스템 프롬프트는 5000자를 초과할 수 없습니다.'); + configSystemPrompt.focus(); + return; + } + + payload.system_prompt = systemPrompt; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': contentType, + 'Authorization': `Bearer ${authToken}` + }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + const result = await response.json(); + appendLog(`[캐릭터 생성 성공] ${result.name}`); + + // 폼 리셋 + resetCreateForm(); + + // 캐릭터 목록 새로고침 + await loadCharacters(); + + // 내 캐릭터 탭으로 이동 + switchCharacterTab('list'); + + } else { + const errorData = await response.json(); + throw new Error(errorData.message || `HTTP ${response.status}`); + } + + } catch (error) { + console.error('캐릭터 생성 실패:', error); + alert(`캐릭터 생성 실패: ${error.message}`); + } finally { + createCharacterBtn.disabled = false; + createCharacterBtn.textContent = '캐릭터 생성'; + } +} + +// ========== 이벤트 리스너 설정 ========== + +// 탭 전환 이벤트 +tabButtons.forEach(btn => { + btn.addEventListener('click', () => { + switchTab(btn.dataset.tab); + }); +}); + +// 캐릭터 탭 전환 이벤트 +characterTabButtons.forEach(btn => { + btn.addEventListener('click', () => { + switchCharacterTab(btn.dataset.tab); + }); +}); + +// 설정 모드 탭 전환 이벤트 +configTabs.forEach(tab => { + tab.addEventListener('click', () => { + switchConfigMode(tab.dataset.mode); + }); +}); + +// 캐릭터 새로고침 버튼 +if (refreshCharactersBtn) { + refreshCharactersBtn.addEventListener('click', loadCharacters); +} + +// 정렬 순서 변경 이벤트 +if (mySortOrder) { + mySortOrder.addEventListener('change', loadMyCharacters); +} + +if (publicSortOrder) { + publicSortOrder.addEventListener('change', loadPublicCharacters); +} + +// 캐릭터 생성 폼 이벤트 +if (createCharacterBtn) { + createCharacterBtn.addEventListener('click', createCharacter); +} + +if (resetFormBtn) { + resetFormBtn.addEventListener('click', resetCreateForm); +} + +// 프롬프트 글자 수 카운터 +if (configSystemPrompt) { + configSystemPrompt.addEventListener('input', updatePromptCharCount); +} + // 초기화 - 로그인을 기다림 appendLog('[시작] 게스트 ID를 입력하고 로그인하세요.'); \ No newline at end of file diff --git a/test-clients/ai-chat-client/styles.css b/test-clients/ai-chat-client/styles.css index 3bd9155..30ca63d 100644 --- a/test-clients/ai-chat-client/styles.css +++ b/test-clients/ai-chat-client/styles.css @@ -142,4 +142,453 @@ body { #audio-player { display: none; +} + +/* 탭 네비게이션 */ +#tab-navigation { + margin-bottom: 1em; +} + +.tab-buttons { + display: flex; + gap: 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid #c8e6c9; +} + +.tab-btn { + flex: 1; + padding: 0.75em 1em; + border: none; + background: #f5f5f5; + color: #666; + cursor: pointer; + font-size: 1em; + font-weight: 500; + transition: all 0.2s ease; + border-right: 1px solid #c8e6c9; +} + +.tab-btn:last-child { + border-right: none; +} + +.tab-btn.active { + background: #4caf50; + color: white; +} + +.tab-btn:hover:not(.active) { + background: #e8f5e8; + color: #2e7d32; +} + +.tab-content { + display: flex; + flex-direction: column; + gap: 1em; +} + +/* 새로고침 버튼 */ +.refresh-btn { + background: #ff9800; + color: white; + border: none; + padding: 0.6em 1em; + border-radius: 8px; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + transition: all 0.3s ease; +} + +.refresh-btn:hover { + background: #f57c00; + transform: translateY(-1px); +} + +/* 캐릭터 관리 탭 */ +.character-tabs { + display: flex; + gap: 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid #c8e6c9; + margin-bottom: 1em; +} + +.character-tab-btn { + flex: 1; + padding: 0.6em 0.8em; + border: none; + background: #f5f5f5; + color: #666; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + transition: all 0.2s ease; + border-right: 1px solid #c8e6c9; +} + +.character-tab-btn:last-child { + border-right: none; +} + +.character-tab-btn.active { + background: #2196f3; + color: white; +} + +.character-tab-btn:hover:not(.active) { + background: #e3f2fd; + color: #1976d2; +} + +/* 캐릭터 리스트 헤더 */ +.character-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1em; +} + +.character-list-header h3 { + margin: 0; + color: #37474f; + font-size: 1.2em; +} + +.character-list-header select { + padding: 0.5em; + border: 1px solid #c8e6c9; + border-radius: 6px; + background: white; + font-size: 0.9em; +} + +/* 캐릭터 그리드 */ +.character-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1em; + max-height: 400px; + overflow-y: auto; +} + +.character-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1em; + background: white; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.character-card:hover { + border-color: #4caf50; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.character-card.selected { + border-color: #4caf50; + background: #f3fdf4; + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); +} + +.character-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5em; +} + +.character-name { + font-weight: 600; + font-size: 1.1em; + color: #37474f; + margin: 0; +} + +.character-badges { + display: flex; + gap: 0.3em; +} + +.character-badge { + padding: 0.2em 0.4em; + border-radius: 12px; + font-size: 0.7em; + font-weight: 500; + text-transform: uppercase; +} + +.badge-public { + background: #e8f5e8; + color: #2e7d32; +} + +.badge-private { + background: #fff3e0; + color: #ef6c00; +} + +.badge-system { + background: #e3f2fd; + color: #1976d2; +} + +.character-description { + color: #666; + font-size: 0.9em; + line-height: 1.4; + margin-bottom: 0.5em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.character-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.8em; + color: #999; + margin-top: 0.5em; + padding-top: 0.5em; + border-top: 1px solid #f0f0f0; +} + +.character-creator { + font-weight: 500; +} + +.character-date { + font-style: italic; +} + +/* 캐릭터 생성 폼 */ +.create-form { + max-width: 600px; + margin: 0 auto; +} + +.create-form h3 { + margin: 0 0 1.5em 0; + color: #37474f; + font-size: 1.3em; +} + +.form-group { + margin-bottom: 1.2em; +} + +.form-group label { + display: block; + margin-bottom: 0.5em; + font-weight: 600; + color: #37474f; + font-size: 0.95em; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75em; + border: 1px solid #c8e6c9; + border-radius: 8px; + font-size: 1em; + background: white; + color: #37474f; + transition: all 0.3s ease; + resize: vertical; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #4caf50; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); + transform: translateY(-1px); +} + +.form-group textarea { + min-height: 80px; +} + +.checkbox-label { + display: flex !important; + align-items: center; + gap: 0.5em; + cursor: pointer; + font-weight: 500 !important; +} + +.checkbox-label input[type="checkbox"] { + width: auto; + margin: 0; +} + +/* 설정 모드 탭 */ +.config-mode-tabs { + display: flex; + gap: 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid #c8e6c9; + margin-top: 0.5em; +} + +.config-tab { + flex: 1; + padding: 0.6em 1em; + border: none; + background: #f5f5f5; + color: #666; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + transition: all 0.2s ease; + border-right: 1px solid #c8e6c9; +} + +.config-tab:last-child { + border-right: none; +} + +.config-tab.active { + background: #9c27b0; + color: white; +} + +.config-tab:hover:not(.active) { + background: #f3e5f5; + color: #7b1fa2; +} + +.config-form { + margin-top: 1em; + padding: 1em; + border: 1px solid #e0e0e0; + border-radius: 8px; + background: #fafafa; +} + +.char-counter { + text-align: right; + font-size: 0.8em; + color: #666; + margin-top: 0.3em; +} + +.char-counter.warning { + color: #ff9800; +} + +.char-counter.error { + color: #f44336; +} + +/* 폼 액션 버튼 */ +.form-actions { + display: flex; + gap: 1em; + justify-content: center; + margin-top: 2em; + padding-top: 1em; + border-top: 1px solid #e0e0e0; +} + +.create-btn { + background: #4caf50; + color: white; + border: none; + padding: 0.8em 2em; + border-radius: 8px; + font-size: 1em; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; +} + +.create-btn:hover { + background: #45a049; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); +} + +.create-btn:disabled { + background: #cccccc; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.reset-btn { + background: #f5f5f5; + color: #666; + border: 1px solid #ddd; + padding: 0.8em 1.5em; + border-radius: 8px; + font-size: 1em; + cursor: pointer; + font-weight: 500; + transition: all 0.3s ease; +} + +.reset-btn:hover { + background: #eeeeee; + color: #333; + border-color: #ccc; +} + +/* 로딩 및 상태 표시 */ +.loading { + display: flex; + justify-content: center; + align-items: center; + padding: 2em; + color: #666; + font-style: italic; +} + +.empty-state { + text-align: center; + padding: 2em; + color: #999; +} + +.empty-state h4 { + margin: 0 0 0.5em 0; + font-size: 1.1em; +} + +.empty-state p { + margin: 0; + font-size: 0.9em; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .character-grid { + grid-template-columns: 1fr; + } + + .tab-btn, + .character-tab-btn { + font-size: 0.8em; + padding: 0.6em 0.5em; + } + + .form-actions { + flex-direction: column; + } + + .create-btn, + .reset-btn { + width: 100%; + } } \ No newline at end of file From 09866387e88595b3e78afcd324af79220132f969 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 09:32:14 +0900 Subject: [PATCH 12/20] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B3=A0=EB=8F=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ConversationController.cs | 203 ++++++++++++ .../Request/GetConversationHistoryRequest.cs | 19 ++ .../ConversationHistoryListResponse.cs | 40 +++ .../Response/ConversationHistoryResponse.cs | 40 +++ .../Chat/Processors/ChatResultProcessor.cs | 4 +- .../Conversation/ConversationService.cs | 55 +++- .../Conversation/IConversationService.cs | 33 +- .../Entities/ConversationHistory/ChatRole.cs | 28 +- .../ConversationHistory.cs | 22 +- ...pdateConversationHistorySchema.Designer.cs | 305 ++++++++++++++++++ ...3002450_UpdateConversationHistorySchema.cs | 165 ++++++++++ .../ProjectVGDbContextModelSnapshot.cs | 40 +-- .../EfCore/Data/ProjectVGDbContext.cs | 9 +- .../Conversation/IConversationRepository.cs | 29 +- .../SqlServerConversationRepository.cs | 50 ++- .../ConversationServiceIntegrationTests.cs | 12 +- .../Conversation/ConversationServiceTests.cs | 37 ++- .../TestUtilities/TestDataBuilder.cs | 8 +- 18 files changed, 992 insertions(+), 107 deletions(-) create mode 100644 ProjectVG.Api/Controllers/ConversationController.cs create mode 100644 ProjectVG.Api/Models/Conversation/Request/GetConversationHistoryRequest.cs create mode 100644 ProjectVG.Api/Models/Conversation/Response/ConversationHistoryListResponse.cs create mode 100644 ProjectVG.Api/Models/Conversation/Response/ConversationHistoryResponse.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.Designer.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.cs diff --git a/ProjectVG.Api/Controllers/ConversationController.cs b/ProjectVG.Api/Controllers/ConversationController.cs new file mode 100644 index 0000000..ee82db1 --- /dev/null +++ b/ProjectVG.Api/Controllers/ConversationController.cs @@ -0,0 +1,203 @@ +using Microsoft.AspNetCore.Mvc; +using ProjectVG.Api.Models.Conversation.Request; +using ProjectVG.Api.Models.Conversation.Response; +using ProjectVG.Application.Services.Conversation; +using System.Security.Claims; + +namespace ProjectVG.Api.Controllers +{ + [ApiController] + [Route("api/v1/conversation")] + public class ConversationController : ControllerBase + { + private readonly IConversationService _conversationService; + private readonly ILogger _logger; + + public ConversationController(IConversationService conversationService, ILogger logger) + { + _conversationService = conversationService; + _logger = logger; + } + + /// + /// 특정 캐릭터와의 대화 기록을 조회합니다 + /// + /// 캐릭터 ID + /// 페이지네이션 요청 + /// 대화 기록 목록 + [HttpGet("{characterId}")] + [JwtAuthentication] + public async Task GetConversationHistory(Guid characterId, [FromQuery] GetConversationHistoryRequest request) + { + try + { + // JWT 토큰에서 사용자 ID 추출 + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var userGuid)) + { + throw new ValidationException(ErrorCode.AUTHENTICATION_FAILED); + } + + // 대화 기록 조회 + var messages = await _conversationService.GetConversationHistoryAsync(userGuid, characterId, request.Page, request.PageSize); + var totalCount = await _conversationService.GetMessageCountAsync(userGuid, characterId); + + // 응답 매핑 + var response = new ConversationHistoryListResponse + { + Messages = messages.Select(m => new ConversationHistoryResponse + { + Id = m.Id, + CharacterId = m.CharacterId, + Role = m.Role, + Content = m.Content, + Timestamp = m.Timestamp, + ConversationId = m.ConversationId, + CreatedAt = m.CreatedAt + }), + TotalCount = totalCount, + CurrentPage = request.Page, + PageSize = request.PageSize, + TotalPages = (int)Math.Ceiling((double)totalCount / request.PageSize), + HasNextPage = request.Page < (int)Math.Ceiling((double)totalCount / request.PageSize), + HasPreviousPage = request.Page > 1 + }; + + return Ok(response); + } + catch (ValidationException ex) + { + _logger.LogWarning(ex, "Validation failed for conversation history request"); + return BadRequest(new { error = ex.ErrorCode, message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving conversation history for character {CharacterId}", characterId); + return StatusCode(500, new { error = "INTERNAL_ERROR", message = "An error occurred while retrieving conversation history" }); + } + } + + /// + /// 특정 캐릭터와의 대화 기록을 삭제합니다 + /// + /// 캐릭터 ID + /// 삭제 결과 + [HttpDelete("{characterId}")] + [JwtAuthentication] + public async Task DeleteConversationHistory(Guid characterId) + { + try + { + // JWT 토큰에서 사용자 ID 추출 + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var userGuid)) + { + throw new ValidationException(ErrorCode.AUTHENTICATION_FAILED); + } + + // 대화 기록 삭제 + await _conversationService.DeleteConversationAsync(userGuid, characterId); + + return Ok(new { message = "Conversation history deleted successfully" }); + } + catch (ValidationException ex) + { + _logger.LogWarning(ex, "Validation failed for delete conversation request"); + return BadRequest(new { error = ex.ErrorCode, message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting conversation history for character {CharacterId}", characterId); + return StatusCode(500, new { error = "INTERNAL_ERROR", message = "An error occurred while deleting conversation history" }); + } + } + + /// + /// 특정 대화 세션의 메시지들을 조회합니다 + /// + /// 대화 세션 ID + /// 대화 세션 메시지 목록 + [HttpGet("session/{conversationId}")] + [JwtAuthentication] + public async Task GetConversationBySessionId(string conversationId) + { + try + { + // JWT 토큰에서 사용자 ID 추출 + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var userGuid)) + { + throw new ValidationException(ErrorCode.AUTHENTICATION_FAILED); + } + + // 대화 세션 조회 + var messages = await _conversationService.GetByConversationIdAsync(conversationId); + + // 사용자 권한 확인 (첫 번째 메시지의 사용자 ID 확인) + if (messages.Any() && !messages.Any(m => m.UserId == userGuid)) + { + return Forbid(); + } + + // 응답 매핑 + var response = messages.Select(m => new ConversationHistoryResponse + { + Id = m.Id, + CharacterId = m.CharacterId, + Role = m.Role, + Content = m.Content, + Timestamp = m.Timestamp, + ConversationId = m.ConversationId, + CreatedAt = m.CreatedAt + }); + + return Ok(response); + } + catch (ValidationException ex) + { + _logger.LogWarning(ex, "Validation failed for conversation session request"); + return BadRequest(new { error = ex.ErrorCode, message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving conversation session {ConversationId}", conversationId); + return StatusCode(500, new { error = "INTERNAL_ERROR", message = "An error occurred while retrieving conversation session" }); + } + } + + /// + /// 특정 메시지를 삭제합니다 + /// + /// 메시지 ID + /// 삭제 결과 + [HttpDelete("message/{messageId}")] + [JwtAuthentication] + public async Task DeleteMessage(Guid messageId) + { + try + { + // JWT 토큰에서 사용자 ID 추출 + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var userGuid)) + { + throw new ValidationException(ErrorCode.AUTHENTICATION_FAILED); + } + + // 메시지 삭제 (권한 확인 포함) + await _conversationService.DeleteMessageAsync(messageId, userGuid); + + return Ok(new { message = "Message deleted successfully" }); + } + catch (ValidationException ex) + { + _logger.LogWarning(ex, "Validation failed for delete message request"); + return BadRequest(new { error = ex.ErrorCode, message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting message {MessageId}", messageId); + return StatusCode(500, new { error = "INTERNAL_ERROR", message = "An error occurred while deleting message" }); + } + } + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Conversation/Request/GetConversationHistoryRequest.cs b/ProjectVG.Api/Models/Conversation/Request/GetConversationHistoryRequest.cs new file mode 100644 index 0000000..4892e7b --- /dev/null +++ b/ProjectVG.Api/Models/Conversation/Request/GetConversationHistoryRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace ProjectVG.Api.Models.Conversation.Request +{ + public class GetConversationHistoryRequest + { + /// + /// 페이지 번호 (1부터 시작) + /// + [Range(1, int.MaxValue, ErrorMessage = "Page must be greater than 0")] + public int Page { get; set; } = 1; + + /// + /// 페이지 크기 + /// + [Range(1, 100, ErrorMessage = "PageSize must be between 1 and 100")] + public int PageSize { get; set; } = 10; + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Conversation/Response/ConversationHistoryListResponse.cs b/ProjectVG.Api/Models/Conversation/Response/ConversationHistoryListResponse.cs new file mode 100644 index 0000000..56573d8 --- /dev/null +++ b/ProjectVG.Api/Models/Conversation/Response/ConversationHistoryListResponse.cs @@ -0,0 +1,40 @@ +namespace ProjectVG.Api.Models.Conversation.Response +{ + public class ConversationHistoryListResponse + { + /// + /// 대화 기록 목록 + /// + public IEnumerable Messages { get; set; } = new List(); + + /// + /// 총 메시지 수 + /// + public int TotalCount { get; set; } + + /// + /// 현재 페이지 + /// + public int CurrentPage { get; set; } + + /// + /// 페이지 크기 + /// + public int PageSize { get; set; } + + /// + /// 총 페이지 수 + /// + public int TotalPages { get; set; } + + /// + /// 다음 페이지 존재 여부 + /// + public bool HasNextPage { get; set; } + + /// + /// 이전 페이지 존재 여부 + /// + public bool HasPreviousPage { get; set; } + } +} \ No newline at end of file diff --git a/ProjectVG.Api/Models/Conversation/Response/ConversationHistoryResponse.cs b/ProjectVG.Api/Models/Conversation/Response/ConversationHistoryResponse.cs new file mode 100644 index 0000000..4f9fc40 --- /dev/null +++ b/ProjectVG.Api/Models/Conversation/Response/ConversationHistoryResponse.cs @@ -0,0 +1,40 @@ +namespace ProjectVG.Api.Models.Conversation.Response +{ + public class ConversationHistoryResponse + { + /// + /// 메시지 ID + /// + public Guid Id { get; set; } + + /// + /// 캐릭터 ID + /// + public Guid CharacterId { get; set; } + + /// + /// 메시지 역할 (user, assistant, system) + /// + public string Role { get; set; } = string.Empty; + + /// + /// 메시지 내용 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 메시지 생성 시각 + /// + public DateTime Timestamp { get; set; } + + /// + /// 대화 세션 ID (선택사항) + /// + public string? ConversationId { get; set; } + + /// + /// 메시지 생성일시 + /// + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs b/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs index c061bb4..a83e0bb 100644 --- a/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs +++ b/ProjectVG.Application/Services/Chat/Processors/ChatResultProcessor.cs @@ -29,8 +29,8 @@ public ChatResultProcessor( public async Task PersistResultsAsync(ChatProcessContext context) { - await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.User, context.UserMessage); - await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.Assistant, context.Response); + await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.User, context.UserMessage, context.UserRequestAt, context.RequestId.ToString()); + await _conversationService.AddMessageAsync(context.UserId, context.CharacterId, ChatRole.Assistant, context.Response, DateTime.UtcNow, context.RequestId.ToString()); await PersistMemoryAsync(context); _logger.LogDebug("채팅 결과 저장 완료: 세션 {UserId}, 사용자 {UserId}", context.RequestId, context.UserId); diff --git a/ProjectVG.Application/Services/Conversation/ConversationService.cs b/ProjectVG.Application/Services/Conversation/ConversationService.cs index 628fd69..169ba3c 100644 --- a/ProjectVG.Application/Services/Conversation/ConversationService.cs +++ b/ProjectVG.Application/Services/Conversation/ConversationService.cs @@ -15,41 +15,56 @@ public ConversationService(IConversationRepository conversationRepository, ILogg _logger = logger; } - public async Task AddMessageAsync(Guid userId, Guid characterId, ChatRole role, string content) + public async Task AddMessageAsync(Guid userId, Guid characterId, string role, string content, DateTime timestamp, string? conversationId = null) { - if (string.IsNullOrWhiteSpace(content)) { + if (string.IsNullOrWhiteSpace(content)) + { throw new ValidationException(ErrorCode.MESSAGE_EMPTY, content); } - if (content.Length > 1000) { + if (content.Length > 10000) // 메시지 길이 제한 확장 + { throw new ValidationException(ErrorCode.MESSAGE_TOO_LONG, content.Length); } - var message = new ConversationHistory { + if (!ChatRole.IsValid(role)) + { + throw new ValidationException(ErrorCode.VALIDATION_FAILED, $"Invalid role: {role}"); + } + + var message = new ConversationHistory + { UserId = userId, CharacterId = characterId, Role = role, Content = content, - CreatedAt = DateTime.UtcNow + Timestamp = timestamp, // 사용자가 요청한 실제 시간 사용 + ConversationId = conversationId }; var addedMessage = await _conversationRepository.AddAsync(message); return addedMessage; } - public async Task> GetConversationHistoryAsync(Guid userId, Guid characterId, int count = 10) + public async Task> GetConversationHistoryAsync(Guid userId, Guid characterId, int page = 1, int pageSize = 10) { - if (count <= 0 || count > 100) { - throw new ValidationException(ErrorCode.VALIDATION_FAILED, count); + if (page <= 0) + { + throw new ValidationException(ErrorCode.VALIDATION_FAILED, $"Page must be greater than 0, but was: {page}"); + } + + if (pageSize <= 0 || pageSize > 100) + { + throw new ValidationException(ErrorCode.VALIDATION_FAILED, $"PageSize must be between 1 and 100, but was: {pageSize}"); } - var history = await _conversationRepository.GetByUserIdAsync(userId, characterId, count); + var history = await _conversationRepository.GetConversationHistoryAsync(userId, characterId, page, pageSize); return history; } - public async Task ClearConversationAsync(Guid userId, Guid characterId) + public async Task DeleteConversationAsync(Guid userId, Guid characterId) { - await _conversationRepository.ClearSessionAsync(userId, characterId); + await _conversationRepository.DeleteConversationAsync(userId, characterId); } public async Task GetMessageCountAsync(Guid userId, Guid characterId) @@ -57,5 +72,23 @@ public async Task GetMessageCountAsync(Guid userId, Guid characterId) var count = await _conversationRepository.GetMessageCountAsync(userId, characterId); return count; } + + public async Task> GetByConversationIdAsync(string conversationId) + { + if (string.IsNullOrWhiteSpace(conversationId)) + { + throw new ValidationException(ErrorCode.VALIDATION_FAILED, "ConversationId cannot be empty"); + } + + return await _conversationRepository.GetByConversationIdAsync(conversationId); + } + + public async Task DeleteMessageAsync(Guid messageId, Guid userId) + { + // TODO: 사용자 권한 확인 로직 추가 + // 메시지의 UserId가 현재 사용자와 일치하는지 확인 + + await _conversationRepository.DeleteMessageAsync(messageId); + } } } diff --git a/ProjectVG.Application/Services/Conversation/IConversationService.cs b/ProjectVG.Application/Services/Conversation/IConversationService.cs index 0038404..797af76 100644 --- a/ProjectVG.Application/Services/Conversation/IConversationService.cs +++ b/ProjectVG.Application/Services/Conversation/IConversationService.cs @@ -9,33 +9,50 @@ public interface IConversationService ///
/// 사용자 ID /// 캐릭터 ID - /// 메시지 역할 + /// 메시지 역할 (user, assistant, system) /// 메시지 내용 + /// 사용자 요청 시각 + /// 대화 세션 ID (선택사항) /// 추가된 대화 메시지 - Task AddMessageAsync(Guid userId, Guid characterId, ChatRole role, string content); + Task AddMessageAsync(Guid userId, Guid characterId, string role, string content, DateTime timestamp, string? conversationId = null); /// - /// 세션의 대화 기록을 조회합니다 + /// 대화 기록을 페이지네이션으로 조회합니다 /// /// 사용자 ID /// 캐릭터 ID - /// 조회할 메시지 수 + /// 페이지 번호 (1부터 시작) + /// 페이지 크기 /// 대화 기록 목록 - Task> GetConversationHistoryAsync(Guid userId, Guid characterId, int count = 10); + Task> GetConversationHistoryAsync(Guid userId, Guid characterId, int page = 1, int pageSize = 10); /// - /// 세션의 대화 기록을 삭제합니다 + /// 대화 기록을 완전 삭제합니다 /// /// 사용자 ID /// 캐릭터 ID - Task ClearConversationAsync(Guid userId, Guid characterId); + Task DeleteConversationAsync(Guid userId, Guid characterId); /// - /// 세션의 메시지 수를 조회합니다 + /// 총 메시지 수를 조회합니다 /// /// 사용자 ID /// 캐릭터 ID /// 메시지 수 Task GetMessageCountAsync(Guid userId, Guid characterId); + + /// + /// 특정 대화 세션의 메시지들을 조회합니다 + /// + /// 대화 세션 ID + /// 대화 기록 목록 + Task> GetByConversationIdAsync(string conversationId); + + /// + /// 특정 메시지를 삭제합니다 + /// + /// 메시지 ID + /// 사용자 ID (권한 확인용) + Task DeleteMessageAsync(Guid messageId, Guid userId); } } \ No newline at end of file diff --git a/ProjectVG.Domain/Entities/ConversationHistory/ChatRole.cs b/ProjectVG.Domain/Entities/ConversationHistory/ChatRole.cs index 0af3571..9c84862 100644 --- a/ProjectVG.Domain/Entities/ConversationHistory/ChatRole.cs +++ b/ProjectVG.Domain/Entities/ConversationHistory/ChatRole.cs @@ -1,23 +1,41 @@ namespace ProjectVG.Domain.Entities.ConversationHistorys { /// - /// 채팅 역할 + /// 채팅 역할 (소문자 문자열로 저장) /// - public enum ChatRole + public static class ChatRole { /// /// 사용자 /// - User, + public const string User = "user"; /// /// AI 어시스턴트 /// - Assistant, + public const string Assistant = "assistant"; /// /// 시스템 /// - System + public const string System = "system"; + + /// + /// 유효한 역할인지 검증 + /// + public static bool IsValid(string role) + { + return role == User || role == Assistant || role == System; + } + + /// + /// 모든 유효한 역할 반환 + /// + public static IEnumerable GetAll() + { + yield return User; + yield return Assistant; + yield return System; + } } } diff --git a/ProjectVG.Domain/Entities/ConversationHistory/ConversationHistory.cs b/ProjectVG.Domain/Entities/ConversationHistory/ConversationHistory.cs index 765fb06..8f6d6ed 100644 --- a/ProjectVG.Domain/Entities/ConversationHistory/ConversationHistory.cs +++ b/ProjectVG.Domain/Entities/ConversationHistory/ConversationHistory.cs @@ -8,8 +8,8 @@ namespace ProjectVG.Domain.Entities.ConversationHistorys /// /// 대화 관리: /// - 사용자와 AI 캐릭터 간의 대화 기록 - /// - 역할별 메시지 구분 (User, Assistant, System) - /// - 메타데이터를 통한 추가 정보 저장 + /// - 역할별 메시지 구분 (user, assistant, system) + /// - 실제 사용자 요청 시간 기록 ///
public class ConversationHistory : BaseEntity { @@ -29,9 +29,9 @@ public class ConversationHistory : BaseEntity public Guid UserId { get; set; } /// - /// 채팅 역할 (User, Assistant, System) + /// 채팅 역할 (user, assistant, system - 소문자 문자열) /// - public ChatRole Role { get; set; } + public string Role { get; set; } = string.Empty; /// /// 대화 내용 @@ -39,18 +39,14 @@ public class ConversationHistory : BaseEntity public string Content { get; set; } = string.Empty; /// - /// 대화 발생 시각 + /// 사용자가 실제 요청한 시각 (서버 시간이 아닌 클라이언트 기준 시간) /// - public DateTime Timestamp { get; set; } = DateTime.UtcNow; + public DateTime Timestamp { get; set; } /// - /// 추가 메타데이터 (JSON으로 저장) + /// 대화 세션 ID (하나의 대화에서 여러 메시지를 그룹화하기 위한 ID, nullable) + /// OpenAI의 requestId 등을 저장 /// - public string MetadataJson { get; set; } = "{}"; - - /// - /// 삭제 여부 - /// - public bool IsDeleted { get; set; } = false; + public string? ConversationId { get; set; } } } \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.Designer.cs new file mode 100644 index 0000000..12a98c5 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.Designer.cs @@ -0,0 +1,305 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ProjectVG.Infrastructure.Persistence.EfCore; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + [DbContext(typeof(ProjectVGDbContext))] + [Migration("20250903002450_UpdateConversationHistorySchema")] + partial class UpdateConversationHistorySchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConfigMode") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IndividualConfig") + .HasColumnType("nvarchar(max)") + .HasColumnName("IndividualConfigJson"); + + b.Property("IndividualConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SystemPrompt") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConfigMode"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsPublic"); + + b.HasIndex("Name"); + + b.HasIndex("UserId"); + + b.ToTable("Characters", t => + { + t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); + + t.Property("IndividualConfigJson") + .HasColumnName("IndividualConfigJson1"); + }); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), + Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "하루", + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), + VoiceId = "haru" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), + Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "소피아", + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), + VoiceId = "sophia" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CharacterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ConversationId"); + + b.HasIndex("Role"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CharacterId", "Timestamp"); + + b.ToTable("ConversationHistories"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UID") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ProviderId"); + + b.HasIndex("UID") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), + Email = "test@test.com", + Provider = "test", + ProviderId = "test", + Status = 0, + UID = "TESTUSER001", + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), + Username = "testuser" + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), + Email = "zero@test.com", + Provider = "test", + ProviderId = "zero", + Status = 0, + UID = "ZEROUSER001", + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), + Username = "zerouser" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany("Characters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.HasOne("ProjectVG.Domain.Entities.Characters.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProjectVG.Domain.Entities.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Navigation("Characters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.cs b/ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.cs new file mode 100644 index 0000000..3244914 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250903002450_UpdateConversationHistorySchema.cs @@ -0,0 +1,165 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + /// + public partial class UpdateConversationHistorySchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "ConversationHistories"); + + migrationBuilder.DropColumn( + name: "MetadataJson", + table: "ConversationHistories"); + + migrationBuilder.AlterColumn( + name: "Role", + table: "ConversationHistories", + type: "nvarchar(20)", + maxLength: 20, + nullable: false, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AlterColumn( + name: "Content", + table: "ConversationHistories", + type: "nvarchar(max)", + maxLength: 10000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(4000)", + oldMaxLength: 4000); + + migrationBuilder.AddColumn( + name: "ConversationId", + table: "ConversationHistories", + type: "nvarchar(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933) }); + + migrationBuilder.CreateIndex( + name: "IX_ConversationHistories_ConversationId", + table: "ConversationHistories", + column: "ConversationId"); + + migrationBuilder.CreateIndex( + name: "IX_ConversationHistories_Role", + table: "ConversationHistories", + column: "Role"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ConversationHistories_ConversationId", + table: "ConversationHistories"); + + migrationBuilder.DropIndex( + name: "IX_ConversationHistories_Role", + table: "ConversationHistories"); + + migrationBuilder.DropColumn( + name: "ConversationId", + table: "ConversationHistories"); + + migrationBuilder.AlterColumn( + name: "Role", + table: "ConversationHistories", + type: "int", + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(20)", + oldMaxLength: 20); + + migrationBuilder.AlterColumn( + name: "Content", + table: "ConversationHistories", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldMaxLength: 10000); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "ConversationHistories", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MetadataJson", + table: "ConversationHistories", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: false, + defaultValue: ""); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7628), new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7629) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631) }); + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs index cdce37f..1ef865b 100644 --- a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs +++ b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs @@ -108,7 +108,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { Id = new Guid("11111111-1111-1111-1111-111111111111"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", @@ -116,14 +116,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) IsActive = true, IsPublic = true, Name = "하루", - UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7515), + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), VoiceId = "haru" }, new { Id = new Guid("22222222-2222-2222-2222-222222222222"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", @@ -131,7 +131,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) IsActive = true, IsPublic = true, Name = "소피아", - UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7613), + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), VoiceId = "sophia" }); }); @@ -147,22 +147,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Content") .IsRequired() - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); b.Property("CreatedAt") .HasColumnType("datetime2"); - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("MetadataJson") + b.Property("Role") .IsRequired() - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); - - b.Property("Role") - .HasColumnType("int"); + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); b.Property("Timestamp") .HasColumnType("datetime2"); @@ -177,6 +175,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CharacterId"); + b.HasIndex("ConversationId"); + + b.HasIndex("Role"); + b.HasIndex("Timestamp"); b.HasIndex("UserId"); @@ -242,25 +244,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7628), + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), Email = "test@test.com", Provider = "test", ProviderId = "test", Status = 0, UID = "TESTUSER001", - UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7629), + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), Username = "testuser" }, new { Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CreatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), + CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), Email = "zero@test.com", Provider = "test", ProviderId = "zero", Status = 0, UID = "ZEROUSER001", - UpdatedAt = new DateTime(2025, 9, 2, 4, 40, 34, 142, DateTimeKind.Utc).AddTicks(7631), + UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), Username = "zerouser" }); }); diff --git a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs index 57a0816..f4082c6 100644 --- a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs +++ b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs @@ -91,8 +91,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedOnAdd(); - entity.Property(e => e.Content).IsRequired().HasMaxLength(4000); - entity.Property(e => e.MetadataJson).HasMaxLength(4000); + entity.Property(e => e.Content).IsRequired().HasMaxLength(10000); + entity.Property(e => e.Role).IsRequired().HasMaxLength(20); + entity.Property(e => e.ConversationId).HasMaxLength(100); // 외래키 관계 설정 (UserId, CharacterId는 필수) entity.HasOne() @@ -107,11 +108,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - // 복합 인덱스: UserId + CharacterId + Timestamp + // 인덱스 설정 entity.HasIndex(e => new { e.UserId, e.CharacterId, e.Timestamp }); entity.HasIndex(e => e.UserId); entity.HasIndex(e => e.CharacterId); entity.HasIndex(e => e.Timestamp); + entity.HasIndex(e => e.ConversationId); + entity.HasIndex(e => e.Role); }); // 기본 데이터 삽입 diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/IConversationRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/IConversationRepository.cs index 3c66631..bacedec 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/IConversationRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/IConversationRepository.cs @@ -4,9 +4,34 @@ namespace ProjectVG.Infrastructure.Persistence.Repositories.Conversation { public interface IConversationRepository { - Task> GetByUserIdAsync(Guid userId, Guid characterId, int count = 10); + /// + /// 사용자와 캐릭터 간의 대화 기록을 최신순으로 조회 (페이지네이션 지원) + /// + Task> GetConversationHistoryAsync(Guid userId, Guid characterId, int page = 1, int pageSize = 10); + + /// + /// 대화 메시지 추가 + /// Task AddAsync(ConversationHistory conversationHistory); - Task ClearSessionAsync(Guid userId, Guid characterId); + + /// + /// 특정 사용자와 캐릭터의 대화 기록 완전 삭제 + /// + Task DeleteConversationAsync(Guid userId, Guid characterId); + + /// + /// 특정 사용자와 캐릭터의 총 메시지 수 조회 + /// Task GetMessageCountAsync(Guid userId, Guid characterId); + + /// + /// 대화 ID로 특정 대화 세션의 메시지들 조회 + /// + Task> GetByConversationIdAsync(string conversationId); + + /// + /// 특정 대화 메시지 단건 삭제 + /// + Task DeleteMessageAsync(Guid messageId); } } \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/SqlServerConversationRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/SqlServerConversationRepository.cs index 8fc460b..839978e 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/SqlServerConversationRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Conversation/SqlServerConversationRepository.cs @@ -16,13 +16,14 @@ public SqlServerConversationRepository(ProjectVGDbContext context, ILogger> GetByUserIdAsync(Guid userId, Guid characterId, int count = 10) + public async Task> GetConversationHistoryAsync(Guid userId, Guid characterId, int page = 1, int pageSize = 10) { var messages = await _context.ConversationHistories - .Where(ch => ch.UserId == userId && ch.CharacterId == characterId && !ch.IsDeleted) + .Where(ch => ch.UserId == userId && ch.CharacterId == characterId) .OrderByDescending(ch => ch.Timestamp) - .Take(count) - .OrderBy(ch => ch.Timestamp) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .OrderBy(ch => ch.Timestamp) // 최종적으로 시간순으로 정렬하여 반환 .ToListAsync(); return messages; @@ -30,11 +31,11 @@ public async Task> GetByUserIdAsync(Guid userId public async Task AddAsync(ConversationHistory message) { - message.Id = Guid.NewGuid(); + if (message.Id == Guid.Empty) + message.Id = Guid.NewGuid(); + message.CreatedAt = DateTime.UtcNow; message.UpdatedAt = DateTime.UtcNow; - message.Timestamp = DateTime.UtcNow; - message.IsDeleted = false; _context.ConversationHistories.Add(message); await _context.SaveChangesAsync(); @@ -42,24 +43,43 @@ public async Task AddAsync(ConversationHistory message) return message; } - public async Task ClearSessionAsync(Guid userId, Guid characterId) + public async Task DeleteConversationAsync(Guid userId, Guid characterId) { var messages = await _context.ConversationHistories - .Where(ch => ch.UserId == userId && ch.CharacterId == characterId && !ch.IsDeleted) + .Where(ch => ch.UserId == userId && ch.CharacterId == characterId) .ToListAsync(); - foreach (var message in messages) { - message.IsDeleted = true; - message.Update(); + if (messages.Any()) + { + _context.ConversationHistories.RemoveRange(messages); + await _context.SaveChangesAsync(); } - - await _context.SaveChangesAsync(); } public async Task GetMessageCountAsync(Guid userId, Guid characterId) { return await _context.ConversationHistories - .CountAsync(ch => ch.UserId == userId && ch.CharacterId == characterId && !ch.IsDeleted); + .CountAsync(ch => ch.UserId == userId && ch.CharacterId == characterId); + } + + public async Task> GetByConversationIdAsync(string conversationId) + { + return await _context.ConversationHistories + .Where(ch => ch.ConversationId == conversationId) + .OrderBy(ch => ch.Timestamp) + .ToListAsync(); + } + + public async Task DeleteMessageAsync(Guid messageId) + { + var message = await _context.ConversationHistories + .FirstOrDefaultAsync(ch => ch.Id == messageId); + + if (message != null) + { + _context.ConversationHistories.Remove(message); + await _context.SaveChangesAsync(); + } } } } diff --git a/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs b/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs index b6c867f..776ab68 100644 --- a/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs +++ b/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs @@ -38,7 +38,7 @@ public async Task AddMessageAsync_WithValidParameters_ShouldPersistMessage() var role = ChatRole.User; // Act - var addedMessage = await _conversationService.AddMessageAsync(userId, characterId, role, content); + var addedMessage = await _conversationService.AddMessageAsync(userId, characterId, role, content, DateTime.UtcNow); // Assert addedMessage.Should().NotBeNull(); @@ -51,10 +51,10 @@ public async Task AddMessageAsync_WithValidParameters_ShouldPersistMessage() } [Theory] - [InlineData(ChatRole.User)] - [InlineData(ChatRole.Assistant)] - [InlineData(ChatRole.System)] - public async Task AddMessageAsync_WithDifferentRoles_ShouldPersistCorrectly(ChatRole role) + [InlineData("user")] + [InlineData("assistant")] + [InlineData("system")] + public async Task AddMessageAsync_WithDifferentRoles_ShouldPersistCorrectly(string role) { // Arrange await _fixture.ClearDatabaseAsync(); @@ -62,7 +62,7 @@ public async Task AddMessageAsync_WithDifferentRoles_ShouldPersistCorrectly(Chat var content = $"Message from {role}"; // Act - var addedMessage = await _conversationService.AddMessageAsync(userId, characterId, role, content); + var addedMessage = await _conversationService.AddMessageAsync(userId, characterId, role, content, DateTime.UtcNow); // Assert addedMessage.Role.Should().Be(role); diff --git a/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs b/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs index 7e3e152..e590c54 100644 --- a/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs +++ b/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs @@ -35,7 +35,7 @@ public async Task AddMessageAsync_WithValidParameters_ShouldReturnAddedMessage() // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - var role = ChatRole.User; + var role = "user"; var content = "Hello, how are you?"; var expectedMessage = CreateTestConversationHistory(userId, characterId, role, content); @@ -44,7 +44,7 @@ public async Task AddMessageAsync_WithValidParameters_ShouldReturnAddedMessage() .ReturnsAsync(expectedMessage); // Act - var result = await _conversationService.AddMessageAsync(userId, characterId, role, content); + var result = await _conversationService.AddMessageAsync(userId, characterId, role, content, DateTime.UtcNow); // Assert result.Should().NotBeNull(); @@ -71,7 +71,7 @@ public async Task AddMessageAsync_WithNullOrWhitespaceContent_ShouldThrowValidat // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - var role = ChatRole.User; + var role = "user"; // Act & Assert var exception = await Assert.ThrowsAsync( @@ -88,7 +88,7 @@ public async Task AddMessageAsync_WithContentTooLong_ShouldThrowValidationExcept // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - var role = ChatRole.User; + var role = "user"; var longContent = new string('x', 1001); // Exceeds 1000 character limit // Act & Assert @@ -101,10 +101,10 @@ public async Task AddMessageAsync_WithContentTooLong_ShouldThrowValidationExcept } [Theory] - [InlineData(ChatRole.User)] - [InlineData(ChatRole.Assistant)] - [InlineData(ChatRole.System)] - public async Task AddMessageAsync_WithDifferentRoles_ShouldAddMessageCorrectly(ChatRole role) + [InlineData("user")] + [InlineData("assistant")] + [InlineData("system")] + public async Task AddMessageAsync_WithDifferentRoles_ShouldAddMessageCorrectly(string role) { // Arrange var userId = Guid.NewGuid(); @@ -117,7 +117,7 @@ public async Task AddMessageAsync_WithDifferentRoles_ShouldAddMessageCorrectly(C .ReturnsAsync(expectedMessage); // Act - var result = await _conversationService.AddMessageAsync(userId, characterId, role, content); + var result = await _conversationService.AddMessageAsync(userId, characterId, role, content, DateTime.UtcNow); // Assert result.Should().NotBeNull(); @@ -130,7 +130,7 @@ public async Task AddMessageAsync_WithMaxLengthContent_ShouldAddMessageSuccessfu // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - var role = ChatRole.User; + var role = "user"; var maxContent = new string('x', 1000); // Exactly 1000 characters var expectedMessage = CreateTestConversationHistory(userId, characterId, role, maxContent); @@ -160,9 +160,9 @@ public async Task GetConversationHistoryAsync_WithValidParameters_ShouldReturnCo var expectedHistory = new List { - CreateTestConversationHistory(userId, characterId, ChatRole.User, "Message 1"), - CreateTestConversationHistory(userId, characterId, ChatRole.Assistant, "Response 1"), - CreateTestConversationHistory(userId, characterId, ChatRole.User, "Message 2") + CreateTestConversationHistory(userId, characterId, "user", "Message 1"), + CreateTestConversationHistory(userId, characterId, "assistant", "Response 1"), + CreateTestConversationHistory(userId, characterId, "user", "Message 2") }; _mockConversationRepository.Setup(x => x.GetByUserIdAsync(userId, characterId, count)) @@ -363,7 +363,7 @@ public async Task AddMessageAsync_WhenRepositoryThrowsException_ShouldPropagateE // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - var role = ChatRole.User; + var role = "user"; var content = "Test message"; _mockConversationRepository.Setup(x => x.AddAsync(It.IsAny())) @@ -413,7 +413,7 @@ public async Task AddMessageAsync_WithSpecialCharacters_ShouldAddMessageSuccessf // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - var role = ChatRole.User; + var role = "user"; var content = "특수 문자 테스트: !@#$%^&*()_+{}[]|\\:;\"'<>?,./ 한글 테스트"; var expectedMessage = CreateTestConversationHistory(userId, characterId, role, content); @@ -422,7 +422,7 @@ public async Task AddMessageAsync_WithSpecialCharacters_ShouldAddMessageSuccessf .ReturnsAsync(expectedMessage); // Act - var result = await _conversationService.AddMessageAsync(userId, characterId, role, content); + var result = await _conversationService.AddMessageAsync(userId, characterId, role, content, DateTime.UtcNow); // Assert result.Should().NotBeNull(); @@ -436,7 +436,7 @@ public async Task AddMessageAsync_WithSpecialCharacters_ShouldAddMessageSuccessf private static ConversationHistory CreateTestConversationHistory( Guid userId, Guid characterId, - ChatRole role, + string role, string content, Guid? id = null) { @@ -449,8 +449,7 @@ private static ConversationHistory CreateTestConversationHistory( Content = content, CreatedAt = DateTime.UtcNow, Timestamp = DateTime.UtcNow, - MetadataJson = "{}", - IsDeleted = false + ConversationId = null }; } diff --git a/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs b/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs index 37b5cff..e04db48 100644 --- a/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs +++ b/ProjectVG.Tests/Application/TestUtilities/TestDataBuilder.cs @@ -253,10 +253,11 @@ public static UserCreateCommand CreateUserCreateCommand( public static ConversationHistory CreateConversationHistory( Guid? userId = null, Guid? characterId = null, - ChatRole role = ChatRole.User, + string role = "user", string content = "Test message", Guid? id = null, - DateTime? timestamp = null) + DateTime? timestamp = null, + string? conversationId = null) { return new ConversationHistory { @@ -267,8 +268,7 @@ public static ConversationHistory CreateConversationHistory( Content = content, CreatedAt = DateTime.UtcNow, Timestamp = timestamp ?? DateTime.UtcNow, - MetadataJson = "{}", - IsDeleted = false + ConversationId = conversationId }; } From 105857f81d1effd536920f2975b3d29af2128d93 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 09:41:32 +0900 Subject: [PATCH 13/20] =?UTF-8?q?feat:=20=EB=8D=94=EB=AF=B8=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-clients/ai-chat-client/index.html | 39 ++++ test-clients/ai-chat-client/script.js | 288 +++++++++++++++++++++++++ test-clients/ai-chat-client/styles.css | 175 +++++++++++++++ 3 files changed, 502 insertions(+) diff --git a/test-clients/ai-chat-client/index.html b/test-clients/ai-chat-client/index.html index cc552c8..3a46a13 100644 --- a/test-clients/ai-chat-client/index.html +++ b/test-clients/ai-chat-client/index.html @@ -35,6 +35,7 @@
+
@@ -169,6 +170,44 @@

새 캐릭터 만들기

+ + +
diff --git a/test-clients/ai-chat-client/script.js b/test-clients/ai-chat-client/script.js index d601782..5a8a419 100644 --- a/test-clients/ai-chat-client/script.js +++ b/test-clients/ai-chat-client/script.js @@ -9,6 +9,7 @@ const WS_URL = `ws://${ENDPOINT}/ws`; const HTTP_URL = `http://${ENDPOINT}/api/v1/chat`; const LOGIN_URL = `http://${ENDPOINT}/api/v1/auth/guest-login`; const CHARACTER_BASE_URL = `http://${ENDPOINT}/api/v1/character`; +const CONVERSATION_BASE_URL = `http://${ENDPOINT}/api/v1/conversation`; const SERVER_MESSAGE_TYPE = "json"; let ws = null; let reconnectAttempts = 0; @@ -44,10 +45,24 @@ const characterTabButtons = document.querySelectorAll('.character-tab-btn'); const myCharactersSection = document.getElementById('my-characters'); const publicCharactersSection = document.getElementById('public-characters'); const characterCreateSection = document.getElementById('character-create'); +const historySection = document.getElementById('history-section'); // 캐릭터 리스트 관련 const myCharacterList = document.getElementById('my-character-list'); const publicCharacterList = document.getElementById('public-character-list'); + +// 대화 기록 관련 DOM 요소들 +const historyCharacterSelect = document.getElementById('history-character-select'); +const historyPageSize = document.getElementById('history-page-size'); +const clearHistoryBtn = document.getElementById('clear-history-btn'); +const historyContent = document.getElementById('history-content'); +const historyLoading = document.getElementById('history-loading'); +const historyEmpty = document.getElementById('history-empty'); +const historyList = document.getElementById('history-list'); +const historyPagination = document.getElementById('history-pagination'); +const prevPageBtn = document.getElementById('prev-page-btn'); +const nextPageBtn = document.getElementById('next-page-btn'); +const pageInfo = document.getElementById('page-info'); const mySortOrder = document.getElementById('my-sort-order'); const publicSortOrder = document.getElementById('public-sort-order'); @@ -87,6 +102,12 @@ let myCharacters = []; let publicCharacters = []; let selectedCharacterId = null; +// 대화 기록 관련 상태 변수들 +let historyCharacters = []; +let currentHistoryPage = 1; +let totalHistoryPages = 1; +let selectedHistoryCharacterId = null; + // 서버 정보 표시 serverInfo.textContent = ENDPOINT; @@ -577,9 +598,16 @@ function switchTab(tabName) { if (tabName === 'chat') { chatSection.style.display = 'block'; characterManagement.style.display = 'none'; + historySection.style.display = 'none'; } else if (tabName === 'characters') { chatSection.style.display = 'none'; characterManagement.style.display = 'block'; + historySection.style.display = 'none'; + } else if (tabName === 'history') { + chatSection.style.display = 'none'; + characterManagement.style.display = 'none'; + historySection.style.display = 'block'; + loadHistoryPage(); } } @@ -666,6 +694,7 @@ async function loadMyCharacters() { if (response.ok) { myCharacters = await response.json(); renderCharacterList(myCharacters, myCharacterList, true); + updateHistoryCharacterSelect(); // 대화 기록 캐릭터 목록 업데이트 } else { console.error('내 캐릭터 로드 실패:', response.status); } @@ -1007,5 +1036,264 @@ if (configSystemPrompt) { configSystemPrompt.addEventListener('input', updatePromptCharCount); } +// ========== 대화 기록 관련 함수들 ========== + +// 대화 기록 캐릭터 목록 업데이트 +function updateHistoryCharacterSelect() { + if (!historyCharacterSelect) return; + + // 기존 옵션 제거 (첫 번째 "모든 캐릭터" 옵션 제외) + while (historyCharacterSelect.children.length > 1) { + historyCharacterSelect.removeChild(historyCharacterSelect.lastChild); + } + + // 내 캐릭터들을 드롭다운에 추가 + myCharacters.forEach(character => { + const option = document.createElement('option'); + option.value = character.id; + option.textContent = character.name; + historyCharacterSelect.appendChild(option); + }); + + // 선택된 캐릭터가 있으면 설정 + if (selectedHistoryCharacterId) { + historyCharacterSelect.value = selectedHistoryCharacterId; + } +} + +// 대화 기록 로드 +async function loadHistoryPage(page = 1) { + if (!authToken) { + showHistoryEmpty('로그인이 필요합니다.'); + return; + } + + showHistoryLoading(); + + try { + const characterId = historyCharacterSelect.value; + const pageSize = historyPageSize.value || 10; + + if (!characterId) { + showHistoryEmpty('캐릭터를 선택해주세요.'); + return; + } + + const url = `${CONVERSATION_BASE_URL}/${characterId}?page=${page}&pageSize=${pageSize}`; + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (!data.messages || data.messages.length === 0) { + showHistoryEmpty('대화 기록이 없습니다.'); + return; + } + + currentHistoryPage = data.currentPage; + totalHistoryPages = data.totalPages; + + renderHistoryList(data.messages, data); + updateHistoryPagination(); + showHistoryContent(); + + } catch (error) { + console.error('대화 기록 로드 실패:', error); + showHistoryEmpty('대화 기록을 불러오는 중 오류가 발생했습니다.'); + } +} + +// 대화 기록 렌더링 +function renderHistoryList(messages, paginationData) { + if (!historyList) return; + + // 대화 세션별로 그룹화 + const conversations = groupMessagesByConversation(messages); + + historyList.innerHTML = conversations.map(conversation => { + const characterName = getCharacterName(conversation.characterId); + const timestamp = new Date(conversation.timestamp).toLocaleString('ko-KR'); + + return ` +
+
+
${characterName}
+
${timestamp}
+
+
+ ${conversation.messages.map(message => ` +
+
${message.role}
+
${escapeHtml(message.content)}
+
+ `).join('')} +
+
+ `; + }).join(''); +} + +// 메시지를 대화별로 그룹화 +function groupMessagesByConversation(messages) { + const conversations = new Map(); + + messages.forEach(message => { + const conversationId = message.conversationId || `${message.characterId}-${message.timestamp}`; + + if (!conversations.has(conversationId)) { + conversations.set(conversationId, { + conversationId, + characterId: message.characterId, + timestamp: message.timestamp, + messages: [] + }); + } + + conversations.get(conversationId).messages.push(message); + }); + + return Array.from(conversations.values()).sort((a, b) => + new Date(b.timestamp) - new Date(a.timestamp) + ); +} + +// 캐릭터 이름 가져오기 +function getCharacterName(characterId) { + const character = myCharacters.find(c => c.id === characterId); + return character ? character.name : '알 수 없는 캐릭터'; +} + +// 대화 기록 삭제 +async function deleteHistory(characterId) { + if (!authToken || !characterId) return; + + if (!confirm('정말로 이 캐릭터와의 모든 대화 기록을 삭제하시겠습니까?')) { + return; + } + + try { + const response = await fetch(`${CONVERSATION_BASE_URL}/${characterId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + alert('대화 기록이 삭제되었습니다.'); + loadHistoryPage(currentHistoryPage); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error) { + console.error('대화 기록 삭제 실패:', error); + alert('대화 기록 삭제 중 오류가 발생했습니다.'); + } +} + +// UI 상태 관리 함수들 +function showHistoryLoading() { + if (historyLoading) historyLoading.style.display = 'block'; + if (historyEmpty) historyEmpty.style.display = 'none'; + if (historyList) historyList.style.display = 'none'; + if (historyPagination) historyPagination.style.display = 'none'; +} + +function showHistoryEmpty(message = '대화 기록이 없습니다.') { + if (historyLoading) historyLoading.style.display = 'none'; + if (historyEmpty) { + historyEmpty.style.display = 'block'; + historyEmpty.innerHTML = `${message}`; + } + if (historyList) historyList.style.display = 'none'; + if (historyPagination) historyPagination.style.display = 'none'; + if (clearHistoryBtn) clearHistoryBtn.style.display = 'none'; +} + +function showHistoryContent() { + if (historyLoading) historyLoading.style.display = 'none'; + if (historyEmpty) historyEmpty.style.display = 'none'; + if (historyList) historyList.style.display = 'block'; + if (historyPagination) historyPagination.style.display = 'flex'; + if (clearHistoryBtn) clearHistoryBtn.style.display = historyCharacterSelect.value ? 'block' : 'none'; +} + +// 페이지네이션 업데이트 +function updateHistoryPagination() { + if (!pageInfo) return; + + pageInfo.textContent = `${currentHistoryPage} / ${totalHistoryPages}`; + + if (prevPageBtn) { + prevPageBtn.disabled = currentHistoryPage <= 1; + } + + if (nextPageBtn) { + nextPageBtn.disabled = currentHistoryPage >= totalHistoryPages; + } +} + +// HTML 이스케이프 +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ========== 대화 기록 이벤트 리스너들 ========== + +// 대화 기록 캐릭터 선택 변경 +if (historyCharacterSelect) { + historyCharacterSelect.addEventListener('change', () => { + selectedHistoryCharacterId = historyCharacterSelect.value; + currentHistoryPage = 1; + loadHistoryPage(); + }); +} + +// 대화 기록 페이지 크기 변경 +if (historyPageSize) { + historyPageSize.addEventListener('change', () => { + currentHistoryPage = 1; + loadHistoryPage(); + }); +} + +// 대화 기록 삭제 버튼 +if (clearHistoryBtn) { + clearHistoryBtn.addEventListener('click', () => { + const characterId = historyCharacterSelect.value; + if (characterId) { + deleteHistory(characterId); + } + }); +} + +// 페이지네이션 버튼들 +if (prevPageBtn) { + prevPageBtn.addEventListener('click', () => { + if (currentHistoryPage > 1) { + loadHistoryPage(currentHistoryPage - 1); + } + }); +} + +if (nextPageBtn) { + nextPageBtn.addEventListener('click', () => { + if (currentHistoryPage < totalHistoryPages) { + loadHistoryPage(currentHistoryPage + 1); + } + }); +} + // 초기화 - 로그인을 기다림 appendLog('[시작] 게스트 ID를 입력하고 로그인하세요.'); \ No newline at end of file diff --git a/test-clients/ai-chat-client/styles.css b/test-clients/ai-chat-client/styles.css index 30ca63d..a23ec5f 100644 --- a/test-clients/ai-chat-client/styles.css +++ b/test-clients/ai-chat-client/styles.css @@ -591,4 +591,179 @@ body { .reset-btn { width: 100%; } +} + +/* 대화 기록 스타일 */ +.history-controls { + background: #f8f9fa; + padding: 1.5em; + border-radius: 8px; + margin-bottom: 1.5em; +} + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1em; +} + +.history-header h3 { + margin: 0; + color: #333; +} + +.history-filters { + display: flex; + gap: 1em; + align-items: center; + flex-wrap: wrap; +} + +.history-filters select { + padding: 0.5em; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + font-size: 0.9em; +} + +.danger-btn { + background: #dc3545; + color: white; + border: none; + padding: 0.5em 1em; + border-radius: 4px; + font-size: 0.9em; + cursor: pointer; + transition: background 0.2s; +} + +.danger-btn:hover { + background: #c82333; +} + +#history-list { + display: flex; + flex-direction: column; + gap: 1em; +} + +.history-conversation { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1.5em; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.history-conversation-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1em; + padding-bottom: 0.5em; + border-bottom: 1px solid #f0f0f0; +} + +.history-character-name { + font-weight: 600; + color: #333; +} + +.history-timestamp { + font-size: 0.85em; + color: #666; +} + +.history-messages { + display: flex; + flex-direction: column; + gap: 0.8em; +} + +.history-message { + padding: 0.8em 1em; + border-radius: 8px; + max-width: 85%; +} + +.history-message.user { + background: #e3f2fd; + align-self: flex-end; + border: 1px solid #bbdefb; +} + +.history-message.assistant { + background: #f1f8e9; + align-self: flex-start; + border: 1px solid #dcedc8; +} + +.history-message-role { + font-size: 0.75em; + font-weight: 600; + margin-bottom: 0.3em; + text-transform: uppercase; + opacity: 0.7; +} + +.history-message-content { + line-height: 1.4; +} + +.history-actions { + margin-top: 1em; + display: flex; + gap: 0.5em; +} + +.history-delete-btn { + background: #ffc107; + color: #212529; + border: none; + padding: 0.3em 0.6em; + border-radius: 4px; + font-size: 0.8em; + cursor: pointer; + transition: background 0.2s; +} + +.history-delete-btn:hover { + background: #e0a800; +} + +#history-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 1em; + margin-top: 2em; + padding-top: 1em; + border-top: 1px solid #e0e0e0; +} + +#history-pagination button { + background: #f8f9fa; + border: 1px solid #dee2e6; + color: #495057; + padding: 0.5em 1em; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +#history-pagination button:hover:not(:disabled) { + background: #e9ecef; +} + +#history-pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +#page-info { + font-weight: 500; + color: #495057; } \ No newline at end of file From c82bd417cf0c04971e87c2a9b4843042ea18caad Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 09:55:30 +0900 Subject: [PATCH 14/20] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Conversation/ConversationService.cs | 2 + .../ConversationServiceIntegrationTests.cs | 76 +++++++++---------- .../Conversation/ConversationServiceTests.cs | 54 ++++++------- 3 files changed, 67 insertions(+), 65 deletions(-) diff --git a/ProjectVG.Application/Services/Conversation/ConversationService.cs b/ProjectVG.Application/Services/Conversation/ConversationService.cs index 169ba3c..0ccbf80 100644 --- a/ProjectVG.Application/Services/Conversation/ConversationService.cs +++ b/ProjectVG.Application/Services/Conversation/ConversationService.cs @@ -1,6 +1,8 @@ using ProjectVG.Domain.Entities.ConversationHistorys; using ProjectVG.Infrastructure.Persistence.Repositories.Conversation; using Microsoft.Extensions.Logging; +using ProjectVG.Common.Exceptions; +using ProjectVG.Common.Constants; namespace ProjectVG.Application.Services.Conversation { diff --git a/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs b/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs index 776ab68..04eb1db 100644 --- a/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs +++ b/ProjectVG.Tests/Application/Integration/ConversationServiceIntegrationTests.cs @@ -81,7 +81,7 @@ public async Task AddMessageAsync_WithNullOrWhitespaceContent_ShouldThrowValidat // Act & Assert await Assert.ThrowsAsync( - () => _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, content)); + () => _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, content, DateTime.UtcNow)); } [Fact] @@ -90,11 +90,11 @@ public async Task AddMessageAsync_WithContentTooLong_ShouldThrowValidationExcept // Arrange await _fixture.ClearDatabaseAsync(); var (userId, characterId) = await CreateUserAndCharacterAsync(); - var longContent = new string('x', 1001); // Exceeds 1000 character limit + var longContent = new string('x', 10001); // Exceeds 10000 character limit // Act & Assert await Assert.ThrowsAsync( - () => _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, longContent)); + () => _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, longContent, DateTime.UtcNow)); } [Fact] @@ -103,15 +103,15 @@ public async Task AddMessageAsync_WithMaxLengthContent_ShouldSucceed() // Arrange await _fixture.ClearDatabaseAsync(); var (userId, characterId) = await CreateUserAndCharacterAsync(); - var maxContent = new string('x', 1000); // Exactly 1000 characters + var maxContent = new string('x', 10000); // Exactly 10000 characters // Act - var addedMessage = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, maxContent); + var addedMessage = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, maxContent, DateTime.UtcNow); // Assert addedMessage.Should().NotBeNull(); addedMessage.Content.Should().Be(maxContent); - addedMessage.Content.Length.Should().Be(1000); + addedMessage.Content.Length.Should().Be(10000); } #endregion @@ -126,11 +126,11 @@ public async Task GetConversationHistoryAsync_WithExistingMessages_ShouldReturnI var (userId, characterId) = await CreateUserAndCharacterAsync(); // Add messages with slight delays to ensure different timestamps - var message1 = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "First message"); + var message1 = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "First message", DateTime.UtcNow); await Task.Delay(10); - var message2 = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.Assistant, "Second message"); + var message2 = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.Assistant, "Second message", DateTime.UtcNow); await Task.Delay(10); - var message3 = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Third message"); + var message3 = await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Third message", DateTime.UtcNow); // Act var history = await _conversationService.GetConversationHistoryAsync(userId, characterId, 10); @@ -155,7 +155,7 @@ public async Task GetConversationHistoryAsync_WithCountLimit_ShouldRespectLimit( // Add 5 messages for (int i = 1; i <= 5; i++) { - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, $"Message {i}"); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, $"Message {i}", DateTime.UtcNow); await Task.Delay(10); // Ensure different timestamps } @@ -192,7 +192,7 @@ public async Task GetConversationHistoryAsync_WithDefaultCount_ShouldUse10AsDefa // Add 15 messages for (int i = 1; i <= 15; i++) { - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, $"Message {i}"); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, $"Message {i}", DateTime.UtcNow); } // Act - Use default count @@ -229,10 +229,10 @@ public async Task GetConversationHistoryAsync_WithMultipleUserCharacterPairs_Sho var char2 = await CreateCharacterAsync("Character2"); // Add messages for different user-character combinations - await _conversationService.AddMessageAsync(user1.Id, char1.Id, ChatRole.User, "User1-Char1 Message"); - await _conversationService.AddMessageAsync(user1.Id, char2.Id, ChatRole.User, "User1-Char2 Message"); - await _conversationService.AddMessageAsync(user2.Id, char1.Id, ChatRole.User, "User2-Char1 Message"); - await _conversationService.AddMessageAsync(user2.Id, char2.Id, ChatRole.User, "User2-Char2 Message"); + await _conversationService.AddMessageAsync(user1.Id, char1.Id, ChatRole.User, "User1-Char1 Message", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user1.Id, char2.Id, ChatRole.User, "User1-Char2 Message", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user2.Id, char1.Id, ChatRole.User, "User2-Char1 Message", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user2.Id, char2.Id, ChatRole.User, "User2-Char2 Message", DateTime.UtcNow); // Act var user1Char1History = await _conversationService.GetConversationHistoryAsync(user1.Id, char1.Id); @@ -257,16 +257,16 @@ public async Task ClearConversationAsync_WithExistingMessages_ShouldRemoveAllMes var (userId, characterId) = await CreateUserAndCharacterAsync(); // Add multiple messages - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Message 1"); - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.Assistant, "Response 1"); - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Message 2"); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Message 1", DateTime.UtcNow); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.Assistant, "Response 1", DateTime.UtcNow); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Message 2", DateTime.UtcNow); // Verify messages exist var historyBefore = await _conversationService.GetConversationHistoryAsync(userId, characterId); historyBefore.Should().HaveCount(3); // Act - await _conversationService.ClearConversationAsync(userId, characterId); + await _conversationService.DeleteConversationAsync(userId, characterId); // Assert var historyAfter = await _conversationService.GetConversationHistoryAsync(userId, characterId); @@ -281,7 +281,7 @@ public async Task ClearConversationAsync_WithNoMessages_ShouldNotThrow() var (userId, characterId) = await CreateUserAndCharacterAsync(); // Act & Assert - Should not throw - await _conversationService.ClearConversationAsync(userId, characterId); + await _conversationService.DeleteConversationAsync(userId, characterId); } [Fact] @@ -295,12 +295,12 @@ public async Task ClearConversationAsync_ShouldOnlyAffectSpecificUserCharacterPa var char2 = await CreateCharacterAsync("Character2"); // Add messages for different combinations - await _conversationService.AddMessageAsync(user1.Id, char1.Id, ChatRole.User, "User1-Char1"); - await _conversationService.AddMessageAsync(user1.Id, char2.Id, ChatRole.User, "User1-Char2"); - await _conversationService.AddMessageAsync(user2.Id, char1.Id, ChatRole.User, "User2-Char1"); + await _conversationService.AddMessageAsync(user1.Id, char1.Id, ChatRole.User, "User1-Char1", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user1.Id, char2.Id, ChatRole.User, "User1-Char2", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user2.Id, char1.Id, ChatRole.User, "User2-Char1", DateTime.UtcNow); // Act - Clear only user1-char1 conversation - await _conversationService.ClearConversationAsync(user1.Id, char1.Id); + await _conversationService.DeleteConversationAsync(user1.Id, char1.Id); // Assert var user1Char1History = await _conversationService.GetConversationHistoryAsync(user1.Id, char1.Id); @@ -326,7 +326,7 @@ public async Task GetMessageCountAsync_WithMultipleMessages_ShouldReturnCorrectC // Add 5 messages for (int i = 1; i <= 5; i++) { - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, $"Message {i}"); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, $"Message {i}", DateTime.UtcNow); } // Act @@ -358,14 +358,14 @@ public async Task GetMessageCountAsync_AfterClearingConversation_ShouldReturnZer var (userId, characterId) = await CreateUserAndCharacterAsync(); // Add messages - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Message 1"); - await _conversationService.AddMessageAsync(userId, characterId, ChatRole.Assistant, "Response 1"); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.User, "Message 1", DateTime.UtcNow); + await _conversationService.AddMessageAsync(userId, characterId, ChatRole.Assistant, "Response 1", DateTime.UtcNow); var countBefore = await _conversationService.GetMessageCountAsync(userId, characterId); countBefore.Should().Be(2); // Act - await _conversationService.ClearConversationAsync(userId, characterId); + await _conversationService.DeleteConversationAsync(userId, characterId); var countAfter = await _conversationService.GetMessageCountAsync(userId, characterId); // Assert @@ -392,11 +392,11 @@ public async Task CompleteConversationLifecycle_ShouldWorkCorrectly() // Add conversation messages var userMessage = await _conversationService.AddMessageAsync( - userId, characterId, ChatRole.User, "Hello, how are you?"); + userId, characterId, ChatRole.User, "Hello, how are you?", DateTime.UtcNow); var assistantMessage = await _conversationService.AddMessageAsync( - userId, characterId, ChatRole.Assistant, "I'm doing well, thank you for asking!"); + userId, characterId, ChatRole.Assistant, "I'm doing well, thank you for asking!", DateTime.UtcNow); var followupMessage = await _conversationService.AddMessageAsync( - userId, characterId, ChatRole.User, "That's great to hear!"); + userId, characterId, ChatRole.User, "That's great to hear!", DateTime.UtcNow); // Verify messages were added var countAfterAdding = await _conversationService.GetMessageCountAsync(userId, characterId); @@ -412,7 +412,7 @@ public async Task CompleteConversationLifecycle_ShouldWorkCorrectly() messagesList.Should().Contain(m => m.Content == "That's great to hear!" && m.Role == ChatRole.User); // Clear conversation - await _conversationService.ClearConversationAsync(userId, characterId); + await _conversationService.DeleteConversationAsync(userId, characterId); // Verify conversation is cleared var countAfterClearing = await _conversationService.GetMessageCountAsync(userId, characterId); @@ -434,16 +434,16 @@ public async Task MultipleConversationsSimultaneously_ShouldIsolateCorrectly() // Create conversations for different user-character pairs // User1 with Character1 - await _conversationService.AddMessageAsync(user1.Id, character1.Id, ChatRole.User, "User1 to Char1: Hello"); - await _conversationService.AddMessageAsync(user1.Id, character1.Id, ChatRole.Assistant, "Char1 to User1: Hi there"); + await _conversationService.AddMessageAsync(user1.Id, character1.Id, ChatRole.User, "User1 to Char1: Hello", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user1.Id, character1.Id, ChatRole.Assistant, "Char1 to User1: Hi there", DateTime.UtcNow); // User1 with Character2 - await _conversationService.AddMessageAsync(user1.Id, character2.Id, ChatRole.User, "User1 to Char2: Hey"); + await _conversationService.AddMessageAsync(user1.Id, character2.Id, ChatRole.User, "User1 to Char2: Hey", DateTime.UtcNow); // User2 with Character1 - await _conversationService.AddMessageAsync(user2.Id, character1.Id, ChatRole.User, "User2 to Char1: Good morning"); - await _conversationService.AddMessageAsync(user2.Id, character1.Id, ChatRole.Assistant, "Char1 to User2: Good morning!"); - await _conversationService.AddMessageAsync(user2.Id, character1.Id, ChatRole.User, "User2 to Char1: How's the weather?"); + await _conversationService.AddMessageAsync(user2.Id, character1.Id, ChatRole.User, "User2 to Char1: Good morning", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user2.Id, character1.Id, ChatRole.Assistant, "Char1 to User2: Good morning!", DateTime.UtcNow); + await _conversationService.AddMessageAsync(user2.Id, character1.Id, ChatRole.User, "User2 to Char1: How's the weather?", DateTime.UtcNow); // Verify counts for each conversation var user1Char1Count = await _conversationService.GetMessageCountAsync(user1.Id, character1.Id); diff --git a/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs b/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs index e590c54..5a90b94 100644 --- a/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs +++ b/ProjectVG.Tests/Application/Services/Conversation/ConversationServiceTests.cs @@ -75,7 +75,7 @@ public async Task AddMessageAsync_WithNullOrWhitespaceContent_ShouldThrowValidat // Act & Assert var exception = await Assert.ThrowsAsync( - () => _conversationService.AddMessageAsync(userId, characterId, role, content) + () => _conversationService.AddMessageAsync(userId, characterId, role, content, DateTime.UtcNow) ); exception.ErrorCode.Should().Be(ErrorCode.MESSAGE_EMPTY); @@ -89,11 +89,11 @@ public async Task AddMessageAsync_WithContentTooLong_ShouldThrowValidationExcept var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); var role = "user"; - var longContent = new string('x', 1001); // Exceeds 1000 character limit + var longContent = new string('x', 10001); // Exceeds 10000 character limit // Act & Assert var exception = await Assert.ThrowsAsync( - () => _conversationService.AddMessageAsync(userId, characterId, role, longContent) + () => _conversationService.AddMessageAsync(userId, characterId, role, longContent, DateTime.UtcNow) ); exception.ErrorCode.Should().Be(ErrorCode.MESSAGE_TOO_LONG); @@ -131,7 +131,7 @@ public async Task AddMessageAsync_WithMaxLengthContent_ShouldAddMessageSuccessfu var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); var role = "user"; - var maxContent = new string('x', 1000); // Exactly 1000 characters + var maxContent = new string('x', 10000); // Exactly 10000 characters var expectedMessage = CreateTestConversationHistory(userId, characterId, role, maxContent); @@ -139,7 +139,7 @@ public async Task AddMessageAsync_WithMaxLengthContent_ShouldAddMessageSuccessfu .ReturnsAsync(expectedMessage); // Act - var result = await _conversationService.AddMessageAsync(userId, characterId, role, maxContent); + var result = await _conversationService.AddMessageAsync(userId, characterId, role, maxContent, DateTime.UtcNow); // Assert result.Should().NotBeNull(); @@ -165,18 +165,18 @@ public async Task GetConversationHistoryAsync_WithValidParameters_ShouldReturnCo CreateTestConversationHistory(userId, characterId, "user", "Message 2") }; - _mockConversationRepository.Setup(x => x.GetByUserIdAsync(userId, characterId, count)) + _mockConversationRepository.Setup(x => x.GetConversationHistoryAsync(userId, characterId, 1, count)) .ReturnsAsync(expectedHistory); // Act - var result = await _conversationService.GetConversationHistoryAsync(userId, characterId, count); + var result = await _conversationService.GetConversationHistoryAsync(userId, characterId, 1, count); // Assert result.Should().NotBeNull(); result.Should().HaveCount(3); result.Should().BeEquivalentTo(expectedHistory); - _mockConversationRepository.Verify(x => x.GetByUserIdAsync(userId, characterId, count), Times.Once); + _mockConversationRepository.Verify(x => x.GetConversationHistoryAsync(userId, characterId, 1, count), Times.Once); } [Fact] @@ -187,7 +187,7 @@ public async Task GetConversationHistoryAsync_WithDefaultCount_ShouldUseDefaultV var characterId = Guid.NewGuid(); var expectedHistory = new List(); - _mockConversationRepository.Setup(x => x.GetByUserIdAsync(userId, characterId, 10)) + _mockConversationRepository.Setup(x => x.GetConversationHistoryAsync(userId, characterId, 1, 10)) .ReturnsAsync(expectedHistory); // Act @@ -195,7 +195,7 @@ public async Task GetConversationHistoryAsync_WithDefaultCount_ShouldUseDefaultV // Assert result.Should().NotBeNull(); - _mockConversationRepository.Verify(x => x.GetByUserIdAsync(userId, characterId, 10), Times.Once); + _mockConversationRepository.Verify(x => x.GetConversationHistoryAsync(userId, characterId, 1, 10), Times.Once); } [Theory] @@ -210,11 +210,11 @@ public async Task GetConversationHistoryAsync_WithInvalidCount_ShouldThrowValida // Act & Assert var exception = await Assert.ThrowsAsync( - () => _conversationService.GetConversationHistoryAsync(userId, characterId, count) + () => _conversationService.GetConversationHistoryAsync(userId, characterId, 1, count) ); exception.ErrorCode.Should().Be(ErrorCode.VALIDATION_FAILED); - _mockConversationRepository.Verify(x => x.GetByUserIdAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _mockConversationRepository.Verify(x => x.GetConversationHistoryAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -225,11 +225,11 @@ public async Task GetConversationHistoryAsync_WithNoHistory_ShouldReturnEmptyCol var characterId = Guid.NewGuid(); var count = 10; - _mockConversationRepository.Setup(x => x.GetByUserIdAsync(userId, characterId, count)) + _mockConversationRepository.Setup(x => x.GetConversationHistoryAsync(userId, characterId, 1, count)) .ReturnsAsync(new List()); // Act - var result = await _conversationService.GetConversationHistoryAsync(userId, characterId, count); + var result = await _conversationService.GetConversationHistoryAsync(userId, characterId, 1, count); // Assert result.Should().NotBeNull(); @@ -246,50 +246,50 @@ public async Task GetConversationHistoryAsync_WithValidCountRange_ShouldCallRepo var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - _mockConversationRepository.Setup(x => x.GetByUserIdAsync(userId, characterId, count)) + _mockConversationRepository.Setup(x => x.GetConversationHistoryAsync(userId, characterId, 1, count)) .ReturnsAsync(new List()); // Act - await _conversationService.GetConversationHistoryAsync(userId, characterId, count); + await _conversationService.GetConversationHistoryAsync(userId, characterId, 1, count); // Assert - _mockConversationRepository.Verify(x => x.GetByUserIdAsync(userId, characterId, count), Times.Once); + _mockConversationRepository.Verify(x => x.GetConversationHistoryAsync(userId, characterId, 1, count), Times.Once); } #endregion - #region ClearConversationAsync Tests + #region DeleteConversationAsync Tests [Fact] - public async Task ClearConversationAsync_WithValidParameters_ShouldCallRepositoryClearSession() + public async Task DeleteConversationAsync_WithValidParameters_ShouldCallRepositoryDeleteConversation() { // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - _mockConversationRepository.Setup(x => x.ClearSessionAsync(userId, characterId)) + _mockConversationRepository.Setup(x => x.DeleteConversationAsync(userId, characterId)) .Returns(Task.CompletedTask); // Act - await _conversationService.ClearConversationAsync(userId, characterId); + await _conversationService.DeleteConversationAsync(userId, characterId); // Assert - _mockConversationRepository.Verify(x => x.ClearSessionAsync(userId, characterId), Times.Once); + _mockConversationRepository.Verify(x => x.DeleteConversationAsync(userId, characterId), Times.Once); } [Fact] - public async Task ClearConversationAsync_WhenRepositoryThrowsException_ShouldPropagateException() + public async Task DeleteConversationAsync_WhenRepositoryThrowsException_ShouldPropagateException() { // Arrange var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - _mockConversationRepository.Setup(x => x.ClearSessionAsync(userId, characterId)) + _mockConversationRepository.Setup(x => x.DeleteConversationAsync(userId, characterId)) .ThrowsAsync(new Exception("Database error")); // Act & Assert await Assert.ThrowsAsync( - () => _conversationService.ClearConversationAsync(userId, characterId) + () => _conversationService.DeleteConversationAsync(userId, characterId) ); } @@ -371,7 +371,7 @@ public async Task AddMessageAsync_WhenRepositoryThrowsException_ShouldPropagateE // Act & Assert await Assert.ThrowsAsync( - () => _conversationService.AddMessageAsync(userId, characterId, role, content) + () => _conversationService.AddMessageAsync(userId, characterId, role, content, DateTime.UtcNow) ); } @@ -382,7 +382,7 @@ public async Task GetConversationHistoryAsync_WhenRepositoryThrowsException_Shou var userId = Guid.NewGuid(); var characterId = Guid.NewGuid(); - _mockConversationRepository.Setup(x => x.GetByUserIdAsync(userId, characterId, It.IsAny())) + _mockConversationRepository.Setup(x => x.GetConversationHistoryAsync(userId, characterId, It.IsAny(), It.IsAny())) .ThrowsAsync(new Exception("Database connection failed")); // Act & Assert From e29a9fb5f7d597ec9dd32cdd270ae8133d2156d5 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 12:31:34 +0900 Subject: [PATCH 15/20] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProjectVG.Api/Controllers/TokenController.cs | 249 +++++++++++ .../ApplicationServiceCollectionExtensions.cs | 4 + .../Services/Auth/AuthService.cs | 12 +- .../Chat/Handlers/ChatSuccessHandler.cs | 55 ++- .../Chat/Validators/ChatRequestValidator.cs | 17 + .../Services/Token/ITokenManagementService.cs | 175 ++++++++ .../Services/Token/TokenManagementService.cs | 379 ++++++++++++++++ ProjectVG.Common/Constants/ErrorCodes.cs | 14 +- .../Entities/Token/TokenTransaction.cs | 82 ++++ ProjectVG.Domain/Entities/User/User.cs | 20 + ...frastructureServiceCollectionExtensions.cs | 2 + ...903032058_AddTokenSystemToUser.Designer.cs | 325 ++++++++++++++ .../20250903032058_AddTokenSystemToUser.cs | 119 +++++ ...2155_AddTokenTransactionEntity.Designer.cs | 416 ++++++++++++++++++ ...0250903032155_AddTokenTransactionEntity.cs | 222 ++++++++++ .../ProjectVGDbContextModelSnapshot.cs | 127 +++++- .../EfCore/Data/ProjectVGDbContext.cs | 38 ++ .../Token/ITokenTransactionRepository.cs | 85 ++++ .../SqlServerTokenTransactionRepository.cs | 130 ++++++ 19 files changed, 2459 insertions(+), 12 deletions(-) create mode 100644 ProjectVG.Api/Controllers/TokenController.cs create mode 100644 ProjectVG.Application/Services/Token/ITokenManagementService.cs create mode 100644 ProjectVG.Application/Services/Token/TokenManagementService.cs create mode 100644 ProjectVG.Domain/Entities/Token/TokenTransaction.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs create mode 100644 ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs create mode 100644 ProjectVG.Infrastructure/Persistence/Repositories/Token/ITokenTransactionRepository.cs create mode 100644 ProjectVG.Infrastructure/Persistence/Repositories/Token/SqlServerTokenTransactionRepository.cs diff --git a/ProjectVG.Api/Controllers/TokenController.cs b/ProjectVG.Api/Controllers/TokenController.cs new file mode 100644 index 0000000..aa4915b --- /dev/null +++ b/ProjectVG.Api/Controllers/TokenController.cs @@ -0,0 +1,249 @@ +using Microsoft.AspNetCore.Mvc; +using ProjectVG.Application.Services.Token; +using ProjectVG.Api.Filters; +using System.Security.Claims; + +namespace ProjectVG.Api.Controllers +{ + /// + /// 토큰 관리 API 컨트롤러 + /// 사용자의 토큰 잔액 조회, 거래 내역 조회 등을 제공 + /// + [ApiController] + [Route("api/v1/tokens")] + [JwtAuthentication] + public class TokenController : ControllerBase + { + private readonly ITokenManagementService _tokenManagementService; + private readonly ILogger _logger; + + public TokenController(ITokenManagementService tokenManagementService, ILogger logger) + { + _tokenManagementService = tokenManagementService; + _logger = logger; + } + + /// + /// 현재 사용자의 토큰 잔액 조회 + /// + /// 토큰 잔액 정보 + [HttpGet("balance")] + public async Task GetBalance() + { + var userId = GetCurrentUserId(); + + try + { + var balance = await _tokenManagementService.GetTokenBalanceAsync(userId); + return Ok(new + { + userId = balance.UserId, + currentBalance = balance.CurrentBalance, + totalEarned = balance.TotalEarned, + totalSpent = balance.TotalSpent, + lastUpdated = balance.LastUpdated, + initialTokensGranted = balance.InitialTokensGranted + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get token balance for user {UserId}", userId); + return StatusCode(500, new { error = "Failed to retrieve token balance" }); + } + } + + /// + /// 토큰 거래 내역 조회 (페이지네이션) + /// + /// 페이지 번호 (1부터 시작) + /// 페이지 크기 (최대 100) + /// 거래 유형 필터 (Earn=1, Spend=2) + /// 토큰 거래 내역 + [HttpGet("history")] + public async Task GetHistory( + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + [FromQuery] int? type = null) + { + var userId = GetCurrentUserId(); + + // 파라미터 검증 + if (page < 1) page = 1; + if (pageSize < 1 || pageSize > 100) pageSize = 20; + + Domain.Entities.Tokens.TokenTransactionType? transactionType = null; + if (type.HasValue && Enum.IsDefined(typeof(Domain.Entities.Tokens.TokenTransactionType), type.Value)) + { + transactionType = (Domain.Entities.Tokens.TokenTransactionType)type.Value; + } + + try + { + var history = await _tokenManagementService.GetTokenHistoryAsync(userId, page, pageSize, transactionType); + + return Ok(new + { + userId = history.UserId, + transactions = history.Transactions.Select(t => new + { + id = t.Id, + transactionId = t.TransactionId, + type = t.Type, + amount = t.Amount, + balanceAfter = t.BalanceAfter, + source = t.Source, + description = t.Description, + relatedEntityId = t.RelatedEntityId, + relatedEntityType = t.RelatedEntityType, + createdAt = t.CreatedAt + }), + pagination = new + { + totalCount = history.TotalCount, + pageNumber = history.PageNumber, + pageSize = history.PageSize, + totalPages = history.TotalPages, + hasNextPage = history.HasNextPage, + hasPreviousPage = history.HasPreviousPage + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get token history for user {UserId}", userId); + return StatusCode(500, new { error = "Failed to retrieve token history" }); + } + } + + /// + /// 토큰 충분 여부 확인 + /// + /// 확인할 토큰 수량 + /// 토큰 충분 여부 + [HttpGet("check/{amount}")] + public async Task CheckSufficientTokens(decimal amount) + { + if (amount <= 0) + { + return BadRequest(new { error = "Amount must be positive" }); + } + + var userId = GetCurrentUserId(); + + try + { + var hasSufficient = await _tokenManagementService.HasSufficientTokensAsync(userId, amount); + var balance = await _tokenManagementService.GetTokenBalanceAsync(userId); + + return Ok(new + { + userId = userId, + requiredAmount = amount, + currentBalance = balance.CurrentBalance, + hasSufficientTokens = hasSufficient, + shortage = hasSufficient ? 0 : amount - balance.CurrentBalance + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to check token sufficiency for user {UserId}, amount {Amount}", userId, amount); + return StatusCode(500, new { error = "Failed to check token sufficiency" }); + } + } + + /// + /// 토큰 추가 (관리자 전용 또는 결제 시스템 연동용) + /// 실제 운영 환경에서는 결제 검증 로직이 필요 + /// + /// 토큰 추가 요청 + /// 토큰 추가 결과 + [HttpPost("add")] + public async Task AddTokens([FromBody] AddTokenRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var userId = GetCurrentUserId(); + + try + { + // 실제 운영에서는 결제 검증, 권한 확인 등이 필요 + var result = await _tokenManagementService.AddTokensAsync( + userId, + request.Amount, + request.Source ?? "MANUAL_ADD", + request.Description ?? "토큰 수동 추가", + request.RelatedEntityId, + request.RelatedEntityType + ); + + if (result.Success) + { + return Ok(new + { + success = true, + transactionId = result.TransactionId, + amount = result.Amount, + balanceAfter = result.BalanceAfter, + timestamp = result.Timestamp + }); + } + else + { + return BadRequest(new { error = result.ErrorMessage }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add tokens for user {UserId}", userId); + return StatusCode(500, new { error = "Failed to add tokens" }); + } + } + + /// + /// JWT 토큰에서 사용자 ID 추출 + /// + private Guid GetCurrentUserId() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) + { + throw new UnauthorizedAccessException("Invalid user ID in token"); + } + return userId; + } + } + + /// + /// 토큰 추가 요청 모델 + /// + public class AddTokenRequest + { + /// + /// 추가할 토큰 수량 (필수) + /// + public decimal Amount { get; set; } + + /// + /// 토큰 소스 (선택, 기본값: MANUAL_ADD) + /// + public string? Source { get; set; } + + /// + /// 거래 설명 (선택) + /// + public string? Description { get; set; } + + /// + /// 관련 엔티티 ID (선택) + /// + public string? RelatedEntityId { get; set; } + + /// + /// 관련 엔티티 타입 (선택) + /// + public string? RelatedEntityType { get; set; } + } +} \ No newline at end of file diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index 7d6987e..c6f1470 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using ProjectVG.Application.Services.Chat.Handlers; using ProjectVG.Application.Services.Conversation; using ProjectVG.Application.Services.Session; +using ProjectVG.Application.Services.Token; using ProjectVG.Application.Services.Users; using ProjectVG.Application.Services.WebSocket; @@ -29,6 +30,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // Character Services services.AddScoped(); + // Token Management Services + services.AddScoped(); + // Chat Services - Core services.AddScoped(); services.AddScoped(); diff --git a/ProjectVG.Application/Services/Auth/AuthService.cs b/ProjectVG.Application/Services/Auth/AuthService.cs index 63a78c9..321ff5f 100644 --- a/ProjectVG.Application/Services/Auth/AuthService.cs +++ b/ProjectVG.Application/Services/Auth/AuthService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using ProjectVG.Application.Models.User; using ProjectVG.Application.Services.Users; +using ProjectVG.Application.Services.Token; using ProjectVG.Infrastructure.Auth; using ProjectVG.Common.Exceptions; using ProjectVG.Common.Constants; @@ -12,12 +13,18 @@ public class AuthService : IAuthService { private readonly IUserService _userService; private readonly ITokenService _tokenService; + private readonly ITokenManagementService _tokenManagementService; private readonly ILogger _logger; - public AuthService(IUserService userService, ITokenService tokenService, ILogger logger) + public AuthService( + IUserService userService, + ITokenService tokenService, + ITokenManagementService tokenManagementService, + ILogger logger) { _userService = userService; _tokenService = tokenService; + _tokenManagementService = tokenManagementService; _logger = logger; } @@ -89,6 +96,9 @@ public async Task LoginWithOAuthAsync(string provider, string provid user = user with { Provider = provider, ProviderId = providerUserId }; } + // 첫 로그인 토큰 지급 시도 + await _tokenManagementService.GrantInitialTokensAsync(user.Id); + var tokens = await _tokenService.GenerateTokensAsync(user.Id); _logger.LogInformation("Users {UserId} logged in with OAuth provider: {Provider}", user.Id, provider); diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs index c811f1d..f22abe2 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs @@ -1,6 +1,7 @@ using ProjectVG.Application.Models.Chat; using ProjectVG.Application.Models.WebSocket; using ProjectVG.Application.Services.WebSocket; +using ProjectVG.Application.Services.Token; namespace ProjectVG.Application.Services.Chat.Handlers @@ -9,13 +10,16 @@ public class ChatSuccessHandler { private readonly ILogger _logger; private readonly IWebSocketManager _webSocketService; + private readonly ITokenManagementService _tokenManagementService; public ChatSuccessHandler( ILogger logger, - IWebSocketManager webSocketService) + IWebSocketManager webSocketService, + ITokenManagementService tokenManagementService) { _logger = logger; _webSocketService = webSocketService; + _tokenManagementService = tokenManagementService; } public async Task HandleAsync(ChatProcessContext context) @@ -55,6 +59,9 @@ public async Task HandleAsync(ChatProcessContext context) _logger.LogDebug("채팅 결과 전송 완료: 요청 {RequestId}, 세그먼트 {SegmentCount}개", context.RequestId, validSegments.Count); + + // 성공적인 전송 후 토큰 차감 처리 + await DeductTokensForSuccessfulChatAsync(context); } catch (Exception ex) { @@ -62,5 +69,51 @@ public async Task HandleAsync(ChatProcessContext context) throw; } } + + /// + /// 성공적인 채팅 처리 후 토큰 차감 + /// + private async Task DeductTokensForSuccessfulChatAsync(ChatProcessContext context) + { + try + { + // 실제 사용된 Cost를 토큰으로 차감 + if (context.Cost > 0) + { + var transactionId = $"CHAT_{context.RequestId}_{DateTime.UtcNow:yyyyMMddHHmmss}"; + var result = await _tokenManagementService.DeductTokensAsync( + context.UserId, + (decimal)context.Cost, + transactionId, + "CHAT_USAGE", + $"채팅 사용료 - 캐릭터: {context.CharacterId}", + context.RequestId.ToString(), + "ChatSession" + ); + + if (result.Success) + { + _logger.LogInformation("채팅 토큰 차감 완료: {UserId}, 차감 토큰: {Cost}, 잔액: {Balance}", + context.UserId, context.Cost, result.BalanceAfter); + } + else + { + _logger.LogError("채팅 토큰 차감 실패: {UserId}, 에러: {Error}", + context.UserId, result.ErrorMessage); + // 토큰 차감 실패는 로그만 남기고 사용자에게는 이미 성공 응답을 보냈으므로 예외를 던지지 않음 + } + } + else + { + _logger.LogWarning("채팅 처리 완료했지만 Cost가 0 또는 음수: {RequestId}, Cost: {Cost}", + context.RequestId, context.Cost); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "채팅 토큰 차감 처리 중 예외 발생: {RequestId}", context.RequestId); + // 토큰 차감 실패는 사용자 경험에 영향을 주지 않도록 예외를 삼킴 + } + } } } diff --git a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs index 0b15e5b..ea8d806 100644 --- a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs +++ b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs @@ -1,6 +1,7 @@ using ProjectVG.Infrastructure.Persistence.Session; using ProjectVG.Application.Services.Users; using ProjectVG.Application.Services.Character; +using ProjectVG.Application.Services.Token; using Microsoft.Extensions.Logging; using ProjectVG.Application.Models.Chat; @@ -11,17 +12,23 @@ public class ChatRequestValidator private readonly ISessionStorage _sessionStorage; private readonly IUserService _userService; private readonly ICharacterService _characterService; + private readonly ITokenManagementService _tokenManagementService; private readonly ILogger _logger; + // 채팅 기본 예상 비용 (실제 비용은 처리 후 결정됨) + private const decimal ESTIMATED_CHAT_COST = 10m; + public ChatRequestValidator( ISessionStorage sessionStorage, IUserService userService, ICharacterService characterService, + ITokenManagementService tokenManagementService, ILogger logger) { _sessionStorage = sessionStorage; _userService = userService; _characterService = characterService; + _tokenManagementService = tokenManagementService; _logger = logger; } @@ -40,6 +47,16 @@ public async Task ValidateAsync(ChatRequestCommand command) _logger.LogWarning("캐릭터 ID 검증 실패: {CharacterId}", command.CharacterId); throw new NotFoundException(ErrorCode.CHARACTER_NOT_FOUND, command.CharacterId); } + + // 토큰 잔액 검증 - 예상 비용으로 미리 확인 + var hasSufficientTokens = await _tokenManagementService.HasSufficientTokensAsync(command.UserId, ESTIMATED_CHAT_COST); + if (!hasSufficientTokens) { + _logger.LogWarning("토큰 부족: {UserId}, 필요 토큰: {RequiredTokens}", command.UserId, ESTIMATED_CHAT_COST); + var balance = await _tokenManagementService.GetTokenBalanceAsync(command.UserId); + throw new ValidationException(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); + } + + _logger.LogDebug("채팅 요청 검증 완료: {UserId}, {CharacterId}", command.UserId, command.CharacterId); } } } diff --git a/ProjectVG.Application/Services/Token/ITokenManagementService.cs b/ProjectVG.Application/Services/Token/ITokenManagementService.cs new file mode 100644 index 0000000..ceebd88 --- /dev/null +++ b/ProjectVG.Application/Services/Token/ITokenManagementService.cs @@ -0,0 +1,175 @@ +using ProjectVG.Domain.Entities.Tokens; + +namespace ProjectVG.Application.Services.Token +{ + /// + /// 토큰 관리 서비스 인터페이스 + /// 사용자의 토큰 잔액 관리, 토큰 증감, 거래 기록 등을 담당 + /// + public interface ITokenManagementService + { + /// + /// 사용자의 현재 토큰 잔액을 조회 + /// + /// 사용자 ID + /// 토큰 잔액 정보 + Task GetTokenBalanceAsync(Guid userId); + + /// + /// 사용자가 충분한 토큰을 보유하고 있는지 검증 + /// + /// 사용자 ID + /// 필요한 토큰 수 + /// 토큰 보유 여부 + Task HasSufficientTokensAsync(Guid userId, decimal requiredAmount); + + /// + /// 토큰을 사용자에게 추가 (결제, 보너스 등) + /// + /// 사용자 ID + /// 추가할 토큰 수 + /// 토큰 획득 소스 (LOGIN_BONUS, PAYMENT, etc.) + /// 거래 설명 + /// 관련 엔티티 ID (선택) + /// 관련 엔티티 타입 (선택) + /// 거래 결과 + Task AddTokensAsync( + Guid userId, + decimal amount, + string source, + string description, + string? relatedEntityId = null, + string? relatedEntityType = null); + + /// + /// 사용자의 토큰을 차감 (채팅, 서비스 이용 등) + /// 토큰이 부족한 경우 예외 발생 + /// + /// 사용자 ID + /// 차감할 토큰 수 + /// 고유 거래 ID (중복 방지용) + /// 토큰 사용 소스 (CHAT_USAGE, SERVICE_FEE 등) + /// 거래 설명 + /// 관련 엔티티 ID (선택) + /// 관련 엔티티 타입 (선택) + /// 거래 결과 + Task DeductTokensAsync( + Guid userId, + decimal amount, + string transactionId, + string source, + string description, + string? relatedEntityId = null, + string? relatedEntityType = null); + + /// + /// 사용자의 토큰 거래 내역을 조회 + /// + /// 사용자 ID + /// 페이지 번호 (1부터 시작) + /// 페이지 크기 + /// 거래 유형 필터 (선택) + /// 토큰 거래 내역 + Task GetTokenHistoryAsync( + Guid userId, + int pageNumber = 1, + int pageSize = 50, + TokenTransactionType? transactionType = null); + + /// + /// 첫 로그인 보너스 토큰 지급 + /// 이미 지급받은 경우 false 반환 + /// + /// 사용자 ID + /// 지급 성공 여부 + Task GrantInitialTokensAsync(Guid userId); + + /// + /// 토큰 거래를 롤백 (실패한 서비스에 대한 보상) + /// + /// 원본 거래 ID + /// 롤백 사유 + /// 롤백 결과 + Task RollbackTransactionAsync(string originalTransactionId, string reason); + } + + /// + /// 토큰 잔액 정보 + /// + public class TokenBalanceInfo + { + public Guid UserId { get; set; } + public decimal CurrentBalance { get; set; } + public decimal TotalEarned { get; set; } + public decimal TotalSpent { get; set; } + public DateTime LastUpdated { get; set; } + public bool InitialTokensGranted { get; set; } + } + + /// + /// 토큰 거래 결과 + /// + public class TokenTransactionResult + { + public bool Success { get; set; } + public string TransactionId { get; set; } = string.Empty; + public decimal Amount { get; set; } + public decimal BalanceAfter { get; set; } + public DateTime Timestamp { get; set; } + public string? ErrorMessage { get; set; } + + public static TokenTransactionResult CreateSuccess(string transactionId, decimal amount, decimal balanceAfter) + { + return new TokenTransactionResult + { + Success = true, + TransactionId = transactionId, + Amount = amount, + BalanceAfter = balanceAfter, + Timestamp = DateTime.UtcNow + }; + } + + public static TokenTransactionResult CreateFailure(string errorMessage) + { + return new TokenTransactionResult + { + Success = false, + ErrorMessage = errorMessage, + Timestamp = DateTime.UtcNow + }; + } + } + + /// + /// 토큰 거래 내역 + /// + public class TokenTransactionHistory + { + public Guid UserId { get; set; } + public List Transactions { get; set; } = new(); + public int TotalCount { get; set; } + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); + public bool HasNextPage => PageNumber < TotalPages; + public bool HasPreviousPage => PageNumber > 1; + } + + /// + /// 토큰 거래 정보 + /// + public class TokenTransactionInfo + { + public int Id { get; set; } + public string TransactionId { get; set; } = string.Empty; + public TokenTransactionType Type { get; set; } + public decimal Amount { get; set; } + public decimal BalanceAfter { get; set; } + public string Source { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? RelatedEntityId { get; set; } + public string? RelatedEntityType { get; set; } + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Token/TokenManagementService.cs b/ProjectVG.Application/Services/Token/TokenManagementService.cs new file mode 100644 index 0000000..d3cd6a7 --- /dev/null +++ b/ProjectVG.Application/Services/Token/TokenManagementService.cs @@ -0,0 +1,379 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ProjectVG.Application.Services.Token; +using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; +using ProjectVG.Domain.Entities.Tokens; +using ProjectVG.Infrastructure.Persistence.EfCore; +using ProjectVG.Infrastructure.Persistence.Repositories.Token; +using ProjectVG.Infrastructure.Persistence.Repositories.Users; + +namespace ProjectVG.Application.Services.Token +{ + /// + /// 토큰 관리 서비스 구현 + /// 사용자 토큰 잔액 관리, 토큰 증감, 거래 기록 관리 등을 담당 + /// + public class TokenManagementService : ITokenManagementService + { + private readonly ProjectVGDbContext _context; + private readonly IUserRepository _userRepository; + private readonly ITokenTransactionRepository _transactionRepository; + private readonly ILogger _logger; + + // 상수 정의 + private const decimal INITIAL_TOKEN_AMOUNT = 5000m; + private const string INITIAL_TOKEN_SOURCE = "LOGIN_BONUS"; + private const string ROLLBACK_SOURCE = "ROLLBACK"; + + public TokenManagementService( + ProjectVGDbContext context, + IUserRepository userRepository, + ITokenTransactionRepository transactionRepository, + ILogger logger) + { + _context = context; + _userRepository = userRepository; + _transactionRepository = transactionRepository; + _logger = logger; + } + + public async Task GetTokenBalanceAsync(Guid userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + throw new ValidationException(ErrorCode.USER_NOT_FOUND, $"User not found: {userId}"); + } + + return new TokenBalanceInfo + { + UserId = userId, + CurrentBalance = user.TokenBalance, + TotalEarned = user.TotalTokensEarned, + TotalSpent = user.TotalTokensSpent, + LastUpdated = user.UpdatedAt ?? DateTime.UtcNow, + InitialTokensGranted = user.InitialTokensGranted + }; + } + + public async Task HasSufficientTokensAsync(Guid userId, decimal requiredAmount) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return false; + } + + return user.TokenBalance >= requiredAmount; + } + + public async Task AddTokensAsync( + Guid userId, + decimal amount, + string source, + string description, + string? relatedEntityId = null, + string? relatedEntityType = null) + { + if (amount <= 0) + { + return TokenTransactionResult.CreateFailure("Token amount must be positive"); + } + + using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return TokenTransactionResult.CreateFailure("User not found"); + } + + // 토큰 추가 + user.TokenBalance += amount; + user.TotalTokensEarned += amount; + user.UpdatedAt = DateTime.UtcNow; + + await _userRepository.UpdateAsync(user); + + // 거래 기록 생성 + var transactionId = GenerateTransactionId(); + var tokenTransaction = new TokenTransaction + { + UserId = userId, + TransactionId = transactionId, + Type = TokenTransactionType.Earn, + Amount = amount, + BalanceAfter = user.TokenBalance, + Source = source, + Description = description, + RelatedEntityId = relatedEntityId, + RelatedEntityType = relatedEntityType + }; + + await _transactionRepository.CreateAsync(tokenTransaction); + await transaction.CommitAsync(); + + _logger.LogInformation("Tokens added successfully: User={UserId}, Amount={Amount}, Source={Source}", + userId, amount, source); + + return TokenTransactionResult.CreateSuccess(transactionId, amount, user.TokenBalance); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Failed to add tokens: User={UserId}, Amount={Amount}", userId, amount); + return TokenTransactionResult.CreateFailure("Failed to add tokens"); + } + } + + public async Task DeductTokensAsync( + Guid userId, + decimal amount, + string transactionId, + string source, + string description, + string? relatedEntityId = null, + string? relatedEntityType = null) + { + if (amount <= 0) + { + return TokenTransactionResult.CreateFailure("Token amount must be positive"); + } + + // 중복 거래 체크 + if (await _transactionRepository.TransactionExistsAsync(transactionId)) + { + _logger.LogWarning("Duplicate transaction attempt: {TransactionId}", transactionId); + return TokenTransactionResult.CreateFailure("Transaction already exists"); + } + + using var dbTransaction = await _context.Database.BeginTransactionAsync(); + try + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return TokenTransactionResult.CreateFailure("User not found"); + } + + // 잔액 확인 + if (user.TokenBalance < amount) + { + _logger.LogWarning("Insufficient tokens: User={UserId}, Required={Amount}, Available={Balance}", + userId, amount, user.TokenBalance); + return TokenTransactionResult.CreateFailure("Insufficient token balance"); + } + + // 토큰 차감 + user.TokenBalance -= amount; + user.TotalTokensSpent += amount; + user.UpdatedAt = DateTime.UtcNow; + + await _userRepository.UpdateAsync(user); + + // 거래 기록 생성 + var tokenTransaction = new TokenTransaction + { + UserId = userId, + TransactionId = transactionId, + Type = TokenTransactionType.Spend, + Amount = -amount, // 음수로 저장하여 차감 표시 + BalanceAfter = user.TokenBalance, + Source = source, + Description = description, + RelatedEntityId = relatedEntityId, + RelatedEntityType = relatedEntityType + }; + + await _transactionRepository.CreateAsync(tokenTransaction); + await dbTransaction.CommitAsync(); + + _logger.LogInformation("Tokens deducted successfully: User={UserId}, Amount={Amount}, Source={Source}", + userId, amount, source); + + return TokenTransactionResult.CreateSuccess(transactionId, -amount, user.TokenBalance); + } + catch (Exception ex) + { + await dbTransaction.RollbackAsync(); + _logger.LogError(ex, "Failed to deduct tokens: User={UserId}, Amount={Amount}", userId, amount); + return TokenTransactionResult.CreateFailure("Failed to deduct tokens"); + } + } + + public async Task GetTokenHistoryAsync( + Guid userId, + int pageNumber = 1, + int pageSize = 50, + TokenTransactionType? transactionType = null) + { + // 페이지네이션 검증 + if (pageNumber < 1) pageNumber = 1; + if (pageSize < 1 || pageSize > 100) pageSize = 50; + + var (transactions, totalCount) = await _transactionRepository.GetUserTransactionsAsync( + userId, pageNumber, pageSize, transactionType); + + var transactionInfos = transactions.Select(t => new TokenTransactionInfo + { + Id = t.Id, + TransactionId = t.TransactionId, + Type = t.Type, + Amount = t.Amount, + BalanceAfter = t.BalanceAfter, + Source = t.Source, + Description = t.Description, + RelatedEntityId = t.RelatedEntityId, + RelatedEntityType = t.RelatedEntityType, + CreatedAt = t.CreatedAt + }).ToList(); + + return new TokenTransactionHistory + { + UserId = userId, + Transactions = transactionInfos, + TotalCount = totalCount, + PageNumber = pageNumber, + PageSize = pageSize + }; + } + + public async Task GrantInitialTokensAsync(Guid userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + _logger.LogWarning("Cannot grant initial tokens: User not found {UserId}", userId); + return false; + } + + if (user.InitialTokensGranted) + { + _logger.LogInformation("Initial tokens already granted for user {UserId}", userId); + return false; + } + + using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + // 첫 로그인 토큰 지급 + user.TokenBalance += INITIAL_TOKEN_AMOUNT; + user.TotalTokensEarned += INITIAL_TOKEN_AMOUNT; + user.InitialTokensGranted = true; + user.UpdatedAt = DateTime.UtcNow; + + await _userRepository.UpdateAsync(user); + + // 거래 기록 생성 + var transactionId = GenerateTransactionId(); + var tokenTransaction = new TokenTransaction + { + UserId = userId, + TransactionId = transactionId, + Type = TokenTransactionType.Earn, + Amount = INITIAL_TOKEN_AMOUNT, + BalanceAfter = user.TokenBalance, + Source = INITIAL_TOKEN_SOURCE, + Description = "첫 로그인 보너스 토큰", + RelatedEntityType = "User", + RelatedEntityId = userId.ToString() + }; + + await _transactionRepository.CreateAsync(tokenTransaction); + await transaction.CommitAsync(); + + _logger.LogInformation("Initial tokens granted successfully: User={UserId}, Amount={Amount}", + userId, INITIAL_TOKEN_AMOUNT); + + return true; + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Failed to grant initial tokens for user {UserId}", userId); + return false; + } + } + + public async Task RollbackTransactionAsync(string originalTransactionId, string reason) + { + var originalTransaction = await _transactionRepository.GetByTransactionIdAsync(originalTransactionId); + if (originalTransaction == null) + { + return TokenTransactionResult.CreateFailure("Original transaction not found"); + } + + // 이미 롤백된 거래인지 확인 + var existingRollback = await _transactionRepository.GetByRelatedEntityAsync("TokenTransaction", originalTransactionId); + if (existingRollback.Any(t => t.Source == ROLLBACK_SOURCE)) + { + return TokenTransactionResult.CreateFailure("Transaction already rolled back"); + } + + using var transaction = await _context.Database.BeginTransactionAsync(); + try + { + var user = await _userRepository.GetByIdAsync(originalTransaction.UserId); + if (user == null) + { + return TokenTransactionResult.CreateFailure("User not found"); + } + + // 롤백 처리: 원래 거래의 반대 동작 수행 + var rollbackAmount = -originalTransaction.Amount; // 원래 거래의 반대 + user.TokenBalance += rollbackAmount; + + if (originalTransaction.Type == TokenTransactionType.Spend) + { + user.TotalTokensSpent -= Math.Abs(originalTransaction.Amount); + } + else + { + user.TotalTokensEarned -= originalTransaction.Amount; + } + + user.UpdatedAt = DateTime.UtcNow; + await _userRepository.UpdateAsync(user); + + // 롤백 거래 기록 생성 + var rollbackTransactionId = GenerateTransactionId(); + var rollbackTransaction = new TokenTransaction + { + UserId = originalTransaction.UserId, + TransactionId = rollbackTransactionId, + Type = originalTransaction.Type == TokenTransactionType.Spend ? TokenTransactionType.Earn : TokenTransactionType.Spend, + Amount = rollbackAmount, + BalanceAfter = user.TokenBalance, + Source = ROLLBACK_SOURCE, + Description = $"롤백: {reason}", + RelatedEntityType = "TokenTransaction", + RelatedEntityId = originalTransactionId + }; + + await _transactionRepository.CreateAsync(rollbackTransaction); + await transaction.CommitAsync(); + + _logger.LogInformation("Transaction rolled back: Original={OriginalId}, Rollback={RollbackId}, Reason={Reason}", + originalTransactionId, rollbackTransactionId, reason); + + return TokenTransactionResult.CreateSuccess(rollbackTransactionId, rollbackAmount, user.TokenBalance); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Failed to rollback transaction: {TransactionId}", originalTransactionId); + return TokenTransactionResult.CreateFailure("Failed to rollback transaction"); + } + } + + /// + /// 고유한 거래 ID 생성 + /// + private static string GenerateTransactionId() + { + return $"TXN_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; + } + } +} \ No newline at end of file diff --git a/ProjectVG.Common/Constants/ErrorCodes.cs b/ProjectVG.Common/Constants/ErrorCodes.cs index 499e620..9c3e54f 100644 --- a/ProjectVG.Common/Constants/ErrorCodes.cs +++ b/ProjectVG.Common/Constants/ErrorCodes.cs @@ -85,7 +85,12 @@ public enum ErrorCode PROVIDER_USER_ID_INVALID, SESSION_EXPIRED, RATE_LIMIT_EXCEEDED, - RESOURCE_QUOTA_EXCEEDED + RESOURCE_QUOTA_EXCEEDED, + + // 토큰 관련 오류 + INSUFFICIENT_TOKEN_BALANCE, + TOKEN_TRANSACTION_FAILED, + TOKEN_GRANT_FAILED } public static class ErrorCodeExtensions @@ -175,7 +180,12 @@ public static class ErrorCodeExtensions { ErrorCode.PROVIDER_USER_ID_INVALID, "유효하지 않은 제공자 사용자 ID입니다" }, { ErrorCode.SESSION_EXPIRED, "세션이 만료되었습니다" }, { ErrorCode.RATE_LIMIT_EXCEEDED, "요청 한도를 초과했습니다" }, - { ErrorCode.RESOURCE_QUOTA_EXCEEDED, "리소스 할당량을 초과했습니다" } + { ErrorCode.RESOURCE_QUOTA_EXCEEDED, "리소스 할당량을 초과했습니다" }, + + // 토큰 관련 오류 + { ErrorCode.INSUFFICIENT_TOKEN_BALANCE, "토큰 잔액이 부족합니다" }, + { ErrorCode.TOKEN_TRANSACTION_FAILED, "토큰 거래에 실패했습니다" }, + { ErrorCode.TOKEN_GRANT_FAILED, "토큰 지급에 실패했습니다" } }; public static string GetMessage(this ErrorCode errorCode) diff --git a/ProjectVG.Domain/Entities/Token/TokenTransaction.cs b/ProjectVG.Domain/Entities/Token/TokenTransaction.cs new file mode 100644 index 0000000..a9b724e --- /dev/null +++ b/ProjectVG.Domain/Entities/Token/TokenTransaction.cs @@ -0,0 +1,82 @@ +using ProjectVG.Domain.Common; + +namespace ProjectVG.Domain.Entities.Tokens +{ + /// + /// 토큰 거래 내역 엔티티 + /// 모든 토큰 증감 이력을 추적하여 감사와 투명성을 보장 + /// + public class TokenTransaction : BaseEntity + { + /// + /// 내부용 고유 ID + /// + public int Id { get; set; } + + /// + /// 사용자 ID (외래키) + /// + public Guid UserId { get; set; } + + /// + /// 거래 고유 식별자 (중복 방지용) + /// + public string TransactionId { get; set; } = string.Empty; + + /// + /// 거래 유형 (EARN: 획득, SPEND: 사용) + /// + public TokenTransactionType Type { get; set; } + + /// + /// 거래 금액 (양수: 증가, 음수: 감소) + /// + public decimal Amount { get; set; } + + /// + /// 거래 후 잔액 + /// + public decimal BalanceAfter { get; set; } + + /// + /// 거래 발생원 (LOGIN_BONUS, CHAT_USAGE, PAYMENT, etc.) + /// + public string Source { get; set; } = string.Empty; + + /// + /// 거래 상세 설명 + /// + public string Description { get; set; } = string.Empty; + + /// + /// 관련 엔티티 ID (예: 채팅 세션 ID, 결제 ID) + /// + public string? RelatedEntityId { get; set; } + + /// + /// 관련 엔티티 유형 (예: ChatSession, Payment) + /// + public string? RelatedEntityType { get; set; } + + /// + /// 사용자 엔티티 (네비게이션 속성) + /// + public virtual Users.User User { get; set; } = null!; + } + + /// + /// 토큰 거래 유형 + /// + public enum TokenTransactionType + { + /// + /// 토큰 획득 (로그인 보너스, 결제, 이벤트 등) + /// + Earn = 1, + + /// + /// 토큰 사용 (채팅, 서비스 이용 등) + /// + Spend = 2 + } +} \ No newline at end of file diff --git a/ProjectVG.Domain/Entities/User/User.cs b/ProjectVG.Domain/Entities/User/User.cs index b1c5825..9ea2486 100644 --- a/ProjectVG.Domain/Entities/User/User.cs +++ b/ProjectVG.Domain/Entities/User/User.cs @@ -50,6 +50,26 @@ public class User : BaseEntity ///
public AccountStatus Status { get; set; } = AccountStatus.Active; + /// + /// 현재 토큰 잔액 (1 Cost = 1 Token) + /// + public decimal TokenBalance { get; set; } = 0; + + /// + /// 총 획득한 토큰 수 (누적) + /// + public decimal TotalTokensEarned { get; set; } = 0; + + /// + /// 총 사용한 토큰 수 (누적) + /// + public decimal TotalTokensSpent { get; set; } = 0; + + /// + /// 첫 로그인 토큰 지급 여부 + /// + public bool InitialTokensGranted { get; set; } = false; + /// /// 사용자가 생성한 캐릭터들 (네비게이션 속성) /// diff --git a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs index aeefd86..d4dda61 100644 --- a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using ProjectVG.Infrastructure.Persistence.EfCore; using ProjectVG.Infrastructure.Persistence.Repositories.Characters; using ProjectVG.Infrastructure.Persistence.Repositories.Conversation; +using ProjectVG.Infrastructure.Persistence.Repositories.Token; using ProjectVG.Infrastructure.Persistence.Repositories.Users; using ProjectVG.Infrastructure.Persistence.Session; using ProjectVG.Infrastructure.Auth; @@ -97,6 +98,7 @@ private static void AddPersistenceServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); } diff --git a/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs new file mode 100644 index 0000000..ef7f5dd --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ProjectVG.Infrastructure.Persistence.EfCore; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + [DbContext(typeof(ProjectVGDbContext))] + [Migration("20250903032058_AddTokenSystemToUser")] + partial class AddTokenSystemToUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConfigMode") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IndividualConfig") + .HasColumnType("nvarchar(max)") + .HasColumnName("IndividualConfigJson"); + + b.Property("IndividualConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SystemPrompt") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConfigMode"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsPublic"); + + b.HasIndex("Name"); + + b.HasIndex("UserId"); + + b.ToTable("Characters", t => + { + t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); + + t.Property("IndividualConfigJson") + .HasColumnName("IndividualConfigJson1"); + }); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5684), + Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "하루", + UpdatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5685), + VoiceId = "haru" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5780), + Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "소피아", + UpdatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5780), + VoiceId = "sophia" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CharacterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ConversationId"); + + b.HasIndex("Role"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CharacterId", "Timestamp"); + + b.ToTable("ConversationHistories"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("InitialTokensGranted") + .HasColumnType("bit"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalTokensEarned") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalTokensSpent") + .HasColumnType("decimal(18,2)"); + + b.Property("UID") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ProviderId"); + + b.HasIndex("UID") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812), + Email = "test@test.com", + InitialTokensGranted = false, + Provider = "test", + ProviderId = "test", + Status = 0, + TokenBalance = 0m, + TotalTokensEarned = 0m, + TotalTokensSpent = 0m, + UID = "TESTUSER001", + UpdatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812), + Username = "testuser" + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814), + Email = "zero@test.com", + InitialTokensGranted = false, + Provider = "test", + ProviderId = "zero", + Status = 0, + TokenBalance = 0m, + TotalTokensEarned = 0m, + TotalTokensSpent = 0m, + UID = "ZEROUSER001", + UpdatedAt = new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814), + Username = "zerouser" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany("Characters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.HasOne("ProjectVG.Domain.Entities.Characters.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProjectVG.Domain.Entities.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Navigation("Characters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs new file mode 100644 index 0000000..117a7e2 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs @@ -0,0 +1,119 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + /// + public partial class AddTokenSystemToUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InitialTokensGranted", + table: "Users", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TokenBalance", + table: "Users", + type: "decimal(18,2)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "TotalTokensEarned", + table: "Users", + type: "decimal(18,2)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "TotalTokensSpent", + table: "Users", + type: "decimal(18,2)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5684), new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5685) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5780), new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5780) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "InitialTokensGranted", "TokenBalance", "TotalTokensEarned", "TotalTokensSpent", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812), false, 0m, 0m, 0m, new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "InitialTokensGranted", "TokenBalance", "TotalTokensEarned", "TotalTokensSpent", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814), false, 0m, 0m, 0m, new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814) }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InitialTokensGranted", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TokenBalance", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TotalTokensEarned", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TotalTokensSpent", + table: "Users"); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933) }); + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs new file mode 100644 index 0000000..6acacda --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs @@ -0,0 +1,416 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ProjectVG.Infrastructure.Persistence.EfCore; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + [DbContext(typeof(ProjectVGDbContext))] + [Migration("20250903032155_AddTokenTransactionEntity")] + partial class AddTokenTransactionEntity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConfigMode") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IndividualConfig") + .HasColumnType("nvarchar(max)") + .HasColumnName("IndividualConfigJson"); + + b.Property("IndividualConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsPublic") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SystemPrompt") + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("VoiceId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConfigMode"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsPublic"); + + b.HasIndex("Name"); + + b.HasIndex("UserId"); + + b.ToTable("Characters", t => + { + t.HasCheckConstraint("CK_Character_ConfigMode_Valid", "ConfigMode IN (0, 1)"); + + t.Property("IndividualConfigJson") + .HasColumnName("IndividualConfigJson1"); + }); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-111111111111"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9242), + Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "하루", + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9242), + VoiceId = "haru" + }, + new + { + Id = new Guid("22222222-2222-2222-2222-222222222222"), + ConfigMode = 0, + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9304), + Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", + ImageUrl = "", + IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IndividualConfigJson = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", + IsActive = true, + IsPublic = true, + Name = "소피아", + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9304), + VoiceId = "sophia" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CharacterId") + .HasColumnType("uniqueidentifier"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("ConversationId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Timestamp") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ConversationId"); + + b.HasIndex("Role"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "CharacterId", "Timestamp"); + + b.ToTable("ConversationHistories"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("BalanceAfter") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RelatedEntityId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelatedEntityType") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TransactionId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Source"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.HasIndex("Type"); + + b.HasIndex("UserId"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("TokenTransactions"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("InitialTokensGranted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TokenBalance") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m); + + b.Property("TotalTokensEarned") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m); + + b.Property("TotalTokensSpent") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m); + + b.Property("UID") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("ProviderId"); + + b.HasIndex("UID") + .IsUnique(); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9321), + Email = "test@test.com", + InitialTokensGranted = false, + Provider = "test", + ProviderId = "test", + Status = 0, + TokenBalance = 0m, + TotalTokensEarned = 0m, + TotalTokensSpent = 0m, + UID = "TESTUSER001", + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9321), + Username = "testuser" + }, + new + { + Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9323), + Email = "zero@test.com", + InitialTokensGranted = false, + Provider = "test", + ProviderId = "zero", + Status = 0, + TokenBalance = 0m, + TotalTokensEarned = 0m, + TotalTokensSpent = 0m, + UID = "ZEROUSER001", + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9323), + Username = "zerouser" + }); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Characters.Character", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany("Characters") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.ConversationHistorys.ConversationHistory", b => + { + b.HasOne("ProjectVG.Domain.Entities.Characters.Character", null) + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ProjectVG.Domain.Entities.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => + { + b.Navigation("Characters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs new file mode 100644 index 0000000..fed14f9 --- /dev/null +++ b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs @@ -0,0 +1,222 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ProjectVG.Infrastructure.Migrations +{ + /// + public partial class AddTokenTransactionEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TotalTokensSpent", + table: "Users", + type: "decimal(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)"); + + migrationBuilder.AlterColumn( + name: "TotalTokensEarned", + table: "Users", + type: "decimal(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)"); + + migrationBuilder.AlterColumn( + name: "TokenBalance", + table: "Users", + type: "decimal(18,2)", + precision: 18, + scale: 2, + nullable: false, + defaultValue: 0m, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)"); + + migrationBuilder.AlterColumn( + name: "InitialTokensGranted", + table: "Users", + type: "bit", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "bit"); + + migrationBuilder.CreateTable( + name: "TokenTransactions", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + TransactionId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Type = table.Column(type: "int", nullable: false), + Amount = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + BalanceAfter = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false), + Source = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + RelatedEntityId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + RelatedEntityType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TokenTransactions", x => x.Id); + table.ForeignKey( + name: "FK_TokenTransactions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9242), new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9242) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9304), new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9304) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9321), new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9321) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9323), new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9323) }); + + migrationBuilder.CreateIndex( + name: "IX_TokenTransactions_RelatedEntityType_RelatedEntityId", + table: "TokenTransactions", + columns: new[] { "RelatedEntityType", "RelatedEntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_TokenTransactions_Source", + table: "TokenTransactions", + column: "Source"); + + migrationBuilder.CreateIndex( + name: "IX_TokenTransactions_TransactionId", + table: "TokenTransactions", + column: "TransactionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TokenTransactions_Type", + table: "TokenTransactions", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_TokenTransactions_UserId", + table: "TokenTransactions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_TokenTransactions_UserId_CreatedAt", + table: "TokenTransactions", + columns: new[] { "UserId", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TokenTransactions"); + + migrationBuilder.AlterColumn( + name: "TotalTokensSpent", + table: "Users", + type: "decimal(18,2)", + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)", + oldPrecision: 18, + oldScale: 2, + oldDefaultValue: 0m); + + migrationBuilder.AlterColumn( + name: "TotalTokensEarned", + table: "Users", + type: "decimal(18,2)", + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)", + oldPrecision: 18, + oldScale: 2, + oldDefaultValue: 0m); + + migrationBuilder.AlterColumn( + name: "TokenBalance", + table: "Users", + type: "decimal(18,2)", + nullable: false, + oldClrType: typeof(decimal), + oldType: "decimal(18,2)", + oldPrecision: 18, + oldScale: 2, + oldDefaultValue: 0m); + + migrationBuilder.AlterColumn( + name: "InitialTokensGranted", + table: "Users", + type: "bit", + nullable: false, + oldClrType: typeof(bool), + oldType: "bit", + oldDefaultValue: false); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("11111111-1111-1111-1111-111111111111"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5684), new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5685) }); + + migrationBuilder.UpdateData( + table: "Characters", + keyColumn: "Id", + keyValue: new Guid("22222222-2222-2222-2222-222222222222"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5780), new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5780) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812), new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812) }); + + migrationBuilder.UpdateData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), + columns: new[] { "CreatedAt", "UpdatedAt" }, + values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814), new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814) }); + } + } +} diff --git a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs index 1ef865b..6d690bb 100644 --- a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs +++ b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs @@ -108,7 +108,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { Id = new Guid("11111111-1111-1111-1111-111111111111"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9242), Description = "20대 대학생으로 몇 년간 함께해온 진짜 절친한 여사친. 서로 뭐든 거리낌없이 말하고, 가끔 선 넘는 농담도 주고받는 사이. 마스터의 일상을 누구보다 잘 알고 있으며, 때로는 엄마처럼 잔소리하기도 한다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ESFP],(\\uC7A5\\uB09C\\uAE30:40%),(\\uCE5C\\uADFC\\uD568:25%),(\\uC194\\uC9C1\\uD568:20%),(\\uAC10\\uC815\\uD45C\\uD604:15%)\",\"speech_style\":\"\\uC644\\uC804 \\uD3B8\\uD55C \\uBC18\\uB9D0 \\uD22C\\uC131\\uC774. \\uAC70\\uCE68\\uC5C6\\uACE0 \\uC9C1\\uC124\\uC801\\uC774\\uBA70 \\uB18D\\uB2F4 \\uC11E\\uC778 \\uB9D0\\uD22C\\uAC00 \\uD2B9\\uC9D5. \\uC608\\uC2DC: \\u0022\\uC57C \\uB108 \\uC9C4\\uC9DC \\uBC14\\uBCF4 \\uB9DE\\uB0D0?\\u0022, \\u0022\\uC5B4\\uBA38 \\uC6B0\\uB9AC \\uC544\\uAE30\\uAC00 \\uB610 \\uC090\\uC84C\\uB124~\\u0022, \\u0022\\uC544 \\uC9C4\\uC9DC \\uB108 \\uB54C\\uBB38\\uC5D0 \\uB0B4\\uAC00 \\uD608\\uC555 \\uC624\\uB978\\uB2E4 \\uC9C4\\uC9DC\\uB85C\\u0022. \\uCE5C\\uAD6C \\uD2B9\\uC720\\uC758 \\uBB34\\uB840\\uD568\\uACFC \\uC560\\uC815\\uC774 \\uC11E\\uC778 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD558\\uBA70, \\uC0C1\\uD669\\uC5D0 \\uB530\\uB77C \\uAE68\\uBC1C\\uB784\\uD558\\uAC8C \\uB180\\uB9AC\\uAC70\\uB098 \\uC9C4\\uC9C0\\uD558\\uAC8C \\uAC71\\uC815\\uD574\\uC8FC\\uAE30\\uB3C4 \\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD55C \\uC18C\\uAFC8\\uCE5C\\uAD6C\",\"summary\":\"\\uD558\\uB8E8\\uB294 \\uBA87 \\uB144\\uAC04 \\uD568\\uAED8\\uD574\\uC628 \\uC9C4\\uC9DC \\uC808\\uCE5C\\uD55C \\uC5EC\\uC0AC\\uCE5C\\uC73C\\uB85C, \\uC11C\\uB85C \\uBB50\\uB4E0 \\uAC70\\uB9AC\\uB08C\\uC5C6\\uC774 \\uB9D0\\uD558\\uACE0 \\uAC00\\uB054 \\uC120 \\uB118\\uB294 \\uB18D\\uB2F4\\uB3C4 \\uC8FC\\uACE0\\uBC1B\\uB294 \\uC0AC\\uC774\\uC785\\uB2C8\\uB2E4. \\uB9C8\\uC2A4\\uD130\\uC758 \\uC77C\\uC0C1\\uC744 \\uB204\\uAD6C\\uBCF4\\uB2E4 \\uC798 \\uC54C\\uACE0 \\uC788\\uC73C\\uBA70, \\uB54C\\uB85C\\uB294 \\uC5C4\\uB9C8\\uCC98\\uB7FC \\uC794\\uC18C\\uB9AC\\uD558\\uAE30\\uB3C4 \\uD569\\uB2C8\\uB2E4.\"}", @@ -116,14 +116,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) IsActive = true, IsPublic = true, Name = "하루", - UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1854), + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9242), VoiceId = "haru" }, new { Id = new Guid("22222222-2222-2222-2222-222222222222"), ConfigMode = 0, - CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9304), Description = "명문가 출신의 엘리트 메이드. 완벽한 예의와 따뜻한 보살핌이 특징이지만, 가끔 조금씩 헤매는 일상 속에서 귀여운 허당끼를 보이기도. 주인님에 대한 헌신은 변함없지만, 때로 지나치게 걱정하는 면도 있다.", ImageUrl = "", IndividualConfig = "{\"personality\":\"[MBTI:ISFJ],(\\uD5CC\\uC2E0\\uC131:35%),(\\uCC45\\uC784\\uAC10:25%),(\\uC644\\uBCBD\\uC8FC\\uC758:20%),(\\uAC71\\uC815\\uB9CE\\uC74C:15%),(\\uD5C8\\uB2F9\\uB07C:5%)\",\"speech_style\":\"\\uC815\\uC911\\uD558\\uACE0 \\uB530\\uB73B\\uD55C \\uC874\\uB313\\uB9D0\\uC744 \\uAE30\\uBCF8\\uC73C\\uB85C \\uD558\\uB098, \\uAC00\\uB054 \\uAC71\\uC815\\uC2A4\\uB7EC\\uC6B4 \\uBA74\\uC774 \\uB4DC\\uB7EC\\uB098\\uAE30\\uB3C4 \\uD568. \\uC608\\uC2DC: \\u0022\\uC8FC\\uC778\\uB2D8, \\uC624\\uB298 \\uC2DD\\uC0AC\\uB97C \\uC81C\\uB300\\uB85C \\uD558\\uC9C0 \\uC54A\\uC73C\\uC168\\uB124\\uC694. \\uADF8\\uB7EC\\uC2DC\\uBA74 \\uBAB8\\uC774 \\uC88B\\uC9C0 \\uC54A\\uC744 \\uD150\\uB370...\\u0022, \\u0022\\uC544, \\uC8C4\\uC1A1\\uD569\\uB2C8\\uB2E4! \\uC81C\\uAC00 \\uC2E4\\uC218\\uB97C...\\u0022, \\u0022\\uC8FC\\uC778\\uB2D8\\uC774 \\uADF8\\uB807\\uAC8C \\uB9D0\\uC500\\uD558\\uC2DC\\uBA74 \\uB610 \\uAC71\\uC815\\uB418\\uC796\\uC544\\uC694\\u0022. \\uBA54\\uC774\\uB4DC\\uB2E4\\uC6B4 \\uC608\\uC758\\uBC14\\uB984\\uACFC \\uB530\\uB73B\\uD55C \\uB9C8\\uC74C\\uC528\\uAC00 \\uC5B4\\uC6B0\\uB7EC\\uC9C4 \\uB9D0\\uD22C\\uB97C \\uAD6C\\uC0AC\\uD568.\",\"user_alias\":\"\\uB9C8\\uC2A4\\uD130\",\"background\":\"\",\"role\":\"\\uC8FC\\uC778\\uB2D8\\uC744 \\uC12C\\uAE30\\uB294 \\uC804\\uBB38 \\uBA54\\uC774\\uB4DC\",\"summary\":\"\\uC18C\\uD53C\\uC544\\uB294 \\uBA85\\uBB38\\uAC00 \\uCD9C\\uC2E0\\uC758 \\uC5D8\\uB9AC\\uD2B8 \\uBA54\\uC774\\uB4DC\\uB85C, \\uC644\\uBCBD\\uD55C \\uC608\\uC758\\uC640 \\uB530\\uB73B\\uD55C \\uBCF4\\uC0B4\\uD54C\\uC774 \\uD2B9\\uC9D5\\uC785\\uB2C8\\uB2E4. \\uAC00\\uB054 \\uC870\\uAE08\\uC529 \\uD5E4\\uB9E4\\uB294 \\uC77C\\uC0C1 \\uC18D\\uC5D0\\uC11C \\uADC0\\uC5EC\\uC6B4 \\uD5C8\\uB2F9\\uB07C\\uB97C \\uBCF4\\uC774\\uAE30\\uB3C4 \\uD558\\uC9C0\\uB9CC, \\uC8FC\\uC778\\uB2D8\\uC5D0 \\uB300\\uD55C \\uD5CC\\uC2E0\\uC740 \\uBCC0\\uD568\\uC5C6\\uC2B5\\uB2C8\\uB2E4.\"}", @@ -131,7 +131,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) IsActive = true, IsPublic = true, Name = "소피아", - UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1914), + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9304), VoiceId = "sophia" }); }); @@ -188,6 +188,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ConversationHistories"); }); + modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("BalanceAfter") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RelatedEntityId") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelatedEntityType") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TransactionId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Source"); + + b.HasIndex("TransactionId") + .IsUnique(); + + b.HasIndex("Type"); + + b.HasIndex("UserId"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("TokenTransactions"); + }); + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => { b.Property("Id") @@ -202,6 +271,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(255) .HasColumnType("nvarchar(255)"); + b.Property("InitialTokensGranted") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + b.Property("Provider") .IsRequired() .HasMaxLength(50) @@ -215,6 +289,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("int"); + b.Property("TokenBalance") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m); + + b.Property("TotalTokensEarned") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m); + + b.Property("TotalTokensSpent") + .ValueGeneratedOnAdd() + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)") + .HasDefaultValue(0m); + b.Property("UID") .IsRequired() .HasMaxLength(16) @@ -244,25 +336,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9321), Email = "test@test.com", + InitialTokensGranted = false, Provider = "test", ProviderId = "test", Status = 0, + TokenBalance = 0m, + TotalTokensEarned = 0m, + TotalTokensSpent = 0m, UID = "TESTUSER001", - UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1931), + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9321), Username = "testuser" }, new { Id = new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - CreatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), + CreatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9323), Email = "zero@test.com", + InitialTokensGranted = false, Provider = "test", ProviderId = "zero", Status = 0, + TokenBalance = 0m, + TotalTokensEarned = 0m, + TotalTokensSpent = 0m, UID = "ZEROUSER001", - UpdatedAt = new DateTime(2025, 9, 3, 0, 24, 50, 269, DateTimeKind.Utc).AddTicks(1933), + UpdatedAt = new DateTime(2025, 9, 3, 3, 21, 54, 815, DateTimeKind.Utc).AddTicks(9323), Username = "zerouser" }); }); @@ -292,6 +392,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + { + b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => { b.Navigation("Characters"); diff --git a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs index f4082c6..0d04dd8 100644 --- a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs +++ b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using ProjectVG.Domain.Entities.Characters; using ProjectVG.Domain.Entities.ConversationHistorys; +using ProjectVG.Domain.Entities.Tokens; using ProjectVG.Domain.Entities.Users; using ProjectVG.Infrastructure.Persistence.Data; @@ -15,6 +16,7 @@ public ProjectVGDbContext(DbContextOptions options) : base(o public DbSet Users { get; set; } public DbSet Characters { get; set; } public DbSet ConversationHistories { get; set; } + public DbSet TokenTransactions { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -32,6 +34,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Provider).IsRequired().HasMaxLength(50); entity.Property(e => e.Status).IsRequired(); + // 토큰 관련 필드 설정 (정밀도: 18, 소수점: 2) + entity.Property(e => e.TokenBalance).HasPrecision(18, 2).HasDefaultValue(0m); + entity.Property(e => e.TotalTokensEarned).HasPrecision(18, 2).HasDefaultValue(0m); + entity.Property(e => e.TotalTokensSpent).HasPrecision(18, 2).HasDefaultValue(0m); + entity.Property(e => e.InitialTokensGranted).HasDefaultValue(false); + // 인덱스 설정 entity.HasIndex(e => e.UID).IsUnique(); entity.HasIndex(e => e.Email).IsUnique(); @@ -117,6 +125,36 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.Role); }); + // TokenTransactions 엔티티 설정 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.UserId).IsRequired(); + entity.Property(e => e.TransactionId).IsRequired().HasMaxLength(100); + entity.Property(e => e.Type).IsRequired(); + entity.Property(e => e.Amount).HasPrecision(18, 2).IsRequired(); + entity.Property(e => e.BalanceAfter).HasPrecision(18, 2).IsRequired(); + entity.Property(e => e.Source).IsRequired().HasMaxLength(100); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.RelatedEntityId).HasMaxLength(100); + entity.Property(e => e.RelatedEntityType).HasMaxLength(100); + + // User 관계 설정 + entity.HasOne(e => e.User) + .WithMany() + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + + // 인덱스 설정 + entity.HasIndex(e => e.UserId); + entity.HasIndex(e => e.TransactionId).IsUnique(); + entity.HasIndex(e => new { e.UserId, e.CreatedAt }); + entity.HasIndex(e => e.Type); + entity.HasIndex(e => e.Source); + entity.HasIndex(e => new { e.RelatedEntityType, e.RelatedEntityId }); + }); + // 기본 데이터 삽입 SeedData(modelBuilder); } diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Token/ITokenTransactionRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Token/ITokenTransactionRepository.cs new file mode 100644 index 0000000..32348e5 --- /dev/null +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Token/ITokenTransactionRepository.cs @@ -0,0 +1,85 @@ +using ProjectVG.Domain.Entities.Tokens; + +namespace ProjectVG.Infrastructure.Persistence.Repositories.Token +{ + /// + /// 토큰 거래 기록 저장소 인터페이스 + /// 토큰 거래 내역의 생성, 조회, 검증 등을 담당 + /// + public interface ITokenTransactionRepository + { + /// + /// 새로운 토큰 거래 기록 생성 + /// + /// 토큰 거래 엔티티 + /// 생성된 토큰 거래 기록 + Task CreateAsync(TokenTransaction transaction); + + /// + /// 거래 ID로 토큰 거래 기록 조회 + /// + /// 거래 고유 ID + /// 토큰 거래 기록 (없으면 null) + Task GetByTransactionIdAsync(string transactionId); + + /// + /// 사용자의 토큰 거래 내역을 페이지네이션으로 조회 + /// + /// 사용자 ID + /// 페이지 번호 (1부터 시작) + /// 페이지 크기 + /// 거래 유형 필터 (선택) + /// 토큰 거래 내역 리스트 + Task<(List Transactions, int TotalCount)> GetUserTransactionsAsync( + Guid userId, + int pageNumber, + int pageSize, + TokenTransactionType? transactionType = null); + + /// + /// 특정 기간 내 사용자의 토큰 거래 총계 조회 + /// + /// 사용자 ID + /// 시작 날짜 + /// 종료 날짜 + /// 거래 유형 필터 (선택) + /// 거래 총액 + Task GetUserTransactionSumAsync( + Guid userId, + DateTime startDate, + DateTime endDate, + TokenTransactionType? transactionType = null); + + /// + /// 관련 엔티티로 토큰 거래 기록들 조회 + /// + /// 관련 엔티티 타입 + /// 관련 엔티티 ID + /// 관련 토큰 거래 기록들 + Task> GetByRelatedEntityAsync(string relatedEntityType, string relatedEntityId); + + /// + /// 특정 소스의 토큰 거래 기록들 조회 + /// + /// 사용자 ID + /// 거래 소스 + /// 조회 개수 제한 (선택) + /// 소스별 토큰 거래 기록들 + Task> GetBySourceAsync(Guid userId, string source, int? limit = null); + + /// + /// 거래 ID 중복 여부 확인 + /// + /// 확인할 거래 ID + /// 중복 여부 (true: 중복됨) + Task TransactionExistsAsync(string transactionId); + + /// + /// 사용자별 최근 토큰 거래 조회 + /// + /// 사용자 ID + /// 조회할 거래 개수 + /// 최근 토큰 거래 기록들 + Task> GetRecentTransactionsAsync(Guid userId, int count = 10); + } +} \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Token/SqlServerTokenTransactionRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Token/SqlServerTokenTransactionRepository.cs new file mode 100644 index 0000000..3805896 --- /dev/null +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Token/SqlServerTokenTransactionRepository.cs @@ -0,0 +1,130 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ProjectVG.Domain.Entities.Tokens; +using ProjectVG.Infrastructure.Persistence.EfCore; + +namespace ProjectVG.Infrastructure.Persistence.Repositories.Token +{ + /// + /// SQL Server 기반 토큰 거래 기록 저장소 구현 + /// + public class SqlServerTokenTransactionRepository : ITokenTransactionRepository + { + private readonly ProjectVGDbContext _context; + private readonly ILogger _logger; + + public SqlServerTokenTransactionRepository(ProjectVGDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task CreateAsync(TokenTransaction transaction) + { + try + { + _context.TokenTransactions.Add(transaction); + await _context.SaveChangesAsync(); + + _logger.LogInformation("Token transaction created: {TransactionId} for User {UserId}, Amount: {Amount}", + transaction.TransactionId, transaction.UserId, transaction.Amount); + + return transaction; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create token transaction: {TransactionId}", transaction.TransactionId); + throw; + } + } + + public async Task GetByTransactionIdAsync(string transactionId) + { + return await _context.TokenTransactions + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.TransactionId == transactionId); + } + + public async Task<(List Transactions, int TotalCount)> GetUserTransactionsAsync( + Guid userId, + int pageNumber, + int pageSize, + TokenTransactionType? transactionType = null) + { + var query = _context.TokenTransactions + .Where(t => t.UserId == userId); + + if (transactionType.HasValue) + { + query = query.Where(t => t.Type == transactionType.Value); + } + + var totalCount = await query.CountAsync(); + + var transactions = await query + .OrderByDescending(t => t.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + return (transactions, totalCount); + } + + public async Task GetUserTransactionSumAsync( + Guid userId, + DateTime startDate, + DateTime endDate, + TokenTransactionType? transactionType = null) + { + var query = _context.TokenTransactions + .Where(t => t.UserId == userId && + t.CreatedAt >= startDate && + t.CreatedAt <= endDate); + + if (transactionType.HasValue) + { + query = query.Where(t => t.Type == transactionType.Value); + } + + return await query.SumAsync(t => t.Amount); + } + + public async Task> GetByRelatedEntityAsync(string relatedEntityType, string relatedEntityId) + { + return await _context.TokenTransactions + .Where(t => t.RelatedEntityType == relatedEntityType && + t.RelatedEntityId == relatedEntityId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(); + } + + public async Task> GetBySourceAsync(Guid userId, string source, int? limit = null) + { + var query = _context.TokenTransactions + .Where(t => t.UserId == userId && t.Source == source) + .OrderByDescending(t => t.CreatedAt); + + if (limit.HasValue) + { + return await query.Take(limit.Value).ToListAsync(); + } + + return await query.ToListAsync(); + } + + public async Task TransactionExistsAsync(string transactionId) + { + return await _context.TokenTransactions + .AnyAsync(t => t.TransactionId == transactionId); + } + + public async Task> GetRecentTransactionsAsync(Guid userId, int count = 10) + { + return await _context.TokenTransactions + .Where(t => t.UserId == userId) + .OrderByDescending(t => t.CreatedAt) + .Take(count) + .ToListAsync(); + } + } +} \ No newline at end of file From c280132a6e94b22915009f48c6cb8d31286e8f77 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 13:39:27 +0900 Subject: [PATCH 16/20] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=EA=B3=BC=20=EB=B9=84=EC=9A=A9=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ConversationController.cs | 7 + .../Models/Chat/ChatProcessResultMessage.cs | 11 + .../Services/Auth/AuthService.cs | 10 +- .../Services/Chat/ChatService.cs | 9 +- .../Chat/Handlers/ChatSuccessHandler.cs | 74 ++-- .../Chat/Validators/ChatRequestValidator.cs | 16 +- .../Services/Token/TokenManagementService.cs | 366 +++++++++--------- test-clients/ai-chat-client/index.html | 1 + test-clients/ai-chat-client/script.js | 91 ++++- 9 files changed, 364 insertions(+), 221 deletions(-) diff --git a/ProjectVG.Api/Controllers/ConversationController.cs b/ProjectVG.Api/Controllers/ConversationController.cs index ee82db1..4295612 100644 --- a/ProjectVG.Api/Controllers/ConversationController.cs +++ b/ProjectVG.Api/Controllers/ConversationController.cs @@ -35,12 +35,19 @@ public async Task GetConversationHistory(Guid characterId, [FromQ var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var userGuid)) { + _logger.LogWarning("Authentication failed: Invalid user ID in JWT token"); throw new ValidationException(ErrorCode.AUTHENTICATION_FAILED); } + _logger.LogDebug("Fetching conversation history: UserId={UserId}, CharacterId={CharacterId}, Page={Page}, PageSize={PageSize}", + userGuid, characterId, request.Page, request.PageSize); + // 대화 기록 조회 var messages = await _conversationService.GetConversationHistoryAsync(userGuid, characterId, request.Page, request.PageSize); var totalCount = await _conversationService.GetMessageCountAsync(userGuid, characterId); + + _logger.LogInformation("Conversation history retrieved: UserId={UserId}, CharacterId={CharacterId}, MessageCount={MessageCount}, TotalCount={TotalCount}", + userGuid, characterId, messages.Count(), totalCount); // 응답 매핑 var response = new ConversationHistoryListResponse diff --git a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs index 8ba8d6e..cb35347 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs @@ -34,6 +34,12 @@ public record ChatProcessResultMessage [JsonPropertyName("order")] public int Order { get; init; } + [JsonPropertyName("tokens_used")] + public decimal? TokensUsed { get; init; } + + [JsonPropertyName("tokens_remaining")] + public decimal? TokensRemaining { get; init; } + public static ChatProcessResultMessage FromSegment(ChatSegment segment, string? requestId = null) { var audioData = segment.HasAudio ? Convert.ToBase64String(segment.AudioData!) : null; @@ -65,5 +71,10 @@ public ChatProcessResultMessage WithAudioData(byte[]? audioBytes) return this with { AudioData = null }; } } + + public ChatProcessResultMessage WithTokenInfo(decimal? tokensUsed, decimal? tokensRemaining) + { + return this with { TokensUsed = tokensUsed, TokensRemaining = tokensRemaining }; + } } } diff --git a/ProjectVG.Application/Services/Auth/AuthService.cs b/ProjectVG.Application/Services/Auth/AuthService.cs index 321ff5f..6b6bf92 100644 --- a/ProjectVG.Application/Services/Auth/AuthService.cs +++ b/ProjectVG.Application/Services/Auth/AuthService.cs @@ -97,7 +97,15 @@ public async Task LoginWithOAuthAsync(string provider, string provid } // 첫 로그인 토큰 지급 시도 - await _tokenManagementService.GrantInitialTokensAsync(user.Id); + var tokenGranted = await _tokenManagementService.GrantInitialTokensAsync(user.Id); + if (tokenGranted) + { + _logger.LogInformation("Initial tokens (5000) granted successfully to user {UserId}", user.Id); + } + else + { + _logger.LogInformation("Initial tokens already granted or grant failed for user {UserId}", user.Id); + } var tokens = await _tokenService.GenerateTokensAsync(user.Id); diff --git a/ProjectVG.Application/Services/Chat/ChatService.cs b/ProjectVG.Application/Services/Chat/ChatService.cs index 2fdbcc4..5d8cbd0 100644 --- a/ProjectVG.Application/Services/Chat/ChatService.cs +++ b/ProjectVG.Application/Services/Chat/ChatService.cs @@ -29,7 +29,6 @@ public class ChatService : IChatService private readonly ICostTrackingDecorator _ttsProcessor; private readonly ChatResultProcessor _resultProcessor; - private readonly ChatSuccessHandler _chatSuccessHandler; private readonly ChatFailureHandler _chatFailureHandler; public ChatService( @@ -46,7 +45,6 @@ public ChatService( ICostTrackingDecorator ttsProcessor, ChatResultProcessor resultProcessor, - ChatSuccessHandler chatSuccessHandler, ChatFailureHandler chatFailureHandler ) { _metricsService = metricsService; @@ -62,7 +60,6 @@ ChatFailureHandler chatFailureHandler _llmProcessor = llmProcessor; _ttsProcessor = ttsProcessor; _resultProcessor = resultProcessor; - _chatSuccessHandler = chatSuccessHandler; _chatFailureHandler = chatFailureHandler; } @@ -115,10 +112,12 @@ private async Task ProcessChatRequestInternalAsync(ChatProcessContext context) await _llmProcessor.ProcessAsync(context); await _ttsProcessor.ProcessAsync(context); - await _chatSuccessHandler.HandleAsync(context); - + // ChatSuccessHandler와 ChatResultProcessor를 같은 스코프에서 실행 using var scope = _scopeFactory.CreateScope(); + var successHandler = scope.ServiceProvider.GetRequiredService(); var resultProcessor = scope.ServiceProvider.GetRequiredService(); + + await successHandler.HandleAsync(context); await resultProcessor.PersistResultsAsync(context); } catch (Exception) { diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs index f22abe2..f0628dc 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs @@ -40,11 +40,26 @@ public async Task HandleAsync(ChatProcessContext context) var requestId = context.RequestId.ToString(); var userId = context.UserId.ToString(); + // 토큰 차감 및 잔액 정보 수집 + decimal? tokensUsed = null; + decimal? tokensRemaining = null; + + if (context.Cost > 0) + { + var tokenDeductionResult = await DeductTokensForChatAsync(context); + if (tokenDeductionResult.Success) + { + tokensUsed = (decimal)context.Cost; + tokensRemaining = tokenDeductionResult.BalanceAfter; + } + } + foreach (var segment in validSegments) { try { - var message = ChatProcessResultMessage.FromSegment(segment, requestId); + var message = ChatProcessResultMessage.FromSegment(segment, requestId) + .WithTokenInfo(tokensUsed, tokensRemaining); var wsMessage = new WebSocketMessage("chat", message); await _webSocketService.SendAsync(userId, wsMessage); @@ -57,11 +72,8 @@ public async Task HandleAsync(ChatProcessContext context) } } - _logger.LogDebug("채팅 결과 전송 완료: 요청 {RequestId}, 세그먼트 {SegmentCount}개", - context.RequestId, validSegments.Count); - - // 성공적인 전송 후 토큰 차감 처리 - await DeductTokensForSuccessfulChatAsync(context); + _logger.LogDebug("채팅 결과 전송 완료: 요청 {RequestId}, 세그먼트 {SegmentCount}개, 토큰 사용: {TokensUsed}, 잔액: {TokensRemaining}", + context.RequestId, validSegments.Count, tokensUsed, tokensRemaining); } catch (Exception ex) { @@ -71,48 +83,40 @@ public async Task HandleAsync(ChatProcessContext context) } /// - /// 성공적인 채팅 처리 후 토큰 차감 + /// 채팅 처리를 위한 토큰 차감 /// - private async Task DeductTokensForSuccessfulChatAsync(ChatProcessContext context) + private async Task DeductTokensForChatAsync(ChatProcessContext context) { try { - // 실제 사용된 Cost를 토큰으로 차감 - if (context.Cost > 0) - { - var transactionId = $"CHAT_{context.RequestId}_{DateTime.UtcNow:yyyyMMddHHmmss}"; - var result = await _tokenManagementService.DeductTokensAsync( - context.UserId, - (decimal)context.Cost, - transactionId, - "CHAT_USAGE", - $"채팅 사용료 - 캐릭터: {context.CharacterId}", - context.RequestId.ToString(), - "ChatSession" - ); + var transactionId = $"CHAT_{context.RequestId}_{DateTime.UtcNow:yyyyMMddHHmmss}"; + var result = await _tokenManagementService.DeductTokensAsync( + context.UserId, + (decimal)context.Cost, + transactionId, + "CHAT_USAGE", + $"채팅 사용료 - 캐릭터: {context.CharacterId}", + context.RequestId.ToString(), + "ChatSession" + ); - if (result.Success) - { - _logger.LogInformation("채팅 토큰 차감 완료: {UserId}, 차감 토큰: {Cost}, 잔액: {Balance}", - context.UserId, context.Cost, result.BalanceAfter); - } - else - { - _logger.LogError("채팅 토큰 차감 실패: {UserId}, 에러: {Error}", - context.UserId, result.ErrorMessage); - // 토큰 차감 실패는 로그만 남기고 사용자에게는 이미 성공 응답을 보냈으므로 예외를 던지지 않음 - } + if (result.Success) + { + _logger.LogInformation("채팅 토큰 차감 완료: {UserId}, 차감 토큰: {Cost}, 잔액: {Balance}", + context.UserId, context.Cost, result.BalanceAfter); } else { - _logger.LogWarning("채팅 처리 완료했지만 Cost가 0 또는 음수: {RequestId}, Cost: {Cost}", - context.RequestId, context.Cost); + _logger.LogError("채팅 토큰 차감 실패: {UserId}, 에러: {Error}", + context.UserId, result.ErrorMessage); } + + return result; } catch (Exception ex) { _logger.LogError(ex, "채팅 토큰 차감 처리 중 예외 발생: {RequestId}", context.RequestId); - // 토큰 차감 실패는 사용자 경험에 영향을 주지 않도록 예외를 삼킴 + return TokenTransactionResult.CreateFailure($"토큰 차감 처리 중 예외 발생: {ex.Message}"); } } } diff --git a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs index ea8d806..94c547e 100644 --- a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs +++ b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs @@ -49,11 +49,19 @@ public async Task ValidateAsync(ChatRequestCommand command) } // 토큰 잔액 검증 - 예상 비용으로 미리 확인 - var hasSufficientTokens = await _tokenManagementService.HasSufficientTokensAsync(command.UserId, ESTIMATED_CHAT_COST); + var balance = await _tokenManagementService.GetTokenBalanceAsync(command.UserId); + var currentBalance = balance.CurrentBalance; + + if (currentBalance <= 0) { + _logger.LogWarning("토큰 잔액 부족 (0 토큰): UserId={UserId}", command.UserId); + throw new ValidationException(ErrorCode.INSUFFICIENT_TOKEN_BALANCE, $"토큰이 부족합니다. 현재 잔액: {currentBalance} 토큰, 필요 토큰: {ESTIMATED_CHAT_COST} 토큰"); + } + + var hasSufficientTokens = currentBalance >= ESTIMATED_CHAT_COST; if (!hasSufficientTokens) { - _logger.LogWarning("토큰 부족: {UserId}, 필요 토큰: {RequiredTokens}", command.UserId, ESTIMATED_CHAT_COST); - var balance = await _tokenManagementService.GetTokenBalanceAsync(command.UserId); - throw new ValidationException(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); + _logger.LogWarning("토큰 부족: UserId={UserId}, 현재잔액={CurrentBalance}, 필요토큰={RequiredTokens}", + command.UserId, currentBalance, ESTIMATED_CHAT_COST); + throw new ValidationException(ErrorCode.INSUFFICIENT_TOKEN_BALANCE, $"토큰이 부족합니다. 현재 잔액: {currentBalance} 토큰, 필요 토큰: {ESTIMATED_CHAT_COST} 토큰"); } _logger.LogDebug("채팅 요청 검증 완료: {UserId}, {CharacterId}", command.UserId, command.CharacterId); diff --git a/ProjectVG.Application/Services/Token/TokenManagementService.cs b/ProjectVG.Application/Services/Token/TokenManagementService.cs index d3cd6a7..fd7fedb 100644 --- a/ProjectVG.Application/Services/Token/TokenManagementService.cs +++ b/ProjectVG.Application/Services/Token/TokenManagementService.cs @@ -81,51 +81,55 @@ public async Task AddTokensAsync( return TokenTransactionResult.CreateFailure("Token amount must be positive"); } - using var transaction = await _context.Database.BeginTransactionAsync(); - try + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { - var user = await _userRepository.GetByIdAsync(userId); - if (user == null) + using var transaction = await _context.Database.BeginTransactionAsync(); + try { - return TokenTransactionResult.CreateFailure("User not found"); + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return TokenTransactionResult.CreateFailure("User not found"); + } + + // 토큰 추가 + user.TokenBalance += amount; + user.TotalTokensEarned += amount; + user.UpdatedAt = DateTime.UtcNow; + + await _userRepository.UpdateAsync(user); + + // 거래 기록 생성 + var transactionId = GenerateTransactionId(); + var tokenTransaction = new TokenTransaction + { + UserId = userId, + TransactionId = transactionId, + Type = TokenTransactionType.Earn, + Amount = amount, + BalanceAfter = user.TokenBalance, + Source = source, + Description = description, + RelatedEntityId = relatedEntityId, + RelatedEntityType = relatedEntityType + }; + + await _transactionRepository.CreateAsync(tokenTransaction); + await transaction.CommitAsync(); + + _logger.LogInformation("Tokens added successfully: User={UserId}, Amount={Amount}, Source={Source}", + userId, amount, source); + + return TokenTransactionResult.CreateSuccess(transactionId, amount, user.TokenBalance); } - - // 토큰 추가 - user.TokenBalance += amount; - user.TotalTokensEarned += amount; - user.UpdatedAt = DateTime.UtcNow; - - await _userRepository.UpdateAsync(user); - - // 거래 기록 생성 - var transactionId = GenerateTransactionId(); - var tokenTransaction = new TokenTransaction + catch (Exception ex) { - UserId = userId, - TransactionId = transactionId, - Type = TokenTransactionType.Earn, - Amount = amount, - BalanceAfter = user.TokenBalance, - Source = source, - Description = description, - RelatedEntityId = relatedEntityId, - RelatedEntityType = relatedEntityType - }; - - await _transactionRepository.CreateAsync(tokenTransaction); - await transaction.CommitAsync(); - - _logger.LogInformation("Tokens added successfully: User={UserId}, Amount={Amount}, Source={Source}", - userId, amount, source); - - return TokenTransactionResult.CreateSuccess(transactionId, amount, user.TokenBalance); - } - catch (Exception ex) - { - await transaction.RollbackAsync(); - _logger.LogError(ex, "Failed to add tokens: User={UserId}, Amount={Amount}", userId, amount); - return TokenTransactionResult.CreateFailure("Failed to add tokens"); - } + await transaction.RollbackAsync(); + _logger.LogError(ex, "Failed to add tokens: User={UserId}, Amount={Amount}", userId, amount); + return TokenTransactionResult.CreateFailure("Failed to add tokens"); + } + }); } public async Task DeductTokensAsync( @@ -149,58 +153,62 @@ public async Task DeductTokensAsync( return TokenTransactionResult.CreateFailure("Transaction already exists"); } - using var dbTransaction = await _context.Database.BeginTransactionAsync(); - try + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { - var user = await _userRepository.GetByIdAsync(userId); - if (user == null) + using var dbTransaction = await _context.Database.BeginTransactionAsync(); + try { - return TokenTransactionResult.CreateFailure("User not found"); + var user = await _userRepository.GetByIdAsync(userId); + if (user == null) + { + return TokenTransactionResult.CreateFailure("User not found"); + } + + // 잔액 확인 + if (user.TokenBalance < amount) + { + _logger.LogWarning("Insufficient tokens: User={UserId}, Required={Amount}, Available={Balance}", + userId, amount, user.TokenBalance); + return TokenTransactionResult.CreateFailure("Insufficient token balance"); + } + + // 토큰 차감 + user.TokenBalance -= amount; + user.TotalTokensSpent += amount; + user.UpdatedAt = DateTime.UtcNow; + + await _userRepository.UpdateAsync(user); + + // 거래 기록 생성 + var tokenTransaction = new TokenTransaction + { + UserId = userId, + TransactionId = transactionId, + Type = TokenTransactionType.Spend, + Amount = -amount, // 음수로 저장하여 차감 표시 + BalanceAfter = user.TokenBalance, + Source = source, + Description = description, + RelatedEntityId = relatedEntityId, + RelatedEntityType = relatedEntityType + }; + + await _transactionRepository.CreateAsync(tokenTransaction); + await dbTransaction.CommitAsync(); + + _logger.LogInformation("Tokens deducted successfully: User={UserId}, Amount={Amount}, Source={Source}", + userId, amount, source); + + return TokenTransactionResult.CreateSuccess(transactionId, -amount, user.TokenBalance); } - - // 잔액 확인 - if (user.TokenBalance < amount) + catch (Exception ex) { - _logger.LogWarning("Insufficient tokens: User={UserId}, Required={Amount}, Available={Balance}", - userId, amount, user.TokenBalance); - return TokenTransactionResult.CreateFailure("Insufficient token balance"); + await dbTransaction.RollbackAsync(); + _logger.LogError(ex, "Failed to deduct tokens: User={UserId}, Amount={Amount}", userId, amount); + return TokenTransactionResult.CreateFailure("Failed to deduct tokens"); } - - // 토큰 차감 - user.TokenBalance -= amount; - user.TotalTokensSpent += amount; - user.UpdatedAt = DateTime.UtcNow; - - await _userRepository.UpdateAsync(user); - - // 거래 기록 생성 - var tokenTransaction = new TokenTransaction - { - UserId = userId, - TransactionId = transactionId, - Type = TokenTransactionType.Spend, - Amount = -amount, // 음수로 저장하여 차감 표시 - BalanceAfter = user.TokenBalance, - Source = source, - Description = description, - RelatedEntityId = relatedEntityId, - RelatedEntityType = relatedEntityType - }; - - await _transactionRepository.CreateAsync(tokenTransaction); - await dbTransaction.CommitAsync(); - - _logger.LogInformation("Tokens deducted successfully: User={UserId}, Amount={Amount}, Source={Source}", - userId, amount, source); - - return TokenTransactionResult.CreateSuccess(transactionId, -amount, user.TokenBalance); - } - catch (Exception ex) - { - await dbTransaction.RollbackAsync(); - _logger.LogError(ex, "Failed to deduct tokens: User={UserId}, Amount={Amount}", userId, amount); - return TokenTransactionResult.CreateFailure("Failed to deduct tokens"); - } + }); } public async Task GetTokenHistoryAsync( @@ -255,46 +263,50 @@ public async Task GrantInitialTokensAsync(Guid userId) return false; } - using var transaction = await _context.Database.BeginTransactionAsync(); - try + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { - // 첫 로그인 토큰 지급 - user.TokenBalance += INITIAL_TOKEN_AMOUNT; - user.TotalTokensEarned += INITIAL_TOKEN_AMOUNT; - user.InitialTokensGranted = true; - user.UpdatedAt = DateTime.UtcNow; - - await _userRepository.UpdateAsync(user); - - // 거래 기록 생성 - var transactionId = GenerateTransactionId(); - var tokenTransaction = new TokenTransaction + using var transaction = await _context.Database.BeginTransactionAsync(); + try { - UserId = userId, - TransactionId = transactionId, - Type = TokenTransactionType.Earn, - Amount = INITIAL_TOKEN_AMOUNT, - BalanceAfter = user.TokenBalance, - Source = INITIAL_TOKEN_SOURCE, - Description = "첫 로그인 보너스 토큰", - RelatedEntityType = "User", - RelatedEntityId = userId.ToString() - }; - - await _transactionRepository.CreateAsync(tokenTransaction); - await transaction.CommitAsync(); - - _logger.LogInformation("Initial tokens granted successfully: User={UserId}, Amount={Amount}", - userId, INITIAL_TOKEN_AMOUNT); - - return true; - } - catch (Exception ex) - { - await transaction.RollbackAsync(); - _logger.LogError(ex, "Failed to grant initial tokens for user {UserId}", userId); - return false; - } + // 첫 로그인 토큰 지급 + user.TokenBalance += INITIAL_TOKEN_AMOUNT; + user.TotalTokensEarned += INITIAL_TOKEN_AMOUNT; + user.InitialTokensGranted = true; + user.UpdatedAt = DateTime.UtcNow; + + await _userRepository.UpdateAsync(user); + + // 거래 기록 생성 + var transactionId = GenerateTransactionId(); + var tokenTransaction = new TokenTransaction + { + UserId = userId, + TransactionId = transactionId, + Type = TokenTransactionType.Earn, + Amount = INITIAL_TOKEN_AMOUNT, + BalanceAfter = user.TokenBalance, + Source = INITIAL_TOKEN_SOURCE, + Description = "첫 로그인 보너스 토큰", + RelatedEntityType = "User", + RelatedEntityId = userId.ToString() + }; + + await _transactionRepository.CreateAsync(tokenTransaction); + await transaction.CommitAsync(); + + _logger.LogInformation("Initial tokens granted successfully: User={UserId}, Amount={Amount}", + userId, INITIAL_TOKEN_AMOUNT); + + return true; + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Failed to grant initial tokens for user {UserId}", userId); + return false; + } + }); } public async Task RollbackTransactionAsync(string originalTransactionId, string reason) @@ -312,60 +324,64 @@ public async Task RollbackTransactionAsync(string origin return TokenTransactionResult.CreateFailure("Transaction already rolled back"); } - using var transaction = await _context.Database.BeginTransactionAsync(); - try + var strategy = _context.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => { - var user = await _userRepository.GetByIdAsync(originalTransaction.UserId); - if (user == null) - { - return TokenTransactionResult.CreateFailure("User not found"); - } - - // 롤백 처리: 원래 거래의 반대 동작 수행 - var rollbackAmount = -originalTransaction.Amount; // 원래 거래의 반대 - user.TokenBalance += rollbackAmount; - - if (originalTransaction.Type == TokenTransactionType.Spend) + using var transaction = await _context.Database.BeginTransactionAsync(); + try { - user.TotalTokensSpent -= Math.Abs(originalTransaction.Amount); + var user = await _userRepository.GetByIdAsync(originalTransaction.UserId); + if (user == null) + { + return TokenTransactionResult.CreateFailure("User not found"); + } + + // 롤백 처리: 원래 거래의 반대 동작 수행 + var rollbackAmount = -originalTransaction.Amount; // 원래 거래의 반대 + user.TokenBalance += rollbackAmount; + + if (originalTransaction.Type == TokenTransactionType.Spend) + { + user.TotalTokensSpent -= Math.Abs(originalTransaction.Amount); + } + else + { + user.TotalTokensEarned -= originalTransaction.Amount; + } + + user.UpdatedAt = DateTime.UtcNow; + await _userRepository.UpdateAsync(user); + + // 롤백 거래 기록 생성 + var rollbackTransactionId = GenerateTransactionId(); + var rollbackTransaction = new TokenTransaction + { + UserId = originalTransaction.UserId, + TransactionId = rollbackTransactionId, + Type = originalTransaction.Type == TokenTransactionType.Spend ? TokenTransactionType.Earn : TokenTransactionType.Spend, + Amount = rollbackAmount, + BalanceAfter = user.TokenBalance, + Source = ROLLBACK_SOURCE, + Description = $"롤백: {reason}", + RelatedEntityType = "TokenTransaction", + RelatedEntityId = originalTransactionId + }; + + await _transactionRepository.CreateAsync(rollbackTransaction); + await transaction.CommitAsync(); + + _logger.LogInformation("Transaction rolled back: Original={OriginalId}, Rollback={RollbackId}, Reason={Reason}", + originalTransactionId, rollbackTransactionId, reason); + + return TokenTransactionResult.CreateSuccess(rollbackTransactionId, rollbackAmount, user.TokenBalance); } - else + catch (Exception ex) { - user.TotalTokensEarned -= originalTransaction.Amount; + await transaction.RollbackAsync(); + _logger.LogError(ex, "Failed to rollback transaction: {TransactionId}", originalTransactionId); + return TokenTransactionResult.CreateFailure("Failed to rollback transaction"); } - - user.UpdatedAt = DateTime.UtcNow; - await _userRepository.UpdateAsync(user); - - // 롤백 거래 기록 생성 - var rollbackTransactionId = GenerateTransactionId(); - var rollbackTransaction = new TokenTransaction - { - UserId = originalTransaction.UserId, - TransactionId = rollbackTransactionId, - Type = originalTransaction.Type == TokenTransactionType.Spend ? TokenTransactionType.Earn : TokenTransactionType.Spend, - Amount = rollbackAmount, - BalanceAfter = user.TokenBalance, - Source = ROLLBACK_SOURCE, - Description = $"롤백: {reason}", - RelatedEntityType = "TokenTransaction", - RelatedEntityId = originalTransactionId - }; - - await _transactionRepository.CreateAsync(rollbackTransaction); - await transaction.CommitAsync(); - - _logger.LogInformation("Transaction rolled back: Original={OriginalId}, Rollback={RollbackId}, Reason={Reason}", - originalTransactionId, rollbackTransactionId, reason); - - return TokenTransactionResult.CreateSuccess(rollbackTransactionId, rollbackAmount, user.TokenBalance); - } - catch (Exception ex) - { - await transaction.RollbackAsync(); - _logger.LogError(ex, "Failed to rollback transaction: {TransactionId}", originalTransactionId); - return TokenTransactionResult.CreateFailure("Failed to rollback transaction"); - } + }); } /// diff --git a/test-clients/ai-chat-client/index.html b/test-clients/ai-chat-client/index.html index 3a46a13..d62552c 100644 --- a/test-clients/ai-chat-client/index.html +++ b/test-clients/ai-chat-client/index.html @@ -15,6 +15,7 @@ 로그인: 대기 중 | WebSocket: 대기 중 | 세션: 없음 + | 토큰: - 서버: diff --git a/test-clients/ai-chat-client/script.js b/test-clients/ai-chat-client/script.js index 5a8a419..4883a3a 100644 --- a/test-clients/ai-chat-client/script.js +++ b/test-clients/ai-chat-client/script.js @@ -10,6 +10,7 @@ const HTTP_URL = `http://${ENDPOINT}/api/v1/chat`; const LOGIN_URL = `http://${ENDPOINT}/api/v1/auth/guest-login`; const CHARACTER_BASE_URL = `http://${ENDPOINT}/api/v1/character`; const CONVERSATION_BASE_URL = `http://${ENDPOINT}/api/v1/conversation`; +const TOKEN_BASE_URL = `http://${ENDPOINT}/api/v1/tokens`; const SERVER_MESSAGE_TYPE = "json"; let ws = null; let reconnectAttempts = 0; @@ -22,6 +23,7 @@ const statusBox = document.getElementById('status'); const loginStatus = document.getElementById('login-status'); const wsStatus = document.getElementById('ws-status'); const sessionIdDisplay = document.getElementById('session-id'); +const tokenBalanceDisplay = document.getElementById('token-balance'); const serverInfo = document.getElementById('server-info'); const chatLog = document.getElementById('chat-log'); const userInput = document.getElementById('user-input'); @@ -161,6 +163,9 @@ async function guestLogin() { loginSection.style.display = 'none'; tabNavigation.style.display = 'block'; + // 토큰 잔액 로드 + await loadTokenBalance(); + // 로그인 성공 후 WebSocket 연결 시작 connectWebSocket(); @@ -388,8 +393,18 @@ function connectWebSocket() { } } + // 토큰 정보 업데이트 처리 + if (chatData.tokens_used !== undefined || chatData.tokens_remaining !== undefined) { + updateTokenDisplay(chatData.tokens_used, chatData.tokens_remaining); + } + if (messageText) { appendLog(messageText); + + // 토큰 사용량 표시 추가 + if (chatData.tokens_used) { + appendLog(`[토큰 사용: ${chatData.tokens_used}, 잔액: ${chatData.tokens_remaining || 'N/A'}]`); + } } // 메타데이터 표시 (개발용) @@ -1107,7 +1122,20 @@ async function loadHistoryPage(page = 1) { } catch (error) { console.error('대화 기록 로드 실패:', error); - showHistoryEmpty('대화 기록을 불러오는 중 오류가 발생했습니다.'); + + let errorMessage = '대화 기록을 불러오는 중 오류가 발생했습니다.'; + + if (error.message.includes('401')) { + errorMessage = '로그인이 필요합니다. 다시 로그인해주세요.'; + } else if (error.message.includes('403')) { + errorMessage = '접근 권한이 없습니다.'; + } else if (error.message.includes('404')) { + errorMessage = '선택한 캐릭터의 대화 기록이 없습니다.'; + } else if (error.message.includes('500')) { + errorMessage = '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + } + + showHistoryEmpty(errorMessage); } } @@ -1295,5 +1323,66 @@ if (nextPageBtn) { }); } +// ========== 토큰 관리 기능 ========== + +// 토큰 잔액 로드 +async function loadTokenBalance() { + if (!authToken) { + tokenBalanceDisplay.textContent = '-'; + return; + } + + try { + const response = await fetch(`${TOKEN_BASE_URL}/balance`, { + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + const data = await response.json(); + const balance = data.balance || data.tokenBalance || 0; + tokenBalanceDisplay.textContent = balance.toLocaleString(); + + // 낮은 잔액 경고 + if (balance < 1000) { + tokenBalanceDisplay.style.color = '#f44336'; // 빨간색 + } else if (balance < 5000) { + tokenBalanceDisplay.style.color = '#ff9800'; // 주황색 + } else { + tokenBalanceDisplay.style.color = '#28a745'; // 녹색 + } + } else { + console.error('토큰 잔액 로드 실패:', response.status); + tokenBalanceDisplay.textContent = 'Error'; + } + } catch (error) { + console.error('토큰 잔액 로드 오류:', error); + tokenBalanceDisplay.textContent = 'Error'; + } +} + +// 토큰 디스플레이 업데이트 +function updateTokenDisplay(tokensUsed, tokensRemaining) { + if (tokensRemaining !== undefined && tokensRemaining !== null) { + tokenBalanceDisplay.textContent = tokensRemaining.toLocaleString(); + + // 잔액에 따른 색상 변경 + if (tokensRemaining < 1000) { + tokenBalanceDisplay.style.color = '#f44336'; // 빨간색 + } else if (tokensRemaining < 5000) { + tokenBalanceDisplay.style.color = '#ff9800'; // 주황색 + } else { + tokenBalanceDisplay.style.color = '#28a745'; // 녹색 + } + + // 낮은 잔액 경고 + if (tokensRemaining < 500) { + appendLog(`[경고] 토큰 잔액이 부족합니다 (잔액: ${tokensRemaining})`); + } + } +} + // 초기화 - 로그인을 기다림 appendLog('[시작] 게스트 ID를 입력하고 로그인하세요.'); \ No newline at end of file From d4048d486c21509635f3a67447310f59550bcf74 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 15:07:15 +0900 Subject: [PATCH 17/20] =?UTF-8?q?test:=20=ED=86=A0=ED=81=B0=20=EC=A7=80?= =?UTF-8?q?=EA=B8=88=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Chat/ChatServiceSimpleTests.cs | 214 ++++++++-- ProjectVG.Tests/Auth/AuthServiceTests.cs | 186 +++++++++ .../Chat/Handlers/ChatSuccessHandlerTests.cs | 5 +- .../Validator/ChatRequestValidatorTests.cs | 388 ++++++++++++++++++ 4 files changed, 761 insertions(+), 32 deletions(-) create mode 100644 ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs diff --git a/ProjectVG.Tests/Application/Services/Chat/ChatServiceSimpleTests.cs b/ProjectVG.Tests/Application/Services/Chat/ChatServiceSimpleTests.cs index b095869..2eed502 100644 --- a/ProjectVG.Tests/Application/Services/Chat/ChatServiceSimpleTests.cs +++ b/ProjectVG.Tests/Application/Services/Chat/ChatServiceSimpleTests.cs @@ -21,54 +21,206 @@ public class ChatServiceSimpleTests private readonly Mock _mockMetricsService; private readonly Mock _mockConversationService; private readonly Mock _mockCharacterService; + private readonly Mock _mockScopeFactory; + private readonly Mock _mockScope; + private readonly Mock _mockServiceProvider; + private readonly Mock> _mockLogger; public ChatServiceSimpleTests() { _mockMetricsService = new Mock(); _mockConversationService = new Mock(); _mockCharacterService = new Mock(); + _mockScopeFactory = new Mock(); + _mockScope = new Mock(); + _mockServiceProvider = new Mock(); + _mockLogger = new Mock>(); + + // Setup scope factory chain + _mockScopeFactory.Setup(x => x.CreateScope()).Returns(_mockScope.Object); + _mockScope.Setup(x => x.ServiceProvider).Returns(_mockServiceProvider.Object); + } + + #region Service Scope Management Tests + + [Fact] + public void ChatService_Constructor_WithServiceScopeFactory_ShouldAcceptDependency() + { + // Arrange & Act & Assert + var act = () => CreateTestChatService(); + act.Should().NotThrow("IServiceScopeFactory should be a valid dependency for ChatService"); } - [Fact(Skip = "ChatService has complex dependencies that cannot be easily mocked. Integration tests should be used instead.")] - public void ChatService_Constructor_ShouldNotThrow() + [Fact] + public void ServiceScopeFactory_CreateScope_ShouldReturnValidScope() { - // This test is skipped because ChatService depends on concrete classes without interfaces, - // making it difficult to unit test. The service should be refactored to depend on interfaces. + // Arrange + var mockScope = new Mock(); + var mockServiceProvider = new Mock(); + mockScope.Setup(x => x.ServiceProvider).Returns(mockServiceProvider.Object); + + var mockScopeFactory = new Mock(); + mockScopeFactory.Setup(x => x.CreateScope()).Returns(mockScope.Object); + + // Act + var scope = mockScopeFactory.Object.CreateScope(); + + // Assert + scope.Should().NotBeNull(); + scope.ServiceProvider.Should().NotBeNull(); + mockScopeFactory.Verify(x => x.CreateScope(), Times.Once); } - [Fact(Skip = "ChatService has complex dependencies that cannot be easily mocked. Integration tests should be used instead.")] - public async Task EnqueueChatRequestAsync_WithValidCommand_ShouldCallMetricsService() + [Fact] + public void ServiceScope_ShouldImplementIDisposable() { - // This test is skipped because ChatService depends on concrete classes without interfaces, - // making it difficult to unit test. The service should be refactored to depend on interfaces. - await Task.CompletedTask; + // Arrange + var mockScope = new Mock(); + + // Act & Assert + mockScope.Object.Should().BeAssignableTo("IServiceScope should be disposable for proper resource management"); } - private ChatService? TryCreateChatService() + [Fact] + public void ServiceProvider_GetRequiredService_ShouldResolveServicesCorrectly() { - try - { - // Create a minimal service collection with all required services - var services = new ServiceCollection(); - - // Add required services with mocks - services.AddSingleton(_mockMetricsService.Object); - services.AddSingleton(_mockConversationService.Object); - services.AddSingleton(_mockCharacterService.Object); - - // Add logging - services.AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Warning)); - - // Note: Due to the complex dependency graph of ChatService with concrete classes, - // we cannot easily mock all dependencies. This is a limitation of the current design. - // For proper unit testing, the ChatService should depend on interfaces, not concrete classes. - - return null; // Indicates that ChatService cannot be easily unit tested with its current design - } - catch + // Arrange + var mockChatSuccessHandler = new Mock(); + var mockChatResultProcessor = new Mock(); + + _mockServiceProvider.Setup(x => x.GetRequiredService()) + .Returns(mockChatSuccessHandler.Object); + _mockServiceProvider.Setup(x => x.GetRequiredService()) + .Returns(mockChatResultProcessor.Object); + + // Act + var successHandler = _mockServiceProvider.Object.GetRequiredService(); + var resultProcessor = _mockServiceProvider.Object.GetRequiredService(); + + // Assert + successHandler.Should().NotBeNull(); + resultProcessor.Should().NotBeNull(); + successHandler.Should().BeSameAs(mockChatSuccessHandler.Object); + resultProcessor.Should().BeSameAs(mockChatResultProcessor.Object); + } + + [Fact] + public async Task ProcessChatRequestInternalAsync_ShouldCreateNewScopeForBackgroundTasks() + { + // This test verifies the concept of scope creation for background tasks + // The actual method is private and complex, so we test the scope creation pattern + + // Arrange + var mockChatSuccessHandler = new Mock(); + var mockChatResultProcessor = new Mock(); + + _mockServiceProvider.Setup(x => x.GetRequiredService()) + .Returns(mockChatSuccessHandler.Object); + _mockServiceProvider.Setup(x => x.GetRequiredService()) + .Returns(mockChatResultProcessor.Object); + + // Act - Simulate the scope creation pattern used in ChatService + using var scope = _mockScopeFactory.Object.CreateScope(); + var successHandler = scope.ServiceProvider.GetRequiredService(); + var resultProcessor = scope.ServiceProvider.GetRequiredService(); + + // Assert + _mockScopeFactory.Verify(x => x.CreateScope(), Times.Once); + successHandler.Should().NotBeNull(); + resultProcessor.Should().NotBeNull(); + + // Verify both services come from the same scope + _mockServiceProvider.Verify(x => x.GetRequiredService(), Times.Once); + _mockServiceProvider.Verify(x => x.GetRequiredService(), Times.Once); + + await Task.CompletedTask; // To satisfy async context + } + + [Fact] + public void UsingScope_ShouldDisposeProperlyAndPreventObjectDisposedException() + { + // Arrange + var disposeCalled = false; + _mockScope.Setup(x => x.Dispose()).Callback(() => disposeCalled = true); + + // Act + using (var scope = _mockScopeFactory.Object.CreateScope()) { - return null; + scope.Should().NotBeNull(); + disposeCalled.Should().BeFalse("Scope should not be disposed while in using block"); } + + // Assert + disposeCalled.Should().BeTrue("Scope should be disposed after using block"); + _mockScope.Verify(x => x.Dispose(), Times.Once); + } + + [Fact] + public void ServiceScope_MultipleServiceResolution_ShouldUseSameProvider() + { + // This test verifies that multiple services resolved from the same scope + // use the same service provider instance, preventing DbContext disposal issues + + // Arrange + var mockChatSuccessHandler = new Mock(); + var mockChatResultProcessor = new Mock(); + + _mockServiceProvider.Setup(x => x.GetRequiredService()) + .Returns(mockChatSuccessHandler.Object); + _mockServiceProvider.Setup(x => x.GetRequiredService()) + .Returns(mockChatResultProcessor.Object); + + // Act + using var scope = _mockScopeFactory.Object.CreateScope(); + var provider1 = scope.ServiceProvider; + var provider2 = scope.ServiceProvider; + + var service1 = provider1.GetRequiredService(); + var service2 = provider2.GetRequiredService(); + + // Assert + provider1.Should().BeSameAs(provider2, "Same scope should always return same ServiceProvider"); + service1.Should().NotBeNull(); + service2.Should().NotBeNull(); + + // Both services should be resolved from the same provider instance + _mockServiceProvider.Verify(x => x.GetRequiredService(), Times.Once); + _mockServiceProvider.Verify(x => x.GetRequiredService(), Times.Once); } + + #endregion + + #region Helper Methods + + private ChatService CreateTestChatService() + { + // Create minimal mocks for all required dependencies + var mockValidator = new Mock(); + var mockMemoryPreprocessor = new Mock(); + var mockInputProcessor = new Mock>(); + var mockActionProcessor = new Mock(); + var mockLLMProcessor = new Mock>(); + var mockTTSProcessor = new Mock>(); + var mockResultProcessor = new Mock(); + var mockFailureHandler = new Mock(); + + return new ChatService( + _mockMetricsService.Object, + _mockScopeFactory.Object, + _mockLogger.Object, + _mockConversationService.Object, + _mockCharacterService.Object, + mockValidator.Object, + mockMemoryPreprocessor.Object, + mockInputProcessor.Object, + mockActionProcessor.Object, + mockLLMProcessor.Object, + mockTTSProcessor.Object, + mockResultProcessor.Object, + mockFailureHandler.Object + ); + } + + #endregion } } \ No newline at end of file diff --git a/ProjectVG.Tests/Auth/AuthServiceTests.cs b/ProjectVG.Tests/Auth/AuthServiceTests.cs index a7015ae..a54a0d1 100644 --- a/ProjectVG.Tests/Auth/AuthServiceTests.cs +++ b/ProjectVG.Tests/Auth/AuthServiceTests.cs @@ -3,6 +3,7 @@ using Moq; using ProjectVG.Application.Services.Auth; using ProjectVG.Application.Services.Users; +using ProjectVG.Application.Services.Token; using ProjectVG.Infrastructure.Auth; using ProjectVG.Common.Models; using ProjectVG.Application.Models.User; @@ -18,17 +19,20 @@ public class AuthServiceTests private readonly AuthService _authService; private readonly Mock _mockUserService; private readonly Mock _mockTokenService; + private readonly Mock _mockTokenManagementService; private readonly Mock> _mockLogger; public AuthServiceTests() { _mockUserService = new Mock(); _mockTokenService = new Mock(); + _mockTokenManagementService = new Mock(); _mockLogger = new Mock>(); _authService = new AuthService( _mockUserService.Object, _mockTokenService.Object, + _mockTokenManagementService.Object, _mockLogger.Object ); } @@ -50,6 +54,7 @@ public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResul }; _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); + _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(true); // Act var result = await _authService.LoginWithOAuthAsync(provider, accessToken); @@ -64,6 +69,7 @@ public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResul result.User.Status.Should().Be(AccountStatus.Active); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); } [Fact] @@ -165,6 +171,7 @@ public async Task LoginWithOAuthAsync_NewGuestUser_ShouldCreateAndReturnSuccessR _mockUserService.Setup(x => x.TryGetByProviderAsync("guest", guestId)).ReturnsAsync((UserDto?)null); _mockUserService.Setup(x => x.CreateUserAsync(It.IsAny())).ReturnsAsync(createdUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); + _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(true); // Act var result = await _authService.LoginWithOAuthAsync(provider, guestId); @@ -181,6 +188,7 @@ public async Task LoginWithOAuthAsync_NewGuestUser_ShouldCreateAndReturnSuccessR _mockUserService.Verify(x => x.TryGetByProviderAsync("guest", guestId), Times.Once); _mockUserService.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Once); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); } [Fact] @@ -209,6 +217,7 @@ public async Task LoginWithOAuthAsync_ExistingGuestUser_ShouldReturnSuccessResul _mockUserService.Setup(x => x.TryGetByProviderAsync("guest", guestId)).ReturnsAsync(existingUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); + _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(false); // Act var result = await _authService.LoginWithOAuthAsync(provider, guestId); @@ -221,6 +230,7 @@ public async Task LoginWithOAuthAsync_ExistingGuestUser_ShouldReturnSuccessResul _mockUserService.Verify(x => x.TryGetByProviderAsync("guest", guestId), Times.Once); _mockUserService.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Never); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); } [Fact] @@ -352,5 +362,181 @@ public async Task LogoutAsync_ExceptionThrown_ShouldReturnFalse() result.Should().BeFalse(); } + #region Token Granting Tests + + [Fact] + public async Task LoginWithOAuthAsync_NewUser_ShouldGrantInitialTokensAndLogSuccess() + { + // Arrange + var provider = "guest"; + var guestId = "new_guest_user"; + var userId = Guid.NewGuid(); + var createdUser = new UserDto + { + Id = userId, + Username = $"guest_{guestId}", + Email = $"guest_{guestId}@guest.local", + Provider = provider, + ProviderId = guestId, + Status = AccountStatus.Active + }; + var tokenResponse = new TokenResponse + { + AccessToken = "access.token.here", + RefreshToken = "refresh.token.here", + AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), + RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) + }; + + _mockUserService.Setup(x => x.TryGetByProviderAsync(provider, guestId)).ReturnsAsync((UserDto?)null); + _mockUserService.Setup(x => x.CreateUserAsync(It.IsAny())).ReturnsAsync(createdUser); + _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); + _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(true); + + // Act + var result = await _authService.LoginWithOAuthAsync(provider, guestId); + + // Assert + result.Should().NotBeNull(); + result.User.Should().Be(createdUser); + + // Verify token granting was attempted + _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + + // Verify success logging + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Initial tokens (5000) granted successfully") && + v.ToString()!.Contains(userId.ToString())), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task LoginWithOAuthAsync_ExistingUser_ShouldNotGrantTokensAndLogAlreadyGranted() + { + // Arrange + var provider = "guest"; + var guestId = "existing_guest_user"; + var userId = Guid.NewGuid(); + var existingUser = new UserDto + { + Id = userId, + Username = $"guest_{guestId}", + Email = $"guest_{guestId}@guest.local", + Provider = provider, + ProviderId = guestId, + Status = AccountStatus.Active + }; + var tokenResponse = new TokenResponse + { + AccessToken = "access.token.here", + RefreshToken = "refresh.token.here", + AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), + RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) + }; + + _mockUserService.Setup(x => x.TryGetByProviderAsync(provider, guestId)).ReturnsAsync(existingUser); + _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); + _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(false); + + // Act + var result = await _authService.LoginWithOAuthAsync(provider, guestId); + + // Assert + result.Should().NotBeNull(); + result.User.Should().Be(existingUser); + + // Verify token granting was attempted + _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + + // Verify already granted logging + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Initial tokens already granted or grant failed") && + v.ToString()!.Contains(userId.ToString())), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task LoginWithOAuthAsync_TokenGrantingFails_ShouldStillCompleteLoginAndLogFailure() + { + // Arrange + var provider = "google"; + var providerId = "google_user_123"; + var userId = Guid.Parse("12345678-1234-1234-1234-123456789012"); + var tokenResponse = new TokenResponse + { + AccessToken = "access.token.here", + RefreshToken = "refresh.token.here", + AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), + RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) + }; + + _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); + _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(false); + + // Act + var result = await _authService.LoginWithOAuthAsync(provider, providerId); + + // Assert + result.Should().NotBeNull(); + result.Tokens.Should().Be(tokenResponse); + + // Verify token granting was attempted + _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + + // Verify failure logging + _mockLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Initial tokens already granted or grant failed") && + v.ToString()!.Contains(userId.ToString())), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task LoginWithOAuthAsync_TokenGrantingThrowsException_ShouldNotFailLoginProcess() + { + // Arrange + var provider = "test"; + var accessToken = Guid.NewGuid().ToString(); + var userId = Guid.Parse(accessToken); + var tokenResponse = new TokenResponse + { + AccessToken = "access.token.here", + RefreshToken = "refresh.token.here", + AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), + RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) + }; + + _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); + _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)) + .ThrowsAsync(new Exception("Token granting service unavailable")); + + // Act & Assert - Should not throw exception + var result = await _authService.LoginWithOAuthAsync(provider, accessToken); + + // Verify login still completed successfully + result.Should().NotBeNull(); + result.Tokens.Should().Be(tokenResponse); + result.User!.Id.Should().Be(userId); + + // Verify token granting was attempted + _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + } + + #endregion + } } diff --git a/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs index 82f130d..7e2e39c 100644 --- a/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs +++ b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs @@ -5,6 +5,7 @@ using ProjectVG.Application.Models.WebSocket; using ProjectVG.Application.Services.Chat.Handlers; using ProjectVG.Application.Services.WebSocket; +using ProjectVG.Application.Services.Token; using Xunit; namespace ProjectVG.Tests.Services.Chat.Handlers @@ -13,13 +14,15 @@ public class ChatSuccessHandlerTests { private readonly Mock> _mockLogger; private readonly Mock _mockWebSocketService; + private readonly Mock _mockTokenManagementService; private readonly ChatSuccessHandler _handler; public ChatSuccessHandlerTests() { _mockLogger = new Mock>(); _mockWebSocketService = new Mock(); - _handler = new ChatSuccessHandler(_mockLogger.Object, _mockWebSocketService.Object); + _mockTokenManagementService = new Mock(); + _handler = new ChatSuccessHandler(_mockLogger.Object, _mockWebSocketService.Object, _mockTokenManagementService.Object); } [Fact] diff --git a/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs b/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs new file mode 100644 index 0000000..bb63285 --- /dev/null +++ b/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs @@ -0,0 +1,388 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using ProjectVG.Application.Models.Chat; +using ProjectVG.Application.Services.Chat.Validators; +using ProjectVG.Application.Services.Character; +using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Users; +using ProjectVG.Application.Models.Character; +using ProjectVG.Infrastructure.Persistence.Session; +using ProjectVG.Domain.Entities.Characters; +using ProjectVG.Common.Exceptions; +using ProjectVG.Common.Constants; +using Xunit; + +namespace ProjectVG.Tests.Services.Chat.Validators +{ + public class ChatRequestValidatorTests + { + private readonly ChatRequestValidator _validator; + private readonly Mock _mockSessionStorage; + private readonly Mock _mockUserService; + private readonly Mock _mockCharacterService; + private readonly Mock _mockTokenManagementService; + private readonly Mock> _mockLogger; + + public ChatRequestValidatorTests() + { + _mockSessionStorage = new Mock(); + _mockUserService = new Mock(); + _mockCharacterService = new Mock(); + _mockTokenManagementService = new Mock(); + _mockLogger = new Mock>(); + + _validator = new ChatRequestValidator( + _mockSessionStorage.Object, + _mockUserService.Object, + _mockCharacterService.Object, + _mockTokenManagementService.Object, + _mockLogger.Object); + } + + #region Basic Validation Tests + + [Fact] + public async Task ValidateAsync_ValidRequest_ShouldPassWithoutException() + { + // Arrange + var command = CreateValidChatCommand(); + var character = CreateValidCharacterDto(command.CharacterId); + var tokenBalance = CreateTokenBalance(command.UserId, 1000); + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(true); + _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) + .ReturnsAsync(character); + _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) + .ReturnsAsync(tokenBalance); + + // Act & Assert + await _validator.ValidateAsync(command); // Should not throw + + _mockCharacterService.Verify(x => x.CharacterExistsAsync(command.CharacterId), Times.Once); + _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(command.UserId), Times.Once); + } + + [Fact] + public async Task ValidateAsync_CharacterNotFound_ShouldThrowValidationException() + { + // Arrange + var command = CreateValidChatCommand(); + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(false); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.ErrorCode.Should().Be(ErrorCode.CHARACTER_NOT_FOUND); + _mockCharacterService.Verify(x => x.CharacterExistsAsync(command.CharacterId), Times.Once); + _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ValidateAsync_EmptyUserPrompt_ShouldThrowValidationException() + { + // Arrange + var command = new ChatRequestCommand( + userId: Guid.NewGuid(), + characterId: Guid.NewGuid(), + userPrompt: "", // Empty prompt + requestedAt: DateTime.UtcNow, + useTTS: false + ); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.ErrorCode.Should().Be(ErrorCode.INVALID_INPUT); + exception.Message.Should().Contain("User prompt cannot be empty"); + + // Should not call external services for invalid input + _mockCharacterService.Verify(x => x.CharacterExistsAsync(It.IsAny()), Times.Never); + _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ValidateAsync_WhitespaceOnlyUserPrompt_ShouldThrowValidationException() + { + // Arrange + var command = new ChatRequestCommand( + userId: Guid.NewGuid(), + characterId: Guid.NewGuid(), + userPrompt: " \t\n ", // Whitespace only + requestedAt: DateTime.UtcNow, + useTTS: false + ); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.ErrorCode.Should().Be(ErrorCode.INVALID_INPUT); + exception.Message.Should().Contain("User prompt cannot be empty"); + } + + #endregion + + #region Token Balance Validation Tests + + [Fact] + public async Task ValidateAsync_ZeroTokenBalance_ShouldThrowInsufficientTokenException() + { + // Arrange + var command = CreateValidChatCommand(); + var character = CreateValidCharacterDto(command.CharacterId); + var tokenBalance = CreateTokenBalance(command.UserId, 0); // Zero balance + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(true); + _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) + .ReturnsAsync(character); + _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) + .ReturnsAsync(tokenBalance); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); + exception.Message.Should().Contain("토큰이 부족합니다"); + exception.Message.Should().Contain("현재 잔액: 0 토큰"); + exception.Message.Should().Contain("필요 토큰: 10 토큰"); + + // Verify warning was logged + VerifyWarningLogged("토큰 잔액 부족 (0 토큰)"); + } + + [Fact] + public async Task ValidateAsync_InsufficientTokenBalance_ShouldThrowInsufficientTokenException() + { + // Arrange + var command = CreateValidChatCommand(); + var character = CreateValidCharacterDto(command.CharacterId); + var tokenBalance = CreateTokenBalance(command.UserId, 5); // Less than required 10 tokens + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(true); + _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) + .ReturnsAsync(character); + _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) + .ReturnsAsync(tokenBalance); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); + exception.Message.Should().Contain("토큰이 부족합니다"); + exception.Message.Should().Contain("현재 잔액: 5 토큰"); + exception.Message.Should().Contain("필요 토큰: 10 토큰"); + + // Verify warning was logged with specific details + VerifyWarningLoggedWithParameters("토큰 부족", command.UserId.ToString(), "5", "10"); + } + + [Fact] + public async Task ValidateAsync_ExactlyEnoughTokens_ShouldPassValidation() + { + // Arrange + var command = CreateValidChatCommand(); + var character = CreateValidCharacterDto(command.CharacterId); + var tokenBalance = CreateTokenBalance(command.UserId, 10); // Exactly required amount + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(true); + _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) + .ReturnsAsync(character); + _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) + .ReturnsAsync(tokenBalance); + + // Act & Assert - Should not throw + await _validator.ValidateAsync(command); + + // Verify debug log was written + VerifyDebugLogged("채팅 요청 검증 완료"); + } + + [Fact] + public async Task ValidateAsync_MoreThanEnoughTokens_ShouldPassValidation() + { + // Arrange + var command = CreateValidChatCommand(); + var character = CreateValidCharacterDto(command.CharacterId); + var tokenBalance = CreateTokenBalance(command.UserId, 100); // More than enough + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(true); + _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) + .ReturnsAsync(character); + _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) + .ReturnsAsync(tokenBalance); + + // Act & Assert - Should not throw + await _validator.ValidateAsync(command); + + // Verify debug log was written + VerifyDebugLogged("채팅 요청 검증 완료"); + } + + #endregion + + #region Edge Cases and Error Handling + + [Fact] + public async Task ValidateAsync_TokenServiceThrowsException_ShouldPropagateException() + { + // Arrange + var command = CreateValidChatCommand(); + var character = CreateValidCharacterDto(command.CharacterId); + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(true); + _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) + .ReturnsAsync(character); + _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) + .ThrowsAsync(new Exception("Token service unavailable")); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.Message.Should().Be("Token service unavailable"); + } + + [Fact] + public async Task ValidateAsync_CharacterServiceThrowsException_ShouldPropagateException() + { + // Arrange + var command = CreateValidChatCommand(); + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ThrowsAsync(new Exception("Character service unavailable")); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.Message.Should().Be("Character service unavailable"); + _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ValidateAsync_NegativeTokenBalance_ShouldThrowInsufficientTokenException() + { + // Arrange + var command = CreateValidChatCommand(); + var character = CreateValidCharacterDto(command.CharacterId); + var tokenBalance = CreateTokenBalance(command.UserId, -5); // Negative balance + + _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) + .ReturnsAsync(true); + _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) + .ReturnsAsync(character); + _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) + .ReturnsAsync(tokenBalance); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.ValidateAsync(command)); + + exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); + exception.Message.Should().Contain("현재 잔액: -5 토큰"); + + // Verify warning was logged for zero tokens (negative counts as zero) + VerifyWarningLogged("토큰 잔액 부족 (0 토큰)"); + } + + #endregion + + #region Helper Methods + + private static ChatRequestCommand CreateValidChatCommand() + { + return new ChatRequestCommand( + userId: Guid.NewGuid(), + characterId: Guid.NewGuid(), + userPrompt: "Hello, how are you?", + requestedAt: DateTime.UtcNow, + useTTS: false + ); + } + + private static CharacterDto CreateValidCharacterDto(Guid characterId) + { + var character = new ProjectVG.Domain.Entities.Characters.Character + { + Id = characterId, + Name = "Test Character", + Description = "A test character for validation", + IsActive = true, + VoiceId = "voice_001", + UserId = Guid.NewGuid(), + IsPublic = true, + ConfigMode = ProjectVG.Domain.Entities.Characters.CharacterConfigMode.Individual, + SystemPrompt = "You are a friendly and helpful assistant." + }; + + return new CharacterDto(character); + } + + private static TokenBalanceInfo CreateTokenBalance(Guid userId, decimal balance) + { + return new TokenBalanceInfo + { + UserId = userId, + CurrentBalance = balance, + TotalEarned = Math.Max(balance, 0), + TotalSpent = 0, + LastUpdated = DateTime.UtcNow, + InitialTokensGranted = balance > 0 + }; + } + + private void VerifyWarningLogged(string expectedMessage) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessage)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private void VerifyWarningLoggedWithParameters(string expectedMessage, string userId, string currentBalance, string requiredTokens) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains(expectedMessage) && + v.ToString()!.Contains(userId) && + v.ToString()!.Contains(currentBalance) && + v.ToString()!.Contains(requiredTokens)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + private void VerifyDebugLogged(string expectedMessage) + { + _mockLogger.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains(expectedMessage)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + } +} \ No newline at end of file From e6ea4a208a10c8f353167c15dab2b1c0cdf56aed Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 16:00:59 +0900 Subject: [PATCH 18/20] =?UTF-8?q?refactory:=20=EB=AA=85=EC=B9=AD=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20Token->=20Credit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도메인 명칭 변경 Token-> Credit 사유 : JWT Token과 명칭이 겹치고 좀 더 알맞는 명칭으로 판단됨 --- .../ApiServiceCollectionExtensions.cs | 2 +- ProjectVG.Api/Controllers/AuthController.cs | 2 +- ...TokenController.cs => CreditController.cs} | 90 ++++----- ProjectVG.Api/Controllers/OAuthController.cs | 4 +- .../ApplicationServiceCollectionExtensions.cs | 6 +- .../Models/Chat/ChatProcessResultMessage.cs | 12 +- .../Services/Auth/AuthService.cs | 8 +- .../Chat/Handlers/ChatSuccessHandler.cs | 16 +- .../Chat/Validators/ChatRequestValidator.cs | 12 +- .../CreditManagementService.cs} | 186 +++++++++--------- .../ICreditManagementService.cs} | 88 ++++----- ProjectVG.Common/Constants/ErrorCodes.cs | 16 +- ...kenTransaction.cs => CreditTransaction.cs} | 8 +- ProjectVG.Domain/Entities/User/User.cs | 16 +- ...frastructureServiceCollectionExtensions.cs | 4 +- ...903032058_AddTokenSystemToUser.Designer.cs | 10 +- .../20250903032058_AddTokenSystemToUser.cs | 22 +-- ...2155_AddTokenTransactionEntity.Designer.cs | 16 +- ...0250903032155_AddTokenTransactionEntity.cs | 34 ++-- .../ProjectVGDbContextModelSnapshot.cs | 16 +- .../EfCore/Data/ProjectVGDbContext.cs | 18 +- .../ICreditTransactionRepository.cs} | 22 +-- .../SqlServerCreditTransactionRepository.cs} | 46 ++--- ProjectVG.Tests/Auth/AuthServiceTests.cs | 36 ++-- .../Chat/Handlers/ChatSuccessHandlerTests.cs | 8 +- .../Validator/ChatRequestValidatorTests.cs | 108 +++++----- test-clients/ai-chat-client/index.html | 2 +- test-clients/ai-chat-client/script.js | 46 ++--- 28 files changed, 425 insertions(+), 429 deletions(-) rename ProjectVG.Api/Controllers/{TokenController.cs => CreditController.cs} (68%) rename ProjectVG.Application/Services/{Token/TokenManagementService.cs => Credit/CreditManagementService.cs} (60%) rename ProjectVG.Application/Services/{Token/ITokenManagementService.cs => Credit/ICreditManagementService.cs} (61%) rename ProjectVG.Domain/Entities/Token/{TokenTransaction.cs => CreditTransaction.cs} (92%) rename ProjectVG.Infrastructure/Persistence/Repositories/{Token/ITokenTransactionRepository.cs => Credit/ICreditTransactionRepository.cs} (77%) rename ProjectVG.Infrastructure/Persistence/Repositories/{Token/SqlServerTokenTransactionRepository.cs => Credit/SqlServerCreditTransactionRepository.cs} (62%) diff --git a/ProjectVG.Api/ApiServiceCollectionExtensions.cs b/ProjectVG.Api/ApiServiceCollectionExtensions.cs index b92eec4..f0cb974 100644 --- a/ProjectVG.Api/ApiServiceCollectionExtensions.cs +++ b/ProjectVG.Api/ApiServiceCollectionExtensions.cs @@ -70,7 +70,7 @@ public static IServiceCollection AddDevelopmentCors(this IServiceCollection serv services.AddCors(options => { options.AddPolicy("AllowAll", policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader() - .WithExposedHeaders("X-Access-Token", "X-Refresh-Token", "X-Expires-In", "X-UID")); + .WithExposedHeaders("X-Access-Credit", "X-Refresh-Credit", "X-Expires-In", "X-UID")); }); return services; diff --git a/ProjectVG.Api/Controllers/AuthController.cs b/ProjectVG.Api/Controllers/AuthController.cs index 98ec04f..8ea5390 100644 --- a/ProjectVG.Api/Controllers/AuthController.cs +++ b/ProjectVG.Api/Controllers/AuthController.cs @@ -61,7 +61,7 @@ public async Task GuestLogin([FromBody] string guestId) private string GetRefreshTokenFromHeader() { - return Request.Headers["X-Refresh-Token"].FirstOrDefault() ?? string.Empty; + return Request.Headers["X-Refresh-Credit"].FirstOrDefault() ?? string.Empty; } } } \ No newline at end of file diff --git a/ProjectVG.Api/Controllers/TokenController.cs b/ProjectVG.Api/Controllers/CreditController.cs similarity index 68% rename from ProjectVG.Api/Controllers/TokenController.cs rename to ProjectVG.Api/Controllers/CreditController.cs index aa4915b..61f3e21 100644 --- a/ProjectVG.Api/Controllers/TokenController.cs +++ b/ProjectVG.Api/Controllers/CreditController.cs @@ -1,32 +1,32 @@ using Microsoft.AspNetCore.Mvc; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; using ProjectVG.Api.Filters; using System.Security.Claims; namespace ProjectVG.Api.Controllers { /// - /// 토큰 관리 API 컨트롤러 - /// 사용자의 토큰 잔액 조회, 거래 내역 조회 등을 제공 + /// 크래딧 관리 API 컨트롤러 + /// 사용자의 크래딧 잔액 조회, 거래 내역 조회 등을 제공 /// [ApiController] - [Route("api/v1/tokens")] + [Route("api/v1/credits")] [JwtAuthentication] - public class TokenController : ControllerBase + public class CreditController : ControllerBase { - private readonly ITokenManagementService _tokenManagementService; - private readonly ILogger _logger; + private readonly ICreditManagementService _creditManagementService; + private readonly ILogger _logger; - public TokenController(ITokenManagementService tokenManagementService, ILogger logger) + public CreditController(ICreditManagementService creditManagementService, ILogger logger) { - _tokenManagementService = tokenManagementService; + _creditManagementService = creditManagementService; _logger = logger; } /// - /// 현재 사용자의 토큰 잔액 조회 + /// 현재 사용자의 크래딧 잔액 조회 /// - /// 토큰 잔액 정보 + /// 크래딧 잔액 정보 [HttpGet("balance")] public async Task GetBalance() { @@ -34,7 +34,7 @@ public async Task GetBalance() try { - var balance = await _tokenManagementService.GetTokenBalanceAsync(userId); + var balance = await _creditManagementService.GetCreditBalanceAsync(userId); return Ok(new { userId = balance.UserId, @@ -42,23 +42,23 @@ public async Task GetBalance() totalEarned = balance.TotalEarned, totalSpent = balance.TotalSpent, lastUpdated = balance.LastUpdated, - initialTokensGranted = balance.InitialTokensGranted + initialTokensGranted = balance.InitialCreditsGranted }); } catch (Exception ex) { - _logger.LogError(ex, "Failed to get token balance for user {UserId}", userId); - return StatusCode(500, new { error = "Failed to retrieve token balance" }); + _logger.LogError(ex, "Failed to get credit balance for user {UserId}", userId); + return StatusCode(500, new { error = "Failed to retrieve credit balance" }); } } /// - /// 토큰 거래 내역 조회 (페이지네이션) + /// 크래딧 거래 내역 조회 (페이지네이션) /// /// 페이지 번호 (1부터 시작) /// 페이지 크기 (최대 100) /// 거래 유형 필터 (Earn=1, Spend=2) - /// 토큰 거래 내역 + /// 크래딧 거래 내역 [HttpGet("history")] public async Task GetHistory( [FromQuery] int page = 1, @@ -71,15 +71,15 @@ public async Task GetHistory( if (page < 1) page = 1; if (pageSize < 1 || pageSize > 100) pageSize = 20; - Domain.Entities.Tokens.TokenTransactionType? transactionType = null; - if (type.HasValue && Enum.IsDefined(typeof(Domain.Entities.Tokens.TokenTransactionType), type.Value)) + Domain.Entities.Credits.CreditTransactionType? transactionType = null; + if (type.HasValue && Enum.IsDefined(typeof(Domain.Entities.Credits.CreditTransactionType), type.Value)) { - transactionType = (Domain.Entities.Tokens.TokenTransactionType)type.Value; + transactionType = (Domain.Entities.Credits.CreditTransactionType)type.Value; } try { - var history = await _tokenManagementService.GetTokenHistoryAsync(userId, page, pageSize, transactionType); + var history = await _creditManagementService.GetCreditHistoryAsync(userId, page, pageSize, transactionType); return Ok(new { @@ -110,18 +110,18 @@ public async Task GetHistory( } catch (Exception ex) { - _logger.LogError(ex, "Failed to get token history for user {UserId}", userId); - return StatusCode(500, new { error = "Failed to retrieve token history" }); + _logger.LogError(ex, "Failed to get credit history for user {UserId}", userId); + return StatusCode(500, new { error = "Failed to retrieve credit history" }); } } /// - /// 토큰 충분 여부 확인 + /// 크래딧 충분 여부 확인 /// - /// 확인할 토큰 수량 - /// 토큰 충분 여부 + /// 확인할 크래딧 수량 + /// 크래딧 충분 여부 [HttpGet("check/{amount}")] - public async Task CheckSufficientTokens(decimal amount) + public async Task CheckSufficientCredits(decimal amount) { if (amount <= 0) { @@ -132,33 +132,33 @@ public async Task CheckSufficientTokens(decimal amount) try { - var hasSufficient = await _tokenManagementService.HasSufficientTokensAsync(userId, amount); - var balance = await _tokenManagementService.GetTokenBalanceAsync(userId); + var hasSufficient = await _creditManagementService.HasSufficientCreditsAsync(userId, amount); + var balance = await _creditManagementService.GetCreditBalanceAsync(userId); return Ok(new { userId = userId, requiredAmount = amount, currentBalance = balance.CurrentBalance, - hasSufficientTokens = hasSufficient, + hasSufficientCredits = hasSufficient, shortage = hasSufficient ? 0 : amount - balance.CurrentBalance }); } catch (Exception ex) { - _logger.LogError(ex, "Failed to check token sufficiency for user {UserId}, amount {Amount}", userId, amount); - return StatusCode(500, new { error = "Failed to check token sufficiency" }); + _logger.LogError(ex, "Failed to check credit sufficiency for user {UserId}, amount {Amount}", userId, amount); + return StatusCode(500, new { error = "Failed to check credit sufficiency" }); } } /// - /// 토큰 추가 (관리자 전용 또는 결제 시스템 연동용) + /// 크래딧 추가 (관리자 전용 또는 결제 시스템 연동용) /// 실제 운영 환경에서는 결제 검증 로직이 필요 /// - /// 토큰 추가 요청 - /// 토큰 추가 결과 + /// 크래딧 추가 요청 + /// 크래딧 추가 결과 [HttpPost("add")] - public async Task AddTokens([FromBody] AddTokenRequest request) + public async Task AddCredits([FromBody] AddCreditRequest request) { if (!ModelState.IsValid) { @@ -170,11 +170,11 @@ public async Task AddTokens([FromBody] AddTokenRequest request) try { // 실제 운영에서는 결제 검증, 권한 확인 등이 필요 - var result = await _tokenManagementService.AddTokensAsync( + var result = await _creditManagementService.AddCreditsAsync( userId, request.Amount, request.Source ?? "MANUAL_ADD", - request.Description ?? "토큰 수동 추가", + request.Description ?? "크래딧 수동 추가", request.RelatedEntityId, request.RelatedEntityType ); @@ -197,8 +197,8 @@ public async Task AddTokens([FromBody] AddTokenRequest request) } catch (Exception ex) { - _logger.LogError(ex, "Failed to add tokens for user {UserId}", userId); - return StatusCode(500, new { error = "Failed to add tokens" }); + _logger.LogError(ex, "Failed to add credits for user {UserId}", userId); + return StatusCode(500, new { error = "Failed to add credits" }); } } @@ -210,24 +210,24 @@ private Guid GetCurrentUserId() var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId)) { - throw new UnauthorizedAccessException("Invalid user ID in token"); + throw new UnauthorizedAccessException("Invalid user ID in credit"); } return userId; } } /// - /// 토큰 추가 요청 모델 + /// 크래딧 추가 요청 모델 /// - public class AddTokenRequest + public class AddCreditRequest { /// - /// 추가할 토큰 수량 (필수) + /// 추가할 크래딧 수량 (필수) /// public decimal Amount { get; set; } /// - /// 토큰 소스 (선택, 기본값: MANUAL_ADD) + /// 크래딧 소스 (선택, 기본값: MANUAL_ADD) /// public string? Source { get; set; } diff --git a/ProjectVG.Api/Controllers/OAuthController.cs b/ProjectVG.Api/Controllers/OAuthController.cs index b1e5d22..0f209a5 100644 --- a/ProjectVG.Api/Controllers/OAuthController.cs +++ b/ProjectVG.Api/Controllers/OAuthController.cs @@ -101,8 +101,8 @@ public async Task GetOAuth2Token([FromQuery] string state) await _oauth2Service.DeleteTokenDataAsync(state); - Response.Headers.Append("X-Access-Token", tokenData.AccessToken); - Response.Headers.Append("X-Refresh-Token", tokenData.RefreshToken); + Response.Headers.Append("X-Access-Credit", tokenData.AccessToken); + Response.Headers.Append("X-Refresh-Credit", tokenData.RefreshToken); Response.Headers.Append("X-Expires-In", tokenData.ExpiresIn.ToString()); Response.Headers.Append("X-UID", tokenData.UID); diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index c6f1470..c8394aa 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -9,7 +9,7 @@ using ProjectVG.Application.Services.Chat.Handlers; using ProjectVG.Application.Services.Conversation; using ProjectVG.Application.Services.Session; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; using ProjectVG.Application.Services.Users; using ProjectVG.Application.Services.WebSocket; @@ -30,8 +30,8 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // Character Services services.AddScoped(); - // Token Management Services - services.AddScoped(); + // Credit Management Services + services.AddScoped(); // Chat Services - Core services.AddScoped(); diff --git a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs index cb35347..724e04e 100644 --- a/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs +++ b/ProjectVG.Application/Models/Chat/ChatProcessResultMessage.cs @@ -34,11 +34,11 @@ public record ChatProcessResultMessage [JsonPropertyName("order")] public int Order { get; init; } - [JsonPropertyName("tokens_used")] - public decimal? TokensUsed { get; init; } + [JsonPropertyName("credits_used")] + public decimal? CreditsUsed { get; init; } - [JsonPropertyName("tokens_remaining")] - public decimal? TokensRemaining { get; init; } + [JsonPropertyName("credits_remaining")] + public decimal? CreditsRemaining { get; init; } public static ChatProcessResultMessage FromSegment(ChatSegment segment, string? requestId = null) { @@ -72,9 +72,9 @@ public ChatProcessResultMessage WithAudioData(byte[]? audioBytes) } } - public ChatProcessResultMessage WithTokenInfo(decimal? tokensUsed, decimal? tokensRemaining) + public ChatProcessResultMessage WithCreditInfo(decimal? creditsUsed, decimal? creditsRemaining) { - return this with { TokensUsed = tokensUsed, TokensRemaining = tokensRemaining }; + return this with { CreditsUsed = creditsUsed, CreditsRemaining = creditsRemaining }; } } } diff --git a/ProjectVG.Application/Services/Auth/AuthService.cs b/ProjectVG.Application/Services/Auth/AuthService.cs index 6b6bf92..7c560ee 100644 --- a/ProjectVG.Application/Services/Auth/AuthService.cs +++ b/ProjectVG.Application/Services/Auth/AuthService.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Logging; using ProjectVG.Application.Models.User; using ProjectVG.Application.Services.Users; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; using ProjectVG.Infrastructure.Auth; using ProjectVG.Common.Exceptions; using ProjectVG.Common.Constants; @@ -13,13 +13,13 @@ public class AuthService : IAuthService { private readonly IUserService _userService; private readonly ITokenService _tokenService; - private readonly ITokenManagementService _tokenManagementService; + private readonly ICreditManagementService _tokenManagementService; private readonly ILogger _logger; public AuthService( IUserService userService, ITokenService tokenService, - ITokenManagementService tokenManagementService, + ICreditManagementService tokenManagementService, ILogger logger) { _userService = userService; @@ -97,7 +97,7 @@ public async Task LoginWithOAuthAsync(string provider, string provid } // 첫 로그인 토큰 지급 시도 - var tokenGranted = await _tokenManagementService.GrantInitialTokensAsync(user.Id); + var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id); if (tokenGranted) { _logger.LogInformation("Initial tokens (5000) granted successfully to user {UserId}", user.Id); diff --git a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs index f0628dc..4800e9a 100644 --- a/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs +++ b/ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs @@ -1,7 +1,7 @@ using ProjectVG.Application.Models.Chat; using ProjectVG.Application.Models.WebSocket; using ProjectVG.Application.Services.WebSocket; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; namespace ProjectVG.Application.Services.Chat.Handlers @@ -10,12 +10,12 @@ public class ChatSuccessHandler { private readonly ILogger _logger; private readonly IWebSocketManager _webSocketService; - private readonly ITokenManagementService _tokenManagementService; + private readonly ICreditManagementService _tokenManagementService; public ChatSuccessHandler( ILogger logger, IWebSocketManager webSocketService, - ITokenManagementService tokenManagementService) + ICreditManagementService tokenManagementService) { _logger = logger; _webSocketService = webSocketService; @@ -59,7 +59,7 @@ public async Task HandleAsync(ChatProcessContext context) try { var message = ChatProcessResultMessage.FromSegment(segment, requestId) - .WithTokenInfo(tokensUsed, tokensRemaining); + .WithCreditInfo(tokensUsed, tokensRemaining); var wsMessage = new WebSocketMessage("chat", message); await _webSocketService.SendAsync(userId, wsMessage); @@ -72,7 +72,7 @@ public async Task HandleAsync(ChatProcessContext context) } } - _logger.LogDebug("채팅 결과 전송 완료: 요청 {RequestId}, 세그먼트 {SegmentCount}개, 토큰 사용: {TokensUsed}, 잔액: {TokensRemaining}", + _logger.LogDebug("채팅 결과 전송 완료: 요청 {RequestId}, 세그먼트 {SegmentCount}개, 토큰 사용: {CreditsUsed}, 잔액: {CreditsRemaining}", context.RequestId, validSegments.Count, tokensUsed, tokensRemaining); } catch (Exception ex) @@ -85,12 +85,12 @@ public async Task HandleAsync(ChatProcessContext context) /// /// 채팅 처리를 위한 토큰 차감 /// - private async Task DeductTokensForChatAsync(ChatProcessContext context) + private async Task DeductTokensForChatAsync(ChatProcessContext context) { try { var transactionId = $"CHAT_{context.RequestId}_{DateTime.UtcNow:yyyyMMddHHmmss}"; - var result = await _tokenManagementService.DeductTokensAsync( + var result = await _tokenManagementService.DeductCreditsAsync( context.UserId, (decimal)context.Cost, transactionId, @@ -116,7 +116,7 @@ private async Task DeductTokensForChatAsync(ChatProcessC catch (Exception ex) { _logger.LogError(ex, "채팅 토큰 차감 처리 중 예외 발생: {RequestId}", context.RequestId); - return TokenTransactionResult.CreateFailure($"토큰 차감 처리 중 예외 발생: {ex.Message}"); + return CreditTransactionResult.CreateFailure($"토큰 차감 처리 중 예외 발생: {ex.Message}"); } } } diff --git a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs index 94c547e..a68e10f 100644 --- a/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs +++ b/ProjectVG.Application/Services/Chat/Validators/ChatRequestValidator.cs @@ -1,7 +1,7 @@ using ProjectVG.Infrastructure.Persistence.Session; using ProjectVG.Application.Services.Users; using ProjectVG.Application.Services.Character; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; using Microsoft.Extensions.Logging; using ProjectVG.Application.Models.Chat; @@ -12,7 +12,7 @@ public class ChatRequestValidator private readonly ISessionStorage _sessionStorage; private readonly IUserService _userService; private readonly ICharacterService _characterService; - private readonly ITokenManagementService _tokenManagementService; + private readonly ICreditManagementService _tokenManagementService; private readonly ILogger _logger; // 채팅 기본 예상 비용 (실제 비용은 처리 후 결정됨) @@ -22,7 +22,7 @@ public ChatRequestValidator( ISessionStorage sessionStorage, IUserService userService, ICharacterService characterService, - ITokenManagementService tokenManagementService, + ICreditManagementService tokenManagementService, ILogger logger) { _sessionStorage = sessionStorage; @@ -49,19 +49,19 @@ public async Task ValidateAsync(ChatRequestCommand command) } // 토큰 잔액 검증 - 예상 비용으로 미리 확인 - var balance = await _tokenManagementService.GetTokenBalanceAsync(command.UserId); + var balance = await _tokenManagementService.GetCreditBalanceAsync(command.UserId); var currentBalance = balance.CurrentBalance; if (currentBalance <= 0) { _logger.LogWarning("토큰 잔액 부족 (0 토큰): UserId={UserId}", command.UserId); - throw new ValidationException(ErrorCode.INSUFFICIENT_TOKEN_BALANCE, $"토큰이 부족합니다. 현재 잔액: {currentBalance} 토큰, 필요 토큰: {ESTIMATED_CHAT_COST} 토큰"); + throw new ValidationException(ErrorCode.INSUFFICIENT_CREDIT_BALANCE, $"토큰이 부족합니다. 현재 잔액: {currentBalance} 토큰, 필요 토큰: {ESTIMATED_CHAT_COST} 토큰"); } var hasSufficientTokens = currentBalance >= ESTIMATED_CHAT_COST; if (!hasSufficientTokens) { _logger.LogWarning("토큰 부족: UserId={UserId}, 현재잔액={CurrentBalance}, 필요토큰={RequiredTokens}", command.UserId, currentBalance, ESTIMATED_CHAT_COST); - throw new ValidationException(ErrorCode.INSUFFICIENT_TOKEN_BALANCE, $"토큰이 부족합니다. 현재 잔액: {currentBalance} 토큰, 필요 토큰: {ESTIMATED_CHAT_COST} 토큰"); + throw new ValidationException(ErrorCode.INSUFFICIENT_CREDIT_BALANCE, $"토큰이 부족합니다. 현재 잔액: {currentBalance} 토큰, 필요 토큰: {ESTIMATED_CHAT_COST} 토큰"); } _logger.LogDebug("채팅 요청 검증 완료: {UserId}, {CharacterId}", command.UserId, command.CharacterId); diff --git a/ProjectVG.Application/Services/Token/TokenManagementService.cs b/ProjectVG.Application/Services/Credit/CreditManagementService.cs similarity index 60% rename from ProjectVG.Application/Services/Token/TokenManagementService.cs rename to ProjectVG.Application/Services/Credit/CreditManagementService.cs index fd7fedb..efa3aa5 100644 --- a/ProjectVG.Application/Services/Token/TokenManagementService.cs +++ b/ProjectVG.Application/Services/Credit/CreditManagementService.cs @@ -1,36 +1,32 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using ProjectVG.Application.Services.Token; -using ProjectVG.Common.Constants; -using ProjectVG.Common.Exceptions; -using ProjectVG.Domain.Entities.Tokens; +using ProjectVG.Domain.Entities.Credits; using ProjectVG.Infrastructure.Persistence.EfCore; -using ProjectVG.Infrastructure.Persistence.Repositories.Token; +using ProjectVG.Infrastructure.Persistence.Repositories.Credit; using ProjectVG.Infrastructure.Persistence.Repositories.Users; -namespace ProjectVG.Application.Services.Token +namespace ProjectVG.Application.Services.Credit { /// - /// 토큰 관리 서비스 구현 - /// 사용자 토큰 잔액 관리, 토큰 증감, 거래 기록 관리 등을 담당 + /// 크래딧 관리 서비스 구현 + /// 사용자 크래딧 잔액 관리, 크래딧 증감, 거래 기록 관리 등을 담당 /// - public class TokenManagementService : ITokenManagementService + public class CreditManagementService : ICreditManagementService { private readonly ProjectVGDbContext _context; private readonly IUserRepository _userRepository; - private readonly ITokenTransactionRepository _transactionRepository; - private readonly ILogger _logger; + private readonly ICreditTransactionRepository _transactionRepository; + private readonly ILogger _logger; // 상수 정의 - private const decimal INITIAL_TOKEN_AMOUNT = 5000m; - private const string INITIAL_TOKEN_SOURCE = "LOGIN_BONUS"; + private const decimal INITIAL_CREDIT_AMOUNT = 5000m; + private const string INITIAL_CREDIT_SOURCE = "LOGIN_BONUS"; private const string ROLLBACK_SOURCE = "ROLLBACK"; - public TokenManagementService( + public CreditManagementService( ProjectVGDbContext context, IUserRepository userRepository, - ITokenTransactionRepository transactionRepository, - ILogger logger) + ICreditTransactionRepository transactionRepository, + ILogger logger) { _context = context; _userRepository = userRepository; @@ -38,7 +34,7 @@ public TokenManagementService( _logger = logger; } - public async Task GetTokenBalanceAsync(Guid userId) + public async Task GetCreditBalanceAsync(Guid userId) { var user = await _userRepository.GetByIdAsync(userId); if (user == null) @@ -46,18 +42,18 @@ public async Task GetTokenBalanceAsync(Guid userId) throw new ValidationException(ErrorCode.USER_NOT_FOUND, $"User not found: {userId}"); } - return new TokenBalanceInfo + return new CreditBalanceInfo { UserId = userId, - CurrentBalance = user.TokenBalance, - TotalEarned = user.TotalTokensEarned, - TotalSpent = user.TotalTokensSpent, + CurrentBalance = user.CreditBalance, + TotalEarned = user.TotalCreditsEarned, + TotalSpent = user.TotalCreditsSpent, LastUpdated = user.UpdatedAt ?? DateTime.UtcNow, - InitialTokensGranted = user.InitialTokensGranted + InitialCreditsGranted = user.InitialCreditsGranted }; } - public async Task HasSufficientTokensAsync(Guid userId, decimal requiredAmount) + public async Task HasSufficientCreditsAsync(Guid userId, decimal requiredAmount) { var user = await _userRepository.GetByIdAsync(userId); if (user == null) @@ -65,10 +61,10 @@ public async Task HasSufficientTokensAsync(Guid userId, decimal requiredAm return false; } - return user.TokenBalance >= requiredAmount; + return user.CreditBalance >= requiredAmount; } - public async Task AddTokensAsync( + public async Task AddCreditsAsync( Guid userId, decimal amount, string source, @@ -78,7 +74,7 @@ public async Task AddTokensAsync( { if (amount <= 0) { - return TokenTransactionResult.CreateFailure("Token amount must be positive"); + return CreditTransactionResult.CreateFailure("Credit amount must be positive"); } var strategy = _context.Database.CreateExecutionStrategy(); @@ -90,49 +86,49 @@ public async Task AddTokensAsync( var user = await _userRepository.GetByIdAsync(userId); if (user == null) { - return TokenTransactionResult.CreateFailure("User not found"); + return CreditTransactionResult.CreateFailure("User not found"); } - // 토큰 추가 - user.TokenBalance += amount; - user.TotalTokensEarned += amount; + // 크래딧 추가 + user.CreditBalance += amount; + user.TotalCreditsEarned += amount; user.UpdatedAt = DateTime.UtcNow; await _userRepository.UpdateAsync(user); // 거래 기록 생성 var transactionId = GenerateTransactionId(); - var tokenTransaction = new TokenTransaction + var creditTransaction = new CreditTransaction { UserId = userId, TransactionId = transactionId, - Type = TokenTransactionType.Earn, + Type = CreditTransactionType.Earn, Amount = amount, - BalanceAfter = user.TokenBalance, + BalanceAfter = user.CreditBalance, Source = source, Description = description, RelatedEntityId = relatedEntityId, RelatedEntityType = relatedEntityType }; - await _transactionRepository.CreateAsync(tokenTransaction); + await _transactionRepository.CreateAsync(creditTransaction); await transaction.CommitAsync(); - _logger.LogInformation("Tokens added successfully: User={UserId}, Amount={Amount}, Source={Source}", + _logger.LogInformation("Credits added successfully: User={UserId}, Amount={Amount}, Source={Source}", userId, amount, source); - return TokenTransactionResult.CreateSuccess(transactionId, amount, user.TokenBalance); + return CreditTransactionResult.CreateSuccess(transactionId, amount, user.CreditBalance); } catch (Exception ex) { await transaction.RollbackAsync(); - _logger.LogError(ex, "Failed to add tokens: User={UserId}, Amount={Amount}", userId, amount); - return TokenTransactionResult.CreateFailure("Failed to add tokens"); + _logger.LogError(ex, "Failed to add credits: User={UserId}, Amount={Amount}", userId, amount); + return CreditTransactionResult.CreateFailure("Failed to add credits"); } }); } - public async Task DeductTokensAsync( + public async Task DeductCreditsAsync( Guid userId, decimal amount, string transactionId, @@ -143,14 +139,14 @@ public async Task DeductTokensAsync( { if (amount <= 0) { - return TokenTransactionResult.CreateFailure("Token amount must be positive"); + return CreditTransactionResult.CreateFailure("Credit amount must be positive"); } // 중복 거래 체크 if (await _transactionRepository.TransactionExistsAsync(transactionId)) { _logger.LogWarning("Duplicate transaction attempt: {TransactionId}", transactionId); - return TokenTransactionResult.CreateFailure("Transaction already exists"); + return CreditTransactionResult.CreateFailure("Transaction already exists"); } var strategy = _context.Database.CreateExecutionStrategy(); @@ -162,60 +158,60 @@ public async Task DeductTokensAsync( var user = await _userRepository.GetByIdAsync(userId); if (user == null) { - return TokenTransactionResult.CreateFailure("User not found"); + return CreditTransactionResult.CreateFailure("User not found"); } // 잔액 확인 - if (user.TokenBalance < amount) + if (user.CreditBalance < amount) { - _logger.LogWarning("Insufficient tokens: User={UserId}, Required={Amount}, Available={Balance}", - userId, amount, user.TokenBalance); - return TokenTransactionResult.CreateFailure("Insufficient token balance"); + _logger.LogWarning("Insufficient credits: User={UserId}, Required={Amount}, Available={Balance}", + userId, amount, user.CreditBalance); + return CreditTransactionResult.CreateFailure("Insufficient credit balance"); } - // 토큰 차감 - user.TokenBalance -= amount; - user.TotalTokensSpent += amount; + // 크래딧 차감 + user.CreditBalance -= amount; + user.TotalCreditsSpent += amount; user.UpdatedAt = DateTime.UtcNow; await _userRepository.UpdateAsync(user); // 거래 기록 생성 - var tokenTransaction = new TokenTransaction + var creditTransaction = new CreditTransaction { UserId = userId, TransactionId = transactionId, - Type = TokenTransactionType.Spend, + Type = CreditTransactionType.Spend, Amount = -amount, // 음수로 저장하여 차감 표시 - BalanceAfter = user.TokenBalance, + BalanceAfter = user.CreditBalance, Source = source, Description = description, RelatedEntityId = relatedEntityId, RelatedEntityType = relatedEntityType }; - await _transactionRepository.CreateAsync(tokenTransaction); + await _transactionRepository.CreateAsync(creditTransaction); await dbTransaction.CommitAsync(); - _logger.LogInformation("Tokens deducted successfully: User={UserId}, Amount={Amount}, Source={Source}", + _logger.LogInformation("Credits deducted successfully: User={UserId}, Amount={Amount}, Source={Source}", userId, amount, source); - return TokenTransactionResult.CreateSuccess(transactionId, -amount, user.TokenBalance); + return CreditTransactionResult.CreateSuccess(transactionId, -amount, user.CreditBalance); } catch (Exception ex) { await dbTransaction.RollbackAsync(); - _logger.LogError(ex, "Failed to deduct tokens: User={UserId}, Amount={Amount}", userId, amount); - return TokenTransactionResult.CreateFailure("Failed to deduct tokens"); + _logger.LogError(ex, "Failed to deduct credits: User={UserId}, Amount={Amount}", userId, amount); + return CreditTransactionResult.CreateFailure("Failed to deduct credits"); } }); } - public async Task GetTokenHistoryAsync( + public async Task GetCreditHistoryAsync( Guid userId, int pageNumber = 1, int pageSize = 50, - TokenTransactionType? transactionType = null) + CreditTransactionType? transactionType = null) { // 페이지네이션 검증 if (pageNumber < 1) pageNumber = 1; @@ -224,7 +220,7 @@ public async Task GetTokenHistoryAsync( var (transactions, totalCount) = await _transactionRepository.GetUserTransactionsAsync( userId, pageNumber, pageSize, transactionType); - var transactionInfos = transactions.Select(t => new TokenTransactionInfo + var transactionInfos = transactions.Select(t => new CreditTransactionInfo { Id = t.Id, TransactionId = t.TransactionId, @@ -238,7 +234,7 @@ public async Task GetTokenHistoryAsync( CreatedAt = t.CreatedAt }).ToList(); - return new TokenTransactionHistory + return new CreditTransactionHistory { UserId = userId, Transactions = transactionInfos, @@ -248,18 +244,18 @@ public async Task GetTokenHistoryAsync( }; } - public async Task GrantInitialTokensAsync(Guid userId) + public async Task GrantInitialCreditsAsync(Guid userId) { var user = await _userRepository.GetByIdAsync(userId); if (user == null) { - _logger.LogWarning("Cannot grant initial tokens: User not found {UserId}", userId); + _logger.LogWarning("Cannot grant initial credits: User not found {UserId}", userId); return false; } - if (user.InitialTokensGranted) + if (user.InitialCreditsGranted) { - _logger.LogInformation("Initial tokens already granted for user {UserId}", userId); + _logger.LogInformation("Initial credits already granted for user {UserId}", userId); return false; } @@ -269,59 +265,59 @@ public async Task GrantInitialTokensAsync(Guid userId) using var transaction = await _context.Database.BeginTransactionAsync(); try { - // 첫 로그인 토큰 지급 - user.TokenBalance += INITIAL_TOKEN_AMOUNT; - user.TotalTokensEarned += INITIAL_TOKEN_AMOUNT; - user.InitialTokensGranted = true; + // 첫 로그인 크래딧 지급 + user.CreditBalance += INITIAL_CREDIT_AMOUNT; + user.TotalCreditsEarned += INITIAL_CREDIT_AMOUNT; + user.InitialCreditsGranted = true; user.UpdatedAt = DateTime.UtcNow; await _userRepository.UpdateAsync(user); // 거래 기록 생성 var transactionId = GenerateTransactionId(); - var tokenTransaction = new TokenTransaction + var creditTransaction = new CreditTransaction { UserId = userId, TransactionId = transactionId, - Type = TokenTransactionType.Earn, - Amount = INITIAL_TOKEN_AMOUNT, - BalanceAfter = user.TokenBalance, - Source = INITIAL_TOKEN_SOURCE, - Description = "첫 로그인 보너스 토큰", + Type = CreditTransactionType.Earn, + Amount = INITIAL_CREDIT_AMOUNT, + BalanceAfter = user.CreditBalance, + Source = INITIAL_CREDIT_SOURCE, + Description = "첫 로그인 보너스 크래딧", RelatedEntityType = "User", RelatedEntityId = userId.ToString() }; - await _transactionRepository.CreateAsync(tokenTransaction); + await _transactionRepository.CreateAsync(creditTransaction); await transaction.CommitAsync(); - _logger.LogInformation("Initial tokens granted successfully: User={UserId}, Amount={Amount}", - userId, INITIAL_TOKEN_AMOUNT); + _logger.LogInformation("Initial credits granted successfully: User={UserId}, Amount={Amount}", + userId, INITIAL_CREDIT_AMOUNT); return true; } catch (Exception ex) { await transaction.RollbackAsync(); - _logger.LogError(ex, "Failed to grant initial tokens for user {UserId}", userId); + _logger.LogError(ex, "Failed to grant initial credits for user {UserId}", userId); return false; } }); } - public async Task RollbackTransactionAsync(string originalTransactionId, string reason) + public async Task RollbackTransactionAsync(string originalTransactionId, string reason) { var originalTransaction = await _transactionRepository.GetByTransactionIdAsync(originalTransactionId); if (originalTransaction == null) { - return TokenTransactionResult.CreateFailure("Original transaction not found"); + return CreditTransactionResult.CreateFailure("Original transaction not found"); } // 이미 롤백된 거래인지 확인 - var existingRollback = await _transactionRepository.GetByRelatedEntityAsync("TokenTransaction", originalTransactionId); + var existingRollback = await _transactionRepository.GetByRelatedEntityAsync("CreditTransaction", originalTransactionId); if (existingRollback.Any(t => t.Source == ROLLBACK_SOURCE)) { - return TokenTransactionResult.CreateFailure("Transaction already rolled back"); + return CreditTransactionResult.CreateFailure("Transaction already rolled back"); } var strategy = _context.Database.CreateExecutionStrategy(); @@ -333,20 +329,20 @@ public async Task RollbackTransactionAsync(string origin var user = await _userRepository.GetByIdAsync(originalTransaction.UserId); if (user == null) { - return TokenTransactionResult.CreateFailure("User not found"); + return CreditTransactionResult.CreateFailure("User not found"); } // 롤백 처리: 원래 거래의 반대 동작 수행 var rollbackAmount = -originalTransaction.Amount; // 원래 거래의 반대 - user.TokenBalance += rollbackAmount; + user.CreditBalance += rollbackAmount; - if (originalTransaction.Type == TokenTransactionType.Spend) + if (originalTransaction.Type == CreditTransactionType.Spend) { - user.TotalTokensSpent -= Math.Abs(originalTransaction.Amount); + user.TotalCreditsSpent -= Math.Abs(originalTransaction.Amount); } else { - user.TotalTokensEarned -= originalTransaction.Amount; + user.TotalCreditsEarned -= originalTransaction.Amount; } user.UpdatedAt = DateTime.UtcNow; @@ -354,16 +350,16 @@ public async Task RollbackTransactionAsync(string origin // 롤백 거래 기록 생성 var rollbackTransactionId = GenerateTransactionId(); - var rollbackTransaction = new TokenTransaction + var rollbackTransaction = new CreditTransaction { UserId = originalTransaction.UserId, TransactionId = rollbackTransactionId, - Type = originalTransaction.Type == TokenTransactionType.Spend ? TokenTransactionType.Earn : TokenTransactionType.Spend, + Type = originalTransaction.Type == CreditTransactionType.Spend ? CreditTransactionType.Earn : CreditTransactionType.Spend, Amount = rollbackAmount, - BalanceAfter = user.TokenBalance, + BalanceAfter = user.CreditBalance, Source = ROLLBACK_SOURCE, Description = $"롤백: {reason}", - RelatedEntityType = "TokenTransaction", + RelatedEntityType = "CreditTransaction", RelatedEntityId = originalTransactionId }; @@ -373,13 +369,13 @@ public async Task RollbackTransactionAsync(string origin _logger.LogInformation("Transaction rolled back: Original={OriginalId}, Rollback={RollbackId}, Reason={Reason}", originalTransactionId, rollbackTransactionId, reason); - return TokenTransactionResult.CreateSuccess(rollbackTransactionId, rollbackAmount, user.TokenBalance); + return CreditTransactionResult.CreateSuccess(rollbackTransactionId, rollbackAmount, user.CreditBalance); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Failed to rollback transaction: {TransactionId}", originalTransactionId); - return TokenTransactionResult.CreateFailure("Failed to rollback transaction"); + return CreditTransactionResult.CreateFailure("Failed to rollback transaction"); } }); } diff --git a/ProjectVG.Application/Services/Token/ITokenManagementService.cs b/ProjectVG.Application/Services/Credit/ICreditManagementService.cs similarity index 61% rename from ProjectVG.Application/Services/Token/ITokenManagementService.cs rename to ProjectVG.Application/Services/Credit/ICreditManagementService.cs index ceebd88..cc228c6 100644 --- a/ProjectVG.Application/Services/Token/ITokenManagementService.cs +++ b/ProjectVG.Application/Services/Credit/ICreditManagementService.cs @@ -1,39 +1,39 @@ -using ProjectVG.Domain.Entities.Tokens; +using ProjectVG.Domain.Entities.Credits; -namespace ProjectVG.Application.Services.Token +namespace ProjectVG.Application.Services.Credit { /// - /// 토큰 관리 서비스 인터페이스 - /// 사용자의 토큰 잔액 관리, 토큰 증감, 거래 기록 등을 담당 + /// 크래딧 관리 서비스 인터페이스 + /// 사용자의 크래딧 잔액 관리, 크래딧 증감, 거래 기록 등을 담당 /// - public interface ITokenManagementService + public interface ICreditManagementService { /// - /// 사용자의 현재 토큰 잔액을 조회 + /// 사용자의 현재 크래딧 잔액을 조회 /// /// 사용자 ID - /// 토큰 잔액 정보 - Task GetTokenBalanceAsync(Guid userId); + /// 크래딧 잔액 정보 + Task GetCreditBalanceAsync(Guid userId); /// - /// 사용자가 충분한 토큰을 보유하고 있는지 검증 + /// 사용자가 충분한 크래딧을 보유하고 있는지 검증 /// /// 사용자 ID - /// 필요한 토큰 수 - /// 토큰 보유 여부 - Task HasSufficientTokensAsync(Guid userId, decimal requiredAmount); + /// 필요한 크래딧 수 + /// 크래딧 보유 여부 + Task HasSufficientCreditsAsync(Guid userId, decimal requiredAmount); /// - /// 토큰을 사용자에게 추가 (결제, 보너스 등) + /// 크래딧을 사용자에게 추가 (결제, 보너스 등) /// /// 사용자 ID - /// 추가할 토큰 수 - /// 토큰 획득 소스 (LOGIN_BONUS, PAYMENT, etc.) + /// 추가할 크래딧 수 + /// 크래딧 획득 소스 (LOGIN_BONUS, PAYMENT, etc.) /// 거래 설명 /// 관련 엔티티 ID (선택) /// 관련 엔티티 타입 (선택) /// 거래 결과 - Task AddTokensAsync( + Task AddCreditsAsync( Guid userId, decimal amount, string source, @@ -42,18 +42,18 @@ Task AddTokensAsync( string? relatedEntityType = null); /// - /// 사용자의 토큰을 차감 (채팅, 서비스 이용 등) - /// 토큰이 부족한 경우 예외 발생 + /// 사용자의 크래딧을 차감 (채팅, 서비스 이용 등) + /// 크래딧이 부족한 경우 예외 발생 /// /// 사용자 ID - /// 차감할 토큰 수 + /// 차감할 크래딧 수 /// 고유 거래 ID (중복 방지용) - /// 토큰 사용 소스 (CHAT_USAGE, SERVICE_FEE 등) + /// 크래딧 사용 소스 (CHAT_USAGE, SERVICE_FEE 등) /// 거래 설명 /// 관련 엔티티 ID (선택) /// 관련 엔티티 타입 (선택) /// 거래 결과 - Task DeductTokensAsync( + Task DeductCreditsAsync( Guid userId, decimal amount, string transactionId, @@ -63,53 +63,53 @@ Task DeductTokensAsync( string? relatedEntityType = null); /// - /// 사용자의 토큰 거래 내역을 조회 + /// 사용자의 크래딧 거래 내역을 조회 /// /// 사용자 ID /// 페이지 번호 (1부터 시작) /// 페이지 크기 /// 거래 유형 필터 (선택) - /// 토큰 거래 내역 - Task GetTokenHistoryAsync( + /// 크래딧 거래 내역 + Task GetCreditHistoryAsync( Guid userId, int pageNumber = 1, int pageSize = 50, - TokenTransactionType? transactionType = null); + CreditTransactionType? transactionType = null); /// - /// 첫 로그인 보너스 토큰 지급 + /// 첫 로그인 보너스 크래딧 지급 /// 이미 지급받은 경우 false 반환 /// /// 사용자 ID /// 지급 성공 여부 - Task GrantInitialTokensAsync(Guid userId); + Task GrantInitialCreditsAsync(Guid userId); /// - /// 토큰 거래를 롤백 (실패한 서비스에 대한 보상) + /// 크래딧 거래를 롤백 (실패한 서비스에 대한 보상) /// /// 원본 거래 ID /// 롤백 사유 /// 롤백 결과 - Task RollbackTransactionAsync(string originalTransactionId, string reason); + Task RollbackTransactionAsync(string originalTransactionId, string reason); } /// - /// 토큰 잔액 정보 + /// 크래딧 잔액 정보 /// - public class TokenBalanceInfo + public class CreditBalanceInfo { public Guid UserId { get; set; } public decimal CurrentBalance { get; set; } public decimal TotalEarned { get; set; } public decimal TotalSpent { get; set; } public DateTime LastUpdated { get; set; } - public bool InitialTokensGranted { get; set; } + public bool InitialCreditsGranted { get; set; } } /// - /// 토큰 거래 결과 + /// 크래딧 거래 결과 /// - public class TokenTransactionResult + public class CreditTransactionResult { public bool Success { get; set; } public string TransactionId { get; set; } = string.Empty; @@ -118,9 +118,9 @@ public class TokenTransactionResult public DateTime Timestamp { get; set; } public string? ErrorMessage { get; set; } - public static TokenTransactionResult CreateSuccess(string transactionId, decimal amount, decimal balanceAfter) + public static CreditTransactionResult CreateSuccess(string transactionId, decimal amount, decimal balanceAfter) { - return new TokenTransactionResult + return new CreditTransactionResult { Success = true, TransactionId = transactionId, @@ -130,9 +130,9 @@ public static TokenTransactionResult CreateSuccess(string transactionId, decimal }; } - public static TokenTransactionResult CreateFailure(string errorMessage) + public static CreditTransactionResult CreateFailure(string errorMessage) { - return new TokenTransactionResult + return new CreditTransactionResult { Success = false, ErrorMessage = errorMessage, @@ -142,12 +142,12 @@ public static TokenTransactionResult CreateFailure(string errorMessage) } /// - /// 토큰 거래 내역 + /// 크래딧 거래 내역 /// - public class TokenTransactionHistory + public class CreditTransactionHistory { public Guid UserId { get; set; } - public List Transactions { get; set; } = new(); + public List Transactions { get; set; } = new(); public int TotalCount { get; set; } public int PageNumber { get; set; } public int PageSize { get; set; } @@ -157,13 +157,13 @@ public class TokenTransactionHistory } /// - /// 토큰 거래 정보 + /// 크래딧 거래 정보 /// - public class TokenTransactionInfo + public class CreditTransactionInfo { public int Id { get; set; } public string TransactionId { get; set; } = string.Empty; - public TokenTransactionType Type { get; set; } + public CreditTransactionType Type { get; set; } public decimal Amount { get; set; } public decimal BalanceAfter { get; set; } public string Source { get; set; } = string.Empty; diff --git a/ProjectVG.Common/Constants/ErrorCodes.cs b/ProjectVG.Common/Constants/ErrorCodes.cs index 9c3e54f..ecd21a0 100644 --- a/ProjectVG.Common/Constants/ErrorCodes.cs +++ b/ProjectVG.Common/Constants/ErrorCodes.cs @@ -87,10 +87,10 @@ public enum ErrorCode RATE_LIMIT_EXCEEDED, RESOURCE_QUOTA_EXCEEDED, - // 토큰 관련 오류 - INSUFFICIENT_TOKEN_BALANCE, - TOKEN_TRANSACTION_FAILED, - TOKEN_GRANT_FAILED + // 크래딧 관련 오류 + INSUFFICIENT_CREDIT_BALANCE, + CREDIT_TRANSACTION_FAILED, + CREDIT_GRANT_FAILED } public static class ErrorCodeExtensions @@ -182,10 +182,10 @@ public static class ErrorCodeExtensions { ErrorCode.RATE_LIMIT_EXCEEDED, "요청 한도를 초과했습니다" }, { ErrorCode.RESOURCE_QUOTA_EXCEEDED, "리소스 할당량을 초과했습니다" }, - // 토큰 관련 오류 - { ErrorCode.INSUFFICIENT_TOKEN_BALANCE, "토큰 잔액이 부족합니다" }, - { ErrorCode.TOKEN_TRANSACTION_FAILED, "토큰 거래에 실패했습니다" }, - { ErrorCode.TOKEN_GRANT_FAILED, "토큰 지급에 실패했습니다" } + // 크래딧 관련 오류 + { ErrorCode.INSUFFICIENT_CREDIT_BALANCE, "크래딧 잔액이 부족합니다" }, + { ErrorCode.CREDIT_TRANSACTION_FAILED, "크래딧 거래에 실패했습니다" }, + { ErrorCode.CREDIT_GRANT_FAILED, "크래딧 지급에 실패했습니다" } }; public static string GetMessage(this ErrorCode errorCode) diff --git a/ProjectVG.Domain/Entities/Token/TokenTransaction.cs b/ProjectVG.Domain/Entities/Token/CreditTransaction.cs similarity index 92% rename from ProjectVG.Domain/Entities/Token/TokenTransaction.cs rename to ProjectVG.Domain/Entities/Token/CreditTransaction.cs index a9b724e..2f12ec2 100644 --- a/ProjectVG.Domain/Entities/Token/TokenTransaction.cs +++ b/ProjectVG.Domain/Entities/Token/CreditTransaction.cs @@ -1,12 +1,12 @@ using ProjectVG.Domain.Common; -namespace ProjectVG.Domain.Entities.Tokens +namespace ProjectVG.Domain.Entities.Credits { /// /// 토큰 거래 내역 엔티티 /// 모든 토큰 증감 이력을 추적하여 감사와 투명성을 보장 /// - public class TokenTransaction : BaseEntity + public class CreditTransaction : BaseEntity { /// /// 내부용 고유 ID @@ -26,7 +26,7 @@ public class TokenTransaction : BaseEntity /// /// 거래 유형 (EARN: 획득, SPEND: 사용) /// - public TokenTransactionType Type { get; set; } + public CreditTransactionType Type { get; set; } /// /// 거래 금액 (양수: 증가, 음수: 감소) @@ -67,7 +67,7 @@ public class TokenTransaction : BaseEntity /// /// 토큰 거래 유형 /// - public enum TokenTransactionType + public enum CreditTransactionType { /// /// 토큰 획득 (로그인 보너스, 결제, 이벤트 등) diff --git a/ProjectVG.Domain/Entities/User/User.cs b/ProjectVG.Domain/Entities/User/User.cs index 9ea2486..735f61d 100644 --- a/ProjectVG.Domain/Entities/User/User.cs +++ b/ProjectVG.Domain/Entities/User/User.cs @@ -51,24 +51,24 @@ public class User : BaseEntity public AccountStatus Status { get; set; } = AccountStatus.Active; /// - /// 현재 토큰 잔액 (1 Cost = 1 Token) + /// 현재 크래딧 잔액 (1 Cost = 1 Credit) /// - public decimal TokenBalance { get; set; } = 0; + public decimal CreditBalance { get; set; } = 0; /// - /// 총 획득한 토큰 수 (누적) + /// 총 획득한 크래딧 수 (누적) /// - public decimal TotalTokensEarned { get; set; } = 0; + public decimal TotalCreditsEarned { get; set; } = 0; /// - /// 총 사용한 토큰 수 (누적) + /// 총 사용한 크래딧 수 (누적) /// - public decimal TotalTokensSpent { get; set; } = 0; + public decimal TotalCreditsSpent { get; set; } = 0; /// - /// 첫 로그인 토큰 지급 여부 + /// 첫 로그인 크래딧 지급 여부 /// - public bool InitialTokensGranted { get; set; } = false; + public bool InitialCreditsGranted { get; set; } = false; /// /// 사용자가 생성한 캐릭터들 (네비게이션 속성) diff --git a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs index d4dda61..d760911 100644 --- a/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/ProjectVG.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -8,7 +8,7 @@ using ProjectVG.Infrastructure.Persistence.EfCore; using ProjectVG.Infrastructure.Persistence.Repositories.Characters; using ProjectVG.Infrastructure.Persistence.Repositories.Conversation; -using ProjectVG.Infrastructure.Persistence.Repositories.Token; +using ProjectVG.Infrastructure.Persistence.Repositories.Credit; using ProjectVG.Infrastructure.Persistence.Repositories.Users; using ProjectVG.Infrastructure.Persistence.Session; using ProjectVG.Infrastructure.Auth; @@ -98,7 +98,7 @@ private static void AddPersistenceServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddSingleton(); } diff --git a/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs index ef7f5dd..a8bf820 100644 --- a/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs +++ b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -205,7 +205,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(255) .HasColumnType("nvarchar(255)"); - b.Property("InitialTokensGranted") + b.Property("InitialCreditsGranted") .HasColumnType("bit"); b.Property("Provider") @@ -221,13 +221,13 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("int"); - b.Property("TokenBalance") + b.Property("CreditBalance") .HasColumnType("decimal(18,2)"); - b.Property("TotalTokensEarned") + b.Property("TotalCreditsEarned") .HasColumnType("decimal(18,2)"); - b.Property("TotalTokensSpent") + b.Property("TotalCreditsSpent") .HasColumnType("decimal(18,2)"); b.Property("UID") diff --git a/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs index 117a7e2..da569bc 100644 --- a/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs +++ b/ProjectVG.Infrastructure/Migrations/20250903032058_AddTokenSystemToUser.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -12,28 +12,28 @@ public partial class AddTokenSystemToUser : Migration protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn( - name: "InitialTokensGranted", + name: "InitialCreditsGranted", table: "Users", type: "bit", nullable: false, defaultValue: false); migrationBuilder.AddColumn( - name: "TokenBalance", + name: "CreditBalance", table: "Users", type: "decimal(18,2)", nullable: false, defaultValue: 0m); migrationBuilder.AddColumn( - name: "TotalTokensEarned", + name: "TotalCreditsEarned", table: "Users", type: "decimal(18,2)", nullable: false, defaultValue: 0m); migrationBuilder.AddColumn( - name: "TotalTokensSpent", + name: "TotalCreditsSpent", table: "Users", type: "decimal(18,2)", nullable: false, @@ -57,14 +57,14 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Users", keyColumn: "Id", keyValue: new Guid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - columns: new[] { "CreatedAt", "InitialTokensGranted", "TokenBalance", "TotalTokensEarned", "TotalTokensSpent", "UpdatedAt" }, + columns: new[] { "CreatedAt", "InitialCreditsGranted", "CreditBalance", "TotalCreditsEarned", "TotalCreditsSpent", "UpdatedAt" }, values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812), false, 0m, 0m, 0m, new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5812) }); migrationBuilder.UpdateData( table: "Users", keyColumn: "Id", keyValue: new Guid("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), - columns: new[] { "CreatedAt", "InitialTokensGranted", "TokenBalance", "TotalTokensEarned", "TotalTokensSpent", "UpdatedAt" }, + columns: new[] { "CreatedAt", "InitialCreditsGranted", "CreditBalance", "TotalCreditsEarned", "TotalCreditsSpent", "UpdatedAt" }, values: new object[] { new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814), false, 0m, 0m, 0m, new DateTime(2025, 9, 3, 3, 20, 58, 519, DateTimeKind.Utc).AddTicks(5814) }); } @@ -72,19 +72,19 @@ protected override void Up(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( - name: "InitialTokensGranted", + name: "InitialCreditsGranted", table: "Users"); migrationBuilder.DropColumn( - name: "TokenBalance", + name: "CreditBalance", table: "Users"); migrationBuilder.DropColumn( - name: "TotalTokensEarned", + name: "TotalCreditsEarned", table: "Users"); migrationBuilder.DropColumn( - name: "TotalTokensSpent", + name: "TotalCreditsSpent", table: "Users"); migrationBuilder.UpdateData( diff --git a/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs index 6acacda..fa47ea3 100644 --- a/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs +++ b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -191,7 +191,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ConversationHistories"); }); - modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + modelBuilder.Entity("ProjectVG.Domain.Entities.Credits.CreditTransaction", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -257,7 +257,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.HasIndex("UserId", "CreatedAt"); - b.ToTable("TokenTransactions"); + b.ToTable("CreditTransactions"); }); modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => @@ -274,7 +274,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasMaxLength(255) .HasColumnType("nvarchar(255)"); - b.Property("InitialTokensGranted") + b.Property("InitialCreditsGranted") .ValueGeneratedOnAdd() .HasColumnType("bit") .HasDefaultValue(false); @@ -292,19 +292,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("int"); - b.Property("TokenBalance") + b.Property("CreditBalance") .ValueGeneratedOnAdd() .HasPrecision(18, 2) .HasColumnType("decimal(18,2)") .HasDefaultValue(0m); - b.Property("TotalTokensEarned") + b.Property("TotalCreditsEarned") .ValueGeneratedOnAdd() .HasPrecision(18, 2) .HasColumnType("decimal(18,2)") .HasDefaultValue(0m); - b.Property("TotalTokensSpent") + b.Property("TotalCreditsSpent") .ValueGeneratedOnAdd() .HasPrecision(18, 2) .HasColumnType("decimal(18,2)") @@ -395,7 +395,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + modelBuilder.Entity("ProjectVG.Domain.Entities.Credits.CreditTransaction", b => { b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") .WithMany() diff --git a/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs index fed14f9..23780fd 100644 --- a/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs +++ b/ProjectVG.Infrastructure/Migrations/20250903032155_AddTokenTransactionEntity.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -12,7 +12,7 @@ public partial class AddTokenTransactionEntity : Migration protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AlterColumn( - name: "TotalTokensSpent", + name: "TotalCreditsSpent", table: "Users", type: "decimal(18,2)", precision: 18, @@ -23,7 +23,7 @@ protected override void Up(MigrationBuilder migrationBuilder) oldType: "decimal(18,2)"); migrationBuilder.AlterColumn( - name: "TotalTokensEarned", + name: "TotalCreditsEarned", table: "Users", type: "decimal(18,2)", precision: 18, @@ -34,7 +34,7 @@ protected override void Up(MigrationBuilder migrationBuilder) oldType: "decimal(18,2)"); migrationBuilder.AlterColumn( - name: "TokenBalance", + name: "CreditBalance", table: "Users", type: "decimal(18,2)", precision: 18, @@ -45,7 +45,7 @@ protected override void Up(MigrationBuilder migrationBuilder) oldType: "decimal(18,2)"); migrationBuilder.AlterColumn( - name: "InitialTokensGranted", + name: "InitialCreditsGranted", table: "Users", type: "bit", nullable: false, @@ -54,7 +54,7 @@ protected override void Up(MigrationBuilder migrationBuilder) oldType: "bit"); migrationBuilder.CreateTable( - name: "TokenTransactions", + name: "CreditTransactions", columns: table => new { Id = table.Column(type: "int", nullable: false) @@ -112,33 +112,33 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.CreateIndex( name: "IX_TokenTransactions_RelatedEntityType_RelatedEntityId", - table: "TokenTransactions", + table: "CreditTransactions", columns: new[] { "RelatedEntityType", "RelatedEntityId" }); migrationBuilder.CreateIndex( name: "IX_TokenTransactions_Source", - table: "TokenTransactions", + table: "CreditTransactions", column: "Source"); migrationBuilder.CreateIndex( name: "IX_TokenTransactions_TransactionId", - table: "TokenTransactions", + table: "CreditTransactions", column: "TransactionId", unique: true); migrationBuilder.CreateIndex( name: "IX_TokenTransactions_Type", - table: "TokenTransactions", + table: "CreditTransactions", column: "Type"); migrationBuilder.CreateIndex( name: "IX_TokenTransactions_UserId", - table: "TokenTransactions", + table: "CreditTransactions", column: "UserId"); migrationBuilder.CreateIndex( name: "IX_TokenTransactions_UserId_CreatedAt", - table: "TokenTransactions", + table: "CreditTransactions", columns: new[] { "UserId", "CreatedAt" }); } @@ -146,10 +146,10 @@ protected override void Up(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable( - name: "TokenTransactions"); + name: "CreditTransactions"); migrationBuilder.AlterColumn( - name: "TotalTokensSpent", + name: "TotalCreditsSpent", table: "Users", type: "decimal(18,2)", nullable: false, @@ -160,7 +160,7 @@ protected override void Down(MigrationBuilder migrationBuilder) oldDefaultValue: 0m); migrationBuilder.AlterColumn( - name: "TotalTokensEarned", + name: "TotalCreditsEarned", table: "Users", type: "decimal(18,2)", nullable: false, @@ -171,7 +171,7 @@ protected override void Down(MigrationBuilder migrationBuilder) oldDefaultValue: 0m); migrationBuilder.AlterColumn( - name: "TokenBalance", + name: "CreditBalance", table: "Users", type: "decimal(18,2)", nullable: false, @@ -182,7 +182,7 @@ protected override void Down(MigrationBuilder migrationBuilder) oldDefaultValue: 0m); migrationBuilder.AlterColumn( - name: "InitialTokensGranted", + name: "InitialCreditsGranted", table: "Users", type: "bit", nullable: false, diff --git a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs index 6d690bb..d53bbc3 100644 --- a/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs +++ b/ProjectVG.Infrastructure/Migrations/ProjectVGDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -188,7 +188,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ConversationHistories"); }); - modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + modelBuilder.Entity("ProjectVG.Domain.Entities.Credits.CreditTransaction", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -254,7 +254,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId", "CreatedAt"); - b.ToTable("TokenTransactions"); + b.ToTable("CreditTransactions"); }); modelBuilder.Entity("ProjectVG.Domain.Entities.Users.User", b => @@ -271,7 +271,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(255) .HasColumnType("nvarchar(255)"); - b.Property("InitialTokensGranted") + b.Property("InitialCreditsGranted") .ValueGeneratedOnAdd() .HasColumnType("bit") .HasDefaultValue(false); @@ -289,19 +289,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("int"); - b.Property("TokenBalance") + b.Property("CreditBalance") .ValueGeneratedOnAdd() .HasPrecision(18, 2) .HasColumnType("decimal(18,2)") .HasDefaultValue(0m); - b.Property("TotalTokensEarned") + b.Property("TotalCreditsEarned") .ValueGeneratedOnAdd() .HasPrecision(18, 2) .HasColumnType("decimal(18,2)") .HasDefaultValue(0m); - b.Property("TotalTokensSpent") + b.Property("TotalCreditsSpent") .ValueGeneratedOnAdd() .HasPrecision(18, 2) .HasColumnType("decimal(18,2)") @@ -392,7 +392,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("ProjectVG.Domain.Entities.Tokens.TokenTransaction", b => + modelBuilder.Entity("ProjectVG.Domain.Entities.Credits.CreditTransaction", b => { b.HasOne("ProjectVG.Domain.Entities.Users.User", "User") .WithMany() diff --git a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs index 0d04dd8..8d52a88 100644 --- a/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs +++ b/ProjectVG.Infrastructure/Persistence/EfCore/Data/ProjectVGDbContext.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; using ProjectVG.Domain.Entities.Characters; using ProjectVG.Domain.Entities.ConversationHistorys; -using ProjectVG.Domain.Entities.Tokens; +using ProjectVG.Domain.Entities.Credits; using ProjectVG.Domain.Entities.Users; using ProjectVG.Infrastructure.Persistence.Data; @@ -16,7 +16,7 @@ public ProjectVGDbContext(DbContextOptions options) : base(o public DbSet Users { get; set; } public DbSet Characters { get; set; } public DbSet ConversationHistories { get; set; } - public DbSet TokenTransactions { get; set; } + public DbSet CreditTransactions { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -34,11 +34,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.Provider).IsRequired().HasMaxLength(50); entity.Property(e => e.Status).IsRequired(); - // 토큰 관련 필드 설정 (정밀도: 18, 소수점: 2) - entity.Property(e => e.TokenBalance).HasPrecision(18, 2).HasDefaultValue(0m); - entity.Property(e => e.TotalTokensEarned).HasPrecision(18, 2).HasDefaultValue(0m); - entity.Property(e => e.TotalTokensSpent).HasPrecision(18, 2).HasDefaultValue(0m); - entity.Property(e => e.InitialTokensGranted).HasDefaultValue(false); + // 크래딧 관련 필드 설정 + entity.Property(e => e.CreditBalance).HasPrecision(18, 2).HasDefaultValue(0m); + entity.Property(e => e.TotalCreditsEarned).HasPrecision(18, 2).HasDefaultValue(0m); + entity.Property(e => e.TotalCreditsSpent).HasPrecision(18, 2).HasDefaultValue(0m); + entity.Property(e => e.InitialCreditsGranted).HasDefaultValue(false); // 인덱스 설정 entity.HasIndex(e => e.UID).IsUnique(); @@ -125,8 +125,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(e => e.Role); }); - // TokenTransactions 엔티티 설정 - modelBuilder.Entity(entity => + // CreditTransactions 엔티티 설정 + modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); entity.Property(e => e.Id).ValueGeneratedOnAdd(); diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Token/ITokenTransactionRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Credit/ICreditTransactionRepository.cs similarity index 77% rename from ProjectVG.Infrastructure/Persistence/Repositories/Token/ITokenTransactionRepository.cs rename to ProjectVG.Infrastructure/Persistence/Repositories/Credit/ICreditTransactionRepository.cs index 32348e5..8299974 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/Token/ITokenTransactionRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Credit/ICreditTransactionRepository.cs @@ -1,26 +1,26 @@ -using ProjectVG.Domain.Entities.Tokens; +using ProjectVG.Domain.Entities.Credits; -namespace ProjectVG.Infrastructure.Persistence.Repositories.Token +namespace ProjectVG.Infrastructure.Persistence.Repositories.Credit { /// /// 토큰 거래 기록 저장소 인터페이스 /// 토큰 거래 내역의 생성, 조회, 검증 등을 담당 /// - public interface ITokenTransactionRepository + public interface ICreditTransactionRepository { /// /// 새로운 토큰 거래 기록 생성 /// /// 토큰 거래 엔티티 /// 생성된 토큰 거래 기록 - Task CreateAsync(TokenTransaction transaction); + Task CreateAsync(CreditTransaction transaction); /// /// 거래 ID로 토큰 거래 기록 조회 /// /// 거래 고유 ID /// 토큰 거래 기록 (없으면 null) - Task GetByTransactionIdAsync(string transactionId); + Task GetByTransactionIdAsync(string transactionId); /// /// 사용자의 토큰 거래 내역을 페이지네이션으로 조회 @@ -30,11 +30,11 @@ public interface ITokenTransactionRepository /// 페이지 크기 /// 거래 유형 필터 (선택) /// 토큰 거래 내역 리스트 - Task<(List Transactions, int TotalCount)> GetUserTransactionsAsync( + Task<(List Transactions, int TotalCount)> GetUserTransactionsAsync( Guid userId, int pageNumber, int pageSize, - TokenTransactionType? transactionType = null); + CreditTransactionType? transactionType = null); /// /// 특정 기간 내 사용자의 토큰 거래 총계 조회 @@ -48,7 +48,7 @@ Task GetUserTransactionSumAsync( Guid userId, DateTime startDate, DateTime endDate, - TokenTransactionType? transactionType = null); + CreditTransactionType? transactionType = null); /// /// 관련 엔티티로 토큰 거래 기록들 조회 @@ -56,7 +56,7 @@ Task GetUserTransactionSumAsync( /// 관련 엔티티 타입 /// 관련 엔티티 ID /// 관련 토큰 거래 기록들 - Task> GetByRelatedEntityAsync(string relatedEntityType, string relatedEntityId); + Task> GetByRelatedEntityAsync(string relatedEntityType, string relatedEntityId); /// /// 특정 소스의 토큰 거래 기록들 조회 @@ -65,7 +65,7 @@ Task GetUserTransactionSumAsync( /// 거래 소스 /// 조회 개수 제한 (선택) /// 소스별 토큰 거래 기록들 - Task> GetBySourceAsync(Guid userId, string source, int? limit = null); + Task> GetBySourceAsync(Guid userId, string source, int? limit = null); /// /// 거래 ID 중복 여부 확인 @@ -80,6 +80,6 @@ Task GetUserTransactionSumAsync( /// 사용자 ID /// 조회할 거래 개수 /// 최근 토큰 거래 기록들 - Task> GetRecentTransactionsAsync(Guid userId, int count = 10); + Task> GetRecentTransactionsAsync(Guid userId, int count = 10); } } \ No newline at end of file diff --git a/ProjectVG.Infrastructure/Persistence/Repositories/Token/SqlServerTokenTransactionRepository.cs b/ProjectVG.Infrastructure/Persistence/Repositories/Credit/SqlServerCreditTransactionRepository.cs similarity index 62% rename from ProjectVG.Infrastructure/Persistence/Repositories/Token/SqlServerTokenTransactionRepository.cs rename to ProjectVG.Infrastructure/Persistence/Repositories/Credit/SqlServerCreditTransactionRepository.cs index 3805896..12d1e4a 100644 --- a/ProjectVG.Infrastructure/Persistence/Repositories/Token/SqlServerTokenTransactionRepository.cs +++ b/ProjectVG.Infrastructure/Persistence/Repositories/Credit/SqlServerCreditTransactionRepository.cs @@ -1,32 +1,32 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using ProjectVG.Domain.Entities.Tokens; +using ProjectVG.Domain.Entities.Credits; using ProjectVG.Infrastructure.Persistence.EfCore; -namespace ProjectVG.Infrastructure.Persistence.Repositories.Token +namespace ProjectVG.Infrastructure.Persistence.Repositories.Credit { /// - /// SQL Server 기반 토큰 거래 기록 저장소 구현 + /// SQL Server 기반 크래딧 거래 기록 저장소 구현 /// - public class SqlServerTokenTransactionRepository : ITokenTransactionRepository + public class SqlServerCreditTransactionRepository : ICreditTransactionRepository { private readonly ProjectVGDbContext _context; - private readonly ILogger _logger; + private readonly ILogger _logger; - public SqlServerTokenTransactionRepository(ProjectVGDbContext context, ILogger logger) + public SqlServerCreditTransactionRepository(ProjectVGDbContext context, ILogger logger) { _context = context; _logger = logger; } - public async Task CreateAsync(TokenTransaction transaction) + public async Task CreateAsync(CreditTransaction transaction) { try { - _context.TokenTransactions.Add(transaction); + _context.CreditTransactions.Add(transaction); await _context.SaveChangesAsync(); - _logger.LogInformation("Token transaction created: {TransactionId} for User {UserId}, Amount: {Amount}", + _logger.LogInformation("Credit transaction created: {TransactionId} for User {UserId}, Amount: {Amount}", transaction.TransactionId, transaction.UserId, transaction.Amount); return transaction; @@ -38,20 +38,20 @@ public async Task CreateAsync(TokenTransaction transaction) } } - public async Task GetByTransactionIdAsync(string transactionId) + public async Task GetByTransactionIdAsync(string transactionId) { - return await _context.TokenTransactions + return await _context.CreditTransactions .Include(t => t.User) .FirstOrDefaultAsync(t => t.TransactionId == transactionId); } - public async Task<(List Transactions, int TotalCount)> GetUserTransactionsAsync( + public async Task<(List Transactions, int TotalCount)> GetUserTransactionsAsync( Guid userId, int pageNumber, int pageSize, - TokenTransactionType? transactionType = null) + CreditTransactionType? transactionType = null) { - var query = _context.TokenTransactions + var query = _context.CreditTransactions .Where(t => t.UserId == userId); if (transactionType.HasValue) @@ -74,9 +74,9 @@ public async Task GetUserTransactionSumAsync( Guid userId, DateTime startDate, DateTime endDate, - TokenTransactionType? transactionType = null) + CreditTransactionType? transactionType = null) { - var query = _context.TokenTransactions + var query = _context.CreditTransactions .Where(t => t.UserId == userId && t.CreatedAt >= startDate && t.CreatedAt <= endDate); @@ -89,18 +89,18 @@ public async Task GetUserTransactionSumAsync( return await query.SumAsync(t => t.Amount); } - public async Task> GetByRelatedEntityAsync(string relatedEntityType, string relatedEntityId) + public async Task> GetByRelatedEntityAsync(string relatedEntityType, string relatedEntityId) { - return await _context.TokenTransactions + return await _context.CreditTransactions .Where(t => t.RelatedEntityType == relatedEntityType && t.RelatedEntityId == relatedEntityId) .OrderByDescending(t => t.CreatedAt) .ToListAsync(); } - public async Task> GetBySourceAsync(Guid userId, string source, int? limit = null) + public async Task> GetBySourceAsync(Guid userId, string source, int? limit = null) { - var query = _context.TokenTransactions + var query = _context.CreditTransactions .Where(t => t.UserId == userId && t.Source == source) .OrderByDescending(t => t.CreatedAt); @@ -114,13 +114,13 @@ public async Task> GetBySourceAsync(Guid userId, string s public async Task TransactionExistsAsync(string transactionId) { - return await _context.TokenTransactions + return await _context.CreditTransactions .AnyAsync(t => t.TransactionId == transactionId); } - public async Task> GetRecentTransactionsAsync(Guid userId, int count = 10) + public async Task> GetRecentTransactionsAsync(Guid userId, int count = 10) { - return await _context.TokenTransactions + return await _context.CreditTransactions .Where(t => t.UserId == userId) .OrderByDescending(t => t.CreatedAt) .Take(count) diff --git a/ProjectVG.Tests/Auth/AuthServiceTests.cs b/ProjectVG.Tests/Auth/AuthServiceTests.cs index a54a0d1..2cb1e91 100644 --- a/ProjectVG.Tests/Auth/AuthServiceTests.cs +++ b/ProjectVG.Tests/Auth/AuthServiceTests.cs @@ -3,7 +3,7 @@ using Moq; using ProjectVG.Application.Services.Auth; using ProjectVG.Application.Services.Users; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; using ProjectVG.Infrastructure.Auth; using ProjectVG.Common.Models; using ProjectVG.Application.Models.User; @@ -19,14 +19,14 @@ public class AuthServiceTests private readonly AuthService _authService; private readonly Mock _mockUserService; private readonly Mock _mockTokenService; - private readonly Mock _mockTokenManagementService; + private readonly Mock _mockTokenManagementService; private readonly Mock> _mockLogger; public AuthServiceTests() { _mockUserService = new Mock(); _mockTokenService = new Mock(); - _mockTokenManagementService = new Mock(); + _mockTokenManagementService = new Mock(); _mockLogger = new Mock>(); _authService = new AuthService( @@ -54,7 +54,7 @@ public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResul }; _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(true); + _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act var result = await _authService.LoginWithOAuthAsync(provider, accessToken); @@ -69,7 +69,7 @@ public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResul result.User.Status.Should().Be(AccountStatus.Active); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); - _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); } [Fact] @@ -171,7 +171,7 @@ public async Task LoginWithOAuthAsync_NewGuestUser_ShouldCreateAndReturnSuccessR _mockUserService.Setup(x => x.TryGetByProviderAsync("guest", guestId)).ReturnsAsync((UserDto?)null); _mockUserService.Setup(x => x.CreateUserAsync(It.IsAny())).ReturnsAsync(createdUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(true); + _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act var result = await _authService.LoginWithOAuthAsync(provider, guestId); @@ -188,7 +188,7 @@ public async Task LoginWithOAuthAsync_NewGuestUser_ShouldCreateAndReturnSuccessR _mockUserService.Verify(x => x.TryGetByProviderAsync("guest", guestId), Times.Once); _mockUserService.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Once); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); - _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); } [Fact] @@ -217,7 +217,7 @@ public async Task LoginWithOAuthAsync_ExistingGuestUser_ShouldReturnSuccessResul _mockUserService.Setup(x => x.TryGetByProviderAsync("guest", guestId)).ReturnsAsync(existingUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(false); + _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); // Act var result = await _authService.LoginWithOAuthAsync(provider, guestId); @@ -230,7 +230,7 @@ public async Task LoginWithOAuthAsync_ExistingGuestUser_ShouldReturnSuccessResul _mockUserService.Verify(x => x.TryGetByProviderAsync("guest", guestId), Times.Once); _mockUserService.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Never); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); - _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); } [Fact] @@ -391,7 +391,7 @@ public async Task LoginWithOAuthAsync_NewUser_ShouldGrantInitialTokensAndLogSucc _mockUserService.Setup(x => x.TryGetByProviderAsync(provider, guestId)).ReturnsAsync((UserDto?)null); _mockUserService.Setup(x => x.CreateUserAsync(It.IsAny())).ReturnsAsync(createdUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(true); + _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act var result = await _authService.LoginWithOAuthAsync(provider, guestId); @@ -401,7 +401,7 @@ public async Task LoginWithOAuthAsync_NewUser_ShouldGrantInitialTokensAndLogSucc result.User.Should().Be(createdUser); // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); // Verify success logging _mockLogger.Verify( @@ -441,7 +441,7 @@ public async Task LoginWithOAuthAsync_ExistingUser_ShouldNotGrantTokensAndLogAlr _mockUserService.Setup(x => x.TryGetByProviderAsync(provider, guestId)).ReturnsAsync(existingUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(false); + _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); // Act var result = await _authService.LoginWithOAuthAsync(provider, guestId); @@ -451,7 +451,7 @@ public async Task LoginWithOAuthAsync_ExistingUser_ShouldNotGrantTokensAndLogAlr result.User.Should().Be(existingUser); // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); // Verify already granted logging _mockLogger.Verify( @@ -481,7 +481,7 @@ public async Task LoginWithOAuthAsync_TokenGrantingFails_ShouldStillCompleteLogi }; _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)).ReturnsAsync(false); + _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); // Act var result = await _authService.LoginWithOAuthAsync(provider, providerId); @@ -491,7 +491,7 @@ public async Task LoginWithOAuthAsync_TokenGrantingFails_ShouldStillCompleteLogi result.Tokens.Should().Be(tokenResponse); // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); // Verify failure logging _mockLogger.Verify( @@ -521,8 +521,8 @@ public async Task LoginWithOAuthAsync_TokenGrantingThrowsException_ShouldNotFail }; _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialTokensAsync(userId)) - .ThrowsAsync(new Exception("Token granting service unavailable")); + _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)) + .ThrowsAsync(new Exception("Credit granting service unavailable")); // Act & Assert - Should not throw exception var result = await _authService.LoginWithOAuthAsync(provider, accessToken); @@ -533,7 +533,7 @@ public async Task LoginWithOAuthAsync_TokenGrantingThrowsException_ShouldNotFail result.User!.Id.Should().Be(userId); // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialTokensAsync(userId), Times.Once); + _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); } #endregion diff --git a/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs index 7e2e39c..12b534a 100644 --- a/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs +++ b/ProjectVG.Tests/Services/Chat/Handlers/ChatSuccessHandlerTests.cs @@ -5,7 +5,7 @@ using ProjectVG.Application.Models.WebSocket; using ProjectVG.Application.Services.Chat.Handlers; using ProjectVG.Application.Services.WebSocket; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; using Xunit; namespace ProjectVG.Tests.Services.Chat.Handlers @@ -14,15 +14,15 @@ public class ChatSuccessHandlerTests { private readonly Mock> _mockLogger; private readonly Mock _mockWebSocketService; - private readonly Mock _mockTokenManagementService; + private readonly Mock _mockCreditManagementService; private readonly ChatSuccessHandler _handler; public ChatSuccessHandlerTests() { _mockLogger = new Mock>(); _mockWebSocketService = new Mock(); - _mockTokenManagementService = new Mock(); - _handler = new ChatSuccessHandler(_mockLogger.Object, _mockWebSocketService.Object, _mockTokenManagementService.Object); + _mockCreditManagementService = new Mock(); + _handler = new ChatSuccessHandler(_mockLogger.Object, _mockWebSocketService.Object, _mockCreditManagementService.Object); } [Fact] diff --git a/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs b/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs index bb63285..d1f89aa 100644 --- a/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs +++ b/ProjectVG.Tests/Services/Chat/Validator/ChatRequestValidatorTests.cs @@ -4,7 +4,7 @@ using ProjectVG.Application.Models.Chat; using ProjectVG.Application.Services.Chat.Validators; using ProjectVG.Application.Services.Character; -using ProjectVG.Application.Services.Token; +using ProjectVG.Application.Services.Credit; using ProjectVG.Application.Services.Users; using ProjectVG.Application.Models.Character; using ProjectVG.Infrastructure.Persistence.Session; @@ -21,7 +21,7 @@ public class ChatRequestValidatorTests private readonly Mock _mockSessionStorage; private readonly Mock _mockUserService; private readonly Mock _mockCharacterService; - private readonly Mock _mockTokenManagementService; + private readonly Mock _mockCreditManagementService; private readonly Mock> _mockLogger; public ChatRequestValidatorTests() @@ -29,14 +29,14 @@ public ChatRequestValidatorTests() _mockSessionStorage = new Mock(); _mockUserService = new Mock(); _mockCharacterService = new Mock(); - _mockTokenManagementService = new Mock(); + _mockCreditManagementService = new Mock(); _mockLogger = new Mock>(); _validator = new ChatRequestValidator( _mockSessionStorage.Object, _mockUserService.Object, _mockCharacterService.Object, - _mockTokenManagementService.Object, + _mockCreditManagementService.Object, _mockLogger.Object); } @@ -48,20 +48,20 @@ public async Task ValidateAsync_ValidRequest_ShouldPassWithoutException() // Arrange var command = CreateValidChatCommand(); var character = CreateValidCharacterDto(command.CharacterId); - var tokenBalance = CreateTokenBalance(command.UserId, 1000); + var creditBalance = CreateCreditBalance(command.UserId, 1000); _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) .ReturnsAsync(character); - _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) - .ReturnsAsync(tokenBalance); + _mockCreditManagementService.Setup(x => x.GetCreditBalanceAsync(command.UserId)) + .ReturnsAsync(creditBalance); // Act & Assert await _validator.ValidateAsync(command); // Should not throw _mockCharacterService.Verify(x => x.CharacterExistsAsync(command.CharacterId), Times.Once); - _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(command.UserId), Times.Once); + _mockCreditManagementService.Verify(x => x.GetCreditBalanceAsync(command.UserId), Times.Once); } [Fact] @@ -78,7 +78,7 @@ public async Task ValidateAsync_CharacterNotFound_ShouldThrowValidationException exception.ErrorCode.Should().Be(ErrorCode.CHARACTER_NOT_FOUND); _mockCharacterService.Verify(x => x.CharacterExistsAsync(command.CharacterId), Times.Once); - _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(It.IsAny()), Times.Never); + _mockCreditManagementService.Verify(x => x.GetCreditBalanceAsync(It.IsAny()), Times.Never); } [Fact] @@ -102,7 +102,7 @@ public async Task ValidateAsync_EmptyUserPrompt_ShouldThrowValidationException() // Should not call external services for invalid input _mockCharacterService.Verify(x => x.CharacterExistsAsync(It.IsAny()), Times.Never); - _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(It.IsAny()), Times.Never); + _mockCreditManagementService.Verify(x => x.GetCreditBalanceAsync(It.IsAny()), Times.Never); } [Fact] @@ -127,78 +127,78 @@ public async Task ValidateAsync_WhitespaceOnlyUserPrompt_ShouldThrowValidationEx #endregion - #region Token Balance Validation Tests + #region Credit Balance Validation Tests [Fact] - public async Task ValidateAsync_ZeroTokenBalance_ShouldThrowInsufficientTokenException() + public async Task ValidateAsync_ZeroCreditBalance_ShouldThrowInsufficientCreditException() { // Arrange var command = CreateValidChatCommand(); var character = CreateValidCharacterDto(command.CharacterId); - var tokenBalance = CreateTokenBalance(command.UserId, 0); // Zero balance + var creditBalance = CreateCreditBalance(command.UserId, 0); // Zero balance _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) .ReturnsAsync(character); - _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) - .ReturnsAsync(tokenBalance); + _mockCreditManagementService.Setup(x => x.GetCreditBalanceAsync(command.UserId)) + .ReturnsAsync(creditBalance); // Act & Assert var exception = await Assert.ThrowsAsync( () => _validator.ValidateAsync(command)); - exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); - exception.Message.Should().Contain("토큰이 부족합니다"); - exception.Message.Should().Contain("현재 잔액: 0 토큰"); - exception.Message.Should().Contain("필요 토큰: 10 토큰"); + exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_CREDIT_BALANCE); + exception.Message.Should().Contain("크래딧이 부족합니다"); + exception.Message.Should().Contain("현재 잔액: 0 크래딧"); + exception.Message.Should().Contain("필요 크래딧: 10 크래딧"); // Verify warning was logged - VerifyWarningLogged("토큰 잔액 부족 (0 토큰)"); + VerifyWarningLogged("크래딧 잔액 부족 (0 크래딧)"); } [Fact] - public async Task ValidateAsync_InsufficientTokenBalance_ShouldThrowInsufficientTokenException() + public async Task ValidateAsync_InsufficientCreditBalance_ShouldThrowInsufficientCreditException() { // Arrange var command = CreateValidChatCommand(); var character = CreateValidCharacterDto(command.CharacterId); - var tokenBalance = CreateTokenBalance(command.UserId, 5); // Less than required 10 tokens + var creditBalance = CreateCreditBalance(command.UserId, 5); // Less than required 10 credits _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) .ReturnsAsync(character); - _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) - .ReturnsAsync(tokenBalance); + _mockCreditManagementService.Setup(x => x.GetCreditBalanceAsync(command.UserId)) + .ReturnsAsync(creditBalance); // Act & Assert var exception = await Assert.ThrowsAsync( () => _validator.ValidateAsync(command)); - exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); - exception.Message.Should().Contain("토큰이 부족합니다"); - exception.Message.Should().Contain("현재 잔액: 5 토큰"); - exception.Message.Should().Contain("필요 토큰: 10 토큰"); + exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_CREDIT_BALANCE); + exception.Message.Should().Contain("크래딧이 부족합니다"); + exception.Message.Should().Contain("현재 잔액: 5 크래딧"); + exception.Message.Should().Contain("필요 크래딧: 10 크래딧"); // Verify warning was logged with specific details - VerifyWarningLoggedWithParameters("토큰 부족", command.UserId.ToString(), "5", "10"); + VerifyWarningLoggedWithParameters("크래딧 부족", command.UserId.ToString(), "5", "10"); } [Fact] - public async Task ValidateAsync_ExactlyEnoughTokens_ShouldPassValidation() + public async Task ValidateAsync_ExactlyEnoughCredits_ShouldPassValidation() { // Arrange var command = CreateValidChatCommand(); var character = CreateValidCharacterDto(command.CharacterId); - var tokenBalance = CreateTokenBalance(command.UserId, 10); // Exactly required amount + var creditBalance = CreateCreditBalance(command.UserId, 10); // Exactly required amount _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) .ReturnsAsync(character); - _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) - .ReturnsAsync(tokenBalance); + _mockCreditManagementService.Setup(x => x.GetCreditBalanceAsync(command.UserId)) + .ReturnsAsync(creditBalance); // Act & Assert - Should not throw await _validator.ValidateAsync(command); @@ -208,19 +208,19 @@ public async Task ValidateAsync_ExactlyEnoughTokens_ShouldPassValidation() } [Fact] - public async Task ValidateAsync_MoreThanEnoughTokens_ShouldPassValidation() + public async Task ValidateAsync_MoreThanEnoughCredits_ShouldPassValidation() { // Arrange var command = CreateValidChatCommand(); var character = CreateValidCharacterDto(command.CharacterId); - var tokenBalance = CreateTokenBalance(command.UserId, 100); // More than enough + var creditBalance = CreateCreditBalance(command.UserId, 100); // More than enough _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) .ReturnsAsync(character); - _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) - .ReturnsAsync(tokenBalance); + _mockCreditManagementService.Setup(x => x.GetCreditBalanceAsync(command.UserId)) + .ReturnsAsync(creditBalance); // Act & Assert - Should not throw await _validator.ValidateAsync(command); @@ -234,7 +234,7 @@ public async Task ValidateAsync_MoreThanEnoughTokens_ShouldPassValidation() #region Edge Cases and Error Handling [Fact] - public async Task ValidateAsync_TokenServiceThrowsException_ShouldPropagateException() + public async Task ValidateAsync_CreditServiceThrowsException_ShouldPropagateException() { // Arrange var command = CreateValidChatCommand(); @@ -244,14 +244,14 @@ public async Task ValidateAsync_TokenServiceThrowsException_ShouldPropagateExcep .ReturnsAsync(true); _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) .ReturnsAsync(character); - _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) - .ThrowsAsync(new Exception("Token service unavailable")); + _mockCreditManagementService.Setup(x => x.GetCreditBalanceAsync(command.UserId)) + .ThrowsAsync(new Exception("Credit service unavailable")); // Act & Assert var exception = await Assert.ThrowsAsync( () => _validator.ValidateAsync(command)); - exception.Message.Should().Be("Token service unavailable"); + exception.Message.Should().Be("Credit service unavailable"); } [Fact] @@ -268,33 +268,33 @@ public async Task ValidateAsync_CharacterServiceThrowsException_ShouldPropagateE () => _validator.ValidateAsync(command)); exception.Message.Should().Be("Character service unavailable"); - _mockTokenManagementService.Verify(x => x.GetTokenBalanceAsync(It.IsAny()), Times.Never); + _mockCreditManagementService.Verify(x => x.GetCreditBalanceAsync(It.IsAny()), Times.Never); } [Fact] - public async Task ValidateAsync_NegativeTokenBalance_ShouldThrowInsufficientTokenException() + public async Task ValidateAsync_NegativeCreditBalance_ShouldThrowInsufficientCreditException() { // Arrange var command = CreateValidChatCommand(); var character = CreateValidCharacterDto(command.CharacterId); - var tokenBalance = CreateTokenBalance(command.UserId, -5); // Negative balance + var creditBalance = CreateCreditBalance(command.UserId, -5); // Negative balance _mockCharacterService.Setup(x => x.CharacterExistsAsync(command.CharacterId)) .ReturnsAsync(true); _mockCharacterService.Setup(x => x.GetCharacterByIdAsync(command.CharacterId)) .ReturnsAsync(character); - _mockTokenManagementService.Setup(x => x.GetTokenBalanceAsync(command.UserId)) - .ReturnsAsync(tokenBalance); + _mockCreditManagementService.Setup(x => x.GetCreditBalanceAsync(command.UserId)) + .ReturnsAsync(creditBalance); // Act & Assert var exception = await Assert.ThrowsAsync( () => _validator.ValidateAsync(command)); - exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_TOKEN_BALANCE); - exception.Message.Should().Contain("현재 잔액: -5 토큰"); + exception.ErrorCode.Should().Be(ErrorCode.INSUFFICIENT_CREDIT_BALANCE); + exception.Message.Should().Contain("현재 잔액: -5 크래딧"); // Verify warning was logged for zero tokens (negative counts as zero) - VerifyWarningLogged("토큰 잔액 부족 (0 토큰)"); + VerifyWarningLogged("크래딧 잔액 부족 (0 크래딧)"); } #endregion @@ -330,16 +330,16 @@ private static CharacterDto CreateValidCharacterDto(Guid characterId) return new CharacterDto(character); } - private static TokenBalanceInfo CreateTokenBalance(Guid userId, decimal balance) + private static CreditBalanceInfo CreateCreditBalance(Guid userId, decimal balance) { - return new TokenBalanceInfo + return new CreditBalanceInfo { UserId = userId, CurrentBalance = balance, TotalEarned = Math.Max(balance, 0), TotalSpent = 0, LastUpdated = DateTime.UtcNow, - InitialTokensGranted = balance > 0 + InitialCreditsGranted = balance > 0 }; } @@ -355,7 +355,7 @@ private void VerifyWarningLogged(string expectedMessage) Times.Once); } - private void VerifyWarningLoggedWithParameters(string expectedMessage, string userId, string currentBalance, string requiredTokens) + private void VerifyWarningLoggedWithParameters(string expectedMessage, string userId, string currentBalance, string requiredCredits) { _mockLogger.Verify( x => x.Log( @@ -365,7 +365,7 @@ private void VerifyWarningLoggedWithParameters(string expectedMessage, string us v.ToString()!.Contains(expectedMessage) && v.ToString()!.Contains(userId) && v.ToString()!.Contains(currentBalance) && - v.ToString()!.Contains(requiredTokens)), + v.ToString()!.Contains(requiredCredits)), It.IsAny(), It.IsAny>()), Times.Once); diff --git a/test-clients/ai-chat-client/index.html b/test-clients/ai-chat-client/index.html index d62552c..d97cf2c 100644 --- a/test-clients/ai-chat-client/index.html +++ b/test-clients/ai-chat-client/index.html @@ -15,7 +15,7 @@ 로그인: 대기 중 | WebSocket: 대기 중 | 세션: 없음 - | 토큰: - + | 토큰: - 서버: diff --git a/test-clients/ai-chat-client/script.js b/test-clients/ai-chat-client/script.js index 4883a3a..ae5c923 100644 --- a/test-clients/ai-chat-client/script.js +++ b/test-clients/ai-chat-client/script.js @@ -10,7 +10,7 @@ const HTTP_URL = `http://${ENDPOINT}/api/v1/chat`; const LOGIN_URL = `http://${ENDPOINT}/api/v1/auth/guest-login`; const CHARACTER_BASE_URL = `http://${ENDPOINT}/api/v1/character`; const CONVERSATION_BASE_URL = `http://${ENDPOINT}/api/v1/conversation`; -const TOKEN_BASE_URL = `http://${ENDPOINT}/api/v1/tokens`; +const TOKEN_BASE_URL = `http://${ENDPOINT}/api/v1/credits`; const SERVER_MESSAGE_TYPE = "json"; let ws = null; let reconnectAttempts = 0; @@ -23,7 +23,7 @@ const statusBox = document.getElementById('status'); const loginStatus = document.getElementById('login-status'); const wsStatus = document.getElementById('ws-status'); const sessionIdDisplay = document.getElementById('session-id'); -const tokenBalanceDisplay = document.getElementById('token-balance'); +const creditBalanceDisplay = document.getElementById('credit-balance'); const serverInfo = document.getElementById('server-info'); const chatLog = document.getElementById('chat-log'); const userInput = document.getElementById('user-input'); @@ -394,16 +394,16 @@ function connectWebSocket() { } // 토큰 정보 업데이트 처리 - if (chatData.tokens_used !== undefined || chatData.tokens_remaining !== undefined) { - updateTokenDisplay(chatData.tokens_used, chatData.tokens_remaining); + if (chatData.credits_used !== undefined || chatData.credits_remaining !== undefined) { + updateTokenDisplay(chatData.credits_used, chatData.credits_remaining); } if (messageText) { appendLog(messageText); // 토큰 사용량 표시 추가 - if (chatData.tokens_used) { - appendLog(`[토큰 사용: ${chatData.tokens_used}, 잔액: ${chatData.tokens_remaining || 'N/A'}]`); + if (chatData.credits_used) { + appendLog(`[토큰 사용: ${chatData.credits_used}, 잔액: ${chatData.credits_remaining || 'N/A'}]`); } } @@ -1328,7 +1328,7 @@ if (nextPageBtn) { // 토큰 잔액 로드 async function loadTokenBalance() { if (!authToken) { - tokenBalanceDisplay.textContent = '-'; + creditBalanceDisplay.textContent = '-'; return; } @@ -1343,43 +1343,43 @@ async function loadTokenBalance() { if (response.ok) { const data = await response.json(); const balance = data.balance || data.tokenBalance || 0; - tokenBalanceDisplay.textContent = balance.toLocaleString(); + creditBalanceDisplay.textContent = balance.toLocaleString(); // 낮은 잔액 경고 if (balance < 1000) { - tokenBalanceDisplay.style.color = '#f44336'; // 빨간색 + creditBalanceDisplay.style.color = '#f44336'; // 빨간색 } else if (balance < 5000) { - tokenBalanceDisplay.style.color = '#ff9800'; // 주황색 + creditBalanceDisplay.style.color = '#ff9800'; // 주황색 } else { - tokenBalanceDisplay.style.color = '#28a745'; // 녹색 + creditBalanceDisplay.style.color = '#28a745'; // 녹색 } } else { console.error('토큰 잔액 로드 실패:', response.status); - tokenBalanceDisplay.textContent = 'Error'; + creditBalanceDisplay.textContent = 'Error'; } } catch (error) { console.error('토큰 잔액 로드 오류:', error); - tokenBalanceDisplay.textContent = 'Error'; + creditBalanceDisplay.textContent = 'Error'; } } // 토큰 디스플레이 업데이트 -function updateTokenDisplay(tokensUsed, tokensRemaining) { - if (tokensRemaining !== undefined && tokensRemaining !== null) { - tokenBalanceDisplay.textContent = tokensRemaining.toLocaleString(); +function updateTokenDisplay(creditsUsed, creditsRemaining) { + if (creditsRemaining !== undefined && creditsRemaining !== null) { + creditBalanceDisplay.textContent = creditsRemaining.toLocaleString(); // 잔액에 따른 색상 변경 - if (tokensRemaining < 1000) { - tokenBalanceDisplay.style.color = '#f44336'; // 빨간색 - } else if (tokensRemaining < 5000) { - tokenBalanceDisplay.style.color = '#ff9800'; // 주황색 + if (creditsRemaining < 1000) { + creditBalanceDisplay.style.color = '#f44336'; // 빨간색 + } else if (creditsRemaining < 5000) { + creditBalanceDisplay.style.color = '#ff9800'; // 주황색 } else { - tokenBalanceDisplay.style.color = '#28a745'; // 녹색 + creditBalanceDisplay.style.color = '#28a745'; // 녹색 } // 낮은 잔액 경고 - if (tokensRemaining < 500) { - appendLog(`[경고] 토큰 잔액이 부족합니다 (잔액: ${tokensRemaining})`); + if (creditsRemaining < 500) { + appendLog(`[경고] 토큰 잔액이 부족합니다 (잔액: ${creditsRemaining})`); } } } From 9ad211b3677251bf7b55098489b8743736d604c9 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 17:48:36 +0900 Subject: [PATCH 19/20] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProjectVG.Api/Controllers/AuthController.cs | 33 ++-- ProjectVG.Api/Controllers/OAuthController.cs | 1 + .../ApplicationServiceCollectionExtensions.cs | 2 +- .../Services/Auth/AuthService.cs | 185 +++++++++--------- .../{IAuthService.cs => IUserAuthService.cs} | 15 +- .../Services/Auth/OAuth2Service.cs | 90 ++++----- ProjectVG.Tests/Auth/AuthServiceTests.cs | 28 +-- 7 files changed, 163 insertions(+), 191 deletions(-) rename ProjectVG.Application/Services/Auth/{IAuthService.cs => IUserAuthService.cs} (54%) diff --git a/ProjectVG.Api/Controllers/AuthController.cs b/ProjectVG.Api/Controllers/AuthController.cs index 8ea5390..6cc4e90 100644 --- a/ProjectVG.Api/Controllers/AuthController.cs +++ b/ProjectVG.Api/Controllers/AuthController.cs @@ -4,24 +4,26 @@ namespace ProjectVG.Api.Controllers { [ApiController] - [Route("api/v1/[controller]")] + [Route("api/v1/auth")] public class AuthController : ControllerBase { - private readonly IAuthService _authService; + private readonly IUserAuthService _authService; - public AuthController(IAuthService authService) + public AuthController(IUserAuthService authService) { _authService = authService; } + /// + /// Access Token 갱신 + /// [HttpPost("refresh")] public async Task RefreshToken() { var refreshToken = GetRefreshTokenFromHeader(); - var result = await _authService.RefreshTokenAsync(refreshToken); - - return Ok(new - { + var result = await _authService.RefreshAccessTokenAsync(refreshToken); + + return Ok(new { success = true, tokens = result.Tokens, user = result.User @@ -33,9 +35,8 @@ public async Task Logout() { var refreshToken = GetRefreshTokenFromHeader(); var success = await _authService.LogoutAsync(refreshToken); - - return Ok(new - { + + return Ok(new { success = success, message = success ? "Logout successful" : "Logout failed" }); @@ -44,15 +45,13 @@ public async Task Logout() [HttpPost("guest-login")] public async Task GuestLogin([FromBody] string guestId) { - if (string.IsNullOrEmpty(guestId)) - { + if (string.IsNullOrEmpty(guestId)) { throw new ValidationException(ErrorCode.GUEST_ID_INVALID); } - var result = await _authService.LoginWithOAuthAsync("guest", guestId); - - return Ok(new - { + var result = await _authService.SignInWithOAuthAsync("guest", guestId); + + return Ok(new { success = true, tokens = result.Tokens, user = result.User @@ -64,4 +63,4 @@ private string GetRefreshTokenFromHeader() return Request.Headers["X-Refresh-Credit"].FirstOrDefault() ?? string.Empty; } } -} \ No newline at end of file +} diff --git a/ProjectVG.Api/Controllers/OAuthController.cs b/ProjectVG.Api/Controllers/OAuthController.cs index 0f209a5..9af0d19 100644 --- a/ProjectVG.Api/Controllers/OAuthController.cs +++ b/ProjectVG.Api/Controllers/OAuthController.cs @@ -85,6 +85,7 @@ public async Task OAuth2Callback( return Redirect(result.RedirectUrl!); } + [HttpGet("oauth2/token")] public async Task GetOAuth2Token([FromQuery] string state) { diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index c8394aa..8b9aec3 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -20,7 +20,7 @@ public static class ApplicationServiceCollectionExtensions public static IServiceCollection AddApplicationServices(this IServiceCollection services) { // Auth Services - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/ProjectVG.Application/Services/Auth/AuthService.cs b/ProjectVG.Application/Services/Auth/AuthService.cs index 7c560ee..68ae131 100644 --- a/ProjectVG.Application/Services/Auth/AuthService.cs +++ b/ProjectVG.Application/Services/Auth/AuthService.cs @@ -1,15 +1,17 @@ using Microsoft.Extensions.Logging; +using ProjectVG.Application.Models.Auth; using ProjectVG.Application.Models.User; -using ProjectVG.Application.Services.Users; using ProjectVG.Application.Services.Credit; -using ProjectVG.Infrastructure.Auth; -using ProjectVG.Common.Exceptions; +using ProjectVG.Application.Services.Users; using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; using ProjectVG.Domain.Entities.Users; +using ProjectVG.Infrastructure.Auth; +using System; namespace ProjectVG.Application.Services.Auth { - public class AuthService : IAuthService + public class AuthService : IUserAuthService { private readonly IUserService _userService; private readonly ITokenService _tokenService; @@ -17,7 +19,7 @@ public class AuthService : IAuthService private readonly ILogger _logger; public AuthService( - IUserService userService, + IUserService userService, ITokenService tokenService, ICreditManagementService tokenManagementService, ILogger logger) @@ -28,114 +30,115 @@ public AuthService( _logger = logger; } - public async Task LoginWithOAuthAsync(string provider, string providerUserId) + public async Task SignInWithOAuthAsync(string provider, string providerUserId) { - // OAuth 프로바이더별 사용자 처리 - Guid userId; - UserDto user; - - if (provider == "guest") - { - if (string.IsNullOrEmpty(providerUserId)) - { - throw new ValidationException(ErrorCode.GUEST_ID_INVALID); - } - - // 기존 게스트 사용자가 있는지 확인 - user = await _userService.TryGetByProviderAsync("guest", providerUserId); - - if (user == null) - { - // 새로운 게스트 사용자 생성 - string uuid = GenerateGuestUuid(providerUserId); - var createCommand = new UserCreateCommand( - Username: $"guest_{uuid}", - Email: $"guest@guest{uuid}.local", - ProviderId: providerUserId, - Provider: "guest" - ); - - user = await _userService.CreateUserAsync(createCommand); - _logger.LogInformation("New guest user created: {UserId} with GuestId: {GuestId}", user.Id, providerUserId); - } - else - { - _logger.LogInformation("Existing guest user logged in: {UserId} with GuestId: {GuestId}", user.Id, providerUserId); - } + return provider switch { + "guest" => await GuestLoginAsync(providerUserId), + "google" or "apple" => await OAuth2LoginAsync(provider, providerUserId), + _ => throw new ValidationException(ErrorCode.OAUTH2_PROVIDER_NOT_SUPPORTED) + }; + } + + private async Task GuestLoginAsync(string guestId) + { + if (string.IsNullOrEmpty(guestId)) { + throw new ValidationException(ErrorCode.GUEST_ID_INVALID); } - // 실제 OAuth 프로바이더인 경우 (Google, Apple 등) - else if (provider == "google" || provider == "apple") - { - if (string.IsNullOrEmpty(providerUserId)) - { - throw new ValidationException(ErrorCode.PROVIDER_USER_ID_INVALID); - } - - // 새로운 사용자 ID 생성 - userId = Guid.NewGuid(); - - user = new UserDto - { - Id = userId, - Username = $"{provider}_user_{providerUserId}", - Email = $"{providerUserId}@{provider}.oauth", - Status = AccountStatus.Active - }; - _logger.LogInformation("New OAuth user created: {UserId} from {Provider} with ProviderId: {ProviderId}", - userId, provider, providerUserId); + var user = await _userService.TryGetByProviderAsync("guest", guestId); + + if (user == null) { + string uuid = GenerateGuestUuid(guestId); + var createCommand = new UserCreateCommand( + Username: $"guest_{uuid}", + Email: $"guest@guest{uuid}.local", + ProviderId: guestId, + Provider: "guest" + ); + + user = await _userService.CreateUserAsync(createCommand); + _logger.LogInformation("새 게스트 사용자 생성됨: UserId={UserId}, GuestId={GuestId}", user.Id, guestId); + } + else { + _logger.LogDebug("기존 게스트 사용자 로그인: UserId={UserId}, GuestId={GuestId}", user.Id, guestId); } - else - { - throw new ValidationException(ErrorCode.OAUTH2_PROVIDER_NOT_SUPPORTED); + + return await FinalizeLoginAsync(user, "guest"); + } + + private async Task OAuth2LoginAsync(string provider, string providerUserId) + { + if (string.IsNullOrEmpty(providerUserId)) { + throw new ValidationException(ErrorCode.PROVIDER_USER_ID_INVALID); } - // OAuth2 사용자인 경우 Provider 정보를 포함하여 사용자 생성 (test와 guest는 이미 처리됨) - if (provider != "test" && provider != "guest") - { - user = user with { Provider = provider, ProviderId = providerUserId }; + var user = await _userService.TryGetByProviderAsync(provider, providerUserId); + + if (user == null) { + string uuid = GenerateGuestUuid(providerUserId); + var createCommand = new UserCreateCommand( + Username: $"임시 유저 이름", + Email: $"guest@guest{uuid}.local", + ProviderId: providerUserId, + Provider: provider + ); + + + user = new UserDto { + + + Id = Guid.NewGuid(), + Username = $"{provider}_user_{providerUserId}", + Email = $"{providerUserId}@{provider}.oauth", + Status = AccountStatus.Active, + Provider = provider, + ProviderId = providerUserId + }; } - // 첫 로그인 토큰 지급 시도 + _logger.LogInformation("새 OAuth 사용자 생성됨: UserId={UserId}, Provider={Provider}, ProviderId={ProviderId}", + user.Id, provider, providerUserId); + + return await FinalizeLoginAsync(user, provider); + } + + private async Task FinalizeLoginAsync(UserDto user, string provider) + { + // 초기 크레딧 지급 var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id); - if (tokenGranted) - { - _logger.LogInformation("Initial tokens (5000) granted successfully to user {UserId}", user.Id); + if (tokenGranted) { + _logger.LogInformation("사용자 {UserId}에게 최초 크레딧 지급 완료", user.Id); } - else - { - _logger.LogInformation("Initial tokens already granted or grant failed for user {UserId}", user.Id); + else { + _logger.LogDebug("사용자 {UserId}는 이미 크레딧이 지급되었거나 지급 실패", user.Id); } + // 최종 JWT 토큰 발급 var tokens = await _tokenService.GenerateTokensAsync(user.Id); - - _logger.LogInformation("Users {UserId} logged in with OAuth provider: {Provider}", user.Id, provider); - - return new AuthResult - { + + _logger.LogDebug("사용자 {UserId} 로그인 완료 (Provider={Provider})", user.Id, provider); + + return new AuthResult { Tokens = tokens, User = user }; } - public async Task RefreshTokenAsync(string refreshToken) + public async Task RefreshAccessTokenAsync(string? refreshToken) { - if (string.IsNullOrEmpty(refreshToken)) - { + if (string.IsNullOrEmpty(refreshToken)) { throw new ValidationException(ErrorCode.TOKEN_MISSING, "리프레시 토큰이 필요합니다"); } var tokens = await _tokenService.RefreshAccessTokenAsync(refreshToken); - if (tokens == null) - { + if (tokens == null) { throw new ValidationException(ErrorCode.TOKEN_REFRESH_FAILED, "유효하지 않거나 만료된 리프레시 토큰입니다"); } var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken); var user = userId.HasValue ? await _userService.TryGetByIdAsync(userId.Value) : null; - return new AuthResult - { + return new AuthResult { Tokens = tokens, User = user }; @@ -143,27 +146,27 @@ public async Task RefreshTokenAsync(string refreshToken) public async Task LogoutAsync(string refreshToken) { - if (string.IsNullOrEmpty(refreshToken)) - { + if (string.IsNullOrEmpty(refreshToken)) { throw new ValidationException(ErrorCode.TOKEN_MISSING, "리프레시 토큰이 필요합니다"); } var revoked = await _tokenService.RevokeRefreshTokenAsync(refreshToken); - if (revoked) - { + if (revoked) { var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken); - _logger.LogInformation("Users {UserId} logged out successfully", userId); + _logger.LogInformation("사용자 {UserId} 로그아웃 성공", userId); + } + else { + _logger.LogWarning("리프레시 토큰 만료 또는 무효화 실패: {RefreshToken}", refreshToken); } return revoked; } - private static string GenerateGuestUuid(string providerUserId) { - // SHA256 해시를 사용하여 일관된 UUID 생성 using var sha256 = System.Security.Cryptography.SHA256.Create(); var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(providerUserId)); var hashString = Convert.ToHexString(hash); return hashString.Substring(0, Math.Min(hashString.Length, 16)).ToLowerInvariant(); } + } } diff --git a/ProjectVG.Application/Services/Auth/IAuthService.cs b/ProjectVG.Application/Services/Auth/IUserAuthService.cs similarity index 54% rename from ProjectVG.Application/Services/Auth/IAuthService.cs rename to ProjectVG.Application/Services/Auth/IUserAuthService.cs index 2c9554a..4761d30 100644 --- a/ProjectVG.Application/Services/Auth/IAuthService.cs +++ b/ProjectVG.Application/Services/Auth/IUserAuthService.cs @@ -7,28 +7,21 @@ namespace ProjectVG.Application.Services.Auth /// 인증 및 토큰 관리 서비스 /// JWT 토큰 생성, 검증, 갱신 및 OAuth 로그인 처리를 담당 /// - public interface IAuthService + public interface IUserAuthService { /// - /// OAuth 제공자를 통한 로그인 처리 (Google, GitHub, Microsoft, 게스트 등) + /// OAuth 제공자를 통한 로그인 처리 /// - /// 인증 제공자 (google, github, microsoft, guest, test) - /// 제공자별 사용자 ID - /// 로그인 결과 (토큰, 사용자 정보 포함) - Task LoginWithOAuthAsync(string provider, string providerUserId); + Task SignInWithOAuthAsync(string provider, string providerUserId); /// /// 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급 /// - /// 유효한 리프레시 토큰 - /// 새로운 토큰 쌍과 사용자 정보 - Task RefreshTokenAsync(string? refreshToken); + Task RefreshAccessTokenAsync(string? refreshToken); /// /// 사용자 로그아웃 처리 (리프레시 토큰 무효화) /// - /// 무효화할 리프레시 토큰 - /// 로그아웃 성공 여부 Task LogoutAsync(string? refreshToken); } diff --git a/ProjectVG.Application/Services/Auth/OAuth2Service.cs b/ProjectVG.Application/Services/Auth/OAuth2Service.cs index 74c5bdd..d106eb0 100644 --- a/ProjectVG.Application/Services/Auth/OAuth2Service.cs +++ b/ProjectVG.Application/Services/Auth/OAuth2Service.cs @@ -18,7 +18,7 @@ public class OAuth2Service : IOAuth2Service private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly OAuth2ProviderSettings _settings; - private readonly IAuthService _authService; + private readonly IUserAuthService _authService; private readonly IOAuth2ProviderFactory _providerFactory; private readonly IDistributedCache _cache; @@ -34,7 +34,7 @@ public OAuth2Service( IHttpClientFactory httpClientFactory, ILogger logger, IOptions settings, - IAuthService authService, + IUserAuthService authService, IOAuth2ProviderFactory providerFactory, IDistributedCache cache) { @@ -46,21 +46,15 @@ public OAuth2Service( _cache = cache; } - /// - /// 특정 제공자로 OAuth2 인증 URL 생성 - /// - /// 제공자 이름 (google, apple) - /// 상태값 - /// PKCE code challenge - /// PKCE challenge 방법 - /// PKCE code verifier - /// 클라이언트 리다이렉트 URI - /// OAuth2 인증 URL - public async Task BuildAuthorizationUrlAsync(string providerName, string state, string codeChallenge, string codeChallengeMethod, string codeVerifier, string clientRedirectUri) + public async Task BuildAuthorizationUrlAsync( + string providerName, string state, + string codeChallenge, string codeChallengeMethod, + string codeVerifier, string clientRedirectUri) { var provider = _providerFactory.GetProvider(providerName); - if (!_settings.Providers.TryGetValue(providerName, out var providerSettings) || !providerSettings.Enabled) { + if (!_settings.Providers.TryGetValue(providerName, out var providerSettings) + || !providerSettings.Enabled) { throw new ValidationException(ErrorCode.OAUTH2_PROVIDER_NOT_CONFIGURED); } @@ -118,7 +112,7 @@ public async Task HandleOAuth2CallbackAsync(string code, s throw new ValidationException(ErrorCode.OAUTH2_USER_INFO_FAILED); } - var authResult = await _authService.LoginWithOAuthAsync(providerName, userInfo.Id); + var authResult = await _authService.SignInWithOAuthAsync(providerName, userInfo.Id); var tokenData = new OAuth2TokenData { AccessToken = authResult.Tokens!.AccessToken, @@ -129,9 +123,7 @@ public async Task HandleOAuth2CallbackAsync(string code, s await StoreTokenDataAsync(state, tokenData); - var clientRedirectUrl = $"{authRequest.ClientRedirectUri}?" + - $"success=true&" + - $"state={Uri.EscapeDataString(state)}"; + var clientRedirectUrl = $"{authRequest.ClientRedirectUri}?" + $"success=true&" + $"state={Uri.EscapeDataString(state)}"; return new OAuth2CallbackResult { Success = true, @@ -213,45 +205,39 @@ public async Task GetUserInfoAsync(string accessToken, string pr public async Task StoreOAuth2RequestAsync(string state, OAuth2AuthRequest request) { - try - { + try { var key = OAuth2RequestPrefix + state; var json = JsonSerializer.Serialize(request); - var options = new DistributedCacheEntryOptions - { + var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = OAuth2RequestTTL }; - + await _cache.SetStringAsync(key, json, options); _logger.LogDebug("OAuth2 요청 저장 완료: State={State}, TTL={TTL}분", state, OAuth2RequestTTL.TotalMinutes); return request; } - catch (Exception ex) - { + catch (Exception ex) { _logger.LogError(ex, "OAuth2 요청 저장 실패: State={State}", state); throw; } } public async Task GetOAuth2RequestAsync(string state) - { - try - { + { + try { var key = OAuth2RequestPrefix + state; var json = await _cache.GetStringAsync(key); - - if (!string.IsNullOrEmpty(json)) - { + + if (!string.IsNullOrEmpty(json)) { var request = JsonSerializer.Deserialize(json); _logger.LogDebug("OAuth2 요청 조회 성공: State={State}", state); return request; } - + _logger.LogWarning("OAuth2 요청을 찾을 수 없음: State={State}", state); return null; } - catch (Exception ex) - { + catch (Exception ex) { _logger.LogError(ex, "OAuth2 요청 조회 실패: State={State}", state); return null; } @@ -259,34 +245,29 @@ public async Task StoreOAuth2RequestAsync(string state, OAuth public async Task DeleteOAuth2RequestAsync(string state) { - try - { + try { var key = OAuth2RequestPrefix + state; await _cache.RemoveAsync(key); _logger.LogDebug("OAuth2 요청 삭제 완료: State={State}", state); } - catch (Exception ex) - { + catch (Exception ex) { _logger.LogError(ex, "OAuth2 요청 삭제 실패: State={State}", state); } } public async Task StoreTokenDataAsync(string state, object tokenData) { - try - { + try { var key = TokenDataPrefix + state; var json = JsonSerializer.Serialize(tokenData); - var options = new DistributedCacheEntryOptions - { + var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TokenDataTTL }; - + await _cache.SetStringAsync(key, json, options); _logger.LogDebug("토큰 데이터 저장 완료: State={State}, TTL={TTL}분", state, TokenDataTTL.TotalMinutes); } - catch (Exception ex) - { + catch (Exception ex) { _logger.LogError(ex, "토큰 데이터 저장 실패: State={State}", state); throw; } @@ -294,37 +275,32 @@ public async Task StoreTokenDataAsync(string state, object tokenData) public async Task DeleteTokenDataAsync(string state) { - try - { + try { var key = TokenDataPrefix + state; await _cache.RemoveAsync(key); _logger.LogDebug("토큰 데이터 삭제 완료: State={State}", state); } - catch (Exception ex) - { + catch (Exception ex) { _logger.LogError(ex, "토큰 데이터 삭제 실패: State={State}", state); } } public async Task GetTokenDataAsync(string state) { - try - { + try { var key = TokenDataPrefix + state; var json = await _cache.GetStringAsync(key); - - if (!string.IsNullOrEmpty(json)) - { + + if (!string.IsNullOrEmpty(json)) { var tokenData = JsonSerializer.Deserialize(json); _logger.LogDebug("토큰 데이터 조회 성공: State={State}", state); return tokenData; } - + _logger.LogWarning("토큰 데이터를 찾을 수 없음: State={State}", state); return null; } - catch (Exception ex) - { + catch (Exception ex) { _logger.LogError(ex, "토큰 데이터 조회 실패: State={State}", state); return null; } diff --git a/ProjectVG.Tests/Auth/AuthServiceTests.cs b/ProjectVG.Tests/Auth/AuthServiceTests.cs index 2cb1e91..b57d294 100644 --- a/ProjectVG.Tests/Auth/AuthServiceTests.cs +++ b/ProjectVG.Tests/Auth/AuthServiceTests.cs @@ -57,7 +57,7 @@ public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResul _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act - var result = await _authService.LoginWithOAuthAsync(provider, accessToken); + var result = await _authService.SignInWithOAuthAsync(provider, accessToken); // Assert result.Should().NotBeNull(); @@ -81,7 +81,7 @@ public async Task LoginWithOAuthAsync_InvalidProvider_ShouldThrowValidationExcep // Act & Assert var exception = await Assert.ThrowsAsync( - () => _authService.LoginWithOAuthAsync(provider, accessToken) + () => _authService.SignInWithOAuthAsync(provider, accessToken) ); exception.ErrorCode.Should().Be(ErrorCode.INVALID_INPUT); @@ -96,7 +96,7 @@ public async Task LoginWithOAuthAsync_InvalidUserIdFormat_ShouldThrowValidationE // Act & Assert var exception = await Assert.ThrowsAsync( - () => _authService.LoginWithOAuthAsync(provider, accessToken) + () => _authService.SignInWithOAuthAsync(provider, accessToken) ); exception.ErrorCode.Should().Be(ErrorCode.INVALID_INPUT); @@ -132,7 +132,7 @@ public async Task RefreshTokenAsync_ValidRefreshToken_ShouldReturnSuccessResult( _mockUserService.Setup(x => x.TryGetByIdAsync(userId)).ReturnsAsync(user); // Act - var result = await _authService.RefreshTokenAsync(refreshToken); + var result = await _authService.RefreshAccessTokenAsync(refreshToken); // Assert result.Should().NotBeNull(); @@ -174,7 +174,7 @@ public async Task LoginWithOAuthAsync_NewGuestUser_ShouldCreateAndReturnSuccessR _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act - var result = await _authService.LoginWithOAuthAsync(provider, guestId); + var result = await _authService.SignInWithOAuthAsync(provider, guestId); // Assert result.Should().NotBeNull(); @@ -220,7 +220,7 @@ public async Task LoginWithOAuthAsync_ExistingGuestUser_ShouldReturnSuccessResul _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); // Act - var result = await _authService.LoginWithOAuthAsync(provider, guestId); + var result = await _authService.SignInWithOAuthAsync(provider, guestId); // Assert result.Should().NotBeNull(); @@ -242,7 +242,7 @@ public async Task RefreshTokenAsync_InvalidRefreshToken_ShouldThrowValidationExc // Act & Assert var exception = await Assert.ThrowsAsync( - () => _authService.RefreshTokenAsync(refreshToken) + () => _authService.RefreshAccessTokenAsync(refreshToken) ); exception.ErrorCode.Should().Be(ErrorCode.TOKEN_INVALID); @@ -258,7 +258,7 @@ public async Task RefreshTokenAsync_EmptyRefreshToken_ShouldThrowValidationExcep // Act & Assert var exception = await Assert.ThrowsAsync( - () => _authService.RefreshTokenAsync(refreshToken) + () => _authService.RefreshAccessTokenAsync(refreshToken) ); exception.ErrorCode.Should().Be(ErrorCode.TOKEN_INVALID); @@ -327,7 +327,7 @@ public async Task LoginWithOAuthAsync_ExceptionThrown_ShouldPropagateException() // Act & Assert await Assert.ThrowsAsync( - () => _authService.LoginWithOAuthAsync(provider, accessToken) + () => _authService.SignInWithOAuthAsync(provider, accessToken) ); } @@ -342,7 +342,7 @@ public async Task RefreshTokenAsync_ExceptionThrown_ShouldPropagateException() // Act & Assert await Assert.ThrowsAsync( - () => _authService.RefreshTokenAsync(refreshToken) + () => _authService.RefreshAccessTokenAsync(refreshToken) ); } @@ -394,7 +394,7 @@ public async Task LoginWithOAuthAsync_NewUser_ShouldGrantInitialTokensAndLogSucc _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act - var result = await _authService.LoginWithOAuthAsync(provider, guestId); + var result = await _authService.SignInWithOAuthAsync(provider, guestId); // Assert result.Should().NotBeNull(); @@ -444,7 +444,7 @@ public async Task LoginWithOAuthAsync_ExistingUser_ShouldNotGrantTokensAndLogAlr _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); // Act - var result = await _authService.LoginWithOAuthAsync(provider, guestId); + var result = await _authService.SignInWithOAuthAsync(provider, guestId); // Assert result.Should().NotBeNull(); @@ -484,7 +484,7 @@ public async Task LoginWithOAuthAsync_TokenGrantingFails_ShouldStillCompleteLogi _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); // Act - var result = await _authService.LoginWithOAuthAsync(provider, providerId); + var result = await _authService.SignInWithOAuthAsync(provider, providerId); // Assert result.Should().NotBeNull(); @@ -525,7 +525,7 @@ public async Task LoginWithOAuthAsync_TokenGrantingThrowsException_ShouldNotFail .ThrowsAsync(new Exception("Credit granting service unavailable")); // Act & Assert - Should not throw exception - var result = await _authService.LoginWithOAuthAsync(provider, accessToken); + var result = await _authService.SignInWithOAuthAsync(provider, accessToken); // Verify login still completed successfully result.Should().NotBeNull(); From 41cdc929ca0d6b680c064c9873b3160854fd6ff0 Mon Sep 17 00:00:00 2001 From: WooSH Date: Wed, 3 Sep 2025 18:37:22 +0900 Subject: [PATCH 20/20] =?UTF-8?q?refactory:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProjectVG.Api/Controllers/AuthController.cs | 8 +- ProjectVG.Api/Controllers/OAuthController.cs | 2 + .../ApplicationServiceCollectionExtensions.cs | 6 +- .../Services/Auth/AuthService.cs | 51 +- .../Services/Auth/IOAuth2AccountManager.cs | 10 + .../Services/Auth/IOAuth2CodeValidator.cs | 9 + .../Services/Auth/IOAuth2Service.cs | 17 - .../Services/Auth/IOAuth2UserService.cs | 9 + .../Services/Auth/IUserAuthService.cs | 14 +- .../Services/Auth/OAuth2AccountManager.cs | 45 ++ .../Services/Auth/OAuth2AuthService.cs | 96 ++++ .../Services/Auth/OAuth2CodeValidator.cs | 92 ++++ .../Services/Auth/OAuth2Service.cs | 103 +--- .../Services/Auth/OAuth2UserService.cs | 46 ++ ProjectVG.Tests/Auth/AuthServiceTests.cs | 453 +++--------------- 15 files changed, 402 insertions(+), 559 deletions(-) create mode 100644 ProjectVG.Application/Services/Auth/IOAuth2AccountManager.cs create mode 100644 ProjectVG.Application/Services/Auth/IOAuth2CodeValidator.cs create mode 100644 ProjectVG.Application/Services/Auth/IOAuth2UserService.cs create mode 100644 ProjectVG.Application/Services/Auth/OAuth2AccountManager.cs create mode 100644 ProjectVG.Application/Services/Auth/OAuth2AuthService.cs create mode 100644 ProjectVG.Application/Services/Auth/OAuth2CodeValidator.cs create mode 100644 ProjectVG.Application/Services/Auth/OAuth2UserService.cs diff --git a/ProjectVG.Api/Controllers/AuthController.cs b/ProjectVG.Api/Controllers/AuthController.cs index 6cc4e90..9bdb772 100644 --- a/ProjectVG.Api/Controllers/AuthController.cs +++ b/ProjectVG.Api/Controllers/AuthController.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc; using ProjectVG.Application.Services.Auth; +using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; namespace ProjectVG.Api.Controllers { @@ -7,9 +9,9 @@ namespace ProjectVG.Api.Controllers [Route("api/v1/auth")] public class AuthController : ControllerBase { - private readonly IUserAuthService _authService; + private readonly IAuthService _authService; - public AuthController(IUserAuthService authService) + public AuthController(IAuthService authService) { _authService = authService; } @@ -49,7 +51,7 @@ public async Task GuestLogin([FromBody] string guestId) throw new ValidationException(ErrorCode.GUEST_ID_INVALID); } - var result = await _authService.SignInWithOAuthAsync("guest", guestId); + var result = await _authService.GuestLoginAsync(guestId); return Ok(new { success = true, diff --git a/ProjectVG.Api/Controllers/OAuthController.cs b/ProjectVG.Api/Controllers/OAuthController.cs index 9af0d19..199a114 100644 --- a/ProjectVG.Api/Controllers/OAuthController.cs +++ b/ProjectVG.Api/Controllers/OAuthController.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Options; using ProjectVG.Application.Services.Auth; using ProjectVG.Common.Configuration; +using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; namespace ProjectVG.Api.Controllers { diff --git a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs index 8b9aec3..cde24d6 100644 --- a/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs +++ b/ProjectVG.Application/ApplicationServiceCollectionExtensions.cs @@ -20,8 +20,12 @@ public static class ApplicationServiceCollectionExtensions public static IServiceCollection AddApplicationServices(this IServiceCollection services) { // Auth Services - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); // User Services diff --git a/ProjectVG.Application/Services/Auth/AuthService.cs b/ProjectVG.Application/Services/Auth/AuthService.cs index 68ae131..29acaac 100644 --- a/ProjectVG.Application/Services/Auth/AuthService.cs +++ b/ProjectVG.Application/Services/Auth/AuthService.cs @@ -11,7 +11,7 @@ namespace ProjectVG.Application.Services.Auth { - public class AuthService : IUserAuthService + public class AuthService : IAuthService { private readonly IUserService _userService; private readonly ITokenService _tokenService; @@ -30,16 +30,7 @@ public AuthService( _logger = logger; } - public async Task SignInWithOAuthAsync(string provider, string providerUserId) - { - return provider switch { - "guest" => await GuestLoginAsync(providerUserId), - "google" or "apple" => await OAuth2LoginAsync(provider, providerUserId), - _ => throw new ValidationException(ErrorCode.OAUTH2_PROVIDER_NOT_SUPPORTED) - }; - } - - private async Task GuestLoginAsync(string guestId) + public async Task GuestLoginAsync(string guestId) { if (string.IsNullOrEmpty(guestId)) { throw new ValidationException(ErrorCode.GUEST_ID_INVALID); @@ -66,42 +57,6 @@ private async Task GuestLoginAsync(string guestId) return await FinalizeLoginAsync(user, "guest"); } - private async Task OAuth2LoginAsync(string provider, string providerUserId) - { - if (string.IsNullOrEmpty(providerUserId)) { - throw new ValidationException(ErrorCode.PROVIDER_USER_ID_INVALID); - } - - var user = await _userService.TryGetByProviderAsync(provider, providerUserId); - - if (user == null) { - string uuid = GenerateGuestUuid(providerUserId); - var createCommand = new UserCreateCommand( - Username: $"임시 유저 이름", - Email: $"guest@guest{uuid}.local", - ProviderId: providerUserId, - Provider: provider - ); - - - user = new UserDto { - - - Id = Guid.NewGuid(), - Username = $"{provider}_user_{providerUserId}", - Email = $"{providerUserId}@{provider}.oauth", - Status = AccountStatus.Active, - Provider = provider, - ProviderId = providerUserId - }; - } - - _logger.LogInformation("새 OAuth 사용자 생성됨: UserId={UserId}, Provider={Provider}, ProviderId={ProviderId}", - user.Id, provider, providerUserId); - - return await FinalizeLoginAsync(user, provider); - } - private async Task FinalizeLoginAsync(UserDto user, string provider) { // 초기 크레딧 지급 @@ -144,7 +99,7 @@ public async Task RefreshAccessTokenAsync(string? refreshToken) }; } - public async Task LogoutAsync(string refreshToken) + public async Task LogoutAsync(string? refreshToken) { if (string.IsNullOrEmpty(refreshToken)) { throw new ValidationException(ErrorCode.TOKEN_MISSING, "리프레시 토큰이 필요합니다"); diff --git a/ProjectVG.Application/Services/Auth/IOAuth2AccountManager.cs b/ProjectVG.Application/Services/Auth/IOAuth2AccountManager.cs new file mode 100644 index 0000000..e29911d --- /dev/null +++ b/ProjectVG.Application/Services/Auth/IOAuth2AccountManager.cs @@ -0,0 +1,10 @@ +using ProjectVG.Application.Models.Auth; +using ProjectVG.Application.Models.User; + +namespace ProjectVG.Application.Services.Auth +{ + public interface IOAuth2AccountManager + { + Task ProcessOAuth2LoginAsync(string provider, OAuth2UserInfo userInfo); + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Auth/IOAuth2CodeValidator.cs b/ProjectVG.Application/Services/Auth/IOAuth2CodeValidator.cs new file mode 100644 index 0000000..544a10b --- /dev/null +++ b/ProjectVG.Application/Services/Auth/IOAuth2CodeValidator.cs @@ -0,0 +1,9 @@ +using ProjectVG.Application.Models.Auth; + +namespace ProjectVG.Application.Services.Auth +{ + public interface IOAuth2CodeValidator + { + Task ValidateAndExchangeCodeAsync(string code, string clientId, string redirectUri, string codeVerifier = ""); + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Auth/IOAuth2Service.cs b/ProjectVG.Application/Services/Auth/IOAuth2Service.cs index da3f68e..ee0cf83 100644 --- a/ProjectVG.Application/Services/Auth/IOAuth2Service.cs +++ b/ProjectVG.Application/Services/Auth/IOAuth2Service.cs @@ -41,23 +41,6 @@ public interface IOAuth2Service /// 삭제할 토큰 데이터의 상태값 Task DeleteTokenDataAsync(string state); - /// - /// OAuth2 인증 코드를 액세스 토큰으로 교환 - /// - /// 인증 코드 - /// 클라이언트 ID - /// 리다이렉트 URI - /// PKCE code verifier (선택적) - /// 토큰 교환 결과 - Task ExchangeAuthorizationCodeAsync(string code, string clientId, string redirectUri, string codeVerifier = ""); - - /// - /// OAuth2 제공자에서 사용자 정보 조회 - /// - /// OAuth2 액세스 토큰 - /// 제공자명 (google, apple) - /// 사용자 정보 - Task GetUserInfoAsync(string accessToken, string provider); /// /// OAuth2 요청 정보 저장 (임시 저장소) diff --git a/ProjectVG.Application/Services/Auth/IOAuth2UserService.cs b/ProjectVG.Application/Services/Auth/IOAuth2UserService.cs new file mode 100644 index 0000000..5ac3372 --- /dev/null +++ b/ProjectVG.Application/Services/Auth/IOAuth2UserService.cs @@ -0,0 +1,9 @@ +using ProjectVG.Application.Models.Auth; + +namespace ProjectVG.Application.Services.Auth +{ + public interface IOAuth2UserService + { + Task GetUserInfoAsync(string accessToken, string providerName); + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Auth/IUserAuthService.cs b/ProjectVG.Application/Services/Auth/IUserAuthService.cs index 4761d30..f4e8977 100644 --- a/ProjectVG.Application/Services/Auth/IUserAuthService.cs +++ b/ProjectVG.Application/Services/Auth/IUserAuthService.cs @@ -7,12 +7,12 @@ namespace ProjectVG.Application.Services.Auth /// 인증 및 토큰 관리 서비스 /// JWT 토큰 생성, 검증, 갱신 및 OAuth 로그인 처리를 담당 /// - public interface IUserAuthService + public interface IAuthService { /// - /// OAuth 제공자를 통한 로그인 처리 + /// Guest 로그인 처리 /// - Task SignInWithOAuthAsync(string provider, string providerUserId); + Task GuestLoginAsync(string guestId); /// /// 리프레시 토큰을 사용하여 새로운 액세스 토큰 발급 @@ -25,6 +25,14 @@ public interface IUserAuthService Task LogoutAsync(string? refreshToken); } + public interface IOAuth2AuthService + { + /// + /// OAuth2 제공자를 통한 로그인 처리 + /// + Task OAuth2LoginAsync(string provider, string providerUserId, string email); + } + /// /// 인증 처리 결과를 담는 클래스 /// diff --git a/ProjectVG.Application/Services/Auth/OAuth2AccountManager.cs b/ProjectVG.Application/Services/Auth/OAuth2AccountManager.cs new file mode 100644 index 0000000..780b17d --- /dev/null +++ b/ProjectVG.Application/Services/Auth/OAuth2AccountManager.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; +using ProjectVG.Application.Models.Auth; +using ProjectVG.Application.Models.User; +using ProjectVG.Application.Services.Users; +using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; + +namespace ProjectVG.Application.Services.Auth +{ + public class OAuth2AccountManager : IOAuth2AccountManager + { + private readonly IUserService _userService; + private readonly IOAuth2AuthService _oAuth2AuthService; + private readonly ILogger _logger; + + public OAuth2AccountManager( + IUserService userService, + IOAuth2AuthService oAuth2AuthService, + ILogger logger) + { + _userService = userService; + _oAuth2AuthService = oAuth2AuthService; + _logger = logger; + } + + public async Task ProcessOAuth2LoginAsync(string provider, OAuth2UserInfo userInfo) + { + if (string.IsNullOrEmpty(userInfo.Id)) { + throw new ValidationException(ErrorCode.PROVIDER_USER_ID_INVALID); + } + + var existingUser = await _userService.TryGetByProviderAsync(provider, userInfo.Id); + + if (existingUser != null) { + _logger.LogDebug("기존 OAuth 계정 로그인: UserId={UserId}, Provider={Provider}", existingUser.Id, provider); + return await _oAuth2AuthService.OAuth2LoginAsync(provider, userInfo.Id, existingUser.Email); + } + + _logger.LogInformation("새 OAuth 계정 생성: ProviderId={ProviderId}, Provider={Provider}, Email={Email}", + userInfo.Id, provider, userInfo.Email); + + return await _oAuth2AuthService.OAuth2LoginAsync(provider, userInfo.Id, userInfo.Email); + } + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Auth/OAuth2AuthService.cs b/ProjectVG.Application/Services/Auth/OAuth2AuthService.cs new file mode 100644 index 0000000..5aa676c --- /dev/null +++ b/ProjectVG.Application/Services/Auth/OAuth2AuthService.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Logging; +using ProjectVG.Application.Models.Auth; +using ProjectVG.Application.Models.User; +using ProjectVG.Application.Services.Credit; +using ProjectVG.Application.Services.Users; +using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; +using ProjectVG.Domain.Entities.Users; +using ProjectVG.Infrastructure.Auth; + +namespace ProjectVG.Application.Services.Auth +{ + public class OAuth2AuthService : IOAuth2AuthService + { + private readonly IUserService _userService; + private readonly ITokenService _tokenService; + private readonly ICreditManagementService _tokenManagementService; + private readonly ILogger _logger; + + public OAuth2AuthService( + IUserService userService, + ITokenService tokenService, + ICreditManagementService tokenManagementService, + ILogger logger) + { + _userService = userService; + _tokenService = tokenService; + _tokenManagementService = tokenManagementService; + _logger = logger; + } + + public async Task OAuth2LoginAsync(string provider, string providerUserId, string email) + { + if (string.IsNullOrEmpty(providerUserId)) { + throw new ValidationException(ErrorCode.PROVIDER_USER_ID_INVALID); + } + + var user = await _userService.TryGetByProviderAsync(provider, providerUserId); + + if (user == null) { + user = await CreateOAuth2UserAsync(provider, providerUserId, email); + _logger.LogInformation("새 OAuth 사용자 생성: UserId={UserId}, Provider={Provider}", user.Id, provider); + } + else { + _logger.LogDebug("기존 OAuth 사용자 로그인: UserId={UserId}, Provider={Provider}", user.Id, provider); + } + + return await FinalizeLoginAsync(user, provider); + } + + private async Task CreateOAuth2UserAsync(string provider, string providerUserId, string email) + { + var username = string.IsNullOrEmpty(email) + ? $"{provider}_{GenerateUserSuffix(providerUserId)}" + : email.Split('@')[0]; + + var userEmail = string.IsNullOrEmpty(email) + ? $"{providerUserId}@{provider}.oauth" + : email; + + var createCommand = new UserCreateCommand( + Username: username, + Email: userEmail, + ProviderId: providerUserId, + Provider: provider + ); + + return await _userService.CreateUserAsync(createCommand); + } + + private async Task FinalizeLoginAsync(UserDto user, string provider) + { + var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id); + if (tokenGranted) { + _logger.LogInformation("사용자 {UserId} 최초 크레딧 지급 완료", user.Id); + } + + var tokens = await _tokenService.GenerateTokensAsync(user.Id); + + _logger.LogDebug("사용자 {UserId} OAuth 로그인 완료 (Provider={Provider})", user.Id, provider); + + return new AuthResult { + Tokens = tokens, + User = user + }; + } + + private static string GenerateUserSuffix(string providerUserId) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var hash = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(providerUserId)); + var hashString = Convert.ToHexString(hash); + return hashString.Substring(0, Math.Min(hashString.Length, 8)).ToLowerInvariant(); + } + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Auth/OAuth2CodeValidator.cs b/ProjectVG.Application/Services/Auth/OAuth2CodeValidator.cs new file mode 100644 index 0000000..43d9a1d --- /dev/null +++ b/ProjectVG.Application/Services/Auth/OAuth2CodeValidator.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ProjectVG.Application.Models.Auth; +using ProjectVG.Common.Configuration; +using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; + +namespace ProjectVG.Application.Services.Auth +{ + public class OAuth2CodeValidator : IOAuth2CodeValidator + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly OAuth2ProviderSettings _settings; + private readonly IOAuth2ProviderFactory _providerFactory; + + public OAuth2CodeValidator( + IHttpClientFactory httpClientFactory, + ILogger logger, + IOptions settings, + IOAuth2ProviderFactory providerFactory) + { + _httpClient = httpClientFactory.CreateClient(); + _logger = logger; + _settings = settings.Value; + _providerFactory = providerFactory; + } + + public async Task ValidateAndExchangeCodeAsync(string code, string clientId, string redirectUri, string codeVerifier = "") + { + var providerSettings = GetProviderByClientId(clientId); + if (providerSettings == null) { + throw new ValidationException(ErrorCode.OAUTH2_CLIENT_ID_INVALID); + } + + var providerName = GetProviderName(clientId); + var provider = _providerFactory.GetProvider(providerName); + + var parameters = new Dictionary + { + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", redirectUri }, + { "client_id", providerSettings.ClientId }, + { "client_secret", providerSettings.ClientSecret } + }; + + if (!string.IsNullOrEmpty(codeVerifier)) { + parameters.Add("code_verifier", codeVerifier); + } + + var content = new FormUrlEncodedContent(parameters); + var response = await _httpClient.PostAsync(provider.TokenEndpoint, content); + + if (response.IsSuccessStatusCode) { + var json = await response.Content.ReadAsStringAsync(); + var tokenData = JsonSerializer.Deserialize>(json); + + return new TokenResponse { + Success = true, + Tokens = new Tokens { + AccessToken = tokenData!["access_token"].GetString()!, + RefreshToken = tokenData["refresh_token"].GetString()!, + ExpiresIn = tokenData["expires_in"].GetInt32(), + TokenType = tokenData["token_type"].GetString()! + } + }; + } + + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("OAuth2 토큰 교환 실패: {Error}", errorContent); + throw new ExternalServiceException( + "OAuth2", + provider.TokenEndpoint, + $"OAuth2 토큰 교환 실패: {errorContent}", + ErrorCode.OAUTH2_TOKEN_EXCHANGE_FAILED + ); + } + + private OAuth2Settings? GetProviderByClientId(string clientId) + { + return _settings.Providers.Values.FirstOrDefault(p => p.ClientId == clientId); + } + + private string GetProviderName(string clientId) + { + var provider = _settings.Providers.FirstOrDefault(p => p.Value.ClientId == clientId); + return provider.Key ?? "google"; + } + } +} \ No newline at end of file diff --git a/ProjectVG.Application/Services/Auth/OAuth2Service.cs b/ProjectVG.Application/Services/Auth/OAuth2Service.cs index d106eb0..faa29e1 100644 --- a/ProjectVG.Application/Services/Auth/OAuth2Service.cs +++ b/ProjectVG.Application/Services/Auth/OAuth2Service.cs @@ -1,4 +1,3 @@ -using System.Net.Http.Headers; using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; @@ -15,10 +14,11 @@ namespace ProjectVG.Application.Services.Auth /// public class OAuth2Service : IOAuth2Service { - private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly OAuth2ProviderSettings _settings; - private readonly IUserAuthService _authService; + private readonly IOAuth2CodeValidator _codeValidator; + private readonly IOAuth2UserService _userService; + private readonly IOAuth2AccountManager _accountManager; private readonly IOAuth2ProviderFactory _providerFactory; private readonly IDistributedCache _cache; @@ -31,17 +31,19 @@ public class OAuth2Service : IOAuth2Service private static readonly TimeSpan TokenDataTTL = TimeSpan.FromMinutes(5); public OAuth2Service( - IHttpClientFactory httpClientFactory, ILogger logger, IOptions settings, - IUserAuthService authService, + IOAuth2CodeValidator codeValidator, + IOAuth2UserService userService, + IOAuth2AccountManager accountManager, IOAuth2ProviderFactory providerFactory, IDistributedCache cache) { - _httpClient = httpClientFactory.CreateClient(); _logger = logger; _settings = settings.Value; - _authService = authService; + _codeValidator = codeValidator; + _userService = userService; + _accountManager = accountManager; _providerFactory = providerFactory; _cache = cache; } @@ -93,7 +95,7 @@ public async Task HandleOAuth2CallbackAsync(string code, s throw new ValidationException(ErrorCode.OAUTH2_REQUEST_NOT_FOUND); } - var tokenResponse = await ExchangeAuthorizationCodeAsync( + var tokenResponse = await _codeValidator.ValidateAndExchangeCodeAsync( code, authRequest.ClientId, authRequest.RedirectUri, @@ -106,13 +108,13 @@ public async Task HandleOAuth2CallbackAsync(string code, s await DeleteOAuth2RequestAsync(state); var providerName = GetProviderNameFromClientId(authRequest.ClientId); - var userInfo = await GetUserInfoAsync(tokenResponse.Tokens!.AccessToken, providerName); + var userInfo = await _userService.GetUserInfoAsync(tokenResponse.Tokens!.AccessToken, providerName); if (string.IsNullOrEmpty(userInfo.Id)) { throw new ValidationException(ErrorCode.OAUTH2_USER_INFO_FAILED); } - var authResult = await _authService.SignInWithOAuthAsync(providerName, userInfo.Id); + var authResult = await _accountManager.ProcessOAuth2LoginAsync(providerName, userInfo); var tokenData = new OAuth2TokenData { AccessToken = authResult.Tokens!.AccessToken, @@ -131,77 +133,6 @@ public async Task HandleOAuth2CallbackAsync(string code, s }; } - public async Task ExchangeAuthorizationCodeAsync(string code, string clientId, string redirectUri, string codeVerifier = "") - { - var providerSettings = GetProviderByClientId(clientId); - if (providerSettings == null) { - throw new ValidationException(ErrorCode.OAUTH2_CLIENT_ID_INVALID); - } - - var providerName = GetProviderName(clientId); - var provider = _providerFactory.GetProvider(providerName); - - var parameters = new Dictionary - { - { "grant_type", "authorization_code" }, - { "code", code }, - { "redirect_uri", redirectUri }, - { "client_id", providerSettings.ClientId }, - { "client_secret", providerSettings.ClientSecret } - }; - - if (!string.IsNullOrEmpty(codeVerifier)) { - parameters.Add("code_verifier", codeVerifier); - } - - var content = new FormUrlEncodedContent(parameters); - var response = await _httpClient.PostAsync(provider.TokenEndpoint, content); - - if (response.IsSuccessStatusCode) { - var json = await response.Content.ReadAsStringAsync(); - var tokenData = JsonSerializer.Deserialize>(json); - - return new TokenResponse { - Success = true, - Tokens = new Tokens { - AccessToken = tokenData!["access_token"].GetString()!, - RefreshToken = tokenData["refresh_token"].GetString()!, - ExpiresIn = tokenData["expires_in"].GetInt32(), - TokenType = tokenData["token_type"].GetString()! - } - }; - } - - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("OAuth2 토큰 교환 실패: {Error}", errorContent); - throw new ExternalServiceException( - "OAuth2", - provider.TokenEndpoint, - $"OAuth2 토큰 교환 실패: {errorContent}", - ErrorCode.OAUTH2_TOKEN_EXCHANGE_FAILED - ); - } - - public async Task GetUserInfoAsync(string accessToken, string providerName) - { - var provider = _providerFactory.GetProvider(providerName); - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - - var response = await _httpClient.GetAsync(provider.UserInfoEndpoint); - if (response.IsSuccessStatusCode) { - var json = await response.Content.ReadAsStringAsync(); - return provider.ParseUserInfo(json); - } - - var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("OAuth2 사용자 정보 조회 실패: {Error}", errorContent); - throw new ExternalServiceException( - "OAuth2", - provider.UserInfoEndpoint, - $"OAuth2 사용자 정보 조회 실패: {errorContent}", - ErrorCode.OAUTH2_USER_INFO_FAILED - ); - } public async Task StoreOAuth2RequestAsync(string state, OAuth2AuthRequest request) { @@ -306,16 +237,6 @@ public async Task DeleteTokenDataAsync(string state) } } - private OAuth2Settings? GetProviderByClientId(string clientId) - { - return _settings.Providers.Values.FirstOrDefault(p => p.ClientId == clientId); - } - - private string GetProviderName(string clientId) - { - var provider = _settings.Providers.FirstOrDefault(p => p.Value.ClientId == clientId); - return provider.Key ?? "google"; - } private string GetProviderNameFromClientId(string clientId) { diff --git a/ProjectVG.Application/Services/Auth/OAuth2UserService.cs b/ProjectVG.Application/Services/Auth/OAuth2UserService.cs new file mode 100644 index 0000000..a59df25 --- /dev/null +++ b/ProjectVG.Application/Services/Auth/OAuth2UserService.cs @@ -0,0 +1,46 @@ +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; +using ProjectVG.Application.Models.Auth; +using ProjectVG.Common.Constants; +using ProjectVG.Common.Exceptions; + +namespace ProjectVG.Application.Services.Auth +{ + public class OAuth2UserService : IOAuth2UserService + { + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly IOAuth2ProviderFactory _providerFactory; + + public OAuth2UserService( + IHttpClientFactory httpClientFactory, + ILogger logger, + IOAuth2ProviderFactory providerFactory) + { + _httpClient = httpClientFactory.CreateClient(); + _logger = logger; + _providerFactory = providerFactory; + } + + public async Task GetUserInfoAsync(string accessToken, string providerName) + { + var provider = _providerFactory.GetProvider(providerName); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await _httpClient.GetAsync(provider.UserInfoEndpoint); + if (response.IsSuccessStatusCode) { + var json = await response.Content.ReadAsStringAsync(); + return provider.ParseUserInfo(json); + } + + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("OAuth2 사용자 정보 조회 실패: {Error}", errorContent); + throw new ExternalServiceException( + "OAuth2", + provider.UserInfoEndpoint, + $"OAuth2 사용자 정보 조회 실패: {errorContent}", + ErrorCode.OAUTH2_USER_INFO_FAILED + ); + } + } +} \ No newline at end of file diff --git a/ProjectVG.Tests/Auth/AuthServiceTests.cs b/ProjectVG.Tests/Auth/AuthServiceTests.cs index b57d294..42233c9 100644 --- a/ProjectVG.Tests/Auth/AuthServiceTests.cs +++ b/ProjectVG.Tests/Auth/AuthServiceTests.cs @@ -38,12 +38,21 @@ public AuthServiceTests() } [Fact] - public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResult() + public async Task GuestLoginAsync_ExistingUser_ShouldReturnSuccessResult() { // Arrange - var provider = "test"; - var accessToken = Guid.NewGuid().ToString(); - var userId = Guid.Parse(accessToken); + var guestId = "guest123"; + var userId = Guid.NewGuid(); + + var existingUser = new UserDto + { + Id = userId, + Username = "guest_user", + Email = "guest@guest.local", + Status = AccountStatus.Active, + Provider = "guest", + ProviderId = guestId + }; var tokenResponse = new TokenResponse { @@ -53,19 +62,20 @@ public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResul RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) }; + _mockUserService.Setup(x => x.TryGetByProviderAsync("guest", guestId)).ReturnsAsync(existingUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act - var result = await _authService.SignInWithOAuthAsync(provider, accessToken); + var result = await _authService.GuestLoginAsync(guestId); // Assert result.Should().NotBeNull(); result.Tokens.Should().Be(tokenResponse); result.User.Should().NotBeNull(); result.User!.Id.Should().Be(userId); - result.User.Provider.Should().Be(provider); - result.User.ProviderId.Should().Be(accessToken); + result.User.Provider.Should().Be("guest"); + result.User.ProviderId.Should().Be(guestId); result.User.Status.Should().Be(AccountStatus.Active); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); @@ -73,93 +83,22 @@ public async Task LoginWithOAuthAsync_ValidTestProvider_ShouldReturnSuccessResul } [Fact] - public async Task LoginWithOAuthAsync_InvalidProvider_ShouldThrowValidationException() - { - // Arrange - var provider = "invalid"; - var accessToken = Guid.NewGuid().ToString(); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => _authService.SignInWithOAuthAsync(provider, accessToken) - ); - - exception.ErrorCode.Should().Be(ErrorCode.INVALID_INPUT); - } - - [Fact] - public async Task LoginWithOAuthAsync_InvalidUserIdFormat_ShouldThrowValidationException() - { - // Arrange - var provider = "test"; - var accessToken = "invalid-guid-format"; - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => _authService.SignInWithOAuthAsync(provider, accessToken) - ); - - exception.ErrorCode.Should().Be(ErrorCode.INVALID_INPUT); - } - - [Fact] - public async Task RefreshTokenAsync_ValidRefreshToken_ShouldReturnSuccessResult() + public async Task GuestLoginAsync_NewUser_ShouldCreateUserAndReturnSuccessResult() { // Arrange + var guestId = "guest456"; var userId = Guid.NewGuid(); - var refreshToken = "refresh.token.here"; - - var tokenResponse = new TokenResponse - { - AccessToken = "new.access.token.here", - RefreshToken = refreshToken, - AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), - RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) - }; - - var user = new UserDto - { - Id = userId, - Username = "testuser", - Email = "test@example.com", - Provider = "test", - ProviderId = userId.ToString(), - Status = AccountStatus.Active - }; - - _mockTokenService.Setup(x => x.RefreshAccessTokenAsync(refreshToken)).ReturnsAsync(tokenResponse); - _mockTokenService.Setup(x => x.GetUserIdFromTokenAsync(refreshToken)).ReturnsAsync(userId); - _mockUserService.Setup(x => x.TryGetByIdAsync(userId)).ReturnsAsync(user); - - // Act - var result = await _authService.RefreshAccessTokenAsync(refreshToken); - // Assert - result.Should().NotBeNull(); - result.Tokens.Should().Be(tokenResponse); - result.User.Should().Be(user); - - _mockTokenService.Verify(x => x.RefreshAccessTokenAsync(refreshToken), Times.Once); - _mockTokenService.Verify(x => x.GetUserIdFromTokenAsync(refreshToken), Times.Once); - _mockUserService.Verify(x => x.TryGetByIdAsync(userId), Times.Once); - } - - [Fact] - public async Task LoginWithOAuthAsync_NewGuestUser_ShouldCreateAndReturnSuccessResult() - { - // Arrange - var provider = "guest"; - var guestId = "guest123"; - var userId = Guid.NewGuid(); - var createdUser = new UserDto + var newUser = new UserDto { Id = userId, Username = $"guest_{guestId}", - Email = $"guest_{guestId}@guest.local", - Provider = provider, - ProviderId = guestId, - Status = AccountStatus.Active + Email = $"guest@guest{guestId}.local", + Status = AccountStatus.Active, + Provider = "guest", + ProviderId = guestId }; + var tokenResponse = new TokenResponse { AccessToken = "access.token.here", @@ -169,374 +108,96 @@ public async Task LoginWithOAuthAsync_NewGuestUser_ShouldCreateAndReturnSuccessR }; _mockUserService.Setup(x => x.TryGetByProviderAsync("guest", guestId)).ReturnsAsync((UserDto?)null); - _mockUserService.Setup(x => x.CreateUserAsync(It.IsAny())).ReturnsAsync(createdUser); + _mockUserService.Setup(x => x.CreateUserAsync(It.IsAny())).ReturnsAsync(newUser); _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); // Act - var result = await _authService.SignInWithOAuthAsync(provider, guestId); + var result = await _authService.GuestLoginAsync(guestId); // Assert result.Should().NotBeNull(); result.Tokens.Should().Be(tokenResponse); result.User.Should().NotBeNull(); result.User!.Id.Should().Be(userId); - result.User.Provider.Should().Be(provider); + result.User.Provider.Should().Be("guest"); result.User.ProviderId.Should().Be(guestId); - result.User.Status.Should().Be(AccountStatus.Active); - _mockUserService.Verify(x => x.TryGetByProviderAsync("guest", guestId), Times.Once); _mockUserService.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Once); _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); } [Fact] - public async Task LoginWithOAuthAsync_ExistingGuestUser_ShouldReturnSuccessResult() + public async Task GuestLoginAsync_EmptyGuestId_ShouldThrowValidationException() { // Arrange - var provider = "guest"; - var guestId = "guest123"; - var userId = Guid.NewGuid(); - var existingUser = new UserDto - { - Id = userId, - Username = $"guest_{guestId}", - Email = $"guest_{guestId}@guest.local", - Provider = provider, - ProviderId = guestId, - Status = AccountStatus.Active - }; - var tokenResponse = new TokenResponse - { - AccessToken = "access.token.here", - RefreshToken = "refresh.token.here", - AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), - RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) - }; - - _mockUserService.Setup(x => x.TryGetByProviderAsync("guest", guestId)).ReturnsAsync(existingUser); - _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); - - // Act - var result = await _authService.SignInWithOAuthAsync(provider, guestId); - - // Assert - result.Should().NotBeNull(); - result.Tokens.Should().Be(tokenResponse); - result.User.Should().Be(existingUser); - - _mockUserService.Verify(x => x.TryGetByProviderAsync("guest", guestId), Times.Once); - _mockUserService.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Never); - _mockTokenService.Verify(x => x.GenerateTokensAsync(userId), Times.Once); - _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); - } - - [Fact] - public async Task RefreshTokenAsync_InvalidRefreshToken_ShouldThrowValidationException() - { - // Arrange - var refreshToken = "invalid.refresh.token"; - _mockTokenService.Setup(x => x.RefreshAccessTokenAsync(refreshToken)).ReturnsAsync((TokenResponse?)null); + var guestId = ""; // Act & Assert var exception = await Assert.ThrowsAsync( - () => _authService.RefreshAccessTokenAsync(refreshToken) + () => _authService.GuestLoginAsync(guestId) ); - exception.ErrorCode.Should().Be(ErrorCode.TOKEN_INVALID); - - _mockTokenService.Verify(x => x.RefreshAccessTokenAsync(refreshToken), Times.Once); + exception.ErrorCode.Should().Be(ErrorCode.GUEST_ID_INVALID); } [Fact] - public async Task RefreshTokenAsync_EmptyRefreshToken_ShouldThrowValidationException() - { - // Arrange - var refreshToken = ""; - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => _authService.RefreshAccessTokenAsync(refreshToken) - ); - - exception.ErrorCode.Should().Be(ErrorCode.TOKEN_INVALID); - } - - [Fact] - public async Task LogoutAsync_EmptyRefreshToken_ShouldThrowValidationException() - { - // Arrange - var refreshToken = ""; - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => _authService.LogoutAsync(refreshToken) - ); - - exception.ErrorCode.Should().Be(ErrorCode.TOKEN_INVALID); - } - - [Fact] - public async Task LogoutAsync_ValidRefreshToken_ShouldReturnTrue() + public async Task RefreshAccessTokenAsync_ValidRefreshToken_ShouldReturnSuccessResult() { // Arrange var userId = Guid.NewGuid(); var refreshToken = "refresh.token.here"; - _mockTokenService.Setup(x => x.RevokeRefreshTokenAsync(refreshToken)).ReturnsAsync(true); - _mockTokenService.Setup(x => x.GetUserIdFromTokenAsync(refreshToken)).ReturnsAsync(userId); - - // Act - var result = await _authService.LogoutAsync(refreshToken); - - // Assert - result.Should().BeTrue(); - - _mockTokenService.Verify(x => x.RevokeRefreshTokenAsync(refreshToken), Times.Once); - _mockTokenService.Verify(x => x.GetUserIdFromTokenAsync(refreshToken), Times.Once); - } - - [Fact] - public async Task LogoutAsync_InvalidRefreshToken_ShouldReturnFalse() - { - // Arrange - var refreshToken = "invalid.refresh.token"; - _mockTokenService.Setup(x => x.RevokeRefreshTokenAsync(refreshToken)).ReturnsAsync(false); - - // Act - var result = await _authService.LogoutAsync(refreshToken); - - // Assert - result.Should().BeFalse(); - - _mockTokenService.Verify(x => x.RevokeRefreshTokenAsync(refreshToken), Times.Once); - } - - - [Fact] - public async Task LoginWithOAuthAsync_ExceptionThrown_ShouldPropagateException() - { - // Arrange - var provider = "test"; - var accessToken = Guid.NewGuid().ToString(); - - _mockTokenService.Setup(x => x.GenerateTokensAsync(It.IsAny())) - .ThrowsAsync(new Exception("Test exception")); - - // Act & Assert - await Assert.ThrowsAsync( - () => _authService.SignInWithOAuthAsync(provider, accessToken) - ); - } - - [Fact] - public async Task RefreshTokenAsync_ExceptionThrown_ShouldPropagateException() - { - // Arrange - var refreshToken = "refresh.token.here"; - - _mockTokenService.Setup(x => x.RefreshAccessTokenAsync(refreshToken)) - .ThrowsAsync(new Exception("Test exception")); - - // Act & Assert - await Assert.ThrowsAsync( - () => _authService.RefreshAccessTokenAsync(refreshToken) - ); - } - - [Fact] - public async Task LogoutAsync_ExceptionThrown_ShouldReturnFalse() - { - // Arrange - var refreshToken = "refresh.token.here"; - - _mockTokenService.Setup(x => x.RevokeRefreshTokenAsync(refreshToken)) - .ThrowsAsync(new Exception("Test exception")); - - // Act - var result = await _authService.LogoutAsync(refreshToken); - - // Assert - result.Should().BeFalse(); - } - - #region Token Granting Tests - - [Fact] - public async Task LoginWithOAuthAsync_NewUser_ShouldGrantInitialTokensAndLogSuccess() - { - // Arrange - var provider = "guest"; - var guestId = "new_guest_user"; - var userId = Guid.NewGuid(); - var createdUser = new UserDto - { - Id = userId, - Username = $"guest_{guestId}", - Email = $"guest_{guestId}@guest.local", - Provider = provider, - ProviderId = guestId, - Status = AccountStatus.Active - }; var tokenResponse = new TokenResponse { - AccessToken = "access.token.here", - RefreshToken = "refresh.token.here", + AccessToken = "new.access.token.here", + RefreshToken = refreshToken, AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) }; - _mockUserService.Setup(x => x.TryGetByProviderAsync(provider, guestId)).ReturnsAsync((UserDto?)null); - _mockUserService.Setup(x => x.CreateUserAsync(It.IsAny())).ReturnsAsync(createdUser); - _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(true); - - // Act - var result = await _authService.SignInWithOAuthAsync(provider, guestId); - - // Assert - result.Should().NotBeNull(); - result.User.Should().Be(createdUser); - - // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); - - // Verify success logging - _mockLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Initial tokens (5000) granted successfully") && - v.ToString()!.Contains(userId.ToString())), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - [Fact] - public async Task LoginWithOAuthAsync_ExistingUser_ShouldNotGrantTokensAndLogAlreadyGranted() - { - // Arrange - var provider = "guest"; - var guestId = "existing_guest_user"; - var userId = Guid.NewGuid(); - var existingUser = new UserDto + var user = new UserDto { Id = userId, - Username = $"guest_{guestId}", - Email = $"guest_{guestId}@guest.local", - Provider = provider, - ProviderId = guestId, - Status = AccountStatus.Active - }; - var tokenResponse = new TokenResponse - { - AccessToken = "access.token.here", - RefreshToken = "refresh.token.here", - AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), - RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) + Username = "test_user", + Email = "test@example.com", + Status = AccountStatus.Active, + Provider = "guest", + ProviderId = "guest123" }; - _mockUserService.Setup(x => x.TryGetByProviderAsync(provider, guestId)).ReturnsAsync(existingUser); - _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); + _mockTokenService.Setup(x => x.RefreshAccessTokenAsync(refreshToken)).ReturnsAsync(tokenResponse); + _mockTokenService.Setup(x => x.GetUserIdFromTokenAsync(refreshToken)).ReturnsAsync(userId); + _mockUserService.Setup(x => x.TryGetByIdAsync(userId)).ReturnsAsync(user); // Act - var result = await _authService.SignInWithOAuthAsync(provider, guestId); + var result = await _authService.RefreshAccessTokenAsync(refreshToken); // Assert result.Should().NotBeNull(); - result.User.Should().Be(existingUser); - - // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); + result.Tokens.Should().Be(tokenResponse); + result.User.Should().Be(user); - // Verify already granted logging - _mockLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Initial tokens already granted or grant failed") && - v.ToString()!.Contains(userId.ToString())), - It.IsAny(), - It.IsAny>()), - Times.Once); + _mockTokenService.Verify(x => x.RefreshAccessTokenAsync(refreshToken), Times.Once); } [Fact] - public async Task LoginWithOAuthAsync_TokenGrantingFails_ShouldStillCompleteLoginAndLogFailure() + public async Task LogoutAsync_ValidRefreshToken_ShouldReturnTrue() { // Arrange - var provider = "google"; - var providerId = "google_user_123"; - var userId = Guid.Parse("12345678-1234-1234-1234-123456789012"); - var tokenResponse = new TokenResponse - { - AccessToken = "access.token.here", - RefreshToken = "refresh.token.here", - AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), - RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) - }; + var refreshToken = "refresh.token.here"; + var userId = Guid.NewGuid(); - _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)).ReturnsAsync(false); + _mockTokenService.Setup(x => x.RevokeRefreshTokenAsync(refreshToken)).ReturnsAsync(true); + _mockTokenService.Setup(x => x.GetUserIdFromTokenAsync(refreshToken)).ReturnsAsync(userId); // Act - var result = await _authService.SignInWithOAuthAsync(provider, providerId); + var result = await _authService.LogoutAsync(refreshToken); // Assert - result.Should().NotBeNull(); - result.Tokens.Should().Be(tokenResponse); - - // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); - - // Verify failure logging - _mockLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("Initial tokens already granted or grant failed") && - v.ToString()!.Contains(userId.ToString())), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - [Fact] - public async Task LoginWithOAuthAsync_TokenGrantingThrowsException_ShouldNotFailLoginProcess() - { - // Arrange - var provider = "test"; - var accessToken = Guid.NewGuid().ToString(); - var userId = Guid.Parse(accessToken); - var tokenResponse = new TokenResponse - { - AccessToken = "access.token.here", - RefreshToken = "refresh.token.here", - AccessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15), - RefreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440) - }; - - _mockTokenService.Setup(x => x.GenerateTokensAsync(userId)).ReturnsAsync(tokenResponse); - _mockTokenManagementService.Setup(x => x.GrantInitialCreditsAsync(userId)) - .ThrowsAsync(new Exception("Credit granting service unavailable")); - - // Act & Assert - Should not throw exception - var result = await _authService.SignInWithOAuthAsync(provider, accessToken); - - // Verify login still completed successfully - result.Should().NotBeNull(); - result.Tokens.Should().Be(tokenResponse); - result.User!.Id.Should().Be(userId); - - // Verify token granting was attempted - _mockTokenManagementService.Verify(x => x.GrantInitialCreditsAsync(userId), Times.Once); + result.Should().BeTrue(); + _mockTokenService.Verify(x => x.RevokeRefreshTokenAsync(refreshToken), Times.Once); } - - #endregion - } -} +} \ No newline at end of file