From 7ffc15253626fd1e7267ee5112ed7865b42f509f Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 25 Feb 2026 18:07:11 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20=ED=83=9C=EA=B7=B8=20CRUD=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#186)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tag DTO 6종 생성 (List/Create/Update/Delete Request/Response) - ITagRepository 확장 (GetTagListAsync, CountByServiceAsync) - IDeviceRepository 확장 (GetDeviceCountsByTagIdsAsync) - ITagService/TagService 구현 (CRUD 비즈니스 로직) - TagController 신규 생성 (v1/in/tag/list, create, update, delete) - DI 등록 Closes #186 --- SPMS.API/Controllers/TagController.cs | 73 +++++++++ .../DTOs/Tag/CreateTagRequestDto.cs | 20 +++ .../DTOs/Tag/DeleteTagRequestDto.cs | 11 ++ .../DTOs/Tag/TagListRequestDto.cs | 18 +++ .../DTOs/Tag/TagListResponseDto.cs | 40 +++++ SPMS.Application/DTOs/Tag/TagResponseDto.cs | 27 ++++ .../DTOs/Tag/UpdateTagRequestDto.cs | 15 ++ SPMS.Application/DependencyInjection.cs | 1 + SPMS.Application/Interfaces/ITagService.cs | 11 ++ SPMS.Application/Services/TagService.cs | 142 ++++++++++++++++++ SPMS.Domain/Interfaces/IDeviceRepository.cs | 1 + SPMS.Domain/Interfaces/ITagRepository.cs | 3 + .../Repositories/DeviceRepository.cs | 18 +++ .../Persistence/Repositories/TagRepository.cs | 28 ++++ 14 files changed, 408 insertions(+) create mode 100644 SPMS.API/Controllers/TagController.cs create mode 100644 SPMS.Application/DTOs/Tag/CreateTagRequestDto.cs create mode 100644 SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs create mode 100644 SPMS.Application/DTOs/Tag/TagListRequestDto.cs create mode 100644 SPMS.Application/DTOs/Tag/TagListResponseDto.cs create mode 100644 SPMS.Application/DTOs/Tag/TagResponseDto.cs create mode 100644 SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs create mode 100644 SPMS.Application/Interfaces/ITagService.cs create mode 100644 SPMS.Application/Services/TagService.cs diff --git a/SPMS.API/Controllers/TagController.cs b/SPMS.API/Controllers/TagController.cs new file mode 100644 index 0000000..bf90924 --- /dev/null +++ b/SPMS.API/Controllers/TagController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Tag; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Exceptions; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/tag")] +[Authorize] +[ApiExplorerSettings(GroupName = "tag")] +public class TagController : ControllerBase +{ + private readonly ITagService _tagService; + + public TagController(ITagService tagService) + { + _tagService = tagService; + } + + [HttpPost("list")] + [SwaggerOperation(Summary = "태그 목록 조회", Description = "태그 목록을 페이지 단위로 조회합니다. 서비스 필터, 키워드 검색, deviceCount를 포함합니다.")] + public async Task GetListAsync([FromBody] TagListRequestDto request) + { + var serviceId = GetServiceIdOrNull(); + var result = await _tagService.GetListAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("create")] + [SwaggerOperation(Summary = "태그 생성", Description = "새 태그를 생성합니다. 서비스당 최대 10개, 동일 서비스 내 태그명 중복 불가.")] + public async Task CreateAsync([FromBody] CreateTagRequestDto request) + { + var adminId = GetAdminId(); + var result = await _tagService.CreateAsync(adminId, request); + return Ok(ApiResponse.Success(result, "태그 생성 성공")); + } + + [HttpPost("update")] + [SwaggerOperation(Summary = "태그 수정", Description = "태그 설명을 수정합니다. 태그명(Name)은 변경 불가.")] + public async Task UpdateAsync([FromBody] UpdateTagRequestDto request) + { + var result = await _tagService.UpdateAsync(request); + return Ok(ApiResponse.Success(result, "태그 수정 성공")); + } + + [HttpPost("delete")] + [SwaggerOperation(Summary = "태그 삭제", Description = "태그를 삭제합니다.")] + public async Task DeleteAsync([FromBody] DeleteTagRequestDto request) + { + await _tagService.DeleteAsync(request); + return Ok(ApiResponse.Success()); + } + + private long? GetServiceIdOrNull() + { + if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) + return serviceId; + return null; + } + + private long GetAdminId() + { + var adminIdClaim = User.FindFirst("adminId")?.Value; + if (string.IsNullOrEmpty(adminIdClaim) || !long.TryParse(adminIdClaim, out var adminId)) + throw new SpmsException(ErrorCodes.Unauthorized, "인증 정보가 올바르지 않습니다.", 401); + + return adminId; + } +} diff --git a/SPMS.Application/DTOs/Tag/CreateTagRequestDto.cs b/SPMS.Application/DTOs/Tag/CreateTagRequestDto.cs new file mode 100644 index 0000000..d5da18e --- /dev/null +++ b/SPMS.Application/DTOs/Tag/CreateTagRequestDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Tag; + +public class CreateTagRequestDto +{ + [Required] + [JsonPropertyName("service_id")] + public long ServiceId { get; set; } + + [Required] + [MaxLength(50)] + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = string.Empty; + + [MaxLength(200)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs b/SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs new file mode 100644 index 0000000..b0194ff --- /dev/null +++ b/SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Tag; + +public class DeleteTagRequestDto +{ + [Required] + [JsonPropertyName("tag_id")] + public long TagId { get; set; } +} diff --git a/SPMS.Application/DTOs/Tag/TagListRequestDto.cs b/SPMS.Application/DTOs/Tag/TagListRequestDto.cs new file mode 100644 index 0000000..1b99cd5 --- /dev/null +++ b/SPMS.Application/DTOs/Tag/TagListRequestDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Tag; + +public class TagListRequestDto +{ + [JsonPropertyName("service_id")] + public long? ServiceId { get; set; } + + [JsonPropertyName("keyword")] + public string? Keyword { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } = 1; + + [JsonPropertyName("size")] + public int Size { get; set; } = 20; +} diff --git a/SPMS.Application/DTOs/Tag/TagListResponseDto.cs b/SPMS.Application/DTOs/Tag/TagListResponseDto.cs new file mode 100644 index 0000000..a4983a4 --- /dev/null +++ b/SPMS.Application/DTOs/Tag/TagListResponseDto.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; +using SPMS.Application.DTOs.Notice; + +namespace SPMS.Application.DTOs.Tag; + +public class TagListResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = new(); + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = new(); +} + +public class TagSummaryDto +{ + [JsonPropertyName("tag_id")] + public long TagId { get; set; } + + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("service_id")] + public long ServiceId { get; set; } + + [JsonPropertyName("service_name")] + public string ServiceName { get; set; } = string.Empty; + + [JsonPropertyName("device_count")] + public int DeviceCount { get; set; } + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime? UpdatedAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Tag/TagResponseDto.cs b/SPMS.Application/DTOs/Tag/TagResponseDto.cs new file mode 100644 index 0000000..43c6e96 --- /dev/null +++ b/SPMS.Application/DTOs/Tag/TagResponseDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Tag; + +public class TagResponseDto +{ + [JsonPropertyName("tag_id")] + public long TagId { get; set; } + + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("service_id")] + public long ServiceId { get; set; } + + [JsonPropertyName("service_name")] + public string ServiceName { get; set; } = string.Empty; + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public DateTime? UpdatedAt { get; set; } +} diff --git a/SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs b/SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs new file mode 100644 index 0000000..ca2c35d --- /dev/null +++ b/SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Tag; + +public class UpdateTagRequestDto +{ + [Required] + [JsonPropertyName("tag_id")] + public long TagId { get; set; } + + [MaxLength(200)] + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index 775048a..b37e4b5 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -22,6 +22,7 @@ public static class DependencyInjection services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/ITagService.cs b/SPMS.Application/Interfaces/ITagService.cs new file mode 100644 index 0000000..854e3aa --- /dev/null +++ b/SPMS.Application/Interfaces/ITagService.cs @@ -0,0 +1,11 @@ +using SPMS.Application.DTOs.Tag; + +namespace SPMS.Application.Interfaces; + +public interface ITagService +{ + Task GetListAsync(long? scopeServiceId, TagListRequestDto request); + Task CreateAsync(long adminId, CreateTagRequestDto request); + Task UpdateAsync(UpdateTagRequestDto request); + Task DeleteAsync(DeleteTagRequestDto request); +} diff --git a/SPMS.Application/Services/TagService.cs b/SPMS.Application/Services/TagService.cs new file mode 100644 index 0000000..13618af --- /dev/null +++ b/SPMS.Application/Services/TagService.cs @@ -0,0 +1,142 @@ +using SPMS.Application.DTOs.Notice; +using SPMS.Application.DTOs.Tag; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; +using SPMS.Domain.Entities; +using SPMS.Domain.Exceptions; +using SPMS.Domain.Interfaces; + +namespace SPMS.Application.Services; + +public class TagService : ITagService +{ + private readonly ITagRepository _tagRepository; + private readonly IServiceRepository _serviceRepository; + private readonly IDeviceRepository _deviceRepository; + private readonly IUnitOfWork _unitOfWork; + + private const int MaxTagsPerService = 10; + + public TagService( + ITagRepository tagRepository, + IServiceRepository serviceRepository, + IDeviceRepository deviceRepository, + IUnitOfWork unitOfWork) + { + _tagRepository = tagRepository; + _serviceRepository = serviceRepository; + _deviceRepository = deviceRepository; + _unitOfWork = unitOfWork; + } + + public async Task GetListAsync(long? scopeServiceId, TagListRequestDto request) + { + // scopeServiceId가 있으면 해당 서비스만, request.ServiceId도 추가 필터 + var effectiveServiceId = scopeServiceId ?? request.ServiceId; + + var (items, totalCount) = await _tagRepository.GetTagListAsync( + effectiveServiceId, request.Keyword, request.Page, request.Size); + + // deviceCount 집계 + var tagIds = items.Select(t => t.Id).ToList(); + var deviceCounts = await _deviceRepository.GetDeviceCountsByTagIdsAsync(tagIds); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); + + return new TagListResponseDto + { + Items = items.Select(t => new TagSummaryDto + { + TagId = t.Id, + TagName = t.Name, + Description = t.Description, + ServiceId = t.ServiceId, + ServiceName = t.Service?.ServiceName ?? string.Empty, + DeviceCount = deviceCounts.GetValueOrDefault(t.Id, 0), + CreatedAt = t.CreatedAt, + UpdatedAt = t.UpdatedAt + }).ToList(), + Pagination = new PaginationDto + { + Page = request.Page, + Size = request.Size, + TotalCount = totalCount, + TotalPages = totalPages + } + }; + } + + public async Task CreateAsync(long adminId, CreateTagRequestDto request) + { + // 서비스 존재 확인 + var service = await _serviceRepository.GetByIdAsync(request.ServiceId); + if (service == null) + throw new SpmsException(ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404); + + // 서비스 내 태그명 중복 검사 + if (await _tagRepository.ExistsInServiceAsync(request.ServiceId, request.TagName)) + throw new SpmsException(ErrorCodes.TagNameDuplicate, "동일 서비스 내에 같은 이름의 태그가 존재합니다."); + + // 서비스 내 태그 수 제한 + var currentCount = await _tagRepository.CountByServiceAsync(request.ServiceId); + if (currentCount >= MaxTagsPerService) + throw new SpmsException(ErrorCodes.TagLimitExceeded, $"서비스당 태그는 최대 {MaxTagsPerService}개까지 생성할 수 있습니다."); + + var tag = new Tag + { + ServiceId = request.ServiceId, + Name = request.TagName, + Description = request.Description, + CreatedAt = DateTime.UtcNow, + CreatedBy = adminId + }; + + await _tagRepository.AddAsync(tag); + await _unitOfWork.SaveChangesAsync(); + + return new TagResponseDto + { + TagId = tag.Id, + TagName = tag.Name, + Description = tag.Description, + ServiceId = tag.ServiceId, + ServiceName = service.ServiceName, + CreatedAt = tag.CreatedAt, + UpdatedAt = tag.UpdatedAt + }; + } + + public async Task UpdateAsync(UpdateTagRequestDto request) + { + var tag = await _tagRepository.GetByIdWithServiceAsync(request.TagId); + if (tag == null) + throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404); + + tag.Description = request.Description; + tag.UpdatedAt = DateTime.UtcNow; + + _tagRepository.Update(tag); + await _unitOfWork.SaveChangesAsync(); + + return new TagResponseDto + { + TagId = tag.Id, + TagName = tag.Name, + Description = tag.Description, + ServiceId = tag.ServiceId, + ServiceName = tag.Service?.ServiceName ?? string.Empty, + CreatedAt = tag.CreatedAt, + UpdatedAt = tag.UpdatedAt + }; + } + + public async Task DeleteAsync(DeleteTagRequestDto request) + { + var tag = await _tagRepository.GetByIdWithServiceAsync(request.TagId); + if (tag == null) + throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404); + + _tagRepository.Delete(tag); + await _unitOfWork.SaveChangesAsync(); + } +} diff --git a/SPMS.Domain/Interfaces/IDeviceRepository.cs b/SPMS.Domain/Interfaces/IDeviceRepository.cs index 5605bb0..63d7a8f 100644 --- a/SPMS.Domain/Interfaces/IDeviceRepository.cs +++ b/SPMS.Domain/Interfaces/IDeviceRepository.cs @@ -19,4 +19,5 @@ public interface IDeviceRepository : IRepository Platform? platform = null, bool? pushAgreed = null, bool? isActive = null, List? tags = null, string? keyword = null, bool? marketingAgreed = null); + Task> GetDeviceCountsByTagIdsAsync(IEnumerable tagIds); } diff --git a/SPMS.Domain/Interfaces/ITagRepository.cs b/SPMS.Domain/Interfaces/ITagRepository.cs index b757089..5f94d22 100644 --- a/SPMS.Domain/Interfaces/ITagRepository.cs +++ b/SPMS.Domain/Interfaces/ITagRepository.cs @@ -6,4 +6,7 @@ public interface ITagRepository : IRepository { Task GetByIdWithServiceAsync(long id); Task ExistsInServiceAsync(long serviceId, string name); + Task<(IReadOnlyList Items, int TotalCount)> GetTagListAsync( + long? serviceId, string? keyword, int page, int size); + Task CountByServiceAsync(long serviceId); } diff --git a/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs index 5ef970c..6a67e6e 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs @@ -128,4 +128,22 @@ public class DeviceRepository : Repository, IDeviceRepository .OrderByDescending(d => d.CreatedAt) .ToListAsync(); } + + public async Task> GetDeviceCountsByTagIdsAsync(IEnumerable tagIds) + { + var tagIdList = tagIds.ToList(); + if (tagIdList.Count == 0) + return new Dictionary(); + + var result = new Dictionary(); + foreach (var tagId in tagIdList) + { + var tagStr = tagId.ToString(); + var count = await _dbSet.CountAsync(d => + d.Tags != null && EF.Functions.Like(d.Tags, $"%{tagStr}%")); + result[tagId] = count; + } + + return result; + } } diff --git a/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs index 402953b..2f7adbc 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs @@ -15,4 +15,32 @@ public class TagRepository : Repository, ITagRepository public async Task ExistsInServiceAsync(long serviceId, string name) => await _dbSet.AnyAsync(t => t.ServiceId == serviceId && t.Name == name); + + public async Task<(IReadOnlyList Items, int TotalCount)> GetTagListAsync( + long? serviceId, string? keyword, int page, int size) + { + IQueryable query = _dbSet.Include(t => t.Service); + + if (serviceId.HasValue) + query = query.Where(t => t.ServiceId == serviceId.Value); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var trimmed = keyword.Trim(); + query = query.Where(t => t.Name.Contains(trimmed)); + } + + var totalCount = await query.CountAsync(); + + var items = await query + .OrderByDescending(t => t.CreatedAt) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + return (items, totalCount); + } + + public async Task CountByServiceAsync(long serviceId) + => await _dbSet.CountAsync(t => t.ServiceId == serviceId); }