| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272 |
- 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<Command, Result>
- {
- private static readonly string[] AllowedImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
- public async Task<Result> 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
- );
- 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<List<string>> SyncDetailAsync(int gameID, Command request, CancellationToken ct)
- {
- var urlsToDelete = new List<string>();
- // 장르 맵 재동기화 (선택분과 비교해 추가/삭제만 — 복합키 재추가 식별자 충돌 방지)
- 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<int>();
- 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<int>();
- 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<int, Command.LanguageRow>();
- 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();
- }
- }
|