using System.Text.Json; using SPMS.Application.DTOs.Device; using SPMS.Application.Interfaces; using SPMS.Domain.Common; using SPMS.Domain.Entities; using SPMS.Domain.Enums; using SPMS.Domain.Exceptions; using SPMS.Domain.Interfaces; 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; } public async Task RegisterAsync(long serviceId, DeviceRegisterRequestDto request) { var platform = ParsePlatform(request.Platform); var existing = await _deviceRepository.GetByServiceAndTokenAsync(serviceId, request.DeviceToken); if (existing != null) { existing.ExternalDeviceId = request.DeviceId; existing.Platform = platform; existing.OsVersion = request.OsVersion; existing.AppVersion = request.AppVersion; existing.DeviceModel = request.Model; existing.IsActive = true; existing.UpdatedAt = DateTime.UtcNow; _deviceRepository.Update(existing); await _unitOfWork.SaveChangesAsync(); await _tokenCache.InvalidateAsync(serviceId, existing.ExternalDeviceId); return new DeviceRegisterResponseDto { DeviceId = existing.ExternalDeviceId, IsNew = false }; } var device = new Device { ServiceId = serviceId, ExternalDeviceId = request.DeviceId, DeviceToken = request.DeviceToken, Platform = platform, OsVersion = request.OsVersion, AppVersion = request.AppVersion, DeviceModel = request.Model, PushAgreed = true, MarketingAgreed = false, IsActive = true, CreatedAt = DateTime.UtcNow }; await _deviceRepository.AddAsync(device); await _unitOfWork.SaveChangesAsync(); return new DeviceRegisterResponseDto { DeviceId = device.ExternalDeviceId, IsNew = true }; } public async Task GetInfoAsync(long serviceId, DeviceInfoRequestDto request) { var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId); if (device == null) throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); return new DeviceInfoResponseDto { DeviceId = device.ExternalDeviceId, DeviceToken = device.DeviceToken, Platform = device.Platform.ToString().ToLowerInvariant(), OsVersion = device.OsVersion, AppVersion = device.AppVersion, Model = device.DeviceModel, PushAgreed = device.PushAgreed, MarketingAgreed = device.MarketingAgreed, Tags = await ConvertTagIdsToCodesAsync(device.Tags, serviceId), IsActive = device.IsActive, LastActiveAt = device.UpdatedAt, CreatedAt = device.CreatedAt }; } public async Task UpdateAsync(long serviceId, DeviceUpdateRequestDto request) { var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId); if (device == null) throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); if (request.DeviceToken != null) device.DeviceToken = request.DeviceToken; if (request.OsVersion != null) device.OsVersion = request.OsVersion; if (request.AppVersion != null) device.AppVersion = request.AppVersion; device.UpdatedAt = DateTime.UtcNow; _deviceRepository.Update(device); await _unitOfWork.SaveChangesAsync(); await _tokenCache.InvalidateAsync(serviceId, device.ExternalDeviceId); } public async Task DeleteAsync(long serviceId, DeviceDeleteRequestDto request) { var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId); if (device == null) throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); device.IsActive = false; device.UpdatedAt = DateTime.UtcNow; _deviceRepository.Update(device); await _unitOfWork.SaveChangesAsync(); await _tokenCache.InvalidateAsync(serviceId, device.ExternalDeviceId); } public async Task AdminDeleteAsync(string externalDeviceId) { var results = await _deviceRepository.FindAsync(d => d.ExternalDeviceId == externalDeviceId); var device = results.FirstOrDefault(); if (device == null) throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); device.IsActive = false; device.UpdatedAt = DateTime.UtcNow; _deviceRepository.Update(device); await _unitOfWork.SaveChangesAsync(); await _tokenCache.InvalidateAsync(device.ServiceId, device.ExternalDeviceId); } public async Task GetListAsync(long? serviceId, DeviceListRequestDto request) { Platform? platform = null; 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, 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 { DeviceId = d.ExternalDeviceId, Platform = d.Platform.ToString().ToLowerInvariant(), Model = d.DeviceModel, PushAgreed = d.PushAgreed, Tags = ConvertTagIdsToCodesSync(d.Tags, tagIdToCode), LastActiveAt = d.UpdatedAt, DeviceToken = d.DeviceToken, ServiceName = d.Service?.ServiceName ?? string.Empty, ServiceCode = d.Service?.ServiceCode ?? string.Empty, OsVersion = d.OsVersion, AppVersion = d.AppVersion, MarketingAgreed = d.MarketingAgreed, IsActive = d.IsActive, CreatedAt = d.CreatedAt }).ToList(), Pagination = new DTOs.Notice.PaginationDto { Page = request.Page, Size = request.Size, TotalCount = totalCount, TotalPages = totalPages } }; } public async Task ExportAsync(long? serviceId, DeviceExportRequestDto request) { Platform? platform = null; 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, tagIds, request.Keyword, request.MarketingAgreed); using var workbook = new ClosedXML.Excel.XLWorkbook(); var ws = workbook.Worksheets.Add("기기 목록"); ws.Cell(1, 1).Value = "Device ID"; ws.Cell(1, 2).Value = "Device Token"; ws.Cell(1, 3).Value = "서비스명"; ws.Cell(1, 4).Value = "서비스코드"; ws.Cell(1, 5).Value = "플랫폼"; ws.Cell(1, 6).Value = "모델"; ws.Cell(1, 7).Value = "OS 버전"; ws.Cell(1, 8).Value = "앱 버전"; ws.Cell(1, 9).Value = "푸시 동의"; ws.Cell(1, 10).Value = "광고 동의"; ws.Cell(1, 11).Value = "활성 상태"; ws.Cell(1, 12).Value = "태그"; ws.Cell(1, 13).Value = "등록일"; ws.Cell(1, 14).Value = "마지막 활동"; ws.Row(1).Style.Font.Bold = true; var row = 2; foreach (var d in items) { ws.Cell(row, 1).Value = d.Id; ws.Cell(row, 2).Value = d.DeviceToken; ws.Cell(row, 3).Value = d.Service?.ServiceName ?? string.Empty; ws.Cell(row, 4).Value = d.Service?.ServiceCode ?? string.Empty; ws.Cell(row, 5).Value = d.Platform.ToString().ToLowerInvariant(); ws.Cell(row, 6).Value = d.DeviceModel ?? string.Empty; ws.Cell(row, 7).Value = d.OsVersion ?? string.Empty; ws.Cell(row, 8).Value = d.AppVersion ?? string.Empty; ws.Cell(row, 9).Value = d.PushAgreed ? "Y" : "N"; ws.Cell(row, 10).Value = d.MarketingAgreed ? "Y" : "N"; ws.Cell(row, 11).Value = d.IsActive ? "활성" : "비활성"; ws.Cell(row, 12).Value = d.Tags ?? string.Empty; ws.Cell(row, 13).Value = d.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"); ws.Cell(row, 14).Value = d.UpdatedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? string.Empty; row++; } ws.Columns().AdjustToContents(); using var stream = new MemoryStream(); workbook.SaveAs(stream); return stream.ToArray(); } public async Task SetTagsAsync(long serviceId, DeviceTagsRequestDto request) { var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId); if (device == null) throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); // 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(); } public async Task SetAgreeAsync(long serviceId, DeviceAgreeRequestDto request) { var device = await _deviceRepository.GetByExternalIdAndServiceAsync(request.DeviceId, serviceId); if (device == null) throw new SpmsException(ErrorCodes.DeviceNotFound, "존재하지 않는 디바이스입니다.", 404); device.PushAgreed = request.PushAgreed; device.MarketingAgreed = request.MarketingAgreed; device.AgreeUpdatedAt = DateTime.UtcNow; if (request.MarketingAgreed != device.MarketingAgreed) device.MktAgreeUpdatedAt = DateTime.UtcNow; _deviceRepository.Update(device); await _unitOfWork.SaveChangesAsync(); await _tokenCache.InvalidateAsync(serviceId, device.ExternalDeviceId); } private static Platform ParsePlatform(string platform) { return platform.ToLowerInvariant() switch { "ios" => Platform.iOS, "android" => Platform.Android, "web" => Platform.Web, _ => throw new SpmsException(ErrorCodes.BadRequest, "유효하지 않은 플랫폼입니다.", 400) }; } private static List? ParseTagIds(string? tagsJson) { if (string.IsNullOrWhiteSpace(tagsJson)) return null; try { 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); } }