🧱 Localization infrastructure
This commit is contained in:
		| @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Mvc; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using NodaTime.Extensions; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Account; | ||||
|  | ||||
| @@ -261,6 +262,46 @@ public class AccountController( | ||||
|         return NoContent(); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("me/check-in")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<CheckInResult>> GetCheckInResult() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|         var userId = currentUser.Id; | ||||
|          | ||||
|         var today = SystemClock.Instance.GetCurrentInstant().InUtc().Date; | ||||
|         var localTime = new TimeOnly(0, 0); | ||||
|         var startOfDay = today.ToDateOnly().ToDateTime(localTime).ToInstant(); | ||||
|         var endOfDay = today.PlusDays(1).ToDateOnly().ToDateTime(localTime).ToInstant(); | ||||
|          | ||||
|         var result = await db.AccountCheckInResults | ||||
|             .Where(x => x.AccountId == userId) | ||||
|             .Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay) | ||||
|             .OrderByDescending(x => x.CreatedAt) | ||||
|             .FirstOrDefaultAsync(); | ||||
|      | ||||
|         return result is null ? NotFound() : Ok(result); | ||||
|     } | ||||
|      | ||||
|     [HttpPost("me/check-in")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|          | ||||
|         var isAvailable = await events.CheckInDailyIsAvailable(currentUser); | ||||
|         if (!isAvailable) | ||||
|             return BadRequest("Check-in is not available for today."); | ||||
|      | ||||
|         var needsCaptcha = events.CheckInDailyDoAskCaptcha(currentUser); | ||||
|         if (needsCaptcha && string.IsNullOrWhiteSpace(captchaToken)) | ||||
|             return StatusCode(423, "Captcha is required for this check-in."); | ||||
|         if (!await auth.ValidateCaptcha(captchaToken!)) | ||||
|             return BadRequest("Invalid captcha token."); | ||||
|  | ||||
|         return await events.CheckInDaily(currentUser); | ||||
|     } | ||||
|  | ||||
|     [HttpGet("search")] | ||||
|     public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20) | ||||
|     { | ||||
|   | ||||
| @@ -1,14 +1,23 @@ | ||||
| using DysonNetwork.Sphere.Activity; | ||||
| using DysonNetwork.Sphere.Connection; | ||||
| using DysonNetwork.Sphere.Resources; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.Localization; | ||||
| using NodaTime; | ||||
| using NodaTime; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Account; | ||||
|  | ||||
| public class AccountEventService(AppDatabase db, ActivityService act, WebSocketService ws, IMemoryCache cache) | ||||
| public class AccountEventService( | ||||
|     AppDatabase db, | ||||
|     AccountService acc, | ||||
|     ActivityService act, | ||||
|     WebSocketService ws, | ||||
|     IMemoryCache cache | ||||
| ) | ||||
| { | ||||
|     private static readonly Random Random = new(); | ||||
|     private const string StatusCacheKey = "account_status_"; | ||||
|  | ||||
|     public async Task<Status> GetStatus(long userId) | ||||
| @@ -76,4 +85,84 @@ public class AccountEventService(AppDatabase db, ActivityService act, WebSocketS | ||||
|         db.Update(status); | ||||
|         await db.SaveChangesAsync(); | ||||
|     } | ||||
|  | ||||
|     private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative) | ||||
|     private const string CaptchaCacheKey = "checkin_captcha_"; | ||||
|     private const int CaptchaProbabilityPercent = 20; | ||||
|  | ||||
|     public bool CheckInDailyDoAskCaptcha(Account user) | ||||
|     { | ||||
|         var cacheKey = $"{CaptchaCacheKey}{user.Id}"; | ||||
|         if (cache.TryGetValue(cacheKey, out bool? needsCaptcha)) | ||||
|             return needsCaptcha!.Value; | ||||
|  | ||||
|         var result = Random.Next(100) < CaptchaProbabilityPercent; | ||||
|         cache.Set(cacheKey, result, TimeSpan.FromHours(24)); | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> CheckInDailyIsAvailable(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; | ||||
|     } | ||||
|  | ||||
|     public async Task<CheckInResult> CheckInDaily(Account user) | ||||
|     { | ||||
|         if (await CheckInDailyIsAvailable(user)) throw new InvalidOperationException("Check-in is not available"); | ||||
|  | ||||
|         var localizer = acc.GetEventLocalizer(user.Language); | ||||
|  | ||||
|         // 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) | ||||
|             .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 | ||||
|         }; | ||||
|  | ||||
|         db.AccountCheckInResults.Add(result); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         await act.CreateActivity( | ||||
|             user, | ||||
|             "accounts.check-in", | ||||
|             $"account.check-in/{result.Id}", | ||||
|             ActivityVisibility.Friends | ||||
|         ); | ||||
|  | ||||
|         return result; | ||||
|     } | ||||
| } | ||||
| @@ -1,10 +1,13 @@ | ||||
| using System.Globalization; | ||||
| using DysonNetwork.Sphere.Localization; | ||||
| using DysonNetwork.Sphere.Permission; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.Extensions.Caching.Memory; | ||||
| using Microsoft.Extensions.Localization; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Account; | ||||
|  | ||||
| public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache cache) | ||||
| public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache cache, IStringLocalizerFactory localizerFactory) | ||||
| { | ||||
|     public async Task PurgeAccountCache(Account account) | ||||
|     { | ||||
| @@ -31,4 +34,9 @@ public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache c | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|      | ||||
|     public IStringLocalizer GetEventLocalizer(string language) | ||||
|     { | ||||
|         return localizerFactory.Create(language, nameof(AccountEventResource)); | ||||
|     } | ||||
| } | ||||
| @@ -23,4 +23,30 @@ public class Status : ModelBase | ||||
|      | ||||
|     public long AccountId { get; set; } | ||||
|     public Account Account { get; set; } = null!; | ||||
| } | ||||
| 
 | ||||
| public enum CheckInResultLevel | ||||
| { | ||||
|     Worst, | ||||
|     Worse, | ||||
|     Normal, | ||||
|     Better, | ||||
|     Best | ||||
| } | ||||
| 
 | ||||
| public class CheckInResult : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     public CheckInResultLevel Level { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>(); | ||||
|      | ||||
|     public long AccountId { get; set; } | ||||
|     public Account Account { get; set; } = null!; | ||||
| } | ||||
| 
 | ||||
| public class FortuneTip | ||||
| { | ||||
|     public bool IsPositive { get; set; } | ||||
|     public string Title { get; set; } = null!; | ||||
|     public string Content { get; set; } = null!; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user