| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- using System.Globalization;
- using System.Text;
- using Application.Abstractions.Data;
- using Domain.Entities.Forum.Briefings;
- using Domain.Entities.Stocks.ValueObject;
- using Microsoft.EntityFrameworkCore;
- using PostEntity = Domain.Entities.Forum.Posts.Post;
- namespace Application.Helpers;
- /// <summary>
- /// 장전 브리핑 본문 조립 + 발행 엔진 (d2 §③·§⑥ M3 DailyBriefingPublisher).
- /// 전일 지수(IndexDailyPrice 최신 KOSPI/KOSDAQ), 등락 상위/하위 종목(StockDailyPrice 최신 거래일 ChangeRate),
- /// 주요 공시(Disclosure 최근)를 취합해 briefing 보드에 시스템 계정(MemberID=null)으로 IsNotice Post 를 발행하고
- /// DailyBriefing 기록을 남긴다. BriefingDate 로 멱등(이미 발행됐으면 skip).
- ///
- /// 스케줄은 Infrastructure 의 HostedService(DailyBriefingPublisher)가 담당하고, 여기서는 순수 조립/발행 로직만
- /// 다뤄 IAppDbContext 만으로 단위 테스트가 가능하도록 분리했다 (PredictionSettlementEngine 패턴).
- /// 시스템 작성자 정책: Post.MemberID 는 nullable 이므로 전용 Member 없이 null + Name="개미투자" 로 발행한다(가장 단순).
- /// </summary>
- public static class DailyBriefingComposer
- {
- /// <summary>시스템 작성자 표시명 (Post.MemberID=null).</summary>
- public const string SystemAuthorName = "개미투자";
- private const int TopMoversCount = 5;
- private const int DisclosureCount = 10;
- /// <summary>발행 결과 (배치 로그/테스트용).</summary>
- public readonly record struct PublishResult(bool Published, int PostID, bool AlreadyExisted);
- /// <summary>
- /// briefingDate 기준 장전 브리핑을 조립·발행한다. 이미 해당 일자로 발행됐으면 skip(멱등).
- /// 발행 시 briefing 보드는 lazy 시드되며, 반환값으로 발행 여부/PostID 를 준다.
- /// </summary>
- public static async Task<PublishResult> PublishAsync(IAppDbContext db, DateOnly briefingDate, CancellationToken ct)
- {
- var existing = await db.DailyBriefing.AsNoTracking()
- .Where(c => c.BriefingDate == briefingDate)
- .Select(c => c.PostID)
- .FirstOrDefaultAsync(ct);
- if (existing != 0)
- {
- return new PublishResult(false, existing, true);
- }
- var body = await ComposeBodyAsync(db, ct);
- var subject = $"[장전 시황] {briefingDate:yyyy-MM-dd}";
- var boardID = await BoardSeeder.ResolveBriefingBoardIDAsync(db, ct);
- var post = new PostEntity
- {
- BoardID = boardID,
- MemberID = null,
- Name = SystemAuthorName,
- Subject = subject,
- Content = body,
- IsNotice = true
- };
- await db.Post.AddAsync(post, ct);
- await db.SaveChangesAsync(ct); // Post.ID(IDENTITY) 확보 — DailyBriefing FK 에 필요
- await db.DailyBriefing.AddAsync(DailyBriefing.Create(briefingDate, post.ID), ct);
- // Board 글 수 증가 (일반 작성 핸들러와 동일 계약)
- var boardEntity = await db.Board.FirstOrDefaultAsync(c => c.ID == boardID, ct);
- if (boardEntity is not null)
- {
- boardEntity.Posts++;
- boardEntity.UpdatedAt = DateTime.UtcNow;
- var boardGroup = await db.BoardGroup.FirstOrDefaultAsync(c => c.ID == boardEntity.BoardGroupID, ct);
- if (boardGroup is not null)
- {
- boardGroup.Posts++;
- boardGroup.UpdatedAt = DateTime.UtcNow;
- }
- }
- await db.SaveChangesAsync(ct);
- return new PublishResult(true, post.ID, false);
- }
- /// <summary>전일 지수 + 등락 상위/하위 + 주요 공시로 HTML 본문을 조립한다.</summary>
- private static async Task<string> ComposeBodyAsync(IAppDbContext db, CancellationToken ct)
- {
- var sb = new StringBuilder();
- // ── 전일 지수 (KOSPI/KOSDAQ 최신 종가·등락) ──
- sb.Append("<h3>전일 지수</h3>");
- var indices = await LoadLatestIndicesAsync(db, ct);
- if (indices.Count > 0)
- {
- sb.Append("<ul>");
- foreach (var idx in indices)
- {
- var rate = (idx.FlucRateBp / 100m).ToString("0.00", CultureInfo.InvariantCulture);
- var sign = idx.FlucRateBp >= 0 ? "+" : "";
- sb.Append(CultureInfo.InvariantCulture, $"<li>{Escape(idx.IndexName)}: {idx.Close.ToString("N2", CultureInfo.InvariantCulture)} ({sign}{idx.ChangeVal.ToString("N2", CultureInfo.InvariantCulture)}, {sign}{rate}%)</li>");
- }
- sb.Append("</ul>");
- }
- else
- {
- sb.Append("<p>데이터 없음</p>");
- }
- // ── 등락 상위/하위 종목 (최신 거래일 ChangeRate) ──
- var latestTradeDate = await db.StockDailyPrice.AsNoTracking()
- .OrderByDescending(c => c.TradingDate)
- .Select(c => (DateOnly?)c.TradingDate)
- .FirstOrDefaultAsync(ct);
- if (latestTradeDate is DateOnly tradeDate)
- {
- var gainers = await LoadMoversAsync(db, tradeDate, top: true, ct);
- var losers = await LoadMoversAsync(db, tradeDate, top: false, ct);
- sb.Append("<h3>등락 상위</h3>");
- AppendMovers(sb, gainers);
- sb.Append("<h3>등락 하위</h3>");
- AppendMovers(sb, losers);
- }
- // ── 주요 공시 (최근 접수) ──
- sb.Append("<h3>주요 공시</h3>");
- var disclosures = await db.Disclosure.AsNoTracking()
- .OrderByDescending(c => c.RceptDt).ThenByDescending(c => c.RceptNo)
- .Take(DisclosureCount)
- .Select(c => new { c.CorpName, c.ReportNm, c.RceptDt })
- .ToListAsync(ct);
- if (disclosures.Count > 0)
- {
- sb.Append("<ul>");
- foreach (var d in disclosures)
- {
- sb.Append(CultureInfo.InvariantCulture, $"<li>[{d.RceptDt:yyyy-MM-dd}] {Escape(d.CorpName)} — {Escape(d.ReportNm)}</li>");
- }
- sb.Append("</ul>");
- }
- else
- {
- sb.Append("<p>데이터 없음</p>");
- }
- return sb.ToString();
- }
- private sealed record IndexRow(string IndexName, decimal Close, decimal ChangeVal, int FlucRateBp);
- /// <summary>KOSPI/KOSDAQ 각 계열에서 최신 거래일 지수 1건씩 로드.</summary>
- private static async Task<List<IndexRow>> LoadLatestIndicesAsync(IAppDbContext db, CancellationToken ct)
- {
- var rows = new List<IndexRow>();
- foreach (var series in new[] { MarketIndexSeries.KOSPI, MarketIndexSeries.KOSDAQ })
- {
- var idx = await db.IndexDailyPrice.AsNoTracking()
- .Where(c => c.Series == series)
- .OrderByDescending(c => c.TradeDate).ThenBy(c => c.IndexName)
- .Select(c => new IndexRow(c.IndexName, c.Close, c.ChangeVal, c.FlucRateBp))
- .FirstOrDefaultAsync(ct);
- if (idx is not null)
- {
- rows.Add(idx);
- }
- }
- return rows;
- }
- private sealed record MoverRow(string Name, string Code, decimal ChangeRate, int Close);
- /// <summary>최신 거래일 종목 시세를 ChangeRate 내림차순(top)/오름차순으로 N건 로드 — Stock 조인으로 종목명 표기.</summary>
- private static async Task<List<MoverRow>> LoadMoversAsync(IAppDbContext db, DateOnly tradeDate, bool top, CancellationToken ct)
- {
- var query = db.StockDailyPrice.AsNoTracking().Where(c => c.TradingDate == tradeDate);
- var ordered = top ? query.OrderByDescending(c => c.ChangeRate) : query.OrderBy(c => c.ChangeRate);
- var rows = await ordered
- .Take(TopMoversCount)
- .Join(db.Stock.AsNoTracking(), price => price.StockID, stock => stock.ID, (price, stock) => new MoverRow(stock.Name, stock.Code, price.ChangeRate, price.Close))
- .ToListAsync(ct);
- return rows;
- }
- private static void AppendMovers(StringBuilder sb, List<MoverRow> movers)
- {
- if (movers.Count == 0)
- {
- sb.Append("<p>데이터 없음</p>");
- return;
- }
- sb.Append("<ul>");
- foreach (var m in movers)
- {
- var rate = m.ChangeRate.ToString("0.00", CultureInfo.InvariantCulture);
- var sign = m.ChangeRate >= 0 ? "+" : "";
- sb.Append(CultureInfo.InvariantCulture, $"<li>{Escape(m.Name)} (${m.Code}): {m.Close.ToString("N0", CultureInfo.InvariantCulture)}원 ({sign}{rate}%)</li>");
- }
- sb.Append("</ul>");
- }
- private static string Escape(string value)
- {
- return value.Replace("&", "&", StringComparison.Ordinal).Replace("<", "<", StringComparison.Ordinal).Replace(">", ">", StringComparison.Ordinal);
- }
- }
|