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");
}
}