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();
}
}