🧱 Localization infrastructure

This commit is contained in:
2025-05-08 01:55:32 +08:00
parent ee7dc31b20
commit 891dbfb255
19 changed files with 815 additions and 9 deletions

View File

@ -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)
{

View File

@ -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;
}
}

View File

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

View File

@ -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!;
}