diff --git a/SPMS.API/Controllers/AccountController.cs b/SPMS.API/Controllers/AccountController.cs new file mode 100644 index 0000000..8048045 --- /dev/null +++ b/SPMS.API/Controllers/AccountController.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Account; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/account")] +[ApiExplorerSettings(GroupName = "account")] +[Authorize(Roles = "Super")] +public class AccountController : ControllerBase +{ + private readonly IAccountService _accountService; + + public AccountController(IAccountService accountService) + { + _accountService = accountService; + } + + [HttpPost("create")] + [SwaggerOperation( + Summary = "운영자 계정 생성", + Description = "Super Admin이 새로운 운영자(Manager/User) 계정을 생성합니다.")] + [SwaggerResponse(200, "생성 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(409, "이메일 중복")] + public async Task CreateAsync([FromBody] CreateAccountRequestDto request) + { + var result = await _accountService.CreateAsync(request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("list")] + [SwaggerOperation( + Summary = "운영자 목록 조회", + Description = "Super Admin이 운영자(Manager/User) 목록을 조회합니다. Super Admin은 목록에 포함되지 않습니다.")] + [SwaggerResponse(200, "조회 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + public async Task GetListAsync([FromBody] AccountListRequestDto request) + { + var result = await _accountService.GetListAsync(request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("{adminCode}")] + [SwaggerOperation( + Summary = "운영자 상세 조회", + Description = "Super Admin이 특정 운영자의 상세 정보를 조회합니다.")] + [SwaggerResponse(200, "조회 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "운영자를 찾을 수 없음")] + public async Task GetByAdminCodeAsync([FromRoute] string adminCode) + { + var result = await _accountService.GetByAdminCodeAsync(adminCode); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("{adminCode}/update")] + [SwaggerOperation( + Summary = "운영자 정보 수정", + Description = "Super Admin이 운영자의 정보(이름, 전화번호, 권한)를 수정합니다.")] + [SwaggerResponse(200, "수정 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "운영자를 찾을 수 없음")] + public async Task UpdateAsync( + [FromRoute] string adminCode, + [FromBody] UpdateAccountRequestDto request) + { + var result = await _accountService.UpdateAsync(adminCode, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("{adminCode}/delete")] + [SwaggerOperation( + Summary = "운영자 계정 삭제", + Description = "Super Admin이 운영자 계정을 삭제합니다. (Soft Delete)")] + [SwaggerResponse(200, "삭제 성공")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "운영자를 찾을 수 없음")] + public async Task DeleteAsync([FromRoute] string adminCode) + { + await _accountService.DeleteAsync(adminCode); + return Ok(ApiResponse.Success()); + } +} diff --git a/SPMS.Application/DTOs/Account/AccountListRequestDto.cs b/SPMS.Application/DTOs/Account/AccountListRequestDto.cs new file mode 100644 index 0000000..bdfec83 --- /dev/null +++ b/SPMS.Application/DTOs/Account/AccountListRequestDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Account; + +public class AccountListRequestDto +{ + [Range(1, int.MaxValue, ErrorMessage = "페이지 번호는 1 이상이어야 합니다.")] + public int Page { get; set; } = 1; + + [Range(1, 100, ErrorMessage = "페이지 크기는 1~100 사이여야 합니다.")] + public int PageSize { get; set; } = 20; + + public string? SearchKeyword { get; set; } + public int? Role { get; set; } +} diff --git a/SPMS.Application/DTOs/Account/AccountListResponseDto.cs b/SPMS.Application/DTOs/Account/AccountListResponseDto.cs new file mode 100644 index 0000000..9df085f --- /dev/null +++ b/SPMS.Application/DTOs/Account/AccountListResponseDto.cs @@ -0,0 +1,10 @@ +namespace SPMS.Application.DTOs.Account; + +public class AccountListResponseDto +{ + public List Items { get; set; } = new(); + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize); +} diff --git a/SPMS.Application/DTOs/Account/AccountResponseDto.cs b/SPMS.Application/DTOs/Account/AccountResponseDto.cs new file mode 100644 index 0000000..a5a6174 --- /dev/null +++ b/SPMS.Application/DTOs/Account/AccountResponseDto.cs @@ -0,0 +1,13 @@ +namespace SPMS.Application.DTOs.Account; + +public class AccountResponseDto +{ + public string AdminCode { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Phone { get; set; } + public string Role { get; set; } = string.Empty; + public bool EmailVerified { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastLoginAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Account/CreateAccountRequestDto.cs b/SPMS.Application/DTOs/Account/CreateAccountRequestDto.cs new file mode 100644 index 0000000..37e2c97 --- /dev/null +++ b/SPMS.Application/DTOs/Account/CreateAccountRequestDto.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Account; + +public class CreateAccountRequestDto +{ + [Required(ErrorMessage = "이메일을 입력해주세요.")] + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "비밀번호를 입력해주세요.")] + [MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")] + public string Password { get; set; } = string.Empty; + + [Required(ErrorMessage = "이름을 입력해주세요.")] + public string Name { get; set; } = string.Empty; + + [Phone(ErrorMessage = "올바른 전화번호 형식이 아닙니다.")] + public string? Phone { get; set; } + + [Required(ErrorMessage = "권한을 선택해주세요.")] + [Range(1, 2, ErrorMessage = "권한은 Manager(1) 또는 User(2)만 가능합니다.")] + public int Role { get; set; } +} diff --git a/SPMS.Application/DTOs/Account/UpdateAccountRequestDto.cs b/SPMS.Application/DTOs/Account/UpdateAccountRequestDto.cs new file mode 100644 index 0000000..f832c6d --- /dev/null +++ b/SPMS.Application/DTOs/Account/UpdateAccountRequestDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Account; + +public class UpdateAccountRequestDto +{ + [Required(ErrorMessage = "이름을 입력해주세요.")] + public string Name { get; set; } = string.Empty; + + [Phone(ErrorMessage = "올바른 전화번호 형식이 아닙니다.")] + public string? Phone { get; set; } + + [Required(ErrorMessage = "권한을 선택해주세요.")] + [Range(1, 2, ErrorMessage = "권한은 Manager(1) 또는 User(2)만 가능합니다.")] + public int Role { get; set; } +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index 639b82e..d3113d0 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -10,6 +10,7 @@ public static class DependencyInjection { // Application Services services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/IAccountService.cs b/SPMS.Application/Interfaces/IAccountService.cs new file mode 100644 index 0000000..f62b34d --- /dev/null +++ b/SPMS.Application/Interfaces/IAccountService.cs @@ -0,0 +1,12 @@ +using SPMS.Application.DTOs.Account; + +namespace SPMS.Application.Interfaces; + +public interface IAccountService +{ + Task CreateAsync(CreateAccountRequestDto request); + Task GetListAsync(AccountListRequestDto request); + Task GetByAdminCodeAsync(string adminCode); + Task UpdateAsync(string adminCode, UpdateAccountRequestDto request); + Task DeleteAsync(string adminCode); +} diff --git a/SPMS.Application/SPMS.Application.csproj b/SPMS.Application/SPMS.Application.csproj index 3c9c3be..4030c29 100644 --- a/SPMS.Application/SPMS.Application.csproj +++ b/SPMS.Application/SPMS.Application.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + b8d7099d-890d-4aaf-b758-0df8b5b339f8 diff --git a/SPMS.Application/Services/AccountService.cs b/SPMS.Application/Services/AccountService.cs new file mode 100644 index 0000000..fb5c206 --- /dev/null +++ b/SPMS.Application/Services/AccountService.cs @@ -0,0 +1,195 @@ +using System.Linq.Expressions; +using SPMS.Application.DTOs.Account; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Exceptions; +using SPMS.Domain.Interfaces; + +namespace SPMS.Application.Services; + +public class AccountService : IAccountService +{ + private readonly IAdminRepository _adminRepository; + private readonly IUnitOfWork _unitOfWork; + + public AccountService(IAdminRepository adminRepository, IUnitOfWork unitOfWork) + { + _adminRepository = adminRepository; + _unitOfWork = unitOfWork; + } + + public async Task CreateAsync(CreateAccountRequestDto request) + { + // 1. 이메일 중복 검사 + if (await _adminRepository.EmailExistsAsync(request.Email)) + { + throw new SpmsException( + ErrorCodes.Conflict, + "이미 사용 중인 이메일입니다.", + 409); + } + + // 2. AdminCode 생성 (UUID) + var adminCode = Guid.NewGuid().ToString("N")[..12].ToUpper(); + + // 3. Admin 엔티티 생성 + var admin = new Admin + { + AdminCode = adminCode, + Email = request.Email, + Password = BCrypt.Net.BCrypt.HashPassword(request.Password), + Name = request.Name, + Phone = request.Phone ?? string.Empty, + Role = (AdminRole)request.Role, + EmailVerified = false, + CreatedAt = DateTime.UtcNow, + IsDeleted = false + }; + + // 4. 저장 + await _adminRepository.AddAsync(admin); + await _unitOfWork.SaveChangesAsync(); + + // 5. 응답 반환 + return MapToDto(admin); + } + + public async Task GetListAsync(AccountListRequestDto request) + { + // 필터 조건 생성 (Super Admin 제외) + Expression> predicate = a => a.Role != AdminRole.Super; + + // 검색어 필터 + if (!string.IsNullOrWhiteSpace(request.SearchKeyword)) + { + var keyword = request.SearchKeyword; + predicate = a => a.Role != AdminRole.Super && + (a.Name.Contains(keyword) || a.Email.Contains(keyword)); + } + + // Role 필터 + if (request.Role.HasValue) + { + var role = (AdminRole)request.Role.Value; + var basePredicate = predicate; + predicate = a => basePredicate.Compile()(a) && a.Role == role; + } + + // 페이징 조회 + var (items, totalCount) = await _adminRepository.GetPagedAsync( + request.Page, + request.PageSize, + predicate, + a => a.CreatedAt, + descending: true); + + return new AccountListResponseDto + { + Items = items.Select(MapToDto).ToList(), + TotalCount = totalCount, + Page = request.Page, + PageSize = request.PageSize + }; + } + + public async Task GetByAdminCodeAsync(string adminCode) + { + var admin = await _adminRepository.GetByAdminCodeAsync(adminCode); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "운영자를 찾을 수 없습니다.", + 404); + } + + // Super Admin 조회 불가 + if (admin.Role == AdminRole.Super) + { + throw new SpmsException( + ErrorCodes.NotFound, + "운영자를 찾을 수 없습니다.", + 404); + } + + return MapToDto(admin); + } + + public async Task UpdateAsync(string adminCode, UpdateAccountRequestDto request) + { + var admin = await _adminRepository.GetByAdminCodeAsync(adminCode); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "운영자를 찾을 수 없습니다.", + 404); + } + + // Super Admin 수정 불가 + if (admin.Role == AdminRole.Super) + { + throw new SpmsException( + ErrorCodes.Forbidden, + "Super Admin은 수정할 수 없습니다.", + 403); + } + + // 업데이트 + admin.Name = request.Name; + admin.Phone = request.Phone ?? string.Empty; + admin.Role = (AdminRole)request.Role; + + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + + return MapToDto(admin); + } + + public async Task DeleteAsync(string adminCode) + { + var admin = await _adminRepository.GetByAdminCodeAsync(adminCode); + if (admin is null) + { + throw new SpmsException( + ErrorCodes.NotFound, + "운영자를 찾을 수 없습니다.", + 404); + } + + // Super Admin 삭제 불가 + if (admin.Role == AdminRole.Super) + { + throw new SpmsException( + ErrorCodes.Forbidden, + "Super Admin은 삭제할 수 없습니다.", + 403); + } + + // Soft Delete + admin.IsDeleted = true; + admin.DeletedAt = DateTime.UtcNow; + admin.RefreshToken = null; + admin.RefreshTokenExpiresAt = null; + + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + } + + private static AccountResponseDto MapToDto(Admin admin) + { + return new AccountResponseDto + { + AdminCode = admin.AdminCode, + Email = admin.Email, + Name = admin.Name, + Phone = string.IsNullOrEmpty(admin.Phone) ? null : admin.Phone, + Role = admin.Role.ToString(), + EmailVerified = admin.EmailVerified, + CreatedAt = admin.CreatedAt, + LastLoginAt = admin.LastLoginAt + }; + } +} diff --git a/SPMS.Domain/Common/ErrorCodes.cs b/SPMS.Domain/Common/ErrorCodes.cs index 0194968..7bc7034 100644 --- a/SPMS.Domain/Common/ErrorCodes.cs +++ b/SPMS.Domain/Common/ErrorCodes.cs @@ -17,6 +17,7 @@ public static class ErrorCodes public const string NoChange = "105"; public const string LimitExceeded = "106"; public const string Conflict = "107"; + public const string Forbidden = "108"; // === Auth (1) === public const string VerificationCodeError = "111";