♻️ Refactored activities

This commit is contained in:
LittleSheep 2025-06-08 23:52:02 +08:00
parent 39533cced3
commit b8341734df
11 changed files with 3512 additions and 292 deletions

View File

@ -13,7 +13,6 @@ namespace DysonNetwork.Sphere.Account;
public class AccountEventService(
AppDatabase db,
ActivityService act,
WebSocketService ws,
ICacheService cache,
PaymentService payment,
@ -86,13 +85,6 @@ public class AccountEventService(
db.AccountStatuses.Add(status);
await db.SaveChangesAsync();
await act.CreateActivity(
user,
"accounts.status",
$"account.statuses/{status.Id}",
ActivityVisibility.Friends
);
return status;
}
@ -220,13 +212,6 @@ public class AccountEventService(
db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Don't forget to save changes to the database
await act.CreateActivity(
user,
"accounts.check-in",
$"account.check-in/{result.Id}",
ActivityVisibility.Friends
);
// The lock will be automatically released by the await using statement
return result;
}

View File

@ -1,26 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public enum ActivityVisibility
public interface IActivity
{
Public,
Friends,
Selected
public Activity ToActivity();
}
[NotMapped]
public class Activity : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid Id { get; set; }
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public ActivityVisibility Visibility { get; set; } = ActivityVisibility.Public;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
[Column(TypeName = "jsonb")] public ICollection<Guid> UsersVisible { get; set; } = new List<Guid>();
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public object? Data { get; set; }
[NotMapped] public object? Data { get; set; }
// Outdated fields, for backward compability
public int Visibility => 0;
public static Activity Empty()
{
var now = SystemClock.Instance.GetCurrentInstant();
return new Activity
{
CreatedAt = now,
UpdatedAt = now,
Id = Guid.NewGuid(),
Type = "empty",
ResourceIdentifier = "none"
};
}
}

View File

@ -1,38 +1,38 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
/// <summary>
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
/// </summary>
[ApiController]
[Route("/activities")]
public class ActivityController(
AppDatabase db,
ActivityReaderService reader,
RelationshipService rels) : ControllerBase
ActivityService acts
) : ControllerBase
{
/// <summary>
/// Listing the activities for the user, users may be logged in or not to use this API.
/// When the users are not logged in, this API will return the posts that are public.
/// When the users are logged in,
/// the API will personalize the user's experience
/// by ranking up the people they like and the posts they like.
/// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them.
/// </summary>
[HttpGet]
public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] int offset, [FromQuery] int take = 20)
public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] int? cursor, [FromQuery] int take = 20)
{
var cursorTimestamp = cursor is <= 1000
? SystemClock.Instance.GetCurrentInstant()
: Instant.FromUnixTimeMilliseconds(cursor!.Value);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
if (currentUserValue is not Account.Account currentUser)
return Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp));
var totalCount = await db.Activities
.FilterWithVisibility(currentUser, userFriends)
.CountAsync();
var activities = await db.Activities
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.FilterWithVisibility(currentUser, userFriends)
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
activities = await reader.LoadActivityData(activities, currentUser, userFriends);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(activities);
return Ok(await acts.GetActivities(take, cursorTimestamp, currentUser));
}
}

View File

