diff --git a/SPMS.API/Controllers/AccountController.cs b/SPMS.API/Controllers/AccountController.cs index 8048045..139a72c 100644 --- a/SPMS.API/Controllers/AccountController.cs +++ b/SPMS.API/Controllers/AccountController.cs @@ -92,4 +92,74 @@ public class AccountController : ControllerBase await _accountService.DeleteAsync(adminCode); return Ok(ApiResponse.Success()); } + + [HttpPost("operator/create")] + [SwaggerOperation( + Summary = "운영자 계정 생성 (이메일 링크)", + Description = "Super Admin이 운영자 계정을 생성합니다. 비밀번호 설정 이메일이 발송됩니다.")] + [SwaggerResponse(200, "생성 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(409, "이메일 중복")] + public async Task CreateOperatorAsync([FromBody] OperatorCreateRequestDto request) + { + var result = await _accountService.CreateOperatorAsync(request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("operator/delete")] + [SwaggerOperation( + Summary = "운영자 계정 삭제", + Description = "Super Admin이 운영자 계정을 삭제합니다. (Soft Delete) 자기 자신은 삭제할 수 없습니다.")] + [SwaggerResponse(200, "삭제 성공")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음 / 자기 자신 삭제 불가")] + [SwaggerResponse(404, "운영자를 찾을 수 없음")] + public async Task DeleteOperatorAsync([FromBody] OperatorDeleteRequestDto request) + { + var adminId = GetAdminId(); + await _accountService.DeleteOperatorAsync(request.AdminCode, adminId); + return Ok(ApiResponse.Success()); + } + + [HttpPost("operator/list")] + [SwaggerOperation( + Summary = "운영자 목록 조회", + Description = "Super Admin이 운영자(Manager/User) 목록을 조회합니다. is_active 필터로 비활성 운영자도 조회 가능합니다.")] + [SwaggerResponse(200, "조회 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + public async Task GetOperatorListAsync([FromBody] OperatorListRequestDto request) + { + var result = await _accountService.GetOperatorListAsync(request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("operator/password/reset")] + [SwaggerOperation( + Summary = "운영자 비밀번호 초기화", + Description = "Super Admin이 운영자의 비밀번호를 초기화합니다. 비밀번호 재설정 이메일이 발송됩니다.")] + [SwaggerResponse(200, "초기화 성공", typeof(ApiResponse))] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(404, "운영자를 찾을 수 없음")] + public async Task ResetOperatorPasswordAsync([FromBody] OperatorPasswordResetRequestDto request) + { + var result = await _accountService.ResetOperatorPasswordAsync(request.AdminCode); + return Ok(ApiResponse.Success(result)); + } + + private long GetAdminId() + { + var adminIdClaim = User.FindFirst("adminId")?.Value; + if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) + { + throw new SPMS.Domain.Exceptions.SpmsException( + ErrorCodes.Unauthorized, + "인증 정보를 확인할 수 없습니다.", + 401); + } + return adminId; + } } diff --git a/SPMS.Application/DTOs/Account/OperatorCreateRequestDto.cs b/SPMS.Application/DTOs/Account/OperatorCreateRequestDto.cs new file mode 100644 index 0000000..76e2b05 --- /dev/null +++ b/SPMS.Application/DTOs/Account/OperatorCreateRequestDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Account; + +public class OperatorCreateRequestDto +{ + [Required(ErrorMessage = "이메일을 입력해주세요.")] + [EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")] + public string Email { 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/OperatorCreateResponseDto.cs b/SPMS.Application/DTOs/Account/OperatorCreateResponseDto.cs new file mode 100644 index 0000000..1fca107 --- /dev/null +++ b/SPMS.Application/DTOs/Account/OperatorCreateResponseDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Account; + +public class OperatorCreateResponseDto +{ + [JsonPropertyName("admin_code")] + public string AdminCode { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("role")] + public int Role { get; set; } +} diff --git a/SPMS.Application/DTOs/Account/OperatorDeleteRequestDto.cs b/SPMS.Application/DTOs/Account/OperatorDeleteRequestDto.cs new file mode 100644 index 0000000..4f1f377 --- /dev/null +++ b/SPMS.Application/DTOs/Account/OperatorDeleteRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Account; + +public class OperatorDeleteRequestDto +{ + [Required(ErrorMessage = "운영자 코드를 입력해주세요.")] + [JsonPropertyName("admin_code")] + public string AdminCode { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Account/OperatorListRequestDto.cs b/SPMS.Application/DTOs/Account/OperatorListRequestDto.cs new file mode 100644 index 0000000..5bd48ed --- /dev/null +++ b/SPMS.Application/DTOs/Account/OperatorListRequestDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Account; + +public class OperatorListRequestDto +{ + [JsonPropertyName("page")] + public int Page { get; set; } = 1; + + [JsonPropertyName("size")] + public int Size { get; set; } = 20; + + [JsonPropertyName("role")] + public int? Role { get; set; } + + [JsonPropertyName("is_active")] + public bool? IsActive { get; set; } +} diff --git a/SPMS.Application/DTOs/Account/OperatorListResponseDto.cs b/SPMS.Application/DTOs/Account/OperatorListResponseDto.cs new file mode 100644 index 0000000..bc6df21 --- /dev/null +++ b/SPMS.Application/DTOs/Account/OperatorListResponseDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using SPMS.Application.DTOs.Notice; + +namespace SPMS.Application.DTOs.Account; + +public class OperatorListResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = new(); +} + +public class OperatorItemDto +{ + [JsonPropertyName("admin_code")] + public string AdminCode { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("role")] + public int Role { get; set; } + + [JsonPropertyName("is_active")] + public bool IsActive { get; set; } + + [JsonPropertyName("last_login_at")] + public DateTime? LastLoginAt { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Account/OperatorPasswordResetRequestDto.cs b/SPMS.Application/DTOs/Account/OperatorPasswordResetRequestDto.cs new file mode 100644 index 0000000..24f2946 --- /dev/null +++ b/SPMS.Application/DTOs/Account/OperatorPasswordResetRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Account; + +public class OperatorPasswordResetRequestDto +{ + [Required(ErrorMessage = "운영자 코드를 입력해주세요.")] + [JsonPropertyName("admin_code")] + public string AdminCode { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/IAccountService.cs b/SPMS.Application/Interfaces/IAccountService.cs index f62b34d..2c72a9f 100644 --- a/SPMS.Application/Interfaces/IAccountService.cs +++ b/SPMS.Application/Interfaces/IAccountService.cs @@ -9,4 +9,8 @@ public interface IAccountService Task GetByAdminCodeAsync(string adminCode); Task UpdateAsync(string adminCode, UpdateAccountRequestDto request); Task DeleteAsync(string adminCode); + Task CreateOperatorAsync(OperatorCreateRequestDto request); + Task DeleteOperatorAsync(string adminCode, long requestingAdminId); + Task GetOperatorListAsync(OperatorListRequestDto request); + Task ResetOperatorPasswordAsync(string adminCode); } diff --git a/SPMS.Application/Interfaces/IEmailService.cs b/SPMS.Application/Interfaces/IEmailService.cs index 627697c..5c1176e 100644 --- a/SPMS.Application/Interfaces/IEmailService.cs +++ b/SPMS.Application/Interfaces/IEmailService.cs @@ -4,4 +4,5 @@ public interface IEmailService { Task SendVerificationCodeAsync(string email, string code); Task SendPasswordResetTokenAsync(string email, string token); + Task SendPasswordSetupTokenAsync(string email, string token); } diff --git a/SPMS.Application/Services/AccountService.cs b/SPMS.Application/Services/AccountService.cs index fb5c206..f2fec8e 100644 --- a/SPMS.Application/Services/AccountService.cs +++ b/SPMS.Application/Services/AccountService.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using SPMS.Application.DTOs.Account; +using SPMS.Application.DTOs.Notice; using SPMS.Application.Interfaces; using SPMS.Domain.Common; using SPMS.Domain.Entities; @@ -13,11 +14,19 @@ public class AccountService : IAccountService { private readonly IAdminRepository _adminRepository; private readonly IUnitOfWork _unitOfWork; + private readonly IEmailService _emailService; + private readonly ITokenStore _tokenStore; - public AccountService(IAdminRepository adminRepository, IUnitOfWork unitOfWork) + public AccountService( + IAdminRepository adminRepository, + IUnitOfWork unitOfWork, + IEmailService emailService, + ITokenStore tokenStore) { _adminRepository = adminRepository; _unitOfWork = unitOfWork; + _emailService = emailService; + _tokenStore = tokenStore; } public async Task CreateAsync(CreateAccountRequestDto request) @@ -178,6 +187,165 @@ public class AccountService : IAccountService await _unitOfWork.SaveChangesAsync(); } + public async Task CreateOperatorAsync(OperatorCreateRequestDto request) + { + // 1. 이메일 중복 검사 + if (await _adminRepository.EmailExistsAsync(request.Email)) + { + throw new SpmsException( + ErrorCodes.Conflict, + "이미 사용 중인 이메일입니다.", + 409); + } + + // 2. AdminCode 생성 + var adminCode = Guid.NewGuid().ToString("N")[..12].ToUpper(); + + // 3. Admin 엔티티 생성 (비밀번호 없이) + var admin = new Admin + { + AdminCode = adminCode, + Email = request.Email, + Password = string.Empty, + 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. 비밀번호 설정 토큰 생성 및 저장 + var token = Guid.NewGuid().ToString("N"); + await _tokenStore.StoreAsync($"password_setup:{request.Email}", token, TimeSpan.FromHours(24)); + + // 6. 이메일 발송 + await _emailService.SendPasswordSetupTokenAsync(request.Email, token); + + return new OperatorCreateResponseDto + { + AdminCode = adminCode, + Email = request.Email, + Role = request.Role + }; + } + + public async Task DeleteOperatorAsync(string adminCode, long requestingAdminId) + { + 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); + } + + // 자기 자신 삭제 불가 + if (admin.Id == requestingAdminId) + { + throw new SpmsException( + ErrorCodes.Forbidden, + "자기 자신은 삭제할 수 없습니다.", + 403); + } + + // Soft Delete + admin.IsDeleted = true; + admin.DeletedAt = DateTime.UtcNow; + admin.RefreshToken = null; + admin.RefreshTokenExpiresAt = null; + + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + } + + public async Task GetOperatorListAsync(OperatorListRequestDto request) + { + AdminRole? roleFilter = request.Role.HasValue ? (AdminRole)request.Role.Value : null; + + var (items, totalCount) = await _adminRepository.GetOperatorPagedAsync( + request.Page, request.Size, roleFilter, request.IsActive); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); + + return new OperatorListResponseDto + { + Items = items.Select(a => new OperatorItemDto + { + AdminCode = a.AdminCode, + Email = a.Email, + Name = a.Name, + Role = (int)a.Role, + IsActive = !a.IsDeleted, + LastLoginAt = a.LastLoginAt, + CreatedAt = a.CreatedAt + }).ToList(), + Pagination = new PaginationDto + { + Page = request.Page, + Size = request.Size, + TotalCount = totalCount, + TotalPages = totalPages + } + }; + } + + public async Task ResetOperatorPasswordAsync(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); + } + + // 세션 무효화 + admin.RefreshToken = null; + admin.RefreshTokenExpiresAt = null; + + _adminRepository.Update(admin); + await _unitOfWork.SaveChangesAsync(); + + // 비밀번호 재설정 토큰 생성 및 저장 + var token = Guid.NewGuid().ToString("N"); + await _tokenStore.StoreAsync($"password_reset:{admin.Email}", token, TimeSpan.FromHours(1)); + + // 이메일 발송 + await _emailService.SendPasswordResetTokenAsync(admin.Email, token); + + return new OperatorCreateResponseDto + { + AdminCode = admin.AdminCode, + Email = admin.Email, + Role = (int)admin.Role + }; + } + private static AccountResponseDto MapToDto(Admin admin) { return new AccountResponseDto diff --git a/SPMS.Domain/Interfaces/IAdminRepository.cs b/SPMS.Domain/Interfaces/IAdminRepository.cs index 0a2cf77..87a1284 100644 --- a/SPMS.Domain/Interfaces/IAdminRepository.cs +++ b/SPMS.Domain/Interfaces/IAdminRepository.cs @@ -1,4 +1,5 @@ using SPMS.Domain.Entities; +using SPMS.Domain.Enums; namespace SPMS.Domain.Interfaces; @@ -8,4 +9,6 @@ public interface IAdminRepository : IRepository Task GetByAdminCodeAsync(string adminCode); Task GetByRefreshTokenAsync(string refreshToken); Task EmailExistsAsync(string email, long? excludeId = null); + Task<(IReadOnlyList Items, int TotalCount)> GetOperatorPagedAsync( + int page, int size, AdminRole? roleFilter, bool? isActive); } diff --git a/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs index 883ac2b..9f52e06 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/AdminRepository.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using SPMS.Domain.Entities; +using SPMS.Domain.Enums; using SPMS.Domain.Interfaces; namespace SPMS.Infrastructure.Persistence.Repositories; @@ -37,4 +38,43 @@ public class AdminRepository : Repository, IAdminRepository return await query.AnyAsync(); } + + public async Task<(IReadOnlyList Items, int TotalCount)> GetOperatorPagedAsync( + int page, int size, AdminRole? roleFilter, bool? isActive) + { + IQueryable query; + + if (isActive.HasValue) + { + // IgnoreQueryFilters로 삭제된 운영자도 조회 가능 + query = _context.Set().IgnoreQueryFilters(); + + if (isActive.Value) + query = query.Where(a => !a.IsDeleted); + else + query = query.Where(a => a.IsDeleted); + } + else + { + // 기본: 글로벌 필터 적용 (활성 운영자만) + query = _dbSet.AsQueryable(); + } + + // Super Admin 제외 + query = query.Where(a => a.Role != AdminRole.Super); + + // Role 필터 + if (roleFilter.HasValue) + query = query.Where(a => a.Role == roleFilter.Value); + + var totalCount = await query.CountAsync(); + + var items = await query + .OrderByDescending(a => a.CreatedAt) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + return (items, totalCount); + } } diff --git a/SPMS.Infrastructure/Services/ConsoleEmailService.cs b/SPMS.Infrastructure/Services/ConsoleEmailService.cs index 17ad79d..26ca3bc 100644 --- a/SPMS.Infrastructure/Services/ConsoleEmailService.cs +++ b/SPMS.Infrastructure/Services/ConsoleEmailService.cs @@ -27,4 +27,12 @@ public class ConsoleEmailService : IEmailService email, token); return Task.CompletedTask; } + + public Task SendPasswordSetupTokenAsync(string email, string token) + { + _logger.LogInformation( + "[EMAIL] 비밀번호 설정 토큰 발송 → To: {Email}, Token: {Token}", + email, token); + return Task.CompletedTask; + } }