Files
TakeoutSaaS.AdminApi/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs
2026-02-04 10:46:32 +08:00

419 lines
14 KiB
C#

using System.Diagnostics;
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 Microsoft.Extensions.Logging;
namespace TakeoutSaaS.Application.Dictionary.Services;
/// <summary>
/// 字典导入导出服务。
/// </summary>
public sealed class DictionaryImportExportService(
ICsvDictionaryParser csvParser,
IJsonDictionaryParser jsonParser,
IDictionaryGroupRepository groupRepository,
IDictionaryItemRepository itemRepository,
IDictionaryImportLogRepository importLogRepository,
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);
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);
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);
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
};
await itemRepository.AddAsync(item, cancellationToken);
existingMap[item.Key] = item;
successCount++;
}
await itemRepository.SaveChangesAsync(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 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<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
{
var group = await groupRepository.GetByIdAsync(groupId, cancellationToken);
if (group == null)
{
throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在");
}
return 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; }
}
}