Handler.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Messaging;
  4. using Application.Abstractions.Stocks;
  5. using Application.Helpers;
  6. using Domain.Entities.Forum.Posts.ValueObject;
  7. using Domain.Entities.Forum.ValueObject;
  8. using Microsoft.EntityFrameworkCore;
  9. using SharedKernel.Results;
  10. using PostEntity = Domain.Entities.Forum.Posts.Post;
  11. using PostStockTagEntity = Domain.Entities.Forum.Posts.PostStockTag;
  12. using PostPredictionEntity = Domain.Entities.Forum.Posts.PostPrediction;
  13. using BoardEntity = Domain.Entities.Forum.Boards.Board;
  14. using BoardGroupEntity = Domain.Entities.Forum.Boards.BoardGroup;
  15. using BoardMetaEntity = Domain.Entities.Forum.Boards.BoardMeta;
  16. using StockBoardStatsEntity = Domain.Entities.Stocks.StockBoardStats;
  17. namespace Application.Features.Api.Forum.StockBoard.CreatePost;
  18. /// <summary>
  19. /// 종목 가상보드 글 작성 (d2 §③ M2). 단일 물리 Board(Code="stock")를 lazy 시드하고,
  20. /// Post.StockCode 파티션에 글을 등록한다. $종목 태그 추출 → PostStockTag(항상 primary 코드 포함),
  21. /// StockBoardStats 증가(lazy upsert), 보상(RewardService), 선택적 Pending 예측을 한 번에 커밋한다.
  22. /// </summary>
  23. public sealed class Handler(IAppDbContext db, ICacheService cache, IStockBoardTrendingCache trendingCache) : ICommandHandler<Command, Result<int>>
  24. {
  25. /// <summary>단일 물리 종목 보드 코드 (가상 보드 = Post.StockCode 파티셔닝).</summary>
  26. public const string StockBoardCode = "stock";
  27. private static readonly short[] AllowedHorizonDays = [5, 20];
  28. /// <summary>검증 완료된 예측 확정값 — Post 저장 후 PostID 와 결합해 엔티티로 생성.</summary>
  29. private sealed record PredictionPlan(PredictionDirection Direction, decimal BasePrice, short HorizonDays, DateOnly DueDate, decimal? TargetPrice);
  30. public async Task<Result<int>> Handle(Command request, CancellationToken ct)
  31. {
  32. if (string.IsNullOrWhiteSpace(request.Subject))
  33. {
  34. return Result.Failure<int>(Error.Problem("StockBoard.SubjectRequired", "제목을 입력해주세요."));
  35. }
  36. var stockCode = request.StockCode?.Trim() ?? string.Empty;
  37. if (stockCode is not { Length: 6 } || stockCode.Any(c => !char.IsAsciiDigit(c)))
  38. {
  39. return Result.Failure<int>(Error.Problem("StockBoard.InvalidStockCode", "종목 코드가 올바르지 않습니다."));
  40. }
  41. var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(c => c.ID == request.MemberID, ct);
  42. if (member is null)
  43. {
  44. return Result.Failure<int>(Error.NotFound("StockBoard.MemberNotFound", "회원 정보를 찾을 수 없습니다."));
  45. }
  46. // 종목 존재 검증 (Stock FK 미사용 — AnyAsync 사전 검증, D1 소유). Stock.ID 는 예측 BasePrice 조회에 사용.
  47. var stock = await db.Stock.AsNoTracking().Where(c => c.Code == stockCode).Select(c => new { c.ID }).FirstOrDefaultAsync(ct);
  48. if (stock is null)
  49. {
  50. return Result.Failure<int>(Error.NotFound("StockBoard.StockNotFound", "종목을 찾을 수 없습니다."));
  51. }
  52. // 예측 입력 사전 검증 — 예측 지정 시 시세가 없으면 요청 전체 실패(글도 생성하지 않음, d2 §③ 정책).
  53. // Post 저장 후 PostID 로 실제 엔티티를 생성하므로 여기서는 확정값만 산정해 둔다.
  54. PredictionPlan? predictionPlan = null;
  55. if (request.Prediction is { } predIn)
  56. {
  57. var direction = (PredictionDirection)predIn.Direction;
  58. if (!Enum.IsDefined(direction))
  59. {
  60. return Result.Failure<int>(Error.Problem("StockBoard.InvalidDirection", "예측 방향이 올바르지 않습니다."));
  61. }
  62. if (!AllowedHorizonDays.Contains(predIn.HorizonDays))
  63. {
  64. return Result.Failure<int>(Error.Problem("StockBoard.InvalidHorizon", "예측 기간은 5 또는 20 영업일만 가능합니다."));
  65. }
  66. // BasePrice = 작성 시점 최신 T+1 종가
  67. var basePrice = await db.StockDailyPrice.AsNoTracking()
  68. .Where(c => c.StockID == stock.ID)
  69. .OrderByDescending(c => c.TradingDate)
  70. .Select(c => (int?)c.Close)
  71. .FirstOrDefaultAsync(ct);
  72. if (basePrice is null or <= 0)
  73. {
  74. return Result.Failure<int>(Error.Problem("StockBoard.NoPrice", "시세 없음 — 예측을 생성할 수 없습니다."));
  75. }
  76. var holidays = await LoadHolidaysAsync(ct);
  77. var todayKst = DateOnly.FromDateTime(PaperTradingClock.NowKst());
  78. var dueDate = PaperTradingClock.AddBusinessDays(todayKst, predIn.HorizonDays, holidays);
  79. predictionPlan = new PredictionPlan(direction, basePrice.Value, predIn.HorizonDays, dueDate, predIn.TargetPrice);
  80. }
  81. var boardID = await ResolveStockBoardIDAsync(ct);
  82. var content = request.Content ?? string.Empty;
  83. var post = new PostEntity
  84. {
  85. BoardID = boardID,
  86. MemberID = request.MemberID,
  87. StockCode = stockCode,
  88. Subject = request.Subject.Trim(),
  89. Content = content,
  90. Name = member.Name,
  91. SID = member.SID,
  92. Email = member.Email
  93. };
  94. await db.Post.AddAsync(post, ct);
  95. await db.SaveChangesAsync(ct); // Post.ID(IDENTITY) 확보 — 종속 행 FK 에 필요
  96. // $종목 태그 추출 → PostStockTag (항상 primary stockCode 포함, dedupe)
  97. var codes = StockTagParser.Extract(content);
  98. if (!codes.Contains(stockCode))
  99. {
  100. codes.Insert(0, stockCode);
  101. }
  102. foreach (var code in codes)
  103. {
  104. await db.PostStockTag.AddAsync(PostStockTagEntity.Create(post.ID, code), ct);
  105. }
  106. // 예측 — Post.ID 확정 후 Pending 생성 (채점은 M4)
  107. if (predictionPlan is { } plan)
  108. {
  109. var prediction = PostPredictionEntity.Create(
  110. postID: post.ID,
  111. memberID: request.MemberID,
  112. stockCode: stockCode,
  113. direction: plan.Direction,
  114. basePrice: plan.BasePrice,
  115. horizonDays: plan.HorizonDays,
  116. dueDate: plan.DueDate,
  117. targetPrice: plan.TargetPrice);
  118. await db.PostPrediction.AddAsync(prediction, ct);
  119. }
  120. // StockBoardStats 증가 (lazy upsert)
  121. var stats = await db.StockBoardStats.FirstOrDefaultAsync(c => c.StockCode == stockCode, ct);
  122. if (stats is null)
  123. {
  124. stats = StockBoardStatsEntity.Create(stockCode);
  125. stats.IncrementPost(post.CreatedAt);
  126. await db.StockBoardStats.AddAsync(stats, ct);
  127. }
  128. else
  129. {
  130. stats.IncrementPost(post.CreatedAt);
  131. }
  132. // Board 글 수 증가 + MemberStats + 보상 엔진 (호출자 SaveChanges 계약 — 아래 단일 커밋)
  133. var boardEntity = await db.Board.FirstOrDefaultAsync(c => c.ID == boardID, ct);
  134. if (boardEntity is not null)
  135. {
  136. boardEntity.Posts++;
  137. boardEntity.UpdatedAt = DateTime.UtcNow;
  138. var boardGroup = await db.BoardGroup.FirstOrDefaultAsync(c => c.ID == boardEntity.BoardGroupID, ct);
  139. if (boardGroup is not null)
  140. {
  141. boardGroup.Posts++;
  142. boardGroup.UpdatedAt = DateTime.UtcNow;
  143. }
  144. }
  145. var memberStats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
  146. if (memberStats is not null)
  147. {
  148. memberStats.PostCount++;
  149. }
  150. // 보상(경험치+토큰). 지급 실패해도 글 등록은 주된 결과로 유지 — RewardService 는 호출자가 SaveChanges.
  151. await RewardService.GrantForPostAsync(db, cache, request.MemberID, post.ID, content.Length, ct);
  152. await db.SaveChangesAsync(ct);
  153. // 트렌딩 캐시 무효화 — 다음 조회가 최신 언급량으로 재시드 (실패해도 글 등록은 성공)
  154. try
  155. {
  156. await trendingCache.InvalidateAsync(ct);
  157. }
  158. catch
  159. {
  160. // 캐시 무효화 실패는 무시 (TTL 로 자연 만료)
  161. }
  162. return post.ID;
  163. }
  164. /// <summary>단일 물리 종목 보드(Code="stock") 해석 — 없으면 BoardGroup+Board+BoardMeta 를 lazy 시드하고 ID 반환.</summary>
  165. private async Task<int> ResolveStockBoardIDAsync(CancellationToken ct)
  166. {
  167. var existing = await db.Board.AsNoTracking().Where(c => c.Code == StockBoardCode).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct);
  168. if (existing is int id)
  169. {
  170. return id;
  171. }
  172. var group = await db.BoardGroup.FirstOrDefaultAsync(c => c.Code == StockBoardCode, ct);
  173. if (group is null)
  174. {
  175. group = new BoardGroupEntity { Code = StockBoardCode, Name = "종목게시판" };
  176. await db.BoardGroup.AddAsync(group, ct);
  177. await db.SaveChangesAsync(ct);
  178. }
  179. var board = new BoardEntity
  180. {
  181. BoardGroupID = group.ID,
  182. Code = StockBoardCode,
  183. Name = "종목게시판",
  184. IsActive = true,
  185. IsSearch = true
  186. };
  187. await db.Board.AddAsync(board, ct);
  188. await db.SaveChangesAsync(ct);
  189. var meta = new BoardMetaEntity
  190. {
  191. BoardID = board.ID,
  192. List = new Domain.Entities.Forum.Boards.BoardMetaList { Layout = BoardLayout.Default, PerPage = 20 },
  193. View = new Domain.Entities.Forum.Boards.BoardMetaView { AllowLike = true, AllowBookmark = true },
  194. Write = new Domain.Entities.Forum.Boards.BoardMetaWrite { AllowEditor = true, AllowImage = true },
  195. Comment = new Domain.Entities.Forum.Boards.BoardMetaComment { EnableComment = true, AllowLike = true }
  196. };
  197. await db.BoardMeta.AddAsync(meta, ct);
  198. await db.SaveChangesAsync(ct);
  199. return board.ID;
  200. }
  201. /// <summary>DueDate 산정용 휴장일 집합 로드 (오늘 이후 ~ +90일 여유분).</summary>
  202. private async Task<IReadOnlySet<DateOnly>> LoadHolidaysAsync(CancellationToken ct)
  203. {
  204. var today = DateOnly.FromDateTime(PaperTradingClock.NowKst());
  205. var ceiling = today.AddDays(90);
  206. var holidays = await db.MarketHoliday.AsNoTracking().Where(c => c.Date >= today && c.Date <= ceiling).Select(c => c.Date).ToListAsync(ct);
  207. return holidays.ToHashSet();
  208. }
  209. }