improvement: TagCode 도입 — 태그 식별자를 4자리 랜덤 코드로 변경 (#269)
All checks were successful
SPMS_API/pipeline/head This commit looks good
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/270
This commit is contained in:
commit
6c3a384a99
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -62,4 +62,5 @@ Dockerfile
|
|||
Documents/
|
||||
CLAUDE.md
|
||||
TASKS.md
|
||||
.mcp.json
|
||||
TODO.md
|
||||
.mcp.json
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -11,5 +11,5 @@ public class DeviceTagsRequestDto
|
|||
|
||||
[Required]
|
||||
[JsonPropertyName("tags")]
|
||||
public List<int> Tags { get; set; } = new();
|
||||
public List<string> Tags { get; set; } = new();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
1261
SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.Designer.cs
generated
Normal file
1261
SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.cs
Normal file
53
SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}%"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user