Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 5 additions & 6 deletions ProjectVG.Api/Middleware/WebSocketMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,15 @@ await socket.SendAsync(
new ArraySegment<byte>(welcomeMessage),
WebSocketMessageType.Text,
true,
cancellationTokenSource.Token).ConfigureAwait(false);
cancellationTokenSource.Token);

while (socket.State == WebSocketState.Open && !cancellationTokenSource.Token.IsCancellationRequested)
{
WebSocketReceiveResult result;
using var ms = new MemoryStream();
do
{
result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationTokenSource.Token)
.ConfigureAwait(false);
result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationTokenSource.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogInformation("연결 종료 요청: {UserId}", userId);
Expand Down Expand Up @@ -160,7 +159,7 @@ await socket.SendAsync(
new ArraySegment<byte>(pongMessage),
WebSocketMessageType.Text,
true,
cancellationTokenSource.Token).ConfigureAwait(false);
cancellationTokenSource.Token);
}
}
}
Expand All @@ -178,14 +177,14 @@ await socket.SendAsync(
_logger.LogInformation("WebSocket 연결 해제: {UserId}", userId);

try {
await _webSocketService.DisconnectAsync(userId).ConfigureAwait(false);
await _webSocketService.DisconnectAsync(userId);
_connectionRegistry.Unregister(userId);

if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) {
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Connection closed",
CancellationToken.None).ConfigureAwait(false);
CancellationToken.None);
}
Comment on lines 183 to 188
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

CloseAsync에 무제한 CancellationToken.None 사용 지양

네트워크 이슈 시 무기한 블로킹될 수 있습니다. 짧은 타임아웃을 가진 CTS를 사용하세요.

-                        await socket.CloseAsync(
+                        using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+                        await socket.CloseAsync(
                             WebSocketCloseStatus.NormalClosure,
                             "Connection closed",
-                            CancellationToken.None);
+                            closeCts.Token);
📝 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
if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) {
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Connection closed",
CancellationToken.None).ConfigureAwait(false);
CancellationToken.None);
}
if (socket.State == WebSocketState.Open || socket.State == WebSocketState.CloseReceived) {
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Connection closed",
closeCts.Token);
}
🤖 Prompt for AI Agents
In ProjectVG.Api/Middleware/WebSocketMiddleware.cs around lines 183 to 188, the
call to socket.CloseAsync uses CancellationToken.None which can block
indefinitely on network issues; replace it with a CancellationToken from a
short-lived CancellationTokenSource (e.g., a few seconds) to bound the wait.
Create a CTS with a sensible timeout, pass cts.Token into CloseAsync, and ensure
the CTS is disposed (use a using or try/finally) so the token cancels after the
timeout to avoid hanging on network failures.

}
catch (Exception ex) {
Expand Down
2 changes: 1 addition & 1 deletion ProjectVG.Api/Services/TestClientLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public class TestClientLauncher
{
public void Launch()
{
Task.Delay(1000).ContinueWith(_ => {
_ = Task.Delay(1000).ContinueWith(_ => {
try
{
var htmlPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "test-clients", "test-client.html");
Expand Down
20 changes: 10 additions & 10 deletions ProjectVG.Application/Services/Auth/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task<AuthResult> GuestLoginAsync(string guestId)
throw new ValidationException(ErrorCode.GUEST_ID_INVALID);
}

var user = await _userService.TryGetByProviderAsync("guest", guestId).ConfigureAwait(false);
var user = await _userService.TryGetByProviderAsync("guest", guestId);

if (user == null) {
string uuid = GenerateGuestUuid(guestId);
Expand All @@ -41,23 +41,23 @@ public async Task<AuthResult> GuestLoginAsync(string guestId)
Provider: "guest"
);

user = await _userService.CreateUserAsync(createCommand).ConfigureAwait(false);
user = await _userService.CreateUserAsync(createCommand);
_logger.LogInformation("새 게스트 사용자 생성됨: UserId={UserId}, GuestId={GuestId}", user.Id, guestId);
}

return await FinalizeLoginAsync(user, "guest").ConfigureAwait(false);
return await FinalizeLoginAsync(user, "guest");
}

private async Task<AuthResult> FinalizeLoginAsync(UserDto user, string provider)
{
// 초기 크레딧 지급
var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id).ConfigureAwait(false);
var tokenGranted = await _tokenManagementService.GrantInitialCreditsAsync(user.Id);
if (tokenGranted) {
_logger.LogInformation("사용자 {UserId}에게 최초 크레딧 지급 완료", user.Id);
}

// 최종 JWT 토큰 발급
var tokens = await _tokenService.GenerateTokensAsync(user.Id).ConfigureAwait(false);
var tokens = await _tokenService.GenerateTokensAsync(user.Id);

return new AuthResult {
Tokens = tokens,
Expand All @@ -71,13 +71,13 @@ public async Task<AuthResult> RefreshAccessTokenAsync(string? refreshToken)
throw new ValidationException(ErrorCode.TOKEN_MISSING);
}

var tokens = await _tokenService.RefreshAccessTokenAsync(refreshToken).ConfigureAwait(false);
var tokens = await _tokenService.RefreshAccessTokenAsync(refreshToken);
if (tokens == null) {
throw new ValidationException(ErrorCode.TOKEN_REFRESH_FAILED);
}

var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken).ConfigureAwait(false);
var user = userId.HasValue ? await _userService.TryGetByIdAsync(userId.Value).ConfigureAwait(false) : null;
var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken);
var user = userId.HasValue ? await _userService.TryGetByIdAsync(userId.Value) : null;

return new AuthResult {
Tokens = tokens,
Expand All @@ -91,9 +91,9 @@ public async Task<bool> LogoutAsync(string? refreshToken)
throw new ValidationException(ErrorCode.TOKEN_MISSING);
}

var revoked = await _tokenService.RevokeRefreshTokenAsync(refreshToken).ConfigureAwait(false);
var revoked = await _tokenService.RevokeRefreshTokenAsync(refreshToken);
if (revoked) {
var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken).ConfigureAwait(false);
var userId = await _tokenService.GetUserIdFromTokenAsync(refreshToken);
}
return revoked;
}
Expand Down
16 changes: 1 addition & 15 deletions ProjectVG.Application/Services/Chat/ChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,6 @@ public async Task<ChatRequestResult> EnqueueChatRequestAsync(ChatRequestCommand

var preprocessContext = await PrepareChatRequestAsync(command);

LogChatRequestCommand(command);

_ = Task.Run(async () => {
await ProcessChatRequestInternalAsync(preprocessContext);
});
Expand Down Expand Up @@ -112,11 +110,9 @@ private async Task ProcessChatRequestInternalAsync(ChatProcessContext context)
await _llmProcessor.ProcessAsync(context);
await _ttsProcessor.ProcessAsync(context);

// ChatSuccessHandler와 ChatResultProcessor를 같은 스코프에서 실행

var successHandler = scope.ServiceProvider.GetRequiredService<ChatSuccessHandler>();
var resultProcessor = scope.ServiceProvider.GetRequiredService<ChatResultProcessor>();

await successHandler.HandleAsync(context);
await resultProcessor.PersistResultsAsync(context);
}
Expand All @@ -125,20 +121,10 @@ private async Task ProcessChatRequestInternalAsync(ChatProcessContext context)
await failureHandler.HandleAsync(context);
}
finally {
LogChatProcessContext(context);
_metricsService.EndChatMetrics();
_metricsService.LogChatMetrics();
}
}

