improvement: 태그 CRUD API 구현 (#186)

- 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
This commit is contained in:
SEAN 2026-02-25 18:07:11 +09:00
parent 6b4f502bb8
commit 7ffc152536
14 changed files with 408 additions and 0 deletions

View File

@ -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<IActionResult> GetListAsync([FromBody] TagListRequestDto request)
{
var serviceId = GetServiceIdOrNull();
var result = await _tagService.GetListAsync(serviceId, request);
return Ok(ApiResponse<TagListResponseDto>.Success(result));
}
[HttpPost("create")]
[SwaggerOperation(Summary = "태그 생성", Description = "새 태그를 생성합니다. 서비스당 최대 10개, 동일 서비스 내 태그명 중복 불가.")]
public async Task<IActionResult> CreateAsync([FromBody] CreateTagRequestDto request)
{
var adminId = GetAdminId();
var result = await _tagService.CreateAsync(adminId, request);
return Ok(ApiResponse<TagResponseDto>.Success(result, "태그 생성 성공"));
}
[HttpPost("update")]
[SwaggerOperation(Summary = "태그 수정", Description = "태그 설명을 수정합니다. 태그명(Name)은 변경 불가.")]
public async Task<IActionResult> UpdateAsync([FromBody] UpdateTagRequestDto request)
{
var result = await _tagService.UpdateAsync(request);
return Ok(ApiResponse<TagResponseDto>.Success(result, "태그 수정 성공"));
}
[HttpPost("delete")]
[SwaggerOperation(Summary = "태그 삭제", Description = "태그를 삭제합니다.")]
public async Task<IActionResult> 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;
}
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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<TagSummaryDto> 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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -22,6 +22,7 @@ public static class DependencyInjection
services.AddSingleton<IMessageValidationService, MessageValidationService>(); services.AddSingleton<IMessageValidationService, MessageValidationService>();
services.AddScoped<IMessageService, MessageService>(); services.AddScoped<IMessageService, MessageService>();
services.AddScoped<IStatsService, StatsService>(); services.AddScoped<IStatsService, StatsService>();
services.AddScoped<ITagService, TagService>();
return services; return services;
} }

View File

@ -0,0 +1,11 @@
using SPMS.Application.DTOs.Tag;
namespace SPMS.Application.Interfaces;
public interface ITagService
{
Task<TagListResponseDto> GetListAsync(long? scopeServiceId, TagListRequestDto request);
Task<TagResponseDto> CreateAsync(long adminId, CreateTagRequestDto request);
Task<TagResponseDto> UpdateAsync(UpdateTagRequestDto request);
Task DeleteAsync(DeleteTagRequestDto request);
}

View File

@ -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<TagListResponseDto> 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<TagResponseDto> 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<TagResponseDto> 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();
}
}

View File

@ -19,4 +19,5 @@ public interface IDeviceRepository : IRepository<Device>
Platform? platform = null, bool? pushAgreed = null, Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null, bool? isActive = null, List<int>? tags = null,
string? keyword = null, bool? marketingAgreed = null); string? keyword = null, bool? marketingAgreed = null);
Task<Dictionary<long, int>> GetDeviceCountsByTagIdsAsync(IEnumerable<long> tagIds);
} }

View File

@ -6,4 +6,7 @@ public interface ITagRepository : IRepository<Tag>
{ {
Task<Tag?> GetByIdWithServiceAsync(long id); Task<Tag?> GetByIdWithServiceAsync(long id);
Task<bool> ExistsInServiceAsync(long serviceId, string name); Task<bool> ExistsInServiceAsync(long serviceId, string name);
Task<(IReadOnlyList<Tag> Items, int TotalCount)> GetTagListAsync(
long? serviceId, string? keyword, int page, int size);
Task<int> CountByServiceAsync(long serviceId);
} }

View File

@ -128,4 +128,22 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
.OrderByDescending(d => d.CreatedAt) .OrderByDescending(d => d.CreatedAt)
.ToListAsync(); .ToListAsync();
} }
public async Task<Dictionary<long, int>> GetDeviceCountsByTagIdsAsync(IEnumerable<long> tagIds)
{
var tagIdList = tagIds.ToList();
if (tagIdList.Count == 0)
return new Dictionary<long, int>();
var result = new Dictionary<long, int>();
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;
}
} }

View File

@ -15,4 +15,32 @@ public class TagRepository : Repository<Tag>, ITagRepository
public async Task<bool> ExistsInServiceAsync(long serviceId, string name) public async Task<bool> ExistsInServiceAsync(long serviceId, string name)
=> await _dbSet.AnyAsync(t => t.ServiceId == serviceId && t.Name == name); => await _dbSet.AnyAsync(t => t.ServiceId == serviceId && t.Name == name);
public async Task<(IReadOnlyList<Tag> Items, int TotalCount)> GetTagListAsync(
long? serviceId, string? keyword, int page, int size)
{
IQueryable<Tag> 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<int> CountByServiceAsync(long serviceId)
=> await _dbSet.CountAsync(t => t.ServiceId == serviceId);
} }