improvement: 태그 CRUD API 구현 (#186) #245
73
SPMS.API/Controllers/TagController.cs
Normal file
73
SPMS.API/Controllers/TagController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
20
SPMS.Application/DTOs/Tag/CreateTagRequestDto.cs
Normal file
20
SPMS.Application/DTOs/Tag/CreateTagRequestDto.cs
Normal 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; }
|
||||
}
|
||||
11
SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs
Normal 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; }
|
||||
}
|
||||
18
SPMS.Application/DTOs/Tag/TagListRequestDto.cs
Normal file
18
SPMS.Application/DTOs/Tag/TagListRequestDto.cs
Normal 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;
|
||||
}
|
||||
40
SPMS.Application/DTOs/Tag/TagListResponseDto.cs
Normal file
40
SPMS.Application/DTOs/Tag/TagListResponseDto.cs
Normal 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; }
|
||||
}
|
||||
27
SPMS.Application/DTOs/Tag/TagResponseDto.cs
Normal file
27
SPMS.Application/DTOs/Tag/TagResponseDto.cs
Normal 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; }
|
||||
}
|
||||
15
SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs
Normal file
15
SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ public static class DependencyInjection
|
|||
services.AddSingleton<IMessageValidationService, MessageValidationService>();
|
||||
services.AddScoped<IMessageService, MessageService>();
|
||||
services.AddScoped<IStatsService, StatsService>();
|
||||
services.AddScoped<ITagService, TagService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
11
SPMS.Application/Interfaces/ITagService.cs
Normal file
11
SPMS.Application/Interfaces/ITagService.cs
Normal 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);
|
||||
}
|
||||
142
SPMS.Application/Services/TagService.cs
Normal file
142
SPMS.Application/Services/TagService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -19,4 +19,5 @@ public interface IDeviceRepository : IRepository<Device>
|
|||
Platform? platform = null, bool? pushAgreed = null,
|
||||
bool? isActive = null, List<int>? tags = null,
|
||||
string? keyword = null, bool? marketingAgreed = null);
|
||||
Task<Dictionary<long, int>> GetDeviceCountsByTagIdsAsync(IEnumerable<long> tagIds);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,7 @@ public interface ITagRepository : IRepository<Tag>
|
|||
{
|
||||
Task<Tag?> GetByIdWithServiceAsync(long id);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,4 +128,22 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
|
|||
.OrderByDescending(d => d.CreatedAt)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,32 @@ public class TagRepository : Repository<Tag>, ITagRepository
|
|||
|
||||
public async Task<bool> ExistsInServiceAsync(long serviceId, string 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user