Compare commits

...

3 Commits

Author SHA1 Message Date
34902d0486 🗃️ Migrate previous changes 2025-06-12 01:01:52 +08:00
ffb3f83b96 Add the cloud file recycling job back to online with data safety. 2025-06-12 00:58:16 +08:00
2e09e63022 🗃️ Subscriptions modeling 2025-06-12 00:48:38 +08:00
12 changed files with 4174 additions and 105 deletions

View File

@ -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;
@ -29,6 +30,8 @@ public class Account : ModelBase
[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;

View File

@ -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(

View File

@ -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");
}
}
}

View File

@ -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 =>

View File

@ -54,6 +54,12 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
[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
///

View 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
logger.LogInformation("Found {TotalFiles} files to check for unused status", totalFiles);
// 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)
{
// 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 (lastProcessedId != null)
{
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();
// 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
if (fileBatch.Count == 0)
{
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);
hasMoreFiles = false;
continue;
}
if (deletionPlan.Count == 0)
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)
{
logger.LogInformation("No files to delete");
return;
// 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;
}
// 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)
// Log progress periodically
if (processedCount % 10000 == 0 || !hasMoreFiles)
{
// 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)
{
usedStorageIds.Add(file.StorageId);
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);
}
}

View File

@ -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))
{

View 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; }
}

View File

@ -0,0 +1,5 @@
namespace DysonNetwork.Sphere.Wallet;
public class SubscriptionService(AppDatabase db)
{
}

View File

@ -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();

View File

@ -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,