using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; using SharedKernel.Storage; using Domain.Entities.Store; namespace Application.Features.Admin.Store.Game.Update; public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler { private static readonly string[] AllowedImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; public async Task Handle(Command request, CancellationToken ct) { if (request.CommissionRate < 0 || request.CommissionRate > 100) { return Result.Failure(Error.Problem("Game.InvalidCommissionRate", "게임사 수익률은 0~100% 사이여야 합니다.")); } var game = await db.Game.FirstOrDefaultAsync(c => c.ID == request.ID, ct); if (game is null) { return Result.Failure(Error.NotFound("Game.NotFound", "게임을 찾을 수 없습니다.")); } var code = NormalizeOptional(request.Code); var engName = NormalizeOptional(request.EngName); var link = NormalizeOptional(request.Link); // 한글명 중복 (본인 제외) if (await db.Game.AnyAsync(c => c.ID != request.ID && c.KorName == request.KorName, ct)) { return Result.Failure(Error.Conflict("Game.DuplicateKorName", "이미 같은 한글명의 게임이 등록되어 있습니다.")); } // 게임 코드 중복 (본인 제외, 입력 시에만) if (code is not null && await db.Game.AnyAsync(c => c.ID != request.ID && c.Code == code, ct)) { return Result.Failure(Error.Conflict("Game.DuplicateCode", "이미 같은 코드의 게임이 등록되어 있습니다.")); } // 작은 이미지 처리 string? newThumbnail = game.Thumbnail; if (request.RemoveThumbnail) { newThumbnail = null; } if (request.ThumbnailFile is not null) { var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.StoreGame, game.ID); var saved = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, AllowedImageExt, ct); if (saved is not null) { newThumbnail = saved.Url; } } // 큰 이미지 처리 string? newBigImage = game.BigImage; if (request.RemoveBigImage) { newBigImage = null; } if (request.BigImageFile is not null) { var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.StoreGame, game.ID); var saved = await fileStorage.SaveFileAsync(request.BigImageFile, uploadPath, AllowedImageExt, ct); if (saved is not null) { newBigImage = saved.Url; } } game.Update( korName: request.KorName, publisher: request.Publisher, commissionRate: request.CommissionRate, code: code, engName: engName, thumbnail: newThumbnail, bigImage: newBigImage, description: request.Description, link: link, order: request.Order, isActive: request.IsActive ); if (request.ApiCommissionRate < 0 || request.ApiCommissionRate > 100) { return Result.Failure(Error.Problem("Game.InvalidApiCommissionRate", "API 수수료율은 0~100% 사이여야 합니다.")); } game.SetApiCommissionRate(request.ApiCommissionRate); game.SetDetail(request.TrailerUrl, request.MinSpec, request.RecommendedSpec, request.ReleaseDate); var urlsToDelete = await SyncDetailAsync(game.ID, request, ct); await db.SaveChangesAsync(ct); // 물리 파일 삭제는 DB 커밋 성공 후에만 (실패 시 고아 파일/행 방지) foreach (var url in urlsToDelete) { fileStorage.DeleteByUrl(url); } return Result.Success(); } private async Task> SyncDetailAsync(int gameID, Command request, CancellationToken ct) { var urlsToDelete = new List(); // 장르 맵 재동기화 (선택분과 비교해 추가/삭제만 — 복합키 재추가 식별자 충돌 방지) var existingGenres = await db.GameGenreMap.Where(m => m.GameID == gameID).ToListAsync(ct); var selectedGenreIds = request.GenreIDs is { Count: > 0 } genreIds ? (await db.GameGenre.Where(g => genreIds.Contains(g.ID)).Select(g => g.ID).ToListAsync(ct)).ToHashSet() : new HashSet(); var existingGenreIds = existingGenres.Select(m => m.GameGenreID).ToHashSet(); foreach (var m in existingGenres) { if (!selectedGenreIds.Contains(m.GameGenreID)) { db.GameGenreMap.Remove(m); } } foreach (var id in selectedGenreIds) { if (!existingGenreIds.Contains(id)) { db.GameGenreMap.Add(GameGenreMap.Create(gameID, id)); } } // 카테고리 맵 재동기화 (동일 패턴) var existingCategories = await db.GameCategoryMap.Where(m => m.GameID == gameID).ToListAsync(ct); var selectedCategoryIds = request.CategoryIDs is { Count: > 0 } categoryIds ? (await db.GameCategory.Where(c => categoryIds.Contains(c.ID)).Select(c => c.ID).ToListAsync(ct)).ToHashSet() : new HashSet(); var existingCategoryIds = existingCategories.Select(m => m.GameCategoryID).ToHashSet(); foreach (var m in existingCategories) { if (!selectedCategoryIds.Contains(m.GameCategoryID)) { db.GameCategoryMap.Remove(m); } } foreach (var id in selectedCategoryIds) { if (!existingCategoryIds.Contains(id)) { db.GameCategoryMap.Add(GameCategoryMap.Create(gameID, id)); } } // 기존 이미지 삭제 (물리 파일은 커밋 후 삭제하도록 URL 수집) if (request.RemoveImageIDs is { Count: > 0 } removeIds) { var toRemove = await db.GameImage.Where(i => i.GameID == gameID && removeIds.Contains(i.ID)).ToListAsync(ct); foreach (var img in toRemove) { urlsToDelete.Add(img.Url); } db.GameImage.RemoveRange(toRemove); } // 기존 이미지 순서 재지정 (드래그 정렬) var nextOrder = 0; if (request.ImageOrder is { Count: > 0 } orderIds) { var imgs = await db.GameImage.Where(i => i.GameID == gameID && orderIds.Contains(i.ID)).ToListAsync(ct); var byId = imgs.ToDictionary(i => i.ID); var pos = 0; foreach (var id in orderIds) { if (byId.TryGetValue(id, out var img)) { img.Order = pos++; } } nextOrder = pos; } else { nextOrder = (await db.GameImage.Where(i => i.GameID == gameID).Select(i => (int?)i.Order).MaxAsync(ct) ?? -1) + 1; } // 신규 이미지 업로드 (재지정된 순서 뒤에 추가) if (request.DetailImages is { Count: > 0 }) { var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.StoreGameDetail, gameID); var order = nextOrder; foreach (var image in request.DetailImages) { var saved = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExt, ct); if (saved is null) { continue; } db.GameImage.Add(new GameImage { GameID = gameID, FileName = image.FileName, HashedName = saved.FileName, Path = uploadPath.ToRelativePath(), Url = saved.Url, Extension = saved.Extension, ContentType = image.ContentType, Size = saved.Size, Width = saved.Width, Height = saved.Height, Order = order++ }); } } // 언어 지원 재동기화 (기존과 비교: 추가/수정/삭제. 활성 언어만, 중복 ID 정리, 셋 다 미체크면 미저장) var existingLangs = await db.GameLanguageSupport.Where(s => s.GameID == gameID).ToListAsync(ct); var existingByLangId = existingLangs.ToDictionary(s => s.GameLanguageID); var submitted = new Dictionary(); if (request.Languages is { Count: > 0 } langs) { var reqIds = langs.Select(x => x.GameLanguageID).ToList(); var validIds = (await db.GameLanguage.Where(l => reqIds.Contains(l.ID) && l.IsActive).Select(l => l.ID).ToListAsync(ct)).ToHashSet(); foreach (var row in langs) { if (!validIds.Contains(row.GameLanguageID)) { continue; } if (!row.Interface && !row.FullAudio && !row.Subtitles) { continue; } submitted[row.GameLanguageID] = row; } } foreach (var s in existingLangs) { if (!submitted.ContainsKey(s.GameLanguageID)) { db.GameLanguageSupport.Remove(s); } } foreach (var (langId, row) in submitted) { if (existingByLangId.TryGetValue(langId, out var existing)) { existing.SetSupport(row.Interface, row.FullAudio, row.Subtitles); } else { db.GameLanguageSupport.Add(GameLanguageSupport.Create(gameID, langId, row.Interface, row.FullAudio, row.Subtitles)); } } return urlsToDelete; } private static string? NormalizeOptional(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } }