From 445e5d37053b4763e605b15739938b21a42b9e4f Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sat, 24 May 2025 18:29:20 +0800
Subject: [PATCH] :bug: Replace the serializer in cache service with newtonsoft
 json to solve JsonIgnore issue

---
 .../Handlers/MessageTypingHandler.cs          |   1 -
 .../Connection/WebSocketPacket.cs             |   2 +-
 .../DysonNetwork.Sphere.csproj                |   2 +
 .../Permission/PermissionService.cs           |  12 +-
 DysonNetwork.Sphere/Program.cs                |   5 +-
 DysonNetwork.Sphere/Sticker/StickerService.cs |   4 +-
 DysonNetwork.Sphere/Storage/CacheService.cs   | 199 +++++++++---------
 7 files changed, 117 insertions(+), 108 deletions(-)

diff --git a/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs b/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs
index 7bc43b8..11e4dd9 100644
--- a/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs
+++ b/DysonNetwork.Sphere/Connection/Handlers/MessageTypingHandler.cs
@@ -2,7 +2,6 @@ using System.Net.WebSockets;
 using DysonNetwork.Sphere.Chat;
 using DysonNetwork.Sphere.Storage;
 using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
 
 namespace DysonNetwork.Sphere.Connection.Handlers;
 
diff --git a/DysonNetwork.Sphere/Connection/WebSocketPacket.cs b/DysonNetwork.Sphere/Connection/WebSocketPacket.cs
index 09d6f68..1028311 100644
--- a/DysonNetwork.Sphere/Connection/WebSocketPacket.cs
+++ b/DysonNetwork.Sphere/Connection/WebSocketPacket.cs
@@ -61,7 +61,7 @@ public class WebSocketPacket
         {
             PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
             DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
-        }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);;
+        }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
         var json = JsonSerializer.Serialize(this, jsonOpts);
         return System.Text.Encoding.UTF8.GetBytes(json);
     }
diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
index 6833c32..4d2effa 100644
--- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
+++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
@@ -40,7 +40,9 @@
         <PackageReference Include="NetVips" Version="3.0.1"/>
         <PackageReference Include="NetVips.Native.linux-x64" Version="8.16.1"/>
         <PackageReference Include="NetVips.Native.osx-arm64" Version="8.16.1"/>
+        <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
         <PackageReference Include="NodaTime" Version="3.2.2"/>
+        <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
         <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>
diff --git a/DysonNetwork.Sphere/Permission/PermissionService.cs b/DysonNetwork.Sphere/Permission/PermissionService.cs
index 68c4424..45ba123 100644
--- a/DysonNetwork.Sphere/Permission/PermissionService.cs
+++ b/DysonNetwork.Sphere/Permission/PermissionService.cs
@@ -11,17 +11,17 @@ public class PermissionService(
     ILogger<PermissionService> logger)
 {
     private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1);
-    
+
     private const string PermCacheKeyPrefix = "Perm_";
     private const string PermGroupCacheKeyPrefix = "PermCacheGroup_";
     private const string PermissionGroupPrefix = "PermGroup_";
 
-    private static string _GetPermissionCacheKey(string actor, string area, string key) => 
+    private static string _GetPermissionCacheKey(string actor, string area, string key) =>
         PermCacheKeyPrefix + actor + ":" + area + ":" + key;
 
-    private static string _GetGroupsCacheKey(string actor) => 
+    private static string _GetGroupsCacheKey(string actor) =>
         PermGroupCacheKeyPrefix + actor;
-        
+
     private static string _GetPermissionGroupKey(string actor) =>
         PermissionGroupPrefix + actor;
 
