improvement: TagCode 도입 — 태그 식별자를 4자리 랜덤 코드로 변경 (#269)

Closes #269
This commit is contained in:
SEAN 2026-03-02 16:12:06 +09:00
parent 165328b7df
commit 71cd7a5e98
27 changed files with 1535 additions and 56 deletions

1
.gitignore vendored
View File

@ -62,4 +62,5 @@ Dockerfile
Documents/ Documents/
CLAUDE.md CLAUDE.md
TASKS.md TASKS.md
TODO.md
.mcp.json .mcp.json

View File

@ -43,7 +43,8 @@ public class TagController : ControllerBase
[SwaggerOperation(Summary = "태그 수정", Description = "태그 설명을 수정합니다. 태그명(Name)은 변경 불가.")] [SwaggerOperation(Summary = "태그 수정", Description = "태그 설명을 수정합니다. 태그명(Name)은 변경 불가.")]
public async Task<IActionResult> UpdateAsync([FromBody] UpdateTagRequestDto request) public async Task<IActionResult> UpdateAsync([FromBody] UpdateTagRequestDto request)
{ {
var result = await _tagService.UpdateAsync(request); var serviceId = GetServiceId();
var result = await _tagService.UpdateAsync(serviceId, request);
return Ok(ApiResponse<TagResponseDto>.Success(result, "태그 수정 성공")); return Ok(ApiResponse<TagResponseDto>.Success(result, "태그 수정 성공"));
} }
@ -51,7 +52,8 @@ public class TagController : ControllerBase
[SwaggerOperation(Summary = "태그 삭제", Description = "태그를 삭제합니다.")] [SwaggerOperation(Summary = "태그 삭제", Description = "태그를 삭제합니다.")]
public async Task<IActionResult> DeleteAsync([FromBody] DeleteTagRequestDto request) public async Task<IActionResult> DeleteAsync([FromBody] DeleteTagRequestDto request)
{ {
await _tagService.DeleteAsync(request); var serviceId = GetServiceId();
await _tagService.DeleteAsync(serviceId, request);
return Ok(ApiResponse.Success()); return Ok(ApiResponse.Success());
} }
@ -62,6 +64,13 @@ public class TagController : ControllerBase
return null; return null;
} }
private long GetServiceId()
{
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
return serviceId;
throw new SpmsException(ErrorCodes.ServiceScopeRequired, "X-Service-Code 헤더가 필요합니다.", 400);
}
private long GetAdminId() private long GetAdminId()
{ {
var adminIdClaim = User.FindFirst("adminId")?.Value; var adminIdClaim = User.FindFirst("adminId")?.Value;

View File

@ -35,7 +35,7 @@ public class ServiceCodeMiddleware
if (path.StartsWithSegments("/v1/in/stats") || if (path.StartsWithSegments("/v1/in/stats") ||
path.StartsWithSegments("/v1/in/device/list") || path.StartsWithSegments("/v1/in/device/list") ||
path.StartsWithSegments("/v1/in/message/list") || path.StartsWithSegments("/v1/in/message/list") ||
path.StartsWithSegments("/v1/in/tag")) path == "/v1/in/tag/list")
{ {
if (context.Request.Headers.TryGetValue("X-Service-Code", out var optionalCode) && if (context.Request.Headers.TryGetValue("X-Service-Code", out var optionalCode) &&
!string.IsNullOrWhiteSpace(optionalCode)) !string.IsNullOrWhiteSpace(optionalCode))

View File

@ -11,7 +11,7 @@ public class DeviceExportRequestDto
public bool? PushAgreed { get; set; } public bool? PushAgreed { get; set; }
[JsonPropertyName("tags")] [JsonPropertyName("tags")]
public List<int>? Tags { get; set; } public List<string>? Tags { get; set; }
[JsonPropertyName("is_active")] [JsonPropertyName("is_active")]
public bool? IsActive { get; set; } public bool? IsActive { get; set; }

View File

@ -29,7 +29,7 @@ public class DeviceInfoResponseDto
public bool MarketingAgreed { get; set; } public bool MarketingAgreed { get; set; }
[JsonPropertyName("tags")] [JsonPropertyName("tags")]
public List<int>? Tags { get; set; } public List<string>? Tags { get; set; }
[JsonPropertyName("is_active")] [JsonPropertyName("is_active")]
public bool IsActive { get; set; } public bool IsActive { get; set; }

View File

@ -17,7 +17,7 @@ public class DeviceListRequestDto
public bool? PushAgreed { get; set; } public bool? PushAgreed { get; set; }
[JsonPropertyName("tags")] [JsonPropertyName("tags")]
public List<int>? Tags { get; set; } public List<string>? Tags { get; set; }
[JsonPropertyName("is_active")] [JsonPropertyName("is_active")]
public bool? IsActive { get; set; } public bool? IsActive { get; set; }

View File

@ -27,7 +27,7 @@ public class DeviceSummaryDto
public bool PushAgreed { get; set; } public bool PushAgreed { get; set; }
[JsonPropertyName("tags")] [JsonPropertyName("tags")]
public List<int>? Tags { get; set; } public List<string>? Tags { get; set; }
[JsonPropertyName("last_active_at")] [JsonPropertyName("last_active_at")]
public DateTime? LastActiveAt { get; set; } public DateTime? LastActiveAt { get; set; }

View File

@ -11,5 +11,5 @@ public class DeviceTagsRequestDto
[Required] [Required]
[JsonPropertyName("tags")] [JsonPropertyName("tags")]
public List<int> Tags { get; set; } = new(); public List<string> Tags { get; set; } = new();
} }

View File

@ -12,7 +12,7 @@ public class PushScheduleRequestDto
public long? DeviceId { get; set; } public long? DeviceId { get; set; }
public List<int>? Tags { get; set; } public List<string>? Tags { get; set; }
[Required] [Required]
public string ScheduledAt { get; set; } = string.Empty; public string ScheduledAt { get; set; } = string.Empty;

View File

@ -9,7 +9,7 @@ public class PushSendTagRequestDto
[Required] [Required]
[MinLength(1)] [MinLength(1)]
public List<int> Tags { get; set; } = []; public List<string> Tags { get; set; } = [];
public string TagMatch { get; set; } = "or"; public string TagMatch { get; set; } = "or";
} }

