feat: 实现字典管理后端

This commit is contained in:
2025-12-30 19:38:13 +08:00
parent a427b0f22a
commit dc9f6136d6
83 changed files with 6901 additions and 50 deletions

View File

@@ -0,0 +1,212 @@
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);

View File

@@ -0,0 +1,57 @@
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;
}

View File

@@ -0,0 +1,229 @@
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;
}
}

View File

@@ -0,0 +1,82 @@
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);
}
}
}

View File

@@ -0,0 +1,79 @@
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;
}
}

View File

@@ -1,9 +1,15 @@
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;
@@ -30,13 +36,64 @@ public static class DictionaryServiceCollectionExtensions
services.AddPostgresDbContext<DictionaryDbContext>(DatabaseConstants.DictionaryDataSource);
services.AddScoped<IDictionaryRepository, EfDictionaryRepository>();
services.AddScoped<IDictionaryGroupRepository, DictionaryGroupRepository>();
services.AddScoped<IDictionaryItemRepository, DictionaryItemRepository>();
services.AddScoped<ITenantDictionaryOverrideRepository, TenantDictionaryOverrideRepository>();
services.AddScoped<IDictionaryImportLogRepository, DictionaryImportLogRepository>();
services.AddScoped<ICacheInvalidationLogRepository, CacheInvalidationLogRepository>();
services.AddScoped<ISystemParameterRepository, EfSystemParameterRepository>();
services.AddScoped<IDictionaryCache, DistributedDictionaryCache>();
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;
}

View File

@@ -0,0 +1,91 @@
using CsvHelper;
using CsvHelper.Configuration;
using System.Globalization;
using System.Text;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Application.Dictionary.Models;
namespace TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
/// <summary>
/// CSV 字典导入解析器。
/// </summary>
public sealed class CsvDictionaryParser : ICsvDictionaryParser
{
private static readonly CsvConfiguration CsvConfiguration = new(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true,
MissingFieldFound = null,
BadDataFound = null,
DetectColumnCountChanges = false,
TrimOptions = TrimOptions.Trim,
PrepareHeaderForMatch = args => args.Header?.Trim().ToLowerInvariant() ?? string.Empty
};
/// <inheritdoc />
public async Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
if (stream.CanSeek)
{
stream.Position = 0;
}
var rows = new List<DictionaryImportRow>();
using var reader = new StreamReader(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), detectEncodingFromByteOrderMarks: true, leaveOpen: true);
using var csv = new CsvReader(reader, CsvConfiguration);
if (!await csv.ReadAsync() || !csv.ReadHeader())
{
return rows;
}
while (await csv.ReadAsync())
{
cancellationToken.ThrowIfCancellationRequested();
var rowNumber = csv.Context?.Parser?.Row ?? 0;
rows.Add(new DictionaryImportRow
{
RowNumber = rowNumber,
Code = ReadString(csv, "code"),
Key = ReadString(csv, "key"),
Value = ReadString(csv, "value"),
SortOrder = ReadInt(csv, "sortorder"),
IsEnabled = ReadBool(csv, "isenabled"),
Description = ReadString(csv, "description"),
Source = ReadString(csv, "source")
});
}
return rows;
}
private static string? ReadString(CsvReader csv, string name)
{
return csv.TryGetField(name, out string? value)
? string.IsNullOrWhiteSpace(value) ? null : value
: null;
}
private static int? ReadInt(CsvReader csv, string name)
{
if (csv.TryGetField(name, out string? value) && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number))
{
return number;
}
return null;
}
private static bool? ReadBool(CsvReader csv, string name)
{
if (csv.TryGetField(name, out string? value) && bool.TryParse(value, out var flag))
{
return flag;
}
return null;
}
}

View File

