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,40 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Entities;
/// <summary>
/// 字典缓存失效日志。
/// </summary>
public sealed class CacheInvalidationLog : MultiTenantEntityBase
{
/// <summary>
/// 发生时间UTC
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// 字典编码。
/// </summary>
public string DictionaryCode { get; set; } = string.Empty;
/// <summary>
/// 字典作用域。
/// </summary>
public DictionaryScope Scope { get; set; }
/// <summary>
/// 影响的缓存键数量。
/// </summary>
public int AffectedCacheKeyCount { get; set; }
/// <summary>
/// 操作人用户标识。
/// </summary>
public long OperatorId { get; set; }
/// <summary>
/// 操作类型。
/// </summary>
public CacheInvalidationOperation Operation { get; set; }
}

View File

@@ -1,4 +1,5 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Entities;
@@ -11,7 +12,7 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
/// <summary>
/// 分组编码(唯一)。
/// </summary>
public string Code { get; set; } = string.Empty;
public DictionaryCode Code { get; set; }
/// <summary>
/// 分组名称。
@@ -23,6 +24,11 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
/// </summary>
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
/// <summary>
/// 是否允许租户覆盖。
/// </summary>
public bool AllowOverride { get; set; }
/// <summary>
/// 描述信息。
/// </summary>
@@ -33,6 +39,11 @@ public sealed class DictionaryGroup : MultiTenantEntityBase
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 并发控制字段。
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>
/// 字典项集合。
/// </summary>

View File

@@ -0,0 +1,65 @@
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Entities;
/// <summary>
/// 字典导入审计日志。
/// </summary>
public sealed class DictionaryImportLog : MultiTenantEntityBase
{
/// <summary>
/// 操作人用户标识。
/// </summary>
public long OperatorId { get; set; }
/// <summary>
/// 字典分组编码。
/// </summary>
public string DictionaryGroupCode { get; set; } = string.Empty;
/// <summary>
/// 导入文件名。
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// 文件大小(字节)。
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// 文件格式CSV/JSON
/// </summary>
public string Format { get; set; } = string.Empty;
/// <summary>
/// 冲突处理模式。
/// </summary>
public ConflictResolutionMode ConflictMode { get; set; } = ConflictResolutionMode.Skip;
/// <summary>
/// 成功导入数量。
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// 跳过数量。
/// </summary>
public int SkipCount { get; set; }
/// <summary>
/// 错误明细JSON
/// </summary>
public string? ErrorDetails { get; set; }
/// <summary>
/// 处理时间UTC
/// </summary>
public DateTime ProcessedAt { get; set; }
/// <summary>
/// 处理耗时。
/// </summary>
public TimeSpan Duration { get; set; }
}

View File

@@ -42,6 +42,11 @@ public sealed class DictionaryItem : MultiTenantEntityBase
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 并发控制字段。
/// </summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
/// <summary>
/// 导航属性:所属分组。
/// </summary>

View File

