From 322dee445329855065d961147825a8b6d4c416eb Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 2 Nov 2025 11:59:02 +0800 Subject: [PATCH] :sparkles: Publisher rewarding --- .../Credit/SocialCreditService.cs | 26 ++- .../Credit/SocialCreditServiceGrpc.cs | 16 +- DysonNetwork.Shared/Proto/leveling.proto | 65 +++--- .../Registry/ServiceInjectionHelper.cs | 12 ++ .../Publisher/PublisherController.cs | 35 +++- .../Publisher/PublisherService.cs | 188 +++++++++++++++++- .../Publisher/PublisherSettlementJob.cs | 12 ++ .../Startup/ScheduledJobsConfiguration.cs | 8 + 8 files changed, 312 insertions(+), 50 deletions(-) create mode 100644 DysonNetwork.Sphere/Publisher/PublisherSettlementJob.cs diff --git a/DysonNetwork.Pass/Credit/SocialCreditService.cs b/DysonNetwork.Pass/Credit/SocialCreditService.cs index c57daad..7d6e84e 100644 --- a/DysonNetwork.Pass/Credit/SocialCreditService.cs +++ b/DysonNetwork.Pass/Credit/SocialCreditService.cs @@ -8,8 +8,14 @@ namespace DysonNetwork.Pass.Credit; public class SocialCreditService(AppDatabase db, ICacheService cache) { private const string CacheKeyPrefix = "account:credits:"; - - public async Task AddRecord(string reasonType, string reason, double delta, Guid accountId) + + public async Task AddRecord( + string reasonType, + string reason, + double delta, + Guid accountId, + Instant? expiredAt + ) { var record = new SnSocialCreditRecord { @@ -17,21 +23,22 @@ public class SocialCreditService(AppDatabase db, ICacheService cache) Reason = reason, Delta = delta, AccountId = accountId, + ExpiredAt = expiredAt }; db.SocialCreditRecords.Add(record); await db.SaveChangesAsync(); - + await db.AccountProfiles .Where(p => p.AccountId == accountId) .ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits + record.Delta)); - + await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}"); - + return record; } - + private const double BaseSocialCredit = 100; - + public async Task GetSocialCredit(Guid accountId) { var cached = await cache.GetAsync($"{CacheKeyPrefix}{accountId}"); @@ -61,7 +68,8 @@ public class SocialCreditService(AppDatabase db, ICacheService cache) { await db.AccountProfiles .Where(p => p.AccountId == accountId) - .ExecuteUpdateAsync(p => p.SetProperty(v => v.SocialCredits, v => v.SocialCredits - totalDeltaSubtracted)); + .ExecuteUpdateAsync(p => + p.SetProperty(v => v.SocialCredits, v => v.SocialCredits - totalDeltaSubtracted)); await cache.RemoveAsync($"{CacheKeyPrefix}{accountId}"); } @@ -69,4 +77,4 @@ public class SocialCreditService(AppDatabase db, ICacheService cache) .Where(r => r.Status == SocialCreditRecordStatus.Active && r.ExpiredAt.HasValue && r.ExpiredAt <= now) .ExecuteUpdateAsync(r => r.SetProperty(x => x.Status, SocialCreditRecordStatus.Expired)); } -} +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs b/DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs index c5d13b8..b71ff22 100644 --- a/DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs +++ b/DysonNetwork.Pass/Credit/SocialCreditServiceGrpc.cs @@ -1,27 +1,33 @@ using DysonNetwork.Shared.Proto; using Grpc.Core; +using NodaTime.Serialization.Protobuf; namespace DysonNetwork.Pass.Credit; -public class SocialCreditServiceGrpc(SocialCreditService creditService) : Shared.Proto.SocialCreditService.SocialCreditServiceBase +public class SocialCreditServiceGrpc(SocialCreditService creditService) + : Shared.Proto.SocialCreditService.SocialCreditServiceBase { - public override async Task AddRecord(AddSocialCreditRecordRequest request, ServerCallContext context) + public override async Task AddRecord(AddSocialCreditRecordRequest request, + ServerCallContext context) { var accountId = Guid.Parse(request.AccountId); var record = await creditService.AddRecord( request.ReasonType, request.Reason, request.Delta, - accountId); + accountId, + request.ExpiredAt.ToInstant() + ); return record.ToProto(); } - public override async Task GetSocialCredit(GetSocialCreditRequest request, ServerCallContext context) + public override async Task GetSocialCredit(GetSocialCreditRequest request, + ServerCallContext context) { var accountId = Guid.Parse(request.AccountId); var amount = await creditService.GetSocialCredit(accountId); - + return new SocialCreditResponse { Amount = amount }; } } \ No newline at end of file diff --git a/DysonNetwork.Shared/Proto/leveling.proto b/DysonNetwork.Shared/Proto/leveling.proto index ea413f2..622f563 100644 --- a/DysonNetwork.Shared/Proto/leveling.proto +++ b/DysonNetwork.Shared/Proto/leveling.proto @@ -14,25 +14,25 @@ import "google/protobuf/field_mask.proto"; // SocialCreditRecord represents a record of social credit changes for an account message SocialCreditRecord { - string id = 1; // UUID string - string reason_type = 2; - string reason = 3; - double delta = 4; - string account_id = 5; // UUID string - google.protobuf.Timestamp created_at = 6; - google.protobuf.Timestamp updated_at = 7; + string id = 1; // UUID string + string reason_type = 2; + string reason = 3; + double delta = 4; + string account_id = 5; // UUID string + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; } // ExperienceRecord represents a record of experience points gained by an account message ExperienceRecord { - string id = 1; // UUID string - string reason_type = 2; - string reason = 3; - int64 delta = 4; - double bonus_multiplier = 5; - string account_id = 6; // UUID string - google.protobuf.Timestamp created_at = 7; - google.protobuf.Timestamp updated_at = 8; + string id = 1; // UUID string + string reason_type = 2; + string reason = 3; + int64 delta = 4; + double bonus_multiplier = 5; + string account_id = 6; // UUID string + google.protobuf.Timestamp created_at = 7; + google.protobuf.Timestamp updated_at = 8; } // ==================================== @@ -41,26 +41,27 @@ message ExperienceRecord { // Social Credit Requests/Responses message AddSocialCreditRecordRequest { - string reason_type = 1; - string reason = 2; - double delta = 3; - string account_id = 4; // UUID string + string reason_type = 1; + string reason = 2; + double delta = 3; + string account_id = 4; // UUID string + google.protobuf.Timestamp expired_at = 5; } message GetSocialCreditRequest { - string account_id = 1; // UUID string + string account_id = 1; // UUID string } message SocialCreditResponse { - double amount = 1; + double amount = 1; } // Experience Requests/Responses message AddExperienceRecordRequest { - string reason_type = 1; - string reason = 2; - int64 delta = 3; - string account_id = 4; // UUID string + string reason_type = 1; + string reason = 2; + int64 delta = 3; + string account_id = 4; // UUID string } // ==================================== @@ -69,15 +70,15 @@ message AddExperienceRecordRequest { // SocialCreditService provides operations for managing social credit scores service SocialCreditService { - // Adds a new social credit record for an account - rpc AddRecord(AddSocialCreditRecordRequest) returns (SocialCreditRecord); - - // Gets the current social credit score for an account - rpc GetSocialCredit(GetSocialCreditRequest) returns (SocialCreditResponse); + // Adds a new social credit record for an account + rpc AddRecord(AddSocialCreditRecordRequest) returns (SocialCreditRecord); + + // Gets the current social credit score for an account + rpc GetSocialCredit(GetSocialCreditRequest) returns (SocialCreditResponse); } // ExperienceService provides operations for managing experience points service ExperienceService { - // Adds a new experience record for an account - rpc AddRecord(AddExperienceRecordRequest) returns (ExperienceRecord); + // Adds a new experience record for an account + rpc AddRecord(AddExperienceRecordRequest) returns (ExperienceRecord); } diff --git a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs index 219a020..9d0181f 100644 --- a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs +++ b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs @@ -71,6 +71,18 @@ public static class ServiceInjectionHelper { ServerCertificateCustomValidationCallback = (_, _, _, _) => true } ); services.AddSingleton(); + + services + .AddGrpcClient(o => o.Address = new Uri("https://_grpc.pass")) + .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() + { ServerCertificateCustomValidationCallback = (_, _, _, _) => true } + ); + + services + .AddGrpcClient(o => o.Address = new Uri("https://_grpc.pass")) + .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() + { ServerCertificateCustomValidationCallback = (_, _, _, _) => true } + ); return services; } diff --git a/DysonNetwork.Sphere/Publisher/PublisherController.cs b/DysonNetwork.Sphere/Publisher/PublisherController.cs index fcc6618..44b3b80 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherController.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; +using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole; namespace DysonNetwork.Sphere.Publisher; @@ -39,7 +40,7 @@ public class PublisherController( return Ok(publisher); } - + [HttpGet("{name}/heatmap")] public async Task> GetPublisherHeatmap(string name) { @@ -650,6 +651,27 @@ public class PublisherController( return Ok(dict); } + [HttpGet("{name}/rewards")] + [Authorize] + public async Task> GetPublisherExpectedReward( + string name + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var publisher = await db.Publishers + .Where(p => p.Name == name) + .FirstOrDefaultAsync(); + if (publisher is null) return NotFound(); + + if (!await ps.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Viewer)) + return StatusCode(403, "You are not allowed to view stats data of this publisher."); + + var result = await ps.GetPublisherExpectedReward(publisher.Id); + return Ok(result); + } + public class PublisherFeatureRequest { [Required] public string Flag { get; set; } = null!; @@ -701,4 +723,13 @@ public class PublisherController( return NoContent(); } -} + + [HttpPost("rewards/settle")] + [Authorize] + [RequiredPermission("maintenance", "publishers.reward.settle")] + public async Task PerformLotteryDraw() + { + await ps.SettlePublisherRewards(); + return Ok(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Publisher/PublisherService.cs b/DysonNetwork.Sphere/Publisher/PublisherService.cs index 51fdd06..8e9f795 100644 --- a/DysonNetwork.Sphere/Publisher/PublisherService.cs +++ b/DysonNetwork.Sphere/Publisher/PublisherService.cs @@ -1,9 +1,11 @@ +using System.Globalization; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry; using Microsoft.EntityFrameworkCore; using NodaTime; +using NodaTime.Serialization.Protobuf; using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole; using PublisherType = DysonNetwork.Shared.Models.PublisherType; @@ -12,6 +14,8 @@ namespace DysonNetwork.Sphere.Publisher; public class PublisherService( AppDatabase db, FileReferenceService.FileReferenceServiceClient fileRefs, + SocialCreditService.SocialCreditServiceClient socialCredits, + ExperienceService.ExperienceServiceClient experiences, ICacheService cache, RemoteAccountService remoteAccounts ) @@ -300,10 +304,12 @@ public class PublisherService( var postsCount = await db.Posts.Where(e => e.Publisher.Id == publisher.Id).CountAsync(); var postsUpvotes = await db.PostReactions - .Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == Shared.Models.PostReactionAttitude.Positive) + .Where(r => r.Post.Publisher.Id == publisher.Id && + r.Attitude == Shared.Models.PostReactionAttitude.Positive) .CountAsync(); var postsDownvotes = await db.PostReactions - .Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == Shared.Models.PostReactionAttitude.Negative) + .Where(r => r.Post.Publisher.Id == publisher.Id && + r.Attitude == Shared.Models.PostReactionAttitude.Negative) .CountAsync(); var stickerPacksId = await db.StickerPacks.Where(e => e.Publisher.Id == publisher.Id).Select(e => e.Id) @@ -462,4 +468,182 @@ public class PublisherService( return publishers.ToList(); } + + public class PublisherRewardPreview + { + public int Experience { get; set; } + public int SocialCredits { get; set; } + } + + public async Task GetPublisherExpectedReward(Guid publisherId) + { + var cacheKey = $"publisher:{publisherId}:rewards"; + var (found, cached) = await cache.GetAsyncWithStatus(cacheKey); + if (found) + return cached!; + + var now = SystemClock.Instance.GetCurrentInstant(); + var yesterday = now.InZone(DateTimeZone.Utc).Date.PlusDays(-1); + var periodStart = yesterday.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + var periodEnd = periodStart.Plus(Duration.FromDays(1)).Minus(Duration.FromMilliseconds(1)); + + // Get posts stats for this publisher: count, id, exclude content + var postsInPeriod = await db.Posts + .Where(p => p.PublisherId == publisherId && p.CreatedAt >= periodStart && p.CreatedAt <= periodEnd) + .Select(p => new { Id = p.Id, AwardedScore = p.AwardedScore }) + .ToListAsync(); + + // Get reactions for these posts + var postIds = postsInPeriod.Select(p => p.Id).ToList(); + var reactions = await db.PostReactions + .Where(r => postIds.Contains(r.PostId)) + .ToListAsync(); + + if (postsInPeriod.Count == 0) + return new PublisherRewardPreview { Experience = 0, SocialCredits = 0 }; + + // Calculate stats + var postCount = postsInPeriod.Count; + var upvotes = reactions.Count(r => r.Attitude == Shared.Models.PostReactionAttitude.Positive); + var downvotes = reactions.Count(r => r.Attitude == Shared.Models.PostReactionAttitude.Negative); + var awardScore = postsInPeriod.Sum(p => (double)p.AwardedScore); + + // Each post counts as 100 experiences, + // and each point (upvote - downvote + award score * 0.1) count as 10 experiences + var netVotes = upvotes - downvotes; + var points = netVotes + awardScore * 0.1; + var experienceFromPosts = postCount * 100; + var experienceFromPoints = (int)(points * 10); + var totalExperience = experienceFromPosts + experienceFromPoints; + + var preview = new PublisherRewardPreview + { + Experience = totalExperience, + SocialCredits = (int)(points * 10) + }; + + await cache.SetAsync(cacheKey, preview, TimeSpan.FromMinutes(5)); + return preview; + } + + public async Task SettlePublisherRewards() + { + var now = SystemClock.Instance.GetCurrentInstant(); + var yesterday = now.InZone(DateTimeZone.Utc).Date.PlusDays(-1); + var periodStart = yesterday.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + var periodEnd = periodStart.Plus(Duration.FromDays(1)).Minus(Duration.FromMilliseconds(1)); + + // Get posts stats: count, publisher id, exclude content + var postsInPeriod = await db.Posts + .Where(p => p.CreatedAt >= periodStart && p.CreatedAt <= periodEnd) + .Select(p => new { Id = p.Id, PublisherId = p.PublisherId, AwardedScore = p.AwardedScore }) + .ToListAsync(); + + // Get reactions for these posts + var postIds = postsInPeriod.Select(p => p.Id).ToList(); + var reactions = await db.PostReactions + .Where(r => postIds.Contains(r.PostId)) + .ToListAsync(); + + // Group stats by publisher id + var postIdToPublisher = postsInPeriod.ToDictionary(p => p.Id, p => p.PublisherId); + var publisherStats = postsInPeriod + .GroupBy(p => p.PublisherId) + .ToDictionary(g => g.Key, + g => new + { + PostCount = g.Count(), Upvotes = 0, Downvotes = 0, AwardScore = g.Sum(p => (double)p.AwardedScore) + }); + + foreach (var reaction in reactions.Where(r => r.Attitude == Shared.Models.PostReactionAttitude.Positive)) + { + if (!postIdToPublisher.TryGetValue(reaction.PostId, out var pubId) || + !publisherStats.TryGetValue(pubId, out var stat)) continue; + stat = new { stat.PostCount, Upvotes = stat.Upvotes + 1, stat.Downvotes, stat.AwardScore }; + publisherStats[pubId] = stat; + } + + foreach (var reaction in reactions.Where(r => r.Attitude == Shared.Models.PostReactionAttitude.Negative)) + { + if (!postIdToPublisher.TryGetValue(reaction.PostId, out var pubId) || + !publisherStats.TryGetValue(pubId, out var stat)) continue; + stat = new { stat.PostCount, stat.Upvotes, Downvotes = stat.Downvotes + 1, stat.AwardScore }; + publisherStats[pubId] = stat; + } + + var date = now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + var publisherIds = publisherStats.Keys.ToList(); + var publisherMembers = await db.PublisherMembers + .Where(m => publisherIds.Contains(m.PublisherId)) + .ToListAsync(); + var accountIds = publisherMembers.Select(m => m.AccountId).ToList(); + var accounts = (await remoteAccounts.GetAccountBatch(accountIds)).ToDictionary(a => Guid.Parse(a.Id), a => a); + var publisherAccounts = publisherMembers + .GroupBy(m => m.PublisherId) + .ToDictionary(g => g.Key, g => g.Select(m => SnAccount.FromProtoValue(accounts[m.AccountId])).ToList()); + + // Foreach loop through publishers to calculate experience + foreach (var (publisherId, value) in publisherStats) + { + var postCount = value.PostCount; + var upvotes = value.Upvotes; + var downvotes = value.Downvotes; + var awardScore = value.AwardScore; // Fetch or calculate here + + // Each post counts as 100 experiences, + // and each point (upvote - downvote + award score * 0.1) count as 10 experiences + var netVotes = upvotes - downvotes; + var points = netVotes + awardScore * 0.1; + var experienceFromPosts = postCount * 100; + var experienceFromPoints = (int)(points * 10); + var totalExperience = experienceFromPosts + experienceFromPoints; + + if (!publisherAccounts.TryGetValue(publisherId, out var receivers) || receivers.Count == 0) + continue; + + // Use totalExperience for rewarding + foreach (var receiver in receivers) + { + await experiences.AddRecordAsync(new AddExperienceRecordRequest + { + Reason = $"Publishing Reward on {date}", + ReasonType = "publishers.rewards", + AccountId = receiver.Id.ToString(), + Delta = totalExperience, + }); + } + } + + // Foreach loop through publishers to set social credit + var expiredAt = now.InZone(DateTimeZone.Utc).Date.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).Minus(Duration.FromMilliseconds(1)).ToInstant(); + foreach (var (publisherId, value) in publisherStats) + { + var upvotes = value.Upvotes; + var downvotes = value.Downvotes; + var awardScore = value.AwardScore; // Fetch or calculate here + + var netVotes = upvotes - downvotes; + var points = netVotes + awardScore * 0.1; + var socialCreditDelta = (int)(points * 10); + + if (socialCreditDelta == 0) continue; + + if (!publisherAccounts.TryGetValue(publisherId, out var receivers) || receivers.Count == 0) + continue; + + // Set social credit for receivers, expired before next settle + foreach (var receiver in receivers) + { + await socialCredits.AddRecordAsync(new AddSocialCreditRecordRequest + { + Reason = $"Publishing Reward on {date}", + ReasonType = "publishers.rewards", + AccountId = receiver.Id.ToString(), + Delta = socialCreditDelta, + ExpiredAt = expiredAt.ToTimestamp(), + }); + } + } + } } diff --git a/DysonNetwork.Sphere/Publisher/PublisherSettlementJob.cs b/DysonNetwork.Sphere/Publisher/PublisherSettlementJob.cs new file mode 100644 index 0000000..c63ec5d --- /dev/null +++ b/DysonNetwork.Sphere/Publisher/PublisherSettlementJob.cs @@ -0,0 +1,12 @@ +using Quartz; +using DysonNetwork.Sphere.Publisher; + +namespace DysonNetwork.Sphere.Publisher; + +public class PublisherSettlementJob(PublisherService publisherService) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await publisherService.SettlePublisherRewards(); + } +} diff --git a/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs b/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs index 572534f..2f200dd 100644 --- a/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs +++ b/DysonNetwork.Sphere/Startup/ScheduledJobsConfiguration.cs @@ -1,4 +1,5 @@ using DysonNetwork.Sphere.Post; +using DysonNetwork.Sphere.Publisher; using DysonNetwork.Sphere.WebReader; using Quartz; @@ -31,6 +32,13 @@ public static class ScheduledJobsConfiguration .WithIdentity("WebFeedScraperTrigger") .WithCronSchedule("0 0 0 * * ?") ); + + q.AddJob(opts => opts.WithIdentity("PublisherSettlement")); + q.AddTrigger(opts => opts + .ForJob("PublisherSettlement") + .WithIdentity("PublisherSettlementTrigger") + .WithCronSchedule("0 0 0 * * ?") + ); }); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);