Handler.cs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. using SharedKernel.Results;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Cache;
  4. using Application.Abstractions.Messaging;
  5. using Application.Abstractions.Messaging.Email;
  6. using Application.Common;
  7. using Application.Helpers;
  8. using Domain.Entities.EmailVerification;
  9. using Domain.Entities.EmailVerification.ValueObject;
  10. using Microsoft.EntityFrameworkCore;
  11. namespace Application.Features.Api.Auth.Register;
  12. internal sealed class Handler(
  13. IAppDbContext db,
  14. ICacheService cache,
  15. IMailService mailService
  16. ) : ICommandHandler<Command, Result<Response>> {
  17. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  18. {
  19. // 유효성 검사
  20. if (string.IsNullOrWhiteSpace(request.Email))
  21. {
  22. return Result.Failure<Response>(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
  23. }
  24. if (string.IsNullOrWhiteSpace(request.Password))
  25. {
  26. return Result.Failure<Response>(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다."));
  27. }
  28. // Config 로드
  29. var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
  30. // 회원가입 차단 확인
  31. if (accountConfig.IsRegisterBlock)
  32. {
  33. return Result.Failure<Response>(Error.Problem("Auth.RegisterBlocked", "현재 회원가입이 차단되어 있습니다."));
  34. }
  35. // 비밀번호 복잡도 검증
  36. var passwordResult = PasswordPolicyValidator.Validate(request.Password, accountConfig);
  37. if (!passwordResult.IsSuccess)
  38. {
  39. return Result.Failure<Response>(passwordResult.Error);
  40. }
  41. // 이메일 중복 체크
  42. // 이미 인증을 완료한 회원만 진짜 중복으로 차단.
  43. // 미인증 상태(IsEmailVerified=false)는 회원가입 form 으로 다시 진입해 인증을 재시도할 수 있도록 허용.
  44. var email = request.Email.Trim().ToLower();
  45. var existingMember = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
  46. if (existingMember is not null && existingMember.IsEmailVerified)
  47. {
  48. return Result.Failure<Response>(Error.Conflict("Auth.EmailExists", "이미 사용 중인 이메일입니다."));
  49. }
  50. // 금지 이메일 체크
  51. if (!string.IsNullOrWhiteSpace(accountConfig.DeniedEmailList))
  52. {
  53. var deniedEmails = accountConfig.DeniedEmailList
  54. .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  55. .Select(e => e.ToLower());
  56. foreach (var denied in deniedEmails)
  57. {
  58. // 도메인 매칭 (@domain.com) 또는 전체 이메일 매칭
  59. if (denied.StartsWith('@') && email.EndsWith(denied))
  60. {
  61. return Result.Failure<Response>(Error.Problem("Auth.DeniedEmail", "사용할 수 없는 이메일입니다."));
  62. }
  63. else if (email == denied)
  64. {
  65. return Result.Failure<Response>(Error.Problem("Auth.DeniedEmail", "사용할 수 없는 이메일입니다."));
  66. }
  67. }
  68. }
  69. // 금지 별명 체크
  70. if (!string.IsNullOrWhiteSpace(request.Name) && !string.IsNullOrWhiteSpace(accountConfig.DeniedNameList))
  71. {
  72. var name = request.Name.Trim().ToLower();
  73. var deniedNames = accountConfig.DeniedNameList
  74. .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  75. .Select(n => n.ToLower());
  76. if (deniedNames.Contains(name))
  77. {
  78. return Result.Failure<Response>(Error.Problem("Auth.DeniedName", "사용할 수 없는 별명입니다."));
  79. }
  80. }
  81. // 신규 회원 생성 또는 미인증 회원 재활용
  82. Domain.Entities.Members.Member member;
  83. if (existingMember is null)
  84. {
  85. // Member 생성 (비밀번호 해싱 포함)
  86. member = Domain.Entities.Members.Member.Create(email, request.Password);
  87. await db.Member.AddAsync(member, ct);
  88. await db.SaveChangesAsync(ct);
  89. // Member ID 확보 후 연관 엔티티 생성
  90. await db.MemberApprove.AddAsync(
  91. Domain.Entities.Members.MemberApprove.Create(member.ID), ct
  92. );
  93. await db.MemberStats.AddAsync(
  94. Domain.Entities.Members.MemberStats.Create(member.ID), ct
  95. );
  96. await db.Wallet.AddAsync(
  97. Domain.Entities.Wallets.Wallet.Create(member.ID), ct
  98. );
  99. await db.SaveChangesAsync(ct);
  100. }
  101. else
  102. {
  103. // 미인증 상태의 기존 회원: 비밀번호를 새로 입력한 값으로 갱신 후 인증 단계로 다시 안내.
  104. // 계정이 아직 사용자 소유로 확정되지 않았으므로(이메일 인증 미완료) 비밀번호 덮어쓰기 허용.
  105. member = existingMember;
  106. member.SetPassword(request.Password);
  107. await db.SaveChangesAsync(ct);
  108. }
  109. // 이메일 인증이 필요한 경우 인증번호 발송
  110. if (accountConfig.IsRegisterEmailAuth)
  111. {
  112. // 기존 미인증 코드 삭제
  113. var existing = await db.EmailVerifyNumber
  114. .Where(e => e.Email == email && e.Type == VerificationType.Registration && !e.IsVerified)
  115. .ToListAsync(ct);
  116. db.EmailVerifyNumber.RemoveRange(existing);
  117. // 6자리 랜덤 숫자 코드 생성
  118. var code = Random.Shared.Next(100000, 999999).ToString();
  119. var verifyNumber = EmailVerifyNumber.Create(
  120. VerificationType.Registration,
  121. email,
  122. code,
  123. DateTime.UtcNow.AddMinutes(5)
  124. );
  125. await db.EmailVerifyNumber.AddAsync(verifyNumber, ct);
  126. await db.SaveChangesAsync(ct);
  127. // 인증번호 이메일 발송
  128. var emailTpl = await AccountConfigLoader.GetEmailTemplateAsync(cache, db, ct);
  129. var subject = string.IsNullOrWhiteSpace(emailTpl.RegisterEmailFormTitle)
  130. ? "회원가입 이메일 인증번호"
  131. : emailTpl.RegisterEmailFormTitle;
  132. var content = string.IsNullOrWhiteSpace(emailTpl.RegisterEmailFormContent)
  133. ? $"<p>회원가입 이메일 인증번호입니다.</p><p><strong>{code}</strong></p><p>이 코드는 5분간 유효합니다.</p>"
  134. : emailTpl.RegisterEmailFormContent.Render(("Code", code), ("Email", email));
  135. await mailService.SendAsync(new SendData(email, subject, content), ct);
  136. }
  137. return Result.Success(new Response(member.ID, accountConfig.IsRegisterEmailAuth));
  138. }
  139. }