@@ -0,0 +1,64 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Entities;
/// <summary>
/// 租户字典覆盖配置。
/// </summary>
public sealed class TenantDictionaryOverride : IMultiTenantEntity, IAuditableEntity
{
/// <summary>
/// 所属租户 ID。
/// </summary>
public long TenantId { get; set; }
/// <summary>
/// 系统字典分组 ID。
/// </summary>
public long SystemDictionaryGroupId { get; set; }
/// <summary>
/// 是否启用覆盖。
/// </summary>
public bool OverrideEnabled { get; set; }
/// <summary>
/// 隐藏的系统字典项 ID 列表。
/// </summary>
public long[] HiddenSystemItemIds { get; set; } = Array.Empty<long>();
/// <summary>
/// 自定义排序映射JSON
/// </summary>
public string CustomSortOrder { get; set; } = "{}";
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 最近更新时间UTC
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// 删除时间UTC
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// 创建人用户标识。
/// </summary>
public long? CreatedBy { get; set; }
/// <summary>
/// 最后更新人用户标识。
/// </summary>
public long? UpdatedBy { get; set; }
/// <summary>
/// 删除人用户标识。
/// </summary>
public long? DeletedBy { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace TakeoutSaaS.Domain.Dictionary.Enums;
/// <summary>
/// 缓存失效操作类型。
/// </summary>
public enum CacheInvalidationOperation
{
Create = 1,
Update = 2,
Delete = 3
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Domain.Dictionary.Enums;
/// <summary>
/// 字典导入冲突处理策略。
/// </summary>
public enum ConflictResolutionMode
{
/// <summary>
/// 跳过重复项。
/// </summary>
Skip = 1,
/// <summary>
/// 覆盖重复项。
/// </summary>
Overwrite = 2,
/// <summary>
/// 追加新项。
/// </summary>
Append = 3
}

View File

@@ -0,0 +1,29 @@
using TakeoutSaaS.Domain.Dictionary.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
/// <summary>
/// 缓存失效日志仓储。
/// </summary>
public interface ICacheInvalidationLogRepository
{
/// <summary>
/// 新增失效日志。
/// </summary>
Task AddAsync(CacheInvalidationLog log, CancellationToken cancellationToken = default);
/// <summary>
/// 分页查询失效日志。
/// </summary>
Task<(IReadOnlyList<CacheInvalidationLog> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
DateTime? startDate,
DateTime? endDate,
CancellationToken cancellationToken = default);
/// <summary>
/// 保存变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,104 @@
using TakeoutSaaS.Domain.Dictionary.Entities;
using TakeoutSaaS.Domain.Dictionary.Enums;
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
/// <summary>
/// 字典分组仓储契约。
/// </summary>
public interface IDictionaryGroupRepository
{
/// <summary>
/// 按 ID 获取字典分组。
/// </summary>
/// <param name="groupId">分组 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分组实体或 null。</returns>
Task<DictionaryGroup?> GetByIdAsync(long groupId, CancellationToken cancellationToken = default);
/// <summary>
/// 按编码获取字典分组。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="code">分组编码。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分组实体或 null。</returns>
Task<DictionaryGroup?> GetByCodeAsync(long tenantId, DictionaryCode code, CancellationToken cancellationToken = default);
/// <summary>
/// 分页获取字典分组。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="scope">作用域过滤。</param>
/// <param name="keyword">关键字过滤。</param>
/// <param name="isEnabled">启用状态过滤。</param>
/// <param name="page">页码。</param>
/// <param name="pageSize">页大小。</param>
/// <param name="sortBy">排序字段。</param>
/// <param name="sortDescending">是否降序。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分组集合。</returns>
Task<IReadOnlyList<DictionaryGroup>> GetPagedAsync(
long tenantId,
DictionaryScope? scope,
string? keyword,
bool? isEnabled,
int page,
int pageSize,
string? sortBy,
bool sortDescending,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取满足条件的分组数量。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="scope">作用域过滤。</param>
/// <param name="keyword">关键字过滤。</param>
/// <param name="isEnabled">启用状态过滤。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分组数量。</returns>
Task<int> CountAsync(
long tenantId,
DictionaryScope? scope,
string? keyword,
bool? isEnabled,
CancellationToken cancellationToken = default);
/// <summary>
/// 批量获取字典分组。
/// </summary>
/// <param name="groupIds">分组 ID 列表。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>分组集合。</returns>
Task<IReadOnlyList<DictionaryGroup>> GetByIdsAsync(IEnumerable<long> groupIds, CancellationToken cancellationToken = default);
/// <summary>
/// 新增分组。
/// </summary>
/// <param name="group">分组实体。</param>
/// <param name="cancellationToken">取消标记。</param>
Task AddAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
/// <summary>
/// 更新分组。
/// </summary>
/// <param name="group">分组实体。</param>
/// <param name="cancellationToken">取消标记。</param>
Task UpdateAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
/// <summary>
/// 删除分组。
/// </summary>
/// <param name="group">分组实体。</param>
/// <param name="cancellationToken">取消标记。</param>
Task RemoveAsync(DictionaryGroup group, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化更改。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,22 @@
using TakeoutSaaS.Domain.Dictionary.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
/// <summary>
/// 字典导入日志仓储契约。
/// </summary>
public interface IDictionaryImportLogRepository
{
/// <summary>
/// 新增导入日志。
/// </summary>
/// <param name="log">导入日志。</param>
/// <param name="cancellationToken">取消标记。</param>
Task AddAsync(DictionaryImportLog log, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化更改。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,64 @@
using TakeoutSaaS.Domain.Dictionary.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
/// <summary>
/// 字典项仓储契约。
/// </summary>
public interface IDictionaryItemRepository
{
/// <summary>
/// 根据 ID 获取字典项。
/// </summary>
/// <param name="itemId">字典项 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>字典项或 null。</returns>
Task<DictionaryItem?> GetByIdAsync(long itemId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取分组下字典项列表。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="groupId">分组 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>字典项集合。</returns>
Task<IReadOnlyList<DictionaryItem>> GetByGroupIdAsync(long tenantId, long groupId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取合并后的系统/租户字典项。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="systemGroupId">系统分组 ID。</param>
/// <param name="includeOverrides">是否包含租户覆盖。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>字典项集合。</returns>
Task<IReadOnlyList<DictionaryItem>> GetMergedItemsAsync(long tenantId, long systemGroupId, bool includeOverrides, CancellationToken cancellationToken = default);
/// <summary>
/// 新增字典项。
/// </summary>
/// <param name="item">字典项实体。</param>
/// <param name="cancellationToken">取消标记。</param>
Task AddAsync(DictionaryItem item, CancellationToken cancellationToken = default);
/// <summary>
/// 更新字典项。
/// </summary>
/// <param name="item">字典项实体。</param>
/// <param name="cancellationToken">取消标记。</param>
Task UpdateAsync(DictionaryItem item, CancellationToken cancellationToken = default);
/// <summary>
/// 删除字典项。
/// </summary>
/// <param name="item">字典项实体。</param>
/// <param name="cancellationToken">取消标记。</param>
Task RemoveAsync(DictionaryItem item, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化更改。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,47 @@
using TakeoutSaaS.Domain.Dictionary.Entities;
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
/// <summary>
/// 租户字典覆盖仓储契约。
/// </summary>
public interface ITenantDictionaryOverrideRepository
{
/// <summary>
/// 获取租户覆盖配置。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="systemGroupId">系统字典分组 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>覆盖配置或 null。</returns>
Task<TenantDictionaryOverride?> GetAsync(long tenantId, long systemGroupId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取租户全部覆盖配置。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>覆盖配置列表。</returns>
Task<IReadOnlyList<TenantDictionaryOverride>> ListAsync(long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增覆盖配置。
/// </summary>
/// <param name="overrideConfig">覆盖配置。</param>
/// <param name="cancellationToken">取消标记。</param>
Task AddAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default);
/// <summary>
/// 更新覆盖配置。
/// </summary>
/// <param name="overrideConfig">覆盖配置。</param>
/// <param name="cancellationToken">取消标记。</param>
Task UpdateAsync(TenantDictionaryOverride overrideConfig, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化更改。
/// </summary>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,91 @@
using System.Text.RegularExpressions;
namespace TakeoutSaaS.Domain.Dictionary.ValueObjects;
/// <summary>
/// 字典分组编码值对象。
/// </summary>
public readonly struct DictionaryCode : IEquatable<DictionaryCode>
{
private static readonly Regex CodePattern = new("^[a-zA-Z0-9_]{2,64}$", RegexOptions.Compiled);
private readonly string? _value;
/// <summary>
/// 初始化字典编码并进行规范化。
/// </summary>
/// <param name="value">原始编码。</param>
/// <exception cref="ArgumentException">编码非法时抛出。</exception>
public DictionaryCode(string value)
{
_value = Normalize(value);
}
/// <summary>
/// 规范化后的编码值。
/// </summary>
public string Value => _value ?? string.Empty;
/// <summary>
/// 判断编码是否符合规则。
/// </summary>
public static bool IsValid(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
var trimmed = value.Trim();
return CodePattern.IsMatch(trimmed);
}
/// <summary>
/// 规范化编码(去空格、转小写并校验)。
/// </summary>
/// <param name="value">原始编码。</param>
/// <returns>规范化后的编码。</returns>
/// <exception cref="ArgumentException">编码非法时抛出。</exception>
public static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Dictionary code is required.", nameof(value));
}
var trimmed = value.Trim();
if (!CodePattern.IsMatch(trimmed))
{
throw new ArgumentException("Dictionary code must be 2-64 characters of letters, digits, or underscore.", nameof(value));
}
return trimmed.ToLowerInvariant();
}
/// <inheritdoc />
public bool Equals(DictionaryCode other)
=> StringComparer.Ordinal.Equals(Value, other.Value);
/// <inheritdoc />
public override bool Equals(object? obj)
=> obj is DictionaryCode other && Equals(other);
/// <inheritdoc />
public override int GetHashCode()
=> StringComparer.Ordinal.GetHashCode(Value);
/// <inheritdoc />
public override string ToString()
=> Value;
public static bool operator ==(DictionaryCode left, DictionaryCode right)
=> left.Equals(right);
public static bool operator !=(DictionaryCode left, DictionaryCode right)
=> !left.Equals(right);
public static implicit operator string(DictionaryCode code)
=> code.Value;
public static implicit operator DictionaryCode(string value)
=> new(value);
}

View File

@@ -0,0 +1,151 @@
using System.Text.Encodings.Web;
using System.Text.Json;
namespace TakeoutSaaS.Domain.Dictionary.ValueObjects;
/// <summary>
/// 多语言字典值对象,封装语言键值映射。
/// </summary>
public sealed class I18nValue : IEquatable<I18nValue>
{
private static readonly string[] FallbackLocales = ["zh-CN", "en"];
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly Dictionary<string, string> _values;
/// <summary>
/// 初始化多语言值。
/// </summary>
/// <param name="values">语言键值映射。</param>
/// <exception cref="ArgumentException">传入值为空或无有效条目时抛出。</exception>
public I18nValue(IDictionary<string, string> values)
{
if (values == null)
{
throw new ArgumentNullException(nameof(values));
}
_values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in values)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
continue;
}
_values[key.Trim()] = value.Trim();
}
if (_values.Count == 0)
{
throw new ArgumentException("I18nValue requires at least one non-empty entry.", nameof(values));
}
}
/// <summary>
/// 语言键值只读视图。
/// </summary>
public IReadOnlyDictionary<string, string> Values => _values;
/// <summary>
/// 获取指定语言值,支持回退策略。
/// </summary>
/// <param name="locale">语言标识。</param>
/// <returns>匹配语言值。</returns>
public string Extract(string? locale)
{
if (!string.IsNullOrWhiteSpace(locale) && _values.TryGetValue(locale, out var value))
{
return value;
}
foreach (var fallback in FallbackLocales)
{
if (_values.TryGetValue(fallback, out var fallbackValue))
{
return fallbackValue;
}
}
return _values.Values.First();
}
/// <summary>
/// 转换为普通字典。
/// </summary>
public Dictionary<string, string> ToDictionary()
=> new(_values, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 转换为 JSON 字符串。
/// </summary>
public string ToJson()
=> JsonSerializer.Serialize(_values, JsonOptions);
/// <summary>
/// 从 JSON 字符串解析多语言值。
/// </summary>
public static I18nValue FromJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
throw new ArgumentException("JSON payload is required.", nameof(json));
}
var values = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions) ?? new Dictionary<string, string>();
return new I18nValue(values);
}
/// <inheritdoc />
public bool Equals(I18nValue? other)
{
if (other == null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
if (_values.Count != other._values.Count)
{
return false;
}
foreach (var (key, value) in _values)
{
if (!other._values.TryGetValue(key, out var otherValue))
{
return false;
}
if (!string.Equals(value, otherValue, StringComparison.Ordinal))
{
return false;
}
}
return true;
}
/// <inheritdoc />
public override bool Equals(object? obj)
=> obj is I18nValue other && Equals(other);
/// <inheritdoc />
public override int GetHashCode()
{
var hash = new HashCode();
foreach (var pair in _values.OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase))
{
hash.Add(pair.Key, StringComparer.OrdinalIgnoreCase);
hash.Add(pair.Value, StringComparer.Ordinal);
}
return hash.ToHashCode();
}
}