Refreshed account presences system

This commit is contained in:
2025-11-01 17:35:28 +08:00
parent 47722cfd57
commit 4ad63577ba
8 changed files with 3583 additions and 23 deletions

View File

@@ -26,6 +26,7 @@ public class AccountEventService(
{
private static readonly Random Random = new();
private const string StatusCacheKey = "account:status:";
private const string ActivityCacheKey = "account:activities:";
private async Task<bool> GetAccountIsConnected(Guid userId)
{
@@ -41,6 +42,12 @@ public class AccountEventService(
cache.RemoveAsync(cacheKey);
}
public void PurgeActivityCache(Guid userId)
{
var cacheKey = $"{ActivityCacheKey}{userId}";
cache.RemoveAsync(cacheKey);
}
private async Task BroadcastStatusUpdate(SnAccountStatus status)
{
await nats.PublishAsync(
@@ -434,4 +441,105 @@ public class AccountEventService(
};
}).ToList();
}
public async Task<List<SnPresenceActivity>> GetActiveActivities(Guid userId)
{
var cacheKey = $"{ActivityCacheKey}{userId}";
var cachedActivities = await cache.GetAsync<List<SnPresenceActivity>>(cacheKey);
if (cachedActivities != null)
{
return cachedActivities;
}
var now = SystemClock.Instance.GetCurrentInstant();
var activities = await db.PresenceActivities
.Where(e => e.AccountId == userId && e.LeaseExpiresAt > now)
.ToListAsync();
await cache.SetWithGroupsAsync(cacheKey, activities, [$"{AccountService.AccountCachePrefix}{userId}"], TimeSpan.FromMinutes(1));
return activities;
}
public async Task<SnPresenceActivity> SetActivity(SnPresenceActivity activity, int leaseMinutes)
{
if (leaseMinutes < 1 || leaseMinutes > 60)
throw new ArgumentException("Lease minutes must be between 1 and 60");
var now = SystemClock.Instance.GetCurrentInstant();
activity.LeaseMinutes = leaseMinutes;
activity.LeaseExpiresAt = now + Duration.FromMinutes(leaseMinutes);
db.PresenceActivities.Add(activity);
await db.SaveChangesAsync();
PurgeActivityCache(activity.AccountId);
return activity;
}
public async Task<SnPresenceActivity> UpdateActivity(Guid activityId, Action<SnPresenceActivity> update, int? leaseMinutes = null)
{
var activity = await db.PresenceActivities.FindAsync(activityId);
if (activity == null)
throw new KeyNotFoundException("Activity not found");
if (leaseMinutes.HasValue)
{
if (leaseMinutes.Value < 1 || leaseMinutes.Value > 60)
throw new ArgumentException("Lease minutes must be between 1 and 60");
activity.LeaseMinutes = leaseMinutes.Value;
activity.LeaseExpiresAt = SystemClock.Instance.GetCurrentInstant() + Duration.FromMinutes(leaseMinutes.Value);
}
update(activity);
await db.SaveChangesAsync();
PurgeActivityCache(activity.AccountId);
return activity;
}
public async Task<SnPresenceActivity?> UpdateActivityByManualId(string manualId, Guid userId, Action<SnPresenceActivity> update, int? leaseMinutes = null)
{
var activity = await db.PresenceActivities.FirstOrDefaultAsync(e => e.ManualId == manualId && e.AccountId == userId);
if (activity == null)
return null;
if (leaseMinutes.HasValue)
{
if (leaseMinutes.Value < 1 || leaseMinutes.Value > 60)
throw new ArgumentException("Lease minutes must be between 1 and 60");
activity.LeaseMinutes = leaseMinutes.Value;
activity.LeaseExpiresAt = SystemClock.Instance.GetCurrentInstant() + Duration.FromMinutes(leaseMinutes.Value);
}
update(activity);
await db.SaveChangesAsync();
PurgeActivityCache(activity.AccountId);
return activity;
}
public async Task<bool> DeleteActivityByManualId(string manualId, Guid userId)
{
var activity = await db.PresenceActivities.FirstOrDefaultAsync(e => e.ManualId == manualId && e.AccountId == userId);
if (activity == null) return false;
db.Remove(activity);
await db.SaveChangesAsync();
PurgeActivityCache(activity.AccountId);
return true;
}
public async Task<bool> DeleteActivity(Guid activityId)
{
var activity = await db.PresenceActivities.FindAsync(activityId);
if (activity == null) return false;
db.Remove(activity);
await db.SaveChangesAsync();
PurgeActivityCache(activity.AccountId);
return true;
}
}

View File

@@ -0,0 +1,240 @@
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Pass.Account;
/// <summary>
/// Controller for managing user presence activities with lease-based expiration.
/// Supports both user-defined manual IDs and autogenerated GUIDs for activity management.
/// </summary>
[ApiController]
[Route("/api/activities")]
[Authorize]
public class PresenceActivityController(AppDatabase db, AccountEventService service) : ControllerBase
{
/// <summary>
/// Retrieves all active (non-expired) presence activities for the authenticated user.
/// </summary>
/// <returns>List of active presence activities</returns>
[HttpGet]
[ProducesResponseType<List<SnPresenceActivity>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<SnPresenceActivity>>> GetActivities()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var activities = await service.GetActiveActivities(currentUser.Id);
return Ok(activities);
}
/// <summary>
/// Retrieves active presence activities for any user account (admin/debugging endpoint).
/// </summary>
/// <param name="accountId">The account ID to fetch activities for</param>
/// <returns>List of active presence activities</returns>
[HttpGet("{accountId:guid}")]
[ProducesResponseType<List<SnPresenceActivity>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<SnPresenceActivity>>> GetActivitiesByAccountId(Guid accountId)
{
var activities = await service.GetActiveActivities(accountId);
return Ok(activities);
}
/// <summary>
/// Creates a new presence activity with lease expiration.
/// </summary>
/// <param name="request">Activity creation parameters</param>
/// <returns>The created activity</returns>
[HttpPost]
[ProducesResponseType<SnPresenceActivity>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<SnPresenceActivity>> SetActivity(
[FromBody] SetActivityRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var activity = new SnPresenceActivity
{
Type = request.Type,
ManualId = request.ManualId,
Title = request.Title,
Subtitle = request.Subtitle,
Caption = request.Caption,
Meta = request.Meta,
AccountId = currentUser.Id,
};
var result = await service.SetActivity(activity, request.LeaseMinutes);
return Ok(result);
}
/// <summary>
/// Updates an existing presence activity using either its GUID or manual ID.
/// </summary>
/// <param name="id">System-generated GUID of the activity (optional)</param>
/// <param name="manualId">User-defined manual ID of the activity (optional)</param>
/// <param name="request">Update parameters (only provided fields are updated)</param>
/// <returns>The updated activity</returns>
/// <remarks>One of 'id' or 'manualId' must be provided and non-empty.</remarks>
[HttpPut]
[ProducesResponseType<SnPresenceActivity>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<SnPresenceActivity>> UpdateActivity(
[FromQuery] string? id,
[FromQuery] string? manualId,
[FromBody] UpdateActivityRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var type = request.Type;
var title = request.Title;
var subtitle = request.Subtitle;
var caption = request.Caption;
var requestManualId = request.ManualId;
var requestMeta = request.Meta;
var leaseMinutes = request.LeaseMinutes;
if (!string.IsNullOrWhiteSpace(manualId))
{
var result = await service.UpdateActivityByManualId(
manualId,
currentUser.Id,
activity =>
{
if (type.HasValue) activity.Type = type.Value;
if (title != null) activity.Title = title;
if (subtitle != null) activity.Subtitle = subtitle;
if (caption != null) activity.Caption = caption;
if (requestManualId != null) activity.ManualId = requestManualId;
if (requestMeta != null) activity.Meta = requestMeta;
},
leaseMinutes);
if (result == null)
return NotFound();
return Ok(result);
}
else if (!string.IsNullOrWhiteSpace(id) && Guid.TryParse(id, out var activityGuid))
{
var result = await service.UpdateActivity(
activityGuid,
activity =>
{
if (type.HasValue) activity.Type = type.Value;
if (title != null) activity.Title = title;
if (subtitle != null) activity.Subtitle = subtitle;
if (caption != null) activity.Caption = caption;
if (requestManualId != null) activity.ManualId = requestManualId;
if (requestMeta != null) activity.Meta = requestMeta;
},
leaseMinutes);
return Ok(result);
}
else
{
return BadRequest("Either 'id' (GUID) or 'manualId' must be provided");
}
}
/// <summary>
/// Deletes a presence activity using either its GUID or manual ID.
/// </summary>
/// <param name="id">System-generated GUID of the activity (optional)</param>
/// <param name="manualId">User-defined manual ID of the activity (optional)</param>
/// <returns>NoContent on success</returns>
/// <remarks>One of 'id' or 'manualId' must be provided and non-empty. Soft-deletes the activity.</remarks>
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> DeleteActivityById(
[FromQuery] string? id,
[FromQuery] string? manualId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (!string.IsNullOrWhiteSpace(manualId))
{
var deleted = await service.DeleteActivityByManualId(manualId, currentUser.Id);
if (!deleted)
return NotFound();
return NoContent();
}
else
{
if (!string.IsNullOrWhiteSpace(id) && Guid.TryParse(id, out var activityGuid))
{
var deleted = await service.DeleteActivity(activityGuid);
if (!deleted)
return NotFound();
return NoContent();
}
return BadRequest("Either 'id' (GUID) or 'manualId' must be provided");
}
}
/// <summary>
/// Request model for creating a new presence activity.
/// </summary>
public class SetActivityRequest
{
/// <summary>The type of presence activity (e.g., Gaming, Music, Workout)</summary>
public PresenceType Type { get; set; }
/// <summary>User-defined identifier for the activity (optional, for easy reference)</summary>
public string? ManualId { get; set; }
/// <summary>Main title of the activity</summary>
public string? Title { get; set; }
/// <summary>Secondary subtitle of the activity</summary>
public string? Subtitle { get; set; }
/// <summary>Additional caption/description</summary>
public string? Caption { get; set; }
/// <summary>Extensible metadata dictionary for custom developer data</summary>
public Dictionary<string, object>? Meta { get; set; }
/// <summary>Lease duration in minutes (1-60, default: 5)</summary>
public int LeaseMinutes { get; set; } = 5;
}
/// <summary>
/// Request model for updating an existing presence activity.
/// All fields are optional and will only update if provided.
/// </summary>
public class UpdateActivityRequest
{
/// <summary>The type of presence activity (optional update)</summary>
public PresenceType? Type { get; set; }
/// <summary>User-defined identifier update</summary>
public string? ManualId { get; set; }
/// <summary>Title update</summary>
public string? Title { get; set; }
/// <summary>Subtitle update</summary>
public string? Subtitle { get; set; }
/// <summary>Caption update</summary>
public string? Caption { get; set; }
/// <summary>Metadata update</summary>
public Dictionary<string, object>? Meta { get; set; }
/// <summary>Lease renewal in minutes</summary>
public int? LeaseMinutes { get; set; }
}
}

View File

@@ -30,6 +30,7 @@ public class AppDatabase(
public DbSet<SnAccountRelationship> AccountRelationships { get; set; } = null!;
public DbSet<SnAccountStatus> AccountStatuses { get; set; } = null!;
public DbSet<SnCheckInResult> AccountCheckInResults { get; set; } = null!;
public DbSet<SnPresenceActivity> PresenceActivities { get; set; } = null!;
public DbSet<SnAccountBadge> Badges { get; set; } = null!;
public DbSet<SnActionLog> ActionLogs { get; set; } = null!;
public DbSet<SnAbuseReport> AbuseReports { get; set; } = null!;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Pass.Migrations
{
/// <inheritdoc />
public partial class AddPresenceActivity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "background_id",
table: "realms");
migrationBuilder.DropColumn(
name: "picture_id",
table: "realms");
migrationBuilder.CreateTable(
name: "presence_activities",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
manual_id = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
title = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
subtitle = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
caption = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
lease_minutes = table.Column<int>(type: "integer", nullable: false),
lease_expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_presence_activities", x => x.id);
table.ForeignKey(
name: "fk_presence_activities_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_presence_activities_account_id",
table: "presence_activities",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "presence_activities");
migrationBuilder.AddColumn<string>(
name: "background_id",
table: "realms",
type: "character varying(32)",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "picture_id",
table: "realms",
type: "character varying(32)",
maxLength: 32,
nullable: true);
}
}
}

View File

@@ -22,7 +22,7 @@ namespace DysonNetwork.Pass.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("ProductVersion", "9.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -1363,6 +1363,74 @@ namespace DysonNetwork.Pass.Migrations
b.ToTable("permission_nodes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPresenceActivity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<string>("Caption")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("caption");
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<Instant>("LeaseExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("lease_expires_at");
b.Property<int>("LeaseMinutes")
.HasColumnType("integer")
.HasColumnName("lease_minutes");
b.Property<string>("ManualId")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("manual_id");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("Subtitle")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("subtitle");
b.Property<string>("Title")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("title");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_presence_activities");
b.HasIndex("AccountId")
.HasDatabaseName("ix_presence_activities_account_id");
b.ToTable("presence_activities", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealm", b =>
{
b.Property<Guid>("Id")
@@ -1378,11 +1446,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("jsonb")
.HasColumnName("background");
b.Property<string>("BackgroundId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("background_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
@@ -1415,11 +1478,6 @@ namespace DysonNetwork.Pass.Migrations
.HasColumnType("jsonb")
.HasColumnName("picture");
b.Property<string>("PictureId")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("picture_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
@@ -2389,6 +2447,18 @@ namespace DysonNetwork.Pass.Migrations
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPresenceActivity", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_presence_activities_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnRealmMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnRealm", "Realm")