using System.Text.Json; using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Cache; using SharedKernel.Results; using Domain.Entities.Common.ValueObject; using Domain.Entities.Members.Logs; using Application.Helpers; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Attendance.CheckIn; internal sealed class Handler(IAppDbContext db, ICacheService cache) : ICommandHandler> { private static readonly JsonSerializerOptions _rankBonusJsonOptions = new() { PropertyNameCaseInsensitive = true }; public async Task> Handle(Command r, CancellationToken ct) { if (r.MemberID <= 0) { return Result.Failure(Error.Unauthorized("Attendance.LoginRequired", "로그인이 필요합니다.")); } // 출석 설정 조회 var configResponse = await cache.GetAsync(CacheKeys.Config, ct); if (configResponse is null) { configResponse = await new Features.Config.Get.Handler(db, cache).Handle(new Features.Config.Get.Query(), ct); } if (configResponse is null || !configResponse.Attendance.IsEnabled) { return Result.Failure(Error.Problem("Attendance.Disabled", "출석 기능이 비활성화 상태입니다.")); } var attendanceConfig = configResponse.Attendance; // 로컬(서버 KST) 기준 "오늘"을 UTC 범위로 환산 var todayStartUtc = DateTime.Now.Date.ToUniversalTime(); var tomorrowStartUtc = todayStartUtc.AddDays(1); var yesterdayStartUtc = todayStartUtc.AddDays(-1); // 오늘 이미 출석했는지 체크 var alreadyCheckedIn = await db.Attendance.AsNoTracking() .AnyAsync(x => x.MemberID == r.MemberID && x.CreatedAt >= todayStartUtc && x.CreatedAt < tomorrowStartUtc, ct); if (alreadyCheckedIn) { return Result.Failure(Error.Conflict("Attendance.AlreadyCheckedIn", "오늘은 이미 출석하셨습니다.")); } // 어제 출석 기록으로 연속 출석 계산 var yesterdayRecord = await db.Attendance.AsNoTracking() .Where(x => x.MemberID == r.MemberID && x.CreatedAt >= yesterdayStartUtc && x.CreatedAt < todayStartUtc) .Select(x => new { x.ConsecutiveDays }) .FirstOrDefaultAsync(ct); var consecutiveDays = yesterdayRecord is not null ? yesterdayRecord.ConsecutiveDays + 1 : 1; // 보상 계산 var baseExp = attendanceConfig.BaseExp; var basePoint = attendanceConfig.BasePoint; var bonusExp = 0; var bonusPoint = 0; if (attendanceConfig.UseStreakBonus && consecutiveDays > 1) { var streakDays = Math.Min(consecutiveDays - 1, attendanceConfig.StreakBonusMaxDays); bonusExp = attendanceConfig.StreakBonusPerDay * streakDays; bonusPoint = attendanceConfig.StreakBonusPointPerDay * streakDays; } var totalExp = baseExp + bonusExp; var totalPoint = basePoint + bonusPoint; // 경험치 부스터(ExpBooster) 적용 — 활성 시 획득 XP 2배 totalExp = await ExpBoostService.ApplyAsync(db, r.MemberID, totalExp, DateTime.UtcNow, ct); var greeting = string.IsNullOrWhiteSpace(r.Greeting) ? "" : r.Greeting.Trim(); if (greeting.Length > 200) { greeting = greeting[..200]; } // 출석 기록 생성 var attendance = Domain.Entities.Members.Attendance.Create(r.MemberID, greeting, consecutiveDays, totalExp, totalPoint); db.Attendance.Add(attendance); // MemberStats 경험치 + 출석 횟수 var stats = await db.MemberStats.FirstOrDefaultAsync(x => x.MemberID == r.MemberID, ct); if (stats is not null) { stats.Exp += totalExp; stats.AttendanceCount++; // 경험치 로그 if (totalExp > 0) { var expLog = MemberExpLog.Create(r.MemberID, "출석", totalExp, stats.Exp); db.MemberExpLog.Add(expLog); } } // 포인트 보상 (Wallet Reward) if (totalPoint > 0) { var wallet = await db.Wallet .Include(w => w.Balances) .FirstOrDefaultAsync(w => w.MemberID == r.MemberID, ct); if (wallet is not null) { wallet.CreditReward(Money.KRW(totalPoint), "ATTENDANCE", attendance.ID.ToString()); } } await db.SaveChangesAsync(ct); // 오늘 출석 순위 계산 var rank = await db.Attendance.AsNoTracking() .CountAsync(x => x.CreatedAt >= todayStartUtc && x.CreatedAt <= attendance.CreatedAt, ct); // 순위 보상 계산 var rankBonusExp = 0; var rankBonusPoint = 0; if (attendanceConfig.UseRankBonus && rank is >= 1 and <= 10 && !string.IsNullOrWhiteSpace(attendanceConfig.RankBonusConfig)) { try { var rankBonuses = JsonSerializer.Deserialize>(attendanceConfig.RankBonusConfig, _rankBonusJsonOptions); var entry = rankBonuses?.Find(x => x.Rank == rank); if (entry is not null) { rankBonusExp = entry.Exp; rankBonusPoint = entry.Point; // 경험치 부스터 적용 (순위 보상 XP) rankBonusExp = await ExpBoostService.ApplyAsync(db, r.MemberID, rankBonusExp, DateTime.UtcNow, ct); attendance.AddRankBonus(rankBonusExp, rankBonusPoint); if (stats is not null && rankBonusExp > 0) { stats.Exp += rankBonusExp; var expLog = MemberExpLog.Create(r.MemberID, "출석 순위 보상", rankBonusExp, stats.Exp); db.MemberExpLog.Add(expLog); } if (rankBonusPoint > 0) { var wallet = await db.Wallet .Include(w => w.Balances) .FirstOrDefaultAsync(w => w.MemberID == r.MemberID, ct); if (wallet is not null) { wallet.CreditReward(Money.KRW(rankBonusPoint), "ATTENDANCE_RANK", attendance.ID.ToString()); } } await db.SaveChangesAsync(ct); } } catch (JsonException) { // JSON 파싱 실패 시 순위 보상 무시 } } return Result.Success(new Response(attendance.ID, rank, consecutiveDays, totalExp + rankBonusExp, totalPoint + rankBonusPoint, rankBonusExp, rankBonusPoint)); } } internal sealed class RankBonusEntry { public int Rank { get; set; } public int Exp { get; set; } public int Point { get; set; } }