using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Notification; using Domain.Entities.Common; using Domain.Entities.Common.ValueObject; using Domain.Entities.Members; using Domain.Entities.Members.ValueObject; using Domain.Entities.Notifications.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Api.Forum.PostCheer.Send; /// /// 분석글 응원(Cheer) 발신 — D3 M4 (Twitch Bits 모델). /// 후원자 지갑에서 캐시→토큰(CheerSpendOrder) 순으로 총액을 차감하고, 수수료를 제외한 순액을 /// 작성자 지갑 Reward 파티션(출금 불가·상점/모의투자 소비 전용)으로 적립한다. 수수료는 소각(적립 없음). /// /// 규제 가드라인(§⑧): 응원은 사후·자율·무대가이며 글 열람 조건이 아니다(글은 항상 무료 — 언락 아님). /// 본인 글 응원 금지 + 일일 발신 캡으로 자전 응원(수수료만 소각) 어뷰징을 차단한다. /// 두 지갑을 동일 DbContext 에서 로드·수정 후 단일 SaveChanges 로 원자 커밋한다. /// public sealed class Handler(IAppDbContext db, INotificationService notifications) : ICommandHandler> { private static readonly TimeZoneInfo KstZone = ResolveKst(); public async Task> Handle(Command request, CancellationToken ct) { // 게시글 존재 + 작성자(수신자) 확인 var post = await db.Post.AsNoTracking() .Where(c => c.ID == request.PostID && !c.IsDeleted) .Select(c => new { c.ID, c.MemberID, c.Subject }) .FirstOrDefaultAsync(ct); if (post is null) { return Result.Failure(Error.NotFound("Cheer.PostNotFound", "게시글을 찾을 수 없습니다.")); } if (post.MemberID is not int authorID || authorID <= 0) { return Result.Failure(Error.Problem("Cheer.NoAuthor", "작성자가 없는 글에는 응원할 수 없습니다.")); } // 본인 글 응원 금지 (자전 응원으로 수수료만 소각하는 어뷰징 차단 — §⑧) if (authorID == request.MemberID) { return Result.Failure(Error.Problem("Cheer.SelfNotAllowed", "본인 글에는 응원할 수 없습니다.")); } var config = await LoadRewardConfigAsync(ct); if (config is null) { return Result.Failure(Error.Problem("Cheer.ConfigMissing", "응원 설정이 없습니다.")); } var minAmount = Math.Max(1, config.CheerMinAmount); if (request.Amount < minAmount) { return Result.Failure(Error.Problem("Cheer.BelowMin", $"응원은 최소 {minAmount} 이상이어야 합니다.")); } // 일일 발신 총액 캡 (0 = 무제한). SENDER 기준 오늘(KST) PostCheer.Amount 합산. if (config.CheerDailyMax > 0) { var (startUtc, endUtc) = TodayKstRangeUtc(); var sentToday = await db.PostCheer.AsNoTracking() .Where(c => c.FromMemberID == request.MemberID && c.CreatedAt >= startUtc && c.CreatedAt < endUtc) .SumAsync(c => (long)c.Amount, ct); if (sentToday + request.Amount > config.CheerDailyMax) { return Result.Failure(Error.Problem("Cheer.DailyCapExceeded", $"오늘 보낼 수 있는 응원 총액({config.CheerDailyMax})을 초과했습니다.")); } } // 수수료/순액 산정 — fee = floor(amount × feePercent/100) var feePercent = Math.Clamp(config.CheerFeePercent, 0, 100); var fee = (int)((long)request.Amount * feePercent / 100); var net = request.Amount - fee; if (net <= 0) { return Result.Failure(Error.Problem("Cheer.NetNotPositive", "수수료 차감 후 지급액이 0 이하입니다.")); } // 발신자 지갑 (캐시+토큰 파티션 로드) var fromWallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct); if (fromWallet is null) { return Result.Failure(Error.NotFound("Cheer.WalletNotFound", "지갑을 찾을 수 없습니다.")); } if (fromWallet.GetCheerAvailable().Value < request.Amount) { return Result.Failure(Error.Problem("Cheer.InsufficientBalance", "잔액이 부족합니다.")); } // 수신자 지갑 var toWallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == authorID, ct); if (toWallet is null) { return Result.Failure(Error.NotFound("Cheer.AuthorWalletNotFound", "작성자 지갑을 찾을 수 없습니다.")); } // 두 지갑 트랜잭션의 멱등 키 — 사전 생성 GUID (동일 요청의 CheerSent/CheerReceived 를 한 참조로 묶음) var refID = $"cheer:{Guid.NewGuid():N}"; var message = string.IsNullOrWhiteSpace(request.Message) ? null : request.Message.Trim(); if (message is { Length: > 100 }) { message = message[..100]; } // 차감(총액) → 순액 적립. 수수료(fee)는 어디에도 적립하지 않음 = 소각(유통 제거). fromWallet.DebitForCheer(Money.KRW(request.Amount), "CHEER_SENT", refID); toWallet.CreditCheerReceived(Money.KRW(net), "CHEER_RECEIVED", refID); var cheer = Domain.Entities.Forum.Posts.PostCheer.Create( postID: post.ID, fromMemberID: request.MemberID, toMemberID: authorID, amount: request.Amount, feeAmount: fee, netAmount: net, message: message ); db.PostCheer.Add(cheer); // 수신자 일일 카운터에 추적용 기록 (CheerReceived=6, 수신은 캡 미적용 — 통계/어뷰징 모니터링용) var today = TodayKst(); var counter = await db.MemberDailyRewardCounter .FirstOrDefaultAsync(c => c.MemberID == authorID && c.RewardDate == today && c.ActionType == RewardActionType.CheerReceived, ct); if (counter is null) { counter = MemberDailyRewardCounter.Create(authorID, today, RewardActionType.CheerReceived); counter.Add(0, net); db.MemberDailyRewardCounter.Add(counter); } else { counter.Add(0, net); } // 단일 커밋 — 두 지갑 차감/적립 + PostCheer + 카운터가 하나의 트랜잭션. await db.SaveChangesAsync(ct); // 작성자 알림 (best-effort — 자체 커밋). 커밋 완료 후이므로 실패해도 응원은 확정. await notifications.SendAsync( authorID, NotificationType.SystemAnnounce, "응원을 받았습니다", $"회원님의 글에 {request.Amount} 응원이 도착했습니다. (수령 {net})", actionUrl: $"/forum/posts/{post.ID}", relatedType: "PostCheer", relatedID: cheer.ID, imageUrl: null, ct: ct); return Result.Success(cheer.ID); } /// DB Config(권위본)에서 RewardConfig 로드. 쓰기 핸들러이므로 캐시 대신 최신 row 를 읽는다. private async Task LoadRewardConfigAsync(CancellationToken ct) { var entity = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).FirstOrDefaultAsync(ct); return entity?.Reward; } private static DateOnly TodayKst() { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); return DateOnly.FromDateTime(nowKst); } /// 오늘(KST) 자정~내일 자정 구간을 UTC 로 환산 — CreatedAt(UTC) 필터용. private static (DateTime StartUtc, DateTime EndUtc) TodayKstRangeUtc() { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); var startKst = DateTime.SpecifyKind(nowKst.Date, DateTimeKind.Unspecified); var startUtc = TimeZoneInfo.ConvertTimeToUtc(startKst, KstZone); return (startUtc, startUtc.AddDays(1)); } private static TimeZoneInfo ResolveKst() { if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz)) { return tz; } if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz)) { return tz; } return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST"); } }