@@ -0,0 +1,131 @@
using System.Text.Json;
using TakeoutSaaS.Application.Dictionary.Abstractions;
using TakeoutSaaS.Application.Dictionary.Models;
namespace TakeoutSaaS.Infrastructure.Dictionary.ImportExport;
/// <summary>
/// JSON 字典导入解析器。
/// </summary>
public sealed class JsonDictionaryParser : IJsonDictionaryParser
{
private static readonly JsonDocumentOptions DocumentOptions = new()
{
AllowTrailingCommas = true
};
/// <inheritdoc />
public async Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
if (stream.CanSeek)
{
stream.Position = 0;
}
using var document = await JsonDocument.ParseAsync(stream, DocumentOptions, cancellationToken);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<DictionaryImportRow>();
}
var rows = new List<DictionaryImportRow>();
var index = 0;
foreach (var element in document.RootElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
index++;
rows.Add(new DictionaryImportRow
{
RowNumber = index,
Code = ReadString(element, "code"),
Key = ReadString(element, "key"),
Value = ReadValue(element, "value"),
SortOrder = ReadInt(element, "sortOrder"),
IsEnabled = ReadBool(element, "isEnabled"),
Description = ReadString(element, "description"),
Source = ReadString(element, "source")
});
}
return rows;
}
private static string? ReadString(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value) || value.ValueKind == JsonValueKind.Null)
{
return null;
}
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.GetRawText();
}
private static string? ReadValue(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value) || value.ValueKind == JsonValueKind.Null)
{
return null;
}
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.GetRawText();
}
private static int? ReadInt(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
{
return number;
}
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out var parsed))
{
return parsed;
}
return null;
}
private static bool? ReadBool(JsonElement element, string propertyName)
{
if (!TryGetProperty(element, propertyName, out var value))
{
return null;
}
if (value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False)
{
return value.GetBoolean();
}
if (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed))
{
return parsed;
}
return null;
}
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value)
{
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Infrastructure.Dictionary.Options;
/// <summary>
/// 字典缓存预热配置。
/// </summary>
public sealed class DictionaryCacheWarmupOptions
{
/// <summary>
/// 预热字典编码列表(最多前 10 个)。
/// </summary>
public string[] DictionaryCodes { get; set; } = Array.Empty<string>();
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Domain.SystemParameters.Entities;
using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
@@ -29,6 +30,21 @@ public sealed class DictionaryDbContext(
/// </summary>
public DbSet<DictionaryItem> DictionaryItems => Set<DictionaryItem>();
/// <summary>
/// 租户字典覆盖集合。
/// </summary>
public DbSet<TenantDictionaryOverride> TenantDictionaryOverrides => Set<TenantDictionaryOverride>();
/// <summary>
/// 字典导入日志集合。
/// </summary>
public DbSet<DictionaryImportLog> DictionaryImportLogs => Set<DictionaryImportLog>();
/// <summary>
/// 缓存失效日志集合。
/// </summary>
public DbSet<CacheInvalidationLog> CacheInvalidationLogs => Set<CacheInvalidationLog>();
/// <summary>
/// 系统参数集合。
/// </summary>
@@ -41,8 +57,13 @@ public sealed class DictionaryDbContext(
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>());
ConfigureItem(modelBuilder.Entity<DictionaryItem>());
var provider = Database.ProviderName;
var isSqlite = provider != null && provider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase);
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>(), isSqlite);
ConfigureItem(modelBuilder.Entity<DictionaryItem>(), isSqlite);
ConfigureOverride(modelBuilder.Entity<TenantDictionaryOverride>());
ConfigureImportLog(modelBuilder.Entity<DictionaryImportLog>());
ConfigureCacheInvalidationLog(modelBuilder.Entity<CacheInvalidationLog>());
ConfigureSystemParameter(modelBuilder.Entity<SystemParameter>());
ApplyTenantQueryFilters(modelBuilder);
}
@@ -51,48 +72,140 @@ public sealed class DictionaryDbContext(
/// 配置字典分组。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder)
private static void ConfigureGroup(EntityTypeBuilder<DictionaryGroup> builder, bool isSqlite)
{
builder.ToTable("dictionary_groups");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Code).HasMaxLength(64).IsRequired();
builder.Property(x => x.Code)
.HasConversion(code => code.Value, value => new DictionaryCode(value))
.HasMaxLength(64)
.IsRequired();
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
builder.Property(x => x.Scope).HasConversion<int>().IsRequired();
builder.Property(x => x.AllowOverride).HasDefaultValue(false);
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
var rowVersion = builder.Property(x => x.RowVersion)
.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();
builder.HasIndex(x => new { x.TenantId, x.Code })
.IsUnique()
.HasFilter("\"DeletedAt\" IS NULL");
builder.HasIndex(x => new { x.TenantId, x.Scope, x.IsEnabled });
}
/// <summary>
/// 配置字典项。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder)
private static void ConfigureItem(EntityTypeBuilder<DictionaryItem> builder, bool isSqlite)
{
builder.ToTable("dictionary_items");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.GroupId).IsRequired();
builder.Property(x => x.Key).HasMaxLength(64).IsRequired();
builder.Property(x => x.Value).HasMaxLength(256).IsRequired();
builder.Property(x => x.Key).HasMaxLength(128).IsRequired();
builder.Property(x => x.Value).HasColumnType("jsonb").IsRequired();
builder.Property(x => x.Description).HasMaxLength(512);
builder.Property(x => x.SortOrder).HasDefaultValue(100);
builder.Property(x => x.IsEnabled).HasDefaultValue(true);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
var rowVersion = builder.Property(x => x.RowVersion)
.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)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.GroupId, x.Key }).IsUnique();
builder.HasIndex(x => new { x.TenantId, x.GroupId, x.Key })
.IsUnique()
.HasFilter("\"DeletedAt\" IS NULL");
builder.HasIndex(x => new { x.GroupId, x.IsEnabled, x.SortOrder });
}
/// <summary>
/// 配置租户字典覆盖。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureOverride(EntityTypeBuilder<TenantDictionaryOverride> builder)
{
builder.ToTable("tenant_dictionary_overrides");
builder.HasKey(x => new { x.TenantId, x.SystemDictionaryGroupId });
builder.Property(x => x.OverrideEnabled).HasDefaultValue(false);
builder.Property(x => x.HiddenSystemItemIds).HasColumnType("bigint[]");
builder.Property(x => x.CustomSortOrder).HasColumnType("jsonb");
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.HiddenSystemItemIds).HasMethod("gin");
}
/// <summary>
/// 配置字典导入日志。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureImportLog(EntityTypeBuilder<DictionaryImportLog> builder)
{
builder.ToTable("dictionary_import_logs");
builder.HasKey(x => x.Id);
builder.Property(x => x.OperatorId).IsRequired();
builder.Property(x => x.DictionaryGroupCode).HasMaxLength(64).IsRequired();
builder.Property(x => x.FileName).HasMaxLength(256).IsRequired();
builder.Property(x => x.Format).HasMaxLength(16).IsRequired();
builder.Property(x => x.ErrorDetails).HasColumnType("jsonb");
builder.Property(x => x.ProcessedAt).IsRequired();
builder.Property(x => x.Duration).HasColumnType("interval");
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => new { x.TenantId, x.ProcessedAt });
}
/// <summary>
/// 配置缓存失效日志。
/// </summary>
/// <param name="builder">实体构建器。</param>
private static void ConfigureCacheInvalidationLog(EntityTypeBuilder<CacheInvalidationLog> builder)
{
builder.ToTable("dictionary_cache_invalidation_logs");
builder.HasKey(x => x.Id);
builder.Property(x => x.DictionaryCode).HasMaxLength(64).IsRequired();
builder.Property(x => x.Scope).HasConversion<int>().IsRequired();
builder.Property(x => x.Operation).HasConversion<int>().IsRequired();
builder.Property(x => x.Timestamp).IsRequired();
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => new { x.TenantId, x.Timestamp });
}
/// <summary>