@ -1,173 +1,62 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Post;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public class ActivityReaderService(AppDatabase db, PostService ps)
public class ActivityService(AppDatabase db, RelationshipService rels)
{
public async Task<List<Activity>> LoadActivityData(List<Activity> input, Account.Account? currentUser,
List<Guid> userFriends)
public async Task<List<Activity>> GetActivitiesForAnyone(int take, Instant cursor)
{
if (input.Count == 0) return input;
var activities = new List<Activity>();
var postsId = input
.Where(e => e.ResourceIdentifier.StartsWith("posts/"))
.Select(e => Guid.Parse(e.ResourceIdentifier.Split("/").Last()))
.Distinct()
.ToList();
if (postsId.Count > 0)
{
var posts = await db.Posts.Where(e => postsId.Contains(e.Id))
// Crunching up data
var posts = await db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.FilterWithVisibility(currentUser, userFriends)
.Where(e => e.RepliedPostId == null)
.Where(p => p.CreatedAt > cursor)
.FilterWithVisibility(null, [], isListing: true)
.Take(take)
.ToListAsync();
posts = PostService.TruncatePostContent(posts);
posts = await ps.LoadPublishers(posts);
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
// Formatting data
foreach (var post in posts)
post.ReactionsCount =
reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>();
activities.Add(post.ToActivity());
var postsDict = posts.ToDictionary(p => p.Id);
foreach (var item in input)
{
var resourceIdentifier = item.ResourceIdentifier;
if (!resourceIdentifier.StartsWith("posts/")) continue;
var postId = Guid.Parse(resourceIdentifier.Split("/").Last());
if (postsDict.TryGetValue(postId, out var post) && item.Data is null)
{
item.Data = post;
}
}
return activities;
}
var statusesId = input
.Where(e => e.ResourceIdentifier.StartsWith("account.statuses/"))
.Select(e => Guid.Parse(e.ResourceIdentifier.Split("/").Last()))
.Distinct()
.ToList();
if (statusesId.Count > 0)
public async Task<List<Activity>> GetActivities(int take, Instant cursor, Account.Account currentUser)
{
var statuses = await db.AccountStatuses.Where(e => statusesId.Contains(e.Id))
.Include(e => e.Account)
.Include(e => e.Account.Profile)
var activities = new List<Activity>();
var userFriends = await rels.ListAccountFriends(currentUser);
// Crunching data
var posts = await db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Where(e => e.RepliedPostId == null || e.RepliedPostId == currentUser.Id)
.Where(p => p.CreatedAt > cursor)
.FilterWithVisibility(currentUser, userFriends, isListing: true)
.Take(take)
.ToListAsync();
var statusesDict = statuses.ToDictionary(p => p.Id);
foreach (var item in input)
// Formatting data
foreach (var post in posts)
activities.Add(post.ToActivity());
if (activities.Count == 0)
{
var resourceIdentifier = item.ResourceIdentifier;
if (!resourceIdentifier.StartsWith("account.statuses/")) continue;
var statusId = Guid.Parse(resourceIdentifier.Split("/").Last());
if (statusesDict.TryGetValue(statusId, out var status) && item.Data is null)
{
item.Data = status;
}
}
var now = SystemClock.Instance.GetCurrentInstant();
activities.Add(Activity.Empty());
}
var checkInId = input
.Where(e => e.ResourceIdentifier.StartsWith("account.check-in/"))
.Select(e => Guid.Parse(e.ResourceIdentifier.Split("/").Last()))
.Distinct()
.ToList();
if (checkInId.Count > 0)
{
var checkIns = await db.AccountCheckInResults.Where(e => checkInId.Contains(e.Id))
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.ToListAsync();
var checkInsDict = checkIns.ToDictionary(p => p.Id);
foreach (var item in input)
{
var resourceIdentifier = item.ResourceIdentifier;
if (!resourceIdentifier.StartsWith("account.check-in/")) continue;
var checkInResultId = Guid.Parse(resourceIdentifier.Split("/").Last());
if (checkInsDict.TryGetValue(checkInResultId, out var checkIn) && item.Data is null)
{
item.Data = checkIn;
}
}
}
return input;
}
}
public class ActivityService(AppDatabase db)
{
public async Task<Activity> CreateActivity(
Account.Account user,
string type,
string identifier,
ActivityVisibility visibility = ActivityVisibility.Public,
List<Guid>? visibleUsers = null
)
{
var activity = new Activity
{
Type = type,
ResourceIdentifier = identifier,
Visibility = visibility,
AccountId = user.Id,
UsersVisible = visibleUsers ?? []
};
db.Activities.Add(activity);
await db.SaveChangesAsync();
return activity;
}
public async Task CreateNewPostActivity(Account.Account user, Post.Post post)
{
if (post.Visibility is PostVisibility.Unlisted or PostVisibility.Private) return;
var identifier = $"posts/{post.Id}";
if (post.RepliedPostId is not null)
{
var ogPost = await db.Posts
.Where(e => e.Id == post.RepliedPostId)
.Include(e => e.Publisher)
.FirstOrDefaultAsync();
if (ogPost?.Publisher.AccountId == null) return;
await CreateActivity(
user,
"posts.new.replies",
identifier,
ActivityVisibility.Selected,
[ogPost.Publisher.AccountId!.Value]
);
return;
}
await CreateActivity(
user,
"posts.new",
identifier,
post.Visibility == PostVisibility.Friends ? ActivityVisibility.Friends : ActivityVisibility.Public
);
}
}
public static class ActivityQueryExtensions
{
public static IQueryable<Activity> FilterWithVisibility(this IQueryable<Activity> source,
Account.Account? currentUser, List<Guid> userFriends)
{
if (currentUser is null)
return source.Where(e => e.Visibility == ActivityVisibility.Public);
return source
.Where(e => e.Visibility != ActivityVisibility.Friends ||
userFriends.Contains(e.AccountId) ||
e.AccountId == currentUser.Id)
.Where(e => e.Visibility != ActivityVisibility.Selected ||
EF.Functions.JsonExists(e.UsersVisible, currentUser.Id.ToString()));
return activities;
}
}

