From 21dc6e608c5676ced274da7f2a3571d35d27f9aa Mon Sep 17 00:00:00 2001 From: SEAN Date: Tue, 10 Feb 2026 09:38:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20API=20=EA=B5=AC=ED=98=84=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SPMS.API/Controllers/ServiceController.cs | 21 +++++ .../DTOs/Service/CreateServiceRequestDto.cs | 13 +++ .../DTOs/Service/CreateServiceResponseDto.cs | 8 ++ .../Interfaces/IServiceManagementService.cs | 1 + .../Services/ServiceManagementService.cs | 81 +++++++++++++++++++ SPMS.Domain/Interfaces/IServiceRepository.cs | 2 + .../Repositories/ServiceRepository.cs | 6 ++ 7 files changed, 132 insertions(+) create mode 100644 SPMS.Application/DTOs/Service/CreateServiceRequestDto.cs create mode 100644 SPMS.Application/DTOs/Service/CreateServiceResponseDto.cs diff --git a/SPMS.API/Controllers/ServiceController.cs b/SPMS.API/Controllers/ServiceController.cs index 367ed52..71202fa 100644 --- a/SPMS.API/Controllers/ServiceController.cs +++ b/SPMS.API/Controllers/ServiceController.cs @@ -20,6 +20,27 @@ public class ServiceController : ControllerBase _serviceManagementService = serviceManagementService; } + [HttpPost("create")] + [SwaggerOperation( + Summary = "서비스 등록", + Description = "새로운 서비스를 등록합니다. ServiceCode와 API Key가 자동 생성되며, API Key는 응답에서 1회만 표시됩니다.")] + [SwaggerResponse(200, "등록 성공", typeof(ApiResponse))] + [SwaggerResponse(400, "잘못된 요청")] + [SwaggerResponse(401, "인증되지 않은 요청")] + [SwaggerResponse(403, "권한 없음")] + [SwaggerResponse(409, "이미 존재하는 서비스명")] + public async Task CreateAsync([FromBody] CreateServiceRequestDto request) + { + var adminIdClaim = User.FindFirst("adminId")?.Value; + if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) + { + return Unauthorized(ApiResponse.Fail("101", "인증 정보가 유효하지 않습니다.")); + } + + var result = await _serviceManagementService.CreateAsync(request, adminId); + return Ok(ApiResponse.Success(result)); + } + [HttpPost("list")] [SwaggerOperation( Summary = "서비스 목록 조회", diff --git a/SPMS.Application/DTOs/Service/CreateServiceRequestDto.cs b/SPMS.Application/DTOs/Service/CreateServiceRequestDto.cs new file mode 100644 index 0000000..e2bdaed --- /dev/null +++ b/SPMS.Application/DTOs/Service/CreateServiceRequestDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Service; + +public class CreateServiceRequestDto +{ + [Required(ErrorMessage = "서비스명은 필수입니다.")] + [StringLength(100, ErrorMessage = "서비스명은 100자 이내여야 합니다.")] + public string ServiceName { get; set; } = string.Empty; + + [StringLength(500, ErrorMessage = "설명은 500자 이내여야 합니다.")] + public string? Description { get; set; } +} diff --git a/SPMS.Application/DTOs/Service/CreateServiceResponseDto.cs b/SPMS.Application/DTOs/Service/CreateServiceResponseDto.cs new file mode 100644 index 0000000..b020831 --- /dev/null +++ b/SPMS.Application/DTOs/Service/CreateServiceResponseDto.cs @@ -0,0 +1,8 @@ +namespace SPMS.Application.DTOs.Service; + +public class CreateServiceResponseDto +{ + public string ServiceCode { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; + public DateTime ApiKeyCreatedAt { get; set; } +} diff --git a/SPMS.Application/Interfaces/IServiceManagementService.cs b/SPMS.Application/Interfaces/IServiceManagementService.cs index 50984dc..0b65d33 100644 --- a/SPMS.Application/Interfaces/IServiceManagementService.cs +++ b/SPMS.Application/Interfaces/IServiceManagementService.cs @@ -4,6 +4,7 @@ namespace SPMS.Application.Interfaces; public interface IServiceManagementService { + Task CreateAsync(CreateServiceRequestDto request, long adminId); Task GetListAsync(ServiceListRequestDto request); Task GetByServiceCodeAsync(string serviceCode); Task ChangeStatusAsync(string serviceCode, ChangeServiceStatusRequestDto request); diff --git a/SPMS.Application/Services/ServiceManagementService.cs b/SPMS.Application/Services/ServiceManagementService.cs index ceacc22..3a8cacb 100644 --- a/SPMS.Application/Services/ServiceManagementService.cs +++ b/SPMS.Application/Services/ServiceManagementService.cs @@ -27,6 +27,87 @@ public class ServiceManagementService : IServiceManagementService _credentialEncryptionService = credentialEncryptionService; } + public async Task CreateAsync(CreateServiceRequestDto request, long adminId) + { + // 서비스명 중복 검사 + var nameExists = await _serviceRepository.ServiceNameExistsAsync(request.ServiceName); + if (nameExists) + { + throw new SpmsException( + ErrorCodes.Conflict, + "이미 존재하는 서비스명입니다.", + 409); + } + + // ServiceCode 생성 (NanoID 8자리) + var serviceCode = await GenerateUniqueServiceCodeAsync(); + + // API Key 생성 (48자 = 36바이트 Base64) + var randomBytes = new byte[36]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randomBytes); + } + var apiKey = Convert.ToBase64String(randomBytes); + + var now = DateTime.UtcNow; + + var service = new Service + { + ServiceCode = serviceCode, + ServiceName = request.ServiceName, + Description = request.Description, + ApiKey = apiKey, + ApiKeyCreatedAt = now, + SubTier = SubTier.Free, + Status = ServiceStatus.Active, + CreatedAt = now, + CreatedBy = adminId, + IsDeleted = false + }; + + await _serviceRepository.AddAsync(service); + await _unitOfWork.SaveChangesAsync(); + + return new CreateServiceResponseDto + { + ServiceCode = service.ServiceCode, + ApiKey = apiKey, + ApiKeyCreatedAt = service.ApiKeyCreatedAt + }; + } + + private async Task GenerateUniqueServiceCodeAsync() + { + const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const int codeLength = 8; + var buffer = new byte[codeLength]; + + for (var attempt = 0; attempt < 10; attempt++) + { + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(buffer); + } + + var code = new char[codeLength]; + for (var i = 0; i < codeLength; i++) + { + code[i] = alphabet[buffer[i] % alphabet.Length]; + } + + var serviceCode = new string(code); + var exists = await _serviceRepository.ServiceCodeExistsAsync(serviceCode); + if (!exists) + return serviceCode; + } + + throw new SpmsException( + ErrorCodes.InternalError, + "서비스 코드 생성에 실패했습니다. 다시 시도해주세요.", + 500); + } + public async Task GetListAsync(ServiceListRequestDto request) { Expression>? predicate = null; diff --git a/SPMS.Domain/Interfaces/IServiceRepository.cs b/SPMS.Domain/Interfaces/IServiceRepository.cs index dab4cfb..6e1c41e 100644 --- a/SPMS.Domain/Interfaces/IServiceRepository.cs +++ b/SPMS.Domain/Interfaces/IServiceRepository.cs @@ -10,6 +10,8 @@ public interface IServiceRepository : IRepository Task GetByIdWithIpsAsync(long id); Task GetByServiceCodeWithIpsAsync(string serviceCode); Task> GetByStatusAsync(ServiceStatus status); + Task ServiceNameExistsAsync(string serviceName); + Task ServiceCodeExistsAsync(string serviceCode); // ServiceIp methods Task GetServiceIpByIdAsync(long ipId); diff --git a/SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs index 35180ab..643aa92 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/ServiceRepository.cs @@ -28,6 +28,12 @@ public class ServiceRepository : Repository, IServiceRepository public async Task> GetByStatusAsync(ServiceStatus status) => await _dbSet.Where(s => s.Status == status).ToListAsync(); + public async Task ServiceNameExistsAsync(string serviceName) + => await _dbSet.AnyAsync(s => s.ServiceName == serviceName); + + public async Task ServiceCodeExistsAsync(string serviceCode) + => await _dbSet.AnyAsync(s => s.ServiceCode == serviceCode); + // ServiceIp methods public async Task GetServiceIpByIdAsync(long ipId) => await _context.Set().FirstOrDefaultAsync(ip => ip.Id == ipId);