| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- using SharedKernel.Results;
- using Application.Abstractions.Data;
- using Application.Abstractions.Cache;
- using Application.Abstractions.Messaging;
- using Application.Abstractions.Messaging.Email;
- using Application.Common;
- using Application.Helpers;
- using Domain.Entities.EmailVerification;
- using Domain.Entities.EmailVerification.ValueObject;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Features.Api.Auth.Register;
- internal sealed class Handler(
- IAppDbContext db,
- ICacheService cache,
- IMailService mailService
- ) : ICommandHandler<Command, Result<Response>> {
- public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
- {
- // 유효성 검사
- if (string.IsNullOrWhiteSpace(request.Email))
- {
- return Result.Failure<Response>(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다."));
- }
- if (string.IsNullOrWhiteSpace(request.Password))
- {
- return Result.Failure<Response>(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다."));
- }
- // Config 로드
- var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct);
- // 회원가입 차단 확인
- if (accountConfig.IsRegisterBlock)
- {
- return Result.Failure<Response>(Error.Problem("Auth.RegisterBlocked", "현재 회원가입이 차단되어 있습니다."));
- }
- // 비밀번호 복잡도 검증
- var passwordResult = PasswordPolicyValidator.Validate(request.Password, accountConfig);
- if (!passwordResult.IsSuccess)
- {
- return Result.Failure<Response>(passwordResult.Error);
- }
- // 이메일 중복 체크
- // 이미 인증을 완료한 회원만 진짜 중복으로 차단.
- // 미인증 상태(IsEmailVerified=false)는 회원가입 form 으로 다시 진입해 인증을 재시도할 수 있도록 허용.
- var email = request.Email.Trim().ToLower();
- var existingMember = await db.Member.FirstOrDefaultAsync(m => m.Email == email, ct);
- if (existingMember is not null && existingMember.IsEmailVerified)
- {
- return Result.Failure<Response>(Error.Conflict("Auth.EmailExists", "이미 사용 중인 이메일입니다."));
- }
- // 금지 이메일 체크
- if (!string.IsNullOrWhiteSpace(accountConfig.DeniedEmailList))
- {
- var deniedEmails = accountConfig.DeniedEmailList
- .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
- .Select(e => e.ToLower());
- foreach (var denied in deniedEmails)
- {
- // 도메인 매칭 (@domain.com) 또는 전체 이메일 매칭
- if (denied.StartsWith('@') && email.EndsWith(denied))
- {
- return Result.Failure<Response>(Error.Problem("Auth.DeniedEmail", "사용할 수 없는 이메일입니다."));
- }
- else if (email == denied)
- {
- return Result.Failure<Response>(Error.Problem("Auth.DeniedEmail", "사용할 수 없는 이메일입니다."));
- }
- }
- }
- // 금지 별명 체크
- if (!string.IsNullOrWhiteSpace(request.Name) && !string.IsNullOrWhiteSpace(accountConfig.DeniedNameList))
- {
- var name = request.Name.Trim().ToLower();
- var deniedNames = accountConfig.DeniedNameList
- .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
- .Select(n => n.ToLower());
- if (deniedNames.Contains(name))
- {
- return Result.Failure<Response>(Error.Problem("Auth.DeniedName", "사용할 수 없는 별명입니다."));
- }
- }
- // 신규 회원 생성 또는 미인증 회원 재활용
- Domain.Entities.Members.Member member;
- if (existingMember is null)
- {
- // Member 생성 (비밀번호 해싱 포함)
- member = Domain.Entities.Members.Member.Create(email, request.Password);
- await db.Member.AddAsync(member, ct);
- await db.SaveChangesAsync(ct);
- // Member ID 확보 후 연관 엔티티 생성
- await db.MemberApprove.AddAsync(
- Domain.Entities.Members.MemberApprove.Create(member.ID), ct
- );
- await db.MemberStats.AddAsync(
- Domain.Entities.Members.MemberStats.Create(member.ID), ct
- );
- await db.Wallet.AddAsync(
- Domain.Entities.Wallets.Wallet.Create(member.ID), ct
- );
- await db.SaveChangesAsync(ct);
- // 가입 축하 보상 (멱등 — RefID "signup:{memberID}")
- await SignupRewardGranter.GrantAsync(db, cache, member.ID, ct);
- await db.SaveChangesAsync(ct);
- }
- else
- {
- // 미인증 상태의 기존 회원: 비밀번호를 새로 입력한 값으로 갱신 후 인증 단계로 다시 안내.
- // 계정이 아직 사용자 소유로 확정되지 않았으므로(이메일 인증 미완료) 비밀번호 덮어쓰기 허용.
- member = existingMember;
- member.SetPassword(request.Password);
- await db.SaveChangesAsync(ct);
- }
- // 이메일 인증이 필요한 경우 인증번호 발송
- if (accountConfig.IsRegisterEmailAuth)
- {
- // 기존 미인증 코드 삭제
- var existing = await db.EmailVerifyNumber
- .Where(e => e.Email == email && e.Type == VerificationType.Registration && !e.IsVerified)
- .ToListAsync(ct);
- db.EmailVerifyNumber.RemoveRange(existing);
- // 6자리 랜덤 숫자 코드 생성
- var code = Random.Shared.Next(100000, 999999).ToString();
- var verifyNumber = EmailVerifyNumber.Create(
- VerificationType.Registration,
- email,
- code,
- DateTime.UtcNow.AddMinutes(5)
- );
- await db.EmailVerifyNumber.AddAsync(verifyNumber, ct);
- await db.SaveChangesAsync(ct);
- // 인증번호 이메일 발송
- var emailTpl = await AccountConfigLoader.GetEmailTemplateAsync(cache, db, ct);
- var subject = string.IsNullOrWhiteSpace(emailTpl.RegisterEmailFormTitle)
- ? "회원가입 이메일 인증번호"
- : emailTpl.RegisterEmailFormTitle;
- var content = string.IsNullOrWhiteSpace(emailTpl.RegisterEmailFormContent)
- ? $"<p>회원가입 이메일 인증번호입니다.</p><p><strong>{code}</strong></p><p>이 코드는 5분간 유효합니다.</p>"
- : emailTpl.RegisterEmailFormContent.Render(("Code", code), ("Email", email));
- await mailService.SendAsync(new SendData(email, subject, content), ct);
- }
- return Result.Success(new Response(member.ID, accountConfig.IsRegisterEmailAuth));
- }
- }
|