| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149 |
- using Application.Abstractions.Crypto;
- using Application.Abstractions.Data;
- using Application.Abstractions.Messaging;
- using Application.Abstractions.Messaging.Email;
- using Application.Abstractions.YouTube;
- using Application.Common;
- using Domain.Entities.EmailVerification.ValueObject;
- using Microsoft.Extensions.Logging;
- using SharedKernel.Results;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Features.Api.MyPage.Withdraw;
- internal sealed class Handler(
- IAppDbContext db,
- IMailService mailService,
- IGoogleOAuthService oauthService,
- IYouTubeChannelCache youTubeChannelCache,
- IFieldEncryptor fieldEncryptor,
- ILogger<Handler> logger
- ) : ICommandHandler<Command, Result>
- {
- public async Task<Result> Handle(Command request, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(request.Code))
- {
- return Result.Failure(Error.Problem("MyPage.CodeRequired", "인증번호를 입력해주세요."));
- }
- var member = await db.Member.FirstOrDefaultAsync(m => m.ID == request.MemberID, ct);
- if (member is null)
- {
- return Result.Failure(Error.NotFound("MyPage.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
- }
- if (member.IsWithdraw)
- {
- return Result.Failure(Error.Problem("MyPage.AlreadyWithdrawn", "이미 탈퇴한 회원입니다."));
- }
- // 유효한 인증번호 조회 (미인증, 만료 전, 가장 최근)
- var verifyNumber = await db.EmailVerifyNumber
- .Where(e => e.Email == member.Email
- && e.Type == VerificationType.Withdraw
- && !e.IsVerified
- && e.Expiration > DateTime.UtcNow)
- .OrderByDescending(e => e.CreatedAt)
- .FirstOrDefaultAsync(ct);
- if (verifyNumber is null)
- {
- return Result.Failure(Error.NotFound("MyPage.CodeNotFound", "인증번호가 올바르지 않거나 만료되었습니다."));
- }
- if (verifyNumber.Code != request.Code.Trim())
- {
- return Result.Failure(Error.Problem("MyPage.CodeMismatch", "인증번호가 일치하지 않습니다."));
- }
- // 인증 완료 처리
- verifyNumber.MarkVerified();
- // RefreshToken 무효화
- var refreshTokens = await db.RefreshToken.Where(t => t.MemberID == request.MemberID && !t.IsRevoked).ToListAsync(ct);
- foreach (var token in refreshTokens)
- {
- token.Revoke();
- }
- // 발송용 이메일 확보 (탈퇴 처리 전)
- var memberEmail = member.Email;
- // YouTube API Services ToS 준수 — 탈퇴 시 YouTube 출처 데이터 즉시 삭제
- // 1) OAuth refresh/access token revoke (Google /revoke 엔드포인트)
- // 2) MemberOAuthToken 행 삭제
- // 3) Channel.WipeYouTubeData() 로 채널 측 YT 필드 wipe
- // 4) Redis YouTubeChannel:{channelID} 캐시 invalidate
- var oauthTokens = await db.MemberOAuthToken.Where(t => t.MemberID == request.MemberID).ToListAsync(ct);
- var purgedChannelIds = new List<string>();
- foreach (var token in oauthTokens)
- {
- // refresh_token 우선, 없으면 access_token 으로 revoke (둘 다 무효화됨)
- var encrypted = token.RefreshTokenEnc ?? token.AccessTokenEnc;
- if (!string.IsNullOrEmpty(encrypted))
- {
- try
- {
- var plaintext = fieldEncryptor.Decrypt(encrypted, 1);
- if (!string.IsNullOrEmpty(plaintext))
- {
- await oauthService.RevokeTokenAsync(plaintext, ct);
- }
- }
- catch (Exception ex)
- {
- // revoke 실패해도 row 삭제는 진행 (best-effort)
- logger.LogWarning(ex, "[Withdraw] OAuth revoke 실패 (memberID={MemberID}) — row 삭제는 진행", request.MemberID);
- }
- }
- db.MemberOAuthToken.Remove(token);
- }
- var channels = await db.Channel.Where(c => c.MemberID == request.MemberID).ToListAsync(ct);
- foreach (var channel in channels)
- {
- if (!string.IsNullOrEmpty(channel.YouTubeChannelID))
- {
- purgedChannelIds.Add(channel.YouTubeChannelID);
- }
- channel.WipeYouTubeData();
- }
- // 회원 탈퇴 처리
- member.Withdraw();
- await db.SaveChangesAsync(ct);
- // Redis 캐시 invalidate (DB 커밋 후 — 트랜잭션 rollback 시 stale 방지)
- foreach (var channelID in purgedChannelIds)
- {
- try
- {
- await youTubeChannelCache.RemoveAsync(channelID);
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "[Withdraw] Redis YouTube 캐시 제거 실패 (channelID={ChannelID})", channelID);
- }
- }
- // 탈퇴 완료 안내 메일
- var emailTpl = (await db.Config.AsNoTracking().FirstOrDefaultAsync(ct))?.EmailTemplate;
- if (!string.IsNullOrWhiteSpace(emailTpl?.WithdrawEmailFormContent))
- {
- var subject = string.IsNullOrWhiteSpace(emailTpl.WithdrawEmailFormTitle)
- ? "회원탈퇴가 완료되었습니다"
- : emailTpl.WithdrawEmailFormTitle;
- var content = emailTpl.WithdrawEmailFormContent.Render(
- ("Email", memberEmail),
- ("Reason", "회원 본인 요청"),
- ("CreatedAt", DateTime.UtcNow.AddHours(9).ToString("yyyy-MM-dd HH:mm")));
- await mailService.SendAsync(new SendData(memberEmail, subject, content), ct);
- }
- return Result.Success();
- }
- }
|