RewardService.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. using Application.Abstractions.Cache;
  2. using Application.Abstractions.Data;
  3. using Application.Abstractions.Notification;
  4. using Application.Features.Config.Get;
  5. using Domain.Entities.Common;
  6. using Domain.Entities.Common.ValueObject;
  7. using Domain.Entities.Members;
  8. using Domain.Entities.Members.Logs;
  9. using Domain.Entities.Members.ValueObject;
  10. using Domain.Entities.Notifications.ValueObject;
  11. using Microsoft.EntityFrameworkCore;
  12. namespace Application.Helpers;
  13. /// <summary>
  14. /// D3 M2 보상 엔진 — 흩어진 보상 지급(글/댓글/추천/채팅)을 단일 지급 관문으로 통합한다.
  15. /// 액션별 일일 캡(횟수/경험치/토큰)을 MemberDailyRewardCounter 로 강제하고,
  16. /// XP 지급 후 다음 등급 RequiredExp 를 넘으면 인라인으로 자동 승급(래칫, 강등 없음 — d0 §5-⑨)한다.
  17. ///
  18. /// 계약: ActivityTokenGranter 와 동일하게 <b>호출자가 SaveChangesAsync</b> 를 수행한다
  19. /// (작성/전송이 주된 결과, 지급은 부수효과 — 단일 커밋 유지).
  20. /// 멱등: refID 로 이미 지급된 액션이면 재지급하지 않는다 (토큰=WalletTransaction, XP전용=MemberExpLog Reason 마커).
  21. /// PaperReward(8) 는 캡 미적용·기록만 (d0 §2.2) — 별도 GrantPaperRewardAsync 로 처리.
  22. /// </summary>
  23. public static class RewardService
  24. {
  25. private static readonly TimeZoneInfo KstZone = ResolveKst();
  26. // ---- refID 헬퍼 (멱등 키) ----
  27. public static string PostRefIDFor(int postID) => $"reward-post:{postID}";
  28. public static string CommentRefIDFor(int commentID) => $"reward-comment:{commentID}";
  29. public static string LikeGivenRefIDFor(int postID, int giverID) => $"reward-like-given:{postID}:{giverID}";
  30. public static string LikeReceivedRefIDFor(int postID, int giverID) => $"reward-like-received:{postID}:{giverID}";
  31. public static string ChatRefIDFor(int memberID, DateTime sentAtUtc) => $"reward-chat:{memberID}:{sentAtUtc.Ticks}";
  32. /// <summary>게시글 작성 보상 (PostExp + PostPoint). MinPostLength·NewMemberHoldDays·일일 캡 적용.</summary>
  33. public static async Task GrantForPostAsync(IAppDbContext db, ICacheService cache, int memberID, int postID, int contentLength, CancellationToken ct)
  34. {
  35. var config = await LoadConfigAsync(db, cache, ct);
  36. if (config is null || (config.PostExp <= 0 && config.PostPoint <= 0))
  37. {
  38. return;
  39. }
  40. if (config.MinPostLength > 0 && contentLength < config.MinPostLength)
  41. {
  42. return;
  43. }
  44. await GrantAsync(db, memberID, RewardActionType.Post, config.PostExp, config.PostPoint, PostRefIDFor(postID), "게시글 작성 보상", config, ct);
  45. }
  46. /// <summary>댓글 작성 보상 (CommentExp + CommentPoint). MinCommentLength·NewMemberHoldDays·일일 캡 적용.</summary>
  47. public static async Task GrantForCommentAsync(IAppDbContext db, ICacheService cache, int memberID, int commentID, int contentLength, CancellationToken ct)
  48. {
  49. var config = await LoadConfigAsync(db, cache, ct);
  50. if (config is null || (config.CommentExp <= 0 && config.CommentPoint <= 0))
  51. {
  52. return;
  53. }
  54. if (config.MinCommentLength > 0 && contentLength < config.MinCommentLength)
  55. {
  56. return;
  57. }
  58. await GrantAsync(db, memberID, RewardActionType.Comment, config.CommentExp, config.CommentPoint, CommentRefIDFor(commentID), "댓글 작성 보상", config, ct);
  59. }
  60. /// <summary>추천(좋아요) 보상 — 누른 회원(LikeGivenExp) + 받은 작성자(LikeReceivedPoint). 본인 글 추천은 지급 없음.</summary>
  61. public static async Task GrantForLikeAsync(IAppDbContext db, ICacheService cache, int giverID, int? authorID, int postID, CancellationToken ct)
  62. {
  63. var config = await LoadConfigAsync(db, cache, ct);
  64. if (config is null)
  65. {
  66. return;
  67. }
  68. // 누른 쪽 XP
  69. if (config.LikeGivenExp > 0 && giverID > 0)
  70. {
  71. await GrantAsync(db, giverID, RewardActionType.LikeGiven, config.LikeGivenExp, 0, LikeGivenRefIDFor(postID, giverID), "추천 보상", config, ct);
  72. }
  73. // 받은 쪽 토큰 — 본인 글 자추천 제외
  74. if (config.LikeReceivedPoint > 0 && authorID is int author && author > 0 && author != giverID)
  75. {
  76. await GrantAsync(db, author, RewardActionType.LikeReceived, 0, config.LikeReceivedPoint, LikeReceivedRefIDFor(postID, giverID), "추천 받음 보상", config, ct);
  77. }
  78. }
  79. /// <summary>채팅 보상 (Chat XP, 수치는 ChatExpConfig.ChatXpPerMessage 승계 — d0 §3-H). 일일 캡 적용.</summary>
  80. public static async Task GrantForChatAsync(IAppDbContext db, ICacheService cache, int memberID, int chatXp, DateTime sentAtUtc, CancellationToken ct)
  81. {
  82. if (chatXp <= 0 || memberID <= 0)
  83. {
  84. return;
  85. }
  86. var config = await LoadConfigAsync(db, cache, ct);
  87. if (config is null)
  88. {
  89. return;
  90. }
  91. await GrantAsync(db, memberID, RewardActionType.Chat, chatXp, 0, ChatRefIDFor(memberID, sentAtUtc), "채팅 보상", config, ct);
  92. }
  93. /// <summary>
  94. /// 단일 지급 관문. 일일 캡으로 exp/point 를 clamp 하고, 멱등(refID) 판정 후 지급한다.
  95. /// XP → MemberStats.Exp(+MemberExpLog, 자동 승급), Point → Wallet.CreditReward.
  96. /// 캡/멱등으로 실제 지급분이 0 이면 카운터도 원장도 남기지 않는다.
  97. /// </summary>
  98. public static async Task GrantAsync(
  99. IAppDbContext db,
  100. int memberID,
  101. RewardActionType action,
  102. int exp,
  103. int point,
  104. string refID,
  105. string reason,
  106. RewardConfig config,
  107. CancellationToken ct,
  108. INotificationService? notifications = null)
  109. {
  110. if (memberID <= 0 || (exp <= 0 && point <= 0))
  111. {
  112. return;
  113. }
  114. // 신규 회원 보상 유예
  115. if (config.NewMemberHoldDays > 0)
  116. {
  117. var createdAt = await db.Member.AsNoTracking().Where(c => c.ID == memberID).Select(c => (DateTime?)c.CreatedAt).FirstOrDefaultAsync(ct);
  118. if (createdAt is null)
  119. {
  120. return;
  121. }
  122. if (createdAt.Value.AddDays(config.NewMemberHoldDays) > DateTime.UtcNow)
  123. {
  124. return;
  125. }
  126. }
  127. // 멱등: 동일 refID 로 이미 지급됐는지 확인
  128. if (await AlreadyGrantedAsync(db, memberID, action, refID, ct))
  129. {
  130. return;
  131. }
  132. var today = TodayKst();
  133. // 오늘 이 액션의 누적치 로드 (캡 clamp 기준)
  134. var counter = await db.MemberDailyRewardCounter
  135. .FirstOrDefaultAsync(c => c.MemberID == memberID && c.RewardDate == today && c.ActionType == action, ct);
  136. var caps = ResolveCaps(config, action);
  137. var grantExp = ClampByCap(exp, counter?.ExpTotal ?? 0, caps.ExpCap);
  138. var grantPoint = ClampByCap(point, counter?.PointTotal ?? 0, caps.PointCap);
  139. // 횟수 캡 도달 시 이번 지급은 스킵
  140. if (caps.CountCap > 0 && (counter?.Count ?? 0) >= caps.CountCap)
  141. {
  142. return;
  143. }
  144. if (grantExp <= 0 && grantPoint <= 0)
  145. {
  146. return;
  147. }
  148. // 카운터 UPSERT (신규면 추가, 기존이면 누적)
  149. if (counter is null)
  150. {
  151. counter = MemberDailyRewardCounter.Create(memberID, today, action);
  152. counter.Add(grantExp, grantPoint);
  153. db.MemberDailyRewardCounter.Add(counter);
  154. }
  155. else
  156. {
  157. counter.Add(grantExp, grantPoint);
  158. }
  159. // XP 지급 (+ 자동 승급)
  160. if (grantExp > 0)
  161. {
  162. var stats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
  163. if (stats is not null)
  164. {
  165. stats.Exp += grantExp;
  166. // XP 전용(토큰 미지급) 지급의 멱등 판정을 위해 Reason 에 refID 마커를 붙인다.
  167. db.MemberExpLog.Add(MemberExpLog.Create(memberID, BuildExpReason(reason, refID), grantExp, stats.Exp));
  168. await TryPromoteGradeAsync(db, memberID, stats.Exp, notifications, ct);
  169. }
  170. }
  171. // 토큰 지급 (Wallet Reward 파티션)
  172. if (grantPoint > 0)
  173. {
  174. var wallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
  175. wallet?.CreditReward(Money.KRW(grantPoint), reason, refID);
  176. }
  177. }
  178. /// <summary>
  179. /// 모의투자 시즌 확정 보상 등 캡 비적용·기록만(d0 §2.2). 캡 clamp 없이 지급하되 카운터에는 누적 기록만 남긴다.
  180. /// </summary>
  181. public static async Task GrantPaperRewardAsync(
  182. IAppDbContext db,
  183. int memberID,
  184. int exp,
  185. int point,
  186. string refID,
  187. string reason,
  188. CancellationToken ct,
  189. INotificationService? notifications = null)
  190. {
  191. if (memberID <= 0 || (exp <= 0 && point <= 0))
  192. {
  193. return;
  194. }
  195. if (await AlreadyGrantedAsync(db, memberID, RewardActionType.PaperReward, refID, ct))
  196. {
  197. return;
  198. }
  199. var today = TodayKst();
  200. var counter = await db.MemberDailyRewardCounter
  201. .FirstOrDefaultAsync(c => c.MemberID == memberID && c.RewardDate == today && c.ActionType == RewardActionType.PaperReward, ct);
  202. if (counter is null)
  203. {
  204. counter = MemberDailyRewardCounter.Create(memberID, today, RewardActionType.PaperReward);
  205. counter.Add(exp, point);
  206. db.MemberDailyRewardCounter.Add(counter);
  207. }
  208. else
  209. {
  210. counter.Add(exp, point);
  211. }
  212. if (exp > 0)
  213. {
  214. var stats = await db.MemberStats.FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
  215. if (stats is not null)
  216. {
  217. stats.Exp += exp;
  218. db.MemberExpLog.Add(MemberExpLog.Create(memberID, BuildExpReason(reason, refID), exp, stats.Exp));
  219. await TryPromoteGradeAsync(db, memberID, stats.Exp, notifications, ct);
  220. }
  221. }
  222. if (point > 0)
  223. {
  224. var wallet = await db.Wallet.Include(c => c.Balances).FirstOrDefaultAsync(c => c.MemberID == memberID, ct);
  225. wallet?.CreditReward(Money.KRW(point), reason, refID);
  226. }
  227. }
  228. /// <summary>
  229. /// 자동 승급(인라인, d3 §⑥). 현재 등급보다 상위이면서 RequiredExp 를 충족하는 활성 등급 중 최고 등급으로 승급한다.
  230. /// 래칫 — 강등 없음(d0 §5-⑨). 승급 시 Notification + AppHub 푸시(notifications 주입 시).
  231. /// </summary>
  232. private static async Task TryPromoteGradeAsync(IAppDbContext db, int memberID, long currentExp, INotificationService? notifications, CancellationToken ct)
  233. {
  234. var member = await db.Member.FirstOrDefaultAsync(c => c.ID == memberID, ct);
  235. if (member is null)
  236. {
  237. return;
  238. }
  239. // 활성 등급을 RequiredExp 오름차순으로 로드 (소량 — 캐시 불필요)
  240. var grades = await db.MemberGrade.AsNoTracking()
  241. .Where(c => c.IsActive)
  242. .OrderBy(c => c.RequiredExp)
  243. .Select(c => new { c.ID, c.KorName, c.RequiredExp, c.Order })
  244. .ToListAsync(ct);
  245. if (grades.Count == 0)
  246. {
  247. return;
  248. }
  249. // 충족하는 최고 등급 (RequiredExp <= currentExp 중 마지막)
  250. var eligible = grades.LastOrDefault(c => c.RequiredExp <= currentExp);
  251. if (eligible is null)
  252. {
  253. return;
  254. }
  255. // 현재 등급 Order 확인 — 래칫(하향 금지)
  256. var currentOrder = member.MemberGradeID is int gid
  257. ? grades.FirstOrDefault(c => c.ID == gid)?.Order
  258. : null;
  259. var eligibleGrade = grades.First(c => c.ID == eligible.ID);
  260. // 이미 해당 등급 이상이면 승급 없음
  261. if (member.MemberGradeID == eligible.ID)
  262. {
  263. return;
  264. }
  265. if (currentOrder is short order && order >= eligibleGrade.Order)
  266. {
  267. return;
  268. }
  269. member.SetMemberGrade(eligible.ID);
  270. // 승급 알림 (주입 시). 캐시 무효화(멤버 표시정보)는 상위에서 관리.
  271. if (notifications is not null)
  272. {
  273. await notifications.SendAsync(
  274. memberID,
  275. NotificationType.SystemAnnounce,
  276. "등급 승급",
  277. $"축하합니다! '{eligible.KorName}' 등급으로 승급했습니다.",
  278. actionUrl: null,
  279. relatedType: "MemberGrade",
  280. relatedID: eligible.ID,
  281. imageUrl: null,
  282. ct: ct);
  283. }
  284. }
  285. private static async Task<bool> AlreadyGrantedAsync(IAppDbContext db, int memberID, RewardActionType action, string refID, CancellationToken ct)
  286. {
  287. // 토큰을 지급하는 액션은 원장(WalletTransaction.RefID)이 존재하면 이미 지급된 것.
  288. var byLedger = await db.WalletTransaction.AsNoTracking()
  289. .AnyAsync(c => c.RefID == refID && c.TxType == Domain.Entities.Wallets.ValueObject.WalletTransactionType.RewardEarned, ct);
  290. if (byLedger)
  291. {
  292. return true;
  293. }
  294. // XP 전용(토큰 미지급) 액션은 MemberExpLog Reason 마커로 판정.
  295. var marker = RefMarker(refID);
  296. return await db.MemberExpLog.AsNoTracking().AnyAsync(c => c.MemberID == memberID && c.Reason.EndsWith(marker), ct);
  297. }
  298. private static (int CountCap, int ExpCap, int PointCap) ResolveCaps(RewardConfig c, RewardActionType action) => action switch
  299. {
  300. RewardActionType.Post => (c.PostDailyCount, c.PostDailyExp, c.PostDailyPoint),
  301. RewardActionType.Comment => (c.CommentDailyCount, c.CommentDailyExp, c.CommentDailyPoint),
  302. RewardActionType.LikeGiven => (c.LikeGivenDailyCount, c.LikeGivenDailyExp, 0),
  303. RewardActionType.LikeReceived => (c.LikeReceivedDailyCount, 0, c.LikeReceivedDailyPoint),
  304. RewardActionType.Chat => (c.ChatDailyCount, c.ChatDailyExp, 0),
  305. _ => (0, 0, 0)
  306. };
  307. /// <summary>남은 한도로 clamp. cap 0 = 무제한. 이미 소진됐으면 0.</summary>
  308. private static int ClampByCap(int requested, int alreadyToday, int cap)
  309. {
  310. if (requested <= 0)
  311. {
  312. return 0;
  313. }
  314. if (cap <= 0)
  315. {
  316. return requested;
  317. }
  318. var remaining = cap - alreadyToday;
  319. if (remaining <= 0)
  320. {
  321. return 0;
  322. }
  323. return Math.Min(requested, remaining);
  324. }
  325. private static string BuildExpReason(string reason, string refID) => $"{reason} {RefMarker(refID)}";
  326. private static string RefMarker(string refID) => $"[{refID}]";
  327. private static DateOnly TodayKst()
  328. {
  329. var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone);
  330. return DateOnly.FromDateTime(nowKst);
  331. }
  332. private static async Task<RewardConfig?> LoadConfigAsync(IAppDbContext db, ICacheService cache, CancellationToken ct)
  333. {
  334. var cached = await cache.GetAsync<Response>(CacheKeys.Config, ct);
  335. if (cached is not null)
  336. {
  337. return new RewardConfig
  338. {
  339. PostExp = cached.Reward.PostExp,
  340. PostPoint = cached.Reward.PostPoint,
  341. CommentExp = cached.Reward.CommentExp,
  342. CommentPoint = cached.Reward.CommentPoint,
  343. LikeGivenExp = cached.Reward.LikeGivenExp,
  344. LikeReceivedPoint = cached.Reward.LikeReceivedPoint,
  345. MinPostLength = cached.Reward.MinPostLength,
  346. MinCommentLength = cached.Reward.MinCommentLength,
  347. NewMemberHoldDays = cached.Reward.NewMemberHoldDays,
  348. PostDailyCount = cached.Reward.PostDailyCount,
  349. PostDailyExp = cached.Reward.PostDailyExp,
  350. PostDailyPoint = cached.Reward.PostDailyPoint,
  351. CommentDailyCount = cached.Reward.CommentDailyCount,
  352. CommentDailyExp = cached.Reward.CommentDailyExp,
  353. CommentDailyPoint = cached.Reward.CommentDailyPoint,
  354. LikeGivenDailyCount = cached.Reward.LikeGivenDailyCount,
  355. LikeGivenDailyExp = cached.Reward.LikeGivenDailyExp,
  356. LikeReceivedDailyCount = cached.Reward.LikeReceivedDailyCount,
  357. LikeReceivedDailyPoint = cached.Reward.LikeReceivedDailyPoint,
  358. ChatDailyCount = cached.Reward.ChatDailyCount,
  359. ChatDailyExp = cached.Reward.ChatDailyExp
  360. };
  361. }
  362. var entity = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).FirstOrDefaultAsync(ct);
  363. return entity?.Reward;
  364. }
  365. private static TimeZoneInfo ResolveKst()
  366. {
  367. if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
  368. {
  369. return tz;
  370. }
  371. if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
  372. {
  373. return tz;
  374. }
  375. return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
  376. }
  377. }