| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- 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;
- /// <summary>
- /// 종목 가상보드 글 작성 (d2 §③ M2). 단일 물리 Board(Code="stock")를 lazy 시드하고,
- /// Post.StockCode 파티션에 글을 등록한다. $종목 태그 추출 → PostStockTag(항상 primary 코드 포함),
- /// StockBoardStats 증가(lazy upsert), 보상(RewardService), 선택적 Pending 예측을 한 번에 커밋한다.
- /// </summary>
- public sealed class Handler(IAppDbContext db, ICacheService cache, IStockBoardTrendingCache trendingCache) : ICommandHandler<Command, Result<int>>
- {
- /// <summary>단일 물리 종목 보드 코드 (가상 보드 = Post.StockCode 파티셔닝).</summary>
- public const string StockBoardCode = "stock";
- private static readonly short[] AllowedHorizonDays = [5, 20];
- /// <summary>검증 완료된 예측 확정값 — Post 저장 후 PostID 와 결합해 엔티티로 생성.</summary>
- private sealed record PredictionPlan(PredictionDirection Direction, decimal BasePrice, short HorizonDays, DateOnly DueDate, decimal? TargetPrice);
- public async Task<Result<int>> Handle(Command request, CancellationToken ct)
- {
- if (string.IsNullOrWhiteSpace(request.Subject))
- {
- return Result.Failure<int>(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<int>(Error.Problem("StockBoard.InvalidStockCode", "종목 코드가 올바르지 않습니다."));
- }
- var member = await db.Member.AsNoTracking().FirstOrDefaultAsync(c => c.ID == request.MemberID, ct);
- if (member is null)
- {
- return Result.Failure<int>(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<int>(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<int>(Error.Problem("StockBoard.InvalidDirection", "예측 방향이 올바르지 않습니다."));
- }
- if (!AllowedHorizonDays.Contains(predIn.HorizonDays))
- {
- return Result.Failure<int>(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<int>(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;
- }
- /// <summary>단일 물리 종목 보드(Code="stock") 해석 — 없으면 BoardGroup+Board+BoardMeta 를 lazy 시드하고 ID 반환.</summary>
- private async Task<int> 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;
- }
- /// <summary>DueDate 산정용 휴장일 집합 로드 (오늘 이후 ~ +90일 여유분).</summary>
- private async Task<IReadOnlySet<DateOnly>> 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();
- }
- }
|