using Application.Abstractions.Cache; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Stocks; using Application.Helpers; using Domain.Entities.Forum.Posts.ValueObject; using Domain.Entities.Forum.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; using PostEntity = Domain.Entities.Forum.Posts.Post; using PostStockTagEntity = Domain.Entities.Forum.Posts.PostStockTag; using PostPredictionEntity = Domain.Entities.Forum.Posts.PostPrediction; using BoardEntity = Domain.Entities.Forum.Boards.Board; using BoardGroupEntity = Domain.Entities.Forum.Boards.BoardGroup; using BoardMetaEntity = Domain.Entities.Forum.Boards.BoardMeta; using StockBoardStatsEntity = Domain.Entities.Stocks.StockBoardStats; namespace Application.Features.Api.Forum.StockBoard.CreatePost; /// /// 종목 가상보드 글 작성 (d2 §③ M2). 단일 물리 Board(Code="stock")를 lazy 시드하고, /// Post.StockCode 파티션에 글을 등록한다. $종목 태그 추출 → PostStockTag(항상 primary 코드 포함), /// StockBoardStats 증가(lazy upsert), 보상(RewardService), 선택적 Pending 예측을 한 번에 커밋한다. /// public sealed class Handler(IAppDbContext db, ICacheService cache, IStockBoardTrendingCache trendingCache) : ICommandHandler> { /// 단일 물리 종목 보드 코드 (가상 보드 = Post.StockCode 파티셔닝). public const string StockBoardCode = "stock"; private static readonly short[] AllowedHorizonDays = [5, 20]; /// 검증 완료된 예측 확정값 — Post 저장 후 PostID 와 결합해 엔티티로 생성. private sealed record PredictionPlan(PredictionDirection Direction, decimal BasePrice, short HorizonDays, DateOnly DueDate, decimal? TargetPrice); public async Task> Handle(Command request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Subject)) { return Result.Failure(Error.Problem("StockBoard.SubjectRequired", "제목을 입력해주세요.")); } var stockCode = request.StockCode?.Trim() ?? string.Empty; if (stockCode is not { Length: 6 } || stockCode.Any(c => !char.IsAsciiDigit(c))) { return Result.Failure(Error.Problem("StockBoard.InvalidStockCode", "종목 코드가 올바르지 않습니다.")); } var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(c => c.ID == request.MemberID, ct); if (member is null) { return Result.Failure(Error.NotFound("StockBoard.MemberNotFound", "회원 정보를 찾을 수 없습니다.")); } // 종목 존재 검증 (Stock FK 미사용 — AnyAsync 사전 검증, D1 소유). Stock.ID 는 예측 BasePrice 조회에 사용. var stock = await db.Stock.AsNoTracking().Where(c => c.Code == stockCode).Select(c => new { c.ID }).FirstOrDefaultAsync(ct); if (stock is null) { return Result.Failure(Error.NotFound("StockBoard.StockNotFound", "종목을 찾을 수 없습니다.")); } // 예측 입력 사전 검증 — 예측 지정 시 시세가 없으면 요청 전체 실패(글도 생성하지 않음, d2 §③ 정책). // Post 저장 후 PostID 로 실제 엔티티를 생성하므로 여기서는 확정값만 산정해 둔다. PredictionPlan? predictionPlan = null; if (request.Prediction is { } predIn) { var direction = (PredictionDirection)predIn.Direction; if (!Enum.IsDefined(direction)) { return Result.Failure(Error.Problem("StockBoard.InvalidDirection", "예측 방향이 올바르지 않습니다.")); } if (!AllowedHorizonDays.Contains(predIn.HorizonDays)) { return Result.Failure(Error.Problem("StockBoard.InvalidHorizon", "예측 기간은 5 또는 20 영업일만 가능합니다.")); } // BasePrice = 작성 시점 최신 T+1 종가 var basePrice = await db.StockDailyPrice.AsNoTracking() .Where(c => c.StockID == stock.ID) .OrderByDescending(c => c.TradingDate) .Select(c => (int?)c.Close) .FirstOrDefaultAsync(ct); if (basePrice is null or <= 0) { return Result.Failure(Error.Problem("StockBoard.NoPrice", "시세 없음 — 예측을 생성할 수 없습니다.")); } var holidays = await LoadHolidaysAsync(ct); var todayKst = DateOnly.FromDateTime(PaperTradingClock.NowKst()); var dueDate = PaperTradingClock.AddBusinessDays(todayKst, predIn.HorizonDays, holidays); predictionPlan = new PredictionPlan(direction, basePrice.Value, predIn.HorizonDays, dueDate, predIn.TargetPrice); } var boardID = await ResolveStockBoardIDAsync(ct); var content = request.Content ?? string.Empty; var post = new PostEntity { BoardID = boardID, MemberID = request.MemberID, StockCode = stockCode, Subject = request.Subject.Trim(), Content = content, Name = member.Name, SID = member.SID, Email = member.Email }; await db.Post.AddAsync(post, ct); await db.SaveChangesAsync(ct); // Post.ID(IDENTITY) 확보 — 종속 행 FK 에 필요 // $종목 태그 추출 → PostStockTag (항상 primary stockCode 포함, dedupe) var codes = StockTagParser.Extract(content); if (!codes.Contains(stockCode)) { codes.Insert(0, stockCode); } foreach (var code in codes) { await db.PostStockTag.AddAsync(PostStockTagEntity.Create(post.ID, code), ct); } // 예측 — Post.ID 확정 후 Pending 생성 (채점은 M4) if (predictionPlan is { } plan) { var prediction = PostPredictionEntity.Create( postID: post.ID, memberID: request.MemberID, stockCode: stockCode, direction: plan.Direction, basePrice: plan.BasePrice, horizonDays: plan.HorizonDays, dueDate: plan.DueDate, targetPrice: plan.TargetPrice); await db.PostPrediction.AddAsync(prediction, ct); } // StockBoardStats 증가 (lazy upsert) var stats = await db.StockBoardStats.FirstOrDefaultAsync(c => c.StockCode == stockCode, ct); if (stats is null) { stats = StockBoardStatsEntity.Create(stockCode); stats.IncrementPost(post.CreatedAt); await db.StockBoardStats.AddAsync(stats, ct); } else { stats.IncrementPost(post.CreatedAt); } // Board 글 수 증가 + MemberStats + 보상 엔진 (호출자 SaveChanges 계약 — 아래 단일 커밋) 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; } } var memberStats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct); if (memberStats is not null) { memberStats.PostCount++; } // 보상(경험치+토큰). 지급 실패해도 글 등록은 주된 결과로 유지 — RewardService 는 호출자가 SaveChanges. await RewardService.GrantForPostAsync(db, cache, request.MemberID, post.ID, content.Length, ct); await db.SaveChangesAsync(ct); // 트렌딩 캐시 무효화 — 다음 조회가 최신 언급량으로 재시드 (실패해도 글 등록은 성공) try { await trendingCache.InvalidateAsync(ct); } catch { // 캐시 무효화 실패는 무시 (TTL 로 자연 만료) } return post.ID; } /// 단일 물리 종목 보드(Code="stock") 해석 — 없으면 BoardGroup+Board+BoardMeta 를 lazy 시드하고 ID 반환. private async Task ResolveStockBoardIDAsync(CancellationToken ct) { var existing = await db.Board.AsNoTracking().Where(c => c.Code == StockBoardCode).Select(c => (int?)c.ID).FirstOrDefaultAsync(ct); if (existing is int id) { return id; } var group = await db.BoardGroup.FirstOrDefaultAsync(c => c.Code == StockBoardCode, ct); if (group is null) { group = new BoardGroupEntity { Code = StockBoardCode, Name = "종목게시판" }; await db.BoardGroup.AddAsync(group, ct); await db.SaveChangesAsync(ct); } var board = new BoardEntity { BoardGroupID = group.ID, Code = StockBoardCode, Name = "종목게시판", IsActive = true, IsSearch = true }; await db.Board.AddAsync(board, ct); await db.SaveChangesAsync(ct); var meta = new BoardMetaEntity { BoardID = board.ID, List = new Domain.Entities.Forum.Boards.BoardMetaList { Layout = BoardLayout.Default, PerPage = 20 }, View = new Domain.Entities.Forum.Boards.BoardMetaView { AllowLike = true, AllowBookmark = true }, Write = new Domain.Entities.Forum.Boards.BoardMetaWrite { AllowEditor = true, AllowImage = true }, Comment = new Domain.Entities.Forum.Boards.BoardMetaComment { EnableComment = true, AllowLike = true } }; await db.BoardMeta.AddAsync(meta, ct); await db.SaveChangesAsync(ct); return board.ID; } /// DueDate 산정용 휴장일 집합 로드 (오늘 이후 ~ +90일 여유분). private async Task> LoadHolidaysAsync(CancellationToken ct) { var today = DateOnly.FromDateTime(PaperTradingClock.NowKst()); var ceiling = today.AddDays(90); var holidays = await db.MarketHoliday.AsNoTracking().Where(c => c.Date >= today && c.Date <= ceiling).Select(c => c.Date).ToListAsync(ct); return holidays.ToHashSet(); } }