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> { public async Task> Handle(Command request, CancellationToken ct) { // 유효성 검사 if (string.IsNullOrWhiteSpace(request.Email)) { return Result.Failure(Error.Problem("Auth.EmailRequired", "이메일은 필수입니다.")); } if (string.IsNullOrWhiteSpace(request.Password)) { return Result.Failure(Error.Problem("Auth.PasswordRequired", "비밀번호는 필수입니다.")); } // Config 로드 var accountConfig = await AccountConfigLoader.GetAccountConfigAsync(cache, db, ct); // 회원가입 차단 확인 if (accountConfig.IsRegisterBlock) { return Result.Failure(Error.Problem("Auth.RegisterBlocked", "현재 회원가입이 차단되어 있습니다.")); } // 비밀번호 복잡도 검증 var passwordResult = PasswordPolicyValidator.Validate(request.Password, accountConfig); if (!passwordResult.IsSuccess) { return Result.Failure(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(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(Error.Problem("Auth.DeniedEmail", "사용할 수 없는 이메일입니다.")); } else if (email == denied) { return Result.Failure(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(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) ? $"

회원가입 이메일 인증번호입니다.

{code}

이 코드는 5분간 유효합니다.

" : 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)); } }