| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- 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;
- /// <summary>
- /// 보상 엔진 — 흩어진 보상 지급(글/댓글/추천)을 단일 지급 관문으로 통합한다.
- /// 액션별 일일 캡(횟수/경험치/토큰)을 MemberDailyRewardCounter 로 강제하고,
- /// XP 지급 후 다음 등급 RequiredExp 를 넘으면 인라인으로 자동 승급(래칫, 강등 없음)한다.
- ///
- /// 계약: <b>호출자가 SaveChangesAsync</b> 를 수행한다 (작성/전송이 주된 결과, 지급은 부수효과 — 단일 커밋 유지).
- /// 멱등: refID 로 이미 지급된 액션이면 재지급하지 않는다 (토큰=WalletTransaction, XP전용=MemberExpLog Reason 마커).
- /// 설정은 db.Config.Reward 를 직접 읽는다 (캐시 Response 경유 없음 — Config.Get/Response 는 편집 대상 아님).
- /// </summary>
- 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}";
- /// <summary>게시글 작성 보상 (PostExp + PostPoint). MinPostLength·NewMemberHoldDays·일일 캡 적용.</summary>
- 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);
- }
- /// <summary>댓글 작성 보상 (CommentExp + CommentPoint). MinCommentLength·NewMemberHoldDays·일일 캡 적용.</summary>
- 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);
- }
- /// <summary>추천(좋아요) 보상 — 누른 회원(LikeGivenExp) + 받은 작성자(LikeReceivedPoint). 본인 글 추천은 지급 없음.</summary>
- 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);
- }
- }
- /// <summary>
- /// 단일 지급 관문. 일일 캡으로 exp/point 를 clamp 하고, 멱등(refID) 판정 후 지급한다.
- /// XP → MemberStats.Exp(+MemberExpLog, 자동 승급), Point → Wallet.CreditReward.
- /// 캡/멱등으로 실제 지급분이 0 이면 카운터도 원장도 남기지 않는다.
- /// </summary>
- 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);
- }
- }
- /// <summary>
- /// 자동 승급(인라인). 현재 등급보다 상위이면서 RequiredExp 를 충족하는 활성 등급 중 최고 등급으로 승급한다.
- /// 래칫 — 강등 없음. 캐시 무효화(멤버 표시정보)는 상위에서 관리.
- /// </summary>
- 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<bool> 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)
- };
- /// <summary>남은 한도로 clamp. cap 0 = 무제한. 이미 소진됐으면 0.</summary>
- 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<RewardConfig?> 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");
- }
- }
|