| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- using Application.Abstractions.Cache;
- using Application.Abstractions.Data;
- using Application.Abstractions.Notification;
- using Application.Features.Config.Get;
- using Domain.Entities.Common;
- using Domain.Entities.Common.ValueObject;
- using Domain.Entities.Members;
- using Domain.Entities.Members.Logs;
- using Domain.Entities.Members.ValueObject;
- using Domain.Entities.Notifications.ValueObject;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Helpers;
- /// <summary>
- /// D3 M2 보상 엔진 — 흩어진 보상 지급(글/댓글/추천/채팅)을 단일 지급 관문으로 통합한다.
- /// 액션별 일일 캡(횟수/경험치/토큰)을 MemberDailyRewardCounter 로 강제하고,
- /// XP 지급 후 다음 등급 RequiredExp 를 넘으면 인라인으로 자동 승급(래칫, 강등 없음 — d0 §5-⑨)한다.
- ///
- /// 계약: ActivityTokenGranter 와 동일하게 <b>호출자가 SaveChangesAsync</b> 를 수행한다
- /// (작성/전송이 주된 결과, 지급은 부수효과 — 단일 커밋 유지).
- /// 멱등: refID 로 이미 지급된 액션이면 재지급하지 않는다 (토큰=WalletTransaction, XP전용=MemberExpLog Reason 마커).
- /// PaperReward(8) 는 캡 미적용·기록만 (d0 §2.2) — 별도 GrantPaperRewardAsync 로 처리.
- /// </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}";
- public static string ChatRefIDFor(int memberID, DateTime sentAtUtc) => $"reward-chat:{memberID}:{sentAtUtc.Ticks}";
- /// <summary>게시글 작성 보상 (PostExp + PostPoint). MinPostLength·NewMemberHoldDays·일일 캡 적용.</summary>
- public static async Task GrantForPostAsync(IAppDbContext db, ICacheService cache, int memberID, int postID, int contentLength, CancellationToken ct)
- {
- var config = await LoadConfigAsync(db, cache, 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, ICacheService cache, int memberID, int commentID, int contentLength, CancellationToken ct)
- {
- var config = await LoadConfigAsync(db, cache, 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, ICacheService cache, int giverID, int? authorID, int postID, CancellationToken ct)
- {
- var config = await LoadConfigAsync(db, cache, 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>채팅 보상 (Chat XP, 수치는 ChatExpConfig.ChatXpPerMessage 승계 — d0 §3-H). 일일 캡 적용.</summary>
- public static async Task GrantForChatAsync(IAppDbContext db, ICacheService cache, int memberID, int chatXp, DateTime sentAtUtc, CancellationToken ct)
- {
- if (chatXp <= 0 || memberID <= 0)
- {
- return;
- }
- var config = await LoadConfigAsync(db, cache, ct);
- if (config is null)
- {
- return;
- }
- await GrantAsync(db, memberID, RewardActionType.Chat, chatXp, 0, ChatRefIDFor(memberID, sentAtUtc), "채팅 보상", 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,
- INotificationService? notifications = null)
- {
- 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, action, 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, notifications, 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>
- /// 모의투자 시즌 확정 보상 등 캡 비적용·기록만(d0 §2.2). 캡 clamp 없이 지급하되 카운터에는 누적 기록만 남긴다.
- /// </summary>
- public static async Task GrantPaperRewardAsync(
- IAppDbContext db,
- int memberID,
- int exp,
- int point,
- string refID,
- string reason,
- CancellationToken ct,
- INotificationService? notifications = null)
- {
- if (memberID <= 0 || (exp <= 0 && point <= 0))
- {
- return;
- }
- if (await AlreadyGrantedAsync(db, memberID, RewardActionType.PaperReward, refID, ct))
- {
- return;
- }
- var today = TodayKst();
- var counter = await db.MemberDailyRewardCounter
- .FirstOrDefaultAsync(c => c.MemberID == memberID && c.RewardDate == today && c.ActionType == RewardActionType.PaperReward, ct);
- if (counter is null)
- {
- counter = MemberDailyRewardCounter.Create(memberID, today, RewardActionType.PaperReward);
- counter.Add(exp, point);
- db.MemberDailyRewardCounter.Add(counter);
- }
- else
- {
- counter.Add(exp, point);
- }
- if (exp > 0)
- {
- var stats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
- if (stats is not null)
- {
- stats.Exp += exp;
- db.MemberExpLog.Add(MemberExpLog.Create(memberID, BuildExpReason(reason, refID), exp, stats.Exp));
- await TryPromoteGradeAsync(db, memberID, stats.Exp, notifications, ct);
- }
- }
- if (point > 0)
- {
- var wallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
- wallet?.CreditReward(Money.KRW(point), reason, refID);
- }
- }
- /// <summary>
- /// 자동 승급(인라인, d3 §⑥). 현재 등급보다 상위이면서 RequiredExp 를 충족하는 활성 등급 중 최고 등급으로 승급한다.
- /// 래칫 — 강등 없음(d0 §5-⑨). 승급 시 Notification + AppHub 푸시(notifications 주입 시).
- /// </summary>
- private static async Task TryPromoteGradeAsync(IAppDbContext db, int memberID, long currentExp, INotificationService? notifications, 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);
- // 승급 알림 (주입 시). 캐시 무효화(멤버 표시정보)는 상위에서 관리.
- if (notifications is not null)
- {
- await notifications.SendAsync(
- memberID,
- NotificationType.SystemAnnounce,
- "등급 승급",
- $"축하합니다! '{eligible.KorName}' 등급으로 승급했습니다.",
- actionUrl: null,
- relatedType: "MemberGrade",
- relatedID: eligible.ID,
- imageUrl: null,
- ct: ct);
- }
- }
- private static async Task<bool> AlreadyGrantedAsync(IAppDbContext db, int memberID, RewardActionType action, 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),
- RewardActionType.Chat => (c.ChatDailyCount, c.ChatDailyExp, 0),
- _ => (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, ICacheService cache, CancellationToken ct)
- {
- var cached = await cache.GetAsync<Response>(CacheKeys.Config, ct);
- if (cached is not null)
- {
- return new RewardConfig
- {
- PostExp = cached.Reward.PostExp,
- PostPoint = cached.Reward.PostPoint,
- CommentExp = cached.Reward.CommentExp,
- CommentPoint = cached.Reward.CommentPoint,
- LikeGivenExp = cached.Reward.LikeGivenExp,
- LikeReceivedPoint = cached.Reward.LikeReceivedPoint,
- MinPostLength = cached.Reward.MinPostLength,
- MinCommentLength = cached.Reward.MinCommentLength,
- NewMemberHoldDays = cached.Reward.NewMemberHoldDays,
- PostDailyCount = cached.Reward.PostDailyCount,
- PostDailyExp = cached.Reward.PostDailyExp,
- PostDailyPoint = cached.Reward.PostDailyPoint,
- CommentDailyCount = cached.Reward.CommentDailyCount,
- CommentDailyExp = cached.Reward.CommentDailyExp,
- CommentDailyPoint = cached.Reward.CommentDailyPoint,
- LikeGivenDailyCount = cached.Reward.LikeGivenDailyCount,
- LikeGivenDailyExp = cached.Reward.LikeGivenDailyExp,
- LikeReceivedDailyCount = cached.Reward.LikeReceivedDailyCount,
- LikeReceivedDailyPoint = cached.Reward.LikeReceivedDailyPoint,
- ChatDailyCount = cached.Reward.ChatDailyCount,
- ChatDailyExp = cached.Reward.ChatDailyExp
- };
- }
- 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");
- }
- }
|