RewardService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. using Application.Abstractions.Data;
  2. using Domain.Entities.Common;
  3. using Domain.Entities.Common.ValueObject;
  4. using Domain.Entities.Members;
  5. using Domain.Entities.Members.Logs;
  6. using Domain.Entities.Members.ValueObject;
  7. using Microsoft.EntityFrameworkCore;
  8. namespace Application.Helpers;
  9. /// <summary>
  10. /// 보상 엔진 — 흩어진 보상 지급(글/댓글/추천)을 단일 지급 관문으로 통합한다.
  11. /// 액션별 일일 캡(횟수/경험치/토큰)을 MemberDailyRewardCounter 로 강제하고,
  12. /// XP 지급 후 다음 등급 RequiredExp 를 넘으면 인라인으로 자동 승급(래칫, 강등 없음)한다.
  13. ///
  14. /// 계약: <b>호출자가 SaveChangesAsync</b> 를 수행한다 (작성/전송이 주된 결과, 지급은 부수효과 — 단일 커밋 유지).
  15. /// 멱등: refID 로 이미 지급된 액션이면 재지급하지 않는다 (토큰=WalletTransaction, XP전용=MemberExpLog Reason 마커).
  16. /// 설정은 db.Config.Reward 를 직접 읽는다 (캐시 Response 경유 없음 — Config.Get/Response 는 편집 대상 아님).
  17. /// </summary>
  18. public static class RewardService
  19. {
  20. private static readonly TimeZoneInfo KstZone = ResolveKst();
  21. // ---- refID 헬퍼 (멱등 키) ----
  22. public static string PostRefIDFor(int postID) => $"reward-post:{postID}";
  23. public static string CommentRefIDFor(int commentID) => $"reward-comment:{commentID}";
  24. public static string LikeGivenRefIDFor(int postID, int giverID) => $"reward-like-given:{postID}:{giverID}";
  25. public static string LikeReceivedRefIDFor(int postID, int giverID) => $"reward-like-received:{postID}:{giverID}";
  26. /// <summary>게시글 작성 보상 (PostExp + PostPoint). MinPostLength·NewMemberHoldDays·일일 캡 적용.</summary>
  27. public static async Task GrantForPostAsync(IAppDbContext db, int memberID, int postID, int contentLength, CancellationToken ct)
  28. {
  29. var config = await LoadConfigAsync(db, ct);
  30. if (config is null || (config.PostExp <= 0 && config.PostPoint <= 0))
  31. {
  32. return;
  33. }
  34. if (config.MinPostLength > 0 && contentLength < config.MinPostLength)
  35. {
  36. return;
  37. }
  38. await GrantAsync(db, memberID, RewardActionType.Post, config.PostExp, config.PostPoint, PostRefIDFor(postID), "게시글 작성 보상", config, ct);
  39. }
  40. /// <summary>댓글 작성 보상 (CommentExp + CommentPoint). MinCommentLength·NewMemberHoldDays·일일 캡 적용.</summary>
  41. public static async Task GrantForCommentAsync(IAppDbContext db, int memberID, int commentID, int contentLength, CancellationToken ct)
  42. {
  43. var config = await LoadConfigAsync(db, ct);
  44. if (config is null || (config.CommentExp <= 0 && config.CommentPoint <= 0))
  45. {
  46. return;
  47. }
  48. if (config.MinCommentLength > 0 && contentLength < config.MinCommentLength)
  49. {
  50. return;
  51. }
  52. await GrantAsync(db, memberID, RewardActionType.Comment, config.CommentExp, config.CommentPoint, CommentRefIDFor(commentID), "댓글 작성 보상", config, ct);
  53. }
  54. /// <summary>추천(좋아요) 보상 — 누른 회원(LikeGivenExp) + 받은 작성자(LikeReceivedPoint). 본인 글 추천은 지급 없음.</summary>
  55. public static async Task GrantForLikeAsync(IAppDbContext db, int giverID, int? authorID, int postID, CancellationToken ct)
  56. {
  57. var config = await LoadConfigAsync(db, ct);
  58. if (config is null)
  59. {
  60. return;
  61. }
  62. // 누른 쪽 XP
  63. if (config.LikeGivenExp > 0 && giverID > 0)
  64. {
  65. await GrantAsync(db, giverID, RewardActionType.LikeGiven, config.LikeGivenExp, 0, LikeGivenRefIDFor(postID, giverID), "추천 보상", config, ct);
  66. }
  67. // 받은 쪽 토큰 — 본인 글 자추천 제외
  68. if (config.LikeReceivedPoint > 0 && authorID is int author && author > 0 && author != giverID)
  69. {
  70. await GrantAsync(db, author, RewardActionType.LikeReceived, 0, config.LikeReceivedPoint, LikeReceivedRefIDFor(postID, giverID), "추천 받음 보상", config, ct);
  71. }
  72. }
  73. /// <summary>
  74. /// 단일 지급 관문. 일일 캡으로 exp/point 를 clamp 하고, 멱등(refID) 판정 후 지급한다.
  75. /// XP → MemberStats.Exp(+MemberExpLog, 자동 승급), Point → Wallet.CreditReward.
  76. /// 캡/멱등으로 실제 지급분이 0 이면 카운터도 원장도 남기지 않는다.
  77. /// </summary>
  78. public static async Task GrantAsync(
  79. IAppDbContext db,
  80. int memberID,
  81. RewardActionType action,
  82. int exp,
  83. int point,
  84. string refID,
  85. string reason,
  86. RewardConfig config,
  87. CancellationToken ct)
  88. {
  89. if (memberID <= 0 || (exp <= 0 && point <= 0))
  90. {
  91. return;
  92. }
  93. // 신규 회원 보상 유예
  94. if (config.NewMemberHoldDays > 0)
  95. {
  96. var createdAt = await db.Member.AsNoTracking().Where(c => c.ID == memberID).Select(c => (DateTime?)c.CreatedAt).FirstOrDefaultAsync(ct);
  97. if (createdAt is null)
  98. {
  99. return;
  100. }
  101. if (createdAt.Value.AddDays(config.NewMemberHoldDays) > DateTime.UtcNow)
  102. {
  103. return;
  104. }
  105. }
  106. // 멱등: 동일 refID 로 이미 지급됐는지 확인
  107. if (await AlreadyGrantedAsync(db, memberID, refID, ct))
  108. {
  109. return;
  110. }
  111. var today = TodayKst();
  112. // 오늘 이 액션의 누적치 로드 (캡 clamp 기준)
  113. var counter = await db.MemberDailyRewardCounter
  114. .FirstOrDefaultAsync(c => c.MemberID == memberID && c.RewardDate == today && c.ActionType == action, ct);
  115. var caps = ResolveCaps(config, action);
  116. var grantExp = ClampByCap(exp, counter?.ExpTotal ?? 0, caps.ExpCap);
  117. var grantPoint = ClampByCap(point, counter?.PointTotal ?? 0, caps.PointCap);
  118. // 횟수 캡 도달 시 이번 지급은 스킵
  119. if (caps.CountCap > 0 && (counter?.Count ?? 0) >= caps.CountCap)
  120. {
  121. return;
  122. }
  123. if (grantExp <= 0 && grantPoint <= 0)
  124. {
  125. return;
  126. }
  127. // 카운터 UPSERT (신규면 추가, 기존이면 누적)
  128. if (counter is null)
  129. {
  130. counter = MemberDailyRewardCounter.Create(memberID, today, action);
  131. counter.Add(grantExp, grantPoint);
  132. db.MemberDailyRewardCounter.Add(counter);
  133. }
  134. else
  135. {
  136. counter.Add(grantExp, grantPoint);
  137. }
  138. // XP 지급 (+ 자동 승급)
  139. if (grantExp > 0)
  140. {
  141. var stats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
  142. if (stats is not null)
  143. {
  144. stats.Exp += grantExp;
  145. // XP 전용(토큰 미지급) 지급의 멱등 판정을 위해 Reason 에 refID 마커를 붙인다.
  146. db.MemberExpLog.Add(MemberExpLog.Create(memberID, BuildExpReason(reason, refID), grantExp, stats.Exp));
  147. await TryPromoteGradeAsync(db, memberID, stats.Exp, ct);
  148. }
  149. }
  150. // 토큰 지급 (Wallet Reward 파티션)
  151. if (grantPoint > 0)
  152. {
  153. var wallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
  154. wallet?.CreditReward(Money.KRW(grantPoint), reason, refID);
  155. }
  156. }
  157. /// <summary>
  158. /// 자동 승급(인라인). 현재 등급보다 상위이면서 RequiredExp 를 충족하는 활성 등급 중 최고 등급으로 승급한다.
  159. /// 래칫 — 강등 없음. 캐시 무효화(멤버 표시정보)는 상위에서 관리.
  160. /// </summary>
  161. private static async Task TryPromoteGradeAsync(IAppDbContext db, int memberID, long currentExp, CancellationToken ct)
  162. {
  163. var member = await db.Member.FirstOrDefaultAsync(c => c.ID == memberID, ct);
  164. if (member is null)
  165. {
  166. return;
  167. }
  168. // 활성 등급을 RequiredExp 오름차순으로 로드 (소량 — 캐시 불필요)
  169. var grades = await db.MemberGrade.AsNoTracking()
  170. .Where(c => c.IsActive)
  171. .OrderBy(c => c.RequiredExp)
  172. .Select(c => new { c.ID, c.KorName, c.RequiredExp, c.Order })
  173. .ToListAsync(ct);
  174. if (grades.Count == 0)
  175. {
  176. return;
  177. }
  178. // 충족하는 최고 등급 (RequiredExp <= currentExp 중 마지막)
  179. var eligible = grades.LastOrDefault(c => c.RequiredExp <= currentExp);
  180. if (eligible is null)
  181. {
  182. return;
  183. }
  184. // 현재 등급 Order 확인 — 래칫(하향 금지)
  185. var currentOrder = member.MemberGradeID is int gid
  186. ? grades.FirstOrDefault(c => c.ID == gid)?.Order
  187. : null;
  188. var eligibleGrade = grades.First(c => c.ID == eligible.ID);
  189. // 이미 해당 등급이면 승급 없음
  190. if (member.MemberGradeID == eligible.ID)
  191. {
  192. return;
  193. }
  194. if (currentOrder is short order && order >= eligibleGrade.Order)
  195. {
  196. return;
  197. }
  198. member.SetMemberGrade(eligible.ID);
  199. }
  200. private static async Task<bool> AlreadyGrantedAsync(IAppDbContext db, int memberID, string refID, CancellationToken ct)
  201. {
  202. // 토큰을 지급하는 액션은 원장(WalletTransaction.RefID)이 존재하면 이미 지급된 것.
  203. var byLedger = await db.WalletTransaction.AsNoTracking()
  204. .AnyAsync(c => c.RefID == refID && c.TxType == Domain.Entities.Wallets.ValueObject.WalletTransactionType.RewardEarned, ct);
  205. if (byLedger)
  206. {
  207. return true;
  208. }
  209. // XP 전용(토큰 미지급) 액션은 MemberExpLog Reason 마커로 판정.
  210. var marker = RefMarker(refID);
  211. return await db.MemberExpLog.AsNoTracking().AnyAsync(c => c.MemberID == memberID && c.Reason.EndsWith(marker), ct);
  212. }
  213. private static (int CountCap, int ExpCap, int PointCap) ResolveCaps(RewardConfig c, RewardActionType action) => action switch
  214. {
  215. RewardActionType.Post => (c.PostDailyCount, c.PostDailyExp, c.PostDailyPoint),
  216. RewardActionType.Comment => (c.CommentDailyCount, c.CommentDailyExp, c.CommentDailyPoint),
  217. RewardActionType.LikeGiven => (c.LikeGivenDailyCount, c.LikeGivenDailyExp, 0),
  218. RewardActionType.LikeReceived => (c.LikeReceivedDailyCount, 0, c.LikeReceivedDailyPoint),
  219. _ => (0, 0, 0)
  220. };
  221. /// <summary>남은 한도로 clamp. cap 0 = 무제한. 이미 소진됐으면 0.</summary>
  222. private static int ClampByCap(int requested, int alreadyToday, int cap)
  223. {
  224. if (requested <= 0)
  225. {
  226. return 0;
  227. }
  228. if (cap <= 0)
  229. {
  230. return requested;
  231. }
  232. var remaining = cap - alreadyToday;
  233. if (remaining <= 0)
  234. {
  235. return 0;
  236. }
  237. return Math.Min(requested, remaining);
  238. }
  239. private static string BuildExpReason(string reason, string refID) => $"{reason} {RefMarker(refID)}";
  240. private static string RefMarker(string refID) => $"[{refID}]";
  241. private static DateOnly TodayKst()
  242. {
  243. var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone);
  244. return DateOnly.FromDateTime(nowKst);
  245. }
  246. private static async Task<RewardConfig?> LoadConfigAsync(IAppDbContext db, CancellationToken ct)
  247. {
  248. var entity = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).FirstOrDefaultAsync(ct);
  249. return entity?.Reward;
  250. }
  251. private static TimeZoneInfo ResolveKst()
  252. {
  253. if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
  254. {
  255. return tz;
  256. }
  257. if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
  258. {
  259. return tz;
  260. }
  261. return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
  262. }
  263. }