From ba49d1c7a70c04a20b4a335b1caefe691d35f63d Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 12 Jul 2025 11:40:18 +0800 Subject: [PATCH] :recycle: Basically completed the separate of account service --- DysonNetwork.Pass/Account/AbuseReport.cs | 1 + DysonNetwork.Pass/Account/Account.cs | 7 +- .../Account/AccountController.cs | 4 +- .../Account/AccountCurrentController.cs | 81 +--- .../Account/AccountEventService.cs | 32 +- DysonNetwork.Pass/Account/AccountService.cs | 10 +- DysonNetwork.Pass/Account/ActionLog.cs | 1 + DysonNetwork.Pass/Account/ActionLogService.cs | 8 +- DysonNetwork.Pass/Account/Badge.cs | 3 +- DysonNetwork.Pass/Account/Event.cs | 1 + DysonNetwork.Pass/Account/MagicSpell.cs | 1 + .../Account/MagicSpellService.cs | 119 +++-- DysonNetwork.Pass/Account/Notification.cs | 1 + .../Account/NotificationController.cs | 5 +- .../Account/NotificationService.cs | 22 +- DysonNetwork.Pass/Account/Relationship.cs | 1 + DysonNetwork.Pass/AppDatabase.cs | 27 +- DysonNetwork.Pass/Auth/Auth.cs | 2 +- DysonNetwork.Pass/Auth/AuthController.cs | 10 +- DysonNetwork.Pass/Auth/AuthService.cs | 10 +- DysonNetwork.Pass/Auth/CompactTokenService.cs | 2 +- .../Controllers/OidcProviderController.cs | 2 +- .../Services/OidcProviderService.cs | 12 +- .../Auth/OpenId/OidcController.cs | 2 +- DysonNetwork.Pass/Auth/OpenId/OidcService.cs | 4 +- DysonNetwork.Pass/Auth/Session.cs | 9 +- DysonNetwork.Pass/DysonNetwork.Pass.csproj | 69 +++ DysonNetwork.Pass/Email/EmailModels.cs | 31 ++ DysonNetwork.Pass/Email/EmailService.cs | 106 +++++ DysonNetwork.Pass/Email/RazorViewRenderer.cs | 45 ++ .../Handlers/LastActiveFlushHandler.cs | 2 +- .../Localization/AccountEventResource.cs | 6 + .../Localization/EmailResource.cs | 5 + .../Localization/NotificationResource.cs | 6 + .../Localization/SharedResource.cs | 6 + .../Pages/Emails/AccountDeletionEmail.razor | 42 ++ .../Emails/ContactVerificationEmail.razor | 43 ++ .../Pages/Emails/EmailLayout.razor | 337 +++++++++++++ .../Pages/Emails/LandingEmail.razor | 43 ++ .../Pages/Emails/PasswordResetEmail.razor | 44 ++ .../Pages/Emails/VerificationEmail.razor | 27 ++ DysonNetwork.Pass/Permission/Permission.cs | 1 + .../AccountEventResource.Designer.cs | 222 +++++++++ .../Localization/AccountEventResource.resx | 113 +++++ .../AccountEventResource.zh-hans.resx | 98 ++++ .../Localization/EmailResource.Designer.cs | 90 ++++ .../Resources/Localization/EmailResource.resx | 126 +++++ .../Localization/EmailResource.zh-hans.resx | 119 +++++ .../NotificationResource.Designer.cs | 162 +++++++ .../Localization/NotificationResource.resx | 83 ++++ .../NotificationResource.zh-hans.resx | 75 +++ .../Localization/SharedResource.Designer.cs | 48 ++ .../Localization/SharedResource.resx | 21 + .../Localization/SharedResource.zh-hans.resx | 14 + DysonNetwork.Pass/Wallet/OrderController.cs | 57 +++ DysonNetwork.Pass/Wallet/Payment.cs | 61 +++ .../PaymentHandlers/AfdianPaymentHandler.cs | 446 ++++++++++++++++++ .../PaymentHandlers/ISubscriptionOrder.cs | 18 + DysonNetwork.Pass/Wallet/PaymentService.cs | 297 ++++++++++++ DysonNetwork.Pass/Wallet/Subscription.cs | 233 +++++++++ .../Wallet/SubscriptionController.cs | 204 ++++++++ .../Wallet/SubscriptionRenewalJob.cs | 137 ++++++ .../Wallet/SubscriptionService.cs | 394 ++++++++++++++++ DysonNetwork.Pass/Wallet/Wallet.cs | 25 + DysonNetwork.Pass/Wallet/WalletController.cs | 101 ++++ DysonNetwork.Pass/Wallet/WalletService.cs | 49 ++ .../Data/CloudFileReferenceObject.cs | 17 + DysonNetwork.Shared/Data/ICloudFile.cs | 55 +++ DysonNetwork.Shared/Data/ModelBase.cs | 15 + 69 files changed, 4245 insertions(+), 225 deletions(-) create mode 100644 DysonNetwork.Pass/Email/EmailModels.cs create mode 100644 DysonNetwork.Pass/Email/EmailService.cs create mode 100644 DysonNetwork.Pass/Email/RazorViewRenderer.cs create mode 100644 DysonNetwork.Pass/Localization/AccountEventResource.cs create mode 100644 DysonNetwork.Pass/Localization/EmailResource.cs create mode 100644 DysonNetwork.Pass/Localization/NotificationResource.cs create mode 100644 DysonNetwork.Pass/Localization/SharedResource.cs create mode 100644 DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor create mode 100644 DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor create mode 100644 DysonNetwork.Pass/Pages/Emails/EmailLayout.razor create mode 100644 DysonNetwork.Pass/Pages/Emails/LandingEmail.razor create mode 100644 DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor create mode 100644 DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor create mode 100644 DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs create mode 100644 DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx create mode 100644 DysonNetwork.Pass/Resources/Localization/AccountEventResource.zh-hans.resx create mode 100644 DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs create mode 100644 DysonNetwork.Pass/Resources/Localization/EmailResource.resx create mode 100644 DysonNetwork.Pass/Resources/Localization/EmailResource.zh-hans.resx create mode 100644 DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs create mode 100644 DysonNetwork.Pass/Resources/Localization/NotificationResource.resx create mode 100644 DysonNetwork.Pass/Resources/Localization/NotificationResource.zh-hans.resx create mode 100644 DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs create mode 100644 DysonNetwork.Pass/Resources/Localization/SharedResource.resx create mode 100644 DysonNetwork.Pass/Resources/Localization/SharedResource.zh-hans.resx create mode 100644 DysonNetwork.Pass/Wallet/OrderController.cs create mode 100644 DysonNetwork.Pass/Wallet/Payment.cs create mode 100644 DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs create mode 100644 DysonNetwork.Pass/Wallet/PaymentHandlers/ISubscriptionOrder.cs create mode 100644 DysonNetwork.Pass/Wallet/PaymentService.cs create mode 100644 DysonNetwork.Pass/Wallet/Subscription.cs create mode 100644 DysonNetwork.Pass/Wallet/SubscriptionController.cs create mode 100644 DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs create mode 100644 DysonNetwork.Pass/Wallet/SubscriptionService.cs create mode 100644 DysonNetwork.Pass/Wallet/Wallet.cs create mode 100644 DysonNetwork.Pass/Wallet/WalletController.cs create mode 100644 DysonNetwork.Pass/Wallet/WalletService.cs create mode 100644 DysonNetwork.Shared/Data/CloudFileReferenceObject.cs create mode 100644 DysonNetwork.Shared/Data/ICloudFile.cs create mode 100644 DysonNetwork.Shared/Data/ModelBase.cs diff --git a/DysonNetwork.Pass/Account/AbuseReport.cs b/DysonNetwork.Pass/Account/AbuseReport.cs index ebb7a26..bc14691 100644 --- a/DysonNetwork.Pass/Account/AbuseReport.cs +++ b/DysonNetwork.Pass/Account/AbuseReport.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using DysonNetwork.Shared.Data; using NodaTime; namespace DysonNetwork.Pass.Account; diff --git a/DysonNetwork.Pass/Account/Account.cs b/DysonNetwork.Pass/Account/Account.cs index 6674ae2..027e127 100644 --- a/DysonNetwork.Pass/Account/Account.cs +++ b/DysonNetwork.Pass/Account/Account.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; using Microsoft.EntityFrameworkCore; using NodaTime; using OtpNet; @@ -19,12 +20,12 @@ public class Account : ModelBase public Profile Profile { get; set; } = null!; public ICollection Contacts { get; set; } = new List(); - public ICollection Badges { get; set; } = new List(); + public ICollection Badges { get; set; } = new List(); [JsonIgnore] public ICollection AuthFactors { get; set; } = new List(); [JsonIgnore] public ICollection Connections { get; set; } = new List(); - [JsonIgnore] public ICollection Sessions { get; set; } = new List(); - [JsonIgnore] public ICollection Challenges { get; set; } = new List(); + [JsonIgnore] public ICollection Sessions { get; set; } = new List(); + [JsonIgnore] public ICollection Challenges { get; set; } = new List(); [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = new List(); [JsonIgnore] public ICollection IncomingRelationships { get; set; } = new List(); diff --git a/DysonNetwork.Pass/Account/AccountController.cs b/DysonNetwork.Pass/Account/AccountController.cs index b6eabf3..98a2564 100644 --- a/DysonNetwork.Pass/Account/AccountController.cs +++ b/DysonNetwork.Pass/Account/AccountController.cs @@ -34,9 +34,9 @@ public class AccountController( } [HttpGet("{name}/badges")] - [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> GetBadgesByName(string name) + public async Task>> GetBadgesByName(string name) { var account = await db.Accounts .Include(e => e.Badges) diff --git a/DysonNetwork.Pass/Account/AccountCurrentController.cs b/DysonNetwork.Pass/Account/AccountCurrentController.cs index 59642d2..037935d 100644 --- a/DysonNetwork.Pass/Account/AccountCurrentController.cs +++ b/DysonNetwork.Pass/Account/AccountCurrentController.cs @@ -1,12 +1,10 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Pass.Auth; -using DysonNetwork.Pass; -using DysonNetwork.Pass.Storage; +using DysonNetwork.Pass.Permission; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; -using Org.BouncyCastle.Utilities; namespace DysonNetwork.Pass.Account; @@ -16,7 +14,6 @@ namespace DysonNetwork.Pass.Account; public class AccountCurrentController( AppDatabase db, AccountService accounts, - FileReferenceService fileRefService, AccountEventService events, AuthService auth ) : ControllerBase @@ -97,58 +94,12 @@ public class AccountCurrentController( if (request.PictureId is not null) { - var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); - if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); - - var profileResourceId = $"profile:{profile.Id}"; - - // Remove old references for the profile picture - if (profile.Picture is not null) - { - var oldPictureRefs = - await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture"); - foreach (var oldRef in oldPictureRefs) - { - await fileRefService.DeleteReferenceAsync(oldRef.Id); - } - } - - profile.Picture = picture.ToReferenceObject(); - - // Create new reference - await fileRefService.CreateReferenceAsync( - picture.Id, - "profile.picture", - profileResourceId - ); + // TODO: Create reference, set profile picture } if (request.BackgroundId is not null) { - var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); - if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); - - var profileResourceId = $"profile:{profile.Id}"; - - // Remove old references for the profile background - if (profile.Background is not null) - { - var oldBackgroundRefs = - await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background"); - foreach (var oldRef in oldBackgroundRefs) - { - await fileRefService.DeleteReferenceAsync(oldRef.Id); - } - } - - profile.Background = background.ToReferenceObject(); - - // Create new reference - await fileRefService.CreateReferenceAsync( - background.Id, - "profile.background", - profileResourceId - ); + // TODO: Create reference, set profile background } db.Update(profile); @@ -438,7 +389,7 @@ public class AccountCurrentController( public string UserAgent { get; set; } = null!; public string DeviceId { get; set; } = null!; public ChallengePlatform Platform { get; set; } - public List Sessions { get; set; } = []; + public List Sessions { get; set; } = []; } [HttpGet("devices")] @@ -446,7 +397,7 @@ public class AccountCurrentController( public async Task>> GetDevices() { if (HttpContext.Items["CurrentUser"] is not Account currentUser || - HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); @@ -475,13 +426,13 @@ public class AccountCurrentController( [HttpGet("sessions")] [Authorize] - public async Task>> GetSessions( + public async Task>> GetSessions( [FromQuery] int take = 20, [FromQuery] int offset = 0 ) { if (HttpContext.Items["CurrentUser"] is not Account currentUser || - HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); var query = db.AuthSessions .Include(session => session.Account) @@ -503,7 +454,7 @@ public class AccountCurrentController( [HttpDelete("sessions/{id:guid}")] [Authorize] - public async Task> DeleteSession(Guid id) + public async Task> DeleteSession(Guid id) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); @@ -520,10 +471,10 @@ public class AccountCurrentController( [HttpDelete("sessions/current")] [Authorize] - public async Task> DeleteCurrentSession() + public async Task> DeleteCurrentSession() { if (HttpContext.Items["CurrentUser"] is not Account currentUser || - HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); try { @@ -537,7 +488,7 @@ public class AccountCurrentController( } [HttpPatch("sessions/{id:guid}/label")] - public async Task> UpdateSessionLabel(Guid id, [FromBody] string label) + public async Task> UpdateSessionLabel(Guid id, [FromBody] string label) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); @@ -553,10 +504,10 @@ public class AccountCurrentController( } [HttpPatch("sessions/current/label")] - public async Task> UpdateCurrentSessionLabel([FromBody] string label) + public async Task> UpdateCurrentSessionLabel([FromBody] string label) { if (HttpContext.Items["CurrentUser"] is not Account currentUser || - HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); try { @@ -672,9 +623,9 @@ public class AccountCurrentController( } [HttpGet("badges")] - [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType>(StatusCodes.Status200OK)] [Authorize] - public async Task>> GetBadges() + public async Task>> GetBadges() { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); @@ -686,7 +637,7 @@ public class AccountCurrentController( [HttpPost("badges/{id:guid}/active")] [Authorize] - public async Task> ActivateBadge(Guid id) + public async Task> ActivateBadge(Guid id) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); diff --git a/DysonNetwork.Pass/Account/AccountEventService.cs b/DysonNetwork.Pass/Account/AccountEventService.cs index b8fdf9d..234b29b 100644 --- a/DysonNetwork.Pass/Account/AccountEventService.cs +++ b/DysonNetwork.Pass/Account/AccountEventService.cs @@ -1,21 +1,16 @@ using System.Globalization; -using DysonNetwork.Pass; -using DysonNetwork.Pass.Connection; -using DysonNetwork.Pass.Storage; using DysonNetwork.Pass.Wallet; +using DysonNetwork.Shared.Cache; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Localization; using NodaTime; -using Org.BouncyCastle.Asn1.X509; namespace DysonNetwork.Pass.Account; public class AccountEventService( AppDatabase db, - WebSocketService ws, - ICacheService cache, PaymentService payment, + ICacheService cache, IStringLocalizer localizer ) { @@ -34,7 +29,7 @@ public class AccountEventService( var cachedStatus = await cache.GetAsync(cacheKey); if (cachedStatus is not null) { - cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); + cachedStatus!.IsOnline = !cachedStatus.IsInvisible; // && ws.GetAccountIsConnected(userId); return cachedStatus; } @@ -44,7 +39,7 @@ public class AccountEventService( .Where(e => e.ClearedAt == null || e.ClearedAt > now) .OrderByDescending(e => e.CreatedAt) .FirstOrDefaultAsync(); - var isOnline = ws.GetAccountIsConnected(userId); + var isOnline = false; // TODO: Get connection status if (status is not null) { status.IsOnline = !status.IsInvisible && isOnline; @@ -65,7 +60,7 @@ public class AccountEventService( }; } - return new Status + return new Status { Attitude = StatusAttitude.Neutral, IsOnline = false, @@ -86,7 +81,7 @@ public class AccountEventService( var cachedStatus = await cache.GetAsync(cacheKey); if (cachedStatus != null) { - cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId); + cachedStatus.IsOnline = !cachedStatus.IsInvisible /* && ws.GetAccountIsConnected(userId) */; results[userId] = cachedStatus; } else @@ -109,7 +104,7 @@ public class AccountEventService( foreach (var status in statusesFromDb) { - var isOnline = ws.GetAccountIsConnected(status.AccountId); + var isOnline = false; // ws.GetAccountIsConnected(status.AccountId); status.IsOnline = !status.IsInvisible && isOnline; results[status.AccountId] = status; var cacheKey = $"{StatusCacheKey}{status.AccountId}"; @@ -122,7 +117,7 @@ public class AccountEventService( { foreach (var userId in usersWithoutStatus) { - var isOnline = ws.GetAccountIsConnected(userId); + var isOnline = false; // ws.GetAccountIsConnected(userId); var defaultStatus = new Status { Attitude = StatusAttitude.Neutral, @@ -198,11 +193,11 @@ public class AccountEventService( public async Task 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(); } @@ -210,9 +205,10 @@ public class AccountEventService( { // 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)); + 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); @@ -274,7 +270,7 @@ public class AccountEventService( 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 db.SaveChangesAsync(); // Don't forget to save changes to the database // The lock will be automatically released by the await using statement return result; diff --git a/DysonNetwork.Pass/Account/AccountService.cs b/DysonNetwork.Pass/Account/AccountService.cs index ed80166..6ba2919 100644 --- a/DysonNetwork.Pass/Account/AccountService.cs +++ b/DysonNetwork.Pass/Account/AccountService.cs @@ -1,16 +1,14 @@ using System.Globalization; -using DysonNetwork.Pass; +using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Auth.OpenId; using DysonNetwork.Pass.Email; - using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Permission; -using DysonNetwork.Pass.Storage; +using DysonNetwork.Shared.Cache; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; -using Org.BouncyCastle.Utilities; using OtpNet; namespace DysonNetwork.Pass.Account; @@ -454,7 +452,7 @@ public class AccountService( ); } - public async Task UpdateSessionLabel(Account account, Guid sessionId, string label) + public async Task UpdateSessionLabel(Account account, Guid sessionId, string label) { var session = await db.AuthSessions .Include(s => s.Challenge) @@ -574,7 +572,7 @@ public class AccountService( /// This method will grant a badge to the account. /// Shouldn't be exposed to normal user and the user itself. /// - public async Task GrantBadge(Account account, Badge badge) + public async Task GrantBadge(Account account, AccountBadge badge) { badge.AccountId = account.Id; db.Badges.Add(badge); diff --git a/DysonNetwork.Pass/Account/ActionLog.cs b/DysonNetwork.Pass/Account/ActionLog.cs index 03a4d02..32f0c3f 100644 --- a/DysonNetwork.Pass/Account/ActionLog.cs +++ b/DysonNetwork.Pass/Account/ActionLog.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using DysonNetwork.Shared.Data; using Point = NetTopologySuite.Geometries.Point; namespace DysonNetwork.Pass.Account; diff --git a/DysonNetwork.Pass/Account/ActionLogService.cs b/DysonNetwork.Pass/Account/ActionLogService.cs index a85ff6d..b5ca0b4 100644 --- a/DysonNetwork.Pass/Account/ActionLogService.cs +++ b/DysonNetwork.Pass/Account/ActionLogService.cs @@ -1,7 +1,5 @@ -using Quartz; -using DysonNetwork.Pass; -using DysonNetwork.Pass.Storage; -using DysonNetwork.Pass.Storage.Handlers; +using DysonNetwork.Shared.Cache; +using DysonNetwork.Shared.Geo; namespace DysonNetwork.Pass.Account; @@ -38,7 +36,7 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs) else throw new ArgumentException("No user context was found"); - if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession) + if (request.HttpContext.Items["CurrentSession"] is Auth.AuthSession currentSession) log.SessionId = currentSession.Id; fbs.Enqueue(log); diff --git a/DysonNetwork.Pass/Account/Badge.cs b/DysonNetwork.Pass/Account/Badge.cs index 7f15899..b8cb8b1 100644 --- a/DysonNetwork.Pass/Account/Badge.cs +++ b/DysonNetwork.Pass/Account/Badge.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; using NodaTime; namespace DysonNetwork.Pass.Account; -public class Badge : ModelBase +public class AccountBadge : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); [MaxLength(1024)] public string Type { get; set; } = null!; diff --git a/DysonNetwork.Pass/Account/Event.cs b/DysonNetwork.Pass/Account/Event.cs index cbf63f5..ef83d14 100644 --- a/DysonNetwork.Pass/Account/Event.cs +++ b/DysonNetwork.Pass/Account/Event.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using DysonNetwork.Shared.Data; using NodaTime; namespace DysonNetwork.Pass.Account; diff --git a/DysonNetwork.Pass/Account/MagicSpell.cs b/DysonNetwork.Pass/Account/MagicSpell.cs index 37f19be..200a33f 100644 --- a/DysonNetwork.Pass/Account/MagicSpell.cs +++ b/DysonNetwork.Pass/Account/MagicSpell.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; using Microsoft.EntityFrameworkCore; using NodaTime; diff --git a/DysonNetwork.Pass/Account/MagicSpellService.cs b/DysonNetwork.Pass/Account/MagicSpellService.cs index 6140c1f..955390e 100644 --- a/DysonNetwork.Pass/Account/MagicSpellService.cs +++ b/DysonNetwork.Pass/Account/MagicSpellService.cs @@ -1,23 +1,18 @@ -using System.Globalization; using System.Security.Cryptography; using System.Text.Json; -using DysonNetwork.Pass; -using DysonNetwork.Pass.Pages.Emails; using DysonNetwork.Pass.Permission; -using DysonNetwork.Pass.Resources.Localization; -using DysonNetwork.Pass.Resources.Pages.Emails; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; using NodaTime; +using EmailResource = DysonNetwork.Pass.Localization.EmailResource; namespace DysonNetwork.Pass.Account; public class MagicSpellService( AppDatabase db, - EmailService email, IConfiguration configuration, ILogger logger, - IStringLocalizer localizer + IStringLocalizer localizer ) { public async Task CreateMagicSpell( @@ -84,61 +79,61 @@ public class MagicSpellService( try { - switch (spell.Type) - { - case MagicSpellType.AccountActivation: - await email.SendTemplatedEmailAsync( - contact.Account.Nick, - contact.Content, - localizer["EmailLandingTitle"], - new LandingEmailModel - { - Name = contact.Account.Name, - Link = link - } - ); - break; - case MagicSpellType.AccountRemoval: - await email.SendTemplatedEmailAsync( - contact.Account.Nick, - contact.Content, - localizer["EmailAccountDeletionTitle"], - new AccountDeletionEmailModel - { - Name = contact.Account.Name, - Link = link - } - ); - break; - case MagicSpellType.AuthPasswordReset: - await email.SendTemplatedEmailAsync( - contact.Account.Nick, - contact.Content, - localizer["EmailAccountDeletionTitle"], - new PasswordResetEmailModel - { - Name = contact.Account.Name, - Link = link - } - ); - break; - case MagicSpellType.ContactVerification: - if (spell.Meta["contact_method"] is not string contactMethod) - throw new InvalidOperationException("Contact method is not found."); - await email.SendTemplatedEmailAsync( - contact.Account.Nick, - contactMethod!, - localizer["EmailContactVerificationTitle"], - new ContactVerificationEmailModel - { - Name = contact.Account.Name, - Link = link - } - ); - break; - default: - throw new ArgumentOutOfRangeException(); - } + // switch (spell.Type) + // { + // case MagicSpellType.AccountActivation: + // await email.SendTemplatedEmailAsync( + // contact.Account.Nick, + // contact.Content, + // localizer["EmailLandingTitle"], + // new LandingEmailModel + // { + // Name = contact.Account.Name, + // Link = link + // } + // ); + // break; + // case MagicSpellType.AccountRemoval: + // await email.SendTemplatedEmailAsync( + // contact.Account.Nick, + // contact.Content, + // localizer["EmailAccountDeletionTitle"], + // new AccountDeletionEmailModel + // { + // Name = contact.Account.Name, + // Link = link + // } + // ); + // break; + // case MagicSpellType.AuthPasswordReset: + // await email.SendTemplatedEmailAsync( + // contact.Account.Nick, + // contact.Content, + // localizer["EmailAccountDeletionTitle"], + // new PasswordResetEmailModel + // { + // Name = contact.Account.Name, + // Link = link + // } + // ); + // break; + // case MagicSpellType.ContactVerification: + // if (spell.Meta["contact_method"] is not string contactMethod) + // throw new InvalidOperationException("Contact method is not found."); + // await email.SendTemplatedEmailAsync( + // contact.Account.Nick, + // contactMethod!, + // localizer["EmailContactVerificationTitle"], + // new ContactVerificationEmailModel + // { + // Name = contact.Account.Name, + // Link = link + // } + // ); + // break; + // default: + // throw new ArgumentOutOfRangeException(); + // } } catch (Exception err) { diff --git a/DysonNetwork.Pass/Account/Notification.cs b/DysonNetwork.Pass/Account/Notification.cs index f2e2c8e..090ed41 100644 --- a/DysonNetwork.Pass/Account/Notification.cs +++ b/DysonNetwork.Pass/Account/Notification.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; using Microsoft.EntityFrameworkCore; using NodaTime; diff --git a/DysonNetwork.Pass/Account/NotificationController.cs b/DysonNetwork.Pass/Account/NotificationController.cs index 8ad4681..ea2cf5f 100644 --- a/DysonNetwork.Pass/Account/NotificationController.cs +++ b/DysonNetwork.Pass/Account/NotificationController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Pass; +using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Permission; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -69,7 +70,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); var currentUser = currentUserValue as Account; if (currentUser == null) return Unauthorized(); - var currentSession = currentSessionValue as Session; + var currentSession = currentSessionValue as AuthSession; if (currentSession == null) return Unauthorized(); var result = @@ -87,7 +88,7 @@ public class NotificationController(AppDatabase db, NotificationService nty) : C HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); var currentUser = currentUserValue as Account; if (currentUser == null) return Unauthorized(); - var currentSession = currentSessionValue as Session; + var currentSession = currentSessionValue as AuthSession; if (currentSession == null) return Unauthorized(); var affectedRows = await db.NotificationPushSubscriptions diff --git a/DysonNetwork.Pass/Account/NotificationService.cs b/DysonNetwork.Pass/Account/NotificationService.cs index 7fd0099..202ded6 100644 --- a/DysonNetwork.Pass/Account/NotificationService.cs +++ b/DysonNetwork.Pass/Account/NotificationService.cs @@ -9,9 +9,9 @@ namespace DysonNetwork.Pass.Account; public class NotificationService( AppDatabase db, - WebSocketService ws, IHttpClientFactory httpFactory, - IConfiguration config) + IConfiguration config +) { private readonly string _notifyTopic = config["Notifications:Topic"]!; private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!); @@ -31,7 +31,7 @@ public class NotificationService( ) { var now = SystemClock.Instance.GetCurrentInstant(); - + // First check if a matching subscription exists var existingSubscription = await db.NotificationPushSubscriptions .Where(s => s.AccountId == account.Id) @@ -110,12 +110,6 @@ public class NotificationService( public async Task DeliveryNotification(Notification notification) { - ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket - { - Type = "notifications.new", - Data = notification - }); - // Pushing the notification var subscribers = await db.NotificationPushSubscriptions .Where(s => s.AccountId == notification.AccountId) @@ -164,11 +158,6 @@ public class NotificationService( { notification.Account = account; notification.AccountId = account.Id; - ws.SendPacketToAccount(account.Id, new WebSocketPacket - { - Type = "notifications.new", - Data = notification - }); } var subscribers = await db.NotificationPushSubscriptions @@ -202,11 +191,6 @@ public class NotificationService( { notification.Account = account; notification.AccountId = account.Id; - ws.SendPacketToAccount(account.Id, new WebSocketPacket - { - Type = "notifications.new", - Data = notification - }); } var accountsId = accounts.Select(x => x.Id).ToList(); diff --git a/DysonNetwork.Pass/Account/Relationship.cs b/DysonNetwork.Pass/Account/Relationship.cs index 7b4aedd..9131b78 100644 --- a/DysonNetwork.Pass/Account/Relationship.cs +++ b/DysonNetwork.Pass/Account/Relationship.cs @@ -1,3 +1,4 @@ +using DysonNetwork.Shared.Data; using NodaTime; namespace DysonNetwork.Pass.Account; diff --git a/DysonNetwork.Pass/AppDatabase.cs b/DysonNetwork.Pass/AppDatabase.cs index 424af99..04b8a39 100644 --- a/DysonNetwork.Pass/AppDatabase.cs +++ b/DysonNetwork.Pass/AppDatabase.cs @@ -3,6 +3,8 @@ using System.Reflection; using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Auth; using DysonNetwork.Pass.Permission; +using DysonNetwork.Pass.Wallet; +using DysonNetwork.Shared.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Query; @@ -11,18 +13,6 @@ using Quartz; namespace DysonNetwork.Pass; -public interface IIdentifiedResource -{ - public string ResourceIdentifier { get; } -} - -public abstract class ModelBase -{ - public Instant CreatedAt { get; set; } - public Instant UpdatedAt { get; set; } - public Instant? DeletedAt { get; set; } -} - public class AppDatabase( DbContextOptions options, IConfiguration configuration @@ -43,12 +33,19 @@ public class AppDatabase( public DbSet AccountCheckInResults { get; set; } public DbSet Notifications { get; set; } public DbSet NotificationPushSubscriptions { get; set; } - public DbSet Badges { get; set; } + public DbSet Badges { get; set; } public DbSet ActionLogs { get; set; } public DbSet AbuseReports { get; set; } - public DbSet AuthSessions { get; set; } - public DbSet AuthChallenges { get; set; } + public DbSet AuthSessions { get; set; } + public DbSet AuthChallenges { get; set; } + + public DbSet Wallets { get; set; } + public DbSet WalletPockets { get; set; } + public DbSet PaymentOrders { get; set; } + public DbSet PaymentTransactions { get; set; } + public DbSet WalletSubscriptions { get; set; } + public DbSet WalletCoupons { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { diff --git a/DysonNetwork.Pass/Auth/Auth.cs b/DysonNetwork.Pass/Auth/Auth.cs index fd8632a..45c4e41 100644 --- a/DysonNetwork.Pass/Auth/Auth.cs +++ b/DysonNetwork.Pass/Auth/Auth.cs @@ -65,7 +65,7 @@ public class DysonTokenAuthHandler( return AuthenticateResult.Fail("Invalid token."); // Try to get session from cache first - var session = await cache.GetAsync($"{AuthCachePrefix}{sessionId}"); + var session = await cache.GetAsync($"{AuthCachePrefix}{sessionId}"); // If not in cache, load from database if (session is null) diff --git a/DysonNetwork.Pass/Auth/AuthController.cs b/DysonNetwork.Pass/Auth/AuthController.cs index 517da48..ab38462 100644 --- a/DysonNetwork.Pass/Auth/AuthController.cs +++ b/DysonNetwork.Pass/Auth/AuthController.cs @@ -27,7 +27,7 @@ public class AuthController( } [HttpPost("challenge")] - public async Task> StartChallenge([FromBody] ChallengeRequest request) + public async Task> StartChallenge([FromBody] ChallengeRequest request) { var account = await accounts.LookupAccount(request.Account); if (account is null) return NotFound("Account was not found."); @@ -47,7 +47,7 @@ public class AuthController( .FirstOrDefaultAsync(); if (existingChallenge is not null) return existingChallenge; - var challenge = new Challenge + var challenge = new AuthChallenge { ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)), StepTotal = await auth.DetectChallengeRisk(Request, account), @@ -72,7 +72,7 @@ public class AuthController( } [HttpGet("challenge/{id:guid}")] - public async Task> GetChallenge([FromRoute] Guid id) + public async Task> GetChallenge([FromRoute] Guid id) { var challenge = await db.AuthChallenges .Include(e => e.Account) @@ -132,7 +132,7 @@ public class AuthController( } [HttpPatch("challenge/{id:guid}")] - public async Task> DoChallenge( + public async Task> DoChallenge( [FromRoute] Guid id, [FromBody] PerformChallengeRequest request ) @@ -236,7 +236,7 @@ public class AuthController( if (session is not null) return BadRequest("Session already exists for this challenge."); - session = new Session + session = new AuthSession { LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs index 7488b3c..cb35d35 100644 --- a/DysonNetwork.Pass/Auth/AuthService.cs +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -73,9 +73,9 @@ public class AuthService( return totalRequiredSteps; } - public async Task CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) + public async Task CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) { - var challenge = new Challenge + var challenge = new AuthChallenge { AccountId = account.Id, IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), @@ -85,7 +85,7 @@ public class AuthService( Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc }; - var session = new Session + var session = new AuthSession { AccountId = account.Id, CreatedAt = time, @@ -154,7 +154,7 @@ public class AuthService( } } - public string CreateToken(Session session) + public string CreateToken(AuthSession session) { // Load the private key for signing var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!); @@ -183,7 +183,7 @@ public class AuthService( return $"{payloadBase64}.{signatureBase64}"; } - public async Task ValidateSudoMode(Session session, string? pinCode) + public async Task ValidateSudoMode(AuthSession session, string? pinCode) { // Check if the session is already in sudo mode (cached) var sudoModeKey = $"accounts:{session.Id}:sudo"; diff --git a/DysonNetwork.Pass/Auth/CompactTokenService.cs b/DysonNetwork.Pass/Auth/CompactTokenService.cs index 3ef9742..0c49da0 100644 --- a/DysonNetwork.Pass/Auth/CompactTokenService.cs +++ b/DysonNetwork.Pass/Auth/CompactTokenService.cs @@ -7,7 +7,7 @@ public class CompactTokenService(IConfiguration config) private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"] ?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing"); - public string CreateToken(Session session) + public string CreateToken(AuthSession session) { // Load the private key for signing var privateKeyPem = File.ReadAllText(_privateKeyPath); diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs b/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs index c033b93..e96a007 100644 --- a/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs +++ b/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs @@ -114,7 +114,7 @@ public class OidcProviderController( public async Task GetUserInfo() { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || - HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); // Get requested scopes from the token var scopes = currentSession.Challenge.Scopes; diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs index a4d5968..0c1d86e 100644 --- a/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs +++ b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs @@ -37,7 +37,7 @@ public class OidcProviderService( .FirstOrDefaultAsync(c => c.Id == appId); } - public async Task FindValidSessionAsync(Guid accountId, Guid clientId) + public async Task FindValidSessionAsync(Guid accountId, Guid clientId) { var now = SystemClock.Instance.GetCurrentInstant(); @@ -76,7 +76,7 @@ public class OidcProviderService( if (client == null) throw new InvalidOperationException("Client not found"); - Session session; + AuthSession session; var clock = SystemClock.Instance; var now = clock.GetCurrentInstant(); @@ -126,7 +126,7 @@ public class OidcProviderService( private string GenerateJwtToken( CustomApp client, - Session session, + AuthSession session, Instant expiresAt, IEnumerable? scopes = null ) @@ -199,7 +199,7 @@ public class OidcProviderService( } } - public async Task FindSessionByIdAsync(Guid sessionId) + public async Task FindSessionByIdAsync(Guid sessionId) { return await db.AuthSessions .Include(s => s.Account) @@ -208,7 +208,7 @@ public class OidcProviderService( .FirstOrDefaultAsync(s => s.Id == sessionId); } - private static string GenerateRefreshToken(Session session) + private static string GenerateRefreshToken(AuthSession session) { return Convert.ToBase64String(session.Id.ToByteArray()); } @@ -221,7 +221,7 @@ public class OidcProviderService( } public async Task GenerateAuthorizationCodeForReuseSessionAsync( - Session session, + AuthSession session, Guid clientId, string redirectUri, IEnumerable scopes, diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcController.cs b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs index 43f5053..594675f 100644 --- a/DysonNetwork.Pass/Auth/OpenId/OidcController.cs +++ b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs @@ -68,7 +68,7 @@ public class OidcController( /// Handles Apple authentication directly from mobile apps /// [HttpPost("apple/mobile")] - public async Task> AppleMobileLogin( + public async Task> AppleMobileLogin( [FromBody] AppleMobileSignInRequest request) { try diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcService.cs b/DysonNetwork.Pass/Auth/OpenId/OidcService.cs index ea1b7df..d79ad29 100644 --- a/DysonNetwork.Pass/Auth/OpenId/OidcService.cs +++ b/DysonNetwork.Pass/Auth/OpenId/OidcService.cs @@ -187,7 +187,7 @@ public abstract class OidcService( /// Creates a challenge and session for an authenticated user /// Also creates or updates the account connection /// - public async Task CreateChallengeForUserAsync( + public async Task CreateChallengeForUserAsync( OidcUserInfo userInfo, Account.Account account, HttpContext request, @@ -217,7 +217,7 @@ public abstract class OidcService( // Create a challenge that's already completed var now = SystemClock.Instance.GetCurrentInstant(); - var challenge = new Challenge + var challenge = new AuthChallenge { ExpiredAt = now.Plus(Duration.FromHours(1)), StepTotal = await auth.DetectChallengeRisk(request.Request, account), diff --git a/DysonNetwork.Pass/Auth/Session.cs b/DysonNetwork.Pass/Auth/Session.cs index 4fcfee9..bcd0ced 100644 --- a/DysonNetwork.Pass/Auth/Session.cs +++ b/DysonNetwork.Pass/Auth/Session.cs @@ -2,12 +2,13 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using DysonNetwork.Pass; +using DysonNetwork.Shared.Data; using NodaTime; using Point = NetTopologySuite.Geometries.Point; namespace DysonNetwork.Pass.Auth; -public class Session : ModelBase +public class AuthSession : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); [MaxLength(1024)] public string? Label { get; set; } @@ -17,7 +18,7 @@ public class Session : ModelBase public Guid AccountId { get; set; } [JsonIgnore] public Account.Account Account { get; set; } = null!; public Guid ChallengeId { get; set; } - public Challenge Challenge { get; set; } = null!; + public AuthChallenge Challenge { get; set; } = null!; public Guid? AppId { get; set; } // public CustomApp? App { get; set; } } @@ -40,7 +41,7 @@ public enum ChallengePlatform Linux } -public class Challenge : ModelBase +public class AuthChallenge : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); public Instant? ExpiredAt { get; set; } @@ -61,7 +62,7 @@ public class Challenge : ModelBase public Guid AccountId { get; set; } [JsonIgnore] public Account.Account Account { get; set; } = null!; - public Challenge Normalize() + public AuthChallenge Normalize() { if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal; return this; diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index 6869e9f..35dd6df 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -39,4 +39,73 @@ + + + True + True + NotificationResource.resx + + + True + True + SharedResource.resx + + + True + True + NotificationResource.resx + + + True + True + SharedResource.resx + + + True + True + AccountEventResource.resx + + + True + True + AccountEventResource.resx + + + + + + ResXFileCodeGenerator + Email.LandingResource.Designer.cs + + + ResXFileCodeGenerator + NotificationResource.Designer.cs + + + ResXFileCodeGenerator + SharedResource.Designer.cs + + + ResXFileCodeGenerator + AccountEventResource.Designer.cs + + + + + + + + + + + + + + + + + + + + diff --git a/DysonNetwork.Pass/Email/EmailModels.cs b/DysonNetwork.Pass/Email/EmailModels.cs new file mode 100644 index 0000000..39a3075 --- /dev/null +++ b/DysonNetwork.Pass/Email/EmailModels.cs @@ -0,0 +1,31 @@ +namespace DysonNetwork.Pass.Email; + +public class LandingEmailModel +{ + public required string Name { get; set; } + public required string Link { get; set; } +} + +public class AccountDeletionEmailModel +{ + public required string Name { get; set; } + public required string Link { get; set; } +} + +public class PasswordResetEmailModel +{ + public required string Name { get; set; } + public required string Link { get; set; } +} + +public class VerificationEmailModel +{ + public required string Name { get; set; } + public required string Code { get; set; } +} + +public class ContactVerificationEmailModel +{ + public required string Name { get; set; } + public required string Link { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Email/EmailService.cs b/DysonNetwork.Pass/Email/EmailService.cs new file mode 100644 index 0000000..2051cf4 --- /dev/null +++ b/DysonNetwork.Pass/Email/EmailService.cs @@ -0,0 +1,106 @@ +using MailKit.Net.Smtp; +using Microsoft.AspNetCore.Components; +using MimeKit; + +namespace DysonNetwork.Pass.Email; + +public class EmailServiceConfiguration +{ + public string Server { get; set; } = null!; + public int Port { get; set; } + public bool UseSsl { get; set; } + public string Username { get; set; } = null!; + public string Password { get; set; } = null!; + public string FromAddress { get; set; } = null!; + public string FromName { get; set; } = null!; + public string SubjectPrefix { get; set; } = null!; +} + +public class EmailService +{ + private readonly EmailServiceConfiguration _configuration; + private readonly RazorViewRenderer _viewRenderer; + private readonly ILogger _logger; + + public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger logger) + { + var cfg = configuration.GetSection("Email").Get(); + _configuration = cfg ?? throw new ArgumentException("Email service was not configured."); + _viewRenderer = viewRenderer; + _logger = logger; + } + + public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody) + { + await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null); + } + + public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody, + string? htmlBody) + { + subject = $"[{_configuration.SubjectPrefix}] {subject}"; + + var emailMessage = new MimeMessage(); + emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress)); + emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail)); + emailMessage.Subject = subject; + + var bodyBuilder = new BodyBuilder + { + TextBody = textBody + }; + + if (!string.IsNullOrEmpty(htmlBody)) + bodyBuilder.HtmlBody = htmlBody; + + emailMessage.Body = bodyBuilder.ToMessageBody(); + + using var client = new SmtpClient(); + await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl); + await client.AuthenticateAsync(_configuration.Username, _configuration.Password); + await client.SendAsync(emailMessage); + await client.DisconnectAsync(true); + } + + private static string _ConvertHtmlToPlainText(string html) + { + // Remove style tags and their contents + html = System.Text.RegularExpressions.Regex.Replace(html, "]*>.*?", "", + System.Text.RegularExpressions.RegexOptions.Singleline); + + // Replace header tags with text + newlines + html = System.Text.RegularExpressions.Regex.Replace(html, "]*>(.*?)", "$1\n\n", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + // Replace line breaks + html = html.Replace("
", "\n").Replace("
", "\n").Replace("
", "\n"); + + // Remove all remaining HTML tags + html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", ""); + + // Decode HTML entities + html = System.Net.WebUtility.HtmlDecode(html); + + // Remove excess whitespace + html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim(); + + return html; + } + + public async Task SendTemplatedEmailAsync(string? recipientName, string recipientEmail, + string subject, TModel model) + where TComponent : IComponent + { + try + { + var htmlBody = await _viewRenderer.RenderComponentToStringAsync(model); + var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody); + await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody); + } + catch (Exception err) + { + _logger.LogError(err, "Failed to render email template..."); + throw; + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Email/RazorViewRenderer.cs b/DysonNetwork.Pass/Email/RazorViewRenderer.cs new file mode 100644 index 0000000..57e76cc --- /dev/null +++ b/DysonNetwork.Pass/Email/RazorViewRenderer.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using RouteData = Microsoft.AspNetCore.Routing.RouteData; + +namespace DysonNetwork.Pass.Email; + +public class RazorViewRenderer( + IServiceProvider serviceProvider, + ILoggerFactory loggerFactory, + ILogger logger +) +{ + public async Task RenderComponentToStringAsync(TModel? model) + where TComponent : IComponent + { + await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory); + + return await htmlRenderer.Dispatcher.InvokeAsync(async () => + { + try + { + var dictionary = model?.GetType().GetProperties() + .ToDictionary( + prop => prop.Name, + prop => prop.GetValue(model, null) + ) ?? new Dictionary(); + var parameterView = ParameterView.FromDictionary(dictionary); + var output = await htmlRenderer.RenderComponentAsync(parameterView); + return output.ToHtmlString(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error rendering component {ComponentName}", typeof(TComponent).Name); + throw; + } + }); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs b/DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs index 216a2c9..4d96e99 100644 --- a/DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs +++ b/DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs @@ -7,7 +7,7 @@ namespace DysonNetwork.Pass.Handlers; public class LastActiveInfo { - public Auth.Session Session { get; set; } = null!; + public Auth.AuthSession Session { get; set; } = null!; public Account.Account Account { get; set; } = null!; public Instant SeenAt { get; set; } } diff --git a/DysonNetwork.Pass/Localization/AccountEventResource.cs b/DysonNetwork.Pass/Localization/AccountEventResource.cs new file mode 100644 index 0000000..239505e --- /dev/null +++ b/DysonNetwork.Pass/Localization/AccountEventResource.cs @@ -0,0 +1,6 @@ +namespace DysonNetwork.Pass.Localization; + +public class AccountEventResource +{ + +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Localization/EmailResource.cs b/DysonNetwork.Pass/Localization/EmailResource.cs new file mode 100644 index 0000000..2ebcdb4 --- /dev/null +++ b/DysonNetwork.Pass/Localization/EmailResource.cs @@ -0,0 +1,5 @@ +namespace DysonNetwork.Pass.Localization; + +public class EmailResource +{ +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Localization/NotificationResource.cs b/DysonNetwork.Pass/Localization/NotificationResource.cs new file mode 100644 index 0000000..183c09d --- /dev/null +++ b/DysonNetwork.Pass/Localization/NotificationResource.cs @@ -0,0 +1,6 @@ +namespace DysonNetwork.Pass.Localization; + +public class NotificationResource +{ + +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Localization/SharedResource.cs b/DysonNetwork.Pass/Localization/SharedResource.cs new file mode 100644 index 0000000..f21ad84 --- /dev/null +++ b/DysonNetwork.Pass/Localization/SharedResource.cs @@ -0,0 +1,6 @@ +namespace DysonNetwork.Pass.Localization; + +public class SharedResource +{ + +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor b/DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor new file mode 100644 index 0000000..2d4f94c --- /dev/null +++ b/DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor @@ -0,0 +1,42 @@ +@using DysonNetwork.Pass.Localization +@using Microsoft.Extensions.Localization + + + + +

@(Localizer["AccountDeletionHeader"])

+

@(Localizer["AccountDeletionPara1"]) @@@Name,

+

@(Localizer["AccountDeletionPara2"])

+

@(Localizer["AccountDeletionPara3"])

+ + + + + + + + + +

@(Localizer["AccountDeletionPara4"])

+ + +
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string Link { get; set; } + + [Inject] IStringLocalizer Localizer { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor b/DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor new file mode 100644 index 0000000..1af0c55 --- /dev/null +++ b/DysonNetwork.Pass/Pages/Emails/ContactVerificationEmail.razor @@ -0,0 +1,43 @@ +@using DysonNetwork.Pass.Localization +@using Microsoft.Extensions.Localization +@using EmailResource = DysonNetwork.Pass.Localization.EmailResource + + + + +

@(Localizer["ContactVerificationHeader"])

+

@(Localizer["ContactVerificationPara1"]) @Name,

+

@(Localizer["ContactVerificationPara2"])

+ + + + + + + + + +

@(Localizer["ContactVerificationPara3"])

+

@(Localizer["ContactVerificationPara4"])

+ + +
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string Link { get; set; } + + [Inject] IStringLocalizer Localizer { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Pages/Emails/EmailLayout.razor b/DysonNetwork.Pass/Pages/Emails/EmailLayout.razor new file mode 100644 index 0000000..c3f22ff --- /dev/null +++ b/DysonNetwork.Pass/Pages/Emails/EmailLayout.razor @@ -0,0 +1,337 @@ +@inherits LayoutComponentBase + + + + + + + + + + + + + + + + + + + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Pages/Emails/LandingEmail.razor b/DysonNetwork.Pass/Pages/Emails/LandingEmail.razor new file mode 100644 index 0000000..df1e79e --- /dev/null +++ b/DysonNetwork.Pass/Pages/Emails/LandingEmail.razor @@ -0,0 +1,43 @@ +@using DysonNetwork.Pass.Localization +@using Microsoft.Extensions.Localization +@using EmailResource = DysonNetwork.Pass.Localization.EmailResource + + + + +

@(Localizer["LandingHeader1"])

+

@(Localizer["LandingPara1"]) @@@Name,

+

@(Localizer["LandingPara2"])

+

@(Localizer["LandingPara3"])

+ + + + + + + + + +

@(Localizer["LandingPara4"])

+ + +
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string Link { get; set; } + + [Inject] IStringLocalizer Localizer { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor b/DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor new file mode 100644 index 0000000..2b867d9 --- /dev/null +++ b/DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor @@ -0,0 +1,44 @@ +@using DysonNetwork.Pass.Localization +@using Microsoft.Extensions.Localization +@using EmailResource = DysonNetwork.Pass.Localization.EmailResource + + + + +

@(Localizer["PasswordResetHeader"])

+

@(Localizer["PasswordResetPara1"]) @@@Name,

+

@(Localizer["PasswordResetPara2"])

+

@(Localizer["PasswordResetPara3"])

+ + + + + + + + + +

@(Localizer["PasswordResetPara4"])

+ + +
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string Link { get; set; } + + [Inject] IStringLocalizer Localizer { get; set; } = null!; + [Inject] IStringLocalizer LocalizerShared { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor b/DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor new file mode 100644 index 0000000..2763b19 --- /dev/null +++ b/DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor @@ -0,0 +1,27 @@ +@using DysonNetwork.Pass.Localization +@using Microsoft.Extensions.Localization +@using EmailResource = DysonNetwork.Pass.Localization.EmailResource + + + + +

@(Localizer["VerificationHeader1"])

+

@(Localizer["VerificationPara1"]) @@@Name,

+

@(Localizer["VerificationPara2"])

+

@(Localizer["VerificationPara3"])

+ +

@Code

+ +

@(Localizer["VerificationPara4"])

+

@(Localizer["VerificationPara5"])

+ + +
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string Code { get; set; } + + [Inject] IStringLocalizer Localizer { get; set; } = null!; + [Inject] IStringLocalizer LocalizerShared { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Permission/Permission.cs b/DysonNetwork.Pass/Permission/Permission.cs index 8a0eb4a..531cc7c 100644 --- a/DysonNetwork.Pass/Permission/Permission.cs +++ b/DysonNetwork.Pass/Permission/Permission.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; using Microsoft.EntityFrameworkCore; using NodaTime; diff --git a/DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs b/DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs new file mode 100644 index 0000000..c9ec073 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs @@ -0,0 +1,222 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace DysonNetwork.Pass.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class AccountEventResource { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal AccountEventResource() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.AccountEventResource", typeof(AccountEventResource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string FortuneTipPositiveTitle_1 { + get { + return ResourceManager.GetString("FortuneTipPositiveTitle_1", resourceCulture); + } + } + + internal static string FortuneTipPositiveContent_1 { + get { + return ResourceManager.GetString("FortuneTipPositiveContent_1", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_1 { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_1", resourceCulture); + } + } + + internal static string FortuneTipNegativeContent_1 { + get { + return ResourceManager.GetString("FortuneTipNegativeContent_1", resourceCulture); + } + } + + internal static string FortuneTipPositiveTitle_2 { + get { + return ResourceManager.GetString("FortuneTipPositiveTitle_2", resourceCulture); + } + } + + internal static string FortuneTipPositiveContent_2 { + get { + return ResourceManager.GetString("FortuneTipPositiveContent_2", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_2 { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_2", resourceCulture); + } + } + + internal static string FortuneTipNegativeContent_2 { + get { + return ResourceManager.GetString("FortuneTipNegativeContent_2", resourceCulture); + } + } + + internal static string FortuneTipPositiveTitle_3 { + get { + return ResourceManager.GetString("FortuneTipPositiveTitle_3", resourceCulture); + } + } + + internal static string FortuneTipPositiveContent_3 { + get { + return ResourceManager.GetString("FortuneTipPositiveContent_3", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_3 { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_3", resourceCulture); + } + } + + internal static string FortuneTipNegativeContent_3 { + get { + return ResourceManager.GetString("FortuneTipNegativeContent_3", resourceCulture); + } + } + + internal static string FortuneTipPositiveTitle_4 { + get { + return ResourceManager.GetString("FortuneTipPositiveTitle_4", resourceCulture); + } + } + + internal static string FortuneTipPositiveContent_4 { + get { + return ResourceManager.GetString("FortuneTipPositiveContent_4", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_4 { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_4", resourceCulture); + } + } + + internal static string FortuneTipNegativeContent_4 { + get { + return ResourceManager.GetString("FortuneTipNegativeContent_4", resourceCulture); + } + } + + internal static string FortuneTipPositiveTitle_5 { + get { + return ResourceManager.GetString("FortuneTipPositiveTitle_5", resourceCulture); + } + } + + internal static string FortuneTipPositiveContent_5 { + get { + return ResourceManager.GetString("FortuneTipPositiveContent_5", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_5 { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_5", resourceCulture); + } + } + + internal static string FortuneTipNegativeContent_5 { + get { + return ResourceManager.GetString("FortuneTipNegativeContent_5", resourceCulture); + } + } + + internal static string FortuneTipPositiveTitle_6 { + get { + return ResourceManager.GetString("FortuneTipPositiveTitle_6", resourceCulture); + } + } + + internal static string FortuneTipPositiveContent_6 { + get { + return ResourceManager.GetString("FortuneTipPositiveContent_6", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_6 { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_6", resourceCulture); + } + } + + internal static string FortuneTipNegativeContent_6 { + get { + return ResourceManager.GetString("FortuneTipNegativeContent_6", resourceCulture); + } + } + + internal static string FortuneTipPositiveTitle_7 { + get { + return ResourceManager.GetString("FortuneTipPositiveTitle_7", resourceCulture); + } + } + + internal static string FortuneTipPositiveContent_7 { + get { + return ResourceManager.GetString("FortuneTipPositiveContent_7", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_7 { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_7", resourceCulture); + } + } + + internal static string FortuneTipNegativeContent_7 { + get { + return ResourceManager.GetString("FortuneTipNegativeContent_7", resourceCulture); + } + } + + internal static string FortuneTipNegativeTitle_1_ { + get { + return ResourceManager.GetString("FortuneTipNegativeTitle_1 ", resourceCulture); + } + } + } +} diff --git a/DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx b/DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx new file mode 100644 index 0000000..d6e7dc1 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/AccountEventResource.resx @@ -0,0 +1,113 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + Gacha + + + Golden every pull + + + Gacha + + + Won't pull the card you like + + + Gaming + + + Rank up like a hot knife through butter + + + Gaming + + + Dropping ranks like a landslide + + + Lottery + + + Blessed with luck + + + Lottery + + + Ten pulls, all silence + + + Speech + + + Words flow like gems + + + Speech + + + Be careful what you're saying + + + Drawing + + + Inspiration gushes like a spring + + + Drawing + + + Every stroke weighs a thousand pounds + + + Coding + + + 0 error(s), 0 warning(s) + + + Coding + + + 114 error(s), 514 warning(s) + + + Shopping + + + Exchange rate at its lowest + + + Unboxing + + + 225% tariff + + + Gacha + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Localization/AccountEventResource.zh-hans.resx b/DysonNetwork.Pass/Resources/Localization/AccountEventResource.zh-hans.resx new file mode 100644 index 0000000..a9bd551 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/AccountEventResource.zh-hans.resx @@ -0,0 +1,98 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 抽卡 + + + 次次出金 + + + 吃大保底 + + + 游戏 + + + 升段如破竹 + + + 游戏 + + + 掉分如山崩 + + + 抽奖 + + + 欧气加身 + + + 抽奖 + + + 十连皆寂 + + + 演讲 + + + 妙语连珠 + + + 演讲 + + + 谨言慎行 + + + 绘图 + + + 灵感如泉涌 + + + 绘图 + + + 下笔如千斤 + + + 编程 + + + 0 error(s), 0 warning(s) + + + 编程 + + + 114 error(s), 514 warning(s) + + + 购物 + + + 汇率低谷 + + + 开箱 + + + 225% 关税 + + + 抽卡 + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs b/DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs new file mode 100644 index 0000000..6bd42d2 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace DysonNetwork.Pass.Resources.Pages.Emails { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class EmailResource { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal EmailResource() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.Localization.EmailResource", typeof(EmailResource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string LandingHeader1 { + get { + return ResourceManager.GetString("LandingHeader1", resourceCulture); + } + } + + internal static string LandingPara1 { + get { + return ResourceManager.GetString("LandingPara1", resourceCulture); + } + } + + internal static string LandingPara2 { + get { + return ResourceManager.GetString("LandingPara2", resourceCulture); + } + } + + internal static string LandingPara3 { + get { + return ResourceManager.GetString("LandingPara3", resourceCulture); + } + } + + internal static string LandingButton1 { + get { + return ResourceManager.GetString("LandingButton1", resourceCulture); + } + } + + internal static string LandingPara4 { + get { + return ResourceManager.GetString("LandingPara4", resourceCulture); + } + } + + internal static string EmailLandingTitle { + get { + return ResourceManager.GetString("EmailLandingTitle", resourceCulture); + } + } + } +} diff --git a/DysonNetwork.Pass/Resources/Localization/EmailResource.resx b/DysonNetwork.Pass/Resources/Localization/EmailResource.resx new file mode 100644 index 0000000..89dd75c --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/EmailResource.resx @@ -0,0 +1,126 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Welcome to the Solar Network! + + + Dear, + + + Thank you for creating an account on the Solar Network. We're excited to have you join our community! + + + To access all features and ensure the security of your account, please confirm your registration by clicking the button below: + + + Confirm Registration + + + If you didn't create this account, please ignore this email. + + + Confirm your registration + + + Account Deletion Confirmation + + + Dear, + + + We've received a request to delete your Solar Network account. We're sorry to see you go. + + + To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone. + + + Confirm Account Deletion + + + If you did not request to delete your account, please ignore this email or contact our support team immediately. + + + Confirm your account deletion + + + Reset your password + + + Reset Password + + + Password Reset Request + + + Dear, + + + We recieved a request to reset your Solar Network account password. + + + You can click the button below to continue reset your password. + + + If you didn't request this, you can ignore this email safety. + + + Verify Your Email Address + + + Dear, + + + Thank you for creating an account on the Solar Network. We're excited to have you join our community! + + + To verify your email address and access all features of your account, please use the verification code below: + + + This code will expire in 30 minutes. Please enter it on the verification page to complete your registration. + + + If you didn't create this account, please ignore this email. + + + Verify your email address + + + Verify Your Contact Information + + + Dear, + + + Thank you for updating your contact information on the Solar Network. To ensure your account security, we need to verify this change. + + + Please click the button below to verify your contact information: + + + Verify Contact Information + + + If you didn't request this change, please contact our support team immediately. + + + Verify your contact information + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Localization/EmailResource.zh-hans.resx b/DysonNetwork.Pass/Resources/Localization/EmailResource.zh-hans.resx new file mode 100644 index 0000000..fc4ed68 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/EmailResource.zh-hans.resx @@ -0,0 +1,119 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 欢迎来到 Solar Network! + + + 尊敬的 + + + 确认注册 + + + 感谢你在 Solar Network 上注册帐号,我们很激动你即将加入我们的社区! + + + 点击下方按钮来确认你的注册以获得所有功能的权限。 + + + 如果你并没有注册帐号,你可以忽略此邮件。 + + + 确认你的注册 + + + 账户删除确认 + + + 尊敬的 + + + 我们收到了删除您 Solar Network 账户的请求。我们很遗憾看到您的离开。 + + + 请点击下方按钮确认删除您的账户。请注意,此操作是永久性的,无法撤销。 + + + 确认删除账户 + + + 如果您并未请求删除账户,请忽略此邮件或立即联系我们的支持团队。 + + + 确认删除您的账户 + + + 密码重置请求 + + + 尊敬的 + + + 我们收到了重置您 Solar Network 账户密码的请求。 + + + 请点击下方按钮重置您的密码。此链接将在24小时后失效。 + + + 重置密码 + + + 如果您并未请求重置密码,你可以安全地忽略此邮件。 + + + 重置您的密码 + + + 验证您的电子邮箱 + + + 尊敬的 + + + 感谢您在 Solar Network 上注册账号,我们很高兴您即将加入我们的社区! + + + 请使用以下验证码来验证您的电子邮箱并获取账号的所有功能: + + + 此验证码将在30分钟后失效。请在验证页面输入此验证码以完成注册。 + + + 如果您并未创建此账号,请忽略此邮件。 + + + 验证您的电子邮箱 + + + 验证您的联系信息 + + + 尊敬的 + + + 感谢您更新 Solar Network 上的联系信息。为确保您的账户安全,我们需要验证此更改。 + + + 请点击下方按钮验证您的联系信息: + + + 验证联系信息 + + + 如果您没有请求此更改,请立即联系我们的支持团队。 + + + 验证您的联系信息 + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs b/DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs new file mode 100644 index 0000000..a095af1 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs @@ -0,0 +1,162 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace DysonNetwork.Pass.Resources.Localization { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class NotificationResource { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal NotificationResource() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.Localization.NotificationResource", typeof(NotificationResource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string ChatInviteTitle { + get { + return ResourceManager.GetString("ChatInviteTitle", resourceCulture); + } + } + + internal static string ChatInviteBody { + get { + return ResourceManager.GetString("ChatInviteBody", resourceCulture); + } + } + + internal static string ChatInviteDirectBody { + get { + return ResourceManager.GetString("ChatInviteDirectBody", resourceCulture); + } + } + + internal static string RealmInviteTitle { + get { + return ResourceManager.GetString("RealmInviteTitle", resourceCulture); + } + } + + internal static string RealmInviteBody { + get { + return ResourceManager.GetString("RealmInviteBody", resourceCulture); + } + } + + internal static string PostSubscriptionTitle { + get { + return ResourceManager.GetString("PostSubscriptionTitle", resourceCulture); + } + } + + internal static string PostReactTitle { + get { + return ResourceManager.GetString("PostReactTitle", resourceCulture); + } + } + + internal static string PostReactBody { + get { + return ResourceManager.GetString("PostReactBody", resourceCulture); + } + } + + internal static string PostReactContentBody { + get { + return ResourceManager.GetString("PostReactContentBody", resourceCulture); + } + } + + internal static string PostReplyTitle { + get { + return ResourceManager.GetString("PostReplyTitle", resourceCulture); + } + } + + internal static string PostReplyBody { + get { + return ResourceManager.GetString("PostReplyBody", resourceCulture); + } + } + + internal static string PostReplyContentBody { + get { + return ResourceManager.GetString("PostReplyContentBody", resourceCulture); + } + } + + internal static string PostOnlyMedia { + get { + return ResourceManager.GetString("PostOnlyMedia", resourceCulture); + } + } + + internal static string AuthCodeTitle { + get { + return ResourceManager.GetString("AuthCodeTitle", resourceCulture); + } + } + + internal static string AuthCodeBody { + get { + return ResourceManager.GetString("AuthCodeBody", resourceCulture); + } + } + + internal static string SubscriptionAppliedTitle { + get { + return ResourceManager.GetString("SubscriptionAppliedTitle", resourceCulture); + } + } + + internal static string SubscriptionAppliedBody { + get { + return ResourceManager.GetString("SubscriptionAppliedBody", resourceCulture); + } + } + + internal static string OrderPaidTitle { + get { + return ResourceManager.GetString("OrderPaidTitle", resourceCulture); + } + } + + internal static string OrderPaidBody { + get { + return ResourceManager.GetString("OrderPaidBody", resourceCulture); + } + } + } +} diff --git a/DysonNetwork.Pass/Resources/Localization/NotificationResource.resx b/DysonNetwork.Pass/Resources/Localization/NotificationResource.resx new file mode 100644 index 0000000..283612a --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/NotificationResource.resx @@ -0,0 +1,83 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + New Chat Invitation + + + You just got invited to join {0} + + + {0} sent an direct message invitation to you + + + New Realm Invitation + + + You just got invited to join {0} + + + {0} just posted {1} + + + {0} reacted your post + + + {0} added a reaction {1} to your post + + + {0} added a reaction {1} to your post {2} + + + {0} replied your post + + + {0} replied: {1} + + + {0} replied post {1}: {2} + + + shared media + + + Disposable Verification Code + + + {0} is your disposable code, it will expires in 5 minutes + + + Subscription {0} just activated for your account + + + Thank for supporting the Solar Network! Your {0} days {1} subscription just begun, feel free to explore the newly unlocked features! + + + Order {0} recipent + + + {0} {1} was removed from your wallet to pay {2} + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Localization/NotificationResource.zh-hans.resx b/DysonNetwork.Pass/Resources/Localization/NotificationResource.zh-hans.resx new file mode 100644 index 0000000..4cf16b1 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/NotificationResource.zh-hans.resx @@ -0,0 +1,75 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + 新聊天邀请 + + + 你刚被邀请加入聊天 {} + + + {0} 向你发送了一个私聊邀请 + + + 新加入领域邀请 + + + 你刚被邀请加入领域 {0} + + + {0} 有新帖子 + + + {0} 反应了你的帖子 + + + {0} 给你的帖子添加了一个 {1} 的反应 + + + {0} 给你的帖子添加了一个 {1} 的反应 {2} + + + {0} 回复了你的帖子 + + + {0}:{1} + + + {0} 回复了帖子 {1}: {2} + + + 分享媒体 + + + 一次性验证码 + + + {0} 是你的一次性验证码,它将会在五分钟内过期 + + + {0} 的订阅激活成功 + + + 感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧! + + + 订单回执 {0} + + + {0} {1} 已从你的帐户中扣除来支付 {2} + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs b/DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs new file mode 100644 index 0000000..4a303e1 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace DysonNetwork.Pass.Resources { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class SharedResource { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal SharedResource() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.Localization.SharedResource", typeof(SharedResource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/DysonNetwork.Pass/Resources/Localization/SharedResource.resx b/DysonNetwork.Pass/Resources/Localization/SharedResource.resx new file mode 100644 index 0000000..a4c5284 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/SharedResource.resx @@ -0,0 +1,21 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Resources/Localization/SharedResource.zh-hans.resx b/DysonNetwork.Pass/Resources/Localization/SharedResource.zh-hans.resx new file mode 100644 index 0000000..0db1973 --- /dev/null +++ b/DysonNetwork.Pass/Resources/Localization/SharedResource.zh-hans.resx @@ -0,0 +1,14 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/OrderController.cs b/DysonNetwork.Pass/Wallet/OrderController.cs new file mode 100644 index 0000000..bbca4a9 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/OrderController.cs @@ -0,0 +1,57 @@ +using DysonNetwork.Pass.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Pass.Wallet; + +[ApiController] +[Route("/api/orders")] +public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase +{ + [HttpGet("{id:guid}")] + public async Task> GetOrderById(Guid id) + { + var order = await db.PaymentOrders.FindAsync(id); + + if (order == null) + { + return NotFound(); + } + + return Ok(order); + } + + [HttpPost("{id:guid}/pay")] + [Authorize] + public async Task> PayOrder(Guid id, [FromBody] PayOrderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || + HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); + + // Validate PIN code + if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode)) + return StatusCode(403, "Invalid PIN Code"); + + try + { + // Get the wallet for the current user + var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id); + if (wallet == null) + return BadRequest("Wallet was not found."); + + // Pay the order + var paidOrder = await payment.PayOrderAsync(id, wallet.Id); + return Ok(paidOrder); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } +} + +public class PayOrderRequest +{ + public string PinCode { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/Payment.cs b/DysonNetwork.Pass/Wallet/Payment.cs new file mode 100644 index 0000000..9bd27e1 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/Payment.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using DysonNetwork.Shared.Data; +using NodaTime; + +namespace DysonNetwork.Pass.Wallet; + +public class WalletCurrency +{ + public const string SourcePoint = "points"; + public const string GoldenPoint = "golds"; +} + +public enum OrderStatus +{ + Unpaid, + Paid, + Cancelled, + Finished, + Expired +} + +public class Order : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public OrderStatus Status { get; set; } = OrderStatus.Unpaid; + [MaxLength(128)] public string Currency { get; set; } = null!; + [MaxLength(4096)] public string? Remarks { get; set; } + [MaxLength(4096)] public string? AppIdentifier { get; set; } + [Column(TypeName = "jsonb")] public Dictionary? Meta { get; set; } + public decimal Amount { get; set; } + public Instant ExpiredAt { get; set; } + + public Guid? PayeeWalletId { get; set; } + public Wallet? PayeeWallet { get; set; } = null!; + public Guid? TransactionId { get; set; } + public Transaction? Transaction { get; set; } +} + +public enum TransactionType +{ + System, + Transfer, + Order +} + +public class Transaction : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(128)] public string Currency { get; set; } = null!; + public decimal Amount { get; set; } + [MaxLength(4096)] public string? Remarks { get; set; } + public TransactionType Type { get; set; } + + // When the payer is null, it's pay from the system + public Guid? PayerWalletId { get; set; } + public Wallet? PayerWallet { get; set; } + // When the payee is null, it's pay for the system + public Guid? PayeeWalletId { get; set; } + public Wallet? PayeeWallet { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs b/DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs new file mode 100644 index 0000000..5390422 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs @@ -0,0 +1,446 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Wallet.PaymentHandlers; + +public class AfdianPaymentHandler( + IHttpClientFactory httpClientFactory, + ILogger logger, + IConfiguration configuration +) +{ + private readonly IHttpClientFactory _httpClientFactory = + httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + private readonly IConfiguration _configuration = + configuration ?? throw new ArgumentNullException(nameof(configuration)); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private string CalculateSign(string token, string userId, string paramsJson, long ts) + { + var kvString = $"{token}params{paramsJson}ts{ts}user_id{userId}"; + using (var md5 = MD5.Create()) + { + var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(kvString)); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLower(); + } + } + + public async Task ListOrderAsync(int page = 1) + { + try + { + var token = _configuration["Payment:Auth:Afdian"] ?? "_:_"; + var tokenParts = token.Split(':'); + var userId = tokenParts[0]; + token = tokenParts[1]; + var paramsJson = JsonSerializer.Serialize(new { page }, JsonOptions); + var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)) + .TotalSeconds; // Current timestamp in seconds + + var sign = CalculateSign(token, userId, paramsJson, ts); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order") + { + Content = new StringContent(JsonSerializer.Serialize(new + { + user_id = userId, + @params = paramsJson, + ts, + sign + }, JsonOptions), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + $"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}"); + return null; + } + + var result = await JsonSerializer.DeserializeAsync( + await response.Content.ReadAsStreamAsync(), JsonOptions); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching orders"); + throw; + } + } + + /// + /// Get a specific order by its ID (out_trade_no) + /// + /// The order ID to query + /// The order item if found, otherwise null + public async Task GetOrderAsync(string orderId) + { + if (string.IsNullOrEmpty(orderId)) + { + _logger.LogWarning("Order ID cannot be null or empty"); + return null; + } + + try + { + var token = _configuration["Payment:Auth:Afdian"] ?? "_:_"; + var tokenParts = token.Split(':'); + var userId = tokenParts[0]; + token = tokenParts[1]; + var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderId }, JsonOptions); + var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)) + .TotalSeconds; // Current timestamp in seconds + + var sign = CalculateSign(token, userId, paramsJson, ts); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order") + { + Content = new StringContent(JsonSerializer.Serialize(new + { + user_id = userId, + @params = paramsJson, + ts, + sign + }, JsonOptions), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + $"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}"); + return null; + } + + var result = await JsonSerializer.DeserializeAsync( + await response.Content.ReadAsStreamAsync(), JsonOptions); + + // Check if we have a valid response and orders in the list + if (result?.Data.Orders == null || result.Data.Orders.Count == 0) + { + _logger.LogWarning($"No order found with ID: {orderId}"); + return null; + } + + // Since we're querying by a specific order ID, we should only get one result + return result.Data.Orders.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error fetching order with ID: {orderId}"); + throw; + } + } + + /// + /// Get multiple orders by their IDs (out_trade_no) + /// + /// A collection of order IDs to query + /// A list of found order items + public async Task> GetOrderBatchAsync(IEnumerable orderIds) + { + var orders = orderIds.ToList(); + if (orders.Count == 0) + { + _logger.LogWarning("Order IDs cannot be null or empty"); + return []; + } + + try + { + // Join the order IDs with commas as specified in the API documentation + var orderIdsParam = string.Join(",", orders); + + var token = _configuration["Payment:Auth:Afdian"] ?? "_:_"; + var tokenParts = token.Split(':'); + var userId = tokenParts[0]; + token = tokenParts[1]; + var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderIdsParam }, JsonOptions); + var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1)) + .TotalSeconds; // Current timestamp in seconds + + var sign = CalculateSign(token, userId, paramsJson, ts); + + var client = _httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order") + { + Content = new StringContent(JsonSerializer.Serialize(new + { + user_id = userId, + @params = paramsJson, + ts, + sign + }, JsonOptions), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + $"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}"); + return new List(); + } + + var result = await JsonSerializer.DeserializeAsync( + await response.Content.ReadAsStreamAsync(), JsonOptions); + + // Check if we have a valid response and orders in the list + if (result?.Data?.Orders != null && result.Data.Orders.Count != 0) return result.Data.Orders; + _logger.LogWarning($"No orders found with IDs: {orderIdsParam}"); + return []; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error fetching orders"); + throw; + } + } + + /// + /// Handle an incoming webhook from Afdian's payment platform + /// + /// The HTTP request containing webhook data + /// An action to process the received order + /// A WebhookResponse object to be returned to Afdian + public async Task HandleWebhook( + HttpRequest request, + Func? processOrderAction + ) + { + _logger.LogInformation("Received webhook request from afdian..."); + + try + { + // Read the request body + string requestBody; + using (var reader = new StreamReader(request.Body, Encoding.UTF8)) + { + requestBody = await reader.ReadToEndAsync(); + } + + if (string.IsNullOrEmpty(requestBody)) + { + _logger.LogError("Webhook request body is empty"); + return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Empty request body" }; + } + + _logger.LogInformation($"Received webhook: {requestBody}"); + + // Parse the webhook data + var webhook = JsonSerializer.Deserialize(requestBody, JsonOptions); + + if (webhook == null) + { + _logger.LogError("Failed to parse webhook data"); + return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Invalid webhook data" }; + } + + // Validate the webhook type + if (webhook.Data.Type != "order") + { + _logger.LogWarning($"Unsupported webhook type: {webhook.Data.Type}"); + return WebhookResponse.Success; + } + + // Process the order + try + { + // Check for duplicate order processing by storing processed order IDs + // (You would implement a more permanent storage mechanism for production) + if (processOrderAction != null) + await processOrderAction(webhook.Data); + else + _logger.LogInformation( + $"Order received but no processing action provided: {webhook.Data.Order.TradeNumber}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error processing order {webhook.Data.Order.TradeNumber}"); + // Still returning success to Afdian to prevent repeated callbacks + // Your system should handle the error internally + } + + // Return success response to Afdian + return WebhookResponse.Success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling webhook"); + return WebhookResponse.Success; + } + } + + public string? GetSubscriptionPlanId(string subscriptionKey) + { + var planId = _configuration[$"Payment:Subscriptions:Afdian:{subscriptionKey}"]; + + if (string.IsNullOrEmpty(planId)) + { + _logger.LogWarning($"Unknown subscription key: {subscriptionKey}"); + return null; + } + + return planId; + } +} + +public class OrderResponse +{ + [JsonPropertyName("ec")] public int ErrorCode { get; set; } + + [JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!; + + [JsonPropertyName("data")] public OrderData Data { get; set; } = null!; +} + +public class OrderData +{ + [JsonPropertyName("list")] public List Orders { get; set; } = null!; + + [JsonPropertyName("total_count")] public int TotalCount { get; set; } + + [JsonPropertyName("total_page")] public int TotalPages { get; set; } + + [JsonPropertyName("request")] public RequestDetails Request { get; set; } = null!; +} + +public class OrderItem : ISubscriptionOrder +{ + [JsonPropertyName("out_trade_no")] public string TradeNumber { get; set; } = null!; + + [JsonPropertyName("user_id")] public string UserId { get; set; } = null!; + + [JsonPropertyName("plan_id")] public string PlanId { get; set; } = null!; + + [JsonPropertyName("month")] public int Months { get; set; } + + [JsonPropertyName("total_amount")] public string TotalAmount { get; set; } = null!; + + [JsonPropertyName("show_amount")] public string ShowAmount { get; set; } = null!; + + [JsonPropertyName("status")] public int Status { get; set; } + + [JsonPropertyName("remark")] public string Remark { get; set; } = null!; + + [JsonPropertyName("redeem_id")] public string RedeemId { get; set; } = null!; + + [JsonPropertyName("product_type")] public int ProductType { get; set; } + + [JsonPropertyName("discount")] public string Discount { get; set; } = null!; + + [JsonPropertyName("sku_detail")] public List SkuDetail { get; set; } = null!; + + [JsonPropertyName("create_time")] public long CreateTime { get; set; } + + [JsonPropertyName("user_name")] public string UserName { get; set; } = null!; + + [JsonPropertyName("plan_title")] public string PlanTitle { get; set; } = null!; + + [JsonPropertyName("user_private_id")] public string UserPrivateId { get; set; } = null!; + + [JsonPropertyName("address_person")] public string AddressPerson { get; set; } = null!; + + [JsonPropertyName("address_phone")] public string AddressPhone { get; set; } = null!; + + [JsonPropertyName("address_address")] public string AddressAddress { get; set; } = null!; + + public Instant BegunAt => Instant.FromUnixTimeSeconds(CreateTime); + + public Duration Duration => Duration.FromDays(Months * 30); + + public string Provider => "afdian"; + + public string Id => TradeNumber; + + public string SubscriptionId => PlanId; + + public string AccountId => UserId; +} + +public class RequestDetails +{ + [JsonPropertyName("user_id")] public string UserId { get; set; } = null!; + + [JsonPropertyName("params")] public string Params { get; set; } = null!; + + [JsonPropertyName("ts")] public long Timestamp { get; set; } + + [JsonPropertyName("sign")] public string Sign { get; set; } = null!; +} + +/// +/// Request structure for Afdian webhook +/// +public class WebhookRequest +{ + [JsonPropertyName("ec")] public int ErrorCode { get; set; } + + [JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!; + + [JsonPropertyName("data")] public WebhookOrderData Data { get; set; } = null!; +} + +/// +/// Order data contained in the webhook +/// +public class WebhookOrderData +{ + [JsonPropertyName("type")] public string Type { get; set; } = null!; + + [JsonPropertyName("order")] public WebhookOrderDetails Order { get; set; } = null!; +} + +/// +/// Order details in the webhook +/// +public class WebhookOrderDetails : OrderItem +{ + [JsonPropertyName("custom_order_id")] public string CustomOrderId { get; set; } = null!; +} + +/// +/// Response structure to acknowledge webhook receipt +/// +public class WebhookResponse +{ + [JsonPropertyName("ec")] public int ErrorCode { get; set; } = 200; + + [JsonPropertyName("em")] public string ErrorMessage { get; set; } = ""; + + public static WebhookResponse Success => new() + { + ErrorCode = 200, + ErrorMessage = string.Empty + }; +} + +/// +/// SKU detail item +/// +public class SkuDetailItem +{ + [JsonPropertyName("sku_id")] public string SkuId { get; set; } = null!; + + [JsonPropertyName("count")] public int Count { get; set; } + + [JsonPropertyName("name")] public string Name { get; set; } = null!; + + [JsonPropertyName("album_id")] public string AlbumId { get; set; } = null!; + + [JsonPropertyName("pic")] public string Picture { get; set; } = null!; +} diff --git a/DysonNetwork.Pass/Wallet/PaymentHandlers/ISubscriptionOrder.cs b/DysonNetwork.Pass/Wallet/PaymentHandlers/ISubscriptionOrder.cs new file mode 100644 index 0000000..1b4cd58 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/PaymentHandlers/ISubscriptionOrder.cs @@ -0,0 +1,18 @@ +using NodaTime; + +namespace DysonNetwork.Pass.Wallet.PaymentHandlers; + +public interface ISubscriptionOrder +{ + public string Id { get; } + + public string SubscriptionId { get; } + + public Instant BegunAt { get; } + + public Duration Duration { get; } + + public string Provider { get; } + + public string AccountId { get; } +} diff --git a/DysonNetwork.Pass/Wallet/PaymentService.cs b/DysonNetwork.Pass/Wallet/PaymentService.cs new file mode 100644 index 0000000..c01cd62 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/PaymentService.cs @@ -0,0 +1,297 @@ +using System.Globalization; +using DysonNetwork.Pass.Account; +using DysonNetwork.Pass.Localization; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Localization; +using NodaTime; + +namespace DysonNetwork.Pass.Wallet; + +public class PaymentService( + AppDatabase db, + WalletService wat, + NotificationService nty, + IStringLocalizer localizer +) +{ + public async Task CreateOrderAsync( + Guid? payeeWalletId, + string currency, + decimal amount, + Duration? expiration = null, + string? appIdentifier = null, + Dictionary? meta = null, + bool reuseable = true + ) + { + // Check if there's an existing unpaid order that can be reused + if (reuseable && appIdentifier != null) + { + var existingOrder = await db.PaymentOrders + .Where(o => o.Status == OrderStatus.Unpaid && + o.PayeeWalletId == payeeWalletId && + o.Currency == currency && + o.Amount == amount && + o.AppIdentifier == appIdentifier && + o.ExpiredAt > SystemClock.Instance.GetCurrentInstant()) + .FirstOrDefaultAsync(); + + // If an existing order is found, check if meta matches + if (existingOrder != null && meta != null && existingOrder.Meta != null) + { + // Compare meta dictionaries - if they are equivalent, reuse the order + var metaMatches = existingOrder.Meta.Count == meta.Count && + !existingOrder.Meta.Except(meta).Any(); + + if (metaMatches) + { + return existingOrder; + } + } + } + + // Create a new order if no reusable order was found + var order = new Order + { + PayeeWalletId = payeeWalletId, + Currency = currency, + Amount = amount, + ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), + AppIdentifier = appIdentifier, + Meta = meta + }; + + db.PaymentOrders.Add(order); + await db.SaveChangesAsync(); + return order; + } + + public async Task CreateTransactionWithAccountAsync( + Guid? payerAccountId, + Guid? payeeAccountId, + string currency, + decimal amount, + string? remarks = null, + TransactionType type = TransactionType.System + ) + { + Wallet? payer = null, payee = null; + if (payerAccountId.HasValue) + payer = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerAccountId.Value); + if (payeeAccountId.HasValue) + payee = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeAccountId.Value); + + if (payer == null && payerAccountId.HasValue) + throw new ArgumentException("Payer account was specified, but wallet was not found"); + if (payee == null && payeeAccountId.HasValue) + throw new ArgumentException("Payee account was specified, but wallet was not found"); + + return await CreateTransactionAsync( + payer?.Id, + payee?.Id, + currency, + amount, + remarks, + type + ); + } + + public async Task CreateTransactionAsync( + Guid? payerWalletId, + Guid? payeeWalletId, + string currency, + decimal amount, + string? remarks = null, + TransactionType type = TransactionType.System + ) + { + if (payerWalletId == null && payeeWalletId == null) + throw new ArgumentException("At least one wallet must be specified."); + if (amount <= 0) throw new ArgumentException("Cannot create transaction with negative or zero amount."); + + var transaction = new Transaction + { + PayerWalletId = payerWalletId, + PayeeWalletId = payeeWalletId, + Currency = currency, + Amount = amount, + Remarks = remarks, + Type = type + }; + + if (payerWalletId.HasValue) + { + var (payerPocket, isNewlyCreated) = + await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency); + + if (isNewlyCreated || payerPocket.Amount < amount) + throw new InvalidOperationException("Insufficient funds"); + + await db.WalletPockets + .Where(p => p.Id == payerPocket.Id && p.Amount >= amount) + .ExecuteUpdateAsync(s => + s.SetProperty(p => p.Amount, p => p.Amount - amount)); + } + + if (payeeWalletId.HasValue) + { + var (payeePocket, isNewlyCreated) = + await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount); + + if (!isNewlyCreated) + await db.WalletPockets + .Where(p => p.Id == payeePocket.Id) + .ExecuteUpdateAsync(s => + s.SetProperty(p => p.Amount, p => p.Amount + amount)); + } + + db.PaymentTransactions.Add(transaction); + await db.SaveChangesAsync(); + return transaction; + } + + public async Task PayOrderAsync(Guid orderId, Guid payerWalletId) + { + var order = await db.PaymentOrders + .Include(o => o.Transaction) + .FirstOrDefaultAsync(o => o.Id == orderId); + + if (order == null) + { + throw new InvalidOperationException("Order not found"); + } + + if (order.Status != OrderStatus.Unpaid) + { + throw new InvalidOperationException($"Order is in invalid status: {order.Status}"); + } + + if (order.ExpiredAt < SystemClock.Instance.GetCurrentInstant()) + { + order.Status = OrderStatus.Expired; + await db.SaveChangesAsync(); + throw new InvalidOperationException("Order has expired"); + } + + var transaction = await CreateTransactionAsync( + payerWalletId, + order.PayeeWalletId, + order.Currency, + order.Amount, + order.Remarks ?? $"Payment for Order #{order.Id}", + type: TransactionType.Order); + + order.TransactionId = transaction.Id; + order.Transaction = transaction; + order.Status = OrderStatus.Paid; + + await db.SaveChangesAsync(); + + await NotifyOrderPaid(order); + + return order; + } + + private async Task NotifyOrderPaid(Order order) + { + if (order.PayeeWallet is null) return; + var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId); + if (account is null) return; + + AccountService.SetCultureInfo(account); + + // Due to ID is uuid, it longer than 8 words for sure + var readableOrderId = order.Id.ToString().Replace("-", "")[..8]; + var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}"; + + await nty.SendNotification( + account, + "wallets.orders.paid", + localizer["OrderPaidTitle", $"#{readableOrderId}"], + null, + localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, + readableOrderRemark], + new Dictionary() + { + ["order_id"] = order.Id.ToString() + } + ); + } + + public async Task CancelOrderAsync(Guid orderId) + { + var order = await db.PaymentOrders.FindAsync(orderId); + if (order == null) + { + throw new InvalidOperationException("Order not found"); + } + + if (order.Status != OrderStatus.Unpaid) + { + throw new InvalidOperationException($"Cannot cancel order in status: {order.Status}"); + } + + order.Status = OrderStatus.Cancelled; + await db.SaveChangesAsync(); + return order; + } + + public async Task<(Order Order, Transaction RefundTransaction)> RefundOrderAsync(Guid orderId) + { + var order = await db.PaymentOrders + .Include(o => o.Transaction) + .FirstOrDefaultAsync(o => o.Id == orderId); + + if (order == null) + { + throw new InvalidOperationException("Order not found"); + } + + if (order.Status != OrderStatus.Paid) + { + throw new InvalidOperationException($"Cannot refund order in status: {order.Status}"); + } + + if (order.Transaction == null) + { + throw new InvalidOperationException("Order has no associated transaction"); + } + + var refundTransaction = await CreateTransactionAsync( + order.PayeeWalletId, + order.Transaction.PayerWalletId, + order.Currency, + order.Amount, + $"Refund for order {order.Id}"); + + order.Status = OrderStatus.Finished; + await db.SaveChangesAsync(); + + return (order, refundTransaction); + } + + public async Task TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency, + decimal amount) + { + var payerWallet = await wat.GetWalletAsync(payerAccountId); + if (payerWallet == null) + { + throw new InvalidOperationException($"Payer wallet not found for account {payerAccountId}"); + } + + var payeeWallet = await wat.GetWalletAsync(payeeAccountId); + if (payeeWallet == null) + { + throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}"); + } + + return await CreateTransactionAsync( + payerWallet.Id, + payeeWallet.Id, + currency, + amount, + $"Transfer from account {payerAccountId} to {payeeAccountId}", + TransactionType.Transfer); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/Subscription.cs b/DysonNetwork.Pass/Wallet/Subscription.cs new file mode 100644 index 0000000..dd37c5c --- /dev/null +++ b/DysonNetwork.Pass/Wallet/Subscription.cs @@ -0,0 +1,233 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using DysonNetwork.Shared.Data; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Wallet; + +public record class SubscriptionTypeData( + string Identifier, + string? GroupIdentifier, + string Currency, + decimal BasePrice, + int? RequiredLevel = null +) +{ + public static readonly Dictionary SubscriptionDict = + new() + { + [SubscriptionType.Twinkle] = new SubscriptionTypeData( + SubscriptionType.Twinkle, + SubscriptionType.StellarProgram, + WalletCurrency.SourcePoint, + 0, + 1 + ), + [SubscriptionType.Stellar] = new SubscriptionTypeData( + SubscriptionType.Stellar, + SubscriptionType.StellarProgram, + WalletCurrency.SourcePoint, + 1200, + 3 + ), + [SubscriptionType.Nova] = new SubscriptionTypeData( + SubscriptionType.Nova, + SubscriptionType.StellarProgram, + WalletCurrency.SourcePoint, + 2400, + 6 + ), + [SubscriptionType.Supernova] = new SubscriptionTypeData( + SubscriptionType.Supernova, + SubscriptionType.StellarProgram, + WalletCurrency.SourcePoint, + 3600, + 9 + ) + }; + + public static readonly Dictionary SubscriptionHumanReadable = + new() + { + [SubscriptionType.Twinkle] = "Stellar Program Twinkle", + [SubscriptionType.Stellar] = "Stellar Program", + [SubscriptionType.Nova] = "Stellar Program Nova", + [SubscriptionType.Supernova] = "Stellar Program Supernova" + }; +} + +public abstract class SubscriptionType +{ + /// + /// DO NOT USE THIS TYPE DIRECTLY, + /// this is the prefix of all the stellar program subscriptions. + /// + public const string StellarProgram = "solian.stellar"; + + /// + /// No actual usage, just tells there is a free level named twinkle. + /// Applies to every registered user by default, so there is no need to create a record in db for that. + /// + public const string Twinkle = "solian.stellar.twinkle"; + + public const string Stellar = "solian.stellar.primary"; + public const string Nova = "solian.stellar.nova"; + public const string Supernova = "solian.stellar.supernova"; +} + +public abstract class SubscriptionPaymentMethod +{ + /// + /// The solar points / solar dollars. + /// + public const string InAppWallet = "solian.wallet"; + + /// + /// afdian.com + /// aka. China patreon + /// + public const string Afdian = "afdian"; +} + +public enum SubscriptionStatus +{ + Unpaid, + Active, + Expired, + Cancelled +} + +/// +/// The subscription is for the Stellar Program in most cases. +/// The paid subscription in another word. +/// +[Index(nameof(Identifier))] +public class Subscription : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Instant BegunAt { get; set; } + public Instant? EndedAt { get; set; } + + /// + /// The type of the subscriptions + /// + [MaxLength(4096)] + public string Identifier { get; set; } = null!; + + /// + /// The field is used to override the activation status of the membership. + /// Might be used for refund handling and other special cases. + /// + /// Go see the IsAvailable field if you want to get real the status of the membership. + /// + public bool IsActive { get; set; } = true; + + /// + /// Indicates is the current user got the membership for free, + /// to prevent giving the same discount for the same user again. + /// + public bool IsFreeTrial { get; set; } + + public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Unpaid; + + [MaxLength(4096)] public string PaymentMethod { get; set; } = null!; + [Column(TypeName = "jsonb")] public PaymentDetails PaymentDetails { get; set; } = null!; + public decimal BasePrice { get; set; } + public Guid? CouponId { get; set; } + public Coupon? Coupon { get; set; } + public Instant? RenewalAt { get; set; } + + public Guid AccountId { get; set; } + public Account.Account Account { get; set; } = null!; + + [NotMapped] + public bool IsAvailable + { + get + { + if (!IsActive) return false; + + var now = SystemClock.Instance.GetCurrentInstant(); + + if (BegunAt > now) return false; + if (EndedAt.HasValue && now > EndedAt.Value) return false; + if (RenewalAt.HasValue && now > RenewalAt.Value) return false; + if (Status != SubscriptionStatus.Active) return false; + + return true; + } + } + + [NotMapped] + public decimal FinalPrice + { + get + { + if (IsFreeTrial) return 0; + if (Coupon == null) return BasePrice; + + var now = SystemClock.Instance.GetCurrentInstant(); + if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value || + Coupon.ExpiredAt.HasValue && now > Coupon.ExpiredAt.Value) return BasePrice; + + if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value; + if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value); + return BasePrice; + } + } +} + +public class PaymentDetails +{ + public string Currency { get; set; } = null!; + public string? OrderId { get; set; } +} + +/// +/// A discount that can applies in purchases among the Solar Network. +/// For now, it can be used in the subscription purchase. +/// +public class Coupon : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// The items that can apply this coupon. + /// Leave it to null to apply to all items. + /// + [MaxLength(4096)] + public string? Identifier { get; set; } + + /// + /// The code that human-readable and memorizable. + /// Leave it blank to use it only with the ID. + /// + [MaxLength(1024)] + public string? Code { get; set; } + + public Instant? AffectedAt { get; set; } + public Instant? ExpiredAt { get; set; } + + /// + /// The amount of the discount. + /// If this field and the rate field are both not null, + /// the amount discount will be applied and the discount rate will be ignored. + /// Formula: final price = base price - discount amount + /// + public decimal? DiscountAmount { get; set; } + + /// + /// The percentage of the discount. + /// If this field and the amount field are both not null, + /// this field will be ignored. + /// Formula: final price = base price * (1 - discount rate) + /// + public double? DiscountRate { get; set; } + + /// + /// The max usage of the current coupon. + /// Leave it to null to use it unlimited. + /// + public int? MaxUsage { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/SubscriptionController.cs b/DysonNetwork.Pass/Wallet/SubscriptionController.cs new file mode 100644 index 0000000..1e4efc4 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/SubscriptionController.cs @@ -0,0 +1,204 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Pass.Wallet.PaymentHandlers; + +namespace DysonNetwork.Pass.Wallet; + +[ApiController] +[Route("/api/subscriptions")] +public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db) : ControllerBase +{ + [HttpGet] + [Authorize] + public async Task>> ListSubscriptions( + [FromQuery] int offset = 0, + [FromQuery] int take = 20 + ) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var query = db.WalletSubscriptions.AsQueryable() + .Where(s => s.AccountId == currentUser.Id) + .Include(s => s.Coupon) + .OrderByDescending(s => s.BegunAt); + + var totalCount = await query.CountAsync(); + + var subscriptionsList = await query + .Skip(offset) + .Take(take) + .ToListAsync(); + + Response.Headers["X-Total"] = totalCount.ToString(); + + return subscriptionsList; + } + + [HttpGet("fuzzy/{prefix}")] + [Authorize] + public async Task> GetSubscriptionFuzzy(string prefix) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var subs = await db.WalletSubscriptions + .Where(s => s.AccountId == currentUser.Id && s.IsActive) + .Where(s => EF.Functions.ILike(s.Identifier, prefix + "%")) + .OrderByDescending(s => s.BegunAt) + .ToListAsync(); + if (subs.Count == 0) return NotFound(); + var subscription = subs.FirstOrDefault(s => s.IsAvailable); + if (subscription is null) return NotFound(); + + return Ok(subscription); + } + + [HttpGet("{identifier}")] + [Authorize] + public async Task> GetSubscription(string identifier) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var subscription = await subscriptions.GetSubscriptionAsync(currentUser.Id, identifier); + if (subscription is null) return NotFound($"Subscription with identifier {identifier} was not found."); + + return subscription; + } + + public class CreateSubscriptionRequest + { + [Required] public string Identifier { get; set; } = null!; + [Required] public string PaymentMethod { get; set; } = null!; + [Required] public PaymentDetails PaymentDetails { get; set; } = null!; + public string? Coupon { get; set; } + public int? CycleDurationDays { get; set; } + public bool IsFreeTrial { get; set; } = false; + public bool IsAutoRenewal { get; set; } = true; + } + + [HttpPost] + [Authorize] + public async Task> CreateSubscription( + [FromBody] CreateSubscriptionRequest request, + [FromHeader(Name = "X-Noop")] bool noop = false + ) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + Duration? cycleDuration = null; + if (request.CycleDurationDays.HasValue) + cycleDuration = Duration.FromDays(request.CycleDurationDays.Value); + + try + { + var subscription = await subscriptions.CreateSubscriptionAsync( + currentUser, + request.Identifier, + request.PaymentMethod, + request.PaymentDetails, + cycleDuration, + request.Coupon, + request.IsFreeTrial, + request.IsAutoRenewal, + noop + ); + + return subscription; + } + catch (ArgumentOutOfRangeException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("{identifier}/cancel")] + [Authorize] + public async Task> CancelSubscription(string identifier) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + try + { + var subscription = await subscriptions.CancelSubscriptionAsync(currentUser.Id, identifier); + return subscription; + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("{identifier}/order")] + [Authorize] + public async Task> CreateSubscriptionOrder(string identifier) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + try + { + var order = await subscriptions.CreateSubscriptionOrder(currentUser.Id, identifier); + return order; + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + public class SubscriptionOrderRequest + { + [Required] public Guid OrderId { get; set; } + } + + [HttpPost("order/handle")] + [Authorize] + public async Task> HandleSubscriptionOrder([FromBody] SubscriptionOrderRequest request) + { + var order = await db.PaymentOrders.FindAsync(request.OrderId); + if (order is null) return NotFound($"Order with ID {request.OrderId} was not found."); + + try + { + var subscription = await subscriptions.HandleSubscriptionOrder(order); + return subscription; + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + public class RestorePurchaseRequest + { + [Required] public string OrderId { get; set; } = null!; + } + + [HttpPost("order/restore/afdian")] + [Authorize] + public async Task RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request) + { + var order = await afdian.GetOrderAsync(request.OrderId); + if (order is null) return NotFound($"Order with ID {request.OrderId} was not found."); + + var subscription = await subscriptions.CreateSubscriptionFromOrder(order); + return Ok(subscription); + } + + [HttpPost("order/handle/afdian")] + public async Task> AfdianWebhook() + { + var response = await afdian.HandleWebhook(Request, async webhookData => + { + var order = webhookData.Order; + await subscriptions.CreateSubscriptionFromOrder(order); + }); + + return Ok(response); + } +} diff --git a/DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs b/DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs new file mode 100644 index 0000000..5ca89e7 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Pass.Wallet; + +public class SubscriptionRenewalJob( + AppDatabase db, + SubscriptionService subscriptionService, + PaymentService paymentService, + WalletService walletService, + ILogger logger +) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + logger.LogInformation("Starting subscription auto-renewal job..."); + + // First update expired subscriptions + var expiredCount = await subscriptionService.UpdateExpiredSubscriptionsAsync(); + logger.LogInformation("Updated {ExpiredCount} expired subscriptions", expiredCount); + + var now = SystemClock.Instance.GetCurrentInstant(); + const int batchSize = 100; // Process in smaller batches + var processedCount = 0; + var renewedCount = 0; + var failedCount = 0; + + // Find subscriptions that need renewal (due for renewal and are still active) + var subscriptionsToRenew = await db.WalletSubscriptions + .Where(s => s.RenewalAt.HasValue && s.RenewalAt.Value <= now) // Due for renewal + .Where(s => s.Status == SubscriptionStatus.Active) // Only paid subscriptions + .Where(s => s.IsActive) // Only active subscriptions + .Where(s => !s.IsFreeTrial) // Exclude free trials + .OrderBy(s => s.RenewalAt) // Process oldest first + .Take(batchSize) + .Include(s => s.Coupon) // Include coupon information + .ToListAsync(); + + var totalSubscriptions = subscriptionsToRenew.Count; + logger.LogInformation("Found {TotalSubscriptions} subscriptions due for renewal", totalSubscriptions); + + foreach (var subscription in subscriptionsToRenew) + { + try + { + processedCount++; + logger.LogDebug( + "Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {AccountId}", + subscription.Id, subscription.Identifier, subscription.AccountId); + + if (subscription.RenewalAt is null) + { + logger.LogWarning( + "Subscription {SubscriptionId} (Identifier: {Identifier}) has no renewal date or has been cancelled.", + subscription.Id, subscription.Identifier); + subscription.Status = SubscriptionStatus.Cancelled; + db.WalletSubscriptions.Update(subscription); + await db.SaveChangesAsync(); + continue; + } + + // Calculate next cycle duration based on current cycle + var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt; + + // Create an order for the renewal payment + var order = await paymentService.CreateOrderAsync( + null, + WalletCurrency.GoldenPoint, + subscription.FinalPrice, + appIdentifier: SubscriptionService.SubscriptionOrderIdentifier, + meta: new Dictionary() + { + ["subscription_id"] = subscription.Id.ToString(), + ["subscription_identifier"] = subscription.Identifier, + ["is_renewal"] = true + } + ); + + // Try to process the payment automatically + if (subscription.PaymentMethod == SubscriptionPaymentMethod.InAppWallet) + { + try + { + var wallet = await walletService.GetWalletAsync(subscription.AccountId); + if (wallet is null) continue; + + // Process automatic payment from wallet + await paymentService.PayOrderAsync(order.Id, wallet.Id); + + // Update subscription details + subscription.BegunAt = subscription.EndedAt!.Value; + subscription.EndedAt = subscription.BegunAt.Plus(currentCycle); + subscription.RenewalAt = subscription.EndedAt; + + db.WalletSubscriptions.Update(subscription); + await db.SaveChangesAsync(); + + renewedCount++; + logger.LogInformation("Successfully renewed subscription {SubscriptionId}", subscription.Id); + } + catch (Exception ex) + { + // If auto-payment fails, mark for manual payment + logger.LogWarning(ex, "Failed to auto-renew subscription {SubscriptionId} with wallet payment", + subscription.Id); + failedCount++; + } + } + else + { + // For other payment methods, mark as pending payment + logger.LogInformation("Subscription {SubscriptionId} requires manual payment via {PaymentMethod}", + subscription.Id, subscription.PaymentMethod); + failedCount++; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing subscription {SubscriptionId}", subscription.Id); + failedCount++; + } + + // Log progress periodically + if (processedCount % 20 == 0 || processedCount == totalSubscriptions) + { + logger.LogInformation( + "Progress: processed {ProcessedCount}/{TotalSubscriptions} subscriptions, {RenewedCount} renewed, {FailedCount} failed", + processedCount, totalSubscriptions, renewedCount, failedCount); + } + } + + logger.LogInformation( + "Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}", + processedCount, renewedCount, failedCount); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/SubscriptionService.cs b/DysonNetwork.Pass/Wallet/SubscriptionService.cs new file mode 100644 index 0000000..b999d0f --- /dev/null +++ b/DysonNetwork.Pass/Wallet/SubscriptionService.cs @@ -0,0 +1,394 @@ +using System.Text.Json; +using DysonNetwork.Pass.Account; +using DysonNetwork.Pass.Localization; +using DysonNetwork.Pass.Wallet.PaymentHandlers; +using DysonNetwork.Shared.Cache; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Localization; +using NodaTime; + +namespace DysonNetwork.Pass.Wallet; + +public class SubscriptionService( + AppDatabase db, + PaymentService payment, + AccountService accounts, + NotificationService nty, + IStringLocalizer localizer, + IConfiguration configuration, + ICacheService cache, + ILogger logger +) +{ + public async Task CreateSubscriptionAsync( + Account.Account account, + string identifier, + string paymentMethod, + PaymentDetails paymentDetails, + Duration? cycleDuration = null, + string? coupon = null, + bool isFreeTrial = false, + bool isAutoRenewal = true, + bool noop = false + ) + { + var subscriptionInfo = SubscriptionTypeData + .SubscriptionDict.TryGetValue(identifier, out var template) + ? template + : null; + if (subscriptionInfo is null) + throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found."); + var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null + ? SubscriptionTypeData.SubscriptionDict + .Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier) + .Select(s => s.Value.Identifier) + .ToArray() + : [identifier]; + + cycleDuration ??= Duration.FromDays(30); + + var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionsInGroup); + if (existingSubscription is not null && !noop) + throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists."); + if (existingSubscription is not null) + return existingSubscription; + + if (subscriptionInfo.RequiredLevel > 0) + { + var profile = await db.AccountProfiles + .Where(p => p.AccountId == account.Id) + .FirstOrDefaultAsync(); + if (profile is null) throw new InvalidOperationException("Account profile was not found."); + if (profile.Level < subscriptionInfo.RequiredLevel) + throw new InvalidOperationException( + $"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}." + ); + } + + if (isFreeTrial) + { + var prevFreeTrial = await db.WalletSubscriptions + .Where(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial) + .FirstOrDefaultAsync(); + if (prevFreeTrial is not null) + throw new InvalidOperationException("Free trial already exists."); + } + + Coupon? couponData = null; + if (coupon is not null) + { + var inputCouponId = Guid.TryParse(coupon, out var parsedCouponId) ? parsedCouponId : Guid.Empty; + couponData = await db.WalletCoupons + .Where(c => (c.Id == inputCouponId) || (c.Identifier != null && c.Identifier == coupon)) + .FirstOrDefaultAsync(); + if (couponData is null) throw new InvalidOperationException($"Coupon {coupon} was not found."); + } + + var now = SystemClock.Instance.GetCurrentInstant(); + var subscription = new Subscription + { + BegunAt = now, + EndedAt = now.Plus(cycleDuration.Value), + Identifier = identifier, + IsActive = true, + IsFreeTrial = isFreeTrial, + Status = SubscriptionStatus.Unpaid, + PaymentMethod = paymentMethod, + PaymentDetails = paymentDetails, + BasePrice = subscriptionInfo.BasePrice, + CouponId = couponData?.Id, + Coupon = couponData, + RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value), + AccountId = account.Id, + }; + + db.WalletSubscriptions.Add(subscription); + await db.SaveChangesAsync(); + + return subscription; + } + + public async Task CreateSubscriptionFromOrder(ISubscriptionOrder order) + { + var cfgSection = configuration.GetSection("Payment:Subscriptions"); + var provider = order.Provider; + + var currency = "irl"; + var subscriptionIdentifier = order.SubscriptionId; + switch (provider) + { + case "afdian": + // Get the Afdian section first, then bind it to a dictionary + var afdianPlans = cfgSection.GetSection("Afdian").Get>(); + logger.LogInformation("Afdian plans configuration: {Plans}", JsonSerializer.Serialize(afdianPlans)); + if (afdianPlans != null && afdianPlans.TryGetValue(subscriptionIdentifier, out var planName)) + subscriptionIdentifier = planName; + currency = "cny"; + break; + } + + var subscriptionTemplate = SubscriptionTypeData + .SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template) + ? template + : null; + if (subscriptionTemplate is null) + throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier), + $@"Subscription {subscriptionIdentifier} was not found."); + + Account.Account? account = null; + if (!string.IsNullOrEmpty(provider)) + account = await accounts.LookupAccountByConnection(order.AccountId, provider); + else if (Guid.TryParse(order.AccountId, out var accountId)) + account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId); + + if (account is null) + throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}"); + + var cycleDuration = order.Duration; + + var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier); + if (existingSubscription is not null && existingSubscription.PaymentMethod != provider) + throw new InvalidOperationException( + $"Active subscription with identifier {subscriptionIdentifier} already exists."); + if (existingSubscription?.PaymentDetails.OrderId == order.Id) + return existingSubscription; + if (existingSubscription is not null) + { + // Same provider, but different order, renew the subscription + existingSubscription.PaymentDetails.OrderId = order.Id; + existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration); + existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration); + existingSubscription.Status = SubscriptionStatus.Active; + + db.Update(existingSubscription); + await db.SaveChangesAsync(); + + return existingSubscription; + } + + var subscription = new Subscription + { + BegunAt = order.BegunAt, + EndedAt = order.BegunAt.Plus(cycleDuration), + IsActive = true, + Status = SubscriptionStatus.Active, + Identifier = subscriptionIdentifier, + PaymentMethod = provider, + PaymentDetails = new PaymentDetails + { + Currency = currency, + OrderId = order.Id, + }, + BasePrice = subscriptionTemplate.BasePrice, + RenewalAt = order.BegunAt.Plus(cycleDuration), + AccountId = account.Id, + }; + + db.WalletSubscriptions.Add(subscription); + await db.SaveChangesAsync(); + + await NotifySubscriptionBegun(subscription); + + return subscription; + } + + /// + /// Cancel the renewal of the current activated subscription. + /// + /// The user who requested the action. + /// The subscription identifier + /// + /// The active subscription was not found + public async Task CancelSubscriptionAsync(Guid accountId, string identifier) + { + var subscription = await GetSubscriptionAsync(accountId, identifier); + if (subscription is null) + throw new InvalidOperationException($"Subscription with identifier {identifier} was not found."); + if (subscription.Status != SubscriptionStatus.Active) + throw new InvalidOperationException("Subscription is already cancelled."); + if (subscription.RenewalAt is null) + throw new InvalidOperationException("Subscription is no need to be cancelled."); + if (subscription.PaymentMethod != SubscriptionPaymentMethod.InAppWallet) + throw new InvalidOperationException( + "Only in-app wallet subscription can be cancelled. For other payment methods, please head to the payment provider." + ); + + subscription.RenewalAt = null; + + await db.SaveChangesAsync(); + + // Invalidate the cache for this subscription + var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}"; + await cache.RemoveAsync(cacheKey); + + return subscription; + } + + public const string SubscriptionOrderIdentifier = "solian.subscription.order"; + + /// + /// Creates a subscription order for an unpaid or expired subscription. + /// If the subscription is active, it will extend its expiration date. + /// + /// The unique identifier for the account associated with the subscription. + /// The unique subscription identifier. + /// A task that represents the asynchronous operation. The task result contains the created subscription order. + /// Thrown when no matching unpaid or expired subscription is found. + public async Task CreateSubscriptionOrder(Guid accountId, string identifier) + { + var subscription = await db.WalletSubscriptions + .Where(s => s.AccountId == accountId && s.Identifier == identifier) + .Where(s => s.Status != SubscriptionStatus.Expired) + .Include(s => s.Coupon) + .OrderByDescending(s => s.BegunAt) + .FirstOrDefaultAsync(); + if (subscription is null) throw new InvalidOperationException("No matching subscription found."); + + var subscriptionInfo = SubscriptionTypeData.SubscriptionDict + .TryGetValue(subscription.Identifier, out var template) + ? template + : null; + if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found."); + + return await payment.CreateOrderAsync( + null, + subscriptionInfo.Currency, + subscription.FinalPrice, + appIdentifier: SubscriptionOrderIdentifier, + meta: new Dictionary() + { + ["subscription_id"] = subscription.Id.ToString(), + ["subscription_identifier"] = subscription.Identifier, + } + ); + } + + public async Task HandleSubscriptionOrder(Order order) + { + if (order.AppIdentifier != SubscriptionOrderIdentifier || order.Status != OrderStatus.Paid || + order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson) + throw new InvalidOperationException("Invalid order."); + + var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId) + ? parsedSubscriptionId + : Guid.Empty; + if (subscriptionId == Guid.Empty) + throw new InvalidOperationException("Invalid order."); + var subscription = await db.WalletSubscriptions + .Where(s => s.Id == subscriptionId) + .Include(s => s.Coupon) + .FirstOrDefaultAsync(); + if (subscription is null) + throw new InvalidOperationException("Invalid order."); + + if (subscription.Status == SubscriptionStatus.Expired) + { + var now = SystemClock.Instance.GetCurrentInstant(); + var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now); + + var nextRenewalAt = subscription.RenewalAt?.Plus(cycle); + var nextEndedAt = subscription.EndedAt?.Plus(cycle); + + subscription.RenewalAt = nextRenewalAt; + subscription.EndedAt = nextEndedAt; + } + + subscription.Status = SubscriptionStatus.Active; + + db.Update(subscription); + await db.SaveChangesAsync(); + + await NotifySubscriptionBegun(subscription); + + return subscription; + } + + /// + /// Updates the status of expired subscriptions to reflect their current state. + /// This helps maintain accurate subscription records and is typically called periodically. + /// + /// Maximum number of subscriptions to process + /// Number of subscriptions that were marked as expired + public async Task UpdateExpiredSubscriptionsAsync(int batchSize = 100) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + // Find active subscriptions that have passed their end date + var expiredSubscriptions = await db.WalletSubscriptions + .Where(s => s.IsActive) + .Where(s => s.Status == SubscriptionStatus.Active) + .Where(s => s.EndedAt.HasValue && s.EndedAt.Value < now) + .Take(batchSize) + .ToListAsync(); + + if (expiredSubscriptions.Count == 0) + return 0; + + foreach (var subscription in expiredSubscriptions) + { + subscription.Status = SubscriptionStatus.Expired; + + // Clear the cache for this subscription + var cacheKey = $"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}"; + await cache.RemoveAsync(cacheKey); + } + + await db.SaveChangesAsync(); + return expiredSubscriptions.Count; + } + + private async Task NotifySubscriptionBegun(Subscription subscription) + { + var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId); + if (account is null) return; + + AccountService.SetCultureInfo(account); + + var humanReadableName = + SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable) + ? humanReadable + : subscription.Identifier; + var duration = subscription.EndedAt is not null + ? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString() + : "infinite"; + + await nty.SendNotification( + account, + "subscriptions.begun", + localizer["SubscriptionAppliedTitle", humanReadableName], + null, + localizer["SubscriptionAppliedBody", duration, humanReadableName], + new Dictionary() + { + ["subscription_id"] = subscription.Id.ToString(), + } + ); + } + + private const string SubscriptionCacheKeyPrefix = "subscription:"; + + public async Task GetSubscriptionAsync(Guid accountId, params string[] identifiers) + { + // Create a unique cache key for this subscription + var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{string.Join(",", identifiers)}"; + + // Try to get the subscription from cache first + var (found, cachedSubscription) = await cache.GetAsyncWithStatus(cacheKey); + if (found && cachedSubscription != null) + { + return cachedSubscription; + } + + // If not in cache, get from database + var subscription = await db.WalletSubscriptions + .Where(s => s.AccountId == accountId && identifiers.Contains(s.Identifier)) + .OrderByDescending(s => s.BegunAt) + .FirstOrDefaultAsync(); + + // Cache the result if found (with 30 minutes expiry) + if (subscription != null) + await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30)); + + return subscription; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/Wallet.cs b/DysonNetwork.Pass/Wallet/Wallet.cs new file mode 100644 index 0000000..2480b0c --- /dev/null +++ b/DysonNetwork.Pass/Wallet/Wallet.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; + +namespace DysonNetwork.Pass.Wallet; + +public class Wallet : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public ICollection Pockets { get; set; } = new List(); + + public Guid AccountId { get; set; } + public Account.Account Account { get; set; } = null!; +} + +public class WalletPocket : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(128)] public string Currency { get; set; } = null!; + public decimal Amount { get; set; } + + public Guid WalletId { get; set; } + [JsonIgnore] public Wallet Wallet { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/WalletController.cs b/DysonNetwork.Pass/Wallet/WalletController.cs new file mode 100644 index 0000000..70f656e --- /dev/null +++ b/DysonNetwork.Pass/Wallet/WalletController.cs @@ -0,0 +1,101 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Pass.Permission; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Pass.Wallet; + +[ApiController] +[Route("/api/wallets")] +public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase +{ + [HttpPost] + [Authorize] + public async Task> CreateWallet() + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + try + { + var wallet = await ws.CreateWalletAsync(currentUser.Id); + return Ok(wallet); + } + catch (Exception err) + { + return BadRequest(err.Message); + } + } + + [HttpGet] + [Authorize] + public async Task> GetWallet() + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var wallet = await ws.GetWalletAsync(currentUser.Id); + if (wallet is null) return NotFound("Wallet was not found, please create one first."); + return Ok(wallet); + } + + [HttpGet("transactions")] + [Authorize] + public async Task>> GetTransactions( + [FromQuery] int offset = 0, [FromQuery] int take = 20 + ) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var query = db.PaymentTransactions.AsQueryable() + .Include(t => t.PayeeWallet) + .Include(t => t.PayerWallet) + .Where(t => (t.PayeeWallet != null && t.PayeeWallet.AccountId == currentUser.Id) || + (t.PayerWallet != null && t.PayerWallet.AccountId == currentUser.Id)); + + var transactionCount = await query.CountAsync(); + var transactions = await query + .Skip(offset) + .Take(take) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(); + + Response.Headers["X-Total"] = transactionCount.ToString(); + + return Ok(transactions); + } + + public class WalletBalanceRequest + { + public string? Remark { get; set; } + [Required] public decimal Amount { get; set; } + [Required] public string Currency { get; set; } = null!; + [Required] public Guid AccountId { get; set; } + } + + [HttpPost("balance")] + [Authorize] + [RequiredPermission("maintenance", "wallets.balance.modify")] + public async Task> ModifyWalletBalance([FromBody] WalletBalanceRequest request) + { + var wallet = await ws.GetWalletAsync(request.AccountId); + if (wallet is null) return NotFound("Wallet was not found."); + + var transaction = request.Amount >= 0 + ? await payment.CreateTransactionAsync( + payerWalletId: null, + payeeWalletId: wallet.Id, + currency: request.Currency, + amount: request.Amount, + remarks: request.Remark + ) + : await payment.CreateTransactionAsync( + payerWalletId: wallet.Id, + payeeWalletId: null, + currency: request.Currency, + amount: request.Amount, + remarks: request.Remark + ); + + return Ok(transaction); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Wallet/WalletService.cs b/DysonNetwork.Pass/Wallet/WalletService.cs new file mode 100644 index 0000000..98d3636 --- /dev/null +++ b/DysonNetwork.Pass/Wallet/WalletService.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Pass.Wallet; + +public class WalletService(AppDatabase db) +{ + public async Task GetWalletAsync(Guid accountId) + { + return await db.Wallets + .Include(w => w.Pockets) + .FirstOrDefaultAsync(w => w.AccountId == accountId); + } + + public async Task CreateWalletAsync(Guid accountId) + { + var existingWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == accountId); + if (existingWallet != null) + { + throw new InvalidOperationException($"Wallet already exists for account {accountId}"); + } + + var wallet = new Wallet { AccountId = accountId }; + + db.Wallets.Add(wallet); + await db.SaveChangesAsync(); + + return wallet; + } + + public async Task<(WalletPocket wallet, bool isNewlyCreated)> GetOrCreateWalletPocketAsync( + Guid walletId, + string currency, + decimal? initialAmount = null + ) + { + var pocket = await db.WalletPockets.FirstOrDefaultAsync(p => p.Currency == currency && p.WalletId == walletId); + if (pocket != null) return (pocket, false); + + pocket = new WalletPocket + { + Currency = currency, + Amount = initialAmount ?? 0, + WalletId = walletId + }; + + db.WalletPockets.Add(pocket); + return (pocket, true); + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Data/CloudFileReferenceObject.cs b/DysonNetwork.Shared/Data/CloudFileReferenceObject.cs new file mode 100644 index 0000000..ae4054e --- /dev/null +++ b/DysonNetwork.Shared/Data/CloudFileReferenceObject.cs @@ -0,0 +1,17 @@ +namespace DysonNetwork.Shared.Data; + +/// +/// The class that used in jsonb columns which referenced the cloud file. +/// The aim of this class is to store some properties that won't change to a file to reduce the database load. +/// +public class CloudFileReferenceObject : ModelBase, ICloudFile +{ + public string Id { get; set; } = null!; + public string Name { get; set; } = string.Empty; + public Dictionary? FileMeta { get; set; } = null!; + public Dictionary? UserMeta { get; set; } = null!; + public string? MimeType { get; set; } + public string? Hash { get; set; } + public long Size { get; set; } + public bool HasCompression { get; set; } = false; +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Data/ICloudFile.cs b/DysonNetwork.Shared/Data/ICloudFile.cs new file mode 100644 index 0000000..35543a3 --- /dev/null +++ b/DysonNetwork.Shared/Data/ICloudFile.cs @@ -0,0 +1,55 @@ +using NodaTime; + +namespace DysonNetwork.Shared.Data; + +/// +/// Common interface for cloud file entities that can be used in file operations. +/// This interface exposes the essential properties needed for file operations +/// and is implemented by both CloudFile and CloudFileReferenceObject. +/// +public interface ICloudFile +{ + public Instant CreatedAt { get; } + public Instant UpdatedAt { get; } + public Instant? DeletedAt { get; } + + /// + /// Gets the unique identifier of the cloud file. + /// + string Id { get; } + + /// + /// Gets the name of the cloud file. + /// + string Name { get; } + + /// + /// Gets the file metadata dictionary. + /// + Dictionary? FileMeta { get; } + + /// + /// Gets the user metadata dictionary. + /// + Dictionary? UserMeta { get; } + + /// + /// Gets the MIME type of the file. + /// + string? MimeType { get; } + + /// + /// Gets the hash of the file content. + /// + string? Hash { get; } + + /// + /// Gets the size of the file in bytes. + /// + long Size { get; } + + /// + /// Gets whether the file has a compressed version available. + /// + bool HasCompression { get; } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Data/ModelBase.cs b/DysonNetwork.Shared/Data/ModelBase.cs new file mode 100644 index 0000000..641a19e --- /dev/null +++ b/DysonNetwork.Shared/Data/ModelBase.cs @@ -0,0 +1,15 @@ +using NodaTime; + +namespace DysonNetwork.Shared.Data; + +public interface IIdentifiedResource +{ + public string ResourceIdentifier { get; } +} + +public abstract class ModelBase +{ + public Instant CreatedAt { get; set; } + public Instant UpdatedAt { get; set; } + public Instant? DeletedAt { get; set; } +}