Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
# ===========================================
# Copy this file to .env and fill in your actual values

ENVIRONMENT=development
DEBUG_MODE=true
LOG_LEVEL=INFO
LOG_FORMAT=json
DATA_PATH=../data
Comment on lines +6 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ENVIRONMENT vs ASPNETCORE_ENVIRONMENT 값 충돌

두 변수가 서로 다른 값을 갖고 있어 실제 실행 환경이 오인될 수 있습니다. 하나로 통일하세요.

-ASPNETCORE_ENVIRONMENT=Production
+ASPNETCORE_ENVIRONMENT=Development

(또는 ENVIRONMENT 키를 제거/주석 처리하고 ASPNETCORE_ENVIRONMENT만 사용)

Also applies to: 50-50

🧰 Tools
🪛 dotenv-linter (3.3.0)

[warning] 7-7: [UnorderedKey] The DEBUG_MODE key should go before the ENVIRONMENT key

(UnorderedKey)


[warning] 9-9: [UnorderedKey] The LOG_FORMAT key should go before the LOG_LEVEL key

(UnorderedKey)


[warning] 10-10: [UnorderedKey] The DATA_PATH key should go before the DEBUG_MODE key

(UnorderedKey)

🤖 Prompt for AI Agents
In .env.example around lines 6 to 10 (and similarly at lines ~50), there is a
potential conflict between ENVIRONMENT and ASPNETCORE_ENVIRONMENT having
different values; pick one environment variable to be authoritative (preferably
ASPNETCORE_ENVIRONMENT for ASP.NET apps) and either remove or comment out the
duplicate ENVIRONMENT entry, or set both to the same value to avoid mismatched
runtime behavior; update the file so only the chosen key remains (or both match)
and add a brief comment explaining which variable the app reads.


# Database Configuration
DB_CONNECTION_STRING=Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

DB 연결 문자열은 따옴표로 감싸기

특수문자 포함 값은 인용이 안전합니다. dotenv-linter 경고도 해소됩니다.

-DB_CONNECTION_STRING=Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true
+DB_CONNECTION_STRING="Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
DB_CONNECTION_STRING=Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true
DB_CONNECTION_STRING="Server=host.docker.internal,1433;Database=ProjectVG;User Id=sa;Password=YOUR_DB_PASSWORD;TrustServerCertificate=true;MultipleActiveResultSets=true"
🧰 Tools
🪛 dotenv-linter (3.3.0)

[warning] 13-13: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)

🤖 Prompt for AI Agents
.env.example around line 13: the DB_CONNECTION_STRING value contains special
characters and should be wrapped in quotes to be safe and satisfy dotenv-linter;
update the line to enclose the entire connection string in double quotes (e.g.
DB_CONNECTION_STRING="Server=...;Password=...;...") so parsers and linters treat
it as a single quoted value.

DB_PASSWORD=YOUR_DB_PASSWORD

# Redis Configuration
REDIS_CONNECTION_STRING=host.docker.internal:6380

# Distributed System Configuration
DISTRIBUTED_MODE=false
SERVER_ID=

# External Services
LLM_BASE_URL=http://host.docker.internal:7930
MEMORY_BASE_URL=http://host.docker.internal:7940
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ _ReSharper*/
# Docker
**/Dockerfile.*
docker-compose.override.yml
.dockerignore.local

# Keep template files but ignore runtime files
!docker-compose.prod.yml
Expand Down
8 changes: 8 additions & 0 deletions ProjectVG.Api/Middleware/WebSocketMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ await socket.SendAsync(
WebSocketMessageType.Text,
true,
cancellationTokenSource.Token);

// 세션 하트비트 업데이트 (Redis TTL 갱신)
try {
await _webSocketService.UpdateSessionHeartbeatAsync(userId);
}
catch (Exception heartbeatEx) {
_logger.LogWarning(heartbeatEx, "세션 하트비트 업데이트 실패: {UserId}", userId);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion ProjectVG.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
}

builder.Services.AddInfrastructureServices(builder.Configuration);
builder.Services.AddApplicationServices();
builder.Services.AddApplicationServices(builder.Configuration);
builder.Services.AddDevelopmentCors();

// 부하테스트 환경에서 성능 모니터링 서비스 추가
Expand Down
7 changes: 7 additions & 0 deletions ProjectVG.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,12 @@
"JWT": {
"Issuer": "ProjectVG",
"Audience": "ProjectVG"
},
"DistributedSystem": {
"Enabled": false,
"ServerId": "api-server-001",
"HeartbeatIntervalSeconds": 30,
"CleanupIntervalMinutes": 5,
"ServerTimeoutMinutes": 2
}
}
36 changes: 30 additions & 6 deletions ProjectVG.Application/ApplicationServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using ProjectVG.Application.Services.Auth;
using ProjectVG.Application.Services.Character;
using ProjectVG.Application.Services.Chat;
Expand All @@ -12,12 +13,14 @@
using ProjectVG.Application.Services.Credit;
using ProjectVG.Application.Services.Users;
using ProjectVG.Application.Services.WebSocket;
using ProjectVG.Application.Services.MessageBroker;
using ProjectVG.Application.Services.Server;

