♻️ Refactored order handling

This commit is contained in:
2025-09-05 00:13:58 +08:00
parent 3ee04d0b24
commit ddd109c77c
22 changed files with 2414 additions and 61 deletions

View File

@@ -14,7 +14,7 @@ public class BroadcastEventHandler(
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", cancellationToken: stoppingToken)) await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountDeletedEvent.Type, cancellationToken: stoppingToken))
{ {
try try
{ {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddOrderProductIdentifier : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "product_identifier",
table: "payment_orders",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "product_identifier",
table: "payment_orders");
}
}
}

View File

@@ -1381,6 +1381,11 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("payee_wallet_id"); .HasColumnName("payee_wallet_id");
b.Property<string>("ProductIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("product_identifier");
b.Property<string>("Remarks") b.Property<string>("Remarks")
.HasMaxLength(4096) .HasMaxLength(4096)
.HasColumnType("character varying(4096)") .HasColumnType("character varying(4096)")

View File

@@ -170,5 +170,47 @@ namespace DysonNetwork.Sphere.Resources.Localization {
return ResourceManager.GetString("NewLoginBody", resourceCulture); return ResourceManager.GetString("NewLoginBody", resourceCulture);
} }
} }
internal static string FriendRequestTitle {
get {
return ResourceManager.GetString("FriendRequestTitle", resourceCulture);
}
}
internal static string FriendRequestBody {
get {
return ResourceManager.GetString("FriendRequestBody", resourceCulture);
}
}
internal static string OrderReceivedTitle {
get {
return ResourceManager.GetString("OrderReceivedTitle", resourceCulture);
}
}
internal static string OrderReceivedBody {
get {
return ResourceManager.GetString("OrderReceivedBody", resourceCulture);
}
}
internal static string TransactionNewTitle {
get {
return ResourceManager.GetString("TransactionNewTitle", resourceCulture);
}
}
internal static string TransactionNewBodyPlus {
get {
return ResourceManager.GetString("TransactionNewBodyPlus", resourceCulture);
}
}
internal static string TransactionNewBodyMinus {
get {
return ResourceManager.GetString("TransactionNewBodyMinus", resourceCulture);
}
}
} }
} }

View File

@@ -78,7 +78,7 @@
<value>Order {0} recipent</value> <value>Order {0} recipent</value>
</data> </data>
<data name="OrderPaidBody" xml:space="preserve"> <data name="OrderPaidBody" xml:space="preserve">
<value>{0} {1} was removed from your wallet to pay {2}</value> <value>Paid order {2} with {0} {1}</value>
</data> </data>
<data name="NewLoginTitle" xml:space="preserve"> <data name="NewLoginTitle" xml:space="preserve">
<value>New login detected</value> <value>New login detected</value>
@@ -92,4 +92,19 @@
<data name="FriendRequestBody" xml:space="preserve"> <data name="FriendRequestBody" xml:space="preserve">
<value>You can go to relationships page and decide accept their request or not.</value> <value>You can go to relationships page and decide accept their request or not.</value>
</data> </data>
<data name="OrderReceivedTitle" xml:space="preserve">
<value>Order {0} recipent</value>
</data>
<data name="OrderReceivedBody" xml:space="preserve">
<value>Received {2} payment of {0} {1}</value>
</data>
<data name="TransactionNewTitle" xml:space="preserve">
<value>Transaction {0}</value>
</data>
<data name="TransactionNewBodyPlus" xml:space="preserve">
<value>{0} {1} added to your wallet</value>
</data>
<data name="TransactionNewBodyMinus" xml:space="preserve">
<value>{0} {1} removed from your wallet</value>
</data>
</root> </root>

View File

@@ -67,10 +67,10 @@
<value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value> <value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value>
</data> </data>
<data name="OrderPaidTitle" xml:space="preserve"> <data name="OrderPaidTitle" xml:space="preserve">
<value>订单回执 {0}</value> <value>订单收据 {0}</value>
</data> </data>
<data name="OrderPaidBody" xml:space="preserve"> <data name="OrderPaidBody" xml:space="preserve">
<value>{0} {1} 已从你的帐户中扣除来支付 {2}</value> <value>已支付订单 {2} 的 {0} {1}</value>
</data> </data>
<data name="NewLoginTitle" xml:space="preserve"> <data name="NewLoginTitle" xml:space="preserve">
<value>检测到新登陆</value> <value>检测到新登陆</value>
@@ -84,4 +84,19 @@
<data name="FriendRequestBody" xml:space="preserve"> <data name="FriendRequestBody" xml:space="preserve">
<value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value> <value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value>
</data> </data>
<data name="OrderReceivedTitle" xml:space="preserve">
<value>订单收据 {0}</value>
</data>
<data name="OrderReceivedBody" xml:space="preserve">
<value>收到订单 {2} 支付的 {0} {1}</value>
</data>
<data name="TransactionNewTitle" xml:space="preserve">
<value>交易 {0}</value>
</data>
<data name="TransactionNewBodyPlus" xml:space="preserve">
<value>{0} {1} 添加到了您的钱包</value>
</data>
<data name="TransactionNewBodyMinus" xml:space="preserve">
<value>{0} {1} 从您的钱包移除</value>
</data>
</root> </root>

