feat: 实现字典管理后端
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user