private void LogChatRequestCommand(ChatRequestCommand command)
{
_logger.LogInformation("Starting chat process: {CommandInfo}", command.ToDebugString());
}

private void LogChatProcessContext(ChatProcessContext context)
{
_logger.LogInformation("Chat process completed: {ContextInfo}", context.ToDebugString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,22 @@ public ChatRequestValidator(
public async Task ValidateAsync(ChatRequestCommand command)
{
// 세션 검증 - 사용자 활성 세션 확인
await ValidateUserSessionAsync(command.UserId).ConfigureAwait(false);
await ValidateUserSessionAsync(command.UserId);

var userExists = await _userService.ExistsByIdAsync(command.UserId).ConfigureAwait(false);
var userExists = await _userService.ExistsByIdAsync(command.UserId);
if (!userExists) {
_logger.LogWarning("사용자 ID 검증 실패: {UserId}", command.UserId);
throw new NotFoundException(ErrorCode.USER_NOT_FOUND, command.UserId);
}

var characterExists = await _characterService.CharacterExistsAsync(command.CharacterId).ConfigureAwait(false);
var characterExists = await _characterService.CharacterExistsAsync(command.CharacterId);
if (!characterExists) {
_logger.LogWarning("캐릭터 ID 검증 실패: {CharacterId}", command.CharacterId);
throw new NotFoundException(ErrorCode.CHARACTER_NOT_FOUND, command.CharacterId);
}

// 토큰 잔액 검증 - 예상 비용으로 미리 확인
var balance = await _tokenManagementService.GetCreditBalanceAsync(command.UserId).ConfigureAwait(false);
var balance = await _tokenManagementService.GetCreditBalanceAsync(command.UserId);
var currentBalance = balance.CurrentBalance;

if (currentBalance <= 0) {
Expand All @@ -76,8 +76,7 @@ private async Task ValidateUserSessionAsync(Guid userId)
try {
// 사용자 ID를 기반으로 세션 조회
var userSessions = (await _sessionStorage
.GetSessionsByUserIdAsync(userId.ToString())
.ConfigureAwait(false))
.GetSessionsByUserIdAsync(userId.ToString()))
.ToList();

if (userSessions.Count == 0) {
Expand Down
12 changes: 6 additions & 6 deletions ProjectVG.Infrastructure/Auth/TokenService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)
var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15);
var refreshTokenExpiresAt = DateTime.UtcNow.AddMinutes(1440);

Comment on lines 24 to 26
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

토큰 만료 시간 하드코딩 → 설정화 권장

운영 정책 변경에 유연하게 대응할 수 있도록 IOptions 패턴으로 구성값화하세요.

예: TokenOptions { AccessTokenMinutes, RefreshTokenMinutes }를 DI로 주입.

🤖 Prompt for AI Agents
In ProjectVG.Infrastructure/Auth/TokenService.cs around lines 24 to 26, the
access and refresh token lifetimes are hardcoded; change this to use
configurable options injected via IOptions<TokenOptions>. Add a TokenOptions
class with AccessTokenMinutes and RefreshTokenMinutes, inject
IOptions<TokenOptions> into the TokenService constructor, and replace
DateTime.UtcNow.AddMinutes(15) / AddMinutes(1440) with values from
options.Value.AccessTokenMinutes and options.Value.RefreshTokenMinutes; also
ensure TokenOptions is bound in DI (e.g.,
services.Configure<TokenOptions>(configuration.GetSection("TokenOptions"))) and
consider validating the values before use.

var stored = await _refreshTokenStorage.StoreRefreshTokenAsync(refreshToken, userId, refreshTokenExpiresAt).ConfigureAwait(false);
var stored = await _refreshTokenStorage.StoreRefreshTokenAsync(refreshToken, userId, refreshTokenExpiresAt);
if (!stored)
{
_logger.LogError("Failed to store refresh token for user {UserId}", userId);
Expand Down Expand Up @@ -56,14 +56,14 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)
return null;
}

var isValid = await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken).ConfigureAwait(false);
var isValid = await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken);
if (!isValid)
{
_logger.LogWarning("Refresh token not found in storage");
return null;
}

var userId = await _refreshTokenStorage.GetUserIdFromRefreshTokenAsync(refreshToken).ConfigureAwait(false);
var userId = await _refreshTokenStorage.GetUserIdFromRefreshTokenAsync(refreshToken);
if (!userId.HasValue)
{
_logger.LogWarning("User ID not found for refresh token");
Expand All @@ -75,7 +75,7 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)
var accessTokenExpiresAt = DateTime.UtcNow.AddMinutes(15);

// 기존 Refresh Token의 만료 시간 조회
var refreshTokenExpiresAt = await _refreshTokenStorage.GetRefreshTokenExpiresAtAsync(refreshToken).ConfigureAwait(false);
var refreshTokenExpiresAt = await _refreshTokenStorage.GetRefreshTokenExpiresAtAsync(refreshToken);
if (!refreshTokenExpiresAt.HasValue)
{
_logger.LogWarning("Refresh token expiration time not found");
Expand All @@ -93,7 +93,7 @@ public async Task<TokenResponse> GenerateTokensAsync(Guid userId)

public async Task<bool> RevokeRefreshTokenAsync(string refreshToken)
{
return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken).ConfigureAwait(false);
return await _refreshTokenStorage.RemoveRefreshTokenAsync(refreshToken);
}

public async Task<bool> ValidateRefreshTokenAsync(string refreshToken)
Expand All @@ -110,7 +110,7 @@ public async Task<bool> ValidateRefreshTokenAsync(string refreshToken)
return false;
}