View File

@ -6,6 +6,6 @@ namespace SPMS.Application.DTOs.Tag;
public class DeleteTagRequestDto public class DeleteTagRequestDto
{ {
[Required] [Required]
[JsonPropertyName("tag_id")] [JsonPropertyName("tag_code")]
public long TagId { get; set; } public string TagCode { get; set; } = string.Empty;
} }

View File

@ -17,8 +17,8 @@ public class TagSummaryDto
[JsonPropertyName("tag_index")] [JsonPropertyName("tag_index")]
public int TagIndex { get; set; } public int TagIndex { get; set; }
[JsonPropertyName("tag_id")] [JsonPropertyName("tag_code")]
public long TagId { get; set; } public string TagCode { get; set; } = string.Empty;
[JsonPropertyName("tag_name")] [JsonPropertyName("tag_name")]
public string TagName { get; set; } = string.Empty; public string TagName { get; set; } = string.Empty;

View File

@ -4,8 +4,8 @@ namespace SPMS.Application.DTOs.Tag;
public class TagResponseDto public class TagResponseDto
{ {
[JsonPropertyName("tag_id")] [JsonPropertyName("tag_code")]
public long TagId { get; set; } public string TagCode { get; set; } = string.Empty;
[JsonPropertyName("tag_name")] [JsonPropertyName("tag_name")]
public string TagName { get; set; } = string.Empty; public string TagName { get; set; } = string.Empty;

View File

@ -6,8 +6,8 @@ namespace SPMS.Application.DTOs.Tag;
public class UpdateTagRequestDto public class UpdateTagRequestDto
{ {
[Required] [Required]
[JsonPropertyName("tag_id")] [JsonPropertyName("tag_code")]
public long TagId { get; set; } public string TagCode { get; set; } = string.Empty;
[MaxLength(200)] [MaxLength(200)]
[JsonPropertyName("description")] [JsonPropertyName("description")]

View File

@ -6,6 +6,6 @@ public interface ITagService
{ {
Task<TagListResponseDto> GetListAsync(long? scopeServiceId, TagListRequestDto request); Task<TagListResponseDto> GetListAsync(long? scopeServiceId, TagListRequestDto request);
Task<TagResponseDto> CreateAsync(long adminId, CreateTagRequestDto request); Task<TagResponseDto> CreateAsync(long adminId, CreateTagRequestDto request);
Task<TagResponseDto> UpdateAsync(UpdateTagRequestDto request); Task<TagResponseDto> UpdateAsync(long serviceId, UpdateTagRequestDto request);
Task DeleteAsync(DeleteTagRequestDto request); Task DeleteAsync(long serviceId, DeleteTagRequestDto request);
} }

View File

@ -12,15 +12,18 @@ namespace SPMS.Application.Services;
public class DeviceService : IDeviceService public class DeviceService : IDeviceService
{ {
private readonly IDeviceRepository _deviceRepository; private readonly IDeviceRepository _deviceRepository;
private readonly ITagRepository _tagRepository;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ITokenCacheService _tokenCache; private readonly ITokenCacheService _tokenCache;
public DeviceService( public DeviceService(
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
ITagRepository tagRepository,
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
ITokenCacheService tokenCache) ITokenCacheService tokenCache)
{ {
_deviceRepository = deviceRepository; _deviceRepository = deviceRepository;
_tagRepository = tagRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_tokenCache = tokenCache; _tokenCache = tokenCache;
} }
@ -80,7 +83,7 @@ public class DeviceService : IDeviceService
Model = device.DeviceModel, Model = device.DeviceModel,
PushAgreed = device.PushAgreed, PushAgreed = device.PushAgreed,
MarketingAgreed = device.MarketingAgreed, MarketingAgreed = device.MarketingAgreed,
Tags = ParseTags(device.Tags), Tags = await ConvertTagIdsToCodesAsync(device.Tags, serviceId),
IsActive = device.IsActive, IsActive = device.IsActive,
LastActiveAt = device.UpdatedAt, LastActiveAt = device.UpdatedAt,
CreatedAt = device.CreatedAt CreatedAt = device.CreatedAt
@ -138,13 +141,30 @@ public class DeviceService : IDeviceService
if (!string.IsNullOrWhiteSpace(request.Platform)) if (!string.IsNullOrWhiteSpace(request.Platform))
platform = ParsePlatform(request.Platform); platform = ParsePlatform(request.Platform);
// tag_code → tag_id 변환
List<long>? tagIds = null;
if (request.Tags != null && request.Tags.Count > 0 && serviceId.HasValue)
{
var tags = await _tagRepository.GetByTagCodesAndServiceAsync(request.Tags, serviceId.Value);
tagIds = tags.Select(t => t.Id).ToList();
}
var (items, totalCount) = await _deviceRepository.GetPagedAsync( var (items, totalCount) = await _deviceRepository.GetPagedAsync(
serviceId, request.Page, request.Size, serviceId, request.Page, request.Size,
platform, request.PushAgreed, request.IsActive, request.Tags, platform, request.PushAgreed, request.IsActive, tagIds,
request.Keyword, request.MarketingAgreed); request.Keyword, request.MarketingAgreed);
var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); var totalPages = (int)Math.Ceiling((double)totalCount / request.Size);
// 응답의 tag_id → tag_code 일괄 변환을 위한 맵 구축
var allTagIds = new HashSet<long>();
foreach (var d in items)
{
var ids = ParseTagIds(d.Tags);
if (ids != null) allTagIds.UnionWith(ids);
}
var tagIdToCode = await BuildTagIdToCodeMapAsync(allTagIds, serviceId);
return new DeviceListResponseDto return new DeviceListResponseDto
{ {
Items = items.Select(d => new DeviceSummaryDto Items = items.Select(d => new DeviceSummaryDto
@ -153,7 +173,7 @@ public class DeviceService : IDeviceService
Platform = d.Platform.ToString().ToLowerInvariant(), Platform = d.Platform.ToString().ToLowerInvariant(),
Model = d.DeviceModel, Model = d.DeviceModel,
PushAgreed = d.PushAgreed, PushAgreed = d.PushAgreed,
Tags = ParseTags(d.Tags), Tags = ConvertTagIdsToCodesSync(d.Tags, tagIdToCode),
LastActiveAt = d.UpdatedAt, LastActiveAt = d.UpdatedAt,
DeviceToken = d.DeviceToken, DeviceToken = d.DeviceToken,
ServiceName = d.Service?.ServiceName ?? string.Empty, ServiceName = d.Service?.ServiceName ?? string.Empty,
@ -180,9 +200,17 @@ public class DeviceService : IDeviceService
if (!string.IsNullOrWhiteSpace(request.Platform)) if (!string.IsNullOrWhiteSpace(request.Platform))
platform = ParsePlatform(request.Platform); platform = ParsePlatform(request.Platform);
// tag_code → tag_id 변환
List<long>? tagIds = null;
if (request.Tags != null && request.Tags.Count > 0 && serviceId.HasValue)
{
var tags = await _tagRepository.GetByTagCodesAndServiceAsync(request.Tags, serviceId.Value);
tagIds = tags.Select(t => t.Id).ToList();
}
var items = await _deviceRepository.GetAllFilteredAsync( var items = await _deviceRepository.GetAllFilteredAsync(
serviceId, platform, request.PushAgreed, request.IsActive, serviceId, platform, request.PushAgreed, request.IsActive,
request.Tags, request.Keyword, request.MarketingAgreed); tagIds, request.Keyword, request.MarketingAgreed);
using var workbook = new ClosedXML.Excel.XLWorkbook(); using var workbook = new ClosedXML.Excel.XLWorkbook();
var ws = workbook.Worksheets.Add("기기 목록"); var ws = workbook.Worksheets.Add("기기 목록");
@ -235,7 +263,11 @@ public class DeviceService : IDeviceService
if (device == null) if (device == null)
throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404);
device.Tags = JsonSerializer.Serialize(request.Tags); // tag_code → tag_id 변환
var tags = await _tagRepository.GetByTagCodesAndServiceAsync(request.Tags, serviceId);
var tagIds = tags.Select(t => t.Id).ToList();
device.Tags = tagIds.Count > 0 ? JsonSerializer.Serialize(tagIds) : null;
device.UpdatedAt = DateTime.UtcNow; device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device); _deviceRepository.Update(device);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
@ -268,17 +300,58 @@ public class DeviceService : IDeviceService
}; };
} }
private static List<int>? ParseTags(string? tagsJson) private static List<long>? ParseTagIds(string? tagsJson)
{ {
if (string.IsNullOrWhiteSpace(tagsJson)) if (string.IsNullOrWhiteSpace(tagsJson))
return null; return null;
try try
{ {
return JsonSerializer.Deserialize<List<int>>(tagsJson); return JsonSerializer.Deserialize<List<long>>(tagsJson);
} }
catch catch
{ {
return null; return null;
} }
} }
/// <summary>
/// DB에 저장된 tag_id JSON을 tag_code 목록으로 변환 (단일 디바이스용)
/// </summary>
private async Task<List<string>?> ConvertTagIdsToCodesAsync(string? tagsJson, long serviceId)
{
var tagIds = ParseTagIds(tagsJson);
if (tagIds == null || tagIds.Count == 0)
return null;
var tags = await _tagRepository.FindAsync(t => t.ServiceId == serviceId && tagIds.Contains(t.Id));
return tags.Select(t => t.TagCode).ToList();
}
/// <summary>
/// 미리 구축한 tagId→tagCode 맵을 사용하여 동기 변환 (목록 조회용)
/// </summary>
private static List<string>? ConvertTagIdsToCodesSync(string? tagsJson, Dictionary<long, string> tagIdToCode)
{
var tagIds = ParseTagIds(tagsJson);
if (tagIds == null || tagIds.Count == 0)
return null;
return tagIds
.Where(id => tagIdToCode.ContainsKey(id))
.Select(id => tagIdToCode[id])
.ToList();
}
/// <summary>
/// tag_id 집합으로부터 tag_id → tag_code 매핑 딕셔너리 구축
/// </summary>
private async Task<Dictionary<long, string>> BuildTagIdToCodeMapAsync(HashSet<long> tagIds, long? serviceId)
{
if (tagIds.Count == 0)
return new Dictionary<long, string>();
var tagIdList = tagIds.ToList();
var tags = await _tagRepository.FindAsync(t => tagIdList.Contains(t.Id));
return tags.ToDictionary(t => t.Id, t => t.TagCode);
}
} }

View File

@ -1,3 +1,4 @@
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using SPMS.Application.DTOs.Notice; using SPMS.Application.DTOs.Notice;
using SPMS.Application.DTOs.Tag; using SPMS.Application.DTOs.Tag;
@ -17,6 +18,8 @@ public class TagService : ITagService
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private const int MaxTagsPerService = 10; private const int MaxTagsPerService = 10;
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private const int TagCodeLength = 4;
public TagService( public TagService(
ITagRepository tagRepository, ITagRepository tagRepository,
@ -32,7 +35,6 @@ public class TagService : ITagService
public async Task<TagListResponseDto> GetListAsync(long? scopeServiceId, TagListRequestDto request) public async Task<TagListResponseDto> GetListAsync(long? scopeServiceId, TagListRequestDto request)
{ {
// scopeServiceId가 있으면 해당 서비스만, request.ServiceId도 추가 필터
var effectiveServiceId = scopeServiceId ?? request.ServiceId; var effectiveServiceId = scopeServiceId ?? request.ServiceId;
var (items, totalCount) = await _tagRepository.GetTagListAsync( var (items, totalCount) = await _tagRepository.GetTagListAsync(
@ -58,7 +60,7 @@ public class TagService : ITagService
Items = items.Select(t => new TagSummaryDto Items = items.Select(t => new TagSummaryDto
{ {
TagIndex = serviceTagOrders[t.ServiceId].IndexOf(t.Id) + 1, TagIndex = serviceTagOrders[t.ServiceId].IndexOf(t.Id) + 1,
TagId = t.Id, TagCode = t.TagCode,
TagName = t.Name, TagName = t.Name,
Description = t.Description, Description = t.Description,
ServiceId = t.ServiceId, ServiceId = t.ServiceId,
@ -93,9 +95,13 @@ public class TagService : ITagService
if (currentCount >= MaxTagsPerService) if (currentCount >= MaxTagsPerService)
throw new SpmsException(ErrorCodes.TagLimitExceeded, $"서비스당 태그는 최대 {MaxTagsPerService}개까지 생성할 수 있습니다."); throw new SpmsException(ErrorCodes.TagLimitExceeded, $"서비스당 태그는 최대 {MaxTagsPerService}개까지 생성할 수 있습니다.");
// TagCode 생성
var tagCode = await GenerateUniqueTagCodeAsync(request.ServiceId);
var tag = new Tag var tag = new Tag
{ {
ServiceId = request.ServiceId, ServiceId = request.ServiceId,
TagCode = tagCode,
Name = request.TagName, Name = request.TagName,
Description = request.Description, Description = request.Description,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
@ -107,7 +113,7 @@ public class TagService : ITagService
return new TagResponseDto return new TagResponseDto
{ {
TagId = tag.Id, TagCode = tag.TagCode,
TagName = tag.Name, TagName = tag.Name,
Description = tag.Description, Description = tag.Description,
ServiceId = tag.ServiceId, ServiceId = tag.ServiceId,
@ -117,9 +123,9 @@ public class TagService : ITagService
}; };
} }
public async Task<TagResponseDto> UpdateAsync(UpdateTagRequestDto request) public async Task<TagResponseDto> UpdateAsync(long serviceId, UpdateTagRequestDto request)
{ {
var tag = await _tagRepository.GetByIdWithServiceAsync(request.TagId); var tag = await _tagRepository.GetByTagCodeAndServiceAsync(request.TagCode, serviceId);
if (tag == null) if (tag == null)
throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404); throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404);
@ -131,7 +137,7 @@ public class TagService : ITagService
return new TagResponseDto return new TagResponseDto
{ {
TagId = tag.Id, TagCode = tag.TagCode,
TagName = tag.Name, TagName = tag.Name,
Description = tag.Description, Description = tag.Description,
ServiceId = tag.ServiceId, ServiceId = tag.ServiceId,
@ -141,9 +147,9 @@ public class TagService : ITagService
}; };
} }
public async Task DeleteAsync(DeleteTagRequestDto request) public async Task DeleteAsync(long serviceId, DeleteTagRequestDto request)
{ {
var tag = await _tagRepository.GetByIdWithServiceAsync(request.TagId); var tag = await _tagRepository.GetByTagCodeAndServiceAsync(request.TagCode, serviceId);
if (tag == null) if (tag == null)
throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404); throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404);
@ -152,11 +158,11 @@ public class TagService : ITagService
try try
{ {
// 해당 태그를 참조하는 디바이스의 Tags에서 tagId 제거 // 해당 태그를 참조하는 디바이스의 Tags에서 tagId 제거
var devices = await _deviceRepository.GetDevicesByTagIdAsync(request.TagId); var devices = await _deviceRepository.GetDevicesByTagIdAsync(tag.Id);
foreach (var device in devices) foreach (var device in devices)
{ {
var tagList = JsonSerializer.Deserialize<List<int>>(device.Tags!) ?? new(); var tagList = JsonSerializer.Deserialize<List<long>>(device.Tags!) ?? new();
tagList.Remove((int)request.TagId); tagList.Remove(tag.Id);
device.Tags = tagList.Count > 0 ? JsonSerializer.Serialize(tagList) : null; device.Tags = tagList.Count > 0 ? JsonSerializer.Serialize(tagList) : null;
device.UpdatedAt = DateTime.UtcNow; device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device); _deviceRepository.Update(device);
@ -173,4 +179,29 @@ public class TagService : ITagService
throw; throw;
} }
} }
private async Task<string> GenerateUniqueTagCodeAsync(long serviceId)
{
var buffer = new byte[TagCodeLength];
for (var attempt = 0; attempt < 10; attempt++)
{
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(buffer);
}
var code = new char[TagCodeLength];
for (var i = 0; i < TagCodeLength; i++)
{
code[i] = Alphabet[buffer[i] % Alphabet.Length];
}
var tagCode = new string(code);
if (!await _tagRepository.TagCodeExistsInServiceAsync(serviceId, tagCode))
return tagCode;
}
throw new SpmsException(ErrorCodes.InternalError, "태그 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요.", 500);
}
} }

View File

@ -3,6 +3,7 @@ namespace SPMS.Domain.Entities;
public class Tag : BaseEntity public class Tag : BaseEntity
{ {
public long ServiceId { get; set; } public long ServiceId { get; set; }
public string TagCode { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string? Description { get; set; } public string? Description { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }

View File

@ -12,12 +12,12 @@ public interface IDeviceRepository : IRepository<Device>
Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync( Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(
long? serviceId, int page, int size, long? serviceId, int page, int size,
Platform? platform = null, bool? pushAgreed = null, Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null, bool? isActive = null, List<long>? tagIds = null,
string? keyword = null, bool? marketingAgreed = null); string? keyword = null, bool? marketingAgreed = null);
Task<IReadOnlyList<Device>> GetAllFilteredAsync( Task<IReadOnlyList<Device>> GetAllFilteredAsync(
long? serviceId, long? serviceId,
Platform? platform = null, bool? pushAgreed = null, Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null, bool? isActive = null, List<long>? tagIds = null,
string? keyword = null, bool? marketingAgreed = null); string? keyword = null, bool? marketingAgreed = null);
Task<Dictionary<long, int>> GetDeviceCountsByTagIdsAsync(IEnumerable<long> tagIds); Task<Dictionary<long, int>> GetDeviceCountsByTagIdsAsync(IEnumerable<long> tagIds);
Task<IReadOnlyList<Device>> GetDevicesByTagIdAsync(long tagId); Task<IReadOnlyList<Device>> GetDevicesByTagIdAsync(long tagId);

View File

@ -5,8 +5,11 @@ namespace SPMS.Domain.Interfaces;
public interface ITagRepository : IRepository<Tag> public interface ITagRepository : IRepository<Tag>
{ {
Task<Tag?> GetByIdWithServiceAsync(long id); Task<Tag?> GetByIdWithServiceAsync(long id);
Task<Tag?> GetByTagCodeAndServiceAsync(string tagCode, long serviceId);
Task<bool> ExistsInServiceAsync(long serviceId, string name); Task<bool> ExistsInServiceAsync(long serviceId, string name);
Task<bool> TagCodeExistsInServiceAsync(long serviceId, string tagCode);
Task<(IReadOnlyList<Tag> Items, int TotalCount)> GetTagListAsync( Task<(IReadOnlyList<Tag> Items, int TotalCount)> GetTagListAsync(
long? serviceId, string? keyword, int page, int size); long? serviceId, string? keyword, int page, int size);
Task<int> CountByServiceAsync(long serviceId); Task<int> CountByServiceAsync(long serviceId);
Task<IReadOnlyList<Tag>> GetByTagCodesAndServiceAsync(IEnumerable<string> tagCodes, long serviceId);
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SPMS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTagCode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TagCode",
table: "Tag",
type: "varchar(4)",
maxLength: 4,
nullable: false,
defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4");
// 기존 태그에 4자리 랜덤 코드 할당 (서비스별 유니크)
migrationBuilder.Sql(@"
UPDATE Tag
SET TagCode = CONCAT(
SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', FLOOR(1 + RAND() * 62), 1),
SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', FLOOR(1 + RAND() * 62), 1),
SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', FLOOR(1 + RAND() * 62), 1),
SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', FLOOR(1 + RAND() * 62), 1)
)
WHERE TagCode = '';
");
migrationBuilder.CreateIndex(
name: "IX_Tag_ServiceId_TagCode",
table: "Tag",
columns: new[] { "ServiceId", "TagCode" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Tag_ServiceId_TagCode",
table: "Tag");
migrationBuilder.DropColumn(
name: "TagCode",
table: "Tag");
}
}
}

View File

@ -914,6 +914,11 @@ namespace SPMS.Infrastructure.Migrations
b.Property<long>("ServiceId") b.Property<long>("ServiceId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("TagCode")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("varchar(4)");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@ -924,6 +929,9 @@ namespace SPMS.Infrastructure.Migrations
b.HasIndex("ServiceId", "Name") b.HasIndex("ServiceId", "Name")
.IsUnique(); .IsUnique();
b.HasIndex("ServiceId", "TagCode")
.IsUnique();
b.ToTable("Tag", (string)null); b.ToTable("Tag", (string)null);
}); });

View File

@ -13,6 +13,7 @@ public class TagConfiguration : IEntityTypeConfiguration<Tag>
builder.HasKey(e => e.Id); builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd(); builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.TagCode).HasMaxLength(4).IsRequired();
builder.Property(e => e.Name).HasMaxLength(50).IsRequired(); builder.Property(e => e.Name).HasMaxLength(50).IsRequired();
builder.Property(e => e.Description).HasMaxLength(200); builder.Property(e => e.Description).HasMaxLength(200);
builder.Property(e => e.CreatedAt).IsRequired(); builder.Property(e => e.CreatedAt).IsRequired();
@ -30,5 +31,6 @@ public class TagConfiguration : IEntityTypeConfiguration<Tag>
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => new { e.ServiceId, e.Name }).IsUnique(); builder.HasIndex(e => new { e.ServiceId, e.Name }).IsUnique();
builder.HasIndex(e => new { e.ServiceId, e.TagCode }).IsUnique();
} }
} }

