From bb9105c78c6b534112e563dda0fd18a9a5503c1e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 3 Feb 2026 00:36:53 +0800 Subject: [PATCH] :tada: Initial commit of the Wallet --- DysonNetwork.Wallet/AppDatabase.cs | 151 ++++++++++++++++++ .../DysonNetwork.Wallet.csproj | 28 ++++ DysonNetwork.Wallet/Program.cs | 50 ++++++ .../Startup/ApplicationConfiguration.cs | 32 ++++ .../Startup/BroadcastEventHandler.cs | 15 ++ .../Startup/ScheduledJobsConfiguration.cs | 21 +++ .../Startup/ServiceCollectionExtensions.cs | 89 +++++++++++ DysonNetwork.Wallet/appsettings.json | 9 ++ DysonNetwork.Zone/DysonNetwork.Zone.csproj | 1 + 9 files changed, 396 insertions(+) create mode 100644 DysonNetwork.Wallet/AppDatabase.cs create mode 100644 DysonNetwork.Wallet/DysonNetwork.Wallet.csproj create mode 100644 DysonNetwork.Wallet/Program.cs create mode 100644 DysonNetwork.Wallet/Startup/ApplicationConfiguration.cs create mode 100644 DysonNetwork.Wallet/Startup/BroadcastEventHandler.cs create mode 100644 DysonNetwork.Wallet/Startup/ScheduledJobsConfiguration.cs create mode 100644 DysonNetwork.Wallet/Startup/ServiceCollectionExtensions.cs create mode 100644 DysonNetwork.Wallet/appsettings.json diff --git a/DysonNetwork.Wallet/AppDatabase.cs b/DysonNetwork.Wallet/AppDatabase.cs new file mode 100644 index 00000000..33a9fc91 --- /dev/null +++ b/DysonNetwork.Wallet/AppDatabase.cs @@ -0,0 +1,151 @@ +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Serialization; +using DysonNetwork.Shared.Data; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Wallet; + +public class AppDatabase( + DbContextOptions options, + IConfiguration configuration +) : DbContext(options) +{ + public DbSet Wallets { get; set; } = null!; + public DbSet WalletPockets { get; set; } = null!; + public DbSet PaymentOrders { get; set; } = null!; + public DbSet PaymentTransactions { get; set; } = null!; + public DbSet WalletFunds { get; set; } = null!; + public DbSet WalletFundRecipients { get; set; } = null!; + public DbSet WalletSubscriptions { get; set; } = null!; + public DbSet WalletGifts { get; set; } = null!; + public DbSet WalletCoupons { get; set; } = null!; + + public DbSet Lotteries { get; set; } = null!; + public DbSet LotteryRecords { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql( + configuration.GetConnectionString("App"), + opt => opt + .ConfigureDataSource(optSource => optSource + .EnableDynamicJson() + .ConfigureJsonOptions(new JsonSerializerOptions() + { + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + }) + ) + .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) + .UseNodaTime() + ).UseSnakeCaseNamingConvention(); + + base.OnConfiguring(optionsBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasKey(pg => new { pg.GroupId, pg.Actor }); + modelBuilder.Entity() + .HasOne(pg => pg.Group) + .WithMany(g => g.Members) + .HasForeignKey(pg => pg.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId }); + modelBuilder.Entity() + .HasOne(r => r.Account) + .WithMany(a => a.OutgoingRelationships) + .HasForeignKey(r => r.AccountId); + modelBuilder.Entity() + .HasOne(r => r.Related) + .WithMany(a => a.IncomingRelationships) + .HasForeignKey(r => r.RelatedId); + + modelBuilder.Entity() + .HasKey(pm => new { pm.RealmId, pm.AccountId }); + modelBuilder.Entity() + .HasOne(pm => pm.Realm) + .WithMany(p => p.Members) + .HasForeignKey(pm => pm.RealmId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.ApplySoftDeleteFilters(); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + this.ApplyAuditableAndSoftDelete(); + return await base.SaveChangesAsync(cancellationToken); + } +} + +public class AppDatabaseRecyclingJob(AppDatabase db, ILogger logger) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + logger.LogInformation("Deleting soft-deleted records..."); + + var threshold = now - Duration.FromDays(7); + + var entityTypes = db.Model.GetEntityTypes() + .Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase)) + .Select(t => t.ClrType); + + foreach (var entityType in entityTypes) + { + var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)! + .MakeGenericMethod(entityType).Invoke(db, null)!; + var parameter = Expression.Parameter(entityType, "e"); + var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt)); + var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?))); + var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?))); + var finalCondition = Expression.AndAlso(notNull, condition); + var lambda = Expression.Lambda(finalCondition, parameter); + + var queryable = set.Provider.CreateQuery( + Expression.Call( + typeof(Queryable), + "Where", + [entityType], + set.Expression, + Expression.Quote(lambda) + ) + ); + + var toListAsync = typeof(EntityFrameworkQueryableExtensions) + .GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))! + .MakeGenericMethod(entityType); + + var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!; + db.RemoveRange(items); + } + + await db.SaveChangesAsync(); + } +} + +public class AppDatabaseFactory : IDesignTimeDbContextFactory +{ + public AppDatabase CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + return new AppDatabase(optionsBuilder.Options, configuration); + } +} + diff --git a/DysonNetwork.Wallet/DysonNetwork.Wallet.csproj b/DysonNetwork.Wallet/DysonNetwork.Wallet.csproj new file mode 100644 index 00000000..f2508f7c --- /dev/null +++ b/DysonNetwork.Wallet/DysonNetwork.Wallet.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + True + Linux + + + + + + + + + + + + .dockerignore + + + + + + + + diff --git a/DysonNetwork.Wallet/Program.cs b/DysonNetwork.Wallet/Program.cs new file mode 100644 index 00000000..655f981a --- /dev/null +++ b/DysonNetwork.Wallet/Program.cs @@ -0,0 +1,50 @@ +using DysonNetwork.Shared.Auth; +using DysonNetwork.Shared.Networking; +using DysonNetwork.Shared.Registry; +using DysonNetwork.Wallet; +using DysonNetwork.Wallet.Startup; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Configure Kestrel and server options +builder.ConfigureAppKestrel(builder.Configuration); + +// Add application services +builder.Services.AddAppServices(builder.Configuration); +builder.Services.AddDysonAuth(); +builder.Services.AddRingService(); +builder.Services.AddDriveService(); +builder.Services.AddDevelopService(); + +builder.Services.AddAppFlushHandlers(); +builder.Services.AddAppBusinessServices(builder.Configuration); +builder.Services.AddAppScheduledJobs(); + +builder.AddSwaggerManifest( + "DysonNetwork.Wallet", + "The payment service of the Solar Network." +); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Run database migrations +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +// Configure application middleware pipeline +app.ConfigureAppMiddleware(builder.Configuration); + +// Configure gRPC +app.ConfigureGrpcServices(); + +app.UseSwaggerManifest("DysonNetwork.Wallet"); + +app.Run(); \ No newline at end of file diff --git a/DysonNetwork.Wallet/Startup/ApplicationConfiguration.cs b/DysonNetwork.Wallet/Startup/ApplicationConfiguration.cs new file mode 100644 index 00000000..78998e7a --- /dev/null +++ b/DysonNetwork.Wallet/Startup/ApplicationConfiguration.cs @@ -0,0 +1,32 @@ +using DysonNetwork.Shared.Auth; +using DysonNetwork.Shared.Networking; + +namespace DysonNetwork.Wallet.Startup; + +public static class ApplicationConfiguration +{ + public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration) + { + app.MapOpenApi(); + + app.UseRequestLocalization(); + + app.ConfigureForwardedHeaders(configuration); + + app.UseWebSockets(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseMiddleware(); + + app.MapControllers().RequireRateLimiting("fixed"); + + return app; + } + + public static WebApplication ConfigureGrpcServices(this WebApplication app) + { + app.MapGrpcReflectionService(); + + return app; + } +} diff --git a/DysonNetwork.Wallet/Startup/BroadcastEventHandler.cs b/DysonNetwork.Wallet/Startup/BroadcastEventHandler.cs new file mode 100644 index 00000000..40a8909b --- /dev/null +++ b/DysonNetwork.Wallet/Startup/BroadcastEventHandler.cs @@ -0,0 +1,15 @@ +using NATS.Client.Core; + +namespace DysonNetwork.Wallet.Startup; + +public class BroadcastEventHandler( + INatsConnection nats, + ILogger logger, + IServiceProvider serviceProvider +) : BackgroundService +{ + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + return Task.CompletedTask; + } +} diff --git a/DysonNetwork.Wallet/Startup/ScheduledJobsConfiguration.cs b/DysonNetwork.Wallet/Startup/ScheduledJobsConfiguration.cs new file mode 100644 index 00000000..8cad6182 --- /dev/null +++ b/DysonNetwork.Wallet/Startup/ScheduledJobsConfiguration.cs @@ -0,0 +1,21 @@ +using Quartz; + +namespace DysonNetwork.Wallet.Startup; + +public static class ScheduledJobsConfiguration +{ + public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services) + { + services.AddQuartz(q => + { + q.AddJob(opts => opts.WithIdentity("AppDatabaseRecycling")); + q.AddTrigger(opts => opts + .ForJob("AppDatabaseRecycling") + .WithIdentity("AppDatabaseRecyclingTrigger") + .WithCronSchedule("0 0 0 * * ?")); + }); + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); + + return services; + } +} diff --git a/DysonNetwork.Wallet/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Wallet/Startup/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..fc4bcc84 --- /dev/null +++ b/DysonNetwork.Wallet/Startup/ServiceCollectionExtensions.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using Microsoft.AspNetCore.RateLimiting; +using NodaTime; +using NodaTime.Serialization.SystemTextJson; +using System.Text.Json; +using System.Text.Json.Serialization; +using DysonNetwork.Shared.Cache; +using DysonNetwork.Shared.Geometry; +using DysonNetwork.Shared.Registry; + +namespace DysonNetwork.Wallet.Startup; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddLocalization(options => options.ResourcesPath = "Resources"); + + services.AddDbContext(); + services.AddHttpContextAccessor(); + + services.AddHttpClient(); + + // Register gRPC services + services.AddGrpc(options => + { + options.EnableDetailedErrors = true; // Will be adjusted in Program.cs + options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB + options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB + }); + services.AddGrpcReflection(); + + services.AddRingService(); + + services.AddControllers().AddJsonOptions(options => + { + options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; + options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower; + + options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); + }); + services.AddRazorPages(); + + // Configure rate limiting + services.AddRateLimiter(options => + { + options.AddFixedWindowLimiter("captcha", opt => + { + opt.Window = TimeSpan.FromMinutes(1); + opt.PermitLimit = 5; // 5 attempts per minute + opt.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst; + opt.QueueLimit = 2; + }); + }); + + services.Configure(options => + { + var supportedCultures = new[] + { + new CultureInfo("en-US"), + new CultureInfo("zh-Hans"), + }; + + options.SupportedCultures = supportedCultures; + options.SupportedUICultures = supportedCultures; + }); + + return services; + } + + public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } + + public static IServiceCollection AddAppBusinessServices(this IServiceCollection services, + IConfiguration configuration) + { + services.Configure(configuration.GetSection("GeoIP")); + services.AddScoped(); + + services.AddHostedService(); + + return services; + } +} diff --git a/DysonNetwork.Wallet/appsettings.json b/DysonNetwork.Wallet/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/DysonNetwork.Wallet/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/DysonNetwork.Zone/DysonNetwork.Zone.csproj b/DysonNetwork.Zone/DysonNetwork.Zone.csproj index b53d2033..7c5ca312 100644 --- a/DysonNetwork.Zone/DysonNetwork.Zone.csproj +++ b/DysonNetwork.Zone/DysonNetwork.Zone.csproj @@ -28,6 +28,7 @@ +