PredictionSettlementService.cs 3.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Notification;
  3. using Application.Helpers;
  4. using Domain.Entities.Forum.Posts.ValueObject;
  5. using Domain.Entities.Notifications.ValueObject;
  6. using Microsoft.Extensions.DependencyInjection;
  7. using Microsoft.Extensions.Logging;
  8. namespace Infrastructure.StockData;
  9. /// <summary>
  10. /// 예측 채점 배치 (D2 M4 예측 채점+트랙레코드, d2 §⑥) — 매 영업일 18:00 KST(D1 T+1 종가 적재 이후 여유).
  11. /// DueDate 도래한 Pending 예측을 종가로 채점(PredictionSettlementEngine)하고, 영향 회원 트랙레코드를 재집계한 뒤
  12. /// 작성자에게 결과 알림을 best-effort 로 보낸다.
  13. ///
  14. /// BackgroundJobs:PredictionSettlement 플래그로 토글(기본 false), Web.Api 전용(중복 실행 방지).
  15. /// 채점 로직은 순수 엔진에 위임 — 여기서는 스케줄/알림만 담당한다.
  16. /// </summary>
  17. internal sealed class PredictionSettlementService(
  18. IServiceScopeFactory scopeFactory,
  19. ILogger<PredictionSettlementService> logger
  20. ) : DailyScheduledService(logger)
  21. {
  22. /// <summary>KST 실행 시각 — D1 T+1 종가 적재(13시+) 이후 여유.</summary>
  23. private static readonly TimeOnly RunTime = new(18, 0);
  24. protected override string JobName => "PredictionSettlement";
  25. protected override TimeOnly TargetTime => RunTime;
  26. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  27. {
  28. using var scope = scopeFactory.CreateScope();
  29. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  30. var (result, settled) = await PredictionSettlementEngine.RunAsync(db, todayKst, ct);
  31. Logger.LogInformation("[{Job}] 채점 완료 — asOf={AsOf}, considered={Considered}, hits={Hits}, misses={Misses}, voids={Voids}, members={Members}",
  32. JobName, todayKst, result.Considered, result.Hits, result.Misses, result.Voids, result.MembersRecomputed);
  33. if (settled.Count == 0)
  34. {
  35. return true;
  36. }
  37. // 작성자 알림 — best-effort (실패해도 채점은 이미 커밋됨). Void 는 알림 생략.
  38. var notifier = scope.ServiceProvider.GetService<INotificationService>();
  39. if (notifier is not null)
  40. {
  41. foreach (var s in settled)
  42. {
  43. if (s.Status is not (PredictionStatus.Hit or PredictionStatus.Miss))
  44. {
  45. continue;
  46. }
  47. try
  48. {
  49. var hit = s.Status == PredictionStatus.Hit;
  50. await notifier.SendAsync(
  51. s.MemberID,
  52. NotificationType.SystemAnnounce,
  53. hit ? "예측 적중" : "예측 종료",
  54. hit ? $"{s.StockCode} 예측이 적중했습니다." : $"{s.StockCode} 예측이 빗나갔습니다.",
  55. actionUrl: $"/posts/{s.PostID}",
  56. relatedType: nameof(Domain.Entities.Forum.Posts.PostPrediction),
  57. relatedID: s.PredictionID,
  58. ct: ct);
  59. }
  60. catch (Exception ex)
  61. {
  62. Logger.LogWarning(ex, "[{Job}] 작성자 알림 실패 — predictionID={PredictionID}", JobName, s.PredictionID);
  63. }
  64. }
  65. }
  66. return true;
  67. }
  68. }