Handler.cs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. using Application.Abstractions.Crypto;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Messaging;
  4. using Application.Abstractions.Messaging.Email;
  5. using Application.Abstractions.YouTube;
  6. using Application.Common;
  7. using Domain.Entities.EmailVerification.ValueObject;
  8. using Microsoft.Extensions.Logging;
  9. using SharedKernel.Results;
  10. using Microsoft.EntityFrameworkCore;
  11. namespace Application.Features.Api.MyPage.Withdraw;
  12. internal sealed class Handler(
  13. IAppDbContext db,
  14. IMailService mailService,
  15. IGoogleOAuthService oauthService,
  16. IYouTubeChannelCache youTubeChannelCache,
  17. IFieldEncryptor fieldEncryptor,
  18. ILogger<Handler> logger
  19. ) : ICommandHandler<Command, Result>
  20. {
  21. public async Task<Result> Handle(Command request, CancellationToken ct)
  22. {
  23. if (string.IsNullOrWhiteSpace(request.Code))
  24. {
  25. return Result.Failure(Error.Problem("MyPage.CodeRequired", "인증번호를 입력해주세요."));
  26. }
  27. var member = await db.Member.FirstOrDefaultAsync(m => m.ID == request.MemberID, ct);
  28. if (member is null)
  29. {
  30. return Result.Failure(Error.NotFound("MyPage.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
  31. }
  32. if (member.IsWithdraw)
  33. {
  34. return Result.Failure(Error.Problem("MyPage.AlreadyWithdrawn", "이미 탈퇴한 회원입니다."));
  35. }
  36. // 유효한 인증번호 조회 (미인증, 만료 전, 가장 최근)
  37. var verifyNumber = await db.EmailVerifyNumber
  38. .Where(e => e.Email == member.Email
  39. && e.Type == VerificationType.Withdraw
  40. && !e.IsVerified
  41. && e.Expiration > DateTime.UtcNow)
  42. .OrderByDescending(e => e.CreatedAt)
  43. .FirstOrDefaultAsync(ct);
  44. if (verifyNumber is null)
  45. {
  46. return Result.Failure(Error.NotFound("MyPage.CodeNotFound", "인증번호가 올바르지 않거나 만료되었습니다."));
  47. }
  48. if (verifyNumber.Code != request.Code.Trim())
  49. {
  50. return Result.Failure(Error.Problem("MyPage.CodeMismatch", "인증번호가 일치하지 않습니다."));
  51. }
  52. // 인증 완료 처리
  53. verifyNumber.MarkVerified();
  54. // RefreshToken 무효화
  55. var refreshTokens = await db.RefreshToken.Where(t => t.MemberID == request.MemberID && !t.IsRevoked).ToListAsync(ct);
  56. foreach (var token in refreshTokens)
  57. {
  58. token.Revoke();
  59. }
  60. // 발송용 이메일 확보 (탈퇴 처리 전)
  61. var memberEmail = member.Email;
  62. // YouTube API Services ToS 준수 — 탈퇴 시 YouTube 출처 데이터 즉시 삭제
  63. // 1) OAuth refresh/access token revoke (Google /revoke 엔드포인트)
  64. // 2) MemberOAuthToken 행 삭제
  65. // 3) Channel.WipeYouTubeData() 로 채널 측 YT 필드 wipe
  66. // 4) Redis YouTubeChannel:{channelID} 캐시 invalidate
  67. var oauthTokens = await db.MemberOAuthToken.Where(t => t.MemberID == request.MemberID).ToListAsync(ct);
  68. var purgedChannelIds = new List<string>();
  69. foreach (var token in oauthTokens)
  70. {
  71. // refresh_token 우선, 없으면 access_token 으로 revoke (둘 다 무효화됨)
  72. var encrypted = token.RefreshTokenEnc ?? token.AccessTokenEnc;
  73. if (!string.IsNullOrEmpty(encrypted))
  74. {
  75. try
  76. {
  77. var plaintext = fieldEncryptor.Decrypt(encrypted, 1);
  78. if (!string.IsNullOrEmpty(plaintext))
  79. {
  80. await oauthService.RevokeTokenAsync(plaintext, ct);
  81. }
  82. }
  83. catch (Exception ex)
  84. {
  85. // revoke 실패해도 row 삭제는 진행 (best-effort)
  86. logger.LogWarning(ex, "[Withdraw] OAuth revoke 실패 (memberID={MemberID}) — row 삭제는 진행", request.MemberID);
  87. }
  88. }
  89. db.MemberOAuthToken.Remove(token);
  90. }
  91. var channels = await db.Channel.Where(c => c.MemberID == request.MemberID).ToListAsync(ct);
  92. foreach (var channel in channels)
  93. {
  94. if (!string.IsNullOrEmpty(channel.YouTubeChannelID))
  95. {
  96. purgedChannelIds.Add(channel.YouTubeChannelID);
  97. }
  98. channel.WipeYouTubeData();
  99. }
  100. // 회원 탈퇴 처리
  101. member.Withdraw();
  102. await db.SaveChangesAsync(ct);
  103. // Redis 캐시 invalidate (DB 커밋 후 — 트랜잭션 rollback 시 stale 방지)
  104. foreach (var channelID in purgedChannelIds)
  105. {
  106. try
  107. {
  108. await youTubeChannelCache.RemoveAsync(channelID);
  109. }
  110. catch (Exception ex)
  111. {
  112. logger.LogWarning(ex, "[Withdraw] Redis YouTube 캐시 제거 실패 (channelID={ChannelID})", channelID);
  113. }
  114. }
  115. // 탈퇴 완료 안내 메일
  116. var emailTpl = (await db.Config.AsNoTracking().FirstOrDefaultAsync(ct))?.EmailTemplate;
  117. if (!string.IsNullOrWhiteSpace(emailTpl?.WithdrawEmailFormContent))
  118. {
  119. var subject = string.IsNullOrWhiteSpace(emailTpl.WithdrawEmailFormTitle)
  120. ? "회원탈퇴가 완료되었습니다"
  121. : emailTpl.WithdrawEmailFormTitle;
  122. var content = emailTpl.WithdrawEmailFormContent.Render(
  123. ("Email", memberEmail),
  124. ("Reason", "회원 본인 요청"),
  125. ("CreatedAt", DateTime.UtcNow.AddHours(9).ToString("yyyy-MM-dd HH:mm")));
  126. await mailService.SendAsync(new SendData(memberEmail, subject, content), ct);
  127. }
  128. return Result.Success();
  129. }
  130. }