using System.Globalization; using System.Text; using System.Text.Json; using SPMS.Application.DTOs.Notice; using SPMS.Application.DTOs.Push; using SPMS.Application.Interfaces; using SPMS.Domain.Common; using SPMS.Domain.Enums; using SPMS.Domain.Exceptions; using SPMS.Domain.Interfaces; namespace SPMS.Application.Services; public class PushService : IPushService { private readonly IMessageRepository _messageRepository; private readonly IPushQueueService _pushQueueService; private readonly IScheduleCancelStore _scheduleCancelStore; private readonly IPushSendLogRepository _pushSendLogRepository; private readonly IBulkJobStore _bulkJobStore; public PushService( IMessageRepository messageRepository, IPushQueueService pushQueueService, IScheduleCancelStore scheduleCancelStore, IPushSendLogRepository pushSendLogRepository, IBulkJobStore bulkJobStore) { _messageRepository = messageRepository; _pushQueueService = pushQueueService; _scheduleCancelStore = scheduleCancelStore; _pushSendLogRepository = pushSendLogRepository; _bulkJobStore = bulkJobStore; } public async Task SendAsync(long serviceId, PushSendRequestDto request) { var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); var title = ApplyVariables(message.Title, request.Variables); var body = ApplyVariables(message.Body, request.Variables); var requestId = Guid.NewGuid().ToString("N"); var pushMessage = new PushMessageDto { MessageId = message.Id.ToString(), RequestId = requestId, ServiceId = serviceId, SendType = "single", Title = title, Body = body, ImageUrl = message.ImageUrl, LinkUrl = message.LinkUrl, CustomData = ParseCustomData(message.CustomData), Target = new PushTargetDto { Type = "device_ids", Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId }) // UUID string }, CreatedBy = message.CreatedBy, CreatedAt = DateTime.UtcNow.ToString("o") }; await _pushQueueService.PublishPushMessageAsync(pushMessage); return new PushSendResponseDto { RequestId = requestId, SendType = "single", Status = "queued" }; } public async Task SendByTagAsync(long serviceId, PushSendTagRequestDto request) { var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); var requestId = Guid.NewGuid().ToString("N"); var pushMessage = new PushMessageDto { MessageId = message.Id.ToString(), RequestId = requestId, ServiceId = serviceId, SendType = "group", Title = message.Title, Body = message.Body, ImageUrl = message.ImageUrl, LinkUrl = message.LinkUrl, CustomData = ParseCustomData(message.CustomData), Target = new PushTargetDto { Type = "tags", Value = JsonSerializer.SerializeToElement(new { tags = request.Tags, match = request.TagMatch }) }, CreatedBy = message.CreatedBy, CreatedAt = DateTime.UtcNow.ToString("o") }; await _pushQueueService.PublishPushMessageAsync(pushMessage); return new PushSendResponseDto { RequestId = requestId, SendType = "group", Status = "queued" }; } public async Task ScheduleAsync(long serviceId, PushScheduleRequestDto request) { var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); var sendType = request.SendType.ToLowerInvariant(); if (sendType != "single" && sendType != "tag") throw new SpmsException(ErrorCodes.BadRequest, "send_type은 single 또는 tag만 허용됩니다.", 400); if (sendType == "single" && string.IsNullOrWhiteSpace(request.DeviceId)) throw new SpmsException(ErrorCodes.BadRequest, "send_type=single 시 device_id는 필수입니다.", 400); if (sendType == "tag" && (request.Tags == null || request.Tags.Count == 0)) throw new SpmsException(ErrorCodes.BadRequest, "send_type=tag 시 tags는 필수입니다.", 400); var requestId = Guid.NewGuid().ToString("N"); var scheduleId = $"sch_{DateTime.UtcNow:yyyyMMdd}_{requestId[..8]}"; var title = ApplyVariables(message.Title, request.Variables); var body = ApplyVariables(message.Body, request.Variables); PushTargetDto target; if (sendType == "single") { target = new PushTargetDto { Type = "device_ids", Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId! }) // UUID string }; } else { target = new PushTargetDto { Type = "tags", Value = JsonSerializer.SerializeToElement(new { tags = request.Tags, match = "or" }) }; } var pushMessage = new PushMessageDto { MessageId = message.Id.ToString(), RequestId = requestId, ServiceId = serviceId, SendType = sendType, Title = title, Body = body, ImageUrl = message.ImageUrl, LinkUrl = message.LinkUrl, CustomData = ParseCustomData(message.CustomData), Target = target, CreatedBy = message.CreatedBy, CreatedAt = DateTime.UtcNow.ToString("o") }; var scheduleMessage = new ScheduleMessageDto { ScheduleId = scheduleId, MessageId = message.Id.ToString(), ServiceId = serviceId, ScheduledAt = request.ScheduledAt, PushMessage = pushMessage }; await _pushQueueService.PublishScheduleMessageAsync(scheduleMessage); return new PushScheduleResponseDto { ScheduleId = scheduleId, ScheduledAt = request.ScheduledAt, Status = "scheduled" }; } public async Task CancelScheduleAsync(PushScheduleCancelRequestDto request) { await _scheduleCancelStore.MarkCancelledAsync(request.ScheduleId); } public async Task GetLogAsync(long serviceId, PushLogRequestDto request) { long? messageId = null; if (!string.IsNullOrWhiteSpace(request.MessageCode)) { var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); if (message != null) messageId = message.Id; else return new PushLogResponseDto { Items = [], Pagination = new PaginationDto { Page = request.Page, Size = request.Size, TotalCount = 0, TotalPages = 0 } }; } PushResult? status = null; if (!string.IsNullOrWhiteSpace(request.Status)) { status = request.Status.ToLowerInvariant() switch { "success" => PushResult.Success, "failed" => PushResult.Failed, _ => null }; } DateTime? startDate = null; if (!string.IsNullOrWhiteSpace(request.StartDate) && DateTime.TryParseExact(request.StartDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedStart)) startDate = parsedStart; DateTime? endDate = null; if (!string.IsNullOrWhiteSpace(request.EndDate) && DateTime.TryParseExact(request.EndDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedEnd)) endDate = parsedEnd; var (items, totalCount) = await _pushSendLogRepository.GetPagedWithMessageAsync( serviceId, request.Page, request.Size, messageId, request.DeviceId, status, startDate, endDate); var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); return new PushLogResponseDto { Items = items.Select(l => new PushLogItemDto { SendId = l.Id, MessageCode = l.Message?.MessageCode ?? string.Empty, DeviceId = l.Device?.ExternalDeviceId ?? l.DeviceId.ToString(), Status = l.Status.ToString().ToLowerInvariant(), FailReason = l.FailReason, SentAt = l.SentAt }).ToList(), Pagination = new PaginationDto { Page = request.Page, Size = request.Size, TotalCount = totalCount, TotalPages = totalPages } }; } public async Task SendBulkAsync(long serviceId, Stream csvStream, string messageCode) { var message = await _messageRepository.GetByMessageCodeAndServiceAsync(messageCode, serviceId); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); var rows = await ParseCsvAsync(csvStream); if (rows.Count == 0) throw new SpmsException(ErrorCodes.BadRequest, "CSV 파일에 유효한 데이터가 없습니다.", 400); var jobId = await _bulkJobStore.CreateJobAsync(serviceId, rows.Count); foreach (var row in rows) { var variables = row.Variables; var title = ApplyVariables(message.Title, variables); var body = ApplyVariables(message.Body, variables); var requestId = Guid.NewGuid().ToString("N"); var pushMessage = new PushMessageDto { MessageId = message.Id.ToString(), RequestId = requestId, ServiceId = serviceId, SendType = "single", Title = title, Body = body, ImageUrl = message.ImageUrl, LinkUrl = message.LinkUrl, CustomData = ParseCustomData(message.CustomData), Target = new PushTargetDto { Type = "device_ids", Value = JsonSerializer.SerializeToElement(new[] { row.DeviceId }) // UUID string }, CreatedBy = message.CreatedBy, CreatedAt = DateTime.UtcNow.ToString("o"), JobId = jobId }; await _pushQueueService.PublishPushMessageAsync(pushMessage); } return new BulkSendResponseDto { JobId = jobId, Status = "queued", TotalCount = rows.Count }; } public async Task GetJobStatusAsync(long serviceId, JobStatusRequestDto request) { if (string.IsNullOrWhiteSpace(request.JobId)) throw new SpmsException(ErrorCodes.BadRequest, "job_id는 필수입니다.", 400); var job = await _bulkJobStore.GetJobAsync(request.JobId); if (job == null) throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404); if (job.ServiceId != serviceId) throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404); var progress = job.TotalCount > 0 ? (int)Math.Round((double)(job.SentCount + job.FailedCount) / job.TotalCount * 100) : 0; return new JobStatusResponseDto { JobId = job.JobId, Status = job.Status, TotalCount = job.TotalCount, SentCount = job.SentCount, FailedCount = job.FailedCount, Progress = progress, StartedAt = job.StartedAt, CompletedAt = job.CompletedAt }; } public async Task CancelJobAsync(long serviceId, JobCancelRequestDto request) { if (string.IsNullOrWhiteSpace(request.JobId)) throw new SpmsException(ErrorCodes.BadRequest, "job_id는 필수입니다.", 400); var job = await _bulkJobStore.GetJobAsync(request.JobId); if (job == null) throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404); if (job.ServiceId != serviceId) throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404); if (job.Status == "completed" || job.Status == "cancelled" || job.Status == "failed") throw new SpmsException(ErrorCodes.JobAlreadyCompleted, "이미 완료되었거나 취소된 작업입니다.", 400); var cancelledCount = await _bulkJobStore.CancelAsync(request.JobId); return new JobCancelResponseDto { JobId = request.JobId, Status = "cancelled", CancelledCount = cancelledCount }; } public async Task ExportLogAsync(long serviceId, PushLogExportRequestDto request) { if (!DateTime.TryParseExact(request.StartDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var startDate)) throw new SpmsException(ErrorCodes.BadRequest, "start_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (!DateTime.TryParseExact(request.EndDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var endDate)) throw new SpmsException(ErrorCodes.BadRequest, "end_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (startDate > endDate) throw new SpmsException(ErrorCodes.BadRequest, "start_date가 end_date보다 클 수 없습니다.", 400); if ((endDate - startDate).Days > 30) throw new SpmsException(ErrorCodes.BadRequest, "조회 기간은 최대 30일입니다.", 400); var endDateExclusive = endDate.AddDays(1); long? messageId = null; if (!string.IsNullOrWhiteSpace(request.MessageCode)) { var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); if (message != null) messageId = message.Id; } PushResult? status = null; if (!string.IsNullOrWhiteSpace(request.Status)) { status = request.Status.ToLowerInvariant() switch { "success" or "sent" => PushResult.Success, "failed" => PushResult.Failed, _ => null }; } var logs = await _pushSendLogRepository.GetExportLogsAsync( serviceId, startDate, endDateExclusive, messageId, request.DeviceId, status); var sb = new StringBuilder(); sb.AppendLine("send_id,message_code,device_id,platform,status,fail_reason,sent_at"); foreach (var log in logs) { var msgCode = log.Message?.MessageCode ?? string.Empty; var platform = log.Device?.Platform.ToString().ToLowerInvariant() ?? string.Empty; var logStatus = log.Status == PushResult.Success ? "sent" : "failed"; var failReason = log.FailReason?.Replace(",", " ") ?? string.Empty; sb.AppendLine($"{log.Id},{msgCode},{log.DeviceId},{platform},{logStatus},{failReason},{log.SentAt:yyyy-MM-ddTHH:mm:ss}"); } return Encoding.UTF8.GetBytes(sb.ToString()); } private static async Task> ParseCsvAsync(Stream stream) { var rows = new List(); using var reader = new StreamReader(stream); var headerLine = await reader.ReadLineAsync(); if (string.IsNullOrWhiteSpace(headerLine)) return rows; var headers = headerLine.Split(',').Select(h => h.Trim()).ToArray(); var deviceIdIndex = Array.FindIndex(headers, h => h.Equals("device_id", StringComparison.OrdinalIgnoreCase)); if (deviceIdIndex < 0) throw new SpmsException(ErrorCodes.BadRequest, "CSV 헤더에 device_id 컬럼이 필요합니다.", 400); while (await reader.ReadLineAsync() is { } line) { if (string.IsNullOrWhiteSpace(line)) continue; var values = line.Split(',').Select(v => v.Trim()).ToArray(); if (values.Length <= deviceIdIndex) continue; var deviceId = values[deviceIdIndex]; if (string.IsNullOrWhiteSpace(deviceId)) continue; var variables = new Dictionary(); for (var i = 0; i < headers.Length && i < values.Length; i++) { if (i == deviceIdIndex) continue; variables[headers[i]] = values[i]; } rows.Add(new CsvRow { DeviceId = deviceId, Variables = variables }); } return rows; } private class CsvRow { public string DeviceId { get; init; } = string.Empty; public Dictionary Variables { get; init; } = new(); } private static string ApplyVariables(string template, Dictionary? variables) { if (variables == null || variables.Count == 0) return template; var result = template; foreach (var (key, value) in variables) { result = result.Replace($"{{{{{key}}}}}", value); } return result; } private static Dictionary? ParseCustomData(string? customData) { if (string.IsNullOrWhiteSpace(customData)) return null; return JsonSerializer.Deserialize>(customData); } }