♻️ Basically completed the separate of account service
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
@ -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<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
||||
public ICollection<Badge> Badges { get; set; } = new List<Badge>();
|
||||
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
|
||||
|
||||
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
|
||||
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
|
||||
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
|
||||
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
|
||||
[JsonIgnore] public ICollection<Auth.AuthSession> Sessions { get; set; } = new List<Auth.AuthSession>();
|
||||
[JsonIgnore] public ICollection<Auth.AuthChallenge> Challenges { get; set; } = new List<Auth.AuthChallenge>();
|
||||
|
||||
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
|
||||
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
|
||||
|
@ -34,9 +34,9 @@ public class AccountController(
|
||||
}
|
||||
|
||||
[HttpGet("{name}/badges")]
|
||||
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name)
|
||||
public async Task<ActionResult<List<AccountBadge>>> GetBadgesByName(string name)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Include(e => e.Badges)
|
||||
|
@ -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<Session> Sessions { get; set; } = [];
|
||||
public List<AuthSession> Sessions { get; set; } = [];
|
||||
}
|
||||
|
||||
[HttpGet("devices")]
|
||||
@ -446,7 +397,7 @@ public class AccountCurrentController(
|
||||
public async Task<ActionResult<List<AuthorizedDevice>>> 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<ActionResult<List<Session>>> GetSessions(
|
||||
public async Task<ActionResult<List<AuthSession>>> 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<ActionResult<Session>> DeleteSession(Guid id)
|
||||
public async Task<ActionResult<AuthSession>> 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<ActionResult<Session>> DeleteCurrentSession()
|
||||
public async Task<ActionResult<AuthSession>> 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<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
|
||||
public async Task<ActionResult<AuthSession>> 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<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
|
||||
public async Task<ActionResult<AuthSession>> 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<List<Badge>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Badge>>> GetBadges()
|
||||
public async Task<ActionResult<List<AccountBadge>>> 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<ActionResult<Badge>> ActivateBadge(Guid id)
|
||||
public async Task<ActionResult<AccountBadge>> ActivateBadge(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
|
||||
|
@ -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<Localization.AccountEventResource> localizer
|
||||
)
|
||||
{
|
||||
@ -34,7 +29,7 @@ public class AccountEventService(
|
||||
var cachedStatus = await cache.GetAsync<Status>(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<Status>(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<CheckInResult> CheckInDaily(Account user)
|
||||
{
|
||||
var lockKey = $"{CheckInLockKey}{user.Id}";
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
|
||||
|
||||
|
||||
if (lk != null)
|
||||
await lk.ReleaseAsync();
|
||||
}
|
||||
@ -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;
|
||||
|
@ -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<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
|
||||
public async Task<AuthSession> 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.
|
||||
/// </summary>
|
||||
public async Task<Badge> GrantBadge(Account account, Badge badge)
|
||||
public async Task<AccountBadge> GrantBadge(Account account, AccountBadge badge)
|
||||
{
|
||||
badge.AccountId = account.Id;
|
||||
db.Badges.Add(badge);
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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!;
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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<MagicSpellService> logger,
|
||||
IStringLocalizer<Localization.EmailResource> localizer
|
||||
IStringLocalizer<EmailResource> localizer
|
||||
)
|
||||
{
|
||||
public async Task<MagicSpell> CreateMagicSpell(
|
||||
@ -84,61 +79,61 @@ public class MagicSpellService(
|
||||
|
||||
try
|
||||
{
|
||||
switch (spell.Type)
|
||||
{
|
||||
case MagicSpellType.AccountActivation:
|
||||
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailLandingTitle"],
|
||||
new LandingEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
Link = link
|
||||
}
|
||||
);
|
||||
break;
|
||||
case MagicSpellType.AccountRemoval:
|
||||
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailAccountDeletionTitle"],
|
||||
new AccountDeletionEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
Link = link
|
||||
}
|
||||
);
|
||||
break;
|
||||
case MagicSpellType.AuthPasswordReset:
|
||||
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||
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<ContactVerificationEmail, ContactVerificationEmailModel>(
|
||||
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<LandingEmail, LandingEmailModel>(
|
||||
// contact.Account.Nick,
|
||||
// contact.Content,
|
||||
// localizer["EmailLandingTitle"],
|
||||
// new LandingEmailModel
|
||||
// {
|
||||
// Name = contact.Account.Name,
|
||||
// Link = link
|
||||
// }
|
||||
// );
|
||||
// break;
|
||||
// case MagicSpellType.AccountRemoval:
|
||||
// await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
||||
// contact.Account.Nick,
|
||||
// contact.Content,
|
||||
// localizer["EmailAccountDeletionTitle"],
|
||||
// new AccountDeletionEmailModel
|
||||
// {
|
||||
// Name = contact.Account.Name,
|
||||
// Link = link
|
||||
// }
|
||||
// );
|
||||
// break;
|
||||
// case MagicSpellType.AuthPasswordReset:
|
||||
// await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||
// 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<ContactVerificationEmail, ContactVerificationEmailModel>(
|
||||
// contact.Account.Nick,
|
||||
// contactMethod!,
|
||||
// localizer["EmailContactVerificationTitle"],
|
||||
// new ContactVerificationEmailModel
|
||||
// {
|
||||
// Name = contact.Account.Name,
|
||||
// Link = link
|
||||
// }
|
||||
// );
|
||||
// break;
|
||||
// default:
|
||||
// throw new ArgumentOutOfRangeException();
|
||||
// }
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -1,3 +1,4 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
@ -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<AppDatabase> options,
|
||||
IConfiguration configuration
|
||||
@ -43,12 +33,19 @@ public class AppDatabase(
|
||||
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
|
||||
public DbSet<Notification> Notifications { get; set; }
|
||||
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
|
||||
public DbSet<Badge> Badges { get; set; }
|
||||
public DbSet<AccountBadge> Badges { get; set; }
|
||||
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||
public DbSet<AbuseReport> AbuseReports { get; set; }
|
||||
|
||||
public DbSet<Session> AuthSessions { get; set; }
|
||||
public DbSet<Challenge> AuthChallenges { get; set; }
|
||||
public DbSet<AuthSession> AuthSessions { get; set; }
|
||||
public DbSet<AuthChallenge> AuthChallenges { get; set; }
|
||||
|
||||
public DbSet<Wallet.Wallet> Wallets { get; set; }
|
||||
public DbSet<WalletPocket> WalletPockets { get; set; }
|
||||
public DbSet<Order> PaymentOrders { get; set; }
|
||||
public DbSet<Transaction> PaymentTransactions { get; set; }
|
||||
public DbSet<Subscription> WalletSubscriptions { get; set; }
|
||||
public DbSet<Coupon> WalletCoupons { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
|
@ -65,7 +65,7 @@ public class DysonTokenAuthHandler(
|
||||
return AuthenticateResult.Fail("Invalid token.");
|
||||
|
||||
// Try to get session from cache first
|
||||
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
|
||||
var session = await cache.GetAsync<AuthSession>($"{AuthCachePrefix}{sessionId}");
|
||||
|
||||
// If not in cache, load from database
|
||||
if (session is null)
|
||||
|
@ -27,7 +27,7 @@ public class AuthController(
|
||||
}
|
||||
|
||||
[HttpPost("challenge")]
|
||||
public async Task<ActionResult<Challenge>> StartChallenge([FromBody] ChallengeRequest request)
|
||||
public async Task<ActionResult<AuthChallenge>> 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<ActionResult<Challenge>> GetChallenge([FromRoute] Guid id)
|
||||
public async Task<ActionResult<AuthChallenge>> 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<ActionResult<Challenge>> DoChallenge(
|
||||
public async Task<ActionResult<AuthChallenge>> 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)),
|
||||
|
@ -73,9 +73,9 @@ public class AuthService(
|
||||
return totalRequiredSteps;
|
||||
}
|
||||
|
||||
public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
|
||||
public async Task<AuthSession> 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<bool> ValidateSudoMode(Session session, string? pinCode)
|
||||
public async Task<bool> ValidateSudoMode(AuthSession session, string? pinCode)
|
||||
{
|
||||
// Check if the session is already in sudo mode (cached)
|
||||
var sudoModeKey = $"accounts:{session.Id}:sudo";
|
||||
|
@ -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);
|
||||
|
@ -114,7 +114,7 @@ public class OidcProviderController(
|
||||
public async Task<IActionResult> 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;
|
||||
|
@ -37,7 +37,7 @@ public class OidcProviderService(
|
||||
.FirstOrDefaultAsync(c => c.Id == appId);
|
||||
}
|
||||
|
||||
public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||
public async Task<AuthSession?> 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<string>? scopes = null
|
||||
)
|
||||
@ -199,7 +199,7 @@ public class OidcProviderService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Session?> FindSessionByIdAsync(Guid sessionId)
|
||||
public async Task<AuthSession?> 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<string> GenerateAuthorizationCodeForReuseSessionAsync(
|
||||
Session session,
|
||||
AuthSession session,
|
||||
Guid clientId,
|
||||
string redirectUri,
|
||||
IEnumerable<string> scopes,
|
||||
|
@ -68,7 +68,7 @@ public class OidcController(
|
||||
/// Handles Apple authentication directly from mobile apps
|
||||
/// </summary>
|
||||
[HttpPost("apple/mobile")]
|
||||
public async Task<ActionResult<Challenge>> AppleMobileLogin(
|
||||
public async Task<ActionResult<AuthChallenge>> AppleMobileLogin(
|
||||
[FromBody] AppleMobileSignInRequest request)
|
||||
{
|
||||
try
|
||||
|
@ -187,7 +187,7 @@ public abstract class OidcService(
|
||||
/// Creates a challenge and session for an authenticated user
|
||||
/// Also creates or updates the account connection
|
||||
/// </summary>
|
||||
public async Task<Challenge> CreateChallengeForUserAsync(
|
||||
public async Task<AuthChallenge> 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),
|
||||
|
@ -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;
|
||||
|
@ -39,4 +39,73 @@
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Resources\Localization\NotificationResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>NotificationResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\SharedResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>SharedResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\NotificationResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>NotificationResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\SharedResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>SharedResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\AccountEventResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>AccountEventResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\AccountEventResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>AccountEventResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Resources\Localization\EmailResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Email.LandingResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Resources\Localization\NotificationResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>NotificationResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Resources\Localization\SharedResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Resources\Localization\AccountEventResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>AccountEventResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
|
||||
<AdditionalFiles Include="Resources\Localization\AccountEventResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.zh-hans.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.zh-hans.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.zh-hans.resx" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
31
DysonNetwork.Pass/Email/EmailModels.cs
Normal file
31
DysonNetwork.Pass/Email/EmailModels.cs
Normal file
@ -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; }
|
||||
}
|
106
DysonNetwork.Pass/Email/EmailService.cs
Normal file
106
DysonNetwork.Pass/Email/EmailService.cs
Normal file
@ -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<EmailService> _logger;
|
||||
|
||||
public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger<EmailService> logger)
|
||||
{
|
||||
var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>();
|
||||
_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, "<style[^>]*>.*?</style>", "",
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
// Replace header tags with text + newlines
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// Replace line breaks
|
||||
html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\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<TComponent, TModel>(string? recipientName, string recipientEmail,
|
||||
string subject, TModel model)
|
||||
where TComponent : IComponent
|
||||
{
|
||||
try
|
||||
{
|
||||
var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model);
|
||||
var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody);
|
||||
await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
_logger.LogError(err, "Failed to render email template...");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
45
DysonNetwork.Pass/Email/RazorViewRenderer.cs
Normal file
45
DysonNetwork.Pass/Email/RazorViewRenderer.cs
Normal file
@ -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<RazorViewRenderer> logger
|
||||
)
|
||||
{
|
||||
public async Task<string> RenderComponentToStringAsync<TComponent, TModel>(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<string, object?>();
|
||||
var parameterView = ParameterView.FromDictionary(dictionary);
|
||||
var output = await htmlRenderer.RenderComponentAsync<TComponent>(parameterView);
|
||||
return output.ToHtmlString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error rendering component {ComponentName}", typeof(TComponent).Name);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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; }
|
||||
}
|
||||
|
6
DysonNetwork.Pass/Localization/AccountEventResource.cs
Normal file
6
DysonNetwork.Pass/Localization/AccountEventResource.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class AccountEventResource
|
||||
{
|
||||
|
||||
}
|
5
DysonNetwork.Pass/Localization/EmailResource.cs
Normal file
5
DysonNetwork.Pass/Localization/EmailResource.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class EmailResource
|
||||
{
|
||||
}
|
6
DysonNetwork.Pass/Localization/NotificationResource.cs
Normal file
6
DysonNetwork.Pass/Localization/NotificationResource.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class NotificationResource
|
||||
{
|
||||
|
||||
}
|
6
DysonNetwork.Pass/Localization/SharedResource.cs
Normal file
6
DysonNetwork.Pass/Localization/SharedResource.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class SharedResource
|
||||
{
|
||||
|
||||
}
|
42
DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor
Normal file
42
DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor
Normal file
@ -0,0 +1,42 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
|
||||
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["AccountDeletionPara2"])</p>
|
||||
<p>@(Localizer["AccountDeletionPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["AccountDeletionButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["AccountDeletionPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["ContactVerificationHeader"])</p>
|
||||
<p>@(Localizer["ContactVerificationPara1"]) @Name,</p>
|
||||
<p>@(Localizer["ContactVerificationPara2"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["ContactVerificationButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["ContactVerificationPara3"])</p>
|
||||
<p>@(Localizer["ContactVerificationPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
337
DysonNetwork.Pass/Pages/Emails/EmailLayout.razor
Normal file
337
DysonNetwork.Pass/Pages/Emails/EmailLayout.razor
Normal file
@ -0,0 +1,337 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<style media="all" type="text/css">
|
||||
body {
|
||||
font-family: Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table td {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f4f5f6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: #f4f5f6;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0 auto !important;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
padding-top: 24px;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eaebed;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
clear: both;
|
||||
padding-top: 24px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer td,
|
||||
.footer p,
|
||||
.footer span,
|
||||
.footer a {
|
||||
color: #9a9ea6;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0867ec;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
box-sizing: border-box;
|
||||
min-width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn > tbody > tr > td {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn table {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn table td {
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn a {
|
||||
background-color: #ffffff;
|
||||
border: solid 2px #0867ec;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
color: #0867ec;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.btn-primary table td {
|
||||
background-color: #0867ec;
|
||||
}
|
||||
|
||||
.btn-primary a {
|
||||
background-color: #0867ec;
|
||||
border-color: #0867ec;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verification-code
|
||||
{
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.5em;
|
||||
}
|
||||
|
||||
@@media all {
|
||||
.btn-primary table td:hover {
|
||||
background-color: #ec0867 !important;
|
||||
}
|
||||
|
||||
.btn-primary a:hover {
|
||||
background-color: #ec0867 !important;
|
||||
border-color: #ec0867 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.first {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: #0867ec !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.mt0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mb0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
color: transparent;
|
||||
display: none;
|
||||
height: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
mso-hide: all;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.powered-by a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@media only screen and (max-width: 640px) {
|
||||
.main p,
|
||||
.main td,
|
||||
.main span {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
padding-top: 8px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
|
||||
.btn table {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.btn a {
|
||||
font-size: 16px !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.apple-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class="container">
|
||||
<div class="content">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
@ChildContent
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<div class="footer">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="content-block">
|
||||
<span class="apple-link">Solar Network</span>
|
||||
<br> Solsynth LLC © @(DateTime.Now.Year)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="content-block powered-by">
|
||||
Powered by <a href="https://github.com/solsynth/dysonnetwork">Dyson Network</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER --></div>
|
||||
</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
43
DysonNetwork.Pass/Pages/Emails/LandingEmail.razor
Normal file
43
DysonNetwork.Pass/Pages/Emails/LandingEmail.razor
Normal file
@ -0,0 +1,43 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["LandingHeader1"])</p>
|
||||
<p>@(Localizer["LandingPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["LandingPara2"])</p>
|
||||
<p>@(Localizer["LandingPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["LandingButton1"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["LandingPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
44
DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor
Normal file
44
DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor
Normal file
@ -0,0 +1,44 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["PasswordResetHeader"])</p>
|
||||
<p>@(Localizer["PasswordResetPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["PasswordResetPara2"])</p>
|
||||
<p>@(Localizer["PasswordResetPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["PasswordResetButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["PasswordResetPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
27
DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor
Normal file
27
DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor
Normal file
@ -0,0 +1,27 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["VerificationHeader1"])</p>
|
||||
<p>@(Localizer["VerificationPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["VerificationPara2"])</p>
|
||||
<p>@(Localizer["VerificationPara3"])</p>
|
||||
|
||||
<p class="verification-code">@Code</p>
|
||||
|
||||
<p>@(Localizer["VerificationPara4"])</p>
|
||||
<p>@(Localizer["VerificationPara5"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Code { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
@ -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;
|
||||
|
||||
|
222
DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs
generated
Normal file
222
DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs
generated
Normal file
@ -0,0 +1,222 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<data name="FortuneTipPositiveTitle_1" xml:space="preserve">
|
||||
<value>Gacha</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_1" xml:space="preserve">
|
||||
<value>Golden every pull</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_1" xml:space="preserve">
|
||||
<value>Gacha</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_1" xml:space="preserve">
|
||||
<value>Won't pull the card you like</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_2" xml:space="preserve">
|
||||
<value>Gaming</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_2" xml:space="preserve">
|
||||
<value>Rank up like a hot knife through butter</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_2" xml:space="preserve">
|
||||
<value>Gaming</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_2" xml:space="preserve">
|
||||
<value>Dropping ranks like a landslide</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_3" xml:space="preserve">
|
||||
<value>Lottery</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_3" xml:space="preserve">
|
||||
<value>Blessed with luck</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_3" xml:space="preserve">
|
||||
<value>Lottery</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_3" xml:space="preserve">
|
||||
<value>Ten pulls, all silence</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_4" xml:space="preserve">
|
||||
<value>Speech</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_4" xml:space="preserve">
|
||||
<value>Words flow like gems</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_4" xml:space="preserve">
|
||||
<value>Speech</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_4" xml:space="preserve">
|
||||
<value>Be careful what you're saying</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_5" xml:space="preserve">
|
||||
<value>Drawing</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_5" xml:space="preserve">
|
||||
<value>Inspiration gushes like a spring</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_5" xml:space="preserve">
|
||||
<value>Drawing</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_5" xml:space="preserve">
|
||||
<value>Every stroke weighs a thousand pounds</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_6" xml:space="preserve">
|
||||
<value>Coding</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_6" xml:space="preserve">
|
||||
<value>0 error(s), 0 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_6" xml:space="preserve">
|
||||
<value>Coding</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_6" xml:space="preserve">
|
||||
<value>114 error(s), 514 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_7" xml:space="preserve">
|
||||
<value>Shopping</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_7" xml:space="preserve">
|
||||
<value>Exchange rate at its lowest</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_7" xml:space="preserve">
|
||||
<value>Unboxing</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_7" xml:space="preserve">
|
||||
<value>225% tariff</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_1 " xml:space="preserve">
|
||||
<value>Gacha</value>
|
||||
</data>
|
||||
</root>
|
@ -0,0 +1,98 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="FortuneTipPositiveTitle_1" xml:space="preserve">
|
||||
<value>抽卡</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_1" xml:space="preserve">
|
||||
<value>次次出金</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_1" xml:space="preserve">
|
||||
<value>吃大保底</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_2" xml:space="preserve">
|
||||
<value>升段如破竹</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_2" xml:space="preserve">
|
||||
<value>掉分如山崩</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_3" xml:space="preserve">
|
||||
<value>欧气加身</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_3" xml:space="preserve">
|
||||
<value>十连皆寂</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_4" xml:space="preserve">
|
||||
<value>妙语连珠</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_4" xml:space="preserve">
|
||||
<value>谨言慎行</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_5" xml:space="preserve">
|
||||
<value>灵感如泉涌</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_5" xml:space="preserve">
|
||||
<value>下笔如千斤</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_6" xml:space="preserve">
|
||||
<value>0 error(s), 0 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_6" xml:space="preserve">
|
||||
<value>114 error(s), 514 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_7" xml:space="preserve">
|
||||
<value>购物</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_7" xml:space="preserve">
|
||||
<value>汇率低谷</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_7" xml:space="preserve">
|
||||
<value>开箱</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_7" xml:space="preserve">
|
||||
<value>225% 关税</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_1" xml:space="preserve">
|
||||
<value>抽卡</value>
|
||||
</data>
|
||||
</root>
|
90
DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs
generated
Normal file
90
DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs
generated
Normal file
@ -0,0 +1,90 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
126
DysonNetwork.Pass/Resources/Localization/EmailResource.resx
Normal file
126
DysonNetwork.Pass/Resources/Localization/EmailResource.resx
Normal file
@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LandingHeader1" xml:space="preserve">
|
||||
<value>Welcome to the Solar Network!</value>
|
||||
</data>
|
||||
<data name="LandingPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="LandingPara2" xml:space="preserve">
|
||||
<value>Thank you for creating an account on the Solar Network. We're excited to have you join our community!</value>
|
||||
</data>
|
||||
<data name="LandingPara3" xml:space="preserve">
|
||||
<value>To access all features and ensure the security of your account, please confirm your registration by clicking the button below:</value>
|
||||
</data>
|
||||
<data name="LandingButton1" xml:space="preserve">
|
||||
<value>Confirm Registration</value>
|
||||
</data>
|
||||
<data name="LandingPara4" xml:space="preserve">
|
||||
<value>If you didn't create this account, please ignore this email.</value>
|
||||
</data>
|
||||
<data name="EmailLandingTitle" xml:space="preserve">
|
||||
<value>Confirm your registration</value>
|
||||
</data>
|
||||
<data name="AccountDeletionHeader" xml:space="preserve">
|
||||
<value>Account Deletion Confirmation</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara2" xml:space="preserve">
|
||||
<value>We've received a request to delete your Solar Network account. We're sorry to see you go.</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara3" xml:space="preserve">
|
||||
<value>To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone.</value>
|
||||
</data>
|
||||
<data name="AccountDeletionButton" xml:space="preserve">
|
||||
<value>Confirm Account Deletion</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara4" xml:space="preserve">
|
||||
<value>If you did not request to delete your account, please ignore this email or contact our support team immediately.</value>
|
||||
</data>
|
||||
<data name="EmailAccountDeletionTitle" xml:space="preserve">
|
||||
<value>Confirm your account deletion</value>
|
||||
</data>
|
||||
<data name="EmailPasswordResetTitle" xml:space="preserve">
|
||||
<value>Reset your password</value>
|
||||
</data>
|
||||
<data name="PasswordResetButton" xml:space="preserve">
|
||||
<value>Reset Password</value>
|
||||
</data>
|
||||
<data name="PasswordResetHeader" xml:space="preserve">
|
||||
<value>Password Reset Request</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara1" xml:space="preserve">
|
||||
<value>Dear,</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara2" xml:space="preserve">
|
||||
<value>We recieved a request to reset your Solar Network account password.</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara3" xml:space="preserve">
|
||||
<value>You can click the button below to continue reset your password.</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara4" xml:space="preserve">
|
||||
<value>If you didn't request this, you can ignore this email safety.</value>
|
||||
</data>
|
||||
<data name="VerificationHeader1" xml:space="preserve">
|
||||
<value>Verify Your Email Address</value>
|
||||
</data>
|
||||
<data name="VerificationPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="VerificationPara2" xml:space="preserve">
|
||||
<value>Thank you for creating an account on the Solar Network. We're excited to have you join our community!</value>
|
||||
</data>
|
||||
<data name="VerificationPara3" xml:space="preserve">
|
||||
<value>To verify your email address and access all features of your account, please use the verification code below:</value>
|
||||
</data>
|
||||
<data name="VerificationPara4" xml:space="preserve">
|
||||
<value>This code will expire in 30 minutes. Please enter it on the verification page to complete your registration.</value>
|
||||
</data>
|
||||
<data name="VerificationPara5" xml:space="preserve">
|
||||
<value>If you didn't create this account, please ignore this email.</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>Verify your email address</value>
|
||||
</data>
|
||||
<data name="ContactVerificationHeader" xml:space="preserve">
|
||||
<value>Verify Your Contact Information</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara2" xml:space="preserve">
|
||||
<value>Thank you for updating your contact information on the Solar Network. To ensure your account security, we need to verify this change.</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara3" xml:space="preserve">
|
||||
<value>Please click the button below to verify your contact information:</value>
|
||||
</data>
|
||||
<data name="ContactVerificationButton" xml:space="preserve">
|
||||
<value>Verify Contact Information</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara4" xml:space="preserve">
|
||||
<value>If you didn't request this change, please contact our support team immediately.</value>
|
||||
</data>
|
||||
<data name="EmailContactVerificationTitle" xml:space="preserve">
|
||||
<value>Verify your contact information</value>
|
||||
</data>
|
||||
</root>
|
@ -0,0 +1,119 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LandingHeader1" xml:space="preserve">
|
||||
<value>欢迎来到 Solar Network!</value>
|
||||
</data>
|
||||
<data name="LandingPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="LandingButton1" xml:space="preserve">
|
||||
<value>确认注册</value>
|
||||
</data>
|
||||
<data name="LandingPara2" xml:space="preserve">
|
||||
<value>感谢你在 Solar Network 上注册帐号,我们很激动你即将加入我们的社区!</value>
|
||||
</data>
|
||||
<data name="LandingPara3" xml:space="preserve">
|
||||
<value>点击下方按钮来确认你的注册以获得所有功能的权限。</value>
|
||||
</data>
|
||||
<data name="LandingPara4" xml:space="preserve">
|
||||
<value>如果你并没有注册帐号,你可以忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailLandingTitle" xml:space="preserve">
|
||||
<value>确认你的注册</value>
|
||||
</data>
|
||||
<data name="AccountDeletionHeader" xml:space="preserve">
|
||||
<value>账户删除确认</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara2" xml:space="preserve">
|
||||
<value>我们收到了删除您 Solar Network 账户的请求。我们很遗憾看到您的离开。</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮确认删除您的账户。请注意,此操作是永久性的,无法撤销。</value>
|
||||
</data>
|
||||
<data name="AccountDeletionButton" xml:space="preserve">
|
||||
<value>确认删除账户</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara4" xml:space="preserve">
|
||||
<value>如果您并未请求删除账户,请忽略此邮件或立即联系我们的支持团队。</value>
|
||||
</data>
|
||||
<data name="EmailAccountDeletionTitle" xml:space="preserve">
|
||||
<value>确认删除您的账户</value>
|
||||
</data>
|
||||
<data name="PasswordResetHeader" xml:space="preserve">
|
||||
<value>密码重置请求</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="PasswordResetPara2" xml:space="preserve">
|
||||
<value>我们收到了重置您 Solar Network 账户密码的请求。</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮重置您的密码。此链接将在24小时后失效。</value>
|
||||
</data>
|
||||
<data name="PasswordResetButton" xml:space="preserve">
|
||||
<value>重置密码</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara4" xml:space="preserve">
|
||||
<value>如果您并未请求重置密码,你可以安全地忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailPasswordResetTitle" xml:space="preserve">
|
||||
<value>重置您的密码</value>
|
||||
</data>
|
||||
<data name="VerificationHeader1" xml:space="preserve">
|
||||
<value>验证您的电子邮箱</value>
|
||||
</data>
|
||||
<data name="VerificationPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="VerificationPara2" xml:space="preserve">
|
||||
<value>感谢您在 Solar Network 上注册账号,我们很高兴您即将加入我们的社区!</value>
|
||||
</data>
|
||||
<data name="VerificationPara3" xml:space="preserve">
|
||||
<value>请使用以下验证码来验证您的电子邮箱并获取账号的所有功能:</value>
|
||||
</data>
|
||||
<data name="VerificationPara4" xml:space="preserve">
|
||||
<value>此验证码将在30分钟后失效。请在验证页面输入此验证码以完成注册。</value>
|
||||
</data>
|
||||
<data name="VerificationPara5" xml:space="preserve">
|
||||
<value>如果您并未创建此账号,请忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的电子邮箱</value>
|
||||
</data>
|
||||
<data name="ContactVerificationHeader" xml:space="preserve">
|
||||
<value>验证您的联系信息</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara2" xml:space="preserve">
|
||||
<value>感谢您更新 Solar Network 上的联系信息。为确保您的账户安全,我们需要验证此更改。</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮验证您的联系信息:</value>
|
||||
</data>
|
||||
<data name="ContactVerificationButton" xml:space="preserve">
|
||||
<value>验证联系信息</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara4" xml:space="preserve">
|
||||
<value>如果您没有请求此更改,请立即联系我们的支持团队。</value>
|
||||
</data>
|
||||
<data name="EmailContactVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的联系信息</value>
|
||||
</data>
|
||||
</root>
|
162
DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs
generated
Normal file
162
DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs
generated
Normal file
@ -0,0 +1,162 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<data name="ChatInviteTitle" xml:space="preserve">
|
||||
<value>New Chat Invitation</value>
|
||||
</data>
|
||||
<data name="ChatInviteBody" xml:space="preserve">
|
||||
<value>You just got invited to join {0}</value>
|
||||
</data>
|
||||
<data name="ChatInviteDirectBody" xml:space="preserve">
|
||||
<value>{0} sent an direct message invitation to you</value>
|
||||
</data>
|
||||
<data name="RealmInviteTitle" xml:space="preserve">
|
||||
<value>New Realm Invitation</value>
|
||||
</data>
|
||||
<data name="RealmInviteBody" xml:space="preserve">
|
||||
<value>You just got invited to join {0}</value>
|
||||
</data>
|
||||
<data name="PostSubscriptionTitle" xml:space="preserve">
|
||||
<value>{0} just posted {1}</value>
|
||||
</data>
|
||||
<data name="PostReactTitle" xml:space="preserve">
|
||||
<value>{0} reacted your post</value>
|
||||
</data>
|
||||
<data name="PostReactBody" xml:space="preserve">
|
||||
<value>{0} added a reaction {1} to your post</value>
|
||||
</data>
|
||||
<data name="PostReactContentBody" xml:space="preserve">
|
||||
<value>{0} added a reaction {1} to your post {2}</value>
|
||||
</data>
|
||||
<data name="PostReplyTitle" xml:space="preserve">
|
||||
<value>{0} replied your post</value>
|
||||
</data>
|
||||
<data name="PostReplyBody">
|
||||
<value>{0} replied: {1}</value>
|
||||
</data>
|
||||
<data name="PostReplyContentBody">
|
||||
<value>{0} replied post {1}: {2}</value>
|
||||
</data>
|
||||
<data name="PostOnlyMedia" xml:space="preserve">
|
||||
<value>shared media</value>
|
||||
</data>
|
||||
<data name="AuthCodeTitle" xml:space="preserve">
|
||||
<value>Disposable Verification Code</value>
|
||||
</data>
|
||||
<data name="AuthCodeBody" xml:space="preserve">
|
||||
<value>{0} is your disposable code, it will expires in 5 minutes</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedTitle" xml:space="preserve">
|
||||
<value>Subscription {0} just activated for your account</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedBody" xml:space="preserve">
|
||||
<value>Thank for supporting the Solar Network! Your {0} days {1} subscription just begun, feel free to explore the newly unlocked features!</value>
|
||||
</data>
|
||||
<data name="OrderPaidTitle" xml:space="preserve">
|
||||
<value>Order {0} recipent</value>
|
||||
</data>
|
||||
<data name="OrderPaidBody" xml:space="preserve">
|
||||
<value>{0} {1} was removed from your wallet to pay {2}</value>
|
||||
</data>
|
||||
</root>
|
@ -0,0 +1,75 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<data name="ChatInviteTitle" xml:space="preserve">
|
||||
<value>新聊天邀请</value>
|
||||
</data>
|
||||
<data name="ChatInviteBody" xml:space="preserve">
|
||||
<value>你刚被邀请加入聊天 {}</value>
|
||||
</data>
|
||||
<data name="ChatInviteDirectBody" xml:space="preserve">
|
||||
<value>{0} 向你发送了一个私聊邀请</value>
|
||||
</data>
|
||||
<data name="RealmInviteTitle" xml:space="preserve">
|
||||
<value>新加入领域邀请</value>
|
||||
</data>
|
||||
<data name="RealmInviteBody" xml:space="preserve">
|
||||
<value>你刚被邀请加入领域 {0}</value>
|
||||
</data>
|
||||
<data name="PostSubscriptionTitle" xml:space="preserve">
|
||||
<value>{0} 有新帖子</value>
|
||||
</data>
|
||||
<data name="PostReactTitle" xml:space="preserve">
|
||||
<value>{0} 反应了你的帖子</value>
|
||||
</data>
|
||||
<data name="PostReactBody" xml:space="preserve">
|
||||
<value>{0} 给你的帖子添加了一个 {1} 的反应</value>
|
||||
</data>
|
||||
<data name="PostReactContentBody" xml:space="preserve">
|
||||
<value>{0} 给你的帖子添加了一个 {1} 的反应 {2}</value>
|
||||
</data>
|
||||
<data name="PostReplyTitle" xml:space="preserve">
|
||||
<value>{0} 回复了你的帖子</value>
|
||||
</data>
|
||||
<data name="PostReplyBody">
|
||||
<value>{0}:{1}</value>
|
||||
</data>
|
||||
<data name="PostReplyContentBody">
|
||||
<value>{0} 回复了帖子 {1}: {2}</value>
|
||||
</data>
|
||||
<data name="PostOnlyMedia" xml:space="preserve">
|
||||
<value>分享媒体</value>
|
||||
</data>
|
||||
<data name="AuthCodeTitle" xml:space="preserve">
|
||||
<value>一次性验证码</value>
|
||||
</data>
|
||||
<data name="AuthCodeBody" xml:space="preserve">
|
||||
<value>{0} 是你的一次性验证码,它将会在五分钟内过期</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedTitle" xml:space="preserve">
|
||||
<value>{0} 的订阅激活成功</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedBody" xml:space="preserve">
|
||||
<value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value>
|
||||
</data>
|
||||
<data name="OrderPaidTitle" xml:space="preserve">
|
||||
<value>订单回执 {0}</value>
|
||||
</data>
|
||||
<data name="OrderPaidBody" xml:space="preserve">
|
||||
<value>{0} {1} 已从你的帐户中扣除来支付 {2}</value>
|
||||
</data>
|
||||
</root>
|
48
DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs
generated
Normal file
48
DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs
generated
Normal file
@ -0,0 +1,48 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
DysonNetwork.Pass/Resources/Localization/SharedResource.resx
Normal file
21
DysonNetwork.Pass/Resources/Localization/SharedResource.resx
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
@ -0,0 +1,14 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
57
DysonNetwork.Pass/Wallet/OrderController.cs
Normal file
57
DysonNetwork.Pass/Wallet/OrderController.cs
Normal file
@ -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<ActionResult<Order>> 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<ActionResult<Order>> 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;
|
||||
}
|
61
DysonNetwork.Pass/Wallet/Payment.cs
Normal file
61
DysonNetwork.Pass/Wallet/Payment.cs
Normal file
@ -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<string, object>? 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; }
|
||||
}
|
446
DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs
Normal file
446
DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs
Normal file
@ -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<AfdianPaymentHandler> logger,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory =
|
||||
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
|
||||
private readonly ILogger<AfdianPaymentHandler> _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<OrderResponse?> 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<OrderResponse>(
|
||||
await response.Content.ReadAsStreamAsync(), JsonOptions);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching orders");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific order by its ID (out_trade_no)
|
||||
/// </summary>
|
||||
/// <param name="orderId">The order ID to query</param>
|
||||
/// <returns>The order item if found, otherwise null</returns>
|
||||
public async Task<OrderItem?> 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<OrderResponse>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get multiple orders by their IDs (out_trade_no)
|
||||
/// </summary>
|
||||
/// <param name="orderIds">A collection of order IDs to query</param>
|
||||
/// <returns>A list of found order items</returns>
|
||||
public async Task<List<OrderItem>> GetOrderBatchAsync(IEnumerable<string> 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<OrderItem>();
|
||||
}
|
||||
|
||||
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle an incoming webhook from Afdian's payment platform
|
||||
/// </summary>
|
||||
/// <param name="request">The HTTP request containing webhook data</param>
|
||||
/// <param name="processOrderAction">An action to process the received order</param>
|
||||
/// <returns>A WebhookResponse object to be returned to Afdian</returns>
|
||||
public async Task<WebhookResponse> HandleWebhook(
|
||||
HttpRequest request,
|
||||
Func<WebhookOrderData, Task>? 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<WebhookRequest>(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<OrderItem> 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<object> 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!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request structure for Afdian webhook
|
||||
/// </summary>
|
||||
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!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order data contained in the webhook
|
||||
/// </summary>
|
||||
public class WebhookOrderData
|
||||
{
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("order")] public WebhookOrderDetails Order { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order details in the webhook
|
||||
/// </summary>
|
||||
public class WebhookOrderDetails : OrderItem
|
||||
{
|
||||
[JsonPropertyName("custom_order_id")] public string CustomOrderId { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response structure to acknowledge webhook receipt
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SKU detail item
|
||||
/// </summary>
|
||||
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!;
|
||||
}
|
@ -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; }
|
||||
}
|
297
DysonNetwork.Pass/Wallet/PaymentService.cs
Normal file
297
DysonNetwork.Pass/Wallet/PaymentService.cs
Normal file
@ -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<NotificationResource> localizer
|
||||
)
|
||||
{
|
||||
public async Task<Order> CreateOrderAsync(
|
||||
Guid? payeeWalletId,
|
||||
string currency,
|
||||
decimal amount,
|
||||
Duration? expiration = null,
|
||||
string? appIdentifier = null,
|
||||
Dictionary<string, object>? 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<Transaction> 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<Transaction> 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<Order> 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<string, object>()
|
||||
{
|
||||
["order_id"] = order.Id.ToString()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Order> 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<Transaction> 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);
|
||||
}
|
||||
}
|
233
DysonNetwork.Pass/Wallet/Subscription.cs
Normal file
233
DysonNetwork.Pass/Wallet/Subscription.cs
Normal file
@ -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<string, SubscriptionTypeData> 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<string, string> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// DO NOT USE THIS TYPE DIRECTLY,
|
||||
/// this is the prefix of all the stellar program subscriptions.
|
||||
/// </summary>
|
||||
public const string StellarProgram = "solian.stellar";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// The solar points / solar dollars.
|
||||
/// </summary>
|
||||
public const string InAppWallet = "solian.wallet";
|
||||
|
||||
/// <summary>
|
||||
/// afdian.com
|
||||
/// aka. China patreon
|
||||
/// </summary>
|
||||
public const string Afdian = "afdian";
|
||||
}
|
||||
|
||||
public enum SubscriptionStatus
|
||||
{
|
||||
Unpaid,
|
||||
Active,
|
||||
Expired,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The subscription is for the Stellar Program in most cases.
|
||||
/// The paid subscription in another word.
|
||||
/// </summary>
|
||||
[Index(nameof(Identifier))]
|
||||
public class Subscription : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Instant BegunAt { get; set; }
|
||||
public Instant? EndedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of the subscriptions
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string Identifier { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates is the current user got the membership for free,
|
||||
/// to prevent giving the same discount for the same user again.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discount that can applies in purchases among the Solar Network.
|
||||
/// For now, it can be used in the subscription purchase.
|
||||
/// </summary>
|
||||
public class Coupon : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The items that can apply this coupon.
|
||||
/// Leave it to null to apply to all items.
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string? Identifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The code that human-readable and memorizable.
|
||||
/// Leave it blank to use it only with the ID.
|
||||
/// </summary>
|
||||
[MaxLength(1024)]
|
||||
public string? Code { get; set; }
|
||||
|
||||
public Instant? AffectedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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: <code>final price = base price - discount amount</code>
|
||||
/// </summary>
|
||||
public decimal? DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The percentage of the discount.
|
||||
/// If this field and the amount field are both not null,
|
||||
/// this field will be ignored.
|
||||
/// Formula: <code>final price = base price * (1 - discount rate)</code>
|
||||
/// </summary>
|
||||
public double? DiscountRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The max usage of the current coupon.
|
||||
/// Leave it to null to use it unlimited.
|
||||
/// </summary>
|
||||
public int? MaxUsage { get; set; }
|
||||
}
|
204
DysonNetwork.Pass/Wallet/SubscriptionController.cs
Normal file
204
DysonNetwork.Pass/Wallet/SubscriptionController.cs
Normal file
@ -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<ActionResult<List<Subscription>>> 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<ActionResult<Subscription>> 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<ActionResult<Subscription>> 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<ActionResult<Subscription>> 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<ActionResult<Subscription>> 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<ActionResult<Order>> 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<ActionResult<Subscription>> 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<IActionResult> 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<ActionResult<WebhookResponse>> AfdianWebhook()
|
||||
{
|
||||
var response = await afdian.HandleWebhook(Request, async webhookData =>
|
||||
{
|
||||
var order = webhookData.Order;
|
||||
await subscriptions.CreateSubscriptionFromOrder(order);
|
||||
});
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
137
DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs
Normal file
137
DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs
Normal file
@ -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<SubscriptionRenewalJob> 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<string, object>()
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
394
DysonNetwork.Pass/Wallet/SubscriptionService.cs
Normal file
394
DysonNetwork.Pass/Wallet/SubscriptionService.cs
Normal file
@ -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<NotificationResource> localizer,
|
||||
IConfiguration configuration,
|
||||
ICacheService cache,
|
||||
ILogger<SubscriptionService> logger
|
||||
)
|
||||
{
|
||||
public async Task<Subscription> 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<Subscription> 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<Dictionary<string, string>>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel the renewal of the current activated subscription.
|
||||
/// </summary>
|
||||
/// <param name="accountId">The user who requested the action.</param>
|
||||
/// <param name="identifier">The subscription identifier</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException">The active subscription was not found</exception>
|
||||
public async Task<Subscription> 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";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription order for an unpaid or expired subscription.
|
||||
/// If the subscription is active, it will extend its expiration date.
|
||||
/// </summary>
|
||||
/// <param name="accountId">The unique identifier for the account associated with the subscription.</param>
|
||||
/// <param name="identifier">The unique subscription identifier.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the created subscription order.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no matching unpaid or expired subscription is found.</exception>
|
||||
public async Task<Order> 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<string, object>()
|
||||
{
|
||||
["subscription_id"] = subscription.Id.ToString(),
|
||||
["subscription_identifier"] = subscription.Identifier,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Subscription> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of expired subscriptions to reflect their current state.
|
||||
/// This helps maintain accurate subscription records and is typically called periodically.
|
||||
/// </summary>
|
||||
/// <param name="batchSize">Maximum number of subscriptions to process</param>
|
||||
/// <returns>Number of subscriptions that were marked as expired</returns>
|
||||
public async Task<int> 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<string, object>()
|
||||
{
|
||||
["subscription_id"] = subscription.Id.ToString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private const string SubscriptionCacheKeyPrefix = "subscription:";
|
||||
|
||||
public async Task<Subscription?> 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<Subscription>(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;
|
||||
}
|
||||
}
|
25
DysonNetwork.Pass/Wallet/Wallet.cs
Normal file
25
DysonNetwork.Pass/Wallet/Wallet.cs
Normal file
@ -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<WalletPocket> Pockets { get; set; } = new List<WalletPocket>();
|
||||
|
||||
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!;
|
||||
}
|
101
DysonNetwork.Pass/Wallet/WalletController.cs
Normal file
101
DysonNetwork.Pass/Wallet/WalletController.cs
Normal file
@ -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<ActionResult<Wallet>> 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<ActionResult<Wallet>> 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<ActionResult<List<Transaction>>> 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<ActionResult<Transaction>> 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);
|
||||
}
|
||||
}
|
49
DysonNetwork.Pass/Wallet/WalletService.cs
Normal file
49
DysonNetwork.Pass/Wallet/WalletService.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class WalletService(AppDatabase db)
|
||||
{
|
||||
public async Task<Wallet?> GetWalletAsync(Guid accountId)
|
||||
{
|
||||
return await db.Wallets
|
||||
.Include(w => w.Pockets)
|
||||
.FirstOrDefaultAsync(w => w.AccountId == accountId);
|
||||
}
|
||||
|
||||
public async Task<Wallet> 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);
|
||||
}
|
||||
}
|
17
DysonNetwork.Shared/Data/CloudFileReferenceObject.cs
Normal file
17
DysonNetwork.Shared/Data/CloudFileReferenceObject.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace DysonNetwork.Shared.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CloudFileReferenceObject : ModelBase, ICloudFile
|
||||
{
|
||||
public string Id { get; set; } = null!;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public Dictionary<string, object>? FileMeta { get; set; } = null!;
|
||||
public Dictionary<string, object>? 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;
|
||||
}
|
55
DysonNetwork.Shared/Data/ICloudFile.cs
Normal file
55
DysonNetwork.Shared/Data/ICloudFile.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Shared.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface ICloudFile
|
||||
{
|
||||
public Instant CreatedAt { get; }
|
||||
public Instant UpdatedAt { get; }
|
||||
public Instant? DeletedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier of the cloud file.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the cloud file.
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file metadata dictionary.
|
||||
/// </summary>
|
||||
Dictionary<string, object>? FileMeta { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user metadata dictionary.
|
||||
/// </summary>
|
||||
Dictionary<string, object>? UserMeta { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MIME type of the file.
|
||||
/// </summary>
|
||||
string? MimeType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash of the file content.
|
||||
/// </summary>
|
||||
string? Hash { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the size of the file in bytes.
|
||||
/// </summary>
|
||||
long Size { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the file has a compressed version available.
|
||||
/// </summary>
|
||||
bool HasCompression { get; }
|
||||
}
|
15
DysonNetwork.Shared/Data/ModelBase.cs
Normal file
15
DysonNetwork.Shared/Data/ModelBase.cs
Normal file
@ -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; }
|
||||
}
|
Reference in New Issue
Block a user