Award post

This commit is contained in:
2025-09-06 11:19:23 +08:00
parent 5e328509bd
commit 6eacfcd8f2
8 changed files with 182 additions and 16 deletions

View File

@@ -27,6 +27,7 @@ public class PaymentService(
Duration? expiration = null, Duration? expiration = null,
string? appIdentifier = null, string? appIdentifier = null,
string? productIdentifier = null, string? productIdentifier = null,
string? remarks = null,
Dictionary<string, object>? meta = null, Dictionary<string, object>? meta = null,
bool reuseable = true bool reuseable = true
) )
@@ -65,6 +66,7 @@ public class PaymentService(
ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)),
AppIdentifier = appIdentifier, AppIdentifier = appIdentifier,
ProductIdentifier = productIdentifier, ProductIdentifier = productIdentifier,
Remarks = remarks,
Meta = meta Meta = meta
}; };

View File

@@ -15,6 +15,7 @@ public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.Pa
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 : Order.InternalAppIdentifier, request.HasAppIdentifier ? request.AppIdentifier : Order.InternalAppIdentifier,
request.HasProductIdentifier ? request.ProductIdentifier : null, request.HasProductIdentifier ? request.ProductIdentifier : null,
request.HasRemarks ? request.Remarks : null,
request.HasMeta request.HasMeta
? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta) ? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta)
: null, : null,

View File

@@ -97,7 +97,19 @@ public static class GrpcClientHelper
return new PermissionService.PermissionServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, return new PermissionService.PermissionServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath,
clientCertPassword)); clientCertPassword));
} }
public static async Task<PaymentService.PaymentServiceClient> 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<PusherService.PusherServiceClient> CreatePusherServiceClient( public static async Task<PusherService.PusherServiceClient> CreatePusherServiceClient(
IEtcdClient etcdClient, IEtcdClient etcdClient,
string clientCertPath, string clientCertPath,

View File

@@ -126,6 +126,7 @@ message CreateOrderRequest {
optional string product_identifier = 8; 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;
optional string remarks = 9;
bool reuseable = 7; bool reuseable = 7;
} }

View File

@@ -71,6 +71,20 @@ public static class ServiceInjectionHelper
.GetResult(); .GetResult();
}); });
services.AddSingleton<PaymentService.PaymentServiceClient>(sp =>
{
var etcdClient = sp.GetRequiredService<IEtcdClient>();
var config = sp.GetRequiredService<IConfiguration>();
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; return services;
} }

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization;
using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Content; using DysonNetwork.Shared.Content;
using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Data;
@@ -24,6 +25,7 @@ public class PostController(
PublisherService pub, PublisherService pub,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
ActionLogService.ActionLogServiceClient als, ActionLogService.ActionLogServiceClient als,
PaymentService.PaymentServiceClient payments,
PollService polls, PollService polls,
RealmService rs RealmService rs
) )
@@ -324,7 +326,6 @@ public class PostController(
.OrderByDescending(p => p.CreatedAt) .OrderByDescending(p => p.CreatedAt)
.FilterWithVisibility(currentUser, userFriends, userPublishers) .FilterWithVisibility(currentUser, userFriends, userPublishers)
.ToListAsync(); .ToListAsync();
if (posts is null) return NotFound();
posts = await ps.LoadPostInfo(posts, currentUser); posts = await ps.LoadPostInfo(posts, currentUser);
return Ok(posts); return Ok(posts);
@@ -582,14 +583,22 @@ public class PostController(
public class PostAwardRequest public class PostAwardRequest
{ {
public decimal Amount { get; set; } public decimal Amount { get; set; }
public PostReactionAttitude Attitude { get; set; }
[MaxLength(4096)] public string? Message { get; set; } [MaxLength(4096)] public string? Message { get; set; }
} }
public class PostAwardResponse
{
public Guid OrderId { get; set; }
}
[HttpPost("{id:guid}/awards")] [HttpPost("{id:guid}/awards")]
[Authorize] [Authorize]
public async Task<ActionResult<PostAward>> AwardPost(Guid id, [FromBody] PostAwardRequest request) public async Task<ActionResult<PostAwardResponse>> AwardPost(Guid id, [FromBody] PostAwardRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); 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 = var friendsResponse =
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
@@ -605,10 +614,27 @@ public class PostController(
if (post is null) return NotFound(); if (post is null) return NotFound();
var accountId = Guid.Parse(currentUser.Id); 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<string, object?>()
{
["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 public class PostPinRequest

View File

@@ -424,10 +424,12 @@ public partial class PostService(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
if (post.RepliedPostId != null) 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 (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."); throw new InvalidOperationException("Only editors of original post can pin replies.");
post.PinMode = pinMode; post.PinMode = pinMode;
@@ -453,7 +455,8 @@ public partial class PostService(
{ {
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost)); 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."); throw new InvalidOperationException("Only editors of original post can unpin replies.");
} }
else else
@@ -815,9 +818,10 @@ public partial class PostService(
{ {
// The previous day highest rated posts // The previous day highest rated posts
var today = SystemClock.Instance.GetCurrentInstant(); 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 periodEnd = today.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var postsInPeriod = await db.Posts var postsInPeriod = await db.Posts
.Where(e => e.Visibility == PostVisibility.Public) .Where(e => e.Visibility == PostVisibility.Public)
.Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd) .Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd)
@@ -890,6 +894,34 @@ public partial class PostService(
return posts; return posts;
} }
public async Task<PostAward> 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 public static class PostQueryExtensions
@@ -925,4 +957,4 @@ public static class PostQueryExtensions
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) || (e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
publishersId.Contains(e.PublisherId)); publishersId.Contains(e.PublisherId));
} }
} }

