feat: DailyStatWorker 구현 (#148)

Closes #148
This commit is contained in:
SEAN 2026-02-11 10:17:06 +09:00
parent 9ab7d4786d
commit 87b48441cb
5 changed files with 170 additions and 1 deletions

View File

@ -6,4 +6,5 @@ public interface IDailyStatRepository : IRepository<DailyStat>
{ {
Task<IReadOnlyList<DailyStat>> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate); Task<IReadOnlyList<DailyStat>> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate);
Task<DailyStat?> GetByDateAsync(long serviceId, DateOnly date); Task<DailyStat?> GetByDateAsync(long serviceId, DateOnly date);
Task UpsertAsync(long serviceId, DateOnly statDate, int sentCnt, int successCnt, int failCnt, int openCnt);
} }

View File

@ -82,6 +82,7 @@ public static class DependencyInjection
// Workers // Workers
services.AddHostedService<PushWorker>(); services.AddHostedService<PushWorker>();
services.AddHostedService<ScheduleWorker>(); services.AddHostedService<ScheduleWorker>();
services.AddHostedService<DailyStatWorker>();
// Token Store & Email Service // Token Store & Email Service
services.AddMemoryCache(); services.AddMemoryCache();

View File

@ -21,4 +21,32 @@ public class DailyStatRepository : Repository<DailyStat>, IDailyStatRepository
return await _dbSet return await _dbSet
.FirstOrDefaultAsync(s => s.ServiceId == serviceId && s.StatDate == date); .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
});
}
}
} }

View File

@ -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<DailyStatWorker> _logger;
private static readonly TimeZoneInfo KstZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
public DailyStatWorker(
IServiceScopeFactory scopeFactory,
ILogger<DailyStatWorker> 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<AppDbContext>();
var dailyStatRepo = scope.ServiceProvider.GetRequiredService<IDailyStatRepository>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
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<PushSendLog>()
.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<PushOpenLog>()
.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<SystemLog>().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());
}
}

View File

@ -1655,7 +1655,7 @@ Milestone: Phase 3: 메시지 & Push Core
| 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ✅ | | 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ✅ |
| 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-01 | ✅ | | 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-01 | ✅ |
| 11 | [Feature] **웹훅 발송 서비스** | Feature | High | WHK-02 | ✅ | | 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 | ⬜ | | 13 | [Feature] **DeadTokenCleanupWorker 구현** | Feature | Medium | DTK-01 | ⬜ |
| 14 | [Feature] **데이터 보관 주기 관리 배치** | Feature | Medium | RET-01 | ⬜ | | 14 | [Feature] **데이터 보관 주기 관리 배치** | Feature | Medium | RET-01 | ⬜ |
| 15 | [Feature] **Redis 토큰 캐시 관리** | Feature | Medium | - | ⬜ | | 15 | [Feature] **Redis 토큰 캐시 관리** | Feature | Medium | - | ⬜ |