feat: 운영자 계정 CRUD API 구현 (#42)
- AccountController: 운영자 CRUD 엔드포인트 (create, list, detail, update, delete) - AccountService: 비즈니스 로직 구현 - Account DTOs: 요청/응답 DTO 5종 - ErrorCodes: Forbidden 코드 추가 - DI 등록 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e0bf0adf70
commit
b6939c0fa9
95
SPMS.API/Controllers/AccountController.cs
Normal file
95
SPMS.API/Controllers/AccountController.cs
Normal file
|
|
@ -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<AccountResponseDto>))]
|
||||
[SwaggerResponse(400, "잘못된 요청")]
|
||||
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||
[SwaggerResponse(403, "권한 없음")]
|
||||
[SwaggerResponse(409, "이메일 중복")]
|
||||
public async Task<IActionResult> CreateAsync([FromBody] CreateAccountRequestDto request)
|
||||
{
|
||||
var result = await _accountService.CreateAsync(request);
|
||||
return Ok(ApiResponse<AccountResponseDto>.Success(result));
|
||||
}
|
||||
|
||||
[HttpPost("list")]
|
||||
[SwaggerOperation(
|
||||
Summary = "운영자 목록 조회",
|
||||
Description = "Super Admin이 운영자(Manager/User) 목록을 조회합니다. Super Admin은 목록에 포함되지 않습니다.")]
|
||||
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<AccountListResponseDto>))]
|
||||
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||
[SwaggerResponse(403, "권한 없음")]
|
||||
public async Task<IActionResult> GetListAsync([FromBody] AccountListRequestDto request)
|
||||
{
|
||||
var result = await _accountService.GetListAsync(request);
|
||||
return Ok(ApiResponse<AccountListResponseDto>.Success(result));
|
||||
}
|
||||
|
||||
[HttpPost("{adminCode}")]
|
||||
[SwaggerOperation(
|
||||
Summary = "운영자 상세 조회",
|
||||
Description = "Super Admin이 특정 운영자의 상세 정보를 조회합니다.")]
|
||||
[SwaggerResponse(200, "조회 성공", typeof(ApiResponse<AccountResponseDto>))]
|
||||
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||
[SwaggerResponse(403, "권한 없음")]
|
||||
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||
public async Task<IActionResult> GetByAdminCodeAsync([FromRoute] string adminCode)
|
||||
{
|
||||
var result = await _accountService.GetByAdminCodeAsync(adminCode);
|
||||
return Ok(ApiResponse<AccountResponseDto>.Success(result));
|
||||
}
|
||||
|
||||
[HttpPost("{adminCode}/update")]
|
||||
[SwaggerOperation(
|
||||
Summary = "운영자 정보 수정",
|
||||
Description = "Super Admin이 운영자의 정보(이름, 전화번호, 권한)를 수정합니다.")]
|
||||
[SwaggerResponse(200, "수정 성공", typeof(ApiResponse<AccountResponseDto>))]
|
||||
[SwaggerResponse(400, "잘못된 요청")]
|
||||
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||
[SwaggerResponse(403, "권한 없음")]
|
||||
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||
public async Task<IActionResult> UpdateAsync(
|
||||
[FromRoute] string adminCode,
|
||||
[FromBody] UpdateAccountRequestDto request)
|
||||
{
|
||||
var result = await _accountService.UpdateAsync(adminCode, request);
|
||||
return Ok(ApiResponse<AccountResponseDto>.Success(result));
|
||||
}
|
||||
|
||||
[HttpPost("{adminCode}/delete")]
|
||||
[SwaggerOperation(
|
||||
Summary = "운영자 계정 삭제",
|
||||
Description = "Super Admin이 운영자 계정을 삭제합니다. (Soft Delete)")]
|
||||
[SwaggerResponse(200, "삭제 성공")]
|
||||
[SwaggerResponse(401, "인증되지 않은 요청")]
|
||||
[SwaggerResponse(403, "권한 없음")]
|
||||
[SwaggerResponse(404, "운영자를 찾을 수 없음")]
|
||||
public async Task<IActionResult> DeleteAsync([FromRoute] string adminCode)
|
||||
{
|
||||
await _accountService.DeleteAsync(adminCode);
|
||||
return Ok(ApiResponse.Success());
|
||||
}
|
||||
}
|
||||
15
SPMS.Application/DTOs/Account/AccountListRequestDto.cs
Normal file
15
SPMS.Application/DTOs/Account/AccountListRequestDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
10
SPMS.Application/DTOs/Account/AccountListResponseDto.cs
Normal file
10
SPMS.Application/DTOs/Account/AccountListResponseDto.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace SPMS.Application.DTOs.Account;
|
||||
|
||||
public class AccountListResponseDto
|
||||
{
|
||||
public List<AccountResponseDto> 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);
|
||||
}
|
||||
13
SPMS.Application/DTOs/Account/AccountResponseDto.cs
Normal file
13
SPMS.Application/DTOs/Account/AccountResponseDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
24
SPMS.Application/DTOs/Account/CreateAccountRequestDto.cs
Normal file
24
SPMS.Application/DTOs/Account/CreateAccountRequestDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
16
SPMS.Application/DTOs/Account/UpdateAccountRequestDto.cs
Normal file
16
SPMS.Application/DTOs/Account/UpdateAccountRequestDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ public static class DependencyInjection
|
|||
{
|
||||
// Application Services
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IAccountService, AccountService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
12
SPMS.Application/Interfaces/IAccountService.cs
Normal file
12
SPMS.Application/Interfaces/IAccountService.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using SPMS.Application.DTOs.Account;
|
||||
|
||||
namespace SPMS.Application.Interfaces;
|
||||
|
||||
public interface IAccountService
|
||||
{
|
||||
Task<AccountResponseDto> CreateAsync(CreateAccountRequestDto request);
|
||||
Task<AccountListResponseDto> GetListAsync(AccountListRequestDto request);
|
||||
Task<AccountResponseDto> GetByAdminCodeAsync(string adminCode);
|
||||
Task<AccountResponseDto> UpdateAsync(string adminCode, UpdateAccountRequestDto request);
|
||||
Task DeleteAsync(string adminCode);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>b8d7099d-890d-4aaf-b758-0df8b5b339f8</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
195
SPMS.Application/Services/AccountService.cs
Normal file
195
SPMS.Application/Services/AccountService.cs
Normal file
|
|
@ -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<AccountResponseDto> 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<AccountListResponseDto> GetListAsync(AccountListRequestDto request)
|
||||
{
|
||||
// 필터 조건 생성 (Super Admin 제외)
|
||||
Expression<Func<Admin, bool>> 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<AccountResponseDto> 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<AccountResponseDto> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user