View File

@@ -4,6 +4,7 @@ using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit; using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling; using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using DysonNetwork.Pass.Wallet;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using Prometheus; using Prometheus;
@@ -81,6 +82,8 @@ public static class ApplicationConfiguration
app.MapGrpcService<SocialCreditServiceGrpc>(); app.MapGrpcService<SocialCreditServiceGrpc>();
app.MapGrpcService<ExperienceServiceGrpc>(); app.MapGrpcService<ExperienceServiceGrpc>();
app.MapGrpcService<BotAccountReceiverGrpc>(); app.MapGrpcService<BotAccountReceiverGrpc>();
app.MapGrpcService<WalletServiceGrpc>();
app.MapGrpcService<PaymentServiceGrpc>();
return app; return app;
} }

View File

@@ -0,0 +1,34 @@
using System.Text.Json;
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Stream;
using NATS.Client.Core;
namespace DysonNetwork.Pass.Startup;
public class BroadcastEventHandler(
INatsConnection nats,
ILogger<BroadcastEventHandler> logger,
IServiceProvider serviceProvider
) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var msg in nats.SubscribeAsync<byte[]>(PaymentOrderEvent.Type, cancellationToken: stoppingToken))
{
try
{
var evt = JsonSerializer.Deserialize<PaymentOrderEvent>(msg.Data);
if (evt?.ProductIdentifier is null || !evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
continue;
logger.LogInformation("Stellar program order paid: {OrderId}", evt.OrderId);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing AccountDeleted");
}
}
}
}

View File

@@ -54,10 +54,6 @@ public static class ServiceCollectionExtensions
services.AddPusherService(); services.AddPusherService();
// Register gRPC services
services.AddScoped<AccountServiceGrpc>();
services.AddScoped<AuthServiceGrpc>();
// Register OIDC services // Register OIDC services
services.AddScoped<OidcService, GoogleOidcService>(); services.AddScoped<OidcService, GoogleOidcService>();
services.AddScoped<OidcService, AppleOidcService>(); services.AddScoped<OidcService, AppleOidcService>();

View File

@@ -15,9 +15,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
var order = await db.PaymentOrders.FindAsync(id); var order = await db.PaymentOrders.FindAsync(id);
if (order == null) if (order == null)
{
return NotFound(); return NotFound();
}
return Ok(order); return Ok(order);
} }
@@ -41,7 +39,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba
return BadRequest("Wallet was not found."); return BadRequest("Wallet was not found.");
// Pay the order // Pay the order
var paidOrder = await payment.PayOrderAsync(id, wallet.Id); var paidOrder = await payment.PayOrderAsync(id, wallet);
return Ok(paidOrder); return Ok(paidOrder);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)

View File

@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Globalization;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
using NodaTime; using NodaTime;
using NodaTime.Serialization.Protobuf; using NodaTime.Serialization.Protobuf;
@@ -23,11 +24,14 @@ public enum OrderStatus
public class Order : ModelBase public class Order : ModelBase
{ {
public const string InternalAppIdentifier = "internal";
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
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; } [MaxLength(4096)] public string? AppIdentifier { get; set; }
[MaxLength(4096)] public string? ProductIdentifier { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { 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; }
@@ -44,10 +48,11 @@ public class Order : ModelBase
Currency = Currency, Currency = Currency,
Remarks = Remarks, Remarks = Remarks,
AppIdentifier = AppIdentifier, AppIdentifier = AppIdentifier,
ProductIdentifier = ProductIdentifier,
Meta = Meta == null Meta = Meta == null
? null ? null
: Google.Protobuf.ByteString.CopyFrom(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Meta)), : Google.Protobuf.ByteString.CopyFrom(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Meta)),
Amount = Amount.ToString(), Amount = Amount.ToString(CultureInfo.InvariantCulture),
ExpiredAt = ExpiredAt.ToTimestamp(), ExpiredAt = ExpiredAt.ToTimestamp(),
PayeeWalletId = PayeeWalletId?.ToString(), PayeeWalletId = PayeeWalletId?.ToString(),
TransactionId = TransactionId?.ToString(), TransactionId = TransactionId?.ToString(),
@@ -61,6 +66,7 @@ public class Order : ModelBase
Currency = proto.Currency, Currency = proto.Currency,
Remarks = proto.Remarks, Remarks = proto.Remarks,
AppIdentifier = proto.AppIdentifier, AppIdentifier = proto.AppIdentifier,
ProductIdentifier = proto.ProductIdentifier,
Meta = proto.HasMeta Meta = proto.HasMeta
? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(proto.Meta.ToByteArray()) ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(proto.Meta.ToByteArray())
: null, : null,