View File

@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Shared.Stream; using DysonNetwork.Shared.Stream;
using DysonNetwork.Sphere.Post;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NATS.Client.Core; using NATS.Client.Core;
@@ -13,7 +14,84 @@ 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[]>(AccountDeletedEvent.Type, cancellationToken: stoppingToken)) await foreach (var msg in nats.SubscribeAsync<byte[]>(PaymentOrderEvent.Type, cancellationToken: stoppingToken))
{
PaymentOrderEvent? evt = null;
try
{
evt = JsonSerializer.Deserialize<PaymentOrderEvent>(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<PostService>();
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<byte[]>(AccountDeletedEvent.Type,
cancellationToken: stoppingToken))
{ {
try try
{ {
@@ -21,14 +99,14 @@ public class BroadcastEventHandler(
if (evt == null) continue; if (evt == null) continue;
logger.LogInformation("Account deleted: {AccountId}", evt.AccountId); logger.LogInformation("Account deleted: {AccountId}", evt.AccountId);
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>(); var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.ChatMembers await db.ChatMembers
.Where(m => m.AccountId == evt.AccountId) .Where(m => m.AccountId == evt.AccountId)
.ExecuteDeleteAsync(cancellationToken: stoppingToken); .ExecuteDeleteAsync(cancellationToken: stoppingToken);
await db.RealmMembers await db.RealmMembers
.Where(m => m.AccountId == evt.AccountId) .Where(m => m.AccountId == evt.AccountId)
.ExecuteDeleteAsync(cancellationToken: stoppingToken); .ExecuteDeleteAsync(cancellationToken: stoppingToken);
@@ -44,7 +122,7 @@ public class BroadcastEventHandler(
await db.Posts await db.Posts
.Where(p => p.PublisherId == publisher.Id) .Where(p => p.PublisherId == publisher.Id)
.ExecuteDeleteAsync(cancellationToken: stoppingToken); .ExecuteDeleteAsync(cancellationToken: stoppingToken);
var publisherIds = publishers.Select(p => p.Id).ToList(); var publisherIds = publishers.Select(p => p.Id).ToList();
await db.Publishers await db.Publishers
.Where(p => publisherIds.Contains(p.Id)) .Where(p => publisherIds.Contains(p.Id))