KIM-JINO5 před 3 měsíci
rodič
revize
f89d006e92
46 změnil soubory, kde provedl 1696 přidání a 1 odebrání
  1. 4 0
      Application/Abstractions/Data/IAppDbContext.cs
  2. 5 1
      Application/Features/Api/Auth/Login/Handler.cs
  3. 9 0
      Application/Features/Api/Auth/Logout/Command.cs
  4. 38 0
      Application/Features/Api/Auth/Logout/Handler.cs
  5. 9 0
      Application/Features/Api/MyPage/ChangeEmail/Command.cs
  6. 94 0
      Application/Features/Api/MyPage/ChangeEmail/Handler.cs
  7. 9 0
      Application/Features/Api/MyPage/ChangeIntro/Command.cs
  8. 40 0
      Application/Features/Api/MyPage/ChangeIntro/Handler.cs
  9. 9 0
      Application/Features/Api/MyPage/ChangeName/Command.cs
  10. 46 0
      Application/Features/Api/MyPage/ChangeName/Handler.cs
  11. 11 0
      Application/Features/Api/MyPage/ChangePassword/Command.cs
  12. 49 0
      Application/Features/Api/MyPage/ChangePassword/Handler.cs
  13. 9 0
      Application/Features/Api/MyPage/ChangeSummary/Command.cs
  14. 40 0
      Application/Features/Api/MyPage/ChangeSummary/Handler.cs
  15. 8 0
      Application/Features/Api/MyPage/DeleteIntro/Command.cs
  16. 33 0
      Application/Features/Api/MyPage/DeleteIntro/Handler.cs
  17. 8 0
      Application/Features/Api/MyPage/DeleteName/Command.cs
  18. 33 0
      Application/Features/Api/MyPage/DeleteName/Handler.cs
  19. 8 0
      Application/Features/Api/MyPage/DeleteSummary/Command.cs
  20. 33 0
      Application/Features/Api/MyPage/DeleteSummary/Handler.cs
  21. 38 0
      Application/Features/Api/MyPage/GetLoginLogs/Handler.cs
  22. 10 0
      Application/Features/Api/MyPage/GetLoginLogs/Query.cs
  23. 18 0
      Application/Features/Api/MyPage/GetLoginLogs/Response.cs
  24. 11 0
      Application/Features/Api/MyPage/UpdateReceiveSettings/Command.cs
  25. 42 0
      Application/Features/Api/MyPage/UpdateReceiveSettings/Handler.cs
  26. 48 0
      Application/Features/Api/MyPage/VerifyEmail/Handler.cs
  27. 8 0
      Application/Features/Api/MyPage/VerifyEmail/Query.cs
  28. 9 0
      Application/Features/Api/MyPage/Withdraw/Command.cs
  29. 41 0
      Application/Features/Api/MyPage/Withdraw/Handler.cs
  30. 44 0
      Domain/Entities/Members/Member.cs
  31. 4 0
      Infrastructure/Persistence/AppDbContext.cs
  32. 42 0
      Web.Api/Endpoints/Auth/Logout.cs
  33. 42 0
      Web.Api/Endpoints/MyPage/ChangeEmail.cs
  34. 42 0
      Web.Api/Endpoints/MyPage/ChangeIntro.cs
  35. 42 0
      Web.Api/Endpoints/MyPage/ChangeName.cs
  36. 48 0
      Web.Api/Endpoints/MyPage/ChangePassword.cs
  37. 42 0
      Web.Api/Endpoints/MyPage/ChangeSummary.cs
  38. 35 0
      Web.Api/Endpoints/MyPage/DeleteIntro.cs
  39. 35 0
      Web.Api/Endpoints/MyPage/DeleteName.cs
  40. 35 0
      Web.Api/Endpoints/MyPage/DeleteSummary.cs
  41. 42 0
      Web.Api/Endpoints/MyPage/GetLoginLogs.cs
  42. 48 0
      Web.Api/Endpoints/MyPage/UpdateReceiveSettings.cs
  43. 27 0
      Web.Api/Endpoints/MyPage/VerifyEmail.cs
  44. 42 0
      Web.Api/Endpoints/MyPage/Withdraw.cs
  45. 406 0
      bitforum-mypage-postman.json
  46. binární
      tree.txt

+ 4 - 0
Application/Abstractions/Data/IAppDbContext.cs

@@ -11,6 +11,7 @@ using Domain.Entities.Forum.Boards;
 using Domain.Entities.Forum.Posts;
 using Domain.Entities.Forum.Comments;
 using Domain.Entities.Director;
+using Domain.Entities.EmailVerification;
 using Domain.Entities.Forum.Logs;
 using Domain.Entities.Page.Popup;
 
@@ -51,6 +52,9 @@ namespace Application.Abstractions.Data
         DbSet<Channel> Channel { get; set; }
         DbSet<RefreshToken> RefreshToken { get; set; }
 
+        // 이메일 인증
+        DbSet<EmailVerifyToken> EmailVerifyToken { get; set; }
+
         // 지갑
         DbSet<Wallet> Wallet { get; set; }
         DbSet<WalletTransaction> WalletTransaction { get; set; }