View File

@@ -1,9 +1,12 @@
using System.Globalization; using System.Globalization;
using System.Text.Json;
using DysonNetwork.Pass.Localization; using DysonNetwork.Pass.Localization;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Stream;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NATS.Client.Core;
using NodaTime; using NodaTime;
using AccountService = DysonNetwork.Pass.Account.AccountService; using AccountService = DysonNetwork.Pass.Account.AccountService;
@@ -13,7 +16,8 @@ public class PaymentService(
AppDatabase db, AppDatabase db,
WalletService wat, WalletService wat,
PusherService.PusherServiceClient pusher, PusherService.PusherServiceClient pusher,
IStringLocalizer<NotificationResource> localizer IStringLocalizer<NotificationResource> localizer,
INatsConnection nats
) )
{ {
public async Task<Order> CreateOrderAsync( public async Task<Order> CreateOrderAsync(
@@ -22,6 +26,7 @@ public class PaymentService(
decimal amount, decimal amount,
Duration? expiration = null, Duration? expiration = null,
string? appIdentifier = null, string? appIdentifier = null,
string? productIdentifier = null,
Dictionary<string, object>? meta = null, Dictionary<string, object>? meta = null,
bool reuseable = true bool reuseable = true
) )
@@ -29,26 +34,25 @@ public class PaymentService(
// Check if there's an existing unpaid order that can be reused // Check if there's an existing unpaid order that can be reused
if (reuseable && appIdentifier != null) if (reuseable && appIdentifier != null)
{ {
var now = SystemClock.Instance.GetCurrentInstant();
var existingOrder = await db.PaymentOrders var existingOrder = await db.PaymentOrders
.Where(o => o.Status == OrderStatus.Unpaid && .Where(o => o.Status == OrderStatus.Unpaid &&
o.PayeeWalletId == payeeWalletId && o.PayeeWalletId == payeeWalletId &&
o.Currency == currency && o.Currency == currency &&
o.Amount == amount && o.Amount == amount &&
o.AppIdentifier == appIdentifier && o.AppIdentifier == appIdentifier &&
o.ExpiredAt > SystemClock.Instance.GetCurrentInstant()) o.ProductIdentifier == productIdentifier &&
o.ExpiredAt > now)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
// If an existing order is found, check if meta matches // If an existing order is found, check if meta matches
if (existingOrder != null && meta != null && existingOrder.Meta != null) if (existingOrder != null && meta != null && existingOrder.Meta != null)
{ {
// Compare meta dictionaries - if they are equivalent, reuse the order // Compare the meta dictionary - if they are equivalent, reuse the order
var metaMatches = existingOrder.Meta.Count == meta.Count && var metaMatches = existingOrder.Meta.Count == meta.Count &&
!existingOrder.Meta.Except(meta).Any(); !existingOrder.Meta.Except(meta).Any();
if (metaMatches) if (metaMatches)
{
return existingOrder; return existingOrder;
}
} }
} }
@@ -60,6 +64,7 @@ public class PaymentService(
Amount = amount, Amount = amount,
ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)),
AppIdentifier = appIdentifier, AppIdentifier = appIdentifier,
ProductIdentifier = productIdentifier,
Meta = meta Meta = meta
}; };
@@ -104,7 +109,8 @@ public class PaymentService(
string currency, string currency,
decimal amount, decimal amount,
string? remarks = null, string? remarks = null,
TransactionType type = TransactionType.System TransactionType type = TransactionType.System,
bool silent = false
) )
{ {
if (payerWalletId == null && payeeWalletId == null) if (payerWalletId == null && payeeWalletId == null)
@@ -121,8 +127,12 @@ public class PaymentService(
Type = type Type = type
}; };
Wallet? payerWallet = null, payeeWallet = null;
if (payerWalletId.HasValue) if (payerWalletId.HasValue)
{ {
payerWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerWalletId.Value);
var (payerPocket, isNewlyCreated) = var (payerPocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency); await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency);
@@ -137,6 +147,8 @@ public class PaymentService(
if (payeeWalletId.HasValue) if (payeeWalletId.HasValue)
{ {
payeeWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeWalletId.Value);
var (payeePocket, isNewlyCreated) = var (payeePocket, isNewlyCreated) =
await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount); await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount);
@@ -149,13 +161,85 @@ public class PaymentService(
db.PaymentTransactions.Add(transaction); db.PaymentTransactions.Add(transaction);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
if (!silent)
await NotifyNewTransaction(transaction, payerWallet, payeeWallet);
return transaction; return transaction;
} }
private async Task NotifyNewTransaction(Transaction transaction, Wallet? payerWallet, Wallet? payeeWallet)
{
if (payerWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payerWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId) AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = transaction.Amount > 0
? localizer["TransactionNewBodyMinus", readableTransactionRemark]
: localizer["TransactionNewBodyPlus", readableTransactionRemark],
Body = localizer["TransactionNewTitle",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
IsSavable = true
}
}
);
}
if (payeeWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payeeWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8];
var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.transactions",
Title = transaction.Amount > 0
? localizer["TransactionNewBodyPlus", readableTransactionRemark]
: localizer["TransactionNewBodyMinus", readableTransactionRemark],
Body = localizer["TransactionNewTitle",
transaction.Amount.ToString(CultureInfo.InvariantCulture),
transaction.Currency],
IsSavable = true
}
}
);
}
}
public async Task<Order> PayOrderAsync(Guid orderId, Wallet payerWallet)
{ {
var order = await db.PaymentOrders var order = await db.PaymentOrders
.Include(o => o.Transaction) .Include(o => o.Transaction)
.Include(o => o.PayeeWallet)
.FirstOrDefaultAsync(o => o.Id == orderId); .FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null) if (order == null)
@@ -176,7 +260,7 @@ public class PaymentService(
} }
var transaction = await CreateTransactionAsync( var transaction = await CreateTransactionAsync(
payerWalletId, payerWallet.Id,
order.PayeeWalletId, order.PayeeWalletId,
order.Currency, order.Currency,
order.Amount, order.Amount,
@@ -186,41 +270,87 @@ public class PaymentService(
order.TransactionId = transaction.Id; order.TransactionId = transaction.Id;
order.Transaction = transaction; order.Transaction = transaction;
order.Status = OrderStatus.Paid; order.Status = OrderStatus.Paid;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyOrderPaid(order); await NotifyOrderPaid(order, payerWallet, order.PayeeWallet);
await nats.PublishAsync(PaymentOrderEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new PaymentOrderEvent
{
OrderId = order.Id,
WalletId = payerWallet.Id,
AccountId = payerWallet.AccountId,
AppIdentifier = order.AppIdentifier,
ProductIdentifier = order.ProductIdentifier,
Meta = order.Meta ?? [],
Status = (int)order.Status,
}));
return order; return order;
} }
private async Task NotifyOrderPaid(Order order) private async Task NotifyOrderPaid(Order order, Wallet? payerWallet, Wallet? payeeWallet)
{ {
if (order.PayeeWallet is null) return; if (payerWallet is not null)
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId); {
if (account is null) return; var account = await db.Accounts
.Where(a => a.Id == payerWallet.AccountId)
AccountService.SetCultureInfo(account); .FirstOrDefaultAsync();
if (account is null) return;
// Due to ID is uuid, it longer than 8 words for sure AccountService.SetCultureInfo(account);
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
// Due to ID is uuid, it longer than 8 words for sure
await pusher.SendPushNotificationToUserAsync( var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
new SendPushNotificationToUserRequest var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
{
UserId = account.Id.ToString(),
Notification = new PushNotification await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{ {
Topic = "wallets.orders.paid", UserId = account.Id.ToString(),
Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], Notification = new PushNotification
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, {
readableOrderRemark], Topic = "wallets.orders.paid",
IsSavable = true Title = localizer["OrderPaidTitle", $"#{readableOrderId}"],
Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
IsSavable = true
}
} }
} );
); }
if (payeeWallet is not null)
{
var account = await db.Accounts
.Where(a => a.Id == payeeWallet.AccountId)
.FirstOrDefaultAsync();
if (account is null) return;
AccountService.SetCultureInfo(account);
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = account.Id.ToString(),
Notification = new PushNotification
{
Topic = "wallets.orders.received",
Title = localizer["OrderReceivedTitle", $"#{readableOrderId}"],
Body = localizer["OrderReceivedBody", order.Amount.ToString(CultureInfo.InvariantCulture),
order.Currency,
readableOrderRemark],
IsSavable = true
}
}
);
}
} }
public async Task<Order> CancelOrderAsync(Guid orderId) public async Task<Order> CancelOrderAsync(Guid orderId)