View File

@ -50,8 +50,6 @@ public class AppDatabase(
public DbSet<Storage.CloudFile> Files { get; set; }
public DbSet<Storage.CloudFileReference> FileReferences { get; set; }
public DbSet<Activity.Activity> Activities { get; set; }
public DbSet<Publisher.Publisher> Publishers { get; set; }
public DbSet<PublisherMember> PublisherMembers { get; set; }
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class RemoveActivities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "activities");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "activities",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
resource_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
users_visible = table.Column<ICollection<Guid>>(type: "jsonb", nullable: false),
visibility = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_activities", x => x.id);
table.ForeignKey(
name: "fk_activities_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_activities_account_id",
table: "activities",
column: "account_id");
}
}
}

View File

@ -727,64 +727,6 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("account_statuses", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Activity.Activity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Dictionary<string, object>>("Meta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("ResourceIdentifier")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("resource_identifier");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<ICollection<Guid>>("UsersVisible")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("users_visible");
b.Property<int>("Visibility")
.HasColumnType("integer")
.HasColumnName("visibility");
b.HasKey("Id")
.HasName("pk_activities");
b.HasIndex("AccountId")
.HasDatabaseName("ix_activities_account_id");
b.ToTable("activities", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
{
b.Property<Guid>("Id")
@ -2795,18 +2737,6 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Activity.Activity", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_activities_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Storage;
using NodaTime;
using NpgsqlTypes;
@ -22,7 +23,7 @@ public enum PostVisibility
Private
}
public class Post : ModelBase, IIdentifiedResource
public class Post : ModelBase, IIdentifiedResource, IActivity
{
public Guid Id { get; set; }
[MaxLength(1024)] public string? Title { get; set; }
@ -67,6 +68,20 @@ public class Post : ModelBase, IIdentifiedResource
[NotMapped] public bool IsTruncated = false;
public string ResourceIdentifier => $"post/{Id}";
public Activity.Activity ToActivity()
{
return new Activity.Activity()
{
CreatedAt = CreatedAt,
UpdatedAt = UpdatedAt,
DeletedAt = DeletedAt,
Id = Id,
Type = RepliedPostId is null ? "posts.new" : "posts.new.replies",
ResourceIdentifier = ResourceIdentifier,
Data = null
};
}
}
public class PostTag : ModelBase

View File

@ -13,7 +13,6 @@ public class PostService(
AppDatabase db,
FileService fs,
FileReferenceService fileRefService,
ActivityService act,
IStringLocalizer<NotificationResource> localizer,
NotificationService nty,
IServiceScopeFactory factory
@ -107,8 +106,6 @@ public class PostService(
}
}
await act.CreateNewPostActivity(user, post);
if (post.PublishedAt is not null && post.PublishedAt.Value.ToDateTimeUtc() <= DateTime.UtcNow)
_ = Task.Run(async () =>
{
@ -341,8 +338,12 @@ public class PostService(
public static class PostQueryExtensions
{
public static IQueryable<Post> FilterWithVisibility(this IQueryable<Post> source, Account.Account? currentUser,
List<Guid> userFriends, bool isListing = false)
public static IQueryable<Post> FilterWithVisibility(
this IQueryable<Post> source,
Account.Account? currentUser,
List<Guid> userFriends,
bool isListing = false
)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);

View File

@ -1,6 +1,4 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Threading.RateLimiting;
using DysonNetwork.Sphere;
@ -22,23 +20,18 @@ using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
using DysonNetwork.Sphere.Wallet;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using Prometheus;
using Prometheus.DotNetRuntime;
using Prometheus.SystemMetrics;
using Quartz;
using StackExchange.Redis;
using tusdotnet;
using tusdotnet.Models;
using tusdotnet.Models.Configuration;
using tusdotnet.Stores;
var builder = WebApplication.CreateBuilder(args);
@ -213,7 +206,6 @@ builder.Services.AddScoped<FileReferenceMigrationService>();
builder.Services.AddScoped<PublisherService>();
builder.Services.AddScoped<PublisherSubscriptionService>();
builder.Services.AddScoped<ActivityService>();
builder.Services.AddScoped<ActivityReaderService>();
builder.Services.AddScoped<PostService>();
builder.Services.AddScoped<RealmService>();
builder.Services.AddScoped<ChatRoomService>();