KIM-JINO5 пре 3 месеци
родитељ
комит
2f35db5850

+ 2 - 1
.claude/settings.local.json

@@ -26,7 +26,8 @@
       "Bash(grep:*)",
       "Bash(tasklist:*)",
       "Bash(Stop-Process -Id 7452 -Force)",
-      "WebFetch(domain:global-docs.upbit.com)"
+      "WebFetch(domain:global-docs.upbit.com)",
+      "WebFetch(domain:livescore.co.kr)"
     ]
   },
   "model": "opusplan",

+ 3 - 0
Application/Abstractions/Cache/CacheKeys.cs

@@ -29,4 +29,7 @@ public static class CacheKeys
     public static string CryptoCandleLive(string market, string interval) => $"crypto:candle:live:{market.ToLower()}:{interval}";
     public static string CryptoTradeLive(string market) => $"crypto:trade:live:{market.ToLower()}";
     public static string CryptoOrderbookLive(string market) => $"crypto:orderbook:live:{market.ToLower()}";
+
+    // Chat
+    public const string ChatGlobalMessages = "chat:global:messages";
 }

+ 9 - 0
Application/Abstractions/Chat/ChatMessage.cs

@@ -0,0 +1,9 @@
+namespace Application.Abstractions.Chat;
+
+public sealed record ChatMessage(
+    int MemberID,
+    string MemberSID,
+    string MemberName,
+    string Content,
+    DateTime SentAt
+);

+ 8 - 0
Application/Abstractions/Chat/ChatSettings.cs

@@ -0,0 +1,8 @@
+namespace Application.Abstractions.Chat;
+
+public static class ChatSettings
+{
+    public const int MaxMessages = 200;
+    public const int MaxContentLength = 500;
+    public const int RateLimitSeconds = 2;
+}

+ 8 - 0
Application/Abstractions/Chat/IChatHubClient.cs

@@ -0,0 +1,8 @@
+namespace Application.Abstractions.Chat;
+
+public interface IChatHubClient
+{
+    Task ReceiveMessage(ChatMessage message);
+    Task ReceiveHistory(IReadOnlyList <ChatMessage> messages);
+    Task ReceiveSystemMessage(string message);
+}

+ 7 - 0
Application/Abstractions/Chat/IChatHubService.cs

@@ -0,0 +1,7 @@
+namespace Application.Abstractions.Chat;
+
+public interface IChatHubService
+{
+    Task BroadcastMessageAsync(ChatMessage message, CancellationToken ct = default);
+    Task BroadcastSystemMessageAsync(string message, CancellationToken ct = default);
+}

+ 7 - 0
Application/Abstractions/Chat/IChatMessageStore.cs

@@ -0,0 +1,7 @@
+namespace Application.Abstractions.Chat;
+
+public interface IChatMessageStore
+{
+    Task AddMessageAsync(ChatMessage message);
+    Task<IReadOnlyList<ChatMessage>> GetRecentMessagesAsync(int count);
+}

+ 1 - 1
CLAUDE.md

@@ -40,7 +40,7 @@ SharedKernel/    → AppSettings, Result Pattern
 - Records for DTOs and commands
 - Result<T> pattern for error handling (no exceptions for flow control)
 - File-scoped namespaces
-- Always pass CancellationToken to async methods
+- Always pass CancellationToken to async methods (단, StackExchange.Redis 등 CancellationToken을 지원하지 않는 라이브러리는 예외)
 
 ### Patterns We DON'T Use (Never Suggest)
 - Repository pattern (use EF Core directly)

+ 52 - 0
Infrastructure/Chat/RedisChatMessageStore.cs