namespace ProjectVG.Application
{
public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
{
// Auth Services
services.AddScoped<IAuthService, AuthService>();
Expand Down Expand Up @@ -69,13 +72,34 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
// Conversation Services
services.AddScoped<IConversationService, ConversationService>();

// Session Services
services.AddSingleton<IConnectionRegistry, ConnectionRegistry>();

// WebSocket Services
services.AddScoped<IWebSocketManager, WebSocketManager>();
// Distributed System Services
AddDistributedServices(services, configuration);

return services;
}

/// <summary>
/// 분산 시스템 관련 서비스 등록
/// </summary>
private static void AddDistributedServices(IServiceCollection services, IConfiguration configuration)
{
var distributedEnabled = configuration.GetValue<bool>("DistributedSystem:Enabled", false);

if (distributedEnabled)
{
// 분산 환경 서비스
services.AddSingleton<IMessageBroker, DistributedMessageBroker>();
services.AddSingleton<IWebSocketManager, DistributedWebSocketManager>();
}
else
{
// 단일 서버 환경 서비스
services.AddSingleton<IMessageBroker, LocalMessageBroker>();
services.AddSingleton<IWebSocketManager, WebSocketManager>();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

치명적: DistributedMessageBroker ↔ DistributedWebSocketManager 생성자 순환 의존성으로 DI 해석 실패

현재 싱글톤 등록에서 다음 순환이 발생합니다:

  • DistributedMessageBroker → IWebSocketManager
  • DistributedWebSocketManager → IMessageBroker

MS.DI는 생성자 주입 순환을 허용하지 않아 런타임에 InvalidOperationException이 납니다.

권장 수정(간단/국소 변경):

  • DistributedWebSocketManager에서 IMessageBroker 직접 주입을 제거하고 IServiceProvider를 주입해 필요 시 지연 조회하도록 변경합니다. 그 후 분산 브로커인 경우에만 Subscribe/Unsubscribe를 호출하세요.

예시 (다른 파일 수정):

// DistributedWebSocketManager.cs
using Microsoft.Extensions.DependencyInjection; // GetRequiredService 용

public class DistributedWebSocketManager : IWebSocketManager
{
    private readonly ILogger<DistributedWebSocketManager> _logger;
    private readonly IConnectionRegistry _connectionRegistry;
    private readonly ISessionStorage _sessionStorage;
    private readonly IServiceProvider _serviceProvider; // 변경

    public DistributedWebSocketManager(
        ILogger<DistributedWebSocketManager> logger,
        IConnectionRegistry connectionRegistry,
        ISessionStorage sessionStorage,
        IServiceProvider serviceProvider) // 변경
    {
        _logger = logger;
        _connectionRegistry = connectionRegistry;
        _sessionStorage = sessionStorage;
        _serviceProvider = serviceProvider;
    }

    public async Task<string> ConnectAsync(string userId)
    {
        ...
        var broker = _serviceProvider.GetService<IMessageBroker>();
        if (broker is DistributedMessageBroker distributed)
        {
            await distributed.SubscribeToUserChannelAsync(userId);
        }
        return userId;
    }

    public async Task DisconnectAsync(string userId)
    {
        ...
        var broker = _serviceProvider.GetService<IMessageBroker>();
        if (broker is DistributedMessageBroker distributed)
        {
            await distributed.UnsubscribeFromUserChannelAsync(userId);
        }
    }
}

대안:

  • Lazy/Func 기반 지연 주입 또는 Subscribe/Unsubscribe 전용 인터페이스(예: IUserChannelSubscriber)를 도입해 의존 방향을 단방향으로 정리.

이 수정이 반영되면 본 파일의 DI 등록은 그대로 유지해도 순환이 해소됩니다.

🤖 Prompt for AI Agents
ProjectVG.Application/ApplicationServiceCollectionExtensions.cs lines 91-98: the
DI registrations trigger a constructor cycle between DistributedMessageBroker
and DistributedWebSocketManager (each injects the other's interface); to fix,
change DistributedWebSocketManager to stop taking IMessageBroker in its
constructor and instead accept IServiceProvider (or a
lazy/Func<IMessageBroker>), update its ConnectAsync/DisconnectAsync to resolve
IMessageBroker at call-time (e.g., serviceProvider.GetService<IMessageBroker>),
and only call Subscribe/Unsubscribe when the resolved broker is a
DistributedMessageBroker; this removes the circular constructor dependency while
keeping the service registrations unchanged.

}

// WebSocket 연결 관리
services.AddSingleton<IConnectionRegistry, ConnectionRegistry>();
}
}
}
89 changes: 89 additions & 0 deletions ProjectVG.Application/Models/MessageBroker/BrokerMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Text.Json;

