🧱 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