@@ -0,0 +1,52 @@
+using Application.Abstractions.Cache;
+using Application.Abstractions.Chat;
+using StackExchange.Redis;
+using System.Text.Json;
+
+namespace Infrastructure.Chat;
+
+public sealed class RedisChatMessageStore(IConnectionMultiplexer redis) : IChatMessageStore
+{
+    private readonly IDatabase _db = redis.GetDatabase();
+
+    private static readonly JsonSerializerOptions _jsonOptions = new()
+    {
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        WriteIndented = false
+    };
+
+    public async Task AddMessageAsync(ChatMessage message)
+    {
+        var json = JsonSerializer.Serialize(message, _jsonOptions);
+
+        await _db.ListLeftPushAsync(CacheKeys.ChatGlobalMessages, json);
+
+        await _db.ListTrimAsync(CacheKeys.ChatGlobalMessages, 0, ChatSettings.MaxMessages - 1);
+    }
+
+    public async Task<IReadOnlyList<ChatMessage>> GetRecentMessagesAsync(int count)
+    {
+        var values = await _db.ListRangeAsync(CacheKeys.ChatGlobalMessages, 0, count - 1);
+        if (values.Length <= 0)
+        {
+            return [];
+        }
+
+        var messages = new List<ChatMessage>(values.Length);
+
+        foreach (var row in values)
+        {
+            if (!row.IsNullOrEmpty)
+            {
+                var message = JsonSerializer.Deserialize<ChatMessage>((string)row!, _jsonOptions);
+                if (message is not null)
+                {
+                    messages.Add(message);
+                }
+            }
+        }
+
+        messages.Reverse();
+        return messages;
+    }
+}

+ 26 - 7
Infrastructure/DependencyInjection.cs

@@ -1,18 +1,18 @@
-using SharedKernel;
-using SharedKernel.Storage;
 using Application.Abstractions.Authentication;
+using Application.Abstractions.Cache;
+using Application.Abstractions.Chat;
+using Application.Abstractions.Crypto;
 using Application.Abstractions.Data;
 using Application.Abstractions.Identity;
 using Application.Abstractions.Messaging.Email;
-using Application.Abstractions.Cache;
-using Application.Abstractions.Crypto;
 using Infrastructure.Authentication;
+using Infrastructure.Cache;
+using Infrastructure.Chat;
+using Infrastructure.Crypto;
 using Infrastructure.Messaging.Email;
 using Infrastructure.Persistence;
 using Infrastructure.Persistence.Identity;
 using Infrastructure.Storage;
-using Infrastructure.Cache;
-using Infrastructure.Crypto;
 using Microsoft.AspNetCore.Authentication.JwtBearer;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Identity;
@@ -21,8 +21,10 @@ using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.IdentityModel.Tokens;
-using System.Text;
+using SharedKernel;
+using SharedKernel.Storage;
 using StackExchange.Redis;
+using System.Text;
 
 namespace Infrastructure
 {
@@ -93,6 +95,7 @@ namespace Infrastructure
                 client.DefaultRequestHeaders.Add("Accept", "application/json");
                 client.Timeout = TimeSpan.FromSeconds(10);
             });
+            services.AddSingleton<IChatMessageStore, RedisChatMessageStore>();
 
             return services;
         }
@@ -151,6 +154,22 @@ namespace Infrastructure
                     IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.JWT.SecretKey)),
                     ClockSkew = TimeSpan.Zero
                 };
+
+                options.Events = new JwtBearerEvents
+                {
+                    OnMessageReceived = context =>
+                    {
+                        var accessToken = context.Request.Query["access_token"];
+                        var path = context.HttpContext.Request.Path;
+
+                        if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
+                        {
+                            context.Token = accessToken;
+                        }
+
+                        return Task.CompletedTask;
+                    }
+                };
             });
 
             services.AddAuthorization();

+ 113 - 0
Web.Api/Hubs/ChatHub.cs