return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken).ConfigureAwait(false);
return await _refreshTokenStorage.IsRefreshTokenValidAsync(refreshToken);
}

public Task<bool> ValidateAccessTokenAsync(string accessToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,37 +24,37 @@ public async Task<IEnumerable<User>> GetAllAsync()
return await _context.Users
.Where(u => u.Status == AccountStatus.Active)
.OrderBy(u => u.Username)
.ToListAsync().ConfigureAwait(false);
.ToListAsync();
}
Comment on lines 24 to 28
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

읽기 전용 쿼리에 AsNoTracking 적용으로 트래킹 오버헤드 제거 제안

레포 메서드 다수가 조회 전용입니다. EF Core 트래킹 비용 절감을 위해 AsNoTracking 추가를 권장합니다. Update/Delete는 내부에서 별도 조회로 트래킹 엔티티를 확보하므로 안전합니다.

 return await _context.Users
-    .Where(u => u.Status == AccountStatus.Active)
+    .AsNoTracking()
+    .Where(u => u.Status == AccountStatus.Active)
     .OrderBy(u => u.Username)
     .ToListAsync();

-return await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted);
+return await _context.Users
+    .AsNoTracking()
+    .FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted);

-return await _context.Users.FirstOrDefaultAsync(u => u.Username == username && u.Status != AccountStatus.Deleted);
+return await _context.Users
+    .AsNoTracking()
+    .FirstOrDefaultAsync(u => u.Username == username && u.Status != AccountStatus.Deleted);

