Handler.cs 2.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Microsoft.EntityFrameworkCore;
  4. using SharedKernel.Results;
  5. namespace Application.Features.Api.Stocks.Watch.Toggle;
  6. /// <summary>
  7. /// 관심종목 토글 — 회원×종목코드가 있으면 해제, 없으면 등록. Stock.Code 활성 존재를 AnyAsync 로 사전 검증(FK 없이 denorm). (MemberID,StockCode) UNIQUE.
  8. /// </summary>
  9. public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
  10. {
  11. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  12. {
  13. var code = request.StockCode?.Trim() ?? "";
  14. var existing = await db.StockWatch.FirstOrDefaultAsync(c => c.MemberID == request.MemberID && c.StockCode == code, ct);
  15. bool isWatched;
  16. if (existing is not null)
  17. {
  18. // 이미 관심 등록된 종목은 상장폐지(IsActive=false)돼도 항상 해제 가능해야 하므로 존재검증을 건너뛴다
  19. db.StockWatch.Remove(existing);
  20. isWatched = false;
  21. }
  22. else
  23. {
  24. // 신규 등록만 종목 존재(활성) 검증 — FK 없이 denorm 이라 사전 AnyAsync
  25. var exists = await db.Stock.AsNoTracking().AnyAsync(c => c.Code == code && c.IsActive, ct);
  26. if (!exists)
  27. {
  28. return Result.Failure<Response>(Error.NotFound("StockWatch.StockNotFound", "종목을 찾을 수 없습니다."));
  29. }
  30. var watch = Domain.Entities.Stocks.StockWatch.Create(request.MemberID, code);
  31. await db.StockWatch.AddAsync(watch, ct);
  32. isWatched = true;
  33. }
  34. try
  35. {
  36. await db.SaveChangesAsync(ct);
  37. }
  38. catch (DbUpdateConcurrencyException) when (existing is not null)
  39. {
  40. // 동시 해제 경합(대상 행이 이미 삭제됨) — idempotent 성공 처리
  41. return new Response(false);
  42. }
  43. catch (DbUpdateException) when (existing is null)
  44. {
  45. // 동시 등록 경합(UNIQUE 위반) — 다른 요청이 이미 등록 완료 — idempotent 성공 처리
  46. return new Response(true);
  47. }
  48. return new Response(isWatched);
  49. }
  50. }