| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- 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;
- /// <summary>
- /// 분석글 응원(Cheer) 발신 — D3 M4 (Twitch Bits 모델).
- /// 후원자 지갑에서 캐시→토큰(CheerSpendOrder) 순으로 총액을 차감하고, 수수료를 제외한 순액을
- /// 작성자 지갑 Reward 파티션(출금 불가·상점/모의투자 소비 전용)으로 적립한다. 수수료는 소각(적립 없음).
- ///
- /// 규제 가드라인(§⑧): 응원은 사후·자율·무대가이며 글 열람 조건이 아니다(글은 항상 무료 — 언락 아님).
- /// 본인 글 응원 금지 + 일일 발신 캡으로 자전 응원(수수료만 소각) 어뷰징을 차단한다.
- /// 두 지갑을 동일 DbContext 에서 로드·수정 후 단일 SaveChanges 로 원자 커밋한다.
- /// </summary>
- public sealed class Handler(IAppDbContext db, INotificationService notifications) : ICommandHandler<Command, Result<int>>
- {
- private static readonly TimeZoneInfo KstZone = ResolveKst();
- public async Task<Result<int>> 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<int>(Error.NotFound("Cheer.PostNotFound", "게시글을 찾을 수 없습니다."));
- }
- if (post.MemberID is not int authorID || authorID <= 0)
- {
- return Result.Failure<int>(Error.Problem("Cheer.NoAuthor", "작성자가 없는 글에는 응원할 수 없습니다."));
- }
- // 본인 글 응원 금지 (자전 응원으로 수수료만 소각하는 어뷰징 차단 — §⑧)
- if (authorID == request.MemberID)
- {
- return Result.Failure<int>(Error.Problem("Cheer.SelfNotAllowed", "본인 글에는 응원할 수 없습니다."));
- }
- var config = await LoadRewardConfigAsync(ct);
- if (config is null)
- {
- return Result.Failure<int>(Error.Problem("Cheer.ConfigMissing", "응원 설정이 없습니다."));
- }
- var minAmount = Math.Max(1, config.CheerMinAmount);
- if (request.Amount < minAmount)
- {
- return Result.Failure<int>(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<int>(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<int>(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<int>(Error.NotFound("Cheer.WalletNotFound", "지갑을 찾을 수 없습니다."));
- }
- if (fromWallet.GetCheerAvailable().Value < request.Amount)
- {
- return Result.Failure<int>(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<int>(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);
- }
- /// <summary>DB Config(권위본)에서 RewardConfig 로드. 쓰기 핸들러이므로 캐시 대신 최신 row 를 읽는다.</summary>
- private async Task<RewardConfig?> 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);
- }
- /// <summary>오늘(KST) 자정~내일 자정 구간을 UTC 로 환산 — CreatedAt(UTC) 필터용.</summary>
- 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");
- }
- }
|