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

3
.gitignore vendored
View File

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

View File

@ -43,7 +43,8 @@ public class TagController : ControllerBase
[SwaggerOperation(Summary = "태그 수정", Description = "태그 설명을 수정합니다. 태그명(Name)은 변경 불가.")]
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, "태그 수정 성공"));
}
@ -51,7 +52,8 @@ public class TagController : ControllerBase
[SwaggerOperation(Summary = "태그 삭제", Description = "태그를 삭제합니다.")]
public async Task<IActionResult> DeleteAsync([FromBody] DeleteTagRequestDto request)
{
await _tagService.DeleteAsync(request);
var serviceId = GetServiceId();
await _tagService.DeleteAsync(serviceId, request);
return Ok(ApiResponse.Success());
}
@ -62,6 +64,13 @@ public class TagController : ControllerBase
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()
{
var adminIdClaim = User.FindFirst("adminId")?.Value;

View File

@ -35,7 +35,7 @@ public class ServiceCodeMiddleware
if (path.StartsWithSegments("/v1/in/stats") ||
path.StartsWithSegments("/v1/in/device/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) &&
!string.IsNullOrWhiteSpace(optionalCode))

View File

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

View File

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

View File

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

View File

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

View File

@ -11,5 +11,5 @@ public class DeviceTagsRequestDto
[Required]
[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 List<int>? Tags { get; set; }
public List<string>? Tags { get; set; }
[Required]
public string ScheduledAt { get; set; } = string.Empty;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,6 @@ 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);
Task<TagResponseDto> UpdateAsync(long serviceId, UpdateTagRequestDto request);
Task DeleteAsync(long serviceId, DeleteTagRequestDto request);
}

View File

@ -12,15 +12,18 @@ namespace SPMS.Application.Services;
public class DeviceService : IDeviceService
{
private readonly IDeviceRepository _deviceRepository;
private readonly ITagRepository _tagRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly ITokenCacheService _tokenCache;
public DeviceService(
IDeviceRepository deviceRepository,
ITagRepository tagRepository,
IUnitOfWork unitOfWork,
ITokenCacheService tokenCache)
{
_deviceRepository = deviceRepository;
_tagRepository = tagRepository;
_unitOfWork = unitOfWork;
_tokenCache = tokenCache;
}
@ -80,7 +83,7 @@ public class DeviceService : IDeviceService
Model = device.DeviceModel,
PushAgreed = device.PushAgreed,
MarketingAgreed = device.MarketingAgreed,
Tags = ParseTags(device.Tags),
Tags = await ConvertTagIdsToCodesAsync(device.Tags, serviceId),
IsActive = device.IsActive,
LastActiveAt = device.UpdatedAt,
CreatedAt = device.CreatedAt
@ -138,13 +141,30 @@ public class DeviceService : IDeviceService
if (!string.IsNullOrWhiteSpace(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(
serviceId, request.Page, request.Size,
platform, request.PushAgreed, request.IsActive, request.Tags,
platform, request.PushAgreed, request.IsActive, tagIds,
request.Keyword, request.MarketingAgreed);
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
{
Items = items.Select(d => new DeviceSummaryDto
@ -153,7 +173,7 @@ public class DeviceService : IDeviceService
Platform = d.Platform.ToString().ToLowerInvariant(),
Model = d.DeviceModel,
PushAgreed = d.PushAgreed,
Tags = ParseTags(d.Tags),
Tags = ConvertTagIdsToCodesSync(d.Tags, tagIdToCode),
LastActiveAt = d.UpdatedAt,
DeviceToken = d.DeviceToken,
ServiceName = d.Service?.ServiceName ?? string.Empty,
@ -180,9 +200,17 @@ public class DeviceService : IDeviceService
if (!string.IsNullOrWhiteSpace(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(
serviceId, platform, request.PushAgreed, request.IsActive,
request.Tags, request.Keyword, request.MarketingAgreed);
tagIds, request.Keyword, request.MarketingAgreed);
using var workbook = new ClosedXML.Excel.XLWorkbook();
var ws = workbook.Worksheets.Add("기기 목록");
@ -235,7 +263,11 @@ public class DeviceService : IDeviceService
if (device == null)
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;
_deviceRepository.Update(device);
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))
return null;
try
{
return JsonSerializer.Deserialize<List<int>>(tagsJson);
return JsonSerializer.Deserialize<List<long>>(tagsJson);
}
catch
{
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 SPMS.Application.DTOs.Notice;
using SPMS.Application.DTOs.Tag;
@ -17,6 +18,8 @@ public class TagService : ITagService
private readonly IUnitOfWork _unitOfWork;
private const int MaxTagsPerService = 10;
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private const int TagCodeLength = 4;
public TagService(
ITagRepository tagRepository,
@ -32,7 +35,6 @@ public class TagService : ITagService
public async Task<TagListResponseDto> GetListAsync(long? scopeServiceId, TagListRequestDto request)
{
// scopeServiceId가 있으면 해당 서비스만, request.ServiceId도 추가 필터
var effectiveServiceId = scopeServiceId ?? request.ServiceId;
var (items, totalCount) = await _tagRepository.GetTagListAsync(
@ -58,7 +60,7 @@ public class TagService : ITagService
Items = items.Select(t => new TagSummaryDto
{
TagIndex = serviceTagOrders[t.ServiceId].IndexOf(t.Id) + 1,
TagId = t.Id,
TagCode = t.TagCode,
TagName = t.Name,
Description = t.Description,
ServiceId = t.ServiceId,
@ -93,9 +95,13 @@ public class TagService : ITagService
if (currentCount >= MaxTagsPerService)
throw new SpmsException(ErrorCodes.TagLimitExceeded, $"서비스당 태그는 최대 {MaxTagsPerService}개까지 생성할 수 있습니다.");
// TagCode 생성
var tagCode = await GenerateUniqueTagCodeAsync(request.ServiceId);
var tag = new Tag
{
ServiceId = request.ServiceId,
TagCode = tagCode,
Name = request.TagName,
Description = request.Description,
CreatedAt = DateTime.UtcNow,
@ -107,7 +113,7 @@ public class TagService : ITagService
return new TagResponseDto
{
TagId = tag.Id,
TagCode = tag.TagCode,
TagName = tag.Name,
Description = tag.Description,
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)
throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404);
@ -131,7 +137,7 @@ public class TagService : ITagService
return new TagResponseDto
{
TagId = tag.Id,
TagCode = tag.TagCode,
TagName = tag.Name,
Description = tag.Description,
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)
throw new SpmsException(ErrorCodes.TagNotFound, "태그를 찾을 수 없습니다.", 404);
@ -152,11 +158,11 @@ public class TagService : ITagService
try
{
// 해당 태그를 참조하는 디바이스의 Tags에서 tagId 제거
var devices = await _deviceRepository.GetDevicesByTagIdAsync(request.TagId);
var devices = await _deviceRepository.GetDevicesByTagIdAsync(tag.Id);
foreach (var device in devices)
{
var tagList = JsonSerializer.Deserialize<List<int>>(device.Tags!) ?? new();
tagList.Remove((int)request.TagId);
var tagList = JsonSerializer.Deserialize<List<long>>(device.Tags!) ?? new();
tagList.Remove(tag.Id);
device.Tags = tagList.Count > 0 ? JsonSerializer.Serialize(tagList) : null;
device.UpdatedAt = DateTime.UtcNow;
_deviceRepository.Update(device);
@ -173,4 +179,29 @@ public class TagService : ITagService
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 long ServiceId { get; set; }
public string TagCode { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { 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(
long? serviceId, int page, int size,
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);
Task<IReadOnlyList<Device>> GetAllFilteredAsync(
long? serviceId,
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);
Task<Dictionary<long, int>> GetDeviceCountsByTagIdsAsync(IEnumerable<long> tagIds);
Task<IReadOnlyList<Device>> GetDevicesByTagIdAsync(long tagId);

View File

@ -5,8 +5,11 @@ namespace SPMS.Domain.Interfaces;
public interface ITagRepository : IRepository<Tag>
{
Task<Tag?> GetByIdWithServiceAsync(long id);
Task<Tag?> GetByTagCodeAndServiceAsync(string tagCode, long serviceId);
Task<bool> ExistsInServiceAsync(long serviceId, string name);
Task<bool> TagCodeExistsInServiceAsync(long serviceId, string tagCode);
Task<(IReadOnlyList<Tag> Items, int TotalCount)> GetTagListAsync(
long? serviceId, string? keyword, int page, int size);
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")
.HasColumnType("bigint");
b.Property<string>("TagCode")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("varchar(4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
@ -924,6 +929,9 @@ namespace SPMS.Infrastructure.Migrations
b.HasIndex("ServiceId", "Name")
.IsUnique();
b.HasIndex("ServiceId", "TagCode")
.IsUnique();
b.ToTable("Tag", (string)null);
});

View File

@ -13,6 +13,7 @@ public class TagConfiguration : IEntityTypeConfiguration<Tag>
builder.HasKey(e => e.Id);
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.Description).HasMaxLength(200);
builder.Property(e => e.CreatedAt).IsRequired();
@ -30,5 +31,6 @@ public class TagConfiguration : IEntityTypeConfiguration<Tag>
.OnDelete(DeleteBehavior.Restrict);
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(
long? serviceId, int page, int size,
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)
{
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));
}
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}%"));
}
}
@ -86,7 +86,7 @@ public class DeviceRepository : Repository<Device>, IDeviceRepository
public async Task<IReadOnlyList<Device>> GetAllFilteredAsync(
long? serviceId,
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)
{
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));
}
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}%"));
}
}

View File

@ -13,9 +13,17 @@ public class TagRepository : Repository<Tag>, ITagRepository
.Include(t => t.Service)
.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)
=> 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(
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)
=> 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();
var serviceRepo = scope.ServiceProvider.GetRequiredService<IServiceRepository>();
var deviceRepo = scope.ServiceProvider.GetRequiredService<IDeviceRepository>();
var tagRepo = scope.ServiceProvider.GetRequiredService<ITagRepository>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
// 서비스 조회
@ -161,7 +162,7 @@ public class PushWorker : BackgroundService
}
// [2] send_type별 대상 Device 조회
var devices = await ResolveDevicesAsync(deviceRepo, pushMessage, ct);
var devices = await ResolveDevicesAsync(deviceRepo, tagRepo, pushMessage, ct);
if (devices.Count == 0)
{
_logger.LogInformation("발송 대상 없음: requestId={RequestId}", pushMessage.RequestId);
@ -296,7 +297,7 @@ public class PushWorker : BackgroundService
}
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();
@ -346,15 +347,21 @@ public class PushWorker : BackgroundService
case "group":
{
var tags = ParseTags(message.Target);
if (tags.Count == 0) return [];
// tag_code(string) 목록 파싱
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(
d => d.ServiceId == message.ServiceId && d.IsActive && d.PushAgreed);
return allDevices
.Where(d => !string.IsNullOrEmpty(d.Tags) &&
tags.Any(tag => d.Tags.Contains(tag)))
tagIdStrings.Any(tagIdStr => d.Tags.Contains(tagIdStr)))
.ToList();
}
@ -396,9 +403,23 @@ public class PushWorker : BackgroundService
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)
.Select(e => e.GetString()!)
.ToList();