View File

@ -34,7 +34,7 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
public async Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync( public async Task<(IReadOnlyList<Device> Items, int TotalCount)> GetPagedAsync(
long? serviceId, int page, int size, long? serviceId, int page, int size,
Platform? platform = null, bool? pushAgreed = null, Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null, bool? isActive = null, List<long>? tagIds = null,
string? keyword = null, bool? marketingAgreed = null) string? keyword = null, bool? marketingAgreed = null)
{ {
IQueryable<Device> query = _dbSet.Include(d => d.Service); IQueryable<Device> query = _dbSet.Include(d => d.Service);
@ -63,11 +63,11 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
query = query.Where(d => d.DeviceToken.Contains(trimmed)); query = query.Where(d => d.DeviceToken.Contains(trimmed));
} }
if (tags != null && tags.Count > 0) if (tagIds != null && tagIds.Count > 0)
{ {
foreach (var tag in tags) foreach (var tagId in tagIds)
{ {
var tagStr = tag.ToString(); var tagStr = tagId.ToString();
query = query.Where(d => d.Tags != null && EF.Functions.Like(d.Tags, $"%{tagStr}%")); query = query.Where(d => d.Tags != null && EF.Functions.Like(d.Tags, $"%{tagStr}%"));
} }
} }
@ -86,7 +86,7 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
public async Task<IReadOnlyList<Device>> GetAllFilteredAsync( public async Task<IReadOnlyList<Device>> GetAllFilteredAsync(
long? serviceId, long? serviceId,
Platform? platform = null, bool? pushAgreed = null, Platform? platform = null, bool? pushAgreed = null,
bool? isActive = null, List<int>? tags = null, bool? isActive = null, List<long>? tagIds = null,
string? keyword = null, bool? marketingAgreed = null) string? keyword = null, bool? marketingAgreed = null)
{ {
IQueryable<Device> query = _dbSet.Include(d => d.Service); IQueryable<Device> query = _dbSet.Include(d => d.Service);
@ -115,11 +115,11 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
query = query.Where(d => d.DeviceToken.Contains(trimmed)); query = query.Where(d => d.DeviceToken.Contains(trimmed));
} }
if (tags != null && tags.Count > 0) if (tagIds != null && tagIds.Count > 0)
{ {
foreach (var tag in tags) foreach (var tagId in tagIds)
{ {
var tagStr = tag.ToString(); var tagStr = tagId.ToString();
query = query.Where(d => d.Tags != null && EF.Functions.Like(d.Tags, $"%{tagStr}%")); query = query.Where(d => d.Tags != null && EF.Functions.Like(d.Tags, $"%{tagStr}%"));
} }
} }

