using Application.Abstractions.Data; using Domain.Entities.Common; using Domain.Entities.Common.ValueObject; using Domain.Entities.Members; using Domain.Entities.Members.Logs; using Domain.Entities.Members.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Helpers; /// /// 보상 엔진 — 흩어진 보상 지급(글/댓글/추천)을 단일 지급 관문으로 통합한다. /// 액션별 일일 캡(횟수/경험치/토큰)을 MemberDailyRewardCounter 로 강제하고, /// XP 지급 후 다음 등급 RequiredExp 를 넘으면 인라인으로 자동 승급(래칫, 강등 없음)한다. /// /// 계약: 호출자가 SaveChangesAsync 를 수행한다 (작성/전송이 주된 결과, 지급은 부수효과 — 단일 커밋 유지). /// 멱등: refID 로 이미 지급된 액션이면 재지급하지 않는다 (토큰=WalletTransaction, XP전용=MemberExpLog Reason 마커). /// 설정은 db.Config.Reward 를 직접 읽는다 (캐시 Response 경유 없음 — Config.Get/Response 는 편집 대상 아님). /// public static class RewardService { private static readonly TimeZoneInfo KstZone = ResolveKst(); // ---- refID 헬퍼 (멱등 키) ---- public static string PostRefIDFor(int postID) => $"reward-post:{postID}"; public static string CommentRefIDFor(int commentID) => $"reward-comment:{commentID}"; public static string LikeGivenRefIDFor(int postID, int giverID) => $"reward-like-given:{postID}:{giverID}"; public static string LikeReceivedRefIDFor(int postID, int giverID) => $"reward-like-received:{postID}:{giverID}"; /// 게시글 작성 보상 (PostExp + PostPoint). MinPostLength·NewMemberHoldDays·일일 캡 적용. public static async Task GrantForPostAsync(IAppDbContext db, int memberID, int postID, int contentLength, CancellationToken ct) { var config = await LoadConfigAsync(db, ct); if (config is null || (config.PostExp <= 0 && config.PostPoint <= 0)) { return; } if (config.MinPostLength > 0 && contentLength < config.MinPostLength) { return; } await GrantAsync(db, memberID, RewardActionType.Post, config.PostExp, config.PostPoint, PostRefIDFor(postID), "게시글 작성 보상", config, ct); } /// 댓글 작성 보상 (CommentExp + CommentPoint). MinCommentLength·NewMemberHoldDays·일일 캡 적용. public static async Task GrantForCommentAsync(IAppDbContext db, int memberID, int commentID, int contentLength, CancellationToken ct) { var config = await LoadConfigAsync(db, ct); if (config is null || (config.CommentExp <= 0 && config.CommentPoint <= 0)) { return; } if (config.MinCommentLength > 0 && contentLength < config.MinCommentLength) { return; } await GrantAsync(db, memberID, RewardActionType.Comment, config.CommentExp, config.CommentPoint, CommentRefIDFor(commentID), "댓글 작성 보상", config, ct); } /// 추천(좋아요) 보상 — 누른 회원(LikeGivenExp) + 받은 작성자(LikeReceivedPoint). 본인 글 추천은 지급 없음. public static async Task GrantForLikeAsync(IAppDbContext db, int giverID, int? authorID, int postID, CancellationToken ct) { var config = await LoadConfigAsync(db, ct); if (config is null) { return; } // 누른 쪽 XP if (config.LikeGivenExp > 0 && giverID > 0) { await GrantAsync(db, giverID, RewardActionType.LikeGiven, config.LikeGivenExp, 0, LikeGivenRefIDFor(postID, giverID), "추천 보상", config, ct); } // 받은 쪽 토큰 — 본인 글 자추천 제외 if (config.LikeReceivedPoint > 0 && authorID is int author && author > 0 && author != giverID) { await GrantAsync(db, author, RewardActionType.LikeReceived, 0, config.LikeReceivedPoint, LikeReceivedRefIDFor(postID, giverID), "추천 받음 보상", config, ct); } } /// /// 단일 지급 관문. 일일 캡으로 exp/point 를 clamp 하고, 멱등(refID) 판정 후 지급한다. /// XP → MemberStats.Exp(+MemberExpLog, 자동 승급), Point → Wallet.CreditReward. /// 캡/멱등으로 실제 지급분이 0 이면 카운터도 원장도 남기지 않는다. /// public static async Task GrantAsync( IAppDbContext db, int memberID, RewardActionType action, int exp, int point, string refID, string reason, RewardConfig config, CancellationToken ct) { if (memberID <= 0 || (exp <= 0 && point <= 0)) { return; } // 신규 회원 보상 유예 if (config.NewMemberHoldDays > 0) { var createdAt = await db.Member.AsNoTracking().Where(c => c.ID == memberID).Select(c => (DateTime?)c.CreatedAt).FirstOrDefaultAsync(ct); if (createdAt is null) { return; } if (createdAt.Value.AddDays(config.NewMemberHoldDays) > DateTime.UtcNow) { return; } } // 멱등: 동일 refID 로 이미 지급됐는지 확인 if (await AlreadyGrantedAsync(db, memberID, refID, ct)) { return; } var today = TodayKst(); // 오늘 이 액션의 누적치 로드 (캡 clamp 기준) var counter = await db.MemberDailyRewardCounter .FirstOrDefaultAsync(c => c.MemberID == memberID && c.RewardDate == today && c.ActionType == action, ct); var caps = ResolveCaps(config, action); var grantExp = ClampByCap(exp, counter?.ExpTotal ?? 0, caps.ExpCap); var grantPoint = ClampByCap(point, counter?.PointTotal ?? 0, caps.PointCap); // 횟수 캡 도달 시 이번 지급은 스킵 if (caps.CountCap > 0 && (counter?.Count ?? 0) >= caps.CountCap) { return; } if (grantExp <= 0 && grantPoint <= 0) { return; } // 카운터 UPSERT (신규면 추가, 기존이면 누적) if (counter is null) { counter = MemberDailyRewardCounter.Create(memberID, today, action); counter.Add(grantExp, grantPoint); db.MemberDailyRewardCounter.Add(counter); } else { counter.Add(grantExp, grantPoint); } // XP 지급 (+ 자동 승급) if (grantExp > 0) { var stats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == memberID, ct); if (stats is not null) { stats.Exp += grantExp; // XP 전용(토큰 미지급) 지급의 멱등 판정을 위해 Reason 에 refID 마커를 붙인다. db.MemberExpLog.Add(MemberExpLog.Create(memberID, BuildExpReason(reason, refID), grantExp, stats.Exp)); await TryPromoteGradeAsync(db, memberID, stats.Exp, ct); } } // 토큰 지급 (Wallet Reward 파티션) if (grantPoint > 0) { var wallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == memberID, ct); wallet?.CreditReward(Money.KRW(grantPoint), reason, refID); } } /// /// 자동 승급(인라인). 현재 등급보다 상위이면서 RequiredExp 를 충족하는 활성 등급 중 최고 등급으로 승급한다. /// 래칫 — 강등 없음. 캐시 무효화(멤버 표시정보)는 상위에서 관리. /// private static async Task TryPromoteGradeAsync(IAppDbContext db, int memberID, long currentExp, CancellationToken ct) { var member = await db.Member.FirstOrDefaultAsync(c => c.ID == memberID, ct); if (member is null) { return; } // 활성 등급을 RequiredExp 오름차순으로 로드 (소량 — 캐시 불필요) var grades = await db.MemberGrade.AsNoTracking() .Where(c => c.IsActive) .OrderBy(c => c.RequiredExp) .Select(c => new { c.ID, c.KorName, c.RequiredExp, c.Order }) .ToListAsync(ct); if (grades.Count == 0) { return; } // 충족하는 최고 등급 (RequiredExp <= currentExp 중 마지막) var eligible = grades.LastOrDefault(c => c.RequiredExp <= currentExp); if (eligible is null) { return; } // 현재 등급 Order 확인 — 래칫(하향 금지) var currentOrder = member.MemberGradeID is int gid ? grades.FirstOrDefault(c => c.ID == gid)?.Order : null; var eligibleGrade = grades.First(c => c.ID == eligible.ID); // 이미 해당 등급이면 승급 없음 if (member.MemberGradeID == eligible.ID) { return; } if (currentOrder is short order && order >= eligibleGrade.Order) { return; } member.SetMemberGrade(eligible.ID); } private static async Task AlreadyGrantedAsync(IAppDbContext db, int memberID, string refID, CancellationToken ct) { // 토큰을 지급하는 액션은 원장(WalletTransaction.RefID)이 존재하면 이미 지급된 것. var byLedger = await db.WalletTransaction.AsNoTracking() .AnyAsync(c => c.RefID == refID && c.TxType == Domain.Entities.Wallets.ValueObject.WalletTransactionType.RewardEarned, ct); if (byLedger) { return true; } // XP 전용(토큰 미지급) 액션은 MemberExpLog Reason 마커로 판정. var marker = RefMarker(refID); return await db.MemberExpLog.AsNoTracking().AnyAsync(c => c.MemberID == memberID && c.Reason.EndsWith(marker), ct); } private static (int CountCap, int ExpCap, int PointCap) ResolveCaps(RewardConfig c, RewardActionType action) => action switch { RewardActionType.Post => (c.PostDailyCount, c.PostDailyExp, c.PostDailyPoint), RewardActionType.Comment => (c.CommentDailyCount, c.CommentDailyExp, c.CommentDailyPoint), RewardActionType.LikeGiven => (c.LikeGivenDailyCount, c.LikeGivenDailyExp, 0), RewardActionType.LikeReceived => (c.LikeReceivedDailyCount, 0, c.LikeReceivedDailyPoint), _ => (0, 0, 0) }; /// 남은 한도로 clamp. cap 0 = 무제한. 이미 소진됐으면 0. private static int ClampByCap(int requested, int alreadyToday, int cap) { if (requested <= 0) { return 0; } if (cap <= 0) { return requested; } var remaining = cap - alreadyToday; if (remaining <= 0) { return 0; } return Math.Min(requested, remaining); } private static string BuildExpReason(string reason, string refID) => $"{reason} {RefMarker(refID)}"; private static string RefMarker(string refID) => $"[{refID}]"; private static DateOnly TodayKst() { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); return DateOnly.FromDateTime(nowKst); } private static async Task LoadConfigAsync(IAppDbContext db, CancellationToken ct) { var entity = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).FirstOrDefaultAsync(ct); return entity?.Reward; } 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"); } }