Handler.cs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. using System.Globalization;
  2. using System.Security.Cryptography;
  3. using System.Text;
  4. using Application.Abstractions.Data;
  5. using Application.Abstractions.Messaging;
  6. using Microsoft.EntityFrameworkCore;
  7. using SharedKernel.Helpers;
  8. using SharedKernel.Results;
  9. using CouponCodeEntity = Domain.Entities.Store.CouponCode;
  10. namespace Application.Features.Admin.Store.Coupon.ImportCodes;
  11. /// <summary>
  12. /// CSV 양식 (DB 컬럼명 기준, 9 컬럼):
  13. /// ID, Code, Status, IssuedToMemberID, IssuedToMemberName, IssuedToMemberEmail, IssuedAt, UsedAt, CreatedAt
  14. ///
  15. /// Upload 규칙:
  16. /// - 헤더 행 필수 (대소문자 무관, 영어 컬럼명)
  17. /// - Status = 0 (Available 신규 등록) 또는 1 (Used; 회원/일시 필수)
  18. /// - IssuedToMemberID 가 Member 테이블에 존재해야 함 (Status=1 일 때)
  19. /// - IssuedToMemberName / IssuedToMemberEmail / CreatedAt / ID 는 import 시 무시 (참고용)
  20. /// - 일시는 KST 입력 → UTC 변환 후 저장
  21. /// - 기존 코드 hash 중복은 skip
  22. /// </summary>
  23. public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
  24. {
  25. private const int BatchSize = 500;
  26. private const int LookupBatchSize = 1000;
  27. private static readonly string[] ExpectedHeader =
  28. [
  29. "ID",
  30. "Code",
  31. "Status",
  32. "IssuedToMemberID",
  33. "IssuedToMemberName",
  34. "IssuedToMemberEmail",
  35. "IssuedAt",
  36. "UsedAt",
  37. "CreatedAt"
  38. ];
  39. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  40. {
  41. var coupon = await db.Coupon.FirstOrDefaultAsync(c => c.ID == request.CouponID, ct);
  42. if (coupon is null)
  43. {
  44. return Result.Failure<Response>(Error.NotFound("Coupon.NotFound", "쿠폰을 찾을 수 없습니다."));
  45. }
  46. if (request.CsvFile is null || request.CsvFile.Length == 0)
  47. {
  48. return Result.Failure<Response>(Error.Problem("Coupon.EmptyFile", "업로드할 CSV 파일이 비어있습니다."));
  49. }
  50. if (request.CsvFile.Length > 10 * 1024 * 1024)
  51. {
  52. return Result.Failure<Response>(Error.Problem("Coupon.FileTooLarge", "CSV 파일은 10MB 이하만 허용합니다."));
  53. }
  54. List<string[]> rows;
  55. using (var stream = request.CsvFile.OpenReadStream())
  56. using (var reader = new StreamReader(stream, Encoding.UTF8))
  57. {
  58. rows = [.. CsvHelper.Read(reader)];
  59. }
  60. if (rows.Count == 0)
  61. {
  62. return Result.Failure<Response>(Error.Problem("Coupon.EmptyCsv", "CSV 에 데이터가 없습니다."));
  63. }
  64. var header = rows[0];
  65. if (header.Length < ExpectedHeader.Length)
  66. {
  67. return Result.Failure<Response>(Error.Problem("Coupon.InvalidHeader",
  68. $"CSV 헤더 컬럼 수 부족. {ExpectedHeader.Length}개 필요, {header.Length}개 발견."));
  69. }
  70. for (var i = 0; i < ExpectedHeader.Length; i++)
  71. {
  72. if (!string.Equals(header[i].Trim(), ExpectedHeader[i], StringComparison.OrdinalIgnoreCase))
  73. {
  74. return Result.Failure<Response>(Error.Problem("Coupon.InvalidHeader",
  75. $"CSV 헤더 {i + 1}번째 컬럼이 다릅니다. 기대: {ExpectedHeader[i]}, 실제: {header[i]}"));
  76. }
  77. }
  78. var dataRows = rows.Skip(1).ToList();
  79. var totalLines = dataRows.Count;
  80. // 회원 ID 사전 lookup (Status=1 행 후보)
  81. var memberIDsInCsv = new HashSet<int>();
  82. foreach (var row in dataRows)
  83. {
  84. if (row.Length < 4)
  85. {
  86. continue;
  87. }
  88. if (int.TryParse(row[3].Trim(), out var mid) && mid > 0)
  89. {
  90. memberIDsInCsv.Add(mid);
  91. }
  92. }
  93. var existingMemberIDs = new HashSet<int>();
  94. foreach (var batch in memberIDsInCsv.Chunk(LookupBatchSize))
  95. {
  96. var found = await db.Member.AsNoTracking()
  97. .Where(m => batch.Contains(m.ID))
  98. .Select(m => m.ID)
  99. .ToListAsync(ct);
  100. foreach (var id in found)
  101. {
  102. existingMemberIDs.Add(id);
  103. }
  104. }
  105. // 기존 코드 hash 사전 조회
  106. var hashesInCsv = new HashSet<string>();
  107. var rowHashes = new string?[dataRows.Count];
  108. for (var i = 0; i < dataRows.Count; i++)
  109. {
  110. var row = dataRows[i];
  111. if (row.Length < 2)
  112. {
  113. continue;
  114. }
  115. var code = row[1].Trim();
  116. if (string.IsNullOrWhiteSpace(code) || code.Length > 100)
  117. {
  118. continue;
  119. }
  120. var hash = ComputeHash(code);
  121. rowHashes[i] = hash;
  122. hashesInCsv.Add(hash);
  123. }
  124. var existingHashes = new HashSet<string>();
  125. foreach (var batch in hashesInCsv.Chunk(LookupBatchSize))
  126. {
  127. var found = await db.CouponCode.AsNoTracking()
  128. .Where(c => batch.Contains(c.CodeHash))
  129. .Select(c => c.CodeHash)
  130. .ToListAsync(ct);
  131. foreach (var h in found)
  132. {
  133. existingHashes.Add(h);
  134. }
  135. }
  136. var added = 0;
  137. var addedAvailable = 0;
  138. var addedUsed = 0;
  139. var skippedDuplicate = 0;
  140. var skippedInvalid = 0;
  141. var skippedMissingMember = 0;
  142. var batchAddedSinceSave = 0;
  143. var seenInCsv = new HashSet<string>();
  144. for (var i = 0; i < dataRows.Count; i++)
  145. {
  146. var row = dataRows[i];
  147. if (row.Length < ExpectedHeader.Length)
  148. {
  149. skippedInvalid++;
  150. continue;
  151. }
  152. var code = row[1].Trim();
  153. if (string.IsNullOrWhiteSpace(code) || code.Length > 100)
  154. {
  155. skippedInvalid++;
  156. continue;
  157. }
  158. var hash = rowHashes[i];
  159. if (hash is null)
  160. {
  161. skippedInvalid++;
  162. continue;
  163. }
  164. if (existingHashes.Contains(hash))
  165. {
  166. skippedDuplicate++;
  167. continue;
  168. }
  169. if (!seenInCsv.Add(hash))
  170. {
  171. skippedDuplicate++;
  172. continue;
  173. }
  174. var statusStr = row[2].Trim();
  175. if (statusStr != "0" && statusStr != "1")
  176. {
  177. skippedInvalid++;
  178. continue;
  179. }
  180. CouponCodeEntity entity;
  181. if (statusStr == "0")
  182. {
  183. entity = CouponCodeEntity.Create(request.CouponID, code);
  184. addedAvailable++;
  185. }
  186. else
  187. {
  188. if (!int.TryParse(row[3].Trim(), out var memberID) || memberID <= 0)
  189. {
  190. skippedInvalid++;
  191. continue;
  192. }
  193. if (!existingMemberIDs.Contains(memberID))
  194. {
  195. skippedMissingMember++;
  196. continue;
  197. }
  198. if (!TryParseLocalDateTime(row[6].Trim(), out var issuedAtLocal) ||
  199. !TryParseLocalDateTime(row[7].Trim(), out var usedAtLocal))
  200. {
  201. skippedInvalid++;
  202. continue;
  203. }
  204. var issuedAtUtc = DateTime.SpecifyKind(issuedAtLocal, DateTimeKind.Local).ToUniversalTime();
  205. var usedAtUtc = DateTime.SpecifyKind(usedAtLocal, DateTimeKind.Local).ToUniversalTime();
  206. if (issuedAtUtc > usedAtUtc)
  207. {
  208. skippedInvalid++;
  209. continue;
  210. }
  211. try
  212. {
  213. entity = CouponCodeEntity.CreateUsedForImport(request.CouponID, code, memberID, issuedAtUtc, usedAtUtc);
  214. }
  215. catch
  216. {
  217. skippedInvalid++;
  218. continue;
  219. }
  220. addedUsed++;
  221. }
  222. db.CouponCode.Add(entity);
  223. added++;
  224. batchAddedSinceSave++;
  225. if (batchAddedSinceSave >= BatchSize)
  226. {
  227. await db.SaveChangesAsync(ct);
  228. batchAddedSinceSave = 0;
  229. }
  230. }
  231. if (batchAddedSinceSave > 0)
  232. {
  233. await db.SaveChangesAsync(ct);
  234. }
  235. if (added > 0)
  236. {
  237. coupon.IncreaseGeneratedCount(added);
  238. if (addedUsed > 0)
  239. {
  240. coupon.IncreaseUsedCount(addedUsed);
  241. }
  242. await db.SaveChangesAsync(ct);
  243. }
  244. return Result.Success(new Response(
  245. TotalLines: totalLines,
  246. Added: added,
  247. AddedAvailable: addedAvailable,
  248. AddedUsed: addedUsed,
  249. SkippedDuplicate: skippedDuplicate,
  250. SkippedInvalid: skippedInvalid,
  251. SkippedMissingMember: skippedMissingMember
  252. ));
  253. }
  254. private static bool TryParseLocalDateTime(string input, out DateTime result)
  255. {
  256. var formats = new[]
  257. {
  258. "yyyy-MM-dd HH:mm:ss",
  259. "yyyy-MM-dd HH:mm",
  260. "yyyy-MM-ddTHH:mm:ss",
  261. "yyyy-MM-ddTHH:mm",
  262. "yyyy/MM/dd HH:mm:ss",
  263. "yyyy/MM/dd HH:mm"
  264. };
  265. if (DateTime.TryParseExact(input, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out result))
  266. {
  267. return true;
  268. }
  269. return DateTime.TryParse(input, CultureInfo.InvariantCulture, DateTimeStyles.None, out result);
  270. }
  271. private static string ComputeHash(string code)
  272. {
  273. var bytes = Encoding.UTF8.GetBytes(code);
  274. var hash = SHA256.HashData(bytes);
  275. return Convert.ToHexString(hash);
  276. }
  277. }