Handler.cs 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Microsoft.EntityFrameworkCore;
  4. using SharedKernel.Results;
  5. using SharedKernel.Storage;
  6. using Domain.Entities.Store;
  7. namespace Application.Features.Admin.Store.Game.Update;
  8. public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command, Result>
  9. {
  10. private static readonly string[] AllowedImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
  11. public async Task<Result> Handle(Command request, CancellationToken ct)
  12. {
  13. if (request.CommissionRate < 0 || request.CommissionRate > 100)
  14. {
  15. return Result.Failure(Error.Problem("Game.InvalidCommissionRate", "게임사 수익률은 0~100% 사이여야 합니다."));
  16. }
  17. var game = await db.Game.FirstOrDefaultAsync(c => c.ID == request.ID, ct);
  18. if (game is null)
  19. {
  20. return Result.Failure(Error.NotFound("Game.NotFound", "게임을 찾을 수 없습니다."));
  21. }
  22. var code = NormalizeOptional(request.Code);
  23. var engName = NormalizeOptional(request.EngName);
  24. var link = NormalizeOptional(request.Link);
  25. // 한글명 중복 (본인 제외)
  26. if (await db.Game.AnyAsync(c => c.ID != request.ID && c.KorName == request.KorName, ct))
  27. {
  28. return Result.Failure(Error.Conflict("Game.DuplicateKorName", "이미 같은 한글명의 게임이 등록되어 있습니다."));
  29. }
  30. // 게임 코드 중복 (본인 제외, 입력 시에만)
  31. if (code is not null && await db.Game.AnyAsync(c => c.ID != request.ID && c.Code == code, ct))
  32. {
  33. return Result.Failure(Error.Conflict("Game.DuplicateCode", "이미 같은 코드의 게임이 등록되어 있습니다."));
  34. }
  35. // 작은 이미지 처리
  36. string? newThumbnail = game.Thumbnail;
  37. if (request.RemoveThumbnail)
  38. {
  39. newThumbnail = null;
  40. }
  41. if (request.ThumbnailFile is not null)
  42. {
  43. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.StoreGame, game.ID);
  44. var saved = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, AllowedImageExt, ct);
  45. if (saved is not null)
  46. {
  47. newThumbnail = saved.Url;
  48. }
  49. }
  50. // 큰 이미지 처리
  51. string? newBigImage = game.BigImage;
  52. if (request.RemoveBigImage)
  53. {
  54. newBigImage = null;
  55. }
  56. if (request.BigImageFile is not null)
  57. {
  58. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.StoreGame, game.ID);
  59. var saved = await fileStorage.SaveFileAsync(request.BigImageFile, uploadPath, AllowedImageExt, ct);
  60. if (saved is not null)
  61. {
  62. newBigImage = saved.Url;
  63. }
  64. }
  65. game.Update(
  66. korName: request.KorName,
  67. publisher: request.Publisher,
  68. commissionRate: request.CommissionRate,
  69. code: code,
  70. engName: engName,
  71. thumbnail: newThumbnail,
  72. bigImage: newBigImage,
  73. description: request.Description,
  74. link: link,
  75. order: request.Order,
  76. isActive: request.IsActive
  77. );
  78. game.SetDetail(request.TrailerUrl, request.MinSpec, request.RecommendedSpec, request.ReleaseDate);
  79. var urlsToDelete = await SyncDetailAsync(game.ID, request, ct);
  80. await db.SaveChangesAsync(ct);
  81. // 물리 파일 삭제는 DB 커밋 성공 후에만 (실패 시 고아 파일/행 방지)
  82. foreach (var url in urlsToDelete)
  83. {
  84. fileStorage.DeleteByUrl(url);
  85. }
  86. return Result.Success();
  87. }
  88. private async Task<List<string>> SyncDetailAsync(int gameID, Command request, CancellationToken ct)
  89. {
  90. var urlsToDelete = new List<string>();
  91. // 장르 맵 재동기화 (선택분과 비교해 추가/삭제만 — 복합키 재추가 식별자 충돌 방지)
  92. var existingGenres = await db.GameGenreMap.Where(m => m.GameID == gameID).ToListAsync(ct);
  93. var selectedGenreIds = request.GenreIDs is { Count: > 0 } genreIds
  94. ? (await db.GameGenre.Where(g => genreIds.Contains(g.ID)).Select(g => g.ID).ToListAsync(ct)).ToHashSet()
  95. : new HashSet<int>();
  96. var existingGenreIds = existingGenres.Select(m => m.GameGenreID).ToHashSet();
  97. foreach (var m in existingGenres)
  98. {
  99. if (!selectedGenreIds.Contains(m.GameGenreID))
  100. {
  101. db.GameGenreMap.Remove(m);
  102. }
  103. }
  104. foreach (var id in selectedGenreIds)
  105. {
  106. if (!existingGenreIds.Contains(id))
  107. {
  108. db.GameGenreMap.Add(GameGenreMap.Create(gameID, id));
  109. }
  110. }
  111. // 카테고리 맵 재동기화 (동일 패턴)
  112. var existingCategories = await db.GameCategoryMap.Where(m => m.GameID == gameID).ToListAsync(ct);
  113. var selectedCategoryIds = request.CategoryIDs is { Count: > 0 } categoryIds
  114. ? (await db.GameCategory.Where(c => categoryIds.Contains(c.ID)).Select(c => c.ID).ToListAsync(ct)).ToHashSet()
  115. : new HashSet<int>();
  116. var existingCategoryIds = existingCategories.Select(m => m.GameCategoryID).ToHashSet();
  117. foreach (var m in existingCategories)
  118. {
  119. if (!selectedCategoryIds.Contains(m.GameCategoryID))
  120. {
  121. db.GameCategoryMap.Remove(m);
  122. }
  123. }
  124. foreach (var id in selectedCategoryIds)
  125. {
  126. if (!existingCategoryIds.Contains(id))
  127. {
  128. db.GameCategoryMap.Add(GameCategoryMap.Create(gameID, id));
  129. }
  130. }
  131. // 기존 이미지 삭제 (물리 파일은 커밋 후 삭제하도록 URL 수집)
  132. if (request.RemoveImageIDs is { Count: > 0 } removeIds)
  133. {
  134. var toRemove = await db.GameImage.Where(i => i.GameID == gameID && removeIds.Contains(i.ID)).ToListAsync(ct);
  135. foreach (var img in toRemove)
  136. {
  137. urlsToDelete.Add(img.Url);
  138. }
  139. db.GameImage.RemoveRange(toRemove);
  140. }
  141. // 기존 이미지 순서 재지정 (드래그 정렬)
  142. var nextOrder = 0;
  143. if (request.ImageOrder is { Count: > 0 } orderIds)
  144. {
  145. var imgs = await db.GameImage.Where(i => i.GameID == gameID && orderIds.Contains(i.ID)).ToListAsync(ct);
  146. var byId = imgs.ToDictionary(i => i.ID);
  147. var pos = 0;
  148. foreach (var id in orderIds)
  149. {
  150. if (byId.TryGetValue(id, out var img))
  151. {
  152. img.Order = pos++;
  153. }
  154. }
  155. nextOrder = pos;
  156. }
  157. else
  158. {
  159. nextOrder = (await db.GameImage.Where(i => i.GameID == gameID).Select(i => (int?)i.Order).MaxAsync(ct) ?? -1) + 1;
  160. }
  161. // 신규 이미지 업로드 (재지정된 순서 뒤에 추가)
  162. if (request.DetailImages is { Count: > 0 })
  163. {
  164. var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.StoreGameDetail, gameID);
  165. var order = nextOrder;
  166. foreach (var image in request.DetailImages)
  167. {
  168. var saved = await fileStorage.SaveFileAsync(image, uploadPath, AllowedImageExt, ct);
  169. if (saved is null)
  170. {
  171. continue;
  172. }
  173. db.GameImage.Add(new GameImage
  174. {
  175. GameID = gameID,
  176. FileName = image.FileName,
  177. HashedName = saved.FileName,
  178. Path = uploadPath.ToRelativePath(),
  179. Url = saved.Url,
  180. Extension = saved.Extension,
  181. ContentType = image.ContentType,
  182. Size = saved.Size,
  183. Width = saved.Width,
  184. Height = saved.Height,
  185. Order = order++
  186. });
  187. }
  188. }
  189. // 언어 지원 재동기화 (기존과 비교: 추가/수정/삭제. 활성 언어만, 중복 ID 정리, 셋 다 미체크면 미저장)
  190. var existingLangs = await db.GameLanguageSupport.Where(s => s.GameID == gameID).ToListAsync(ct);
  191. var existingByLangId = existingLangs.ToDictionary(s => s.GameLanguageID);
  192. var submitted = new Dictionary<int, Command.LanguageRow>();
  193. if (request.Languages is { Count: > 0 } langs)
  194. {
  195. var reqIds = langs.Select(x => x.GameLanguageID).ToList();
  196. var validIds = (await db.GameLanguage.Where(l => reqIds.Contains(l.ID) && l.IsActive).Select(l => l.ID).ToListAsync(ct)).ToHashSet();
  197. foreach (var row in langs)
  198. {
  199. if (!validIds.Contains(row.GameLanguageID))
  200. {
  201. continue;
  202. }
  203. if (!row.Interface && !row.FullAudio && !row.Subtitles)
  204. {
  205. continue;
  206. }
  207. submitted[row.GameLanguageID] = row;
  208. }
  209. }
  210. foreach (var s in existingLangs)
  211. {
  212. if (!submitted.ContainsKey(s.GameLanguageID))
  213. {
  214. db.GameLanguageSupport.Remove(s);
  215. }
  216. }
  217. foreach (var (langId, row) in submitted)
  218. {
  219. if (existingByLangId.TryGetValue(langId, out var existing))
  220. {
  221. existing.SetSupport(row.Interface, row.FullAudio, row.Subtitles);
  222. }
  223. else
  224. {
  225. db.GameLanguageSupport.Add(GameLanguageSupport.Create(gameID, langId, row.Interface, row.FullAudio, row.Subtitles));
  226. }
  227. }
  228. return urlsToDelete;
  229. }
  230. private static string? NormalizeOptional(string? value)
  231. {
  232. return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
  233. }
  234. }