Publisher rewarding

This commit is contained in:
2025-11-02 11:59:02 +08:00
parent 5e5f4528b9
commit 322dee4453
8 changed files with 312 additions and 50 deletions

View File

@@ -8,8 +8,14 @@ namespace DysonNetwork.Pass.Credit;
public class SocialCreditService(AppDatabase db, ICacheService cache)
{
private const string CacheKeyPrefix = "account:credits:";
public async Task<SnSocialCreditRecord> AddRecord(string reasonType, string reason, double delta, Guid accountId)
public async Task<SnSocialCreditRecord> 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<double> GetSocialCredit(Guid accountId)
{
var cached = await cache.GetAsync<double?>($"{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));
}
}
}

View File

@@ -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<Shared.Proto.SocialCreditRecord> AddRecord(AddSocialCreditRecordRequest request, ServerCallContext context)
public override async Task<SocialCreditRecord> 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<SocialCreditResponse> GetSocialCredit(GetSocialCreditRequest request, ServerCallContext context)
public override async Task<SocialCreditResponse> GetSocialCredit(GetSocialCreditRequest request,
ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var amount = await creditService.GetSocialCredit(accountId);
return new SocialCreditResponse { Amount = amount };
}
}

View File

@@ -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);
}

View File

@@ -71,6 +71,18 @@ public static class ServiceInjectionHelper
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
);
services.AddSingleton<RemoteRealmService>();
services
.AddGrpcClient<SocialCreditService.SocialCreditServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
);
services
.AddGrpcClient<ExperienceService.ExperienceServiceClient>(o => o.Address = new Uri("https://_grpc.pass"))
.ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler()
{ ServerCertificateCustomValidationCallback = (_, _, _, _) => true }
);
return services;
}

View File

@@ -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<ActionResult<ActivityHeatmap>> GetPublisherHeatmap(string name)
{
@@ -650,6 +651,27 @@ public class PublisherController(
return Ok(dict);
}
[HttpGet("{name}/rewards")]
[Authorize]
public async Task<ActionResult<PublisherService.PublisherRewardPreview>> 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<IActionResult> PerformLotteryDraw()
{
await ps.SettlePublisherRewards();
return Ok();
}
}

View File

@@ -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<PublisherRewardPreview> GetPublisherExpectedReward(Guid publisherId)
{
var cacheKey = $"publisher:{publisherId}:rewards";
var (found, cached) = await cache.GetAsyncWithStatus<PublisherRewardPreview>(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(),
});
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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<PublisherSettlementJob>(opts => opts.WithIdentity("PublisherSettlement"));
q.AddTrigger(opts => opts
.ForJob("PublisherSettlement")
.WithIdentity("PublisherSettlementTrigger")
.WithCronSchedule("0 0 0 * * ?")
);
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);