Compare commits
3 Commits
ebac6698ff
...
34902d0486
Author | SHA1 | Date | |
---|---|---|---|
34902d0486 | |||
ffb3f83b96 | |||
2e09e63022 |
@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using DysonNetwork.Sphere.Wallet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using OtpNet;
|
||||
@ -22,13 +23,15 @@ 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>();
|
||||
|
||||
|
||||
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
|
||||
[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<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
|
||||
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
|
||||
|
||||
[JsonIgnore] public ICollection<Subscription> Subscriptions { get; set; } = new List<Subscription>();
|
||||
}
|
||||
|
||||
public abstract class Leveling
|
||||
@ -69,6 +72,7 @@ public class Profile : ModelBase
|
||||
|
||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
|
||||
[Column(TypeName = "jsonb")] public SubscriptionReferenceObject? StellarMembership { get; set; }
|
||||
|
||||
public int Experience { get; set; } = 0;
|
||||
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Linq.Expressions;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
using DysonNetwork.Sphere.Wallet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
@ -81,6 +82,9 @@ public class AppDatabase(
|
||||
public DbSet<Developer.CustomApp> CustomApps { get; set; }
|
||||
public DbSet<Developer.CustomAppSecret> CustomAppSecrets { get; set; }
|
||||
|
||||
public DbSet<Subscription> WalletSubscriptions { get; set; }
|
||||
public DbSet<Coupon> WalletCoupons { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(
|
||||
|
3550
DysonNetwork.Sphere/Migrations/20250611165902_BetterRecyclingFilesAndWalletSubscriptions.Designer.cs
generated
Normal file
3550
DysonNetwork.Sphere/Migrations/20250611165902_BetterRecyclingFilesAndWalletSubscriptions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using DysonNetwork.Sphere.Wallet;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BetterRecyclingFilesAndWalletSubscriptions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_marked_recycle",
|
||||
table: "files",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "location",
|
||||
table: "account_profiles",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<SubscriptionReferenceObject>(
|
||||
name: "stellar_membership",
|
||||
table: "account_profiles",
|
||||
type: "jsonb",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "time_zone",
|
||||
table: "account_profiles",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wallet_coupons",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||
code = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
discount_amount = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
discount_rate = table.Column<double>(type: "double precision", nullable: true),
|
||||
max_usage = table.Column<int>(type: "integer", nullable: true),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_wallet_coupons", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "wallet_subscriptions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
begun_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
ended_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_free_trial = table.Column<bool>(type: "boolean", nullable: false),
|
||||
status = table.Column<int>(type: "integer", nullable: false),
|
||||
payment_method = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||
payment_details = table.Column<PaymentDetails>(type: "jsonb", nullable: false),
|
||||
base_price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
coupon_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
renewal_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_wallet_subscriptions", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_subscriptions_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_wallet_subscriptions_wallet_coupons_coupon_id",
|
||||
column: x => x.coupon_id,
|
||||
principalTable: "wallet_coupons",
|
||||
principalColumn: "id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_subscriptions_account_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_subscriptions_coupon_id",
|
||||
table: "wallet_subscriptions",
|
||||
column: "coupon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_wallet_subscriptions_identifier",
|
||||
table: "wallet_subscriptions",
|
||||
column: "identifier");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "wallet_subscriptions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "wallet_coupons");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_marked_recycle",
|
||||
table: "files");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "location",
|
||||
table: "account_profiles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "stellar_membership",
|
||||
table: "account_profiles");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "time_zone",
|
||||
table: "account_profiles");
|
||||
}
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ using DysonNetwork.Sphere;
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using DysonNetwork.Sphere.Chat;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using DysonNetwork.Sphere.Wallet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@ -611,6 +612,11 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<string>("MiddleName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
@ -630,6 +636,15 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("pronouns");
|
||||
|
||||
b.Property<SubscriptionReferenceObject>("StellarMembership")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("stellar_membership");
|
||||
|
||||
b.Property<string>("TimeZone")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("time_zone");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@ -2238,6 +2253,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasColumnName("hash");
|
||||
|
||||
b.Property<bool>("IsMarkedRecycle")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_marked_recycle");
|
||||
|
||||
b.Property<Guid?>("MessageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("message_id");
|
||||
@ -2357,6 +2376,61 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.ToTable("file_references", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Coupon", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Instant?>("AffectedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("affected_at");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<decimal?>("DiscountAmount")
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("discount_amount");
|
||||
|
||||
b.Property<double?>("DiscountRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("discount_rate");
|
||||
|
||||
b.Property<Instant?>("ExpiredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expired_at");
|
||||
|
||||
b.Property<string>("Identifier")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("identifier");
|
||||
|
||||
b.Property<int?>("MaxUsage")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_usage");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_wallet_coupons");
|
||||
|
||||
b.ToTable("wallet_coupons", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Order", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -2426,6 +2500,93 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.ToTable("payment_orders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Subscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("account_id");
|
||||
|
||||
b.Property<decimal>("BasePrice")
|
||||
.HasColumnType("numeric")
|
||||
.HasColumnName("base_price");
|
||||
|
||||
b.Property<Instant>("BegunAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("begun_at");
|
||||
|
||||
b.Property<Guid?>("CouponId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("coupon_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Instant?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at");
|
||||
|
||||
b.Property<Instant?>("EndedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("ended_at");
|
||||
|
||||
b.Property<string>("Identifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("identifier");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<bool>("IsFreeTrial")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_free_trial");
|
||||
|
||||
b.Property<PaymentDetails>("PaymentDetails")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("payment_details");
|
||||
|
||||
b.Property<string>("PaymentMethod")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)")
|
||||
.HasColumnName("payment_method");
|
||||
|
||||
b.Property<Instant?>("RenewalAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("renewal_at");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_wallet_subscriptions");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_wallet_subscriptions_account_id");
|
||||
|
||||
b.HasIndex("CouponId")
|
||||
.HasDatabaseName("ix_wallet_subscriptions_coupon_id");
|
||||
|
||||
b.HasIndex("Identifier")
|
||||
.HasDatabaseName("ix_wallet_subscriptions_identifier");
|
||||
|
||||
b.ToTable("wallet_subscriptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Transaction", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -3199,6 +3360,25 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.Navigation("Transaction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Subscription", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_wallet_subscriptions_accounts_account_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Sphere.Wallet.Coupon", "Coupon")
|
||||
.WithMany()
|
||||
.HasForeignKey("CouponId")
|
||||
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Coupon");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Transaction", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet")
|
||||
@ -3309,6 +3489,8 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Sessions");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Chat.ChatRoom", b =>
|
||||
|
@ -53,6 +53,12 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
||||
public Instant? UploadedAt { get; set; }
|
||||
[MaxLength(128)] public string? UploadedTo { get; set; }
|
||||
public bool HasCompression { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// The field is set to true if the recycling job plans to delete the file.
|
||||
/// Due to the unstable of the recycling job, this doesn't really delete the file until a human verifies it.
|
||||
/// </summary>
|
||||
public bool IsMarkedRecycle { get; set; } = false;
|
||||
|
||||
/// The object name which stored remotely,
|
||||
/// multiple cloud file may have same storage id to indicate they are the same file
|
||||
|
@ -14,115 +14,81 @@ public class CloudFileUnusedRecyclingJob(
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
return;
|
||||
logger.LogInformation("Deleting unused cloud files...");
|
||||
logger.LogInformation("Marking unused cloud files...");
|
||||
|
||||
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
const int batchSize = 1000; // Process larger batches for efficiency
|
||||
var processedCount = 0;
|
||||
var markedCount = 0;
|
||||
var totalFiles = await db.Files.Where(f => !f.IsMarkedRecycle).CountAsync();
|
||||
|
||||
// Get files that are either expired or created more than an hour ago
|
||||
var fileIds = await db.Files
|
||||
.Select(f => f.Id)
|
||||
.ToListAsync();
|
||||
logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles);
|
||||
|
||||
// Filter to only include files that have no references or all references have expired
|
||||
var deletionPlan = new List<string>();
|
||||
foreach (var batch in fileIds.Chunk(100)) // Process in batches to avoid excessive query size
|
||||
// Define a timestamp to limit the age of files we're processing in this run
|
||||
// This spreads the processing across multiple job runs for very large databases
|
||||
var ageThreshold = now - Duration.FromDays(30); // Process files up to 90 days old in this run
|
||||
|
||||
// Instead of loading all files at once, use pagination
|
||||
var hasMoreFiles = true;
|
||||
string? lastProcessedId = null;
|
||||
|
||||
while (hasMoreFiles)
|
||||
{
|
||||
var references = await fileRefService.GetReferencesAsync(batch);
|
||||
deletionPlan.AddRange(from refer in references
|
||||
where refer.Value.Count == 0 || refer.Value.All(r => r.ExpiredAt != null && now >= r.ExpiredAt)
|
||||
select refer.Key);
|
||||
}
|
||||
// Query for the next batch of files using keyset pagination
|
||||
var filesQuery = db.Files
|
||||
.Where(f => !f.IsMarkedRecycle)
|
||||
.Where(f => f.CreatedAt <= ageThreshold); // Only process older files first
|
||||
|
||||
if (deletionPlan.Count == 0)
|
||||
{
|
||||
logger.LogInformation("No files to delete");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the actual file objects for the files to be deleted
|
||||
var files = await db.Files
|
||||
.Where(f => deletionPlan.Contains(f.Id))
|
||||
.ToListAsync();
|
||||
|
||||
logger.LogInformation($"Found {files.Count} files to delete...");
|
||||
|
||||
// Group files by StorageId and find which ones are safe to delete
|
||||
var storageIds = files.Where(f => f.StorageId != null)
|
||||
.Select(f => f.StorageId!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// Check if any other files with the same storage IDs are referenced
|
||||
var usedStorageIds = new List<string>();
|
||||
var filesWithSameStorageId = await db.Files
|
||||
.Where(f => f.StorageId != null &&
|
||||
storageIds.Contains(f.StorageId) &&
|
||||
!files.Select(ff => ff.Id).Contains(f.Id))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var file in filesWithSameStorageId)
|
||||
{
|
||||
// Get all references for the file
|
||||
var references = await fileRefService.GetReferencesAsync(file.Id);
|
||||
|
||||
// Check if file has active references (non-expired)
|
||||
if (references.Any(r => r.ExpiredAt == null || r.ExpiredAt > now) && file.StorageId != null)
|
||||
if (lastProcessedId != null)
|
||||
{
|
||||
usedStorageIds.Add(file.StorageId);
|
||||
filesQuery = filesQuery.Where(f => string.Compare(f.Id, lastProcessedId) > 0);
|
||||
}
|
||||
|
||||
var fileBatch = await filesQuery
|
||||
.OrderBy(f => f.Id) // Ensure consistent ordering for pagination
|
||||
.Take(batchSize)
|
||||
.Select(f => f.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (fileBatch.Count == 0)
|
||||
{
|
||||
hasMoreFiles = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
processedCount += fileBatch.Count;
|
||||
lastProcessedId = fileBatch.Last();
|
||||
|
||||
// Get all relevant file references for this batch
|
||||
var fileReferences = await fileRefService.GetReferencesAsync(fileBatch);
|
||||
|
||||
// Filter to find files that have no references or all expired references
|
||||
var filesToMark = fileBatch.Where(fileId =>
|
||||
!fileReferences.TryGetValue(fileId, out var references) ||
|
||||
references.Count == 0 ||
|
||||
references.All(r => r.ExpiredAt.HasValue && r.ExpiredAt.Value <= now)
|
||||
).ToList();
|
||||
|
||||
if (filesToMark.Count > 0)
|
||||
{
|
||||
// Use a bulk update for better performance - mark all qualifying files at once
|
||||
var updateCount = await db.Files
|
||||
.Where(f => filesToMark.Contains(f.Id))
|
||||
.ExecuteUpdateAsync(setter => setter
|
||||
.SetProperty(f => f.IsMarkedRecycle, true));
|
||||
|
||||
markedCount += updateCount;
|
||||
}
|
||||
|
||||
// Log progress periodically
|
||||
if (processedCount % 10000 == 0 || !hasMoreFiles)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Progress: processed {ProcessedCount}/{TotalFiles} files, marked {MarkedCount} for recycling",
|
||||
processedCount, totalFiles, markedCount);
|
||||
}
|
||||
}
|
||||
|
||||
// Group files for deletion
|
||||
var filesToDelete = files.Where(f => f.StorageId == null || !usedStorageIds.Contains(f.StorageId))
|
||||
.GroupBy(f => f.UploadedTo)
|
||||
.ToDictionary(grouping => grouping.Key!, grouping => grouping.ToList());
|
||||
|
||||
// Delete files by remote storage
|
||||
foreach (var group in filesToDelete.Where(group => !string.IsNullOrEmpty(group.Key)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dest = fs.GetRemoteStorageConfig(group.Key);
|
||||
var client = fs.CreateMinioClient(dest);
|
||||
if (client == null) continue;
|
||||
|
||||
// Create delete tasks for each file in the group
|
||||
// var deleteTasks = group.Value.Select(file =>
|
||||
// {
|
||||
// var objectId = file.StorageId ?? file.Id;
|
||||
// var tasks = new List<Task>
|
||||
// {
|
||||
// client.RemoveObjectAsync(new Minio.DataModel.Args.RemoveObjectArgs()
|
||||
// .WithBucket(dest.Bucket)
|
||||
// .WithObject(objectId))
|
||||
// };
|
||||
//
|
||||
// if (file.HasCompression)
|
||||
// {
|
||||
// tasks.Add(client.RemoveObjectAsync(new Minio.DataModel.Args.RemoveObjectArgs()
|
||||
// .WithBucket(dest.Bucket)
|
||||
// .WithObject(objectId + ".compressed")));
|
||||
// }
|
||||
//
|
||||
// return Task.WhenAll(tasks);
|
||||
// });
|
||||
//
|
||||
// await Task.WhenAll(deleteTasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting files from remote storage {remote}", group.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all file records from the database
|
||||
var fileIdsToDelete = files.Select(f => f.Id).ToList();
|
||||
await db.Files
|
||||
.Where(f => fileIdsToDelete.Contains(f.Id))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
logger.LogInformation($"Completed deleting {files.Count} files");
|
||||
logger.LogInformation("Completed marking {MarkedCount} files for recycling", markedCount);
|
||||
}
|
||||
}
|
@ -36,9 +36,7 @@ public class FileController(
|
||||
var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId;
|
||||
|
||||
if (!original && file.HasCompression)
|
||||
{
|
||||
fileName += ".compressed";
|
||||
}
|
||||
|
||||
if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false))
|
||||
{
|
||||
|
206
DysonNetwork.Sphere/Wallet/Subscription.cs
Normal file
206
DysonNetwork.Sphere/Wallet/Subscription.cs
Normal file
@ -0,0 +1,206 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
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,
|
||||
Paid,
|
||||
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.Paid) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[NotMapped]
|
||||
public decimal FinalPrice
|
||||
{
|
||||
get
|
||||
{
|
||||
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 SubscriptionReferenceObject ToReference()
|
||||
{
|
||||
return new SubscriptionReferenceObject
|
||||
{
|
||||
Id = Id,
|
||||
BegunAt = BegunAt,
|
||||
EndedAt = EndedAt,
|
||||
Identifier = Identifier,
|
||||
IsActive = IsActive,
|
||||
AccountId = AccountId,
|
||||
CreatedAt = CreatedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
DeletedAt = DeletedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class PaymentDetails
|
||||
{
|
||||
public string Currency { get; set; } = null!;
|
||||
public string? OrderId { get; set; }
|
||||
}
|
||||
|
||||
public class SubscriptionReferenceObject : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Instant BegunAt { get; set; }
|
||||
public Instant? EndedAt { get; set; }
|
||||
[MaxLength(4096)] public string Identifier { get; set; } = null!;
|
||||
public bool IsActive { get; set; } = true;
|
||||
public Guid AccountId { 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; }
|
||||
}
|
5
DysonNetwork.Sphere/Wallet/SubscriptionService.cs
Normal file
5
DysonNetwork.Sphere/Wallet/SubscriptionService.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace DysonNetwork.Sphere.Wallet;
|
||||
|
||||
public class SubscriptionService(AppDatabase db)
|
||||
{
|
||||
}
|
@ -54,6 +54,7 @@ public class WalletController(AppDatabase db, WalletService ws) : ControllerBase
|
||||
var transactions = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
Response.Headers["X-Total"] = transactionCount.ToString();
|
||||
|
@ -5,8 +5,12 @@ We open sourced it here to make everything transparent and open for everyone.
|
||||
|
||||
But it is not designed for self-hosted due to multiple reasons.
|
||||
|
||||
Still, you can deploy it on your own infrastructure.
|
||||
But we will not providing any support for that.
|
||||
1. Branding everywhere: The variables, classes and functions name are just defined to serve the Solar Network. I think you're not hope to see the Solar Network related branding on your own server.
|
||||
2. Hard coded URLs: Some services might use other Solsynth LLC's services which the URL is hard coded into the code and there is no alternative to it. Which means your server need stay connected with our services and the Internet.
|
||||
3. No documentation: The documentation is not available for self-hosted or guide to deploy it on your local machine.
|
||||
4. No support: We don't provide any support for self-hosted.
|
||||
|
||||
Still, you can deploy it on your own infrastructure if you want, we didn't disallow it.
|
||||
|
||||
Besides, according to the APGL v3 license,
|
||||
if you host a modified version of the software,
|
||||
|
Reference in New Issue
Block a user