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