.github
.idx
DysonNetwork.Sphere
Account
Account.cs
AccountController.cs
AccountCurrentController.cs
AccountEventService.cs
AccountService.cs
ActionLog.cs
ActionLogService.cs
Badge.cs
Event.cs
MagicSpell.cs
MagicSpellController.cs
MagicSpellService.cs
Notification.cs
NotificationController.cs
NotificationService.cs
Relationship.cs
RelationshipController.cs
RelationshipService.cs
Activity
Auth
Chat
Connection
Developer
Email
Localization
Migrations
Pages
Permission
Post
Properties
Publisher
Realm
Resources
Sticker
Storage
Wallet
wwwroot
.DS_Store
.gitignore
AppDatabase.cs
Dockerfile
DysonNetwork.Sphere.csproj
DysonNetwork.Sphere.csproj.DotSettings.user
DysonNetwork.Sphere.http
Program.cs
appsettings.json
package.json
postcss.config.js
tailwind.config.js
.dockerignore
.gitignore
DysonNetwork.sln
DysonNetwork.sln.DotSettings.user
compose.yaml
290 lines
9.9 KiB
C#
290 lines
9.9 KiB
C#
using System.Globalization;
|
|
using DysonNetwork.Sphere.Activity;
|
|
using DysonNetwork.Sphere.Connection;
|
|
using DysonNetwork.Sphere.Storage;
|
|
using DysonNetwork.Sphere.Wallet;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Distributed;
|
|
using Microsoft.Extensions.Localization;
|
|
using NodaTime;
|
|
using Org.BouncyCastle.Asn1.X509;
|
|
|
|
namespace DysonNetwork.Sphere.Account;
|
|
|
|
public class AccountEventService(
|
|
AppDatabase db,
|
|
ActivityService act,
|
|
WebSocketService ws,
|
|
ICacheService cache,
|
|
PaymentService payment,
|
|
IStringLocalizer<Localization.AccountEventResource> localizer
|
|
)
|
|
{
|
|
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);
|
|
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<Status> CreateStatus(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();
|
|
|
|
await act.CreateActivity(
|
|
user,
|
|
"accounts.status",
|
|
$"account.statuses/{status.Id}",
|
|
ActivityVisibility.Friends
|
|
);
|
|
|
|
return status;
|
|
}
|
|
|
|
public async Task ClearStatus(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(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(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 const string CheckInLockKey = "CheckInLock_";
|
|
|
|
public async Task<CheckInResult> CheckInDaily(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}"
|
|
);
|
|
}
|
|
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(); // Don't forget to save changes to the database
|
|
|
|
await act.CreateActivity(
|
|
user,
|
|
"accounts.check-in",
|
|
$"account.check-in/{result.Id}",
|
|
ActivityVisibility.Friends
|
|
);
|
|
|
|
// The lock will be automatically released by the await using statement
|
|
return result;
|
|
}
|
|
|
|
public async Task<List<DailyEventResponse>> GetEventCalendar(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();
|
|
}
|
|
} |