-return await _context.Users.FirstOrDefaultAsync(u => u.Email == email && u.Status != AccountStatus.Deleted);
+return await _context.Users
+    .AsNoTracking()
+    .FirstOrDefaultAsync(u => u.Email == email && u.Status != AccountStatus.Deleted);

-return await _context.Users.FirstOrDefaultAsync(u => u.ProviderId == providerId && u.Status != AccountStatus.Deleted);
+return await _context.Users
+    .AsNoTracking()
+    .FirstOrDefaultAsync(u => u.ProviderId == providerId && u.Status != AccountStatus.Deleted);

-return await _context.Users.FirstOrDefaultAsync(u => u.Provider == provider && u.ProviderId == providerId && u.Status != AccountStatus.Deleted);
+return await _context.Users
+    .AsNoTracking()
+    .FirstOrDefaultAsync(u => u.Provider == provider && u.ProviderId == providerId && u.Status != AccountStatus.Deleted);

-return await _context.Users.FirstOrDefaultAsync(u => u.UID == uid && u.Status != AccountStatus.Deleted);
+return await _context.Users
+    .AsNoTracking()
+    .FirstOrDefaultAsync(u => u.UID == uid && u.Status != AccountStatus.Deleted);

Also applies to: 32-33, 37-38, 42-43, 47-48, 52-53, 57-58


public async Task<User?> GetByIdAsync(Guid id)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
return await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted);
}

public async Task<User?> GetByUsernameAsync(string username)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Username == username && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
return await _context.Users.FirstOrDefaultAsync(u => u.Username == username && u.Status != AccountStatus.Deleted);
}

public async Task<User?> GetByEmailAsync(string email)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Email == email && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
return await _context.Users.FirstOrDefaultAsync(u => u.Email == email && u.Status != AccountStatus.Deleted);
}

public async Task<User?> GetByProviderIdAsync(string providerId)
{
return await _context.Users.FirstOrDefaultAsync(u => u.ProviderId == providerId && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
return await _context.Users.FirstOrDefaultAsync(u => u.ProviderId == providerId && u.Status != AccountStatus.Deleted);
}

public async Task<User?> GetByProviderAsync(string provider, string providerId)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Provider == provider && u.ProviderId == providerId && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
return await _context.Users.FirstOrDefaultAsync(u => u.Provider == provider && u.ProviderId == providerId && u.Status != AccountStatus.Deleted);
}

public async Task<User?> GetByUIDAsync(string uid)
{
return await _context.Users.FirstOrDefaultAsync(u => u.UID == uid && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
return await _context.Users.FirstOrDefaultAsync(u => u.UID == uid && u.Status != AccountStatus.Deleted);
}

public async Task<User> CreateAsync(User user)
Expand All @@ -65,14 +65,14 @@ public async Task<User> CreateAsync(User user)
user.Status = AccountStatus.Active;

_context.Users.Add(user);
await _context.SaveChangesAsync().ConfigureAwait(false);
await _context.SaveChangesAsync();

return user;
}

public async Task<User> UpdateAsync(User user)
{
var existingUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == user.Id && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
var existingUser = await _context.Users.FirstOrDefaultAsync(u => u.Id == user.Id && u.Status != AccountStatus.Deleted);
if (existingUser == null) {
throw new NotFoundException(ErrorCode.USER_NOT_FOUND, "User", user.Id);
}
Expand All @@ -85,22 +85,22 @@ public async Task<User> UpdateAsync(User user)
existingUser.Status = user.Status;
existingUser.Update();

await _context.SaveChangesAsync().ConfigureAwait(false);
await _context.SaveChangesAsync();

return existingUser;
}

public async Task DeleteAsync(Guid id)
{
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted).ConfigureAwait(false);
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == id && u.Status != AccountStatus.Deleted);

if (user == null) {
throw new NotFoundException(ErrorCode.USER_NOT_FOUND, "User", id);
}

user.Status = AccountStatus.Deleted;
user.Update();
await _context.SaveChangesAsync().ConfigureAwait(false);
await _context.SaveChangesAsync();
}
}
}
Loading