+ 5 - 1
Application/Features/Api/Auth/Login/Handler.cs

@@ -4,6 +4,7 @@ using Application.Abstractions.Authentication;
 using Application.Abstractions.Data;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Logging;
 using MediatR;
 
 namespace Application.Features.Api.Auth.Login;
@@ -11,7 +12,8 @@ namespace Application.Features.Api.Auth.Login;
 internal sealed class Handler(
     IAppDbContext db,
     IJwtTokenProvider jwtTokenProvider,
-    IOptions<AppSettings> options
+    IOptions<AppSettings> options,
+    ILogger<Handler> logger
 ) : IRequestHandler<Command, Result<Response>> {
 
     private readonly AppSettings.JwtSection _jwt = options.Value.JWT;
@@ -67,6 +69,8 @@ internal sealed class Handler(
 
         await db.SaveChangesAsync(ct);
 
+        logger.LogInformation("{0} 로그인", member.Email);
+
         return Result.Success(new Response(accessToken, refreshToken, expiresAt));
     }
 }

+ 9 - 0
Application/Features/Api/Auth/Logout/Command.cs

@@ -0,0 +1,9 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.Logout;
+
+public sealed record Command(
+    int MemberID,
+    string RefreshToken
+) : ICommand<Result>;

+ 38 - 0
Application/Features/Api/Auth/Logout/Handler.cs

@@ -0,0 +1,38 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.Auth.Logout;
+
+internal sealed class Handler(IAppDbContext db, ILogger<Handler> logger) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(request.RefreshToken))
+        {
+            return Result.Failure(Error.Problem("Auth.TokenRequired", "리프레시 토큰은 필수입니다."));
+        }
+
+        var refreshToken = await db.RefreshToken.FirstOrDefaultAsync(t => t.Token == request.RefreshToken && t.MemberID == request.MemberID, ct);
+
+        if (refreshToken is null)
+        {
+            return Result.Failure(Error.NotFound("Auth.TokenNotFound", "유효하지 않은 리프레시 토큰입니다."));
+        }
+
+        if (refreshToken.IsRevoked)
+        {
+            return Result.Success();
+        }
+
+        refreshToken.Revoke();
+
+        await db.SaveChangesAsync(ct);
+
+        logger.LogInformation("로그아웃 완료");
+
+        return Result.Success();
+    }
+}

+ 9 - 0
Application/Features/Api/MyPage/ChangeEmail/Command.cs

@@ -0,0 +1,9 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.ChangeEmail;
+
+public sealed record Command(
+    int MemberID,
+    string NewEmail
+) : ICommand<Result>;

+ 94 - 0
Application/Features/Api/MyPage/ChangeEmail/Handler.cs

@@ -0,0 +1,94 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Messaging.Email;
+using Domain.Entities.EmailVerification;
+using Domain.Entities.EmailVerification.ValueObject;
+using SharedKernel;
+using SharedKernel.Results;
+using Domain.Entities.Members.Logs;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using System.Security.Cryptography;
+
+namespace Application.Features.Api.MyPage.ChangeEmail;
+
+internal sealed class Handler(
+    IAppDbContext db,
+    IMailService mailService,
+    IOptions<AppSettings> options
+) : ICommandHandler<Command, Result> {
+
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        var newEmail = request.NewEmail?.Trim().ToLower();
+
+        if (string.IsNullOrWhiteSpace(newEmail))
+        {
+            return Result.Failure(Error.Problem("MyPage.EmailRequired", "이메일은 필수입니다."));
+        }
+
+        if (newEmail.Length > 60)
+        {
+            return Result.Failure(Error.Problem("MyPage.EmailTooLong", "이메일은 60자 이하여야 합니다."));
+        }
+
+        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.Email == newEmail)
+        {
+            return Result.Failure(Error.Problem("MyPage.SameEmail", "현재 이메일과 동일합니다."));
+        }
+
+        var emailExists = await db.Member.AnyAsync(m => m.Email == newEmail, ct);
+        if (emailExists)
+        {
+            return Result.Failure(Error.Conflict("MyPage.EmailExists", "이미 사용 중인 이메일입니다."));
+        }
+
+        // 인증 토큰 생성
+        var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
+        var expiration = DateTime.UtcNow.AddHours(24);
+
+        var verifyToken = EmailVerifyToken.Create(
+            VerificationType.ChangedEmail,
+            newEmail,
+            token,
+            expiration,
+            new AdditionalData { Email = member.Email }
+        );
+
+        await db.EmailVerifyToken.AddAsync(verifyToken, ct);
+
+        // 변경 로그 기록
+        var log = new MemberEmailChangeLog(
+            request.MemberID,
+            member.Email,
+            newEmail,
+            null,
+            null,
+            null
+        );
+        await db.MemberEmailChangeLog.AddAsync(log, ct);
+
+        // 이메일 변경 처리
+        member.SetEmail(newEmail);
+
+        await db.SaveChangesAsync(ct);
+
+        // 인증 메일 발송
+        var frontURL = options.Value.App.FrontURL.TrimEnd('/');
+        var verifyUrl = $"{frontURL}/auth/verify-email?token={Uri.EscapeDataString(token)}";
+
+        await mailService.SendAsync(new SendData(
+            newEmail,
+            "[bitforum] 이메일 인증",
+            $"<p>아래 링크를 클릭하여 이메일 인증을 완료해주세요.</p><p><a href=\"{verifyUrl}\">{verifyUrl}</a></p><p>이 링크는 24시간 동안 유효합니다.</p>"
+        ), ct);
+
+        return Result.Success();
+    }
+}