@@ -68,8 +68,8 @@ public class PermissionService(
 
         var result = permission is not null ? _DeserializePermissionValue<T>(permission.Value) : default;
 
-        await cache.SetWithGroupsAsync(cacheKey, result, 
-            new[] { _GetPermissionGroupKey(actor) }, 
+        await cache.SetWithGroupsAsync(cacheKey, result,
+            [_GetPermissionGroupKey(actor)],
             CacheExpiration);
         
         return result;
diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs
index a96327f..4c16c3c 100644
--- a/DysonNetwork.Sphere/Program.cs
+++ b/DysonNetwork.Sphere/Program.cs
@@ -45,13 +45,12 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize =
 builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
 
 builder.Services.AddDbContext<AppDatabase>();
-builder.Services.AddSingleton<IConnectionMultiplexer>(sp => 
+builder.Services.AddSingleton<IConnectionMultiplexer>(_ => 
 {
     var connection = builder.Configuration.GetConnectionString("FastRetrieve")!;
     return ConnectionMultiplexer.Connect(connection);
 });
-
-builder.Services.AddScoped<ICacheService, CacheServiceRedis>();
+builder.Services.AddSingleton<ICacheService, CacheServiceRedis>();
 
 builder.Services.AddHttpClient();
 builder.Services.AddControllers().AddJsonOptions(options =>
diff --git a/DysonNetwork.Sphere/Sticker/StickerService.cs b/DysonNetwork.Sphere/Sticker/StickerService.cs
index 82491c8..abae50f 100644
--- a/DysonNetwork.Sphere/Sticker/StickerService.cs
+++ b/DysonNetwork.Sphere/Sticker/StickerService.cs
@@ -67,9 +67,7 @@ public class StickerService(AppDatabase db, FileService fs, ICacheService cache)
 
         // Invalidate cache for all stickers in this pack
         foreach (var sticker in stickers)
-        {
-            PurgeStickerCache(sticker);
-        }
+            await PurgeStickerCache(sticker);
     }
 
     public async Task<Sticker?> LookupStickerByIdentifierAsync(string identifier)
diff --git a/DysonNetwork.Sphere/Storage/CacheService.cs b/DysonNetwork.Sphere/Storage/CacheService.cs
index 61b0cfc..7a81cf1 100644
--- a/DysonNetwork.Sphere/Storage/CacheService.cs
+++ b/DysonNetwork.Sphere/Storage/CacheService.cs
@@ -1,4 +1,7 @@
-using System.Text.Json;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Serialization;
+using NodaTime;
+using NodaTime.Serialization.JsonNet;
 using StackExchange.Redis;
 
 namespace DysonNetwork.Sphere.Storage;
@@ -12,17 +15,17 @@ public interface IDistributedLock : IAsyncDisposable
     /// The resource identifier this lock is protecting
     /// </summary>
     string Resource { get; }
-    
+
     /// <summary>
     /// Unique identifier for this lock instance
     /// </summary>
     string LockId { get; }
-    
+
     /// <summary>
     /// Extends the lock's expiration time
     /// </summary>
     Task<bool> ExtendAsync(TimeSpan timeSpan);
-    
+
     /// <summary>
     /// Releases the lock immediately
     /// </summary>
@@ -35,32 +38,32 @@ public interface ICacheService
     /// Sets a value in the cache with an optional expiration time
     /// </summary>
     Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
-    
+
     /// <summary>
     /// Gets a value from the cache
     /// </summary>
     Task<T?> GetAsync<T>(string key);
-    
+
     /// <summary>
     /// Removes a specific key from the cache
     /// </summary>
     Task<bool> RemoveAsync(string key);
-    
+
     /// <summary>
     /// Adds a key to a group for group-based operations
     /// </summary>
     Task AddToGroupAsync(string key, string group);
-    
+
     /// <summary>
     /// Removes all keys associated with a specific group
     /// </summary>
     Task RemoveGroupAsync(string group);
-    
+
     /// <summary>
     /// Gets all keys belonging to a specific group
     /// </summary>
     Task<IEnumerable<string>> GetGroupKeysAsync(string group);
-    
+
     /// <summary>
     /// Helper method to set a value in cache and associate it with multiple groups in one operation
     /// </summary>
@@ -71,7 +74,7 @@ public interface ICacheService
     /// <param name="expiry">Optional expiration time for the cached item</param>
     /// <returns>True if the set operation was successful</returns>
     Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
-    
+
     /// <summary>
     /// Acquires a distributed lock on the specified resource
     /// </summary>
@@ -80,8 +83,9 @@ public interface ICacheService
     /// <param name="waitTime">How long to wait for the lock before giving up</param>
     /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
     /// <returns>A distributed lock instance if acquired, null otherwise</returns>
-    Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
-    
+    Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
+        TimeSpan? retryInterval = null);
+
     /// <summary>
     /// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
     /// </summary>
@@ -91,8 +95,9 @@ public interface ICacheService
     /// <param name="waitTime">How long to wait for the lock before giving up</param>
     /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
     /// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
-    Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
-    
+    Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
+        TimeSpan? retryInterval = null);
+
     /// <summary>
     /// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
     /// </summary>
@@ -103,31 +108,32 @@ public interface ICacheService
     /// <param name="waitTime">How long to wait for the lock before giving up</param>
     /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
     /// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
-    Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
+    Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
+        TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
 }
 
 public class RedisDistributedLock : IDistributedLock
 {
     private readonly IDatabase _database;
     private bool _disposed;
-    
+
     private const string LockKeyPrefix = "Lock_";
-    
+
     public string Resource { get; }
     public string LockId { get; }
-    
+
     internal RedisDistributedLock(IDatabase database, string resource, string lockId)
     {
         _database = database;
         Resource = resource;
         LockId = lockId;
     }
-    
+
     public async Task<bool> ExtendAsync(TimeSpan timeSpan)
     {
         if (_disposed)
             throw new ObjectDisposedException(nameof(RedisDistributedLock));
-            
+
         var script = @"
             if redis.call('get', KEYS[1]) == ARGV[1] then
                 return redis.call('pexpire', KEYS[1], ARGV[2])
@@ -135,21 +141,21 @@ public class RedisDistributedLock : IDistributedLock
                 return 0
             end
         ";
-        
+
         var result = await _database.ScriptEvaluateAsync(
             script,
-            new RedisKey[] { $"{LockKeyPrefix}{Resource}" },
-            new RedisValue[] { LockId, (long)timeSpan.TotalMilliseconds }
+            [$"{LockKeyPrefix}{Resource}"],
+            [LockId, (long)timeSpan.TotalMilliseconds]
         );
-        
+
         return (long)result! == 1;
     }
-    
+
     public async Task ReleaseAsync()
     {
         if (_disposed)
             return;
-            
+
         var script = @"
             if redis.call('get', KEYS[1]) == ARGV[1] then
                 return redis.call('del', KEYS[1])
@@ -157,16 +163,16 @@ public class RedisDistributedLock : IDistributedLock
                 return 0
             end
         ";
-        
+
         await _database.ScriptEvaluateAsync(
             script,
-            new RedisKey[] { $"{LockKeyPrefix}{Resource}" },
-            new RedisValue[] { LockId }
+            [$"{LockKeyPrefix}{Resource}"],
+            [LockId]
         );
-        
+
         _disposed = true;
     }
-    
+
     public async ValueTask DisposeAsync()
     {
         await ReleaseAsync();
@@ -177,49 +183,54 @@ public class RedisDistributedLock : IDistributedLock
 public class CacheServiceRedis : ICacheService
 {
     private readonly IDatabase _database;
-    private readonly JsonSerializerOptions _serializerOptions;
-    
+    private readonly JsonSerializerSettings _serializerSettings;
+
+    // Global prefix for all cache keys
+    private const string GlobalKeyPrefix = "dyson:";
+
     // Using prefixes for different types of keys
-    private const string GroupKeyPrefix = "CacheGroup_";
-    private const string LockKeyPrefix = "Lock_";
-    
+    private const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
+    private const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
+
     public CacheServiceRedis(IConnectionMultiplexer redis)
     {
         var rds = redis ?? throw new ArgumentNullException(nameof(redis));
         _database = rds.GetDatabase();
-        _serializerOptions = new JsonSerializerOptions
+        _serializerSettings = new JsonSerializerSettings
         {
-            PropertyNameCaseInsensitive = true
-        };
+            ContractResolver = new CamelCasePropertyNamesContractResolver(),
+            PreserveReferencesHandling = PreserveReferencesHandling.Objects,
+            NullValueHandling = NullValueHandling.Include,
+        }.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
     }
-    
+
     public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null)
     {
         if (string.IsNullOrEmpty(key))
             throw new ArgumentException("Key cannot be null or empty", nameof(key));
-            
-        var serializedValue = JsonSerializer.Serialize(value, _serializerOptions);
+
+        var serializedValue = JsonConvert.SerializeObject(value, _serializerSettings);
         return await _database.StringSetAsync(key, serializedValue, expiry);
     }
-    
+
     public async Task<T?> GetAsync<T>(string key)
     {
         if (string.IsNullOrEmpty(key))
             throw new ArgumentException("Key cannot be null or empty", nameof(key));
-            
+
         var value = await _database.StringGetAsync(key);
-        
+
         if (value.IsNullOrEmpty)
             return default;
-            
-        return JsonSerializer.Deserialize<T>(value!, _serializerOptions);
+
+        return JsonConvert.DeserializeObject<T>(value!, _serializerSettings);
     }
-    
+
     public async Task<bool> RemoveAsync(string key)
     {
         if (string.IsNullOrEmpty(key))
             throw new ArgumentException("Key cannot be null or empty", nameof(key));
-            
+
         // Before removing the key, find all groups it belongs to and remove it from them
         var script = @"
             local groups = redis.call('KEYS', ARGV[1])
@@ -228,128 +239,128 @@ public class CacheServiceRedis : ICacheService
             end
             return redis.call('DEL', ARGV[2])
         ";
-        
+
         var result = await _database.ScriptEvaluateAsync(
             script,
-            values: new RedisValue[] { $"{GroupKeyPrefix}*", key }
+            values: [$"{GroupKeyPrefix}*", key]
         );
-        
+
         return (long)result! > 0;
     }
-    
+
     public async Task AddToGroupAsync(string key, string group)
     {
         if (string.IsNullOrEmpty(key))
             throw new ArgumentException(@"Key cannot be null or empty.", nameof(key));
-            
+
         if (string.IsNullOrEmpty(group))
             throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
-            
+
         var groupKey = $"{GroupKeyPrefix}{group}";
         await _database.SetAddAsync(groupKey, key);
     }
-    
+
     public async Task RemoveGroupAsync(string group)
     {
         if (string.IsNullOrEmpty(group))
             throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
-            
+
         var groupKey = $"{GroupKeyPrefix}{group}";
-        
+
         // Get all keys in the group
         var keys = await _database.SetMembersAsync(groupKey);
-        
+
         if (keys.Length > 0)
         {
             // Delete all the keys
             var keysTasks = keys.Select(key => _database.KeyDeleteAsync(key.ToString()));
             await Task.WhenAll(keysTasks);
         }
-        
+
         // Delete the group itself
         await _database.KeyDeleteAsync(groupKey);
     }
-    
+
     public async Task<IEnumerable<string>> GetGroupKeysAsync(string group)
     {
         if (string.IsNullOrEmpty(group))
             throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
-            
+
         var groupKey = $"{GroupKeyPrefix}{group}";
         var members = await _database.SetMembersAsync(groupKey);
-        
+
         return members.Select(m => m.ToString());
     }
-    
-    public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null)
+
+    public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null,
+        TimeSpan? expiry = null)
     {
-        // First set the value in the cache
+        // First, set the value in the cache
         var setResult = await SetAsync(key, value, expiry);
-        
+
         // If successful and there are groups to associate, add the key to each group
-        if (setResult && groups != null)
-        {
-            var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray();
-            if (groupsArray.Length > 0)
-            {
-                var tasks = groupsArray.Select(group => AddToGroupAsync(key, group));
-                await Task.WhenAll(tasks);
-            }
-        }
-        
+        if (!setResult || groups == null) return setResult;
+        var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray();
+        if (groupsArray.Length <= 0) return setResult;
+        var tasks = groupsArray.Select(group => AddToGroupAsync(key, group));
+        await Task.WhenAll(tasks);
+
         return setResult;
     }