@@ -0,0 +1,113 @@
+using Application.Abstractions.Chat;
+using Application.Abstractions.Data;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.IdentityModel.JsonWebTokens;
+using System.Collections.Concurrent;
+
+namespace Web.Api.Hubs;
+
+[Authorize]
+public sealed class ChatHub(IChatMessageStore messageStore, IServiceScopeFactory scopeFactory) : Hub<IChatHubClient>
+{
+    private static readonly ConcurrentDictionary<string, DateTime> _lastMessageTime = new();
+
+    public override async Task OnConnectedAsync()
+    {
+        var messages = await messageStore.GetRecentMessagesAsync(ChatSettings.MaxMessages);
+        await Clients.Caller.ReceiveHistory(messages);
+
+        var memberName = GetMemberName();
+        if (!string.IsNullOrEmpty(memberName))
+        {
+            await Clients.Others.ReceiveSystemMessage($"{memberName}님이 입장했습니다.");
+        }
+
+        await base.OnConnectedAsync();
+    }
+
+    public override async Task OnDisconnectedAsync(Exception? exception)
+    {
+        _lastMessageTime.TryRemove(Context.ConnectionId, out _);
+
+        var memberName = GetMemberName();
+        if (!string.IsNullOrEmpty(memberName))
+        {
+            await Clients.Others.ReceiveSystemMessage($"{memberName}님이 퇴장했습니다.");
+        }
+
+        await base.OnDisconnectedAsync(exception);
+    }
+
+    public async Task SendMessage(string content)
+    {
+        if (string.IsNullOrWhiteSpace(content))
+        {
+            return;
+        }
+
+        content = content.Trim();
+
+        if (content.Length > ChatSettings.MaxContentLength)
+        {
+            return;
+        }
+
+        var now = DateTime.UtcNow;
+        if (_lastMessageTime.TryGetValue(Context.ConnectionId, out var lastTime))
+        {
+            if ((now - lastTime).TotalSeconds < ChatSettings.RateLimitSeconds)
+            {
+                return;
+            }
+        }
+        _lastMessageTime[Context.ConnectionId] = now;
+
+        var memberID = GetMemberID();
+        if (memberID is null)
+        {
+            return;
+        }
+
+        using var scope = scopeFactory.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
+        var member = await db.Member.AsNoTracking().Where(x => x.ID == memberID.Value).Select(x => new { x.SID, x.Name }).FirstOrDefaultAsync();
+
+        if (member is null)
+        {
+            return;
+        }
+
+        var message = new ChatMessage(
+            memberID.Value,
+            member.SID,
+            member.Name ?? "익명",
+            content,
+            now
+        );
+
+        // 채팅 기록 저장
+        await messageStore.AddMessageAsync(message);
+
+        // 모든 클라이언트에게 메시지 전송
+        await Clients.All.ReceiveMessage(message);
+    }
+
+    private int? GetMemberID()
+    {
+        var sub = Context.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
+
+        if (int.TryParse(sub, out var memberID))
+        {
+            return memberID;
+        }
+
+        return null;
+    }
+
+    private string? GetMemberName()
+    {
+        return Context.User?.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
+    }
+}

+ 19 - 0
Web.Api/Hubs/ChatHubService.cs

@@ -0,0 +1,19 @@
+using Application.Abstractions.Chat;
+using Microsoft.AspNetCore.SignalR;
+
+namespace Web.Api.Hubs;
+
+public sealed class ChatHubService(
+    IHubContext<ChatHub, IChatHubClient> hubContext
+) : IChatHubService {
+
+    public async Task BroadcastMessageAsync(ChatMessage message, CancellationToken ct = default)
+    {
+        await hubContext.Clients.All.ReceiveMessage(message);
+    }
+
+    public async Task BroadcastSystemMessageAsync(string message, CancellationToken ct = default)
+    {
+        await hubContext.Clients.All.ReceiveSystemMessage(message);
+    }
+}

+ 7 - 1
Web.Api/Program.cs

@@ -1,11 +1,12 @@
 using Application;
+using Application.Abstractions.Chat;
 using Application.Abstractions.Crypto;
 using Infrastructure;
 using SharedKernel;
+using System.Reflection;
 using Web.Api;
 using Web.Api.Extensions;
 using Web.Api.Hubs;
-using System.Reflection;
 
 var builder = WebApplication.CreateBuilder(args);
 var settings = builder.Configuration.Get<AppSettings>()!;
@@ -39,6 +40,10 @@ builder.Services.AddCors(options =>
 builder.Services.AddSignalR();
 builder.Services.AddSingleton<ICryptoHubService, CryptoHubService>();
 
+// Chat
+builder.Services.AddSingleton<IChatHubService, ChatHubService>();
+
+// Endpoints
 builder.Services.AddEndpoints(Assembly.GetExecutingAssembly());
 builder.Logging.AddConsole();
 
@@ -67,5 +72,6 @@ app.UseAuthentication();
 app.UseAuthorization();
 app.MapEndpoints();
 app.MapHub<CryptoHub>("/hubs/crypto");
+app.MapHub<ChatHub>("/hubs/chat");
 
 app.Run();