385 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			385 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.Globalization;
 | 
						|
using DysonNetwork.Shared.Cache;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
using DysonNetwork.Shared.Services;
 | 
						|
using MagicOnion.Server;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using Microsoft.Extensions.Localization;
 | 
						|
using NodaTime;
 | 
						|
 | 
						|
namespace DysonNetwork.Pass.Account;
 | 
						|
 | 
						|
public class AccountEventService(
 | 
						|
    AppDatabase db,
 | 
						|
    ICacheService cache,
 | 
						|
    IStringLocalizer<Localization.AccountEventResource> localizer
 | 
						|
) : ServiceBase<IAccountEventService>, IAccountEventService
 | 
						|
{
 | 
						|
    private static readonly Random Random = new();
 | 
						|
    private const string StatusCacheKey = "AccountStatus_";
 | 
						|
 | 
						|
    public void PurgeStatusCache(Guid userId)
 | 
						|
    {
 | 
						|
        var cacheKey = $"{StatusCacheKey}{userId}";
 | 
						|
        cache.RemoveAsync(cacheKey);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<Status> GetStatus(Guid userId)
 | 
						|
    {
 | 
						|
        var cacheKey = $"{StatusCacheKey}{userId}";
 | 
						|
        var cachedStatus = await cache.GetAsync<Status>(cacheKey);
 | 
						|
        if (cachedStatus is not null)
 | 
						|
        {
 | 
						|
            cachedStatus!.IsOnline = !cachedStatus.IsInvisible /*&& ws.GetAccountIsConnected(userId)*/;
 | 
						|
            return cachedStatus;
 | 
						|
        }
 | 
						|
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var status = await db.AccountStatuses
 | 
						|
            .Where(e => e.AccountId == userId)
 | 
						|
            .Where(e => e.ClearedAt == null || e.ClearedAt > now)
 | 
						|
            .OrderByDescending(e => e.CreatedAt)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        // var isOnline = ws.GetAccountIsConnected(userId);
 | 
						|
        var isOnline = false; // Placeholder
 | 
						|
        if (status is not null)
 | 
						|
        {
 | 
						|
            status.IsOnline = !status.IsInvisible && isOnline;
 | 
						|
            await cache.SetWithGroupsAsync(
 | 
						|
                cacheKey,
 | 
						|
                status,
 | 
						|
                [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
 | 
						|
                TimeSpan.FromMinutes(5)
 | 
						|
            );
 | 
						|
            return status;
 | 
						|
        }
 | 
						|
 | 
						|
        if (isOnline)
 | 
						|
        {
 | 
						|
            return new Status
 | 
						|
            {
 | 
						|
                Attitude = StatusAttitude.Neutral,
 | 
						|
                IsOnline = true,
 | 
						|
                IsCustomized = false,
 | 
						|
                Label = "Online",
 | 
						|
                AccountId = userId,
 | 
						|
            };
 | 
						|
        }
 | 
						|
 | 
						|
        return new Status
 | 
						|
        {
 | 
						|
            Attitude = StatusAttitude.Neutral,
 | 
						|
            IsOnline = false,
 | 
						|
            IsCustomized = false,
 | 
						|
            Label = "Offline",
 | 
						|
            AccountId = userId,
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
 | 
						|
    {
 | 
						|
        var results = new Dictionary<Guid, Status>();
 | 
						|
        var cacheMissUserIds = new List<Guid>();
 | 
						|
 | 
						|
        foreach (var userId in userIds)
 | 
						|
        {
 | 
						|
            var cacheKey = $"{StatusCacheKey}{userId}";
 | 
						|
            var cachedStatus = await cache.GetAsync<Status>(cacheKey);
 | 
						|
            if (cachedStatus != null)
 | 
						|
            {
 | 
						|
                cachedStatus.IsOnline = !cachedStatus.IsInvisible /*&& ws.GetAccountIsConnected(userId)*/;
 | 
						|
                results[userId] = cachedStatus;
 | 
						|
            }
 | 
						|
            else
 | 
						|
            {
 | 
						|
                cacheMissUserIds.Add(userId);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        if (cacheMissUserIds.Any())
 | 
						|
        {
 | 
						|
            var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
            var statusesFromDb = await db.AccountStatuses
 | 
						|
                .Where(e => cacheMissUserIds.Contains(e.AccountId))
 | 
						|
                .Where(e => e.ClearedAt == null || e.ClearedAt > now)
 | 
						|
                .GroupBy(e => e.AccountId)
 | 
						|
                .Select(g => g.OrderByDescending(e => e.CreatedAt).First())
 | 
						|
                .ToListAsync();
 | 
						|
 | 
						|
            var foundUserIds = new HashSet<Guid>();
 | 
						|
 | 
						|
            foreach (var status in statusesFromDb)
 | 
						|
            {
 | 
						|
                // var isOnline = ws.GetAccountIsConnected(status.AccountId);
 | 
						|
                var isOnline = false; // Placeholder
 | 
						|
                status.IsOnline = !status.IsInvisible && isOnline;
 | 
						|
                results[status.AccountId] = status;
 | 
						|
                var cacheKey = $"{StatusCacheKey}{status.AccountId}";
 | 
						|
                await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
 | 
						|
                foundUserIds.Add(status.AccountId);
 | 
						|
            }
 | 
						|
 | 
						|
            var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
 | 
						|
            if (usersWithoutStatus.Any())
 | 
						|
            {
 | 
						|
                foreach (var userId in usersWithoutStatus)
 | 
						|
                {
 | 
						|
                    // var isOnline = ws.GetAccountIsConnected(userId);
 | 
						|
                    var isOnline = false; // Placeholder
 | 
						|
                    var defaultStatus = new Status
 | 
						|
                    {
 | 
						|
                        Attitude = StatusAttitude.Neutral,
 | 
						|
                        IsOnline = isOnline,
 | 
						|
                        IsCustomized = false,
 | 
						|
                        Label = isOnline ? "Online" : "Offline",
 | 
						|
                        AccountId = userId,
 | 
						|
                    };
 | 
						|
                    results[userId] = defaultStatus;
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return results;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<Status> CreateStatus(Shared.Models.Account user, Status status)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        await db.AccountStatuses
 | 
						|
            .Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now))
 | 
						|
            .ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now));
 | 
						|
 | 
						|
        db.AccountStatuses.Add(status);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
 | 
						|
        return status;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task ClearStatus(Shared.Models.Account user, Status status)
 | 
						|
    {
 | 
						|
        status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        db.Update(status);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
        PurgeStatusCache(user.Id);
 | 
						|
    }
 | 
						|
 | 
						|
    private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative)
 | 
						|
    private const string CaptchaCacheKey = "CheckInCaptcha_";
 | 
						|
    private const int CaptchaProbabilityPercent = 20;
 | 
						|
 | 
						|
    public async Task<bool> CheckInDailyDoAskCaptcha(Shared.Models.Account user)
 | 
						|
    {
 | 
						|
        var cacheKey = $"{CaptchaCacheKey}{user.Id}";
 | 
						|
        var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
 | 
						|
        if (needsCaptcha is not null)
 | 
						|
            return needsCaptcha!.Value;
 | 
						|
 | 
						|
        var result = Random.Next(100) < CaptchaProbabilityPercent;
 | 
						|
        await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
 | 
						|
        return result;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<bool> CheckInDailyIsAvailable(Shared.Models.Account user)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var lastCheckIn = await db.AccountCheckInResults
 | 
						|
            .Where(x => x.AccountId == user.Id)
 | 
						|
            .OrderByDescending(x => x.CreatedAt)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        if (lastCheckIn == null)
 | 
						|
            return true;
 | 
						|
 | 
						|
        var lastDate = lastCheckIn.CreatedAt.InUtc().Date;
 | 
						|
        var currentDate = now.InUtc().Date;
 | 
						|
 | 
						|
        return lastDate < currentDate;
 | 
						|
    }
 | 
						|
 | 
						|
    private const string CheckInLockKey = "checkin-lock:";
 | 
						|
 | 
						|
    public async Task<CheckInResult> CheckInDaily(Shared.Models.Account user)
 | 
						|
    {
 | 
						|
        var lockKey = $"{CheckInLockKey}{user.Id}";
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
 | 
						|
 | 
						|
            if (lk != null)
 | 
						|
                await lk.ReleaseAsync();
 | 
						|
        }
 | 
						|
        catch
 | 
						|
        {
 | 
						|
            // Ignore errors from this pre-check
 | 
						|
        }
 | 
						|
 | 
						|
        // Now try to acquire the lock properly
 | 
						|
        await using var lockObj =
 | 
						|
            await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
 | 
						|
        if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
 | 
						|
 | 
						|
        var cultureInfo = new CultureInfo(user.Language, false);
 | 
						|
        CultureInfo.CurrentCulture = cultureInfo;
 | 
						|
        CultureInfo.CurrentUICulture = cultureInfo;
 | 
						|
 | 
						|
        // Generate 2 positive tips
 | 
						|
        var positiveIndices = Enumerable.Range(1, FortuneTipCount)
 | 
						|
            .OrderBy(_ => Random.Next())
 | 
						|
            .Take(2)
 | 
						|
            .ToList();
 | 
						|
        var tips = positiveIndices.Select(index => new FortuneTip
 | 
						|
        {
 | 
						|
            IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
 | 
						|
            Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
 | 
						|
        }).ToList();
 | 
						|
 | 
						|
        // Generate 2 negative tips
 | 
						|
        var negativeIndices = Enumerable.Range(1, FortuneTipCount)
 | 
						|
            .Except(positiveIndices)
 | 
						|
            .OrderBy(_ => Random.Next())
 | 
						|
            .Take(2)
 | 
						|
            .ToList();
 | 
						|
        tips.AddRange(negativeIndices.Select(index => new FortuneTip
 | 
						|
        {
 | 
						|
            IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
 | 
						|
            Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
 | 
						|
        }));
 | 
						|
 | 
						|
        var result = new CheckInResult
 | 
						|
        {
 | 
						|
            Tips = tips,
 | 
						|
            Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
 | 
						|
            AccountId = user.Id,
 | 
						|
            RewardExperience = 100,
 | 
						|
            RewardPoints = 10,
 | 
						|
        };
 | 
						|
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
 | 
						|
        try
 | 
						|
        {
 | 
						|
            if (result.RewardPoints.HasValue)
 | 
						|
                // await payment.CreateTransactionWithAccountAsync(
 | 
						|
                //     null,
 | 
						|
                //     user.Id,
 | 
						|
                //     WalletCurrency.SourcePoint,
 | 
						|
                //     result.RewardPoints.Value,
 | 
						|
                //     $"Check-in reward on {now:yyyy/MM/dd}"
 | 
						|
                // );
 | 
						|
                Console.WriteLine($"Simulating transaction for {result.RewardPoints.Value} points");
 | 
						|
        }
 | 
						|
        catch
 | 
						|
        {
 | 
						|
            result.RewardPoints = null;
 | 
						|
        }
 | 
						|
 | 
						|
        await db.AccountProfiles
 | 
						|
            .Where(p => p.AccountId == user.Id)
 | 
						|
            .ExecuteUpdateAsync(s =>
 | 
						|
                s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
 | 
						|
            );
 | 
						|
        db.AccountCheckInResults.Add(result);
 | 
						|
        await db.SaveChangesAsync(); // Remember to save changes to the database
 | 
						|
 | 
						|
        // The lock will be automatically released by the await using statement
 | 
						|
        return result;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<int> GetCheckInStreak(Shared.Models.Account user)
 | 
						|
    {
 | 
						|
        var today = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
 | 
						|
        var yesterdayEnd = today.PlusDays(-1).AtMidnight().InUtc().ToInstant();
 | 
						|
        var yesterdayStart = today.PlusDays(-1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
						|
        var tomorrowEnd = today.PlusDays(1).AtMidnight().InUtc().ToInstant();
 | 
						|
        var tomorrowStart = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
						|
 | 
						|
        var yesterdayResult = await db.AccountCheckInResults
 | 
						|
            .Where(x => x.AccountId == user.Id)
 | 
						|
            .Where(x => x.CreatedAt >= yesterdayStart)
 | 
						|
            .Where(x => x.CreatedAt < yesterdayEnd)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        var tomorrowResult = await db.AccountCheckInResults
 | 
						|
            .Where(x => x.AccountId == user.Id)
 | 
						|
            .Where(x => x.CreatedAt >= tomorrowStart)
 | 
						|
            .Where(x => x.CreatedAt < tomorrowEnd)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        if (yesterdayResult is null && tomorrowResult is null)
 | 
						|
            return 1;
 | 
						|
 | 
						|
        var results = await db.AccountCheckInResults
 | 
						|
            .Where(x => x.AccountId == user.Id)
 | 
						|
            .OrderByDescending(x => x.CreatedAt)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var streak = 0;
 | 
						|
        var day = today;
 | 
						|
        while (results.Any(x =>
 | 
						|
                   x.CreatedAt >= day.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant() &&
 | 
						|
                   x.CreatedAt < day.AtMidnight().InUtc().ToInstant()))
 | 
						|
        {
 | 
						|
            streak++;
 | 
						|
            day = day.PlusDays(-1);
 | 
						|
        }
 | 
						|
 | 
						|
        return streak;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<List<DailyEventResponse>> GetEventCalendar(Shared.Models.Account user, int month, int year = 0,
 | 
						|
        bool replaceInvisible = false)
 | 
						|
    {
 | 
						|
        if (year == 0)
 | 
						|
            year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year;
 | 
						|
 | 
						|
        // Create start and end dates for the specified month
 | 
						|
        var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
						|
        var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month)));
 | 
						|
 | 
						|
        var statuses = await db.AccountStatuses
 | 
						|
            .AsNoTracking()
 | 
						|
            .TagWith("GetEventCalendar_Statuses")
 | 
						|
            .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
 | 
						|
            .Select(x => new Status
 | 
						|
            {
 | 
						|
                Id = x.Id,
 | 
						|
                Attitude = x.Attitude,
 | 
						|
                IsInvisible = !replaceInvisible && x.IsInvisible,
 | 
						|
                IsNotDisturb = x.IsNotDisturb,
 | 
						|
                Label = x.Label,
 | 
						|
                ClearedAt = x.ClearedAt,
 | 
						|
                AccountId = x.AccountId,
 | 
						|
                CreatedAt = x.CreatedAt
 | 
						|
            })
 | 
						|
            .OrderBy(x => x.CreatedAt)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var checkIn = await db.AccountCheckInResults
 | 
						|
            .AsNoTracking()
 | 
						|
            .TagWith("GetEventCalendar_CheckIn")
 | 
						|
            .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month))
 | 
						|
            .Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant())
 | 
						|
            .ToList();
 | 
						|
 | 
						|
        var statusesByDate = statuses
 | 
						|
            .GroupBy(s => s.CreatedAt.InUtc().Date)
 | 
						|
            .ToDictionary(g => g.Key, g => g.ToList());
 | 
						|
 | 
						|
        var checkInByDate = checkIn
 | 
						|
            .ToDictionary(c => c.CreatedAt.InUtc().Date);
 | 
						|
 | 
						|
        return dates.Select(date =>
 | 
						|
        {
 | 
						|
            var utcDate = date.InUtc().Date;
 | 
						|
            return new DailyEventResponse
 | 
						|
            {
 | 
						|
                Date = date,
 | 
						|
                CheckInResult = checkInByDate.GetValueOrDefault(utcDate),
 | 
						|
                Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>())
 | 
						|
            };
 | 
						|
        }).ToList();
 | 
						|
    }
 | 
						|
} |