View File

@@ -0,0 +1,85 @@
-- Initial system dictionary seed data.
-- Target database: takeout_dictionary_db
-- ========================================
-- 1. ORDER_STATUS
-- ========================================
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
VALUES
(1001, 0, 'order_status', '订单状态', 1, true, true, '外卖订单生命周期状态', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "Code") DO NOTHING;
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
VALUES
(10011, 0, 1001, 'PENDING', '{"zh-CN":"待接单","en":"Pending"}', true, true, 10, '订单已创建,等待商家接单', CURRENT_TIMESTAMP, 0),
(10012, 0, 1001, 'ACCEPTED', '{"zh-CN":"已接单","en":"Accepted"}', false, true, 20, '商家已接受订单', CURRENT_TIMESTAMP, 0),
(10013, 0, 1001, 'PREPARING', '{"zh-CN":"制作中","en":"Preparing"}', false, true, 30, '商家正在准备餐品', CURRENT_TIMESTAMP, 0),
(10014, 0, 1001, 'DELIVERING', '{"zh-CN":"配送中","en":"Delivering"}', false, true, 40, '骑手正在配送', CURRENT_TIMESTAMP, 0),
(10015, 0, 1001, 'COMPLETED', '{"zh-CN":"已完成","en":"Completed"}', false, true, 50, '订单已送达', CURRENT_TIMESTAMP, 0),
(10016, 0, 1001, 'CANCELLED', '{"zh-CN":"已取消","en":"Cancelled"}', false, true, 60, '订单已取消', CURRENT_TIMESTAMP, 0),
(10017, 0, 1001, 'REFUNDED', '{"zh-CN":"已退款","en":"Refunded"}', false, true, 70, '订单已退款', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
-- ========================================
-- 2. PAYMENT_METHOD
-- ========================================
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
VALUES
(1002, 0, 'payment_method', '支付方式', 1, true, true, '订单支付方式选项', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "Code") DO NOTHING;
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
VALUES
(10021, 0, 1002, 'ALIPAY', '{"zh-CN":"支付宝","en":"Alipay"}', true, true, 10, '支付宝在线支付', CURRENT_TIMESTAMP, 0),
(10022, 0, 1002, 'WECHAT', '{"zh-CN":"微信支付","en":"WeChat Pay"}', false, true, 20, '微信在线支付', CURRENT_TIMESTAMP, 0),
(10023, 0, 1002, 'CREDIT_CARD', '{"zh-CN":"信用卡","en":"Credit Card"}', false, true, 30, '信用卡支付', CURRENT_TIMESTAMP, 0),
(10024, 0, 1002, 'BALANCE', '{"zh-CN":"余额支付","en":"Balance"}', false, true, 40, '账户余额支付', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
-- ========================================
-- 3. SHIPPING_METHOD
-- ========================================
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
VALUES
(1003, 0, 'shipping_method', '配送方式', 1, true, true, '订单配送方式', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "Code") DO NOTHING;
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
VALUES
(10031, 0, 1003, 'PLATFORM_DELIVERY', '{"zh-CN":"平台配送","en":"Platform Delivery"}', true, true, 10, '平台自有骑手配送', CURRENT_TIMESTAMP, 0),
(10032, 0, 1003, 'MERCHANT_DELIVERY', '{"zh-CN":"商家配送","en":"Merchant Delivery"}', false, true, 20, '商家自行配送', CURRENT_TIMESTAMP, 0),
(10033, 0, 1003, 'SELF_PICKUP', '{"zh-CN":"到店自取","en":"Self Pickup"}', false, true, 30, '顾客到店自取', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
-- ========================================
-- 4. PRODUCT_CATEGORY
-- ========================================
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
VALUES
(1004, 0, 'product_category', '商品分类', 1, false, true, '外卖商品通用分类 (不允许租户覆盖)', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "Code") DO NOTHING;
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
VALUES
(10041, 0, 1004, 'STAPLE_FOOD', '{"zh-CN":"主食","en":"Staple Food"}', false, true, 10, '米饭、面条等主食', CURRENT_TIMESTAMP, 0),
(10042, 0, 1004, 'APPETIZER', '{"zh-CN":"小吃/开胃菜","en":"Appetizer"}', false, true, 20, '小吃和开胃菜', CURRENT_TIMESTAMP, 0),
(10043, 0, 1004, 'MAIN_COURSE', '{"zh-CN":"主菜","en":"Main Course"}', true, true, 30, '正餐主菜', CURRENT_TIMESTAMP, 0),
(10044, 0, 1004, 'BEVERAGE', '{"zh-CN":"饮品","en":"Beverage"}', false, true, 40, '饮料、茶饮等', CURRENT_TIMESTAMP, 0),
(10045, 0, 1004, 'DESSERT', '{"zh-CN":"甜品","en":"Dessert"}', false, true, 50, '甜品、糕点', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;
-- ========================================
-- 5. USER_ROLE
-- ========================================
INSERT INTO dictionary_groups ("Id", "TenantId", "Code", "Name", "Scope", "AllowOverride", "IsEnabled", "Description", "CreatedAt", "CreatedBy")
VALUES
(1005, 0, 'user_role', '用户角色', 1, false, true, '系统用户角色类型 (平台级,不可覆盖)', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "Code") DO NOTHING;
INSERT INTO dictionary_items ("Id", "TenantId", "GroupId", "Key", "Value", "IsDefault", "IsEnabled", "SortOrder", "Description", "CreatedAt", "CreatedBy")
VALUES
(10051, 0, 1005, 'PLATFORM_ADMIN', '{"zh-CN":"平台管理员","en":"Platform Admin"}', false, true, 10, '平台超级管理员', CURRENT_TIMESTAMP, 0),
(10052, 0, 1005, 'TENANT_ADMIN', '{"zh-CN":"租户管理员","en":"Tenant Admin"}', false, true, 20, '租户企业管理员', CURRENT_TIMESTAMP, 0),
(10053, 0, 1005, 'TENANT_USER', '{"zh-CN":"租户员工","en":"Tenant User"}', true, true, 30, '租户普通员工', CURRENT_TIMESTAMP, 0),
(10054, 0, 1005, 'CUSTOMER', '{"zh-CN":"顾客","en":"Customer"}', false, true, 40, '终端用户/顾客', CURRENT_TIMESTAMP, 0)
ON CONFLICT ("TenantId", "GroupId", "Key") DO NOTHING;

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 缓存失效日志仓储实现。
/// </summary>
public sealed class CacheInvalidationLogRepository(DictionaryDbContext context) : ICacheInvalidationLogRepository
{
/// <summary>
/// 新增失效日志。
/// </summary>
public Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default)
{
context.CacheInvalidationLogs.Add(log);
return Task.CompletedTask;
}
/// <summary>
/// 分页查询失效日志。
/// </summary>
public async Task<(IReadOnlyList<CacheInvalidationLog> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
DateTime? startDate,
DateTime? endDate,
CancellationToken cancellationToken = default)
{
var query = context.CacheInvalidationLogs.AsNoTracking();
if (startDate.HasValue)
{
query = query.Where(log => log.Timestamp >= startDate.Value);
}
if (endDate.HasValue)
{
query = query.Where(log => log.Timestamp <= endDate.Value);
}
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(log => log.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (items, total);
}
/// <summary>
/// 保存变更。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 字典分组仓储实现。
/// </summary>
public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDictionaryGroupRepository
{
private static readonly Func<DictionaryDbContext, long, DictionaryCode, Task<DictionaryGroup?>> GetByCodeQuery =
EF.CompileAsyncQuery((DictionaryDbContext db, long tenantId, DictionaryCode code) =>
db.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefault(group => group.TenantId == tenantId && group.DeletedAt == null && group.Code == code));
/// <summary>
/// 按 ID 获取字典分组。
/// </summary>
public Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default)
{
return context.DictionaryGroups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group => group.Id == groupId && group.DeletedAt == null, cancellationToken);
}
/// <summary>
/// 按编码获取字典分组。
/// </summary>
public Task<DictionaryGroup?> GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return GetByCodeQuery(context, tenantId, code);
}
/// <summary>
/// 分页获取字典分组。
/// </summary>
public async Task<IReadOnlyList<DictionaryGroup>> GetPagedAsync(
long tenantId,
DictionaryScope? scope,
string? keyword,
bool? isEnabled,
int page,
int pageSize,
string? sortBy,
bool sortDescending,
CancellationToken cancellationToken = default)
{
var query = BuildQuery(tenantId, scope, keyword, isEnabled);
var skip = Math.Max(page - 1, 0) * Math.Max(pageSize, 1);
query = ApplyOrdering(query, sortBy, sortDescending);
return await query
.Skip(skip)
.Take(pageSize)
.ToListAsync(cancellationToken);
}
/// <summary>
/// 获取满足条件的分组数量。
/// </summary>
public Task<int> CountAsync(
long tenantId,
DictionaryScope? scope,
string? keyword,
bool? isEnabled,
CancellationToken cancellationToken = default)
{
return BuildQuery(tenantId, scope, keyword, isEnabled)
.CountAsync(cancellationToken);
}
/// <summary>
/// 批量获取字典分组。
/// </summary>
public async Task<IReadOnlyList<DictionaryGroup>> GetByIdsAsync(IEnumerable<long> groupIds, CancellationToken cancellationToken = default)
{
var ids = groupIds?.Distinct().ToArray() ?? Array.Empty<long>();
if (ids.Length == 0)
{
return Array.Empty<DictionaryGroup>();
}
return await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.Where(group => ids.Contains(group.Id) && group.DeletedAt == null)
.ToListAsync(cancellationToken);
}
private static IQueryable<DictionaryGroup> ApplyOrdering(IQueryable<DictionaryGroup> query, string? sortBy, bool sortDescending)
{
var normalized = sortBy?.Trim().ToLowerInvariant();
return normalized switch
{
"name" => sortDescending ? query.OrderByDescending(group => group.Name) : query.OrderBy(group => group.Name),
"createdat" => sortDescending ? query.OrderByDescending(group => group.CreatedAt) : query.OrderBy(group => group.CreatedAt),
"updatedat" => sortDescending ? query.OrderByDescending(group => group.UpdatedAt) : query.OrderBy(group => group.UpdatedAt),
_ => sortDescending ? query.OrderByDescending(group => group.Code) : query.OrderBy(group => group.Code)
};
}
/// <summary>
/// 新增分组。
/// </summary>
public Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
{
context.DictionaryGroups.Add(group);
return Task.CompletedTask;
}
/// <summary>
/// 更新分组。
/// </summary>
public Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
{
context.DictionaryGroups.Update(group);
return Task.CompletedTask;
}
/// <summary>
/// 删除分组。
/// </summary>
public Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default)
{
context.DictionaryGroups.Remove(group);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
private IQueryable<DictionaryGroup> BuildQuery(long tenantId, DictionaryScope? scope, string? keyword, bool? isEnabled)
{
var query = context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.Where(group => group.TenantId == tenantId && group.DeletedAt == null);
if (scope.HasValue)
{
query = query.Where(group => group.Scope == scope.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var trimmed = keyword.Trim();
query = query.Where(group =>
EF.Property<string>(group, "Code").Contains(trimmed) ||
group.Name.Contains(trimmed));
}
if (isEnabled.HasValue)
{
query = query.Where(group => group.IsEnabled == isEnabled.Value);
}
return query;
}
}

View File

@@ -0,0 +1,26 @@
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 字典导入日志仓储实现。
/// </summary>
public sealed class DictionaryImportLogRepository(DictionaryDbContext context) : IDictionaryImportLogRepository
{
/// <summary>
/// 新增导入日志。
/// </summary>
public Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default)
{
context.DictionaryImportLogs.Add(log);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,148 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 字典项仓储实现。
/// </summary>
public sealed class DictionaryItemRepository(DictionaryDbContext context) : IDictionaryItemRepository
{
private static readonly Func<DictionaryDbContext, long, long, IEnumerable<DictionaryItem>> GetByGroupQuery =
EF.CompileQuery((DictionaryDbContext db, long tenantId, long groupId) =>
(IEnumerable<DictionaryItem>)db.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == groupId && item.TenantId == tenantId && item.DeletedAt == null)
.OrderBy(item => item.SortOrder));
/// <summary>
/// 根据 ID 获取字典项。
/// </summary>
public Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default)
{
return context.DictionaryItems
.IgnoreQueryFilters()
.FirstOrDefaultAsync(item => item.Id == itemId && item.DeletedAt == null, cancellationToken);
}
/// <summary>
/// 获取分组下字典项列表。
/// </summary>
public Task<IReadOnlyList<DictionaryItem>> GetByGroupIdAsync(
long tenantId,
long groupId,
CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return Task.FromResult<IReadOnlyList<DictionaryItem>>(
GetByGroupQuery(context, tenantId, groupId).ToList());
}
/// <summary>
/// 获取系统与租户合并的字典项列表。
/// </summary>
public async Task<IReadOnlyList<DictionaryItem>> GetMergedItemsAsync(
long tenantId,
long systemGroupId,
bool includeOverrides,
CancellationToken cancellationToken = default)
{
var systemGroup = await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group => group.Id == systemGroupId && group.DeletedAt == null, cancellationToken);
if (systemGroup == null)
{
return Array.Empty<DictionaryItem>();
}
var result = new List<DictionaryItem>();
var systemItems = await context.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == systemGroupId && item.TenantId == 0 && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
result.AddRange(systemItems);
if (!includeOverrides || tenantId == 0)
{
return result;
}
var tenantGroup = await context.DictionaryGroups
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(group =>
group.TenantId == tenantId &&
group.DeletedAt == null &&
group.Code == systemGroup.Code,
cancellationToken);
if (tenantGroup == null)
{
return result;
}
var tenantItems = await context.DictionaryItems
.AsNoTracking()
.IgnoreQueryFilters()
.Where(item => item.GroupId == tenantGroup.Id && item.TenantId == tenantId && item.DeletedAt == null)
.OrderBy(item => item.SortOrder)
.ToListAsync(cancellationToken);
result.AddRange(tenantItems);
return result;
}
/// <summary>
/// 新增字典项。
/// </summary>
public Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default)
{
context.DictionaryItems.Add(item);
return Task.CompletedTask;
}
/// <summary>
/// 更新字典项。
/// </summary>
public Task UpdateAsync(DictionaryItem item, CancellationToken cancellationToken = default)
{
var entry = context.Entry(item);
if (entry.State == EntityState.Detached)
{
context.DictionaryItems.Attach(item);
entry = context.Entry(item);
}
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;
}
/// <summary>
/// 删除字典项。
/// </summary>
public Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default)
{
context.DictionaryItems.Remove(item);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
@@ -27,7 +28,7 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
/// <param name="cancellationToken">取消标记。</param>
/// <returns>匹配分组或 null。</returns>
public Task<DictionaryGroup?> FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default)
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken);
=> context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == new DictionaryCode(code), cancellationToken);
/// <summary>
/// 搜索分组列表。
@@ -153,8 +154,8 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
// 1. 规范化编码
var normalizedCodes = codes
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.Trim().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(code => new DictionaryCode(code))
.Distinct()
.ToArray();
if (normalizedCodes.Length == 0)
@@ -167,7 +168,7 @@ public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDicti
.AsNoTracking()
.IgnoreQueryFilters()
.Include(item => item.Group)
.Where(item => normalizedCodes.Contains(item.Group!.Code));
.Where(item => normalizedCodes.Contains(item.Group!.Code) && item.DeletedAt == null);
// 3. 按租户或系统级过滤
query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == 0));

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Repositories;
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories;
/// <summary>
/// 租户字典覆盖仓储实现。
/// </summary>
public sealed class TenantDictionaryOverrideRepository(DictionaryDbContext context) : ITenantDictionaryOverrideRepository
{
/// <summary>
/// 获取租户覆盖配置。
/// </summary>
public Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default)
{
return context.TenantDictionaryOverrides
.IgnoreQueryFilters()
.FirstOrDefaultAsync(config =>
config.TenantId == tenantId &&
config.SystemDictionaryGroupId == systemGroupId &&
config.DeletedAt == null,
cancellationToken);
}
/// <summary>
/// 获取租户全部覆盖配置。
/// </summary>
public async Task<IReadOnlyList<TenantDictionaryOverride>> ListAsync(long tenantId, CancellationToken cancellationToken = default)
{
return await context.TenantDictionaryOverrides
.AsNoTracking()
.IgnoreQueryFilters()
.Where(config => config.TenantId == tenantId && config.DeletedAt == null)
.ToListAsync(cancellationToken);
}
/// <summary>
/// 新增覆盖配置。
/// </summary>
public Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
{
context.TenantDictionaryOverrides.Add(overrideConfig);
return Task.CompletedTask;
}
/// <summary>
/// 更新覆盖配置。
/// </summary>
public Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default)
{
context.TenantDictionaryOverrides.Update(overrideConfig);
return Task.CompletedTask;
}
/// <summary>
/// 持久化更改。
/// </summary>
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> context.SaveChangesAsync(cancellationToken);
}