namespace ProjectVG.Application.Models.MessageBroker
{
public class BrokerMessage
{
public string MessageId { get; set; } = Guid.NewGuid().ToString();
public string MessageType { get; set; } = string.Empty;
public string? TargetUserId { get; set; }
public string? TargetServerId { get; set; }
public string? SourceServerId { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public string Payload { get; set; } = string.Empty;
public Dictionary<string, string> Headers { get; set; } = new();

public static BrokerMessage CreateUserMessage(string userId, object payload, string? sourceServerId = null)
{
return new BrokerMessage
{
MessageType = "user_message",
TargetUserId = userId,
SourceServerId = sourceServerId,
Payload = JsonSerializer.Serialize(payload),
Headers = new Dictionary<string, string>
{
["content-type"] = "application/json"
}
};
}

public static BrokerMessage CreateServerMessage(string serverId, object payload, string? sourceServerId = null)
{
return new BrokerMessage
{
MessageType = "server_message",
TargetServerId = serverId,
SourceServerId = sourceServerId,
Payload = JsonSerializer.Serialize(payload),
Headers = new Dictionary<string, string>
{
["content-type"] = "application/json"
}
};
}

public static BrokerMessage CreateBroadcastMessage(object payload, string? sourceServerId = null)
{
return new BrokerMessage
{
MessageType = "broadcast_message",
SourceServerId = sourceServerId,
Payload = JsonSerializer.Serialize(payload),
Headers = new Dictionary<string, string>
{
["content-type"] = "application/json"
}
};
}

public T? DeserializePayload<T>()
{
try
{
return JsonSerializer.Deserialize<T>(Payload);
}
catch
{
return default;
}
}

public string ToJson()
{
return JsonSerializer.Serialize(this);
}

public static BrokerMessage? FromJson(string json)
{
try
{
return JsonSerializer.Deserialize<BrokerMessage>(json);
}
catch
{
return null;
}
}
}
}
41 changes: 41 additions & 0 deletions ProjectVG.Application/Models/Server/ServerInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace ProjectVG.Application.Models.Server
{
public class ServerInfo
{
public string ServerId { get; set; } = string.Empty;
public DateTime StartedAt { get; set; }
public DateTime LastHeartbeat { get; set; }
public int ActiveConnections { get; set; }
public string Status { get; set; } = "healthy";
public string? Environment { get; set; }
public string? Version { get; set; }

public ServerInfo()
{
}

public ServerInfo(string serverId)
{
ServerId = serverId;
StartedAt = DateTime.UtcNow;
LastHeartbeat = DateTime.UtcNow;
ActiveConnections = 0;
Status = "healthy";
}

public void UpdateHeartbeat()
{
LastHeartbeat = DateTime.UtcNow;
}

public void UpdateConnectionCount(int count)
{
ActiveConnections = count;
}

public bool IsHealthy(TimeSpan timeout)
{
return DateTime.UtcNow - LastHeartbeat < timeout;
}
}
}
17 changes: 12 additions & 5 deletions ProjectVG.Application/Services/Chat/Handlers/ChatSuccessHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,24 @@
using ProjectVG.Application.Models.WebSocket;
using ProjectVG.Application.Services.WebSocket;
using ProjectVG.Application.Services.Credit;
using ProjectVG.Application.Services.MessageBroker;


namespace ProjectVG.Application.Services.Chat.Handlers
{
public class ChatSuccessHandler
{
private readonly ILogger<ChatSuccessHandler> _logger;
private readonly IWebSocketManager _webSocketService;
private readonly IMessageBroker _messageBroker;
private readonly ICreditManagementService _tokenManagementService;

public ChatSuccessHandler(
ILogger<ChatSuccessHandler> logger,
IWebSocketManager webSocketService,
IMessageBroker messageBroker,
ICreditManagementService tokenManagementService)
{
_logger = logger;
_webSocketService = webSocketService;
_messageBroker = messageBroker;
_tokenManagementService = tokenManagementService;
}

Expand Down Expand Up @@ -61,8 +62,14 @@ public async Task HandleAsync(ChatProcessContext context)
var message = ChatProcessResultMessage.FromSegment(segment, requestId)
.WithCreditInfo(tokensUsed, tokensRemaining);
var wsMessage = new WebSocketMessage("chat", message);

await _webSocketService.SendAsync(userId, wsMessage);

_logger.LogInformation("[메시지브로커] 사용자에게 메시지 전송 시작: UserId={UserId}, MessageType={MessageType}, SegmentOrder={Order}, BrokerType={BrokerType}",
userId, wsMessage.Type, segment.Order, _messageBroker.IsDistributed ? "Distributed" : "Local");

await _messageBroker.SendToUserAsync(userId, wsMessage);

_logger.LogInformation("[메시지브로커] 사용자에게 메시지 전송 완료: UserId={UserId}, MessageType={MessageType}, SegmentOrder={Order}",
userId, wsMessage.Type, segment.Order);
}
catch (Exception ex)
{
Expand Down
Loading