View File

@@ -13,10 +13,10 @@ public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.Pa
request.Currency, request.Currency,
decimal.Parse(request.Amount), decimal.Parse(request.Amount),
request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null, request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null,
request.HasAppIdentifier ? request.AppIdentifier : null, request.HasAppIdentifier ? request.AppIdentifier : Order.InternalAppIdentifier,
// Assuming meta is a JSON string request.HasProductIdentifier ? request.ProductIdentifier : null,
request.HasMeta request.HasMeta
? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(request.Meta.ToStringUtf8()) ? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta)
: null, : null,
request.Reuseable request.Reuseable
); );

View File

@@ -86,7 +86,7 @@ public class SubscriptionRenewalJob(
if (wallet is null) continue; if (wallet is null) continue;
// Process automatic payment from wallet // Process automatic payment from wallet
await paymentService.PayOrderAsync(order.Id, wallet.Id); await paymentService.PayOrderAsync(order.Id, wallet);
// Update subscription details // Update subscription details
subscription.BegunAt = subscription.EndedAt!.Value; subscription.BegunAt = subscription.EndedAt!.Value;

View File

@@ -8,11 +8,7 @@ public class WalletServiceGrpc(WalletService walletService) : Shared.Proto.Walle
public override async Task<Shared.Proto.Wallet> GetWallet(GetWalletRequest request, ServerCallContext context) public override async Task<Shared.Proto.Wallet> GetWallet(GetWalletRequest request, ServerCallContext context)
{ {
var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId)); var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId));
if (wallet == null) return wallet == null ? throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")) : wallet.ToProtoValue();
{
throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found."));
}
return wallet.ToProtoValue();
} }
public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context) public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context)

