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; /// /// 字典导入导出服务。 /// public sealed class DictionaryImportExportService( ICsvDictionaryParser csvParser, IJsonDictionaryParser jsonParser, IDictionaryGroupRepository groupRepository, IDictionaryItemRepository itemRepository, IDictionaryImportLogRepository importLogRepository, ICurrentUserAccessor currentUser, ILogger logger) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); /// /// 导出 CSV。 /// 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); } /// /// 导出 JSON。 /// 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); } /// /// 导入 CSV。 /// public async Task ImportFromCsvAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default) { var rows = await csvParser.ParseAsync(request.FileStream, cancellationToken); return await ImportAsync(request, rows, "CSV", cancellationToken); } /// /// 导入 JSON。 /// public async Task ImportFromJsonAsync(DictionaryImportRequest request, CancellationToken cancellationToken = default) { var rows = await jsonParser.ParseAsync(request.FileStream, cancellationToken); return await ImportAsync(request, rows, "JSON", cancellationToken); } private async Task ImportAsync( DictionaryImportRequest request, IReadOnlyList rows, string format, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); var group = await RequireGroupAsync(request.GroupId, cancellationToken); var errors = new List(); var validRows = new List(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(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> 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 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 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 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 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 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 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; } } }