From 3ce457e9f919f97f1e2ada8582dc0298e63050c9 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 1 Nov 2025 22:34:45 +0800 Subject: [PATCH] :recycle: Optimized presense activity API --- .../Account/AccountEventService.cs | 51 +++- .../Account/PresenceActivityController.cs | 232 +++++++++--------- .../Migrations/AppDatabaseModelSnapshot.cs | 20 ++ DysonNetwork.Shared/Models/AccountEvent.cs | 12 + 4 files changed, 191 insertions(+), 124 deletions(-) diff --git a/DysonNetwork.Pass/Account/AccountEventService.cs b/DysonNetwork.Pass/Account/AccountEventService.cs index b47ce77..1d51d0b 100644 --- a/DysonNetwork.Pass/Account/AccountEventService.cs +++ b/DysonNetwork.Pass/Account/AccountEventService.cs @@ -453,13 +453,29 @@ public class AccountEventService( var now = SystemClock.Instance.GetCurrentInstant(); var activities = await db.PresenceActivities - .Where(e => e.AccountId == userId && e.LeaseExpiresAt > now) + .Where(e => e.AccountId == userId && e.LeaseExpiresAt > now && e.DeletedAt == null) .ToListAsync(); await cache.SetWithGroupsAsync(cacheKey, activities, [$"{AccountService.AccountCachePrefix}{userId}"], TimeSpan.FromMinutes(1)); return activities; } + public async Task<(List, int)> GetAllActivities(Guid userId, int offset = 0, int take = 20) + { + var query = db.PresenceActivities + .Where(e => e.AccountId == userId && e.DeletedAt == null); + + var totalCount = await query.CountAsync(); + + var activities = await query + .OrderByDescending(e => e.CreatedAt) + .Skip(offset) + .Take(take) + .ToListAsync(); + + return (activities, totalCount); + } + public async Task SetActivity(SnPresenceActivity activity, int leaseMinutes) { if (leaseMinutes < 1 || leaseMinutes > 60) @@ -477,12 +493,15 @@ public class AccountEventService( return activity; } - public async Task UpdateActivity(Guid activityId, Action update, int? leaseMinutes = null) + public async Task UpdateActivity(Guid activityId, Guid userId, Action update, int? leaseMinutes = null) { var activity = await db.PresenceActivities.FindAsync(activityId); if (activity == null) throw new KeyNotFoundException("Activity not found"); + if (activity.AccountId != userId) + throw new UnauthorizedAccessException("Activity does not belong to user"); + if (leaseMinutes.HasValue) { if (leaseMinutes.Value < 1 || leaseMinutes.Value > 60) @@ -527,17 +546,39 @@ public class AccountEventService( { var activity = await db.PresenceActivities.FirstOrDefaultAsync(e => e.ManualId == manualId && e.AccountId == userId); if (activity == null) return false; - db.Remove(activity); + var now = SystemClock.Instance.GetCurrentInstant(); + if (activity.LeaseExpiresAt <= now) + { + activity.DeletedAt = now; + } + else + { + activity.LeaseExpiresAt = now; + } + db.Update(activity); await db.SaveChangesAsync(); PurgeActivityCache(activity.AccountId); return true; } - public async Task DeleteActivity(Guid activityId) + public async Task DeleteActivity(Guid activityId, Guid userId) { var activity = await db.PresenceActivities.FindAsync(activityId); if (activity == null) return false; - db.Remove(activity); + + if (activity.AccountId != userId) + throw new UnauthorizedAccessException("Activity does not belong to user"); + + var now = SystemClock.Instance.GetCurrentInstant(); + if (activity.LeaseExpiresAt <= now) + { + activity.DeletedAt = now; + } + else + { + activity.LeaseExpiresAt = now; + } + db.Update(activity); await db.SaveChangesAsync(); PurgeActivityCache(activity.AccountId); return true; diff --git a/DysonNetwork.Pass/Account/PresenceActivityController.cs b/DysonNetwork.Pass/Account/PresenceActivityController.cs index b153ffa..ee0adad 100644 --- a/DysonNetwork.Pass/Account/PresenceActivityController.cs +++ b/DysonNetwork.Pass/Account/PresenceActivityController.cs @@ -15,17 +15,35 @@ public class PresenceActivityController(AppDatabase db, AccountEventService serv : ControllerBase { /// - /// Retrieves all active (non-expired) presence activities for the authenticated user. + /// Retrieves active (non-expired) presence activities for the authenticated user. + /// Optionally includes expired activities if includeExpired is true. /// - /// List of active presence activities + /// Whether to include expired activities + /// The number of activities to skip for pagination + /// The maximum number of activities to return + /// List of presence activities [HttpGet] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task>> GetActivities() + public async Task>> GetActivities( + [FromQuery] bool includeExpired = false, + [FromQuery] int offset = 0, + [FromQuery] int take = 20 + ) { if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); - var activities = await service.GetActiveActivities(currentUser.Id); + List activities; + if (includeExpired) + { + (activities, var total) = await service.GetAllActivities(currentUser.Id, offset, take); + Response.Headers["X-Total"] = total.ToString(); + } + else + { + activities = await service.GetActiveActivities(currentUser.Id); + } + return Ok(activities); } @@ -50,10 +68,12 @@ public class PresenceActivityController(AppDatabase db, AccountEventService serv } /// - /// Creates a new presence activity with lease expiration. + /// Creates or updates a presence activity with lease expiration. + /// If an activity with the same 'manualId' exists, it will be updated. + /// Otherwise, a new activity will be created. /// - /// Activity creation parameters - /// The created activity + /// Activity creation or update parameters + /// The created or updated activity [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -65,106 +85,97 @@ public class PresenceActivityController(AppDatabase db, AccountEventService serv if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); - var activity = new SnPresenceActivity + if (!string.IsNullOrWhiteSpace(request.ManualId)) { - Type = request.Type, + var result = await service.UpdateActivityByManualId( + request.ManualId, + currentUser.Id, + activity => + { + if (request.Type.HasValue) + activity.Type = request.Type.Value; + activity.Title = request.Title; + activity.Subtitle = request.Subtitle; + activity.Caption = request.Caption; + activity.LargeImage = request.LargeImage; + activity.SmallImage = request.SmallImage; + activity.TitleUrl = request.TitleUrl; + activity.SubtitleUrl = request.SubtitleUrl; + activity.Meta = request.Meta; + }, + request.LeaseMinutes + ); + + if (result != null) + { + return Ok(result); + } + } + + if (!request.Type.HasValue) + return BadRequest("Type is required when creating a new activity"); + + var newActivity = new SnPresenceActivity + { + AccountId = currentUser.Id, + Type = request.Type.Value, ManualId = request.ManualId, Title = request.Title, Subtitle = request.Subtitle, Caption = request.Caption, - Meta = request.Meta, - AccountId = currentUser.Id, + LargeImage = request.LargeImage, + SmallImage = request.SmallImage, + TitleUrl = request.TitleUrl, + SubtitleUrl = request.SubtitleUrl, + Meta = request.Meta }; - var result = await service.SetActivity(activity, request.LeaseMinutes); - return Ok(result); + + var createResult = await service.SetActivity(newActivity, request.LeaseMinutes); + return Ok(createResult); } /// /// Updates an existing presence activity using either its GUID or manual ID. /// /// System-generated GUID of the activity (optional) - /// User-defined manual ID of the activity (optional) /// Update parameters (only provided fields are updated) /// The updated activity /// One of 'id' or 'manualId' must be provided and non-empty. - [HttpPut] + [HttpPut("{id:guid}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task> UpdateActivity( - [FromQuery] string? id, - [FromQuery] string? manualId, - [FromBody] UpdateActivityRequest request + [FromRoute] Guid id, + [FromBody] SetActivityRequest 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; + var result = await service.UpdateActivity( + id, + currentUser.Id, + activity => + { + if (request.Type.HasValue) + activity.Type = request.Type.Value; + if (request.Title != null) + activity.Title = request.Title; + if (request.Subtitle != null) + activity.Subtitle = request.Subtitle; + if (request.Caption != null) + activity.Caption = request.Caption; + if (request.ManualId != null) + activity.ManualId = request.ManualId; + if (request.Meta != null) + activity.Meta = request.Meta; + }, + 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"); - } + return Ok(result); } /// @@ -195,18 +206,17 @@ public class PresenceActivityController(AppDatabase db, AccountEventService serv 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(); - } + if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var activityGuid)) return BadRequest("Either 'id' (GUID) or 'manualId' must be provided"); + { + var deleted = await service.DeleteActivity(activityGuid, currentUser.Id); + if (!deleted) + return NotFound(); + + return NoContent(); } + } /// @@ -215,7 +225,7 @@ public class PresenceActivityController(AppDatabase db, AccountEventService serv public class SetActivityRequest { /// The type of presence activity (e.g., Gaming, Music, Workout) - public PresenceType Type { get; set; } + public PresenceType? Type { get; set; } /// User-defined identifier for the activity (optional, for easy reference) public string? ManualId { get; set; } @@ -229,38 +239,22 @@ public class PresenceActivityController(AppDatabase db, AccountEventService serv /// Additional caption/description public string? Caption { get; set; } + /// Large image URL or base64 string + public string? LargeImage { get; set; } + + /// Small image URL or base64 string + public string? SmallImage { get; set; } + + /// Title URL + public string? TitleUrl { get; set; } + + /// Subtitle URL + public string? SubtitleUrl { get; set; } + /// Extensible metadata dictionary for custom developer data public Dictionary? Meta { get; set; } /// Lease duration in minutes (1-60, default: 5) public int LeaseMinutes { get; set; } = 5; } - - /// - /// Request model for updating an existing presence activity. - /// All fields are optional and will only update if provided. - /// - public class UpdateActivityRequest - { - /// The type of presence activity (optional update) - public PresenceType? Type { get; set; } - - /// User-defined identifier update - public string? ManualId { get; set; } - - /// Title update - public string? Title { get; set; } - - /// Subtitle update - public string? Subtitle { get; set; } - - /// Caption update - public string? Caption { get; set; } - - /// Metadata update - public Dictionary? Meta { get; set; } - - /// Lease renewal in minutes - public int? LeaseMinutes { get; set; } - } } diff --git a/DysonNetwork.Pass/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Pass/Migrations/AppDatabaseModelSnapshot.cs index dd327d6..775dd2b 100644 --- a/DysonNetwork.Pass/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Pass/Migrations/AppDatabaseModelSnapshot.cs @@ -1387,6 +1387,11 @@ namespace DysonNetwork.Pass.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("deleted_at"); + b.Property("LargeImage") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("large_image"); + b.Property("LeaseExpiresAt") .HasColumnType("timestamp with time zone") .HasColumnName("lease_expires_at"); @@ -1404,16 +1409,31 @@ namespace DysonNetwork.Pass.Migrations .HasColumnType("jsonb") .HasColumnName("meta"); + b.Property("SmallImage") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("small_image"); + b.Property("Subtitle") .HasMaxLength(4096) .HasColumnType("character varying(4096)") .HasColumnName("subtitle"); + b.Property("SubtitleUrl") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("subtitle_url"); + b.Property("Title") .HasMaxLength(4096) .HasColumnType("character varying(4096)") .HasColumnName("title"); + b.Property("TitleUrl") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("title_url"); + b.Property("Type") .HasColumnType("integer") .HasColumnName("type"); diff --git a/DysonNetwork.Shared/Models/AccountEvent.cs b/DysonNetwork.Shared/Models/AccountEvent.cs index 25b0641..462d6c5 100644 --- a/DysonNetwork.Shared/Models/AccountEvent.cs +++ b/DysonNetwork.Shared/Models/AccountEvent.cs @@ -163,6 +163,18 @@ public class SnPresenceActivity : ModelBase [MaxLength(4096)] public string? Caption { get; set; } + [MaxLength(4096)] + public string? LargeImage { get; set; } + + [MaxLength(4096)] + public string? SmallImage { get; set; } + + [MaxLength(4096)] + public string? TitleUrl { get; set; } + + [MaxLength(4096)] + public string? SubtitleUrl { get; set; } + [Column(TypeName = "jsonb")] public Dictionary? Meta { get; set; }