View File

@@ -123,6 +123,7 @@ message CreateOrderRequest {
string amount = 3; string amount = 3;
optional google.protobuf.Duration expiration = 4; optional google.protobuf.Duration expiration = 4;
optional string app_identifier = 5; optional string app_identifier = 5;
optional string product_identifier = 8;
// Using bytes for meta to represent JSON. // Using bytes for meta to represent JSON.
optional bytes meta = 6; optional bytes meta = 6;
bool reuseable = 7; bool reuseable = 7;
@@ -135,6 +136,7 @@ message Order {
string amount = 4; string amount = 4;
google.protobuf.Timestamp expired_at = 5; google.protobuf.Timestamp expired_at = 5;
optional string app_identifier = 6; optional string app_identifier = 6;
optional string product_identifier = 12;
// Using bytes for meta to represent JSON. // Using bytes for meta to represent JSON.
optional bytes meta = 7; optional bytes meta = 7;
OrderStatus status = 8; OrderStatus status = 8;

View File

@@ -0,0 +1,14 @@
namespace DysonNetwork.Shared.Stream;
public class PaymentOrderEvent
{
public static string Type => "payments.orders";
public Guid OrderId { get; set; }
public Guid WalletId { get; set; }
public Guid AccountId { get; set; }
public string? AppIdentifier { get; set; }
public string? ProductIdentifier { get; set; }
public Dictionary<string, object> Meta { get; set; } = null!;
public int Status { get; set; }
}

View File

@@ -31,6 +31,7 @@ public class AppDatabase(
public DbSet<Post.Post> Posts { get; set; } = null!; public DbSet<Post.Post> Posts { get; set; } = null!;
public DbSet<PostReaction> PostReactions { get; set; } = null!; public DbSet<PostReaction> PostReactions { get; set; } = null!;
public DbSet<PostAward> PostAwards { get; set; } = null!;
public DbSet<PostTag> PostTags { get; set; } = null!; public DbSet<PostTag> PostTags { get; set; } = null!;
public DbSet<PostCategory> PostCategories { get; set; } = null!; public DbSet<PostCategory> PostCategories { get; set; } = null!;
public DbSet<PostCollection> PostCollections { get; set; } = null!; public DbSet<PostCollection> PostCollections { get; set; } = null!;

View File

@@ -51,6 +51,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
public int ViewsTotal { get; set; } public int ViewsTotal { get; set; }
public int Upvotes { get; set; } public int Upvotes { get; set; }
public int Downvotes { get; set; } public int Downvotes { get; set; }
public decimal AwardedScore { get; set; }
[NotMapped] public Dictionary<string, int> ReactionsCount { get; set; } = new(); [NotMapped] public Dictionary<string, int> ReactionsCount { get; set; } = new();
[NotMapped] public int RepliesCount { get; set; } [NotMapped] public int RepliesCount { get; set; }
[NotMapped] public Dictionary<string, bool>? ReactionsMade { get; set; } [NotMapped] public Dictionary<string, bool>? ReactionsMade { get; set; }
@@ -73,6 +74,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity
public Guid PublisherId { get; set; } public Guid PublisherId { get; set; }
public Publisher.Publisher Publisher { get; set; } = null!; public Publisher.Publisher Publisher { get; set; } = null!;
public ICollection<PostAward> Awards { get; set; } = null!;
public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>(); public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>();
public ICollection<PostTag> Tags { get; set; } = new List<PostTag>(); public ICollection<PostTag> Tags { get; set; } = new List<PostTag>();
public ICollection<PostCategory> Categories { get; set; } = new List<PostCategory>(); public ICollection<PostCategory> Categories { get; set; } = new List<PostCategory>();
@@ -168,3 +170,15 @@ public class PostReaction : ModelBase
[JsonIgnore] public Post Post { get; set; } = null!; [JsonIgnore] public Post Post { get; set; } = null!;
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
} }
public class PostAward : ModelBase
{
public Guid Id { get; set; }
public decimal Amount { get; set; }
public PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; }
public Guid PostId { get; set; }
[JsonIgnore] public Post Post { get; set; } = null!;
public Guid AccountId { get; set; }
}

