From a932108c873443656ae91435f8251ecf56e0547a Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 2 Aug 2025 18:45:19 +0800 Subject: [PATCH] :sparkles: Poll stats --- DysonNetwork.Sphere/Poll/Poll.cs | 7 +- DysonNetwork.Sphere/Poll/PollController.cs | 5 +- DysonNetwork.Sphere/Poll/PollService.cs | 152 +++++++++++++++++++-- 3 files changed, 151 insertions(+), 13 deletions(-) diff --git a/DysonNetwork.Sphere/Poll/Poll.cs b/DysonNetwork.Sphere/Poll/Poll.cs index 06aefb8..65fc453 100644 --- a/DysonNetwork.Sphere/Poll/Poll.cs +++ b/DysonNetwork.Sphere/Poll/Poll.cs @@ -20,13 +20,14 @@ public class Poll : ModelBase public Publisher.Publisher Publisher { get; set; } = null!; } -public class PollWithUserAnswer : Poll +public class PollWithAnswer : Poll { public PollAnswer? UserAnswer { get; set; } + public Dictionary> Stats { get; set; } = new(); // question id -> (option id -> count) - public static PollWithUserAnswer FromPoll(Poll poll, PollAnswer? userAnswer = null) + public static PollWithAnswer FromPoll(Poll poll, PollAnswer? userAnswer = null) { - return new PollWithUserAnswer + return new PollWithAnswer { Id = poll.Id, Title = poll.Title, diff --git a/DysonNetwork.Sphere/Poll/PollController.cs b/DysonNetwork.Sphere/Poll/PollController.cs index 028a999..862b096 100644 --- a/DysonNetwork.Sphere/Poll/PollController.cs +++ b/DysonNetwork.Sphere/Poll/PollController.cs @@ -13,13 +13,13 @@ namespace DysonNetwork.Sphere.Poll; public class PollController(AppDatabase db, PollService polls, PublisherService pub) : ControllerBase { [HttpGet("{id:guid}")] - public async Task> GetPoll(Guid id) + public async Task> GetPoll(Guid id) { var poll = await db.Polls .Include(p => p.Questions) .FirstOrDefaultAsync(p => p.Id == id); if (poll is null) return NotFound("Poll not found"); - var pollWithAnswer = PollWithUserAnswer.FromPoll(poll); + var pollWithAnswer = PollWithAnswer.FromPoll(poll); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Ok(pollWithAnswer); @@ -27,6 +27,7 @@ public class PollController(AppDatabase db, PollService polls, PublisherService var answer = await polls.GetPollAnswer(id, accountId); if (answer is not null) pollWithAnswer.UserAnswer = answer; + pollWithAnswer.Stats = await polls.GetPollStats(id); return Ok(pollWithAnswer); } diff --git a/DysonNetwork.Sphere/Poll/PollService.cs b/DysonNetwork.Sphere/Poll/PollService.cs index 2a0f262..e6548db 100644 --- a/DysonNetwork.Sphere/Poll/PollService.cs +++ b/DysonNetwork.Sphere/Poll/PollService.cs @@ -35,7 +35,7 @@ public class PollService(AppDatabase db, ICacheService cache) public async Task GetPollAnswer(Guid pollId, Guid accountId) { - var cacheKey = $"poll:answer:{pollId}:{accountId}"; + var cacheKey = $"{PollAnswerCachePrefix}{pollId}:{accountId}"; var cachedAnswer = await cache.GetAsync(cacheKey); if (cachedAnswer is not null) return cachedAnswer; @@ -123,9 +123,12 @@ public class PollService(AppDatabase db, ICacheService cache) await db.PollAnswers.AddAsync(answerRecord); await db.SaveChangesAsync(); - // Invalidate the cache for this poll answer - var cacheKey = $"poll:answer:{pollId}:{accountId}"; - await cache.SetAsync(cacheKey, answerRecord, TimeSpan.FromMinutes(30)); + // Update cache for this poll answer and invalidate stats cache + var answerCacheKey = $"poll:answer:{pollId}:{accountId}"; + await cache.SetAsync(answerCacheKey, answerRecord, TimeSpan.FromMinutes(30)); + + // Invalidate all stats cache for this poll since answers have changed + await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId); return answerRecord; } @@ -137,11 +140,144 @@ public class PollService(AppDatabase db, ICacheService cache) .Where(e => e.PollId == pollId && e.AccountId == accountId) .ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, now)) > 0; - if (result) + if (!result) return result; + + // Remove the cached answer if it exists + var answerCacheKey = $"poll:answer:{pollId}:{accountId}"; + await cache.RemoveAsync(answerCacheKey); + + // Invalidate all stats cache for this poll since answers have changed + await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId); + + return result; + } + + private const string PollStatsCachePrefix = "poll:stats:"; + private const string PollCacheGroupPrefix = "poll:"; + + // Returns stats for a single question (option id -> count) + public async Task> GetPollQuestionStats(Guid questionId) + { + var cacheKey = $"{PollStatsCachePrefix}{questionId}"; + + // Try to get from cache first + var (found, cachedStats) = await cache.GetAsyncWithStatus>(cacheKey); + if (found && cachedStats != null) { - // Remove the cached answer if it exists - var cacheKey = $"poll:answer:{pollId}:{accountId}"; - await cache.RemoveAsync(cacheKey); + return cachedStats; + } + + var question = await db.PollQuestions + .Where(q => q.Id == questionId) + .FirstOrDefaultAsync(); + + if (question == null) + throw new Exception("Question not found"); + + var answers = await db.PollAnswers + .Where(a => a.PollId == question.PollId && a.DeletedAt == null) + .ToListAsync(); + + var stats = new Dictionary(); + + foreach (var answer in answers) + { + if (!answer.Answer.TryGetValue(questionId.ToString(), out var value)) + continue; + + switch (question.Type) + { + case PollQuestionType.SingleChoice: + if (value.ValueKind == JsonValueKind.String && + Guid.TryParse(value.GetString(), out var selected)) + { + stats.TryGetValue(selected.ToString(), out var count); + stats[selected.ToString()] = count + 1; + } + + break; + + case PollQuestionType.MultipleChoice: + if (value.ValueKind == JsonValueKind.Array) + { + foreach (var element in value.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String || + !Guid.TryParse(element.GetString(), out var opt)) continue; + stats.TryGetValue(opt.ToString(), out var count); + stats[opt.ToString()] = count + 1; + } + } + + break; + + case PollQuestionType.YesNo: + var id = value.ValueKind switch + { + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => "neither" + }; + + stats.TryGetValue(id, out var ynCount); + stats[id] = ynCount + 1; + break; + + case PollQuestionType.Rating: + double sum = 0; + var countRating = 0; + + foreach (var rating in answers) + { + if (!rating.Answer.TryGetValue(questionId.ToString(), out var ratingValue)) + continue; + + if (ratingValue.ValueKind == JsonValueKind.Number && + ratingValue.TryGetDouble(out var ratingNumber)) + { + sum += ratingNumber; + countRating++; + } + } + + if (countRating > 0) + { + var avgRating = sum / countRating; + stats["rating"] = (int)Math.Round(avgRating); + } + + break; + + case PollQuestionType.FreeText: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + // Cache the result with a 1-hour expiration and add to the poll cache group + await cache.SetWithGroupsAsync( + cacheKey, + stats, + new[] { PollCacheGroupPrefix + question.PollId }, + TimeSpan.FromHours(1)); + + return stats; + } + + // Returns stats for all questions in a poll (question id -> (option id -> count)) + public async Task>> GetPollStats(Guid pollId) + { + var questions = await db.PollQuestions + .Where(q => q.PollId == pollId) + .ToListAsync(); + + var result = new Dictionary>(); + + foreach (var question in questions) + { + var stats = await GetPollQuestionStats(question.Id); + result[question.Id] = stats; } return result;