View File

@ -13,9 +13,17 @@ public class TagRepository : Repository<Tag>, ITagRepository
.Include(t => t.Service) .Include(t => t.Service)
.FirstOrDefaultAsync(t => t.Id == id); .FirstOrDefaultAsync(t => t.Id == id);
public async Task<Tag?> GetByTagCodeAndServiceAsync(string tagCode, long serviceId)
=> await _dbSet
.Include(t => t.Service)
.FirstOrDefaultAsync(t => t.TagCode == tagCode && t.ServiceId == serviceId);
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<bool> TagCodeExistsInServiceAsync(long serviceId, string tagCode)
=> await _dbSet.AnyAsync(t => t.ServiceId == serviceId && t.TagCode == tagCode);
public async Task<(IReadOnlyList<Tag> Items, int TotalCount)> GetTagListAsync( public async Task<(IReadOnlyList<Tag> Items, int TotalCount)> GetTagListAsync(
long? serviceId, string? keyword, int page, int size) long? serviceId, string? keyword, int page, int size)
{ {
@ -43,4 +51,12 @@ public class TagRepository : Repository<Tag>, ITagRepository
public async Task<int> CountByServiceAsync(long serviceId) public async Task<int> CountByServiceAsync(long serviceId)
=> await _dbSet.CountAsync(t => t.ServiceId == serviceId); => await _dbSet.CountAsync(t => t.ServiceId == serviceId);
public async Task<IReadOnlyList<Tag>> GetByTagCodesAndServiceAsync(IEnumerable<string> tagCodes, long serviceId)
{
var codeList = tagCodes.ToList();
return await _dbSet
.Where(t => t.ServiceId == serviceId && codeList.Contains(t.TagCode))
.ToListAsync();
}
} }

