From 71cd7a5e983e5c7b581e84f9232e2a5d3c755c53 Mon Sep 17 00:00:00 2001 From: SEAN Date: Mon, 2 Mar 2026 16:12:06 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20TagCode=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=E2=80=94=20=ED=83=9C=EA=B7=B8=20=EC=8B=9D=EB=B3=84=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=204=EC=9E=90=EB=A6=AC=20=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#269)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #269 --- .gitignore | 3 +- SPMS.API/Controllers/TagController.cs | 13 +- SPMS.API/Middlewares/ServiceCodeMiddleware.cs | 2 +- .../DTOs/Device/DeviceExportRequestDto.cs | 2 +- .../DTOs/Device/DeviceInfoResponseDto.cs | 2 +- .../DTOs/Device/DeviceListRequestDto.cs | 2 +- .../DTOs/Device/DeviceListResponseDto.cs | 2 +- .../DTOs/Device/DeviceTagsRequestDto.cs | 2 +- .../DTOs/Push/PushScheduleRequestDto.cs | 2 +- .../DTOs/Push/PushSendTagRequestDto.cs | 2 +- .../DTOs/Tag/DeleteTagRequestDto.cs | 4 +- .../DTOs/Tag/TagListResponseDto.cs | 4 +- SPMS.Application/DTOs/Tag/TagResponseDto.cs | 4 +- .../DTOs/Tag/UpdateTagRequestDto.cs | 4 +- SPMS.Application/Interfaces/ITagService.cs | 4 +- SPMS.Application/Services/DeviceService.cs | 87 +- SPMS.Application/Services/TagService.cs | 53 +- SPMS.Domain/Entities/Tag.cs | 1 + SPMS.Domain/Interfaces/IDeviceRepository.cs | 4 +- SPMS.Domain/Interfaces/ITagRepository.cs | 3 + .../20260302070717_AddTagCode.Designer.cs | 1261 +++++++++++++++++ .../Migrations/20260302070717_AddTagCode.cs | 53 + .../Migrations/AppDbContextModelSnapshot.cs | 8 + .../Configurations/TagConfiguration.cs | 2 + .../Repositories/DeviceRepository.cs | 16 +- .../Persistence/Repositories/TagRepository.cs | 16 + SPMS.Infrastructure/Workers/PushWorker.cs | 35 +- 27 files changed, 1535 insertions(+), 56 deletions(-) create mode 100644 SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.Designer.cs create mode 100644 SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.cs diff --git a/.gitignore b/.gitignore index 1ffb01c..ff946f3 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ Dockerfile Documents/ CLAUDE.md TASKS.md -.mcp.json \ No newline at end of file +TODO.md +.mcp.json diff --git a/SPMS.API/Controllers/TagController.cs b/SPMS.API/Controllers/TagController.cs index bf90924..62a48cd 100644 --- a/SPMS.API/Controllers/TagController.cs +++ b/SPMS.API/Controllers/TagController.cs @@ -43,7 +43,8 @@ public class TagController : ControllerBase [SwaggerOperation(Summary = "태그 수정", Description = "태그 설명을 수정합니다. 태그명(Name)은 변경 불가.")] public async Task UpdateAsync([FromBody] UpdateTagRequestDto request) { - var result = await _tagService.UpdateAsync(request); + var serviceId = GetServiceId(); + var result = await _tagService.UpdateAsync(serviceId, request); return Ok(ApiResponse.Success(result, "태그 수정 성공")); } @@ -51,7 +52,8 @@ public class TagController : ControllerBase [SwaggerOperation(Summary = "태그 삭제", Description = "태그를 삭제합니다.")] public async Task 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; diff --git a/SPMS.API/Middlewares/ServiceCodeMiddleware.cs b/SPMS.API/Middlewares/ServiceCodeMiddleware.cs index 9bcb197..6495489 100644 --- a/SPMS.API/Middlewares/ServiceCodeMiddleware.cs +++ b/SPMS.API/Middlewares/ServiceCodeMiddleware.cs @@ -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)) diff --git a/SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs index db6d53a..fe5ba0b 100644 --- a/SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs +++ b/SPMS.Application/DTOs/Device/DeviceExportRequestDto.cs @@ -11,7 +11,7 @@ public class DeviceExportRequestDto public bool? PushAgreed { get; set; } [JsonPropertyName("tags")] - public List? Tags { get; set; } + public List? Tags { get; set; } [JsonPropertyName("is_active")] public bool? IsActive { get; set; } diff --git a/SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs b/SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs index 2d4e089..bcaa22c 100644 --- a/SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs +++ b/SPMS.Application/DTOs/Device/DeviceInfoResponseDto.cs @@ -29,7 +29,7 @@ public class DeviceInfoResponseDto public bool MarketingAgreed { get; set; } [JsonPropertyName("tags")] - public List? Tags { get; set; } + public List? Tags { get; set; } [JsonPropertyName("is_active")] public bool IsActive { get; set; } diff --git a/SPMS.Application/DTOs/Device/DeviceListRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceListRequestDto.cs index c61fd57..fdffe2f 100644 --- a/SPMS.Application/DTOs/Device/DeviceListRequestDto.cs +++ b/SPMS.Application/DTOs/Device/DeviceListRequestDto.cs @@ -17,7 +17,7 @@ public class DeviceListRequestDto public bool? PushAgreed { get; set; } [JsonPropertyName("tags")] - public List? Tags { get; set; } + public List? Tags { get; set; } [JsonPropertyName("is_active")] public bool? IsActive { get; set; } diff --git a/SPMS.Application/DTOs/Device/DeviceListResponseDto.cs b/SPMS.Application/DTOs/Device/DeviceListResponseDto.cs index 0d170ff..2ca171e 100644 --- a/SPMS.Application/DTOs/Device/DeviceListResponseDto.cs +++ b/SPMS.Application/DTOs/Device/DeviceListResponseDto.cs @@ -27,7 +27,7 @@ public class DeviceSummaryDto public bool PushAgreed { get; set; } [JsonPropertyName("tags")] - public List? Tags { get; set; } + public List? Tags { get; set; } [JsonPropertyName("last_active_at")] public DateTime? LastActiveAt { get; set; } diff --git a/SPMS.Application/DTOs/Device/DeviceTagsRequestDto.cs b/SPMS.Application/DTOs/Device/DeviceTagsRequestDto.cs index d2fe449..ade810e 100644 --- a/SPMS.Application/DTOs/Device/DeviceTagsRequestDto.cs +++ b/SPMS.Application/DTOs/Device/DeviceTagsRequestDto.cs @@ -11,5 +11,5 @@ public class DeviceTagsRequestDto [Required] [JsonPropertyName("tags")] - public List Tags { get; set; } = new(); + public List Tags { get; set; } = new(); } diff --git a/SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs b/SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs index 3178a74..2d10873 100644 --- a/SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs +++ b/SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs @@ -12,7 +12,7 @@ public class PushScheduleRequestDto public long? DeviceId { get; set; } - public List? Tags { get; set; } + public List? Tags { get; set; } [Required] public string ScheduledAt { get; set; } = string.Empty; diff --git a/SPMS.Application/DTOs/Push/PushSendTagRequestDto.cs b/SPMS.Application/DTOs/Push/PushSendTagRequestDto.cs index 40a1432..bdf1c2a 100644 --- a/SPMS.Application/DTOs/Push/PushSendTagRequestDto.cs +++ b/SPMS.Application/DTOs/Push/PushSendTagRequestDto.cs @@ -9,7 +9,7 @@ public class PushSendTagRequestDto [Required] [MinLength(1)] - public List Tags { get; set; } = []; + public List Tags { get; set; } = []; public string TagMatch { get; set; } = "or"; } diff --git a/SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs b/SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs index b0194ff..85d837f 100644 --- a/SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs +++ b/SPMS.Application/DTOs/Tag/DeleteTagRequestDto.cs @@ -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; } diff --git a/SPMS.Application/DTOs/Tag/TagListResponseDto.cs b/SPMS.Application/DTOs/Tag/TagListResponseDto.cs index a00038e..70fa949 100644 --- a/SPMS.Application/DTOs/Tag/TagListResponseDto.cs +++ b/SPMS.Application/DTOs/Tag/TagListResponseDto.cs @@ -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; diff --git a/SPMS.Application/DTOs/Tag/TagResponseDto.cs b/SPMS.Application/DTOs/Tag/TagResponseDto.cs index 43c6e96..c0c1d5e 100644 --- a/SPMS.Application/DTOs/Tag/TagResponseDto.cs +++ b/SPMS.Application/DTOs/Tag/TagResponseDto.cs @@ -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; diff --git a/SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs b/SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs index ca2c35d..8fb514b 100644 --- a/SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs +++ b/SPMS.Application/DTOs/Tag/UpdateTagRequestDto.cs @@ -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")] diff --git a/SPMS.Application/Interfaces/ITagService.cs b/SPMS.Application/Interfaces/ITagService.cs index 854e3aa..6598f32 100644 --- a/SPMS.Application/Interfaces/ITagService.cs +++ b/SPMS.Application/Interfaces/ITagService.cs @@ -6,6 +6,6 @@ public interface ITagService { Task GetListAsync(long? scopeServiceId, TagListRequestDto request); Task CreateAsync(long adminId, CreateTagRequestDto request); - Task UpdateAsync(UpdateTagRequestDto request); - Task DeleteAsync(DeleteTagRequestDto request); + Task UpdateAsync(long serviceId, UpdateTagRequestDto request); + Task DeleteAsync(long serviceId, DeleteTagRequestDto request); } diff --git a/SPMS.Application/Services/DeviceService.cs b/SPMS.Application/Services/DeviceService.cs index 02deddd..e4d6031 100644 --- a/SPMS.Application/Services/DeviceService.cs +++ b/SPMS.Application/Services/DeviceService.cs @@ -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? 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(); + 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? 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? ParseTags(string? tagsJson) + private static List? ParseTagIds(string? tagsJson) { if (string.IsNullOrWhiteSpace(tagsJson)) return null; try { - return JsonSerializer.Deserialize>(tagsJson); + return JsonSerializer.Deserialize>(tagsJson); } catch { return null; } } + + /// + /// DB에 저장된 tag_id JSON을 tag_code 목록으로 변환 (단일 디바이스용) + /// + private async Task?> 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(); + } + + /// + /// 미리 구축한 tagId→tagCode 맵을 사용하여 동기 변환 (목록 조회용) + /// + private static List? ConvertTagIdsToCodesSync(string? tagsJson, Dictionary 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(); + } + + /// + /// tag_id 집합으로부터 tag_id → tag_code 매핑 딕셔너리 구축 + /// + private async Task> BuildTagIdToCodeMapAsync(HashSet tagIds, long? serviceId) + { + if (tagIds.Count == 0) + return new Dictionary(); + + var tagIdList = tagIds.ToList(); + var tags = await _tagRepository.FindAsync(t => tagIdList.Contains(t.Id)); + return tags.ToDictionary(t => t.Id, t => t.TagCode); + } } diff --git a/SPMS.Application/Services/TagService.cs b/SPMS.Application/Services/TagService.cs index 00a838a..adbec8b 100644 --- a/SPMS.Application/Services/TagService.cs +++ b/SPMS.Application/Services/TagService.cs @@ -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 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 UpdateAsync(UpdateTagRequestDto request) + public async Task 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>(device.Tags!) ?? new(); - tagList.Remove((int)request.TagId); + var tagList = JsonSerializer.Deserialize>(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 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); + } } diff --git a/SPMS.Domain/Entities/Tag.cs b/SPMS.Domain/Entities/Tag.cs index 79bcc52..96474a9 100644 --- a/SPMS.Domain/Entities/Tag.cs +++ b/SPMS.Domain/Entities/Tag.cs @@ -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; } diff --git a/SPMS.Domain/Interfaces/IDeviceRepository.cs b/SPMS.Domain/Interfaces/IDeviceRepository.cs index 2cbf623..6887fd4 100644 --- a/SPMS.Domain/Interfaces/IDeviceRepository.cs +++ b/SPMS.Domain/Interfaces/IDeviceRepository.cs @@ -12,12 +12,12 @@ public interface IDeviceRepository : IRepository Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( long? serviceId, int page, int size, Platform? platform = null, bool? pushAgreed = null, - bool? isActive = null, List? tags = null, + bool? isActive = null, List? tagIds = null, string? keyword = null, bool? marketingAgreed = null); Task> GetAllFilteredAsync( long? serviceId, Platform? platform = null, bool? pushAgreed = null, - bool? isActive = null, List? tags = null, + bool? isActive = null, List? tagIds = null, string? keyword = null, bool? marketingAgreed = null); Task> GetDeviceCountsByTagIdsAsync(IEnumerable tagIds); Task> GetDevicesByTagIdAsync(long tagId); diff --git a/SPMS.Domain/Interfaces/ITagRepository.cs b/SPMS.Domain/Interfaces/ITagRepository.cs index 5f94d22..7e453bc 100644 --- a/SPMS.Domain/Interfaces/ITagRepository.cs +++ b/SPMS.Domain/Interfaces/ITagRepository.cs @@ -5,8 +5,11 @@ namespace SPMS.Domain.Interfaces; public interface ITagRepository : IRepository { Task GetByIdWithServiceAsync(long id); + Task GetByTagCodeAndServiceAsync(string tagCode, long serviceId); Task ExistsInServiceAsync(long serviceId, string name); + Task TagCodeExistsInServiceAsync(long serviceId, string tagCode); Task<(IReadOnlyList Items, int TotalCount)> GetTagListAsync( long? serviceId, string? keyword, int page, int size); Task CountByServiceAsync(long serviceId); + Task> GetByTagCodesAndServiceAsync(IEnumerable tagCodes, long serviceId); } diff --git a/SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.Designer.cs b/SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.Designer.cs new file mode 100644 index 0000000..7a49d61 --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.Designer.cs @@ -0,0 +1,1261 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SPMS.Infrastructure; + +#nullable disable + +namespace SPMS.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260302070717_AddTagCode")] + partial class AddTagCode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("SPMS.Domain.Entities.Admin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdminCode") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("varchar(12)"); + + b.Property("AgreePrivacy") + .HasColumnType("tinyint(1)"); + + b.Property("AgreeTerms") + .HasColumnType("tinyint(1)"); + + b.Property("AgreedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EmailVerified") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("EmailVerifiedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("MustChangePassword") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Organization") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("RefreshToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("Role") + .HasColumnType("tinyint"); + + b.Property("TempPasswordIssuedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AdminCode") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Admin", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.AppConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ConfigKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ConfigValue") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "ConfigKey") + .IsUnique(); + + b.ToTable("AppConfig", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Banner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkType") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Position") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("Banner", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.DailyStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FailCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("OpenCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("SentCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("StatDate") + .HasColumnType("date"); + + b.Property("SuccessCnt") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "StatDate") + .IsUnique(); + + b.ToTable("DailyStat", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AgreeUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("AppVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceModel") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DeviceToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("MarketingAgreed") + .HasColumnType("tinyint(1)"); + + b.Property("MktAgreeUpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OsVersion") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Platform") + .HasColumnType("tinyint"); + + b.Property("PushAgreed") + .HasColumnType("tinyint(1)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Tags") + .HasColumnType("json"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "DeviceToken"); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Faq", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("Faq", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.FileEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("MimeType") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId"); + + b.ToTable("File", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("CustomData") + .HasColumnType("json"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("ImageUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkType") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("MessageCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("MessageCode") + .IsUnique(); + + b.HasIndex("ServiceId"); + + b.ToTable("Message", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("IsPinned") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId"); + + b.ToTable("Notice", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Category") + .HasColumnType("tinyint unsigned"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReadAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetAdminId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("TargetAdminId", "CreatedAt"); + + b.HasIndex("TargetAdminId", "IsRead"); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AdminId") + .HasColumnType("bigint"); + + b.Property("Amount") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("PaidAt") + .HasColumnType("datetime(6)"); + + b.Property("PaymentKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("PaymentMethod") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("TierAfter") + .HasColumnType("tinyint"); + + b.Property("TierBefore") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("AdminId"); + + b.HasIndex("ServiceId"); + + b.ToTable("Payment", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushOpenLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("bigint"); + + b.Property("MessageId") + .HasColumnType("bigint"); + + b.Property("OpenedAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("MessageId"); + + b.HasIndex("ServiceId", "OpenedAt"); + + b.ToTable("PushOpenLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushSendLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("DeviceId") + .HasColumnType("bigint"); + + b.Property("FailReason") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MessageId") + .HasColumnType("bigint"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.HasIndex("MessageId"); + + b.HasIndex("ServiceId", "SentAt"); + + b.ToTable("PushSendLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("ApiKeyCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApnsAuthType") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ApnsBundleId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ApnsCertExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("ApnsCertPassword") + .HasColumnType("text"); + + b.Property("ApnsCertificate") + .HasColumnType("text"); + + b.Property("ApnsKeyId") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ApnsPrivateKey") + .HasColumnType("text"); + + b.Property("ApnsTeamId") + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("FcmCredentials") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ServiceCode") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("varchar(8)"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("SubStartedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubTier") + .HasColumnType("tinyint"); + + b.Property("Tags") + .HasColumnType("json"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WebhookEvents") + .HasColumnType("longtext"); + + b.Property("WebhookUrl") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceCode") + .IsUnique(); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.ServiceIp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId"); + + b.ToTable("ServiceIp", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("AdminId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasColumnType("json"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("varchar(45)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("AdminId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("ServiceId"); + + b.ToTable("SystemLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("TagCode") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("varchar(4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ServiceId", "Name") + .IsUnique(); + + b.HasIndex("ServiceId", "TagCode") + .IsUnique(); + + b.ToTable("Tag", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.WebhookLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("EventType") + .HasColumnType("tinyint"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("json"); + + b.Property("ResponseBody") + .HasColumnType("text"); + + b.Property("ResponseCode") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.Property("ServiceId") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("tinyint"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "SentAt"); + + b.ToTable("WebhookLog", (string)null); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.AppConfig", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Banner", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.DailyStat", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Device", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("Devices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Faq", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.FileEntity", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Message", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("Messages") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notice", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Notification", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "TargetAdmin") + .WithMany() + .HasForeignKey("TargetAdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TargetAdmin"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Payment", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "Admin") + .WithMany() + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Admin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushOpenLog", b => + { + b.HasOne("SPMS.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Message", "Message") + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("Message"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.PushSendLog", b => + { + b.HasOne("SPMS.Domain.Entities.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Message", "Message") + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("Message"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.ServiceIp", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("ServiceIps") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.SystemLog", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "Admin") + .WithMany() + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Admin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Tag", b => + { + b.HasOne("SPMS.Domain.Entities.Admin", "CreatedByAdmin") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany("TagList") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByAdmin"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.WebhookLog", b => + { + b.HasOne("SPMS.Domain.Entities.Service", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("SPMS.Domain.Entities.Service", b => + { + b.Navigation("Devices"); + + b.Navigation("Messages"); + + b.Navigation("ServiceIps"); + + b.Navigation("TagList"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.cs b/SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.cs new file mode 100644 index 0000000..8a073fe --- /dev/null +++ b/SPMS.Infrastructure/Migrations/20260302070717_AddTagCode.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SPMS.Infrastructure.Migrations +{ + /// + public partial class AddTagCode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Tag_ServiceId_TagCode", + table: "Tag"); + + migrationBuilder.DropColumn( + name: "TagCode", + table: "Tag"); + } + } +} diff --git a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index b46d3cf..9c62823 100644 --- a/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/SPMS.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -914,6 +914,11 @@ namespace SPMS.Infrastructure.Migrations b.Property("ServiceId") .HasColumnType("bigint"); + b.Property("TagCode") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("varchar(4)"); + b.Property("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); }); diff --git a/SPMS.Infrastructure/Persistence/Configurations/TagConfiguration.cs b/SPMS.Infrastructure/Persistence/Configurations/TagConfiguration.cs index 0fdeaae..ccac23f 100644 --- a/SPMS.Infrastructure/Persistence/Configurations/TagConfiguration.cs +++ b/SPMS.Infrastructure/Persistence/Configurations/TagConfiguration.cs @@ -13,6 +13,7 @@ public class TagConfiguration : IEntityTypeConfiguration 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 .OnDelete(DeleteBehavior.Restrict); builder.HasIndex(e => new { e.ServiceId, e.Name }).IsUnique(); + builder.HasIndex(e => new { e.ServiceId, e.TagCode }).IsUnique(); } } diff --git a/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs index 8598518..6838a15 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/DeviceRepository.cs @@ -34,7 +34,7 @@ public class DeviceRepository : Repository, IDeviceRepository public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( long? serviceId, int page, int size, Platform? platform = null, bool? pushAgreed = null, - bool? isActive = null, List? tags = null, + bool? isActive = null, List? tagIds = null, string? keyword = null, bool? marketingAgreed = null) { IQueryable query = _dbSet.Include(d => d.Service); @@ -63,11 +63,11 @@ public class DeviceRepository : Repository, 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, IDeviceRepository public async Task> GetAllFilteredAsync( long? serviceId, Platform? platform = null, bool? pushAgreed = null, - bool? isActive = null, List? tags = null, + bool? isActive = null, List? tagIds = null, string? keyword = null, bool? marketingAgreed = null) { IQueryable query = _dbSet.Include(d => d.Service); @@ -115,11 +115,11 @@ public class DeviceRepository : Repository, 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}%")); } } diff --git a/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs index 2f7adbc..3f1b675 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/TagRepository.cs @@ -13,9 +13,17 @@ public class TagRepository : Repository, ITagRepository .Include(t => t.Service) .FirstOrDefaultAsync(t => t.Id == id); + public async Task GetByTagCodeAndServiceAsync(string tagCode, long serviceId) + => await _dbSet + .Include(t => t.Service) + .FirstOrDefaultAsync(t => t.TagCode == tagCode && t.ServiceId == serviceId); + public async Task ExistsInServiceAsync(long serviceId, string name) => await _dbSet.AnyAsync(t => t.ServiceId == serviceId && t.Name == name); + public async Task TagCodeExistsInServiceAsync(long serviceId, string tagCode) + => await _dbSet.AnyAsync(t => t.ServiceId == serviceId && t.TagCode == tagCode); + public async Task<(IReadOnlyList Items, int TotalCount)> GetTagListAsync( long? serviceId, string? keyword, int page, int size) { @@ -43,4 +51,12 @@ public class TagRepository : Repository, ITagRepository public async Task CountByServiceAsync(long serviceId) => await _dbSet.CountAsync(t => t.ServiceId == serviceId); + + public async Task> GetByTagCodesAndServiceAsync(IEnumerable tagCodes, long serviceId) + { + var codeList = tagCodes.ToList(); + return await _dbSet + .Where(t => t.ServiceId == serviceId && codeList.Contains(t.TagCode)) + .ToListAsync(); + } } diff --git a/SPMS.Infrastructure/Workers/PushWorker.cs b/SPMS.Infrastructure/Workers/PushWorker.cs index 0ea5a5e..744fefe 100644 --- a/SPMS.Infrastructure/Workers/PushWorker.cs +++ b/SPMS.Infrastructure/Workers/PushWorker.cs @@ -149,6 +149,7 @@ public class PushWorker : BackgroundService using var scope = _scopeFactory.CreateScope(); var serviceRepo = scope.ServiceProvider.GetRequiredService(); var deviceRepo = scope.ServiceProvider.GetRequiredService(); + var tagRepo = scope.ServiceProvider.GetRequiredService(); var unitOfWork = scope.ServiceProvider.GetRequiredService(); // 서비스 조회 @@ -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> 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(); -- 2.45.1