feat: 商户冻结/解冻功能及字典缓存重构
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -646,9 +646,10 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.FrozenReason).HasMaxLength(500);
|
||||
builder.Property(x => x.ClaimedByName).HasMaxLength(100);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.IsRowVersion()
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bytea");
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Status });
|
||||
builder.HasIndex(x => x.ClaimedBy);
|
||||
@@ -978,6 +979,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.StoreId).IsRequired();
|
||||
builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique();
|
||||
}
|
||||
@@ -991,6 +995,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.Weekdays).HasMaxLength(32).IsRequired();
|
||||
builder.Property(x => x.CutoffMinutes).HasDefaultValue(30);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name });
|
||||
}
|
||||
@@ -1079,6 +1086,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.BatchNumber).HasMaxLength(64);
|
||||
builder.Property(x => x.Location).HasMaxLength(64);
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber });
|
||||
}
|
||||
@@ -1101,6 +1111,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.ProductSkuId).IsRequired();
|
||||
builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique();
|
||||
}
|
||||
@@ -1115,6 +1128,9 @@ public class TakeoutAppDbContext(
|
||||
builder.Property(x => x.IdempotencyKey).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Status).HasConversion<int>();
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.HasIndex(x => new { x.TenantId, x.IdempotencyKey }).IsUnique();
|
||||
builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.Status });
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 缓存命中/耗时指标采集器。
|
||||
/// </summary>
|
||||
public sealed class CacheMetricsCollector
|
||||
{
|
||||
private const string MeterName = "TakeoutSaaS.DictionaryCache";
|
||||
private static readonly Meter Meter = new(MeterName, "1.0.0");
|
||||
|
||||
private readonly Counter<long> _hitCounter;
|
||||
private readonly Counter<long> _missCounter;
|
||||
private readonly Counter<long> _invalidationCounter;
|
||||
private readonly Histogram<double> _durationHistogram;
|
||||
private readonly ConcurrentQueue<CacheQueryRecord> _queries = new();
|
||||
private readonly TimeSpan _retention = TimeSpan.FromDays(7);
|
||||
|
||||
private long _hitTotal;
|
||||
private long _missTotal;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化指标采集器。
|
||||
/// </summary>
|
||||
public CacheMetricsCollector()
|
||||
{
|
||||
_hitCounter = Meter.CreateCounter<long>("cache_hit_count");
|
||||
_missCounter = Meter.CreateCounter<long>("cache_miss_count");
|
||||
_invalidationCounter = Meter.CreateCounter<long>("cache_invalidation_count");
|
||||
_durationHistogram = Meter.CreateHistogram<double>("cache_query_duration_ms");
|
||||
|
||||
Meter.CreateObservableGauge(
|
||||
"cache_hit_ratio",
|
||||
() => new Measurement<double>(CalculateHitRatio()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存命中。
|
||||
/// </summary>
|
||||
public void RecordHit(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _hitTotal);
|
||||
_hitCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存未命中。
|
||||
/// </summary>
|
||||
public void RecordMiss(string cacheLevel, string dictionaryCode)
|
||||
{
|
||||
Interlocked.Increment(ref _missTotal);
|
||||
_missCounter.Add(1, new TagList
|
||||
{
|
||||
{ "cache_level", cacheLevel },
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存查询耗时。
|
||||
/// </summary>
|
||||
public void RecordDuration(string dictionaryCode, double durationMs)
|
||||
{
|
||||
_durationHistogram.Record(durationMs, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录查询详情,用于统计窗口分析。
|
||||
/// </summary>
|
||||
public void RecordQuery(string dictionaryCode, bool l1Hit, bool l2Hit, double durationMs)
|
||||
{
|
||||
var record = new CacheQueryRecord(DateTime.UtcNow, NormalizeCode(dictionaryCode), l1Hit, l2Hit, durationMs);
|
||||
_queries.Enqueue(record);
|
||||
PruneOldRecords();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录缓存失效事件。
|
||||
/// </summary>
|
||||
public void RecordInvalidation(string dictionaryCode)
|
||||
{
|
||||
_invalidationCounter.Add(1, new TagList
|
||||
{
|
||||
{ "dictionary_code", NormalizeCode(dictionaryCode) }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定时间范围内的统计快照。
|
||||
/// </summary>
|
||||
public CacheStatsSnapshot GetSnapshot(TimeSpan window)
|
||||
{
|
||||
var since = DateTime.UtcNow.Subtract(window);
|
||||
var records = _queries.Where(record => record.Timestamp >= since).ToList();
|
||||
|
||||
var l1Hits = records.Count(record => record.L1Hit);
|
||||
var l1Misses = records.Count(record => !record.L1Hit);
|
||||
var l2Hits = records.Count(record => record.L2Hit);
|
||||
var l2Misses = records.Count(record => !record.L1Hit && !record.L2Hit);
|
||||
|
||||
var totalHits = l1Hits + l2Hits;
|
||||
var totalMisses = l1Misses + l2Misses;
|
||||
var hitRatio = totalHits + totalMisses == 0 ? 0 : totalHits / (double)(totalHits + totalMisses);
|
||||
var averageDuration = records.Count == 0 ? 0 : records.Average(record => record.DurationMs);
|
||||
|
||||
var topQueried = records
|
||||
.GroupBy(record => record.DictionaryCode)
|
||||
.Select(group => new DictionaryQueryCount(group.Key, group.Count()))
|
||||
.OrderByDescending(item => item.QueryCount)
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
return new CacheStatsSnapshot(
|
||||
totalHits,
|
||||
totalMisses,
|
||||
hitRatio,
|
||||
new CacheLevelStats(l1Hits, l2Hits),
|
||||
new CacheLevelStats(l1Misses, l2Misses),
|
||||
averageDuration,
|
||||
topQueried);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从缓存键解析字典编码。
|
||||
/// </summary>
|
||||
public static string ExtractDictionaryCode(string cacheKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
return "groups";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
return "items";
|
||||
}
|
||||
|
||||
if (cacheKey.StartsWith("dict:", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = cacheKey.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
return parts[2];
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string NormalizeCode(string? code)
|
||||
=> string.IsNullOrWhiteSpace(code) ? "unknown" : code.Trim().ToLowerInvariant();
|
||||
|
||||
private double CalculateHitRatio()
|
||||
{
|
||||
var hits = Interlocked.Read(ref _hitTotal);
|
||||
var misses = Interlocked.Read(ref _missTotal);
|
||||
return hits + misses == 0 ? 0 : hits / (double)(hits + misses);
|
||||
}
|
||||
|
||||
private void PruneOldRecords()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.Subtract(_retention);
|
||||
while (_queries.TryPeek(out var record) && record.Timestamp < cutoff)
|
||||
{
|
||||
_queries.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CacheQueryRecord(
|
||||
DateTime Timestamp,
|
||||
string DictionaryCode,
|
||||
bool L1Hit,
|
||||
bool L2Hit,
|
||||
double DurationMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 缓存统计快照。
|
||||
/// </summary>
|
||||
public sealed record CacheStatsSnapshot(
|
||||
long TotalHits,
|
||||
long TotalMisses,
|
||||
double HitRatio,
|
||||
CacheLevelStats HitsByLevel,
|
||||
CacheLevelStats MissesByLevel,
|
||||
double AverageQueryDurationMs,
|
||||
IReadOnlyList<DictionaryQueryCount> TopQueriedDictionaries);
|
||||
|
||||
/// <summary>
|
||||
/// 命中统计。
|
||||
/// </summary>
|
||||
public sealed record CacheLevelStats(long L1, long L2);
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询次数统计。
|
||||
/// </summary>
|
||||
public sealed record DictionaryQueryCount(string Code, int QueryCount);
|
||||
@@ -1,57 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Dictionary.Services;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热服务。
|
||||
/// </summary>
|
||||
public sealed class CacheWarmupService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DictionaryCacheWarmupOptions> options,
|
||||
ILogger<CacheWarmupService> logger) : IHostedService
|
||||
{
|
||||
private const int MaxWarmupCount = 10;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var codes = options.Value.DictionaryCodes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxWarmupCount)
|
||||
.ToArray();
|
||||
|
||||
if (codes.Length == 0)
|
||||
{
|
||||
logger.LogInformation("未配置字典缓存预热列表。");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var queryService = scope.ServiceProvider.GetRequiredService<DictionaryQueryService>();
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
await queryService.GetMergedDictionaryAsync(code, cancellationToken);
|
||||
logger.LogInformation("字典缓存预热完成: {DictionaryCode}", code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "字典缓存预热失败: {DictionaryCode}", code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 两级缓存封装:L1 内存 + L2 Redis。
|
||||
/// </summary>
|
||||
public sealed class HybridCacheService : IDictionaryHybridCache
|
||||
{
|
||||
private static readonly RedisChannel InvalidationChannel = RedisChannel.Literal("dictionary:cache:invalidate");
|
||||
|
||||
private readonly MemoryCacheService _memoryCache;
|
||||
private readonly RedisCacheService _redisCache;
|
||||
private readonly ISubscriber? _subscriber;
|
||||
private readonly ILogger<HybridCacheService>? _logger;
|
||||
private readonly CacheMetricsCollector? _metrics;
|
||||
private readonly IServiceScopeFactory? _scopeFactory;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化两级缓存服务。
|
||||
/// </summary>
|
||||
public HybridCacheService(
|
||||
MemoryCacheService memoryCache,
|
||||
RedisCacheService redisCache,
|
||||
IConnectionMultiplexer? multiplexer = null,
|
||||
ILogger<HybridCacheService>? logger = null,
|
||||
CacheMetricsCollector? metrics = null,
|
||||
IServiceScopeFactory? scopeFactory = null)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_redisCache = redisCache;
|
||||
_logger = logger;
|
||||
_subscriber = multiplexer?.GetSubscriber();
|
||||
_metrics = metrics;
|
||||
_scopeFactory = scopeFactory;
|
||||
|
||||
if (_subscriber != null)
|
||||
{
|
||||
_subscriber.Subscribe(InvalidationChannel, (_, value) =>
|
||||
{
|
||||
var prefix = value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
_memoryCache.RemoveByPrefix(prefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取缓存,如果不存在则创建并回填。
|
||||
/// </summary>
|
||||
public async Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(key);
|
||||
var l1Hit = false;
|
||||
var l2Hit = false;
|
||||
|
||||
var cached = await _memoryCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l1Hit = true;
|
||||
_metrics?.RecordHit("L1", dictionaryCode);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L1", dictionaryCode);
|
||||
|
||||
try
|
||||
{
|
||||
cached = await _redisCache.GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null)
|
||||
{
|
||||
l2Hit = true;
|
||||
_metrics?.RecordHit("L2", dictionaryCode);
|
||||
await _memoryCache.SetAsync(key, cached, ttl, cancellationToken);
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return cached;
|
||||
}
|
||||
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics?.RecordMiss("L2", dictionaryCode);
|
||||
_logger?.LogWarning(ex, "读取 Redis 缓存失败,降级为数据库查询。");
|
||||
}
|
||||
|
||||
var created = await factory(cancellationToken);
|
||||
if (created == null)
|
||||
{
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return default;
|
||||
}
|
||||
|
||||
await _memoryCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
try
|
||||
{
|
||||
await _redisCache.SetAsync(key, created, ttl, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
_metrics?.RecordDuration(dictionaryCode, stopwatch.Elapsed.TotalMilliseconds);
|
||||
_metrics?.RecordQuery(dictionaryCode, l1Hit, l2Hit, stopwatch.Elapsed.TotalMilliseconds);
|
||||
return created;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 失效指定前缀的缓存键。
|
||||
/// </summary>
|
||||
public async Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dictionaryCode = CacheMetricsCollector.ExtractDictionaryCode(prefix);
|
||||
_metrics?.RecordInvalidation(dictionaryCode);
|
||||
|
||||
var removedCount = _memoryCache.RemoveByPrefixWithCount(prefix);
|
||||
long redisRemoved = 0;
|
||||
try
|
||||
{
|
||||
redisRemoved = await _redisCache.RemoveByPrefixWithCountAsync(prefix, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "删除 Redis 缓存失败。");
|
||||
}
|
||||
|
||||
var totalRemoved = removedCount + (int)Math.Min(redisRemoved, int.MaxValue);
|
||||
|
||||
if (_subscriber != null && !string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
await _subscriber.PublishAsync(InvalidationChannel, prefix);
|
||||
}
|
||||
|
||||
_ = WriteInvalidationLogAsync(prefix, dictionaryCode, totalRemoved, operation);
|
||||
}
|
||||
|
||||
private async Task WriteInvalidationLogAsync(
|
||||
string prefix,
|
||||
string dictionaryCode,
|
||||
int removedCount,
|
||||
CacheInvalidationOperation operation)
|
||||
{
|
||||
if (_scopeFactory == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetService<ICacheInvalidationLogRepository>();
|
||||
if (repo == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentUser = scope.ServiceProvider.GetService<ICurrentUserAccessor>();
|
||||
var tenantId = TryExtractTenantId(prefix) ?? 0;
|
||||
var scopeType = tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
|
||||
|
||||
var log = new CacheInvalidationLog
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
DictionaryCode = dictionaryCode,
|
||||
Scope = scopeType,
|
||||
AffectedCacheKeyCount = removedCount,
|
||||
OperatorId = currentUser?.IsAuthenticated == true ? currentUser.UserId : 0,
|
||||
Operation = operation
|
||||
};
|
||||
|
||||
await repo.AddAsync(log);
|
||||
await repo.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "写入缓存失效日志失败。");
|
||||
}
|
||||
}
|
||||
|
||||
private static long? TryExtractTenantId(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:groups:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:groups:", string.Empty, StringComparison.Ordinal).Trim(':');
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
if (prefix.StartsWith("dict:", StringComparison.Ordinal) && !prefix.StartsWith("dict:items:", StringComparison.Ordinal))
|
||||
{
|
||||
var token = prefix.Replace("dict:", string.Empty, StringComparison.Ordinal);
|
||||
return long.TryParse(token.Split(':', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), out var tenantId)
|
||||
? tenantId
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 本地内存缓存封装。
|
||||
/// </summary>
|
||||
public sealed class MemoryCacheService(IMemoryCache cache)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte> _keys = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(cache.TryGetValue(key, out T? value) ? value : default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cache.Set(key, value, new MemoryCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = ttl
|
||||
});
|
||||
_keys.TryAdd(key, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public void Remove(string key)
|
||||
{
|
||||
cache.Remove(key);
|
||||
_keys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public void RemoveByPrefix(string prefix)
|
||||
=> RemoveByPrefixWithCount(prefix);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public int RemoveByPrefixWithCount(string prefix)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var removed = 0;
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
Remove(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有缓存。
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
foreach (var key in _keys.Keys)
|
||||
{
|
||||
Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using System.Text.Json;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 缓存访问封装。
|
||||
/// </summary>
|
||||
public sealed class RedisCacheService(IDistributedCache cache, IConnectionMultiplexer? multiplexer = null)
|
||||
{
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IDatabase? _database = multiplexer?.GetDatabase();
|
||||
private readonly IConnectionMultiplexer? _multiplexer = multiplexer;
|
||||
|
||||
/// <summary>
|
||||
/// 读取缓存。
|
||||
/// </summary>
|
||||
public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await cache.GetAsync(key, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 写入缓存。
|
||||
/// </summary>
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(value, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl
|
||||
};
|
||||
return cache.SetAsync(key, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除缓存键。
|
||||
/// </summary>
|
||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> cache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键。
|
||||
/// </summary>
|
||||
public async Task RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
=> await RemoveByPrefixWithCountAsync(prefix, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀删除缓存键并返回数量。
|
||||
/// </summary>
|
||||
public async Task<long> RemoveByPrefixWithCountAsync(string prefix, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_multiplexer == null || _database == null || string.IsNullOrWhiteSpace(prefix))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var pattern = prefix.EndsWith('*') ? prefix : $"{prefix}*";
|
||||
long removed = 0;
|
||||
foreach (var endpoint in _multiplexer.GetEndPoints())
|
||||
{
|
||||
var server = _multiplexer.GetServer(endpoint);
|
||||
foreach (var key in server.Keys(pattern: pattern))
|
||||
{
|
||||
await _database.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.StackExchangeRedis;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.SystemParameters.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Caching;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions;
|
||||
@@ -32,9 +25,11 @@ public static class DictionaryServiceCollectionExtensions
|
||||
/// <exception cref="InvalidOperationException">缺少数据库配置时抛出。</exception>
|
||||
public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
// 1. 注册数据库上下文
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.DictionaryDataSource);
|
||||
|
||||
// 2. 注册仓储
|
||||
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
|
||||
services.AddScoped<IDictionaryGroupRepository, DictionaryGroupRepository>();
|
||||
services.AddScoped<IDictionaryItemRepository, DictionaryItemRepository>();
|
||||
@@ -43,69 +38,11 @@ public static class DictionaryServiceCollectionExtensions
|
||||
services.AddScoped<IDictionaryImportLogRepository, DictionaryImportLogRepository>();
|
||||
services.AddScoped<ICacheInvalidationLogRepository, CacheInvalidationLogRepository>();
|
||||
services.AddScoped<ISystemParameterRepository, EfSystemParameterRepository>();
|
||||
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
|
||||
|
||||
// 3. 注册导入导出解析器
|
||||
services.AddScoped<ICsvDictionaryParser, CsvDictionaryParser>();
|
||||
services.AddScoped<IJsonDictionaryParser, JsonDictionaryParser>();
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
var hasDistributedCache = services.Any(descriptor => descriptor.ServiceType == typeof(IDistributedCache));
|
||||
if (!hasDistributedCache)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddDistributedMemoryCache();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(redisConnection) && !services.Any(descriptor => descriptor.ServiceType == typeof(IConnectionMultiplexer)))
|
||||
{
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(redisConnection));
|
||||
}
|
||||
|
||||
services.AddSingleton<MemoryCacheService>();
|
||||
services.AddSingleton<CacheMetricsCollector>();
|
||||
services.AddSingleton(sp => new RedisCacheService(
|
||||
sp.GetRequiredService<IDistributedCache>(),
|
||||
sp.GetService<IConnectionMultiplexer>()));
|
||||
services.AddSingleton(sp => new HybridCacheService(
|
||||
sp.GetRequiredService<MemoryCacheService>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
sp.GetService<IConnectionMultiplexer>(),
|
||||
sp.GetService<ILogger<HybridCacheService>>(),
|
||||
sp.GetService<CacheMetricsCollector>(),
|
||||
sp.GetService<IServiceScopeFactory>()));
|
||||
services.AddSingleton<IDictionaryHybridCache>(sp => sp.GetRequiredService<HybridCacheService>());
|
||||
|
||||
services.AddOptions<DictionaryCacheOptions>()
|
||||
.Bind(configuration.GetSection("Dictionary:Cache"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddOptions<DictionaryCacheWarmupOptions>()
|
||||
.Bind(configuration.GetSection("CacheWarmup"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.AddHostedService<CacheWarmupService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保数据库连接已配置(Database 节或 ConnectionStrings)。
|
||||
/// </summary>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
|
||||
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
|
||||
{
|
||||
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 缓存滑动过期时间。
|
||||
/// </summary>
|
||||
public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存预热配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCacheWarmupOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 预热字典编码列表(最多前 10 个)。
|
||||
/// </summary>
|
||||
public string[] DictionaryCodes { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -60,10 +60,8 @@ public sealed class DictionaryDbContext(
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var provider = Database.ProviderName;
|
||||
var isSqlite = provider != null && provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>(), isSqlite);
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>(), isSqlite);
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
|
||||
ConfigureOverride(modelBuilder.Entity<TenantDictionaryOverride>());
|
||||
ConfigureLabelOverride(modelBuilder.Entity<DictionaryLabelOverride>());
|
||||
ConfigureImportLog(modelBuilder.Entity<DictionaryImportLog>());
|
||||
@@ -75,7 +73,7 @@ public sealed class DictionaryDbContext(
|
||||
/// 配置字典分组。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder, bool isSqlite)
|
||||
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_groups");
|
||||
builder.HasKey(x => x.Id);
|
||||
@@ -92,19 +90,12 @@ public sealed class DictionaryDbContext(
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Code })
|
||||
.IsUnique()
|
||||
@@ -116,7 +107,7 @@ public sealed class DictionaryDbContext(
|
||||
/// 配置字典项。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder, bool isSqlite)
|
||||
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder)
|
||||
{
|
||||
builder.ToTable("dictionary_items");
|
||||
builder.HasKey(x => x.Id);
|
||||
@@ -130,19 +121,12 @@ public sealed class DictionaryDbContext(
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
var rowVersion = builder.Property(x => x.RowVersion)
|
||||
builder.Property(x => x.RowVersion)
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
|
||||
if (isSqlite)
|
||||
{
|
||||
rowVersion.ValueGeneratedNever();
|
||||
rowVersion.HasColumnType("BLOB");
|
||||
}
|
||||
else
|
||||
{
|
||||
rowVersion.IsRowVersion().HasColumnType("bytea");
|
||||
}
|
||||
|
||||
builder.HasOne(x => x.Group)
|
||||
.WithMany(g => g.Items)
|
||||
.HasForeignKey(x => x.GroupId)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
@@ -123,11 +122,6 @@ public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDic
|
||||
}
|
||||
|
||||
entry.State = EntityState.Modified;
|
||||
var originalVersion = item.RowVersion;
|
||||
var nextVersion = RandomNumberGenerator.GetBytes(16);
|
||||
entry.Property(x => x.RowVersion).OriginalValue = originalVersion;
|
||||
entry.Property(x => x.RowVersion).CurrentValue = nextVersion;
|
||||
item.RowVersion = nextVersion;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 IDistributedCache 的字典缓存实现。
|
||||
/// </summary>
|
||||
public sealed class DistributedDictionaryCache(IDistributedCache cache, IOptions<DictionaryCacheOptions> options) : IDictionaryCache
|
||||
{
|
||||
private readonly DictionaryCacheOptions _options = options.Value;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定租户与编码的字典缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>字典项集合或 null。</returns>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 拼装缓存键
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = await cache.GetAsync(cacheKey, cancellationToken);
|
||||
if (payload == null || payload.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 反序列化
|
||||
return JsonSerializer.Deserialize<List<DictionaryItemDto>>(payload, _serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置指定租户与编码的字典缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="items">字典项集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task SetAsync(long tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 序列化并写入缓存
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(items, _serializerOptions);
|
||||
var options = new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = _options.SlidingExpiration
|
||||
};
|
||||
return cache.SetAsync(cacheKey, payload, options, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除指定租户与编码的缓存。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="code">字典编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 删除缓存键
|
||||
var cacheKey = BuildKey(tenantId, code);
|
||||
return cache.RemoveAsync(cacheKey, cancellationToken);
|
||||
}
|
||||
|
||||
private static string BuildKey(long tenantId, string code)
|
||||
=> $"dictionary:{tenantId}:{code.ToLowerInvariant()}";
|
||||
}
|
||||
@@ -108,9 +108,10 @@ public sealed class IdentityDbContext(
|
||||
builder.Property(x => x.MustChangePassword).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasColumnType("text");
|
||||
builder.Property(x => x.RowVersion)
|
||||
.IsRowVersion()
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("bytea");
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.TenantId);
|
||||
ConfigureAuditableEntity(builder);
|
||||
|
||||
Reference in New Issue
Block a user