View File

@ -149,6 +149,7 @@ public class PushWorker : BackgroundService
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var serviceRepo = scope.ServiceProvider.GetRequiredService<IServiceRepository>(); var serviceRepo = scope.ServiceProvider.GetRequiredService<IServiceRepository>();
var deviceRepo = scope.ServiceProvider.GetRequiredService<IDeviceRepository>(); var deviceRepo = scope.ServiceProvider.GetRequiredService<IDeviceRepository>();
var tagRepo = scope.ServiceProvider.GetRequiredService<ITagRepository>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
// 서비스 조회 // 서비스 조회
@ -161,7 +162,7 @@ public class PushWorker : BackgroundService
} }
// [2] send_type별 대상 Device 조회 // [2] send_type별 대상 Device 조회
var devices = await ResolveDevicesAsync(deviceRepo, pushMessage, ct); var devices = await ResolveDevicesAsync(deviceRepo, tagRepo, pushMessage, ct);
if (devices.Count == 0) if (devices.Count == 0)
{ {
_logger.LogInformation("발송 대상 없음: requestId={RequestId}", pushMessage.RequestId); _logger.LogInformation("발송 대상 없음: requestId={RequestId}", pushMessage.RequestId);
@ -296,7 +297,7 @@ public class PushWorker : BackgroundService
} }
private async Task<List<Device>> ResolveDevicesAsync( private async Task<List<Device>> ResolveDevicesAsync(
IDeviceRepository deviceRepo, PushMessageDto message, CancellationToken ct) IDeviceRepository deviceRepo, ITagRepository tagRepo, PushMessageDto message, CancellationToken ct)
{ {
var sendType = message.SendType.ToLowerInvariant(); var sendType = message.SendType.ToLowerInvariant();
@ -346,15 +347,21 @@ public class PushWorker : BackgroundService
case "group": case "group":
{ {
var tags = ParseTags(message.Target); // tag_code(string) 목록 파싱
if (tags.Count == 0) return []; var tagCodes = ParseTags(message.Target);
if (tagCodes.Count == 0) return [];
// tag_code → tag_id 변환
var tags = await tagRepo.GetByTagCodesAndServiceAsync(tagCodes, message.ServiceId);
var tagIdStrings = tags.Select(t => t.Id.ToString()).ToHashSet();
if (tagIdStrings.Count == 0) return [];
var allDevices = await deviceRepo.FindAsync( var allDevices = await deviceRepo.FindAsync(
d => d.ServiceId == message.ServiceId && d.IsActive && d.PushAgreed); d => d.ServiceId == message.ServiceId && d.IsActive && d.PushAgreed);
return allDevices return allDevices
.Where(d => !string.IsNullOrEmpty(d.Tags) && .Where(d => !string.IsNullOrEmpty(d.Tags) &&
tags.Any(tag => d.Tags.Contains(tag))) tagIdStrings.Any(tagIdStr => d.Tags.Contains(tagIdStr)))
.ToList(); .ToList();
} }
@ -396,9 +403,23 @@ public class PushWorker : BackgroundService
try try
{ {
if (target.Value.Value.ValueKind == JsonValueKind.Array) var element = target.Value.Value;
// { tags: [...], match: "or" } 형태
if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("tags", out var tagsElement) &&
tagsElement.ValueKind == JsonValueKind.Array)
{ {
return target.Value.Value.EnumerateArray() return tagsElement.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString()!)
.ToList();
}
// 직접 배열 형태 (호환)
if (element.ValueKind == JsonValueKind.Array)
{
return element.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.String) .Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString()!) .Select(e => e.GetString()!)
.ToList(); .ToList();