Subscription service

This commit is contained in:
LittleSheep 2025-06-22 03:15:16 +08:00
parent 516090a5f8
commit 9fd6016308
9 changed files with 4102 additions and 12 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class WalletOrderAppDX : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders");
migrationBuilder.AlterColumn<Guid>(
name: "payee_wallet_id",
table: "payment_orders",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<string>(
name: "app_identifier",
table: "payment_orders",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<Dictionary<string, object>>(
name: "meta",
table: "payment_orders",
type: "jsonb",
nullable: true);
migrationBuilder.AddForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders",
column: "payee_wallet_id",
principalTable: "wallets",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders");
migrationBuilder.DropColumn(
name: "app_identifier",
table: "payment_orders");
migrationBuilder.DropColumn(
name: "meta",
table: "payment_orders");
migrationBuilder.AlterColumn<Guid>(
name: "payee_wallet_id",
table: "payment_orders",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
table: "payment_orders",
column: "payee_wallet_id",
principalTable: "wallets",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@ -2504,6 +2504,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("numeric") .HasColumnType("numeric")
.HasColumnName("amount"); .HasColumnName("amount");
b.Property<string>("AppIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("app_identifier");
b.Property<Instant>("CreatedAt") b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created_at"); .HasColumnName("created_at");
@ -2526,7 +2531,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("issuer_app_id"); .HasColumnName("issuer_app_id");
b.Property<Guid>("PayeeWalletId") b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<Guid?>("PayeeWalletId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("payee_wallet_id"); .HasColumnName("payee_wallet_id");
@ -3418,8 +3427,6 @@ namespace DysonNetwork.Sphere.Migrations
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet") b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet")
.WithMany() .WithMany()
.HasForeignKey("PayeeWalletId") .HasForeignKey("PayeeWalletId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_payment_orders_wallets_payee_wallet_id"); .HasConstraintName("fk_payment_orders_wallets_payee_wallet_id");
b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction") b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction")

View File

@ -205,8 +205,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<MagicSpellService>(); services.AddScoped<MagicSpellService>();
services.AddScoped<NotificationService>(); services.AddScoped<NotificationService>();
services.AddScoped<AuthService>(); services.AddScoped<AuthService>();
services.AddScoped<AppleOidcService>();
services.AddScoped<GoogleOidcService>();
services.AddScoped<AccountUsernameService>(); services.AddScoped<AccountUsernameService>();
services.AddScoped<FileService>(); services.AddScoped<FileService>();
services.AddScoped<FileReferenceService>(); services.AddScoped<FileReferenceService>();
@ -220,6 +218,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ChatService>(); services.AddScoped<ChatService>();
services.AddScoped<StickerService>(); services.AddScoped<StickerService>();
services.AddScoped<WalletService>(); services.AddScoped<WalletService>();
services.AddScoped<SubscriptionService>();
services.AddScoped<PaymentService>(); services.AddScoped<PaymentService>();
services.AddScoped<IRealtimeService, LivekitRealtimeService>(); services.AddScoped<IRealtimeService, LivekitRealtimeService>();
services.AddScoped<WebReaderService>(); services.AddScoped<WebReaderService>();

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Sphere.Developer; using DysonNetwork.Sphere.Developer;
using NodaTime; using NodaTime;
@ -7,6 +8,7 @@ namespace DysonNetwork.Sphere.Wallet;
public class WalletCurrency public class WalletCurrency
{ {
public const string SourcePoint = "points"; public const string SourcePoint = "points";
public const string GoldenPoint = "golds";
} }
public enum OrderStatus public enum OrderStatus
@ -24,11 +26,13 @@ public class Order : ModelBase
public OrderStatus Status { get; set; } = OrderStatus.Unpaid; public OrderStatus Status { get; set; } = OrderStatus.Unpaid;
[MaxLength(128)] public string Currency { get; set; } = null!; [MaxLength(128)] public string Currency { get; set; } = null!;
[MaxLength(4096)] public string? Remarks { get; set; } [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 decimal Amount { get; set; }
public Instant ExpiredAt { get; set; } public Instant ExpiredAt { get; set; }
public Guid PayeeWalletId { get; set; } public Guid? PayeeWalletId { get; set; }
public Wallet PayeeWallet { get; set; } = null!; public Wallet? PayeeWallet { get; set; } = null!;
public Guid? TransactionId { get; set; } public Guid? TransactionId { get; set; }
public Transaction? Transaction { get; set; } public Transaction? Transaction { get; set; }
public Guid? IssuerAppId { get; set; } public Guid? IssuerAppId { get; set; }

View File

@ -6,14 +6,23 @@ namespace DysonNetwork.Sphere.Wallet;
public class PaymentService(AppDatabase db, WalletService wat) public class PaymentService(AppDatabase db, WalletService wat)
{ {
public async Task<Order> CreateOrderAsync(Guid payeeWalletId, string currency, decimal amount, Duration expiration) public async Task<Order> CreateOrderAsync(
Guid? payeeWalletId,
string currency,
decimal amount,
Duration? expiration = null,
string? appIdentifier = null,
Dictionary<string, object>? meta = null
)
{ {
var order = new Order var order = new Order
{ {
PayeeWalletId = payeeWalletId, PayeeWalletId = payeeWalletId,
Currency = currency, Currency = currency,
Amount = amount, Amount = amount,
ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration) ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)),
AppIdentifier = appIdentifier,
Meta = meta
}; };
db.PaymentOrders.Add(order); db.PaymentOrders.Add(order);

View File

@ -5,6 +5,21 @@ using NodaTime;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Sphere.Wallet;
public record class SubscriptionTypeData(
string Identifier,
decimal BasePrice
)
{
public static Dictionary<string, SubscriptionTypeData> SubscriptionDict =
new()
{
[SubscriptionType.Twinkle] = new SubscriptionTypeData(SubscriptionType.Twinkle, 0),
[SubscriptionType.Stellar] = new SubscriptionTypeData(SubscriptionType.Stellar, 10),
[SubscriptionType.Nova] = new SubscriptionTypeData(SubscriptionType.Nova, 20),
[SubscriptionType.Supernova] = new SubscriptionTypeData(SubscriptionType.Supernova, 30)
};
}
public abstract class SubscriptionType public abstract class SubscriptionType
{ {
/// <summary> /// <summary>

View File

@ -0,0 +1,155 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.Wallet;
[ApiController]
[Route("/subscriptions")]
public class SubscriptionController(SubscriptionService subscriptions, 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("{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)
{
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
);
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 string OrderId { get; set; } = null!;
}
[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);
}
}
}

View File

@ -1,5 +1,191 @@
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Sphere.Wallet;
public class SubscriptionService(AppDatabase db) public class SubscriptionService(AppDatabase db, PaymentService payment, ICacheService cache)
{ {
public async Task<Subscription> CreateSubscriptionAsync(
Account.Account account,
string identifier,
string paymentMethod,
PaymentDetails paymentDetails,
Duration? cycleDuration = null,
string? coupon = null,
bool isFreeTrail = false,
bool isAutoRenewal = true
)
{
var subscriptionTemplate = SubscriptionTypeData
.SubscriptionDict.TryGetValue(identifier, out var template)
? template
: null;
if (subscriptionTemplate is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
cycleDuration ??= Duration.FromDays(30);
var existingSubscription = await GetSubscriptionAsync(account.Id, identifier);
if (existingSubscription is not null)
{
throw new InvalidOperationException($"Active subscription with identifier {identifier} 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 = isFreeTrail,
Status = SubscriptionStatus.Unpaid,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
BasePrice = subscriptionTemplate.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
RenewalAt = (isFreeTrail || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
AccountId = account.Id,
};
db.WalletSubscriptions.Add(subscription);
await db.SaveChangesAsync();
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.Cancelled)
throw new InvalidOperationException("Subscription is already cancelled.");
subscription.Status = SubscriptionStatus.Cancelled;
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.");
return await payment.CreateOrderAsync(
null,
WalletCurrency.GoldenPoint,
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 string subscriptionId)
throw new InvalidOperationException("Invalid order.");
var subscriptionIdParsed = Guid.TryParse(subscriptionId, out var parsedSubscriptionId)
? parsedSubscriptionId
: Guid.Empty;
if (subscriptionIdParsed == Guid.Empty)
throw new InvalidOperationException("Invalid order.");
var subscription = await db.WalletSubscriptions
.Where(s => s.Id == subscriptionIdParsed)
.Include(s => s.Coupon)
.FirstOrDefaultAsync();
if (subscription is null)
throw new InvalidOperationException("Invalid order.");
var now = SystemClock.Instance.GetCurrentInstant();
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? now);
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle);
var nextEndedAt = subscription.RenewalAt?.Plus(cycle);
subscription.Status = SubscriptionStatus.Paid;
subscription.RenewalAt = nextRenewalAt;
subscription.EndedAt = nextEndedAt;
db.Update(subscription);
await db.SaveChangesAsync();
return subscription;
}
private const string SubscriptionCacheKeyPrefix = "subscription:";
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier)
{
// Create a unique cache key for this subscription
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}";
// 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 && s.Identifier == 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;
}
} }