+ 9 - 0
Application/Features/Api/MyPage/ChangeIntro/Command.cs

@@ -0,0 +1,9 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.ChangeIntro;
+
+public sealed record Command(
+    int MemberID,
+    string Intro
+) : ICommand<Result>;

+ 40 - 0
Application/Features/Api/MyPage/ChangeIntro/Handler.cs

@@ -0,0 +1,40 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Members.Logs;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.ChangeIntro;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        var intro = request.Intro?.Trim();
+
+        if (string.IsNullOrWhiteSpace(intro))
+        {
+            return Result.Failure(Error.Problem("MyPage.IntroRequired", "자기소개는 필수입니다."));
+        }
+
+        if (intro.Length > 3000)
+        {
+            return Result.Failure(Error.Problem("MyPage.IntroTooLong", "자기소개는 3000자 이하여야 합니다."));
+        }
+
+        var member = await db.Member.FirstOrDefaultAsync(m => m.ID == request.MemberID, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("MyPage.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        var log = MemberIntroChangeLog.Create(request.MemberID, member.Intro, intro);
+        await db.MemberIntroChangeLog.AddAsync(log, ct);
+
+        member.SetIntro(intro);
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 9 - 0
Application/Features/Api/MyPage/ChangeName/Command.cs

@@ -0,0 +1,9 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.ChangeName;
+
+public sealed record Command(
+    int MemberID,
+    string Name
+) : ICommand<Result>;

+ 46 - 0
Application/Features/Api/MyPage/ChangeName/Handler.cs

@@ -0,0 +1,46 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Members.Logs;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.ChangeName;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        var name = request.Name?.Trim();
+
+        if (string.IsNullOrWhiteSpace(name))
+        {
+            return Result.Failure(Error.Problem("MyPage.NameRequired", "별명은 필수입니다."));
+        }
+
+        if (name.Length > 40)
+        {
+            return Result.Failure(Error.Problem("MyPage.NameTooLong", "별명은 40자 이하여야 합니다."));
+        }
+
+        var member = await db.Member.FirstOrDefaultAsync(m => m.ID == request.MemberID, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("MyPage.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        var nameExists = await db.Member.AnyAsync(m => m.Name == name && m.ID != request.MemberID, ct);
+        if (nameExists)
+        {
+            return Result.Failure(Error.Conflict("MyPage.NameExists", "이미 사용 중인 별명입니다."));
+        }
+
+        var log = MemberNameChangeLog.Create(request.MemberID, member.Name, name);
+        await db.MemberNameChangeLog.AddAsync(log, ct);
+
+        member.SetName(name);
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 11 - 0
Application/Features/Api/MyPage/ChangePassword/Command.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.ChangePassword;
+
+public sealed record Command(
+    int MemberID,
+    string CurrentPassword,
+    string NewPassword,
+    string ConfirmPassword
+) : ICommand<Result>;

+ 49 - 0
Application/Features/Api/MyPage/ChangePassword/Handler.cs

@@ -0,0 +1,49 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.ChangePassword;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(request.CurrentPassword))
+        {
+            return Result.Failure(Error.Problem("MyPage.CurrentPasswordRequired", "현재 비밀번호는 필수입니다."));
+        }
+
+        if (string.IsNullOrWhiteSpace(request.NewPassword))
+        {
+            return Result.Failure(Error.Problem("MyPage.NewPasswordRequired", "새 비밀번호는 필수입니다."));
+        }
+
+        if (request.NewPassword.Length < 6)
+        {
+            return Result.Failure(Error.Problem("MyPage.PasswordTooShort", "비밀번호는 6자 이상이어야 합니다."));
+        }
+
+        if (request.NewPassword != request.ConfirmPassword)
+        {
+            return Result.Failure(Error.Problem("MyPage.PasswordMismatch", "새 비밀번호가 일치하지 않습니다."));
+        }
+
+        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.VerifyPassword(request.CurrentPassword))
+        {
+            return Result.Failure(Error.Problem("MyPage.InvalidPassword", "현재 비밀번호가 올바르지 않습니다."));
+        }
+
+        member.SetPassword(request.NewPassword);
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 9 - 0
Application/Features/Api/MyPage/ChangeSummary/Command.cs

@@ -0,0 +1,9 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.ChangeSummary;
+
+public sealed record Command(
+    int MemberID,
+    string Summary
+) : ICommand<Result>;

+ 40 - 0
Application/Features/Api/MyPage/ChangeSummary/Handler.cs

@@ -0,0 +1,40 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Members.Logs;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.ChangeSummary;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        var summary = request.Summary?.Trim();
+
+        if (string.IsNullOrWhiteSpace(summary))
+        {
+            return Result.Failure(Error.Problem("MyPage.SummaryRequired", "한마디는 필수입니다."));
+        }
+
+        if (summary.Length > 50)
+        {
+            return Result.Failure(Error.Problem("MyPage.SummaryTooLong", "한마디는 50자 이하여야 합니다."));
+        }
+
+        var member = await db.Member.FirstOrDefaultAsync(m => m.ID == request.MemberID, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("MyPage.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        var log = MemberSummaryChangeLog.Create(request.MemberID, member.Summary, summary);
+        await db.MemberSummaryChangeLog.AddAsync(log, ct);
+
+        member.SetSummary(summary);
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 8 - 0
Application/Features/Api/MyPage/DeleteIntro/Command.cs

@@ -0,0 +1,8 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.DeleteIntro;
+
+public sealed record Command(
+    int MemberID
+) : ICommand<Result>;

+ 33 - 0
Application/Features/Api/MyPage/DeleteIntro/Handler.cs

@@ -0,0 +1,33 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Members.Logs;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.DeleteIntro;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        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.Intro is null)
+        {
+            return Result.Failure(Error.Problem("MyPage.IntroEmpty", "자기소개가 설정되어 있지 않습니다."));
+        }
+
+        var log = MemberIntroChangeLog.Create(request.MemberID, member.Intro, null);
+        await db.MemberIntroChangeLog.AddAsync(log, ct);
+
+        member.SetIntro(null);
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 8 - 0
Application/Features/Api/MyPage/DeleteName/Command.cs

@@ -0,0 +1,8 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.DeleteName;
+
+public sealed record Command(
+    int MemberID
+) : ICommand<Result>;

+ 33 - 0
Application/Features/Api/MyPage/DeleteName/Handler.cs

@@ -0,0 +1,33 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Members.Logs;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.DeleteName;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        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.Name is null)
+        {
+            return Result.Failure(Error.Problem("MyPage.NameEmpty", "별명이 설정되어 있지 않습니다."));
+        }
+
+        var log = MemberNameChangeLog.Create(request.MemberID, member.Name, null);
+        await db.MemberNameChangeLog.AddAsync(log, ct);
+
+        member.SetName(null);
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 8 - 0
Application/Features/Api/MyPage/DeleteSummary/Command.cs

@@ -0,0 +1,8 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.DeleteSummary;
+
+public sealed record Command(
+    int MemberID
+) : ICommand<Result>;

+ 33 - 0
Application/Features/Api/MyPage/DeleteSummary/Handler.cs

@@ -0,0 +1,33 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.Members.Logs;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.DeleteSummary;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        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.Summary is null)
+        {
+            return Result.Failure(Error.Problem("MyPage.SummaryEmpty", "한마디가 설정되어 있지 않습니다."));
+        }
+
+        var log = MemberSummaryChangeLog.Create(request.MemberID, member.Summary, null);
+        await db.MemberSummaryChangeLog.AddAsync(log, ct);
+
+        member.SetSummary(null);
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 38 - 0
Application/Features/Api/MyPage/GetLoginLogs/Handler.cs

@@ -0,0 +1,38 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.GetLoginLogs;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Response>>
+{
+    public async Task<Result<Response>> Handle(Query request, CancellationToken ct)
+    {
+        var page = request.Page < 1 ? 1 : request.Page;
+        var pageSize = request.PageSize is < 1 or > 50 ? 20 : request.PageSize;
+
+        var query = db.MemberLoginLog
+            .AsNoTracking()
+            .Where(l => l.MemberID == request.MemberID)
+            .OrderByDescending(l => l.CreatedAt);
+
+        var totalCount = await query.CountAsync(ct);
+
+        var items = await query
+            .Skip((page - 1) * pageSize)
+            .Take(pageSize)
+            .Select(l => new LoginLogItem(
+                l.ID,
+                l.Success,
+                l.Account,
+                l.Reason,
+                l.IpAddress,
+                l.UserAgent,
+                l.CreatedAt
+            ))
+            .ToListAsync(ct);
+
+        return Result.Success(new Response(items, totalCount, page, pageSize));
+    }
+}

+ 10 - 0
Application/Features/Api/MyPage/GetLoginLogs/Query.cs

@@ -0,0 +1,10 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.GetLoginLogs;
+
+public sealed record Query(
+    int MemberID,
+    int Page,
+    int PageSize
+) : IQuery<Result<Response>>;

+ 18 - 0
Application/Features/Api/MyPage/GetLoginLogs/Response.cs

@@ -0,0 +1,18 @@
+namespace Application.Features.Api.MyPage.GetLoginLogs;
+
+public sealed record Response(
+    List<LoginLogItem> Items,
+    int TotalCount,
+    int Page,
+    int PageSize
+);
+
+public sealed record LoginLogItem(
+    int ID,
+    bool Success,
+    string Account,
+    string? Reason,
+    string? IpAddress,
+    string? UserAgent,
+    DateTime CreatedAt
+);

+ 11 - 0
Application/Features/Api/MyPage/UpdateReceiveSettings/Command.cs

@@ -0,0 +1,11 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.UpdateReceiveSettings;
+
+public sealed record Command(
+    int MemberID,
+    bool IsReceiveSMS,
+    bool IsReceiveEmail,
+    bool IsReceiveNote
+) : ICommand<Result>;

+ 42 - 0
Application/Features/Api/MyPage/UpdateReceiveSettings/Handler.cs

@@ -0,0 +1,42 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.UpdateReceiveSettings;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        var approve = await db.MemberApprove.FirstOrDefaultAsync(a => a.MemberID == request.MemberID, ct);
+        if (approve is null)
+        {
+            return Result.Failure(Error.NotFound("MyPage.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        var now = DateTime.UtcNow;
+
+        if (approve.IsReceiveSMS != request.IsReceiveSMS)
+        {
+            approve.IsReceiveSMS = request.IsReceiveSMS;
+            approve.ReceiveSMSConsentAt = now;
+        }
+
+        if (approve.IsReceiveEmail != request.IsReceiveEmail)
+        {
+            approve.IsReceiveEmail = request.IsReceiveEmail;
+            approve.ReceiveEmailConsentAt = now;
+        }
+
+        if (approve.IsReceiveNote != request.IsReceiveNote)
+        {
+            approve.IsReceiveNote = request.IsReceiveNote;
+            approve.ReceiveNoteConsentAt = now;
+        }
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 48 - 0
Application/Features/Api/MyPage/VerifyEmail/Handler.cs

@@ -0,0 +1,48 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Domain.Entities.EmailVerification.ValueObject;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.VerifyEmail;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result>
+{
+    public async Task<Result> Handle(Query request, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(request.Token))
+        {
+            return Result.Failure(Error.Problem("MyPage.TokenRequired", "인증 토큰은 필수입니다."));
+        }
+
+        var verifyToken = await db.EmailVerifyToken.FirstOrDefaultAsync(t => t.Token == request.Token && t.Type == VerificationType.ChangedEmail, ct);
+
+        if (verifyToken is null)
+        {
+            return Result.Failure(Error.NotFound("MyPage.TokenNotFound", "유효하지 않은 인증 토큰입니다."));
+        }
+
+        if (verifyToken.IsVerified)
+        {
+            return Result.Failure(Error.Problem("MyPage.AlreadyVerified", "이미 인증된 토큰입니다."));
+        }
+
+        if (verifyToken.Expiration < DateTime.UtcNow)
+        {
+            return Result.Failure(Error.Problem("MyPage.TokenExpired", "인증 토큰이 만료되었습니다."));
+        }
+
+        var member = await db.Member.FirstOrDefaultAsync(m => m.Email == verifyToken.Email, ct);
+        if (member is null)
+        {
+            return Result.Failure(Error.NotFound("MyPage.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
+        }
+
+        verifyToken.MarkVerified();
+        member.MarkEmailVerified();
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 8 - 0
Application/Features/Api/MyPage/VerifyEmail/Query.cs

@@ -0,0 +1,8 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.VerifyEmail;
+
+public sealed record Query(
+    string Token
+) : IQuery<Result>;

+ 9 - 0
Application/Features/Api/MyPage/Withdraw/Command.cs

@@ -0,0 +1,9 @@
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+
+namespace Application.Features.Api.MyPage.Withdraw;
+
+public sealed record Command(
+    int MemberID,
+    string Password
+) : ICommand<Result>;

+ 41 - 0
Application/Features/Api/MyPage/Withdraw/Handler.cs

@@ -0,0 +1,41 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using SharedKernel.Results;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Api.MyPage.Withdraw;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result>
+{
+    public async Task<Result> Handle(Command request, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(request.Password))
+        {
+            return Result.Failure(Error.Problem("MyPage.PasswordRequired", "비밀번호는 필수입니다."));
+        }
+
+        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.VerifyPassword(request.Password))
+        {
+            return Result.Failure(Error.Problem("MyPage.InvalidPassword", "비밀번호가 올바르지 않습니다."));
+        }
+
+        // RefreshToken 무효화
+        var refreshTokens = await db.RefreshToken.Where(t => t.MemberID == request.MemberID && !t.IsRevoked).ToListAsync(ct);
+        foreach (var token in refreshTokens)
+        {
+            token.Revoke();
+        }
+
+        member.Withdraw();
+
+        await db.SaveChangesAsync(ct);
+
+        return Result.Success();
+    }
+}

+ 44 - 0
Domain/Entities/Members/Member.cs

@@ -155,6 +155,49 @@ namespace Domain.Entities.Members
             return VerifyHashedPassword(PasswordHash, password);
         }
 
+        public void SetEmail(string email)
+        {
+            Email = email;
+            IsEmailVerified = false;
+            LastEmailChangedAt = DateTime.UtcNow;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
+        public void SetName(string? name)
+        {
+            Name = name;
+            LastNameChangedAt = DateTime.UtcNow;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
+        public void SetSummary(string? summary)
+        {
+            Summary = summary;
+            LastSummaryChangedAt = DateTime.UtcNow;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
+        public void SetIntro(string? intro)
+        {
+            Intro = intro;
+            LastIntroChangedAt = DateTime.UtcNow;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
+        public void MarkEmailVerified()
+        {
+            IsEmailVerified = true;
+            EmailVerifiedAt = DateTime.UtcNow;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
+        public void Withdraw()
+        {
+            IsWithdraw = true;
+            DeletedAt = DateTime.UtcNow;
+            UpdatedAt = DateTime.UtcNow;
+        }
+
         // PBKDF2 (RFC 2898) - 순수 C# 비밀번호 해싱
         private static string HashPassword(string password)
         {
@@ -175,6 +218,7 @@ namespace Domain.Entities.Members
             return Convert.ToBase64String(combined);
         }
 
+        // 비밀번호 검증
         private static bool VerifyHashedPassword(string hashedPassword, string password)
         {
             var combined = Convert.FromBase64String(hashedPassword);

+ 4 - 0
Infrastructure/Persistence/AppDbContext.cs

@@ -11,6 +11,7 @@ using Domain.Entities.Forum.Boards;
 using Domain.Entities.Forum.Posts;
 using Domain.Entities.Forum.Comments;
 using Domain.Entities.Director;
+using Domain.Entities.EmailVerification;
 using Domain.Entities.Forum.Logs;
 using Domain.Entities.Page.Popup;
 using Microsoft.EntityFrameworkCore;
@@ -56,6 +57,9 @@ namespace Infrastructure.Persistence
         public DbSet<Channel> Channel { get; set; }
         public DbSet<RefreshToken> RefreshToken { get; set; }
 
+        // EmailVerification
+        public DbSet<EmailVerifyToken> EmailVerifyToken { get; set; }
+
         // Wallet
         public DbSet<Wallet> Wallet { get; set; }
         public DbSet<WalletTransaction> WalletTransaction { get; set; }

+ 42 - 0
Web.Api/Endpoints/Auth/Logout.cs

@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.Auth;
+
+internal sealed class Logout : IEndpoint
+{
+    public sealed record Request(string RefreshToken);
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/auth/logout", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.Auth.Logout.Command(
+                memberID.Value,
+                request.RefreshToken
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("Auth")
+        .RequireAuthorization();
+    }
+}

+ 42 - 0
Web.Api/Endpoints/MyPage/ChangeEmail.cs

@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class ChangeEmail : IEndpoint
+{
+    public sealed record Request(string NewEmail);
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/mypage/email", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.ChangeEmail.Command(
+                memberID.Value,
+                request.NewEmail
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 42 - 0
Web.Api/Endpoints/MyPage/ChangeIntro.cs

@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class ChangeIntro : IEndpoint
+{
+    public sealed record Request(string Intro);
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/mypage/intro", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.ChangeIntro.Command(
+                memberID.Value,
+                request.Intro
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 42 - 0
Web.Api/Endpoints/MyPage/ChangeName.cs

@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class ChangeName : IEndpoint
+{
+    public sealed record Request(string Name);
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/mypage/name", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.ChangeName.Command(
+                memberID.Value,
+                request.Name
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 48 - 0
Web.Api/Endpoints/MyPage/ChangePassword.cs

@@ -0,0 +1,48 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class ChangePassword : IEndpoint
+{
+    public sealed record Request(
+        string CurrentPassword,
+        string NewPassword,
+        string ConfirmPassword
+    );
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/mypage/password", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.ChangePassword.Command(
+                memberID.Value,
+                request.CurrentPassword,
+                request.NewPassword,
+                request.ConfirmPassword
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 42 - 0
Web.Api/Endpoints/MyPage/ChangeSummary.cs

@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class ChangeSummary : IEndpoint
+{
+    public sealed record Request(string Summary);
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/mypage/summary", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.ChangeSummary.Command(
+                memberID.Value,
+                request.Summary
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 35 - 0
Web.Api/Endpoints/MyPage/DeleteIntro.cs

@@ -0,0 +1,35 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class DeleteIntro : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapDelete("api/mypage/intro", async (
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.DeleteIntro.Command(memberID.Value);
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 35 - 0
Web.Api/Endpoints/MyPage/DeleteName.cs

@@ -0,0 +1,35 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class DeleteName : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapDelete("api/mypage/name", async (
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.DeleteName.Command(memberID.Value);
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 35 - 0
Web.Api/Endpoints/MyPage/DeleteSummary.cs

@@ -0,0 +1,35 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class DeleteSummary : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapDelete("api/mypage/summary", async (
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.DeleteSummary.Command(memberID.Value);
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 42 - 0
Web.Api/Endpoints/MyPage/GetLoginLogs.cs

@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class GetLoginLogs : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapGet("api/mypage/login-logs", async (
+            int? page,
+            int? pageSize,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var query = new Application.Features.Api.MyPage.GetLoginLogs.Query(
+                memberID.Value,
+                page ?? 1,
+                pageSize ?? 20
+            );
+
+            var result = await sender.Send(query, ct);
+
+            return result.Match(
+                data => ApiResponse.Ok(data),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 48 - 0
Web.Api/Endpoints/MyPage/UpdateReceiveSettings.cs

@@ -0,0 +1,48 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class UpdateReceiveSettings : IEndpoint
+{
+    public sealed record Request(
+        bool IsReceiveSMS,
+        bool IsReceiveEmail,
+        bool IsReceiveNote
+    );
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/mypage/receive-settings", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.UpdateReceiveSettings.Command(
+                memberID.Value,
+                request.IsReceiveSMS,
+                request.IsReceiveEmail,
+                request.IsReceiveNote
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 27 - 0
Web.Api/Endpoints/MyPage/VerifyEmail.cs

@@ -0,0 +1,27 @@
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class VerifyEmail : IEndpoint
+{
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapGet("api/mypage/email/verify", async (
+            string token,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var query = new Application.Features.Api.MyPage.VerifyEmail.Query(token);
+            var result = await sender.Send(query, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .AllowAnonymous();
+    }
+}

+ 42 - 0
Web.Api/Endpoints/MyPage/Withdraw.cs

@@ -0,0 +1,42 @@
+using System.Security.Claims;
+using MediatR;
+using Web.Api.Common;
+using Web.Api.Extensions;
+
+namespace Web.Api.Endpoints.MyPage;
+
+internal sealed class Withdraw : IEndpoint
+{
+    public sealed record Request(string Password);
+
+    public void MapEndpoint(IEndpointRouteBuilder app)
+    {
+        app.MapPost("api/mypage/withdraw", async (
+            Request request,
+            ClaimsPrincipal user,
+            ISender sender,
+            CancellationToken ct
+        ) => {
+            var memberID = user.GetMemberID();
+
+            if (memberID is null)
+            {
+                return ApiResponse.Fail(StatusCodes.Status401Unauthorized, "Invalid token");
+            }
+
+            var command = new Application.Features.Api.MyPage.Withdraw.Command(
+                memberID.Value,
+                request.Password
+            );
+
+            var result = await sender.Send(command, ct);
+
+            return result.Match(
+                () => ApiResponse.Ok(),
+                CustomResults.Problem
+            );
+        })
+        .WithTags("MyPage")
+        .RequireAuthorization();
+    }
+}

+ 406 - 0
bitforum-mypage-postman.json

@@ -0,0 +1,406 @@
+{
+	"info": {
+		"_postman_id": "mypage-api-collection",
+		"name": "bitForum - MyPage API",
+		"description": "bitForum 마이페이지 API 컬렉션\n\n로그인 후 Bearer Token을 설정하여 사용하세요.",
+		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+	},
+	"variable": [
+		{
+			"key": "baseUrl",
+			"value": "https://localhost:4000",
+			"type": "string"
+		},
+		{
+			"key": "accessToken",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "refreshToken",
+			"value": "",
+			"type": "string"
+		}
+	],
+	"auth": {
+		"type": "bearer",
+		"bearer": [
+			{
+				"key": "token",
+				"value": "{{accessToken}}",
+				"type": "string"
+			}
+		]
+	},
+	"item": [
+		{
+			"name": "🔐 Auth",
+			"item": [
+				{
+					"name": "[POST] 로그인",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"exec": [
+									"var json = pm.response.json();",
+									"if (json.success && json.data) {",
+									"    pm.collectionVariables.set('accessToken', json.data.accessToken);",
+									"    pm.collectionVariables.set('refreshToken', json.data.refreshToken);",
+									"    console.log('AccessToken, RefreshToken 저장 완료');",
+									"}"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"auth": {
+							"type": "noauth"
+						},
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"email\": \"test@bitforum.io\",\n    \"password\": \"password123\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/auth/login",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "auth", "login"]
+						},
+						"description": "로그인 후 accessToken, refreshToken이 자동으로 컬렉션 변수에 저장됩니다."
+					},
+					"response": []
+				},
+				{
+					"name": "[POST] 로그아웃",
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"exec": [
+									"var json = pm.response.json();",
+									"if (json.success) {",
+									"    pm.collectionVariables.set('accessToken', '');",
+									"    pm.collectionVariables.set('refreshToken', '');",
+									"    console.log('토큰 초기화 완료');",
+									"}"
+								],
+								"type": "text/javascript"
+							}
+						}
+					],
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"refreshToken\": \"{{refreshToken}}\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/auth/logout",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "auth", "logout"]
+						},
+						"description": "로그아웃. RefreshToken을 서버에서 폐기합니다.\n\n- Bearer 토큰 필요\n- 성공 시 컬렉션 변수의 토큰이 자동 초기화됩니다.\n- 이미 폐기된 토큰이면 그냥 성공 반환"
+					},
+					"response": []
+				}
+			]
+		},
+		{
+			"name": "📧 이메일",
+			"item": [
+				{
+					"name": "[POST] 이메일 변경",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"newEmail\": \"newemail@bitforum.io\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/email",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "email"]
+						},
+						"description": "이메일 변경 요청. 새 이메일로 인증 메일이 발송됩니다.\n\n- 중복 이메일 불가\n- 현재 이메일과 동일하면 에러\n- 변경 즉시 이메일이 바뀌고, IsEmailVerified가 false로 변경"
+					},
+					"response": []
+				},
+				{
+					"name": "[GET] 이메일 인증",
+					"request": {
+						"auth": {
+							"type": "noauth"
+						},
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/email/verify?token=YOUR_TOKEN_HERE",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "email", "verify"],
+							"query": [
+								{
+									"key": "token",
+									"value": "YOUR_TOKEN_HERE",
+									"description": "이메일 인증 토큰 (이메일 변경 시 발송된 링크에 포함)"
+								}
+							]
+						},
+						"description": "이메일 인증 토큰으로 이메일 인증 완료.\n\n- 인증 불필요 (Anonymous)\n- 토큰 만료: 24시간\n- 이미 인증된 토큰은 재사용 불가"
+					},
+					"response": []
+				}
+			]
+		},
+		{
+			"name": "👤 별명",
+			"item": [
+				{
+					"name": "[POST] 별명 변경",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"name\": \"새별명\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/name",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "name"]
+						},
+						"description": "별명 변경.\n\n- 최대 40자\n- 중복 별명 불가\n- 변경 로그 기록됨"
+					},
+					"response": []
+				},
+				{
+					"name": "[DELETE] 별명 삭제",
+					"request": {
+						"method": "DELETE",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/name",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "name"]
+						},
+						"description": "별명 삭제 (null로 초기화).\n\n- 이미 비어있으면 에러\n- 삭제 로그 기록됨"
+					},
+					"response": []
+				}
+			]
+		},
+		{
+			"name": "💬 한마디",
+			"item": [
+				{
+					"name": "[POST] 한마디 변경",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"summary\": \"안녕하세요! 비트포럼입니다.\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/summary",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "summary"]
+						},
+						"description": "한마디(상태 메시지) 변경.\n\n- 최대 50자\n- 변경 로그 기록됨"
+					},
+					"response": []
+				},
+				{
+					"name": "[DELETE] 한마디 삭제",
+					"request": {
+						"method": "DELETE",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/summary",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "summary"]
+						},
+						"description": "한마디 삭제 (null로 초기화).\n\n- 이미 비어있으면 에러\n- 삭제 로그 기록됨"
+					},
+					"response": []
+				}
+			]
+		},
+		{
+			"name": "📝 자기소개",
+			"item": [
+				{
+					"name": "[POST] 자기소개 변경",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"intro\": \"비트코인과 블록체인에 관심이 많은 개발자입니다.\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/intro",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "intro"]
+						},
+						"description": "자기소개 변경.\n\n- 최대 3000자\n- 변경 로그 기록됨"
+					},
+					"response": []
+				},
+				{
+					"name": "[DELETE] 자기소개 삭제",
+					"request": {
+						"method": "DELETE",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/intro",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "intro"]
+						},
+						"description": "자기소개 삭제 (null로 초기화).\n\n- 이미 비어있으면 에러\n- 삭제 로그 기록됨"
+					},
+					"response": []
+				}
+			]
+		},
+		{
+			"name": "🔔 수신설정",
+			"item": [
+				{
+					"name": "[POST] 수신설정 변경",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"isReceiveSMS\": true,\n    \"isReceiveEmail\": true,\n    \"isReceiveNote\": false\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/receive-settings",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "receive-settings"]
+						},
+						"description": "SMS/이메일/쪽지 수신 설정 변경.\n\n- 변경된 항목만 동의 시간이 업데이트됩니다."
+					},
+					"response": []
+				}
+			]
+		},
+		{
+			"name": "🔒 보안",
+			"item": [
+				{
+					"name": "[POST] 비밀번호 변경",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"currentPassword\": \"password123\",\n    \"newPassword\": \"newpassword456\",\n    \"confirmPassword\": \"newpassword456\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/password",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "password"]
+						},
+						"description": "비밀번호 변경.\n\n- 현재 비밀번호 검증 필수\n- 새 비밀번호 6자 이상\n- newPassword와 confirmPassword 일치 필요"
+					},
+					"response": []
+				},
+				{
+					"name": "[POST] 회원탈퇴",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n    \"password\": \"password123\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/withdraw",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "withdraw"]
+						},
+						"description": "회원 탈퇴.\n\n- 비밀번호 검증 필수\n- 모든 RefreshToken 무효화\n- IsWithdraw = true, DeletedAt 설정 (소프트 삭제)"
+					},
+					"response": []
+				},
+				{
+					"name": "[GET] 로그인 기록",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/mypage/login-logs?page=1&pageSize=20",
+							"host": ["{{baseUrl}}"],
+							"path": ["api", "mypage", "login-logs"],
+							"query": [
+								{
+									"key": "page",
+									"value": "1",
+									"description": "페이지 번호 (기본값: 1)"
+								},
+								{
+									"key": "pageSize",
+									"value": "20",
+									"description": "페이지 크기 (기본값: 20, 최대: 50)"
+								}
+							]
+						},
+						"description": "로그인 기록 조회.\n\n- 최신순 정렬\n- 페이지네이션 지원 (page, pageSize)\n- 응답: items[], totalCount, page, pageSize"
+					},
+					"response": []
+				}
+			]
+		}
+	]
+}

binární
tree.txt