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 logger ) : ICommandHandler { public async Task 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(); 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(); } }