View File

@@ -579,6 +579,38 @@ public class PostController(
return Ok(reaction); return Ok(reaction);
} }
public class PostAwardRequest
{
public decimal Amount { get; set; }
[MaxLength(4096)] public string? Message { get; set; }
}
[HttpPost("{id:guid}/awards")]
[Authorize]
public async Task<ActionResult<PostAward>> AwardPost(Guid id, [FromBody] PostAwardRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
{ AccountId = currentUser.Id.ToString() });
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
var post = await db.Posts
.Where(e => e.Id == id)
.Include(e => e.Publisher)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id);
// TODO Make payment, add record
return Ok();
}
public class PostPinRequest public class PostPinRequest
{ {
[Required] public PostPinMode Mode { get; set; } [Required] public PostPinMode Mode { get; set; }
@@ -600,7 +632,7 @@ public class PostController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor)) if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher"); return StatusCode(403, "You are not an editor of this publisher");
if (request.Mode == PostPinMode.RealmPage && post.RealmId != null) if (request.Mode == PostPinMode.RealmPage && post.RealmId != null)
{ {
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator)) if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator))
@@ -644,11 +676,11 @@ public class PostController(
.Include(e => e.RepliedPost) .Include(e => e.RepliedPost)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post is null) return NotFound(); if (post is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor)) if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You are not an editor of this publisher"); return StatusCode(403, "You are not an editor of this publisher");
if (post is { PinMode: PostPinMode.RealmPage, RealmId: not null }) if (post is { PinMode: PostPinMode.RealmPage, RealmId: not null })
{ {
if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator)) if (!await rs.IsMemberWithRole(post.RealmId.Value, accountId, RealmMemberRole.Moderator))
@@ -807,7 +839,7 @@ public class PostController(
if (post is null) return NotFound(); if (post is null) return NotFound();
if (!await pub.IsMemberWithRole(post.Publisher.Id, Guid.Parse(currentUser.Id), if (!await pub.IsMemberWithRole(post.Publisher.Id, Guid.Parse(currentUser.Id),
Publisher.PublisherMemberRole.Editor)) PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to delete the publisher's post."); return StatusCode(403, "You need at least be an editor to delete the publisher's post.");
await ps.DeletePostAsync(post); await ps.DeletePostAsync(post);

View File

@@ -13,7 +13,7 @@ public class BroadcastEventHandler(
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", cancellationToken: stoppingToken)) await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountDeletedEvent.Type, cancellationToken: stoppingToken))
{ {
try try
{ {