using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Api.Stocks.Watch.Toggle; /// /// 관심종목 토글 — 회원×종목코드가 있으면 해제, 없으면 등록. Stock.Code 활성 존재를 AnyAsync 로 사전 검증(FK 없이 denorm). (MemberID,StockCode) UNIQUE. /// public sealed class Handler(IAppDbContext db) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { var code = request.StockCode?.Trim() ?? ""; var existing = await db.StockWatch.FirstOrDefaultAsync(c => c.MemberID == request.MemberID && c.StockCode == code, ct); bool isWatched; if (existing is not null) { // 이미 관심 등록된 종목은 상장폐지(IsActive=false)돼도 항상 해제 가능해야 하므로 존재검증을 건너뛴다 db.StockWatch.Remove(existing); isWatched = false; } else { // 신규 등록만 종목 존재(활성) 검증 — FK 없이 denorm 이라 사전 AnyAsync var exists = await db.Stock.AsNoTracking().AnyAsync(c => c.Code == code && c.IsActive, ct); if (!exists) { return Result.Failure(Error.NotFound("StockWatch.StockNotFound", "종목을 찾을 수 없습니다.")); } var watch = Domain.Entities.Stocks.StockWatch.Create(request.MemberID, code); await db.StockWatch.AddAsync(watch, ct); isWatched = true; } try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) when (existing is not null) { // 동시 해제 경합(대상 행이 이미 삭제됨) — idempotent 성공 처리 return new Response(false); } catch (DbUpdateException) when (existing is null) { // 동시 등록 경합(UNIQUE 위반) — 다른 요청이 이미 등록 완료 — idempotent 성공 처리 return new Response(true); } return new Response(isWatched); } }