| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879 |
- 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;
- /// <summary>
- /// 예측 채점 배치 (D2 M4 예측 채점+트랙레코드, d2 §⑥) — 매 영업일 18:00 KST(D1 T+1 종가 적재 이후 여유).
- /// DueDate 도래한 Pending 예측을 종가로 채점(PredictionSettlementEngine)하고, 영향 회원 트랙레코드를 재집계한 뒤
- /// 작성자에게 결과 알림을 best-effort 로 보낸다.
- ///
- /// BackgroundJobs:PredictionSettlement 플래그로 토글(기본 false), Web.Api 전용(중복 실행 방지).
- /// 채점 로직은 순수 엔진에 위임 — 여기서는 스케줄/알림만 담당한다.
- /// </summary>
- internal sealed class PredictionSettlementService(
- IServiceScopeFactory scopeFactory,
- ILogger<PredictionSettlementService> logger
- ) : DailyScheduledService(logger)
- {
- /// <summary>KST 실행 시각 — D1 T+1 종가 적재(13시+) 이후 여유.</summary>
- private static readonly TimeOnly RunTime = new(18, 0);
- protected override string JobName => "PredictionSettlement";
- protected override TimeOnly TargetTime => RunTime;
- protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
- {
- using var scope = scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- 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<INotificationService>();
- 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;
- }
- }
|