feat: 实现字典管理后端
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// CSV 字典导入解析器。
|
||||
/// </summary>
|
||||
public interface ICsvDictionaryParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析 CSV 数据。
|
||||
/// </summary>
|
||||
/// <param name="stream">输入流。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>解析后的记录列表。</returns>
|
||||
Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 字典两级缓存访问接口。
|
||||
/// </summary>
|
||||
public interface IDictionaryHybridCache
|
||||
{
|
||||
/// <summary>
|
||||
/// 读取缓存,不存在时通过工厂生成并回填。
|
||||
/// </summary>
|
||||
Task<T?> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<T?>> factory,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按前缀失效缓存。
|
||||
/// </summary>
|
||||
Task InvalidateAsync(
|
||||
string prefix,
|
||||
CacheInvalidationOperation operation = CacheInvalidationOperation.Update,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// JSON 字典导入解析器。
|
||||
/// </summary>
|
||||
public interface IJsonDictionaryParser
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析 JSON 数据。
|
||||
/// </summary>
|
||||
/// <param name="stream">输入流。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>解析后的记录列表。</returns>
|
||||
Task<IReadOnlyList<DictionaryImportRow>> ParseAsync(Stream stream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -26,6 +26,11 @@ public sealed class CreateDictionaryGroupRequest
|
||||
[Required]
|
||||
public DictionaryScope Scope { get; set; } = DictionaryScope.Business;
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许租户覆盖。
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述信息。
|
||||
/// </summary>
|
||||
|
||||
@@ -19,14 +19,14 @@ public sealed class CreateDictionaryItemRequest
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
[Required, MaxLength(64)]
|
||||
[Required, MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
[Required, MaxLength(256)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public Dictionary<string, string> Value { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认项。
|
||||
|
||||
@@ -12,6 +12,36 @@ public sealed class DictionaryGroupQuery
|
||||
/// </summary>
|
||||
public DictionaryScope? Scope { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(匹配编码或名称)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 启用状态过滤。
|
||||
/// </summary>
|
||||
public bool? IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分页页码。
|
||||
/// </summary>
|
||||
public int Page { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 分页大小。
|
||||
/// </summary>
|
||||
public int PageSize { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段。
|
||||
/// </summary>
|
||||
public string? SortBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序方向(asc/desc)。
|
||||
/// </summary>
|
||||
public string? SortOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含字典项。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入请求。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组 ID。
|
||||
/// </summary>
|
||||
public long GroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名称。
|
||||
/// </summary>
|
||||
public string FileName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小(字节)。
|
||||
/// </summary>
|
||||
public long FileSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 冲突解决模式。
|
||||
/// </summary>
|
||||
public ConflictResolutionMode ConflictMode { get; init; } = ConflictResolutionMode.Skip;
|
||||
|
||||
/// <summary>
|
||||
/// 文件流。
|
||||
/// </summary>
|
||||
public Stream FileStream { get; init; } = Stream.Null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典覆盖隐藏项请求。
|
||||
/// </summary>
|
||||
public sealed class DictionaryOverrideHiddenItemsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 需要隐藏的系统字典项 ID 列表。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public long[] HiddenItemIds { get; set; } = Array.Empty<long>();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典覆盖排序请求。
|
||||
/// </summary>
|
||||
public sealed class DictionaryOverrideSortOrderRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 排序配置(字典项 ID -> 排序值)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Dictionary<long, int> SortOrder { get; set; } = new();
|
||||
}
|
||||
@@ -19,8 +19,18 @@ public sealed class UpdateDictionaryGroupRequest
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许租户覆盖。
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 行版本,用于并发控制。
|
||||
/// </summary>
|
||||
public byte[]? RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ namespace TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
/// </summary>
|
||||
public sealed class UpdateDictionaryItemRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
[Required, MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值。
|
||||
/// </summary>
|
||||
[Required, MaxLength(256)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
[Required]
|
||||
public Dictionary<string, string> Value { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认项。
|
||||
@@ -33,4 +39,9 @@ public sealed class UpdateDictionaryItemRequest
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 行版本,用于并发控制。
|
||||
/// </summary>
|
||||
public byte[]? RowVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ public static class DictionaryServiceCollectionExtensions
|
||||
public static IServiceCollection AddDictionaryApplication(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IDictionaryAppService, DictionaryAppService>();
|
||||
services.AddScoped<DictionaryCommandService>();
|
||||
services.AddScoped<DictionaryQueryService>();
|
||||
services.AddScoped<DictionaryMergeService>();
|
||||
services.AddScoped<DictionaryOverrideService>();
|
||||
services.AddScoped<DictionaryImportExportService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ public sealed class DictionaryGroupDto
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 分组名称。
|
||||
/// </summary>
|
||||
@@ -35,11 +41,31 @@ public sealed class DictionaryGroupDto
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许覆盖。
|
||||
/// </summary>
|
||||
public bool AllowOverride { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典项集合。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入结果 DTO。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 成功数量。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 跳过数量。
|
||||
/// </summary>
|
||||
public int SkipCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误数量。
|
||||
/// </summary>
|
||||
public int ErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<ImportError> Errors { get; init; } = Array.Empty<ImportError>();
|
||||
|
||||
/// <summary>
|
||||
/// 处理耗时。
|
||||
/// </summary>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 导入错误详情。
|
||||
/// </summary>
|
||||
public sealed class ImportError
|
||||
{
|
||||
/// <summary>
|
||||
/// 行号。
|
||||
/// </summary>
|
||||
public int RowNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字段名。
|
||||
/// </summary>
|
||||
public string Field { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 错误信息。
|
||||
/// </summary>
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入记录。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportRow
|
||||
{
|
||||
/// <summary>
|
||||
/// 行号(从 1 开始,包含表头行的偏移)。
|
||||
/// </summary>
|
||||
public int RowNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典分组编码。
|
||||
/// </summary>
|
||||
public string? Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项键。
|
||||
/// </summary>
|
||||
public string? Key { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项值(JSON 字符串)。
|
||||
/// </summary>
|
||||
public string? Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int? SortOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源:system / tenant。
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
@@ -28,7 +28,8 @@ public sealed class DictionaryItemDto
|
||||
/// <summary>
|
||||
/// 值。
|
||||
/// </summary>
|
||||
public string Value { get; init; } = string.Empty;
|
||||
[JsonPropertyName("value")]
|
||||
public Dictionary<string, string> Value { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 是否默认值。
|
||||
@@ -49,4 +50,14 @@ public sealed class DictionaryItemDto
|
||||
/// 描述。
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源:system / tenant。
|
||||
/// </summary>
|
||||
public string Source { get; init; } = "system";
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖配置 DTO。
|
||||
/// </summary>
|
||||
public sealed class OverrideConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 系统字典分组编码。
|
||||
/// </summary>
|
||||
public string SystemDictionaryGroupCode { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用覆盖。
|
||||
/// </summary>
|
||||
public bool OverrideEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 隐藏的系统字典项 ID 列表。
|
||||
/// </summary>
|
||||
public long[] HiddenSystemItemIds { get; init; } = Array.Empty<long>();
|
||||
|
||||
/// <summary>
|
||||
/// 自定义排序映射。
|
||||
/// </summary>
|
||||
public Dictionary<long, int> CustomSortOrder { get; init; } = new();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
@@ -47,8 +48,10 @@ public sealed class DictionaryAppService(
|
||||
Code = normalizedCode,
|
||||
Name = request.Name.Trim(),
|
||||
Scope = request.Scope,
|
||||
AllowOverride = request.AllowOverride,
|
||||
Description = request.Description?.Trim(),
|
||||
IsEnabled = true
|
||||
IsEnabled = true,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
// 4. 持久化并返回
|
||||
@@ -71,13 +74,32 @@ public sealed class DictionaryAppService(
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!request.RowVersion.SequenceEqual(group.RowVersion))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
// 2. 更新字段
|
||||
group.Name = request.Name.Trim();
|
||||
group.Description = request.Description?.Trim();
|
||||
group.IsEnabled = request.IsEnabled;
|
||||
group.AllowOverride = request.AllowOverride;
|
||||
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("更新字典分组:{GroupId}", group.Id);
|
||||
return MapGroup(group, includeItems: false);
|
||||
@@ -154,11 +176,12 @@ public sealed class DictionaryAppService(
|
||||
TenantId = group.TenantId,
|
||||
GroupId = group.Id,
|
||||
Key = request.Key.Trim(),
|
||||
Value = request.Value.Trim(),
|
||||
Value = DictionaryValueConverter.Serialize(request.Value),
|
||||
Description = request.Description?.Trim(),
|
||||
SortOrder = request.SortOrder,
|
||||
IsDefault = request.IsDefault,
|
||||
IsEnabled = request.IsEnabled
|
||||
IsEnabled = request.IsEnabled,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
@@ -183,15 +206,34 @@ public sealed class DictionaryAppService(
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureScopePermission(group.Scope);
|
||||
|
||||
if (request.RowVersion == null || request.RowVersion.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!request.RowVersion.SequenceEqual(item.RowVersion))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
// 2. 更新字段
|
||||
item.Value = request.Value.Trim();
|
||||
item.Key = request.Key.Trim();
|
||||
item.Value = DictionaryValueConverter.Serialize(request.Value);
|
||||
item.Description = request.Description?.Trim();
|
||||
item.SortOrder = request.SortOrder;
|
||||
item.IsDefault = request.IsDefault;
|
||||
item.IsEnabled = request.IsEnabled;
|
||||
item.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
// 3. 持久化并失效缓存
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
await InvalidateCacheAsync(group, cancellationToken);
|
||||
logger.LogInformation("更新字典项:{ItemId}", item.Id);
|
||||
return MapItem(item);
|
||||
@@ -381,10 +423,15 @@ public sealed class DictionaryAppService(
|
||||
{
|
||||
Id = group.Id,
|
||||
Code = group.Code,
|
||||
TenantId = group.TenantId,
|
||||
Name = group.Name,
|
||||
Scope = group.Scope,
|
||||
Description = group.Description,
|
||||
AllowOverride = group.AllowOverride,
|
||||
IsEnabled = group.IsEnabled,
|
||||
CreatedAt = group.CreatedAt,
|
||||
UpdatedAt = group.UpdatedAt,
|
||||
RowVersion = group.RowVersion,
|
||||
Items = includeItems ? items ?? group.Items.Select(MapItem).ToList() : Array.Empty<DictionaryItemDto>()
|
||||
};
|
||||
}
|
||||
@@ -395,10 +442,15 @@ public sealed class DictionaryAppService(
|
||||
Id = item.Id,
|
||||
GroupId = item.GroupId,
|
||||
Key = item.Key,
|
||||
Value = item.Value,
|
||||
Value = DictionaryValueConverter.Deserialize(item.Value),
|
||||
IsDefault = item.IsDefault,
|
||||
IsEnabled = item.IsEnabled,
|
||||
SortOrder = item.SortOrder,
|
||||
Description = item.Description
|
||||
Description = item.Description,
|
||||
Source = item.TenantId == 0 ? "system" : "tenant",
|
||||
RowVersion = item.RowVersion
|
||||
};
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典缓存键生成器。
|
||||
/// </summary>
|
||||
internal static class DictionaryCacheKeys
|
||||
{
|
||||
internal const string DictionaryPrefix = "dict:";
|
||||
internal const string GroupPrefix = "dict:groups:";
|
||||
internal const string ItemPrefix = "dict:items:";
|
||||
|
||||
internal static string BuildDictionaryKey(long tenantId, DictionaryCode code)
|
||||
=> $"{DictionaryPrefix}{tenantId}:{code.Value}";
|
||||
|
||||
internal static string BuildGroupKey(
|
||||
long tenantId,
|
||||
DictionaryScope scope,
|
||||
int page,
|
||||
int pageSize,
|
||||
string? keyword,
|
||||
bool? isEnabled,
|
||||
string? sortBy,
|
||||
bool sortDescending)
|
||||
{
|
||||
return $"{GroupPrefix}{tenantId}:{scope}:{page}:{pageSize}:{Normalize(keyword)}:{Normalize(isEnabled)}:{Normalize(sortBy)}:{(sortDescending ? "desc" : "asc")}";
|
||||
}
|
||||
|
||||
internal static string BuildGroupPrefix(long tenantId)
|
||||
=> $"{GroupPrefix}{tenantId}:";
|
||||
|
||||
internal static string BuildItemKey(long groupId)
|
||||
=> $"{ItemPrefix}{groupId}";
|
||||
|
||||
private static string Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? "all"
|
||||
: value.Trim().ToLowerInvariant().Replace(":", "_", StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(bool? value)
|
||||
=> value.HasValue ? (value.Value ? "1" : "0") : "all";
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典写操作服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryCommandService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DictionaryCommandService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建字典分组。
|
||||
/// </summary>
|
||||
public async Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var targetTenantId = ResolveTargetTenant(request.Scope);
|
||||
var code = new DictionaryCode(request.Code);
|
||||
|
||||
var existing = await groupRepository.GetByCodeAsync(targetTenantId, code, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {code.Value} 已存在");
|
||||
}
|
||||
|
||||
var group = new DictionaryGroup
|
||||
{
|
||||
TenantId = targetTenantId,
|
||||
Code = code,
|
||||
Name = request.Name.Trim(),
|
||||
Scope = request.Scope,
|
||||
AllowOverride = request.AllowOverride,
|
||||
Description = request.Description?.Trim(),
|
||||
IsEnabled = true,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
await groupRepository.AddAsync(group, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildGroupPrefix(targetTenantId),
|
||||
CacheInvalidationOperation.Create,
|
||||
cancellationToken);
|
||||
|
||||
logger.LogInformation("创建字典分组 {GroupCode}", group.Code);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典分组。
|
||||
/// </summary>
|
||||
public async Task<DictionaryGroupDto> UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
EnsureRowVersion(request.RowVersion, group.RowVersion, "字典分组");
|
||||
|
||||
group.Name = request.Name.Trim();
|
||||
group.Description = request.Description?.Trim();
|
||||
group.IsEnabled = request.IsEnabled;
|
||||
group.AllowOverride = request.AllowOverride;
|
||||
group.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
try
|
||||
{
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典分组已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
||||
logger.LogInformation("更新字典分组 {GroupId}", group.Id);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典分组。
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
foreach (var item in items)
|
||||
{
|
||||
await itemRepository.RemoveAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
await groupRepository.RemoveAsync(group, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateGroupCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
||||
|
||||
logger.LogInformation("删除字典分组 {GroupId}", group.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典项。
|
||||
/// </summary>
|
||||
public async Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
var normalizedKey = request.Key.Trim();
|
||||
if (items.Any(item => string.Equals(item.Key, normalizedKey, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"字典项键 {normalizedKey} 已存在");
|
||||
}
|
||||
|
||||
var sortOrder = request.SortOrder;
|
||||
if (sortOrder <= 0)
|
||||
{
|
||||
sortOrder = items.Count == 0 ? 10 : items.Max(item => item.SortOrder) + 10;
|
||||
}
|
||||
|
||||
var item = new DictionaryItem
|
||||
{
|
||||
TenantId = group.TenantId,
|
||||
GroupId = group.Id,
|
||||
Key = normalizedKey,
|
||||
Value = DictionaryValueConverter.Serialize(request.Value),
|
||||
Description = request.Description?.Trim(),
|
||||
SortOrder = sortOrder,
|
||||
IsDefault = request.IsDefault,
|
||||
IsEnabled = request.IsEnabled,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
await itemRepository.AddAsync(item, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Create, cancellationToken);
|
||||
|
||||
logger.LogInformation("新增字典项 {ItemId}", item.Id);
|
||||
return DictionaryMapper.ToItemDto(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
public async Task<DictionaryItemDto> UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await RequireItemAsync(itemId, cancellationToken);
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
EnsureRowVersion(request.RowVersion, item.RowVersion, "字典项");
|
||||
|
||||
var normalizedKey = request.Key.Trim();
|
||||
if (!string.Equals(item.Key, normalizedKey, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
if (items.Any(existing => existing.Id != item.Id && string.Equals(existing.Key, normalizedKey, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"字典项键 {normalizedKey} 已存在");
|
||||
}
|
||||
}
|
||||
|
||||
item.Key = normalizedKey;
|
||||
item.Value = DictionaryValueConverter.Serialize(request.Value);
|
||||
item.Description = request.Description?.Trim();
|
||||
item.SortOrder = request.SortOrder;
|
||||
item.IsDefault = request.IsDefault;
|
||||
item.IsEnabled = request.IsEnabled;
|
||||
item.RowVersion = RandomNumberGenerator.GetBytes(16);
|
||||
|
||||
try
|
||||
{
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (IsConcurrencyException(exception))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "字典项已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Update, cancellationToken);
|
||||
logger.LogInformation("更新字典项 {ItemId}", item.Id);
|
||||
return DictionaryMapper.ToItemDto(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteItemAsync(long itemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await itemRepository.GetByIdAsync(itemId, cancellationToken);
|
||||
if (item == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var group = await RequireGroupAsync(item.GroupId, cancellationToken);
|
||||
EnsureGroupAccess(group);
|
||||
|
||||
await itemRepository.RemoveAsync(item, cancellationToken);
|
||||
await groupRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateItemCacheAsync(group, CacheInvalidationOperation.Delete, cancellationToken);
|
||||
|
||||
logger.LogInformation("删除字典项 {ItemId}", item.Id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private long ResolveTargetTenant(DictionaryScope scope)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (scope == DictionaryScope.System)
|
||||
{
|
||||
if (tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可创建系统字典");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "业务字典必须在租户上下文中创建");
|
||||
}
|
||||
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureRowVersion(byte[]? requestVersion, byte[] entityVersion, string resourceName)
|
||||
{
|
||||
if (requestVersion == null || requestVersion.Length == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "RowVersion 不能为空");
|
||||
}
|
||||
|
||||
if (!requestVersion.SequenceEqual(entityVersion))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, $"{resourceName}已被修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private async Task<DictionaryItem> RequireItemAsync(long itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
var item = await itemRepository.GetByIdAsync(itemId, cancellationToken);
|
||||
if (item == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典项不存在");
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
private Task InvalidateGroupCacheAsync(
|
||||
DictionaryGroup group,
|
||||
CacheInvalidationOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private Task InvalidateItemCacheAsync(
|
||||
DictionaryGroup group,
|
||||
CacheInvalidationOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), operation, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), operation, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, operation, cancellationToken));
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典导入导出服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryImportExportService(
|
||||
ICsvDictionaryParser csvParser,
|
||||
IJsonDictionaryParser jsonParser,
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
IDictionaryImportLogRepository importLogRepository,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUser,
|
||||
ILogger<DictionaryImportExportService> logger)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 导出 CSV。
|
||||
/// </summary>
|
||||
public async Task ExportToCsvAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupReadable(group);
|
||||
|
||||
var items = await ResolveExportItemsAsync(group, cancellationToken);
|
||||
await WriteCsvAsync(group, items, output, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出 JSON。
|
||||
/// </summary>
|
||||
public async Task ExportToJsonAsync(long groupId, Stream output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await RequireGroupAsync(groupId, cancellationToken);
|
||||
EnsureGroupReadable(group);
|
||||
|
||||
var items = await ResolveExportItemsAsync(group, cancellationToken);
|
||||
var payload = items.Select(item => new DictionaryExportRow
|
||||
{
|
||||
Code = group.Code.Value,
|
||||
Key = item.Key,
|
||||
Value = item.Value,
|
||||
SortOrder = item.SortOrder,
|
||||
IsEnabled = item.IsEnabled,
|
||||
Description = item.Description,
|
||||
Source = item.Source
|
||||
});
|
||||
|
||||
await JsonSerializer.SerializeAsync(output, payload, JsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入 CSV。
|
||||
/// </summary>
|
||||
public async Task<DictionaryImportResultDto> ImportFromCsvAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await csvParser.ParseAsync(request.FileStream, cancellationToken);
|
||||
return await ImportAsync(request, rows, "CSV", cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入 JSON。
|
||||
/// </summary>
|
||||
public async Task<DictionaryImportResultDto> ImportFromJsonAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await jsonParser.ParseAsync(request.FileStream, cancellationToken);
|
||||
return await ImportAsync(request, rows, "JSON", cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<DictionaryImportResultDto> ImportAsync(
|
||||
DictionaryImportRequest request,
|
||||
IReadOnlyList<DictionaryImportRow> rows,
|
||||
string format,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var group = await RequireGroupAsync(request.GroupId, cancellationToken);
|
||||
EnsureGroupWritable(group);
|
||||
|
||||
var errors = new List<DictionaryImportResultDto.ImportError>();
|
||||
var validRows = new List<NormalizedRow>(rows.Count);
|
||||
var hasFatalErrors = false;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!TryNormalizeRow(group, row, errors, out var normalized))
|
||||
{
|
||||
hasFatalErrors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
validRows.Add(normalized);
|
||||
}
|
||||
|
||||
if (hasFatalErrors)
|
||||
{
|
||||
var failed = BuildResult(0, 0, errors, stopwatch.Elapsed);
|
||||
await RecordImportLogAsync(request, group, format, failed, stopwatch.Elapsed, cancellationToken);
|
||||
return failed;
|
||||
}
|
||||
|
||||
var existingItems = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
var existingMap = existingItems.ToDictionary(item => item.Key, StringComparer.OrdinalIgnoreCase);
|
||||
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var successCount = 0;
|
||||
var skipCount = 0;
|
||||
var nextSortOrder = existingItems.Count == 0 ? 0 : existingItems.Max(item => item.SortOrder);
|
||||
|
||||
foreach (var row in validRows)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!seenKeys.Add(row.Key))
|
||||
{
|
||||
skipCount++;
|
||||
errors.Add(CreateError(row.RowNumber, "key", $"字典项键 {row.Key} 在导入文件中重复"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingMap.TryGetValue(row.Key, out var existing))
|
||||
{
|
||||
if (request.ConflictMode != ConflictResolutionMode.Overwrite)
|
||||
{
|
||||
skipCount++;
|
||||
errors.Add(CreateError(row.RowNumber, "key", $"字典项键 {row.Key} 已存在"));
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyUpdate(existing, row, existing.SortOrder, overwriteSort: row.SortOrder.HasValue);
|
||||
await itemRepository.UpdateAsync(existing, cancellationToken);
|
||||
successCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var sortOrder = row.SortOrder ?? 0;
|
||||
if (!row.SortOrder.HasValue)
|
||||
{
|
||||
nextSortOrder = nextSortOrder == 0 ? 10 : nextSortOrder + 10;
|
||||
sortOrder = nextSortOrder;
|
||||
}
|
||||
|
||||
var item = new DictionaryItem
|
||||
{
|
||||
TenantId = group.TenantId,
|
||||
GroupId = group.Id,
|
||||
Key = row.Key,
|
||||
Value = row.ValueJson,
|
||||
SortOrder = sortOrder,
|
||||
IsEnabled = row.IsEnabled ?? true,
|
||||
IsDefault = false,
|
||||
Description = row.Description,
|
||||
RowVersion = RandomNumberGenerator.GetBytes(16)
|
||||
};
|
||||
|
||||
await itemRepository.AddAsync(item, cancellationToken);
|
||||
existingMap[item.Key] = item;
|
||||
successCount++;
|
||||
}
|
||||
|
||||
await itemRepository.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateGroupCacheAsync(group, cancellationToken);
|
||||
|
||||
var result = BuildResult(successCount, skipCount, errors, stopwatch.Elapsed);
|
||||
await RecordImportLogAsync(request, group, format, result, stopwatch.Elapsed, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyUpdate(DictionaryItem item, NormalizedRow row, int defaultSortOrder, bool overwriteSort)
|
||||
{
|
||||
item.Key = row.Key;
|
||||
item.Value = row.ValueJson;
|
||||
if (overwriteSort)
|
||||
{
|
||||
item.SortOrder = row.SortOrder ?? defaultSortOrder;
|
||||
}
|
||||
|
||||
if (row.IsEnabled.HasValue)
|
||||
{
|
||||
item.IsEnabled = row.IsEnabled.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.Description))
|
||||
{
|
||||
item.Description = row.Description;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DictionaryItemDto>> ResolveExportItemsAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
var mergedItems = await itemRepository.GetMergedItemsAsync(tenantId, group.Id, includeOverrides: true, cancellationToken);
|
||||
return mergedItems.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, cancellationToken);
|
||||
return items.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
private static async Task WriteCsvAsync(
|
||||
DictionaryGroup group,
|
||||
IReadOnlyList<DictionaryItemDto> items,
|
||||
Stream output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var writer = new StreamWriter(output, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), leaveOpen: true);
|
||||
|
||||
await writer.WriteLineAsync("code,key,value,sortOrder,isEnabled,description,source");
|
||||
foreach (var item in items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var valueJson = JsonSerializer.Serialize(item.Value, JsonOptions);
|
||||
var row = new[]
|
||||
{
|
||||
group.Code.Value,
|
||||
item.Key,
|
||||
valueJson,
|
||||
item.SortOrder.ToString(),
|
||||
item.IsEnabled ? "true" : "false",
|
||||
item.Description ?? string.Empty,
|
||||
item.Source
|
||||
};
|
||||
|
||||
await writer.WriteLineAsync(ToCsvRow(row));
|
||||
}
|
||||
|
||||
await writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string ToCsvRow(IEnumerable<string> fields)
|
||||
=> string.Join(",", fields.Select(EscapeCsvField));
|
||||
|
||||
private static string EscapeCsvField(string value)
|
||||
{
|
||||
if (value.Contains('"', StringComparison.Ordinal))
|
||||
{
|
||||
value = value.Replace("\"", "\"\"");
|
||||
}
|
||||
|
||||
if (value.Contains(',', StringComparison.Ordinal) ||
|
||||
value.Contains('\n', StringComparison.Ordinal) ||
|
||||
value.Contains('\r', StringComparison.Ordinal) ||
|
||||
value.Contains('"', StringComparison.Ordinal))
|
||||
{
|
||||
return $"\"{value}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private bool TryNormalizeRow(
|
||||
DictionaryGroup group,
|
||||
DictionaryImportRow row,
|
||||
ICollection<DictionaryImportResultDto.ImportError> errors,
|
||||
out NormalizedRow normalized)
|
||||
{
|
||||
normalized = default;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.Code) &&
|
||||
!string.Equals(row.Code.Trim(), group.Code.Value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "code", "字典分组编码不匹配"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(row.Key))
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "key", "字典项键不能为空"));
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = row.Key.Trim();
|
||||
if (key.Length > 128)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "key", "字典项键长度不能超过 128"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(row.Value))
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "value", "字典项值不能为空"));
|
||||
return false;
|
||||
}
|
||||
|
||||
string valueJson;
|
||||
try
|
||||
{
|
||||
var i18n = I18nValue.FromJson(row.Value);
|
||||
valueJson = i18n.ToJson();
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "value", "字典项值必须为合法的多语言 JSON"));
|
||||
return false;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "value", "字典项值必须为合法的多语言 JSON"));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (row.SortOrder.HasValue && row.SortOrder.Value < 0)
|
||||
{
|
||||
errors.Add(CreateError(row.RowNumber, "sortOrder", "排序值不能小于 0"));
|
||||
return false;
|
||||
}
|
||||
|
||||
normalized = new NormalizedRow
|
||||
{
|
||||
RowNumber = row.RowNumber,
|
||||
Key = key,
|
||||
ValueJson = valueJson,
|
||||
SortOrder = row.SortOrder,
|
||||
IsEnabled = row.IsEnabled,
|
||||
Description = row.Description?.Trim()
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DictionaryImportResultDto BuildResult(
|
||||
int successCount,
|
||||
int skipCount,
|
||||
IReadOnlyList<DictionaryImportResultDto.ImportError> errors,
|
||||
TimeSpan duration)
|
||||
{
|
||||
return new DictionaryImportResultDto
|
||||
{
|
||||
SuccessCount = successCount,
|
||||
SkipCount = skipCount,
|
||||
ErrorCount = errors.Count,
|
||||
Errors = errors.ToArray(),
|
||||
Duration = duration
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RecordImportLogAsync(
|
||||
DictionaryImportRequest request,
|
||||
DictionaryGroup group,
|
||||
string format,
|
||||
DictionaryImportResultDto result,
|
||||
TimeSpan duration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = new DictionaryImportLog
|
||||
{
|
||||
TenantId = group.TenantId,
|
||||
OperatorId = currentUser.UserId,
|
||||
DictionaryGroupCode = group.Code.Value,
|
||||
FileName = request.FileName,
|
||||
FileSize = request.FileSize,
|
||||
Format = format,
|
||||
ConflictMode = request.ConflictMode,
|
||||
SuccessCount = result.SuccessCount,
|
||||
SkipCount = result.SkipCount,
|
||||
ErrorDetails = result.Errors.Count == 0 ? null : JsonSerializer.Serialize(result.Errors, JsonOptions),
|
||||
ProcessedAt = DateTime.UtcNow,
|
||||
Duration = duration
|
||||
};
|
||||
|
||||
await importLogRepository.AddAsync(log, cancellationToken);
|
||||
await importLogRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "记录字典导入日志失败");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InvalidateGroupCacheAsync(DictionaryGroup group, CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildGroupPrefix(group.TenantId), CacheInvalidationOperation.Update, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildItemKey(group.Id), CacheInvalidationOperation.Update, cancellationToken),
|
||||
cache.InvalidateAsync(DictionaryCacheKeys.BuildDictionaryKey(group.TenantId, group.Code), CacheInvalidationOperation.Update, cancellationToken)
|
||||
};
|
||||
|
||||
if (group.Scope == DictionaryScope.System)
|
||||
{
|
||||
tasks.Add(cache.InvalidateAsync(DictionaryCacheKeys.DictionaryPrefix, CacheInvalidationOperation.Update, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private void EnsureGroupAccess(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (group.Scope == DictionaryScope.System && tenantId != 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
if (group.Scope == DictionaryScope.Business && tenantId != group.TenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权操作其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGroupReadable(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGroupWritable(DictionaryGroup group)
|
||||
{
|
||||
EnsureGroupAccess(group);
|
||||
}
|
||||
|
||||
private static DictionaryImportResultDto.ImportError CreateError(int rowNumber, string field, string message)
|
||||
=> new()
|
||||
{
|
||||
RowNumber = rowNumber,
|
||||
Field = field,
|
||||
Message = message
|
||||
};
|
||||
|
||||
private sealed class DictionaryExportRow
|
||||
{
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public string Key { get; init; } = string.Empty;
|
||||
public Dictionary<string, string> Value { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public int SortOrder { get; init; }
|
||||
public bool IsEnabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string Source { get; init; } = "system";
|
||||
}
|
||||
|
||||
private readonly struct NormalizedRow
|
||||
{
|
||||
public int RowNumber { get; init; }
|
||||
public string Key { get; init; }
|
||||
public string ValueJson { get; init; }
|
||||
public int? SortOrder { get; init; }
|
||||
public bool? IsEnabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典实体映射辅助。
|
||||
/// </summary>
|
||||
internal static class DictionaryMapper
|
||||
{
|
||||
internal static DictionaryGroupDto ToGroupDto(DictionaryGroup group, IReadOnlyList<DictionaryItemDto>? items = null)
|
||||
{
|
||||
return new DictionaryGroupDto
|
||||
{
|
||||
Id = group.Id,
|
||||
TenantId = group.TenantId,
|
||||
Code = group.Code,
|
||||
Name = group.Name,
|
||||
Scope = group.Scope,
|
||||
AllowOverride = group.AllowOverride,
|
||||
Description = group.Description,
|
||||
IsEnabled = group.IsEnabled,
|
||||
CreatedAt = group.CreatedAt,
|
||||
UpdatedAt = group.UpdatedAt,
|
||||
RowVersion = group.RowVersion,
|
||||
Items = items ?? Array.Empty<DictionaryItemDto>()
|
||||
};
|
||||
}
|
||||
|
||||
internal static DictionaryItemDto ToItemDto(DictionaryItem item)
|
||||
{
|
||||
return new DictionaryItemDto
|
||||
{
|
||||
Id = item.Id,
|
||||
GroupId = item.GroupId,
|
||||
Key = item.Key,
|
||||
Value = DictionaryValueConverter.Deserialize(item.Value),
|
||||
IsDefault = item.IsDefault,
|
||||
IsEnabled = item.IsEnabled,
|
||||
SortOrder = item.SortOrder,
|
||||
Description = item.Description,
|
||||
Source = item.TenantId == 0 ? "system" : "tenant",
|
||||
RowVersion = item.RowVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典覆盖合并服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryMergeService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
ITenantDictionaryOverrideRepository overrideRepository)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 合并系统字典项与租户字典项。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>> MergeItemsAsync(
|
||||
long tenantId,
|
||||
long systemGroupId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var systemGroup = await groupRepository.GetByIdAsync(systemGroupId, cancellationToken);
|
||||
if (systemGroup == null || systemGroup.Scope != DictionaryScope.System || !systemGroup.IsEnabled)
|
||||
{
|
||||
return Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroupId, cancellationToken);
|
||||
var activeSystem = systemItems.Where(item => item.IsEnabled).ToList();
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
return activeSystem.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
var overrideConfig = await overrideRepository.GetAsync(tenantId, systemGroupId, cancellationToken);
|
||||
if (overrideConfig == null || !overrideConfig.OverrideEnabled)
|
||||
{
|
||||
return activeSystem.Select(DictionaryMapper.ToItemDto).ToList();
|
||||
}
|
||||
|
||||
var tenantGroup = await groupRepository.GetByCodeAsync(tenantId, systemGroup.Code, cancellationToken);
|
||||
var tenantItems = tenantGroup != null && tenantGroup.IsEnabled
|
||||
? await itemRepository.GetByGroupIdAsync(tenantId, tenantGroup.Id, cancellationToken)
|
||||
: Array.Empty<DictionaryItem>();
|
||||
|
||||
var activeTenant = tenantItems.Where(item => item.IsEnabled).ToList();
|
||||
var hiddenSet = new HashSet<long>(overrideConfig.HiddenSystemItemIds);
|
||||
var merged = activeSystem
|
||||
.Where(item => !hiddenSet.Contains(item.Id))
|
||||
.Concat(activeTenant)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
|
||||
var sortOrder = ParseSortOrder(overrideConfig.CustomSortOrder);
|
||||
return merged
|
||||
.OrderBy(item => sortOrder.TryGetValue(item.Id, out var custom) ? custom : item.SortOrder)
|
||||
.ThenBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<long, int> ParseSortOrder(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<long, int>>(json, JsonOptions) ?? new Dictionary<long, int>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 租户字典覆盖配置服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryOverrideService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
ITenantDictionaryOverrideRepository overrideRepository,
|
||||
IDictionaryHybridCache cache)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户覆盖配置列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<OverrideConfigDto>> GetOverridesAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var configs = await overrideRepository.ListAsync(tenantId, cancellationToken);
|
||||
if (configs.Count == 0)
|
||||
{
|
||||
return Array.Empty<OverrideConfigDto>();
|
||||
}
|
||||
|
||||
var groupIds = configs.Select(config => config.SystemDictionaryGroupId).Distinct().ToArray();
|
||||
var groups = await groupRepository.GetByIdsAsync(groupIds, cancellationToken);
|
||||
var codeMap = groups
|
||||
.Where(group => group.Scope == DictionaryScope.System)
|
||||
.ToDictionary(group => group.Id, group => group.Code);
|
||||
|
||||
var result = new List<OverrideConfigDto>(configs.Count);
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (codeMap.TryGetValue(config.SystemDictionaryGroupId, out var code))
|
||||
{
|
||||
result.Add(MapOverrideDto(config, code));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户指定分组的覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto?> GetOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
return config == null ? null : MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto> EnableOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
if (!systemGroup.AllowOverride)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
|
||||
}
|
||||
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
config = new TenantDictionaryOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SystemDictionaryGroupId = systemGroup.Id,
|
||||
OverrideEnabled = true,
|
||||
HiddenSystemItemIds = Array.Empty<long>(),
|
||||
CustomSortOrder = "{}"
|
||||
};
|
||||
await overrideRepository.AddAsync(config, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.OverrideEnabled = true;
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关闭覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<bool> DisableOverrideAsync(long tenantId, string systemGroupCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.OverrideEnabled = false;
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新隐藏系统字典项。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto> UpdateHiddenItemsAsync(
|
||||
long tenantId,
|
||||
string systemGroupCode,
|
||||
long[] hiddenIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
if (!systemGroup.AllowOverride)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
|
||||
}
|
||||
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken);
|
||||
var validIds = systemItems.Select(item => item.Id).ToHashSet();
|
||||
var normalized = hiddenIds?.Distinct().ToArray() ?? Array.Empty<long>();
|
||||
|
||||
if (normalized.Any(id => !validIds.Contains(id)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "隐藏项包含无效的系统字典项");
|
||||
}
|
||||
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
config = new TenantDictionaryOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SystemDictionaryGroupId = systemGroup.Id,
|
||||
OverrideEnabled = true,
|
||||
HiddenSystemItemIds = normalized,
|
||||
CustomSortOrder = "{}"
|
||||
};
|
||||
await overrideRepository.AddAsync(config, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.HiddenSystemItemIds = normalized;
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新自定义排序配置。
|
||||
/// </summary>
|
||||
public async Task<OverrideConfigDto> UpdateCustomSortOrderAsync(
|
||||
long tenantId,
|
||||
string systemGroupCode,
|
||||
Dictionary<long, int> sortOrderMap,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (tenantId == 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "租户上下文缺失");
|
||||
}
|
||||
|
||||
var systemGroup = await RequireSystemGroupAsync(systemGroupCode, cancellationToken);
|
||||
if (!systemGroup.AllowOverride)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "该系统字典不允许租户覆盖");
|
||||
}
|
||||
|
||||
var validIds = await CollectValidItemIdsAsync(tenantId, systemGroup, cancellationToken);
|
||||
if (sortOrderMap.Keys.Any(id => !validIds.Contains(id)))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "排序配置包含无效的字典项");
|
||||
}
|
||||
|
||||
var config = await overrideRepository.GetAsync(tenantId, systemGroup.Id, cancellationToken);
|
||||
if (config == null)
|
||||
{
|
||||
config = new TenantDictionaryOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SystemDictionaryGroupId = systemGroup.Id,
|
||||
OverrideEnabled = true,
|
||||
HiddenSystemItemIds = Array.Empty<long>(),
|
||||
CustomSortOrder = SerializeSortOrder(sortOrderMap)
|
||||
};
|
||||
await overrideRepository.AddAsync(config, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.CustomSortOrder = SerializeSortOrder(sortOrderMap);
|
||||
await overrideRepository.UpdateAsync(config, cancellationToken);
|
||||
}
|
||||
|
||||
await overrideRepository.SaveChangesAsync(cancellationToken);
|
||||
await cache.InvalidateAsync(
|
||||
DictionaryCacheKeys.BuildDictionaryKey(tenantId, systemGroup.Code),
|
||||
CacheInvalidationOperation.Update,
|
||||
cancellationToken);
|
||||
|
||||
return MapOverrideDto(config, systemGroup.Code);
|
||||
}
|
||||
|
||||
private async Task<HashSet<long>> CollectValidItemIdsAsync(long tenantId, DictionaryGroup systemGroup, CancellationToken cancellationToken)
|
||||
{
|
||||
var validIds = (await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, cancellationToken))
|
||||
.Select(item => item.Id)
|
||||
.ToHashSet();
|
||||
|
||||
var tenantGroup = await groupRepository.GetByCodeAsync(tenantId, systemGroup.Code, cancellationToken);
|
||||
if (tenantGroup != null)
|
||||
{
|
||||
foreach (var item in await itemRepository.GetByGroupIdAsync(tenantId, tenantGroup.Id, cancellationToken))
|
||||
{
|
||||
validIds.Add(item.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return validIds;
|
||||
}
|
||||
|
||||
private async Task<DictionaryGroup> RequireSystemGroupAsync(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalized = new DictionaryCode(code);
|
||||
var group = await groupRepository.GetByCodeAsync(0, normalized, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "系统字典分组不存在");
|
||||
}
|
||||
|
||||
if (group.Scope != DictionaryScope.System)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "仅支持系统字典覆盖配置");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static OverrideConfigDto MapOverrideDto(TenantDictionaryOverride config, DictionaryCode systemCode)
|
||||
{
|
||||
return new OverrideConfigDto
|
||||
{
|
||||
TenantId = config.TenantId,
|
||||
SystemDictionaryGroupCode = systemCode.Value,
|
||||
OverrideEnabled = config.OverrideEnabled,
|
||||
HiddenSystemItemIds = config.HiddenSystemItemIds,
|
||||
CustomSortOrder = ParseSortOrder(config.CustomSortOrder)
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<long, int> ParseSortOrder(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<long, int>>(json, JsonOptions) ?? new Dictionary<long, int>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<long, int>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeSortOrder(Dictionary<long, int> map)
|
||||
=> JsonSerializer.Serialize(map ?? new Dictionary<long, int>(), JsonOptions);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using TakeoutSaaS.Application.Dictionary.Abstractions;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
using TakeoutSaaS.Application.Dictionary.Models;
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典查询服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryQueryService(
|
||||
IDictionaryGroupRepository groupRepository,
|
||||
IDictionaryItemRepository itemRepository,
|
||||
DictionaryMergeService mergeService,
|
||||
IDictionaryHybridCache cache,
|
||||
ITenantProvider tenantProvider)
|
||||
{
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// 获取字典分组分页数据。
|
||||
/// </summary>
|
||||
public async Task<PagedResult<DictionaryGroupDto>> GetGroupsAsync(
|
||||
DictionaryGroupQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var scope = query.Scope ?? (tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business);
|
||||
var sortDescending = string.Equals(query.SortOrder, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
var targetTenant = scope == DictionaryScope.System ? 0 : tenantId;
|
||||
|
||||
var cacheKey = DictionaryCacheKeys.BuildGroupKey(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
query.SortBy,
|
||||
sortDescending);
|
||||
|
||||
var cached = await cache.GetOrCreateAsync<DictionaryGroupPage>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var groups = await groupRepository.GetPagedAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
query.SortBy,
|
||||
sortDescending,
|
||||
token);
|
||||
|
||||
var total = await groupRepository.CountAsync(
|
||||
targetTenant,
|
||||
scope,
|
||||
query.Keyword,
|
||||
query.IsEnabled,
|
||||
token);
|
||||
|
||||
var items = new List<DictionaryGroupDto>(groups.Count);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
IReadOnlyList<DictionaryItemDto>? groupItems = null;
|
||||
if (query.IncludeItems)
|
||||
{
|
||||
var groupItemEntities = await itemRepository.GetByGroupIdAsync(group.TenantId, group.Id, token);
|
||||
groupItems = groupItemEntities
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
items.Add(DictionaryMapper.ToGroupDto(group, groupItems));
|
||||
}
|
||||
|
||||
return new DictionaryGroupPage
|
||||
{
|
||||
Items = items,
|
||||
Page = query.Page,
|
||||
PageSize = query.PageSize,
|
||||
TotalCount = total
|
||||
};
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var page = cached ?? new DictionaryGroupPage
|
||||
{
|
||||
Items = Array.Empty<DictionaryGroupDto>(),
|
||||
Page = query.Page,
|
||||
PageSize = query.PageSize,
|
||||
TotalCount = 0
|
||||
};
|
||||
|
||||
return new PagedResult<DictionaryGroupDto>(page.Items, page.Page, page.PageSize, page.TotalCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取字典分组详情。
|
||||
/// </summary>
|
||||
public async Task<DictionaryGroupDto?> GetGroupByIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
|
||||
if (group == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
EnsureGroupReadable(group);
|
||||
return DictionaryMapper.ToGroupDto(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取分组下字典项列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = DictionaryCacheKeys.BuildItemKey(groupId);
|
||||
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var group = await groupRepository.GetByIdAsync(groupId, token);
|
||||
if (group == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
|
||||
}
|
||||
|
||||
EnsureGroupReadable(group);
|
||||
var items = await itemRepository.GetByGroupIdAsync(group.TenantId, groupId, token);
|
||||
return items
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return cached ?? Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取合并后的字典项列表。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryItemDto>> GetMergedDictionaryAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!DictionaryCode.IsValid(code))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.ValidationFailed, "字典编码格式不正确");
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var normalized = new DictionaryCode(code);
|
||||
var cacheKey = DictionaryCacheKeys.BuildDictionaryKey(tenantId, normalized);
|
||||
|
||||
var cached = await cache.GetOrCreateAsync<IReadOnlyList<DictionaryItemDto>>(
|
||||
cacheKey,
|
||||
CacheTtl,
|
||||
async token =>
|
||||
{
|
||||
var systemGroup = await groupRepository.GetByCodeAsync(0, normalized, token);
|
||||
if (systemGroup == null || !systemGroup.IsEnabled)
|
||||
{
|
||||
return Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
if (tenantId == 0)
|
||||
{
|
||||
var systemItems = await itemRepository.GetByGroupIdAsync(0, systemGroup.Id, token);
|
||||
return systemItems
|
||||
.Where(item => item.IsEnabled)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Select(DictionaryMapper.ToItemDto)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return await mergeService.MergeItemsAsync(tenantId, systemGroup.Id, token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return cached ?? Array.Empty<DictionaryItemDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典项。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> BatchGetDictionariesAsync(
|
||||
IEnumerable<string> codes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCodes = codes
|
||||
.Where(DictionaryCode.IsValid)
|
||||
.Select(code => new DictionaryCode(code).Value)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var result = new Dictionary<string, IReadOnlyList<DictionaryItemDto>>(StringComparer.OrdinalIgnoreCase);
|
||||
if (normalizedCodes.Length == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var tasks = normalizedCodes.Select(async code =>
|
||||
{
|
||||
var items = await GetMergedDictionaryAsync(code, cancellationToken);
|
||||
return (code, items);
|
||||
});
|
||||
|
||||
foreach (var pair in await Task.WhenAll(tasks))
|
||||
{
|
||||
result[pair.code] = pair.items;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void EnsureGroupReadable(DictionaryGroup group)
|
||||
{
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
if (tenantId != 0 && group.Scope == DictionaryScope.Business && group.TenantId != tenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "无权访问其他租户字典");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DictionaryGroupPage
|
||||
{
|
||||
public IReadOnlyList<DictionaryGroupDto> Items { get; init; } = Array.Empty<DictionaryGroupDto>();
|
||||
public int Page { get; init; }
|
||||
public int PageSize { get; init; }
|
||||
public int TotalCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Domain.Dictionary.ValueObjects;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 字典值序列化与反序列化辅助。
|
||||
/// </summary>
|
||||
internal static class DictionaryValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// 将多语言字典序列化为 JSON。
|
||||
/// </summary>
|
||||
public static string Serialize(Dictionary<string, string> values)
|
||||
{
|
||||
var i18n = new I18nValue(values);
|
||||
return i18n.ToJson();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 JSON 解析为多语言字典。
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> Deserialize(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return I18nValue.FromJson(json).ToDictionary();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["zh-CN"] = json.Trim()
|
||||
};
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典分组请求验证器。
|
||||
/// </summary>
|
||||
public sealed class CreateDictionaryGroupValidator : AbstractValidator<CreateDictionaryGroupRequest>
|
||||
{
|
||||
public CreateDictionaryGroupValidator()
|
||||
{
|
||||
RuleFor(x => x.Code)
|
||||
.NotEmpty()
|
||||
.Length(2, 64)
|
||||
.Matches("^[a-zA-Z0-9_]+$");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128);
|
||||
|
||||
RuleFor(x => x.Scope)
|
||||
.IsInEnum();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 多语言值校验器。
|
||||
/// </summary>
|
||||
public sealed class I18nValueValidator : AbstractValidator<Dictionary<string, string>>
|
||||
{
|
||||
public I18nValueValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.NotNull()
|
||||
.Must(HasAtLeastOneValue)
|
||||
.WithMessage("至少需要提供一种语言的值。");
|
||||
|
||||
RuleForEach(x => x.Keys)
|
||||
.NotEmpty()
|
||||
.Matches("^[a-zA-Z]{2,5}(-[a-zA-Z]{2,5})?$")
|
||||
.WithMessage("语言代码格式不正确。");
|
||||
}
|
||||
|
||||
private static bool HasAtLeastOneValue(Dictionary<string, string>? values)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return values.Any(pair => !string.IsNullOrWhiteSpace(pair.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Dictionary.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典项请求验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateDictionaryItemValidator : AbstractValidator<UpdateDictionaryItemRequest>
|
||||
{
|
||||
public UpdateDictionaryItemValidator()
|
||||
{
|
||||
RuleFor(x => x.Key)
|
||||
.NotEmpty()
|
||||
.MaximumLength(128);
|
||||
|
||||
RuleFor(x => x.Value)
|
||||
.SetValidator(new I18nValueValidator());
|
||||
|
||||
RuleFor(x => x.SortOrder)
|
||||
.GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user