-    
-    public async Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
+
+    public async Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
+        TimeSpan? retryInterval = null)
     {
         if (string.IsNullOrEmpty(resource))
             throw new ArgumentException("Resource cannot be null or empty", nameof(resource));
-        
+
         var lockKey = $"{LockKeyPrefix}{resource}";
         var lockId = Guid.NewGuid().ToString("N");
         var waitTimeSpan = waitTime ?? TimeSpan.Zero;
         var retryIntervalSpan = retryInterval ?? TimeSpan.FromMilliseconds(100);
-        
+
         var startTime = DateTime.UtcNow;
         var acquired = false;
-        
+
         // Try to acquire the lock, retry until waitTime is exceeded
         while (!acquired && (DateTime.UtcNow - startTime) < waitTimeSpan)
         {
             acquired = await _database.StringSetAsync(lockKey, lockId, expiry, When.NotExists);
-            
+
             if (!acquired)
             {
                 await Task.Delay(retryIntervalSpan);
             }
         }
-        
+
         if (!acquired)
         {
             return null; // Could not acquire the lock within the wait time
         }
-        
+
         return new RedisDistributedLock(_database, resource, lockId);
     }
-    
-    public async Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
+
+    public async Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry,
+        TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
     {
         await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
-        
+
         if (lockObj == null)
             return false; // Could not acquire the lock
-            
+
         await action();
         return true;
     }
-    
-    public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
+
+    public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func,
+        TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
     {
         await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
-        
+
         if (lockObj == null)
             return (false, default); // Could not acquire the lock
-            
+
         var result = await func();
         return (true, result);
     }