From 87b48441cbe2a8560c38184ceb41c0238e71b2e2 Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 11 Feb 2026 10:17:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DailyStatWorker=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #148 --- .../Interfaces/IDailyStatRepository.cs | 1 + SPMS.Infrastructure/DependencyInjection.cs | 1 + .../Repositories/DailyStatRepository.cs | 28 ++++ .../Workers/DailyStatWorker.cs | 139 ++++++++++++++++++ TASKS.md | 2 +- 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 SPMS.Infrastructure/Workers/DailyStatWorker.cs diff --git a/SPMS.Domain/Interfaces/IDailyStatRepository.cs b/SPMS.Domain/Interfaces/IDailyStatRepository.cs index 3e2b341..614f060 100644 --- a/SPMS.Domain/Interfaces/IDailyStatRepository.cs +++ b/SPMS.Domain/Interfaces/IDailyStatRepository.cs @@ -6,4 +6,5 @@ public interface IDailyStatRepository : IRepository { Task> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate); Task GetByDateAsync(long serviceId, DateOnly date); + Task UpsertAsync(long serviceId, DateOnly statDate, int sentCnt, int successCnt, int failCnt, int openCnt); } diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 57f05b3..749ad06 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -82,6 +82,7 @@ public static class DependencyInjection // Workers services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); // Token Store & Email Service services.AddMemoryCache(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/DailyStatRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/DailyStatRepository.cs index 55dd2a7..c1d8591 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/DailyStatRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/DailyStatRepository.cs @@ -21,4 +21,32 @@ public class DailyStatRepository : Repository, IDailyStatRepository return await _dbSet .FirstOrDefaultAsync(s => s.ServiceId == serviceId && s.StatDate == date); } + + public async Task UpsertAsync(long serviceId, DateOnly statDate, int sentCnt, int successCnt, int failCnt, int openCnt) + { + var existing = await _dbSet + .FirstOrDefaultAsync(s => s.ServiceId == serviceId && s.StatDate == statDate); + + if (existing != null) + { + existing.SentCnt = sentCnt; + existing.SuccessCnt = successCnt; + existing.FailCnt = failCnt; + existing.OpenCnt = openCnt; + _context.Entry(existing).State = EntityState.Modified; + } + else + { + await _dbSet.AddAsync(new DailyStat + { + ServiceId = serviceId, + StatDate = statDate, + SentCnt = sentCnt, + SuccessCnt = successCnt, + FailCnt = failCnt, + OpenCnt = openCnt, + CreatedAt = DateTime.UtcNow + }); + } + } } diff --git a/SPMS.Infrastructure/Workers/DailyStatWorker.cs b/SPMS.Infrastructure/Workers/DailyStatWorker.cs new file mode 100644 index 0000000..b14af22 --- /dev/null +++ b/SPMS.Infrastructure/Workers/DailyStatWorker.cs @@ -0,0 +1,139 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Interfaces; +using SPMS.Infrastructure.Persistence; + +namespace SPMS.Infrastructure.Workers; + +public class DailyStatWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + private static readonly TimeZoneInfo KstZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul"); + + public DailyStatWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("DailyStatWorker 시작"); + + while (!stoppingToken.IsCancellationRequested) + { + var delay = CalculateDelayUntilNextRun(); + _logger.LogInformation("DailyStatWorker 다음 실행까지 {Delay} 대기", delay); + + try + { + await Task.Delay(delay, stoppingToken); + } + catch (TaskCanceledException) + { + break; + } + + try + { + await AggregateAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "DailyStatWorker 집계 중 오류 발생"); + } + } + } + + private TimeSpan CalculateDelayUntilNextRun() + { + var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); + var nextRun = nowKst.Date.AddHours(0).AddMinutes(5); + + if (nowKst >= nextRun) + nextRun = nextRun.AddDays(1); + + var delay = nextRun - nowKst; + return delay; + } + + private async Task AggregateAsync(CancellationToken stoppingToken) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var dailyStatRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); + var yesterday = DateOnly.FromDateTime(nowKst.AddDays(-1)); + var startUtc = TimeZoneInfo.ConvertTimeToUtc(yesterday.ToDateTime(TimeOnly.MinValue), KstZone); + var endUtc = startUtc.AddDays(1); + + _logger.LogInformation("DailyStatWorker 집계 시작: {Date}", yesterday); + + // 서비스별 발송 통계 집계 + var sendStats = await context.Set() + .Where(l => l.SentAt >= startUtc && l.SentAt < endUtc) + .GroupBy(l => l.ServiceId) + .Select(g => new + { + ServiceId = g.Key, + SentCnt = g.Count(), + SuccessCnt = g.Count(l => l.Status == PushResult.Success), + FailCnt = g.Count(l => l.Status == PushResult.Failed) + }) + .ToListAsync(stoppingToken); + + // 서비스별 열람 통계 집계 + var openStats = await context.Set() + .Where(l => l.OpenedAt >= startUtc && l.OpenedAt < endUtc) + .GroupBy(l => l.ServiceId) + .Select(g => new + { + ServiceId = g.Key, + OpenCnt = g.Count() + }) + .ToListAsync(stoppingToken); + + var openDict = openStats.ToDictionary(o => o.ServiceId, o => o.OpenCnt); + + // 모든 서비스 ID 수집 (발송 + 열람) + var allServiceIds = sendStats.Select(s => s.ServiceId) + .Union(openStats.Select(o => o.ServiceId)) + .Distinct(); + + foreach (var serviceId in allServiceIds) + { + var send = sendStats.FirstOrDefault(s => s.ServiceId == serviceId); + var openCnt = openDict.GetValueOrDefault(serviceId, 0); + + await dailyStatRepo.UpsertAsync( + serviceId, yesterday, + send?.SentCnt ?? 0, + send?.SuccessCnt ?? 0, + send?.FailCnt ?? 0, + openCnt); + } + + await unitOfWork.SaveChangesAsync(); + + // SystemLog에 집계 완료 기록 + context.Set().Add(new SystemLog + { + Action = "DailyStatAggregation", + Details = $"{yesterday:yyyy-MM-dd} 통계 집계 완료 (서비스 {allServiceIds.Count()}개)", + CreatedAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(stoppingToken); + + _logger.LogInformation("DailyStatWorker 집계 완료: {Date}, 서비스 {Count}개", yesterday, allServiceIds.Count()); + } +} diff --git a/TASKS.md b/TASKS.md index e0daec6..0a2b6b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1655,7 +1655,7 @@ Milestone: Phase 3: 메시지 & Push Core | 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ✅ | | 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-01 | ✅ | | 11 | [Feature] **웹훅 발송 서비스** | Feature | High | WHK-02 | ✅ | -| 12 | [Feature] **DailyStatWorker 구현** | Feature | Medium | AAG-01 | ⬜ | +| 12 | [Feature] **DailyStatWorker 구현** | Feature | Medium | AAG-01 | ✅ | | 13 | [Feature] **DeadTokenCleanupWorker 구현** | Feature | Medium | DTK-01 | ⬜ | | 14 | [Feature] **데이터 보관 주기 관리 배치** | Feature | Medium | RET-01 | ⬜ | | 15 | [Feature] **Redis 토큰 캐시 관리** | Feature | Medium | - | ⬜ | -- 2.45.1