using Application.Abstractions.Data; using Application.Abstractions.Notification; using Application.Helpers; using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Notifications.ValueObject; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Infrastructure.StockData; /// /// 예측 채점 배치 (D2 M4 예측 채점+트랙레코드, d2 §⑥) — 매 영업일 18:00 KST(D1 T+1 종가 적재 이후 여유). /// DueDate 도래한 Pending 예측을 종가로 채점(PredictionSettlementEngine)하고, 영향 회원 트랙레코드를 재집계한 뒤 /// 작성자에게 결과 알림을 best-effort 로 보낸다. /// /// BackgroundJobs:PredictionSettlement 플래그로 토글(기본 false), Web.Api 전용(중복 실행 방지). /// 채점 로직은 순수 엔진에 위임 — 여기서는 스케줄/알림만 담당한다. /// internal sealed class PredictionSettlementService( IServiceScopeFactory scopeFactory, ILogger logger ) : DailyScheduledService(logger) { /// KST 실행 시각 — D1 T+1 종가 적재(13시+) 이후 여유. private static readonly TimeOnly RunTime = new(18, 0); protected override string JobName => "PredictionSettlement"; protected override TimeOnly TargetTime => RunTime; protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var (result, settled) = await PredictionSettlementEngine.RunAsync(db, todayKst, ct); Logger.LogInformation("[{Job}] 채점 완료 — asOf={AsOf}, considered={Considered}, hits={Hits}, misses={Misses}, voids={Voids}, members={Members}", JobName, todayKst, result.Considered, result.Hits, result.Misses, result.Voids, result.MembersRecomputed); if (settled.Count == 0) { return true; } // 작성자 알림 — best-effort (실패해도 채점은 이미 커밋됨). Void 는 알림 생략. var notifier = scope.ServiceProvider.GetService(); if (notifier is not null) { foreach (var s in settled) { if (s.Status is not (PredictionStatus.Hit or PredictionStatus.Miss)) { continue; } try { var hit = s.Status == PredictionStatus.Hit; await notifier.SendAsync( s.MemberID, NotificationType.SystemAnnounce, hit ? "예측 적중" : "예측 종료", hit ? $"{s.StockCode} 예측이 적중했습니다." : $"{s.StockCode} 예측이 빗나갔습니다.", actionUrl: $"/posts/{s.PostID}", relatedType: nameof(Domain.Entities.Forum.Posts.PostPrediction), relatedID: s.PredictionID, ct: ct); } catch (Exception ex) { Logger.LogWarning(ex, "[{Job}] 작성자 알림 실패 — predictionID={PredictionID}", JobName, s.PredictionID); } } } return true; } }