From 6eacfcd8f29a41c34bf9db8d4203e2fc681d1d4b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 6 Sep 2025 11:19:23 +0800 Subject: [PATCH] :sparkles: Award post --- DysonNetwork.Pass/Wallet/PaymentService.cs | 2 + .../Wallet/PaymentServiceGrpc.cs | 1 + DysonNetwork.Shared/Proto/GrpcClientHelper.cs | 14 ++- DysonNetwork.Shared/Proto/wallet.proto | 1 + .../Registry/ServiceInjectionHelper.cs | 14 +++ DysonNetwork.Sphere/Post/PostController.cs | 36 ++++++-- DysonNetwork.Sphere/Post/PostService.cs | 44 ++++++++-- .../Startup/BroadcastEventHandler.cs | 86 ++++++++++++++++++- 8 files changed, 182 insertions(+), 16 deletions(-) diff --git a/DysonNetwork.Pass/Wallet/PaymentService.cs b/DysonNetwork.Pass/Wallet/PaymentService.cs index 88d211d..99dc466 100644 --- a/DysonNetwork.Pass/Wallet/PaymentService.cs +++ b/DysonNetwork.Pass/Wallet/PaymentService.cs @@ -27,6 +27,7 @@ public class PaymentService( Duration? expiration = null, string? appIdentifier = null, string? productIdentifier = null, + string? remarks = null, Dictionary? meta = null, bool reuseable = true ) @@ -65,6 +66,7 @@ public class PaymentService( ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), AppIdentifier = appIdentifier, ProductIdentifier = productIdentifier, + Remarks = remarks, Meta = meta }; diff --git a/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs b/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs index 18ea0ef..5b1c3c6 100644 --- a/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs +++ b/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs @@ -15,6 +15,7 @@ public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.Pa request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null, request.HasAppIdentifier ? request.AppIdentifier : Order.InternalAppIdentifier, request.HasProductIdentifier ? request.ProductIdentifier : null, + request.HasRemarks ? request.Remarks : null, request.HasMeta ? GrpcTypeHelper.ConvertByteStringToObject>(request.Meta) : null, diff --git a/DysonNetwork.Shared/Proto/GrpcClientHelper.cs b/DysonNetwork.Shared/Proto/GrpcClientHelper.cs index 96fdf64..29570b4 100644 --- a/DysonNetwork.Shared/Proto/GrpcClientHelper.cs +++ b/DysonNetwork.Shared/Proto/GrpcClientHelper.cs @@ -97,7 +97,19 @@ public static class GrpcClientHelper return new PermissionService.PermissionServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, clientCertPassword)); } - + + public static async Task CreatePaymentServiceClient( + IEtcdClient etcdClient, + string clientCertPath, + string clientKeyPath, + string? clientCertPassword = null + ) + { + var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.Pass"); + return new PaymentService.PaymentServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, + clientCertPassword)); + } + public static async Task CreatePusherServiceClient( IEtcdClient etcdClient, string clientCertPath, diff --git a/DysonNetwork.Shared/Proto/wallet.proto b/DysonNetwork.Shared/Proto/wallet.proto index 74906b1..4069e03 100644 --- a/DysonNetwork.Shared/Proto/wallet.proto +++ b/DysonNetwork.Shared/Proto/wallet.proto @@ -126,6 +126,7 @@ message CreateOrderRequest { optional string product_identifier = 8; // Using bytes for meta to represent JSON. optional bytes meta = 6; + optional string remarks = 9; bool reuseable = 7; } diff --git a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs index f1f9ade..ad032bd 100644 --- a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs +++ b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs @@ -71,6 +71,20 @@ public static class ServiceInjectionHelper .GetResult(); }); + services.AddSingleton(sp => + { + var etcdClient = sp.GetRequiredService(); + var config = sp.GetRequiredService(); + var clientCertPath = config["Service:ClientCert"]!; + var clientKeyPath = config["Service:ClientKey"]!; + var clientCertPassword = config["Service:CertPassword"]; + + return GrpcClientHelper + .CreatePaymentServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword) + .GetAwaiter() + .GetResult(); + }); + return services; } diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 2987617..4364485 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Content; using DysonNetwork.Shared.Data; @@ -24,6 +25,7 @@ public class PostController( PublisherService pub, AccountService.AccountServiceClient accounts, ActionLogService.ActionLogServiceClient als, + PaymentService.PaymentServiceClient payments, PollService polls, RealmService rs ) @@ -324,7 +326,6 @@ public class PostController( .OrderByDescending(p => p.CreatedAt) .FilterWithVisibility(currentUser, userFriends, userPublishers) .ToListAsync(); - if (posts is null) return NotFound(); posts = await ps.LoadPostInfo(posts, currentUser); return Ok(posts); @@ -582,14 +583,22 @@ public class PostController( public class PostAwardRequest { public decimal Amount { get; set; } + public PostReactionAttitude Attitude { get; set; } [MaxLength(4096)] public string? Message { get; set; } } + public class PostAwardResponse + { + public Guid OrderId { get; set; } + } + [HttpPost("{id:guid}/awards")] [Authorize] - public async Task> AwardPost(Guid id, [FromBody] PostAwardRequest request) + public async Task> AwardPost(Guid id, [FromBody] PostAwardRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (request.Attitude == PostReactionAttitude.Neutral) + return BadRequest("You cannot create a neutral post award"); var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest @@ -605,10 +614,27 @@ public class PostController( if (post is null) return NotFound(); var accountId = Guid.Parse(currentUser.Id); - - // TODO Make payment, add record - return Ok(); + var orderRemark = string.IsNullOrWhiteSpace(post.Title) ? "from @" + post.Publisher.Name : post.Title; + var order = await payments.CreateOrderAsync(new CreateOrderRequest + { + ProductIdentifier = "posts.award", + Currency = "points", // NSP - Source Points + Remarks = $"Award post {orderRemark}", + Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary() + { + ["account_id"] = accountId, + ["post_id"] = post.Id, + ["amount"] = request.Amount.ToString(CultureInfo.InvariantCulture), + ["message"] = request.Message, + ["attitude"] = request.Attitude, + }) + }); + + return Ok(new PostAwardResponse() + { + OrderId = Guid.Parse(order.Id), + }); } public class PostPinRequest diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index 8f01464..84dfbc5 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -424,10 +424,12 @@ public partial class PostService( var accountId = Guid.Parse(currentUser.Id); if (post.RepliedPostId != null) { - if (pinMode != PostPinMode.ReplyPage) throw new InvalidOperationException("Replies can only be pinned in the reply page."); + if (pinMode != PostPinMode.ReplyPage) + throw new InvalidOperationException("Replies can only be pinned in the reply page."); if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost)); - if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor)) + if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, + Publisher.PublisherMemberRole.Editor)) throw new InvalidOperationException("Only editors of original post can pin replies."); post.PinMode = pinMode; @@ -453,7 +455,8 @@ public partial class PostService( { if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost)); - if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, Publisher.PublisherMemberRole.Editor)) + if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId, + Publisher.PublisherMemberRole.Editor)) throw new InvalidOperationException("Only editors of original post can unpin replies."); } else @@ -815,9 +818,10 @@ public partial class PostService( { // The previous day highest rated posts var today = SystemClock.Instance.GetCurrentInstant(); - var periodStart = today.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant().Minus(Duration.FromDays(1)); + var periodStart = today.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant() + .Minus(Duration.FromDays(1)); var periodEnd = today.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); - + var postsInPeriod = await db.Posts .Where(e => e.Visibility == PostVisibility.Public) .Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd) @@ -890,6 +894,34 @@ public partial class PostService( return posts; } + + public async Task AwardPost( + Guid postId, + Guid accountId, + decimal amount, + PostReactionAttitude attitude, + string? message + ) + { + var award = new PostAward + { + Amount = amount, + Attitude = attitude, + Message = message, + PostId = postId, + AccountId = accountId + }; + + db.PostAwards.Add(award); + await db.SaveChangesAsync(); + + var delta = award.Attitude == PostReactionAttitude.Positive ? amount : -amount; + + await db.Posts.Where(p => p.Id == postId) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.AwardedScore, p => p.AwardedScore + delta)); + + return award; + } } public static class PostQueryExtensions @@ -925,4 +957,4 @@ public static class PostQueryExtensions (e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) || publishersId.Contains(e.PublisherId)); } -} +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs b/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs index 8952984..d12947d 100644 --- a/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs +++ b/DysonNetwork.Sphere/Startup/BroadcastEventHandler.cs @@ -1,5 +1,6 @@ using System.Text.Json; using DysonNetwork.Shared.Stream; +using DysonNetwork.Sphere.Post; using Microsoft.EntityFrameworkCore; using NATS.Client.Core; @@ -13,7 +14,84 @@ public class BroadcastEventHandler( { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - await foreach (var msg in nats.SubscribeAsync(AccountDeletedEvent.Type, cancellationToken: stoppingToken)) + await foreach (var msg in nats.SubscribeAsync(PaymentOrderEvent.Type, cancellationToken: stoppingToken)) + { + PaymentOrderEvent? evt = null; + try + { + evt = JsonSerializer.Deserialize(msg.Data); + + // Every order goes into the MQ is already paid, so we skipped the status validation + + if (evt?.ProductIdentifier is null) + continue; + + switch (evt.ProductIdentifier) + { + case "posts.award": + { + logger.LogInformation("Handling post award order: {OrderId}", evt.OrderId); + + if (!evt.Meta.TryGetValue("account_id", out var accountIdObj) || + accountIdObj is not string accountIdStr || + !Guid.TryParse(accountIdStr, out var accountId)) + { + logger.LogWarning("Post award order {OrderId} missing or invalid account_id", evt.OrderId); + break; + } + + if (!evt.Meta.TryGetValue("post_id", out var postIdObj) || + postIdObj is not string postIdStr || + !Guid.TryParse(postIdStr, out var postId)) + { + logger.LogWarning("Post award order {OrderId} missing or invalid post_id", evt.OrderId); + break; + } + + if (!evt.Meta.TryGetValue("amount", out var amountObj) || + amountObj is not string amountStr || + !decimal.TryParse(amountStr, out var amount)) + { + logger.LogWarning("Post award order {OrderId} missing or invalid amount", evt.OrderId); + break; + } + + if (!evt.Meta.TryGetValue("attitude", out var attitudeObj) || + attitudeObj is not string attitudeStr || + !int.TryParse(attitudeStr, out var attitudeInt) || + !Enum.IsDefined(typeof(PostReactionAttitude), attitudeInt)) + { + logger.LogWarning("Post award order {OrderId} missing or invalid attitude", evt.OrderId); + break; + } + var attitude = (PostReactionAttitude)attitudeInt; + + string? message = null; + if (evt.Meta.TryGetValue("message", out var messageObj) && + messageObj is string messageStr) + { + message = messageStr; + } + + await using var scope = serviceProvider.CreateAsyncScope(); + var ps = scope.ServiceProvider.GetRequiredService(); + + await ps.AwardPost(postId, accountId, amount, attitude, message); + + logger.LogInformation("Post award for order {OrderId} handled successfully.", evt.OrderId); + + break; + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing payment order event for order {OrderId}", evt?.OrderId); + } + } + + await foreach (var msg in nats.SubscribeAsync(AccountDeletedEvent.Type, + cancellationToken: stoppingToken)) { try { @@ -21,14 +99,14 @@ public class BroadcastEventHandler( if (evt == null) continue; logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); - + using var scope = serviceProvider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.ChatMembers .Where(m => m.AccountId == evt.AccountId) .ExecuteDeleteAsync(cancellationToken: stoppingToken); - + await db.RealmMembers .Where(m => m.AccountId == evt.AccountId) .ExecuteDeleteAsync(cancellationToken: stoppingToken); @@ -44,7 +122,7 @@ public class BroadcastEventHandler( await db.Posts .Where(p => p.PublisherId == publisher.Id) .ExecuteDeleteAsync(cancellationToken: stoppingToken); - + var publisherIds = publishers.Select(p => p.Id).ToList(); await db.Publishers .Where(p => publisherIds.Contains(p.Id))