From 8f64bbf02da98c57ade7d08e81ece8b3619bc474 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 15:54:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BD=E5=AE=B6=E5=9C=B0=E5=8C=BA?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A2=9E=E5=88=A0=E6=94=B9=E4=B8=8E=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=EF=BC=8C=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=92=8C=E6=9B=B4=E6=96=B0=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/System/CountryImportInput.cs | 25 ++ .../Dtos/System/CountryImportOutput.cs | 48 ++++ .../Dtos/System/CountryOutput.cs | 10 + .../Dtos/System/CreateCountryInput.cs | 27 ++ .../Dtos/System/UpdateCountryInput.cs | 33 +++ .../Services/System/CountryService.cs | 248 +++++++++++++++++- .../Shared/CountryImportHelper.cs | 179 +++++++++++++ .../Repositories/ICountryRepository.cs | 5 + .../System/CountryRepository.cs | 11 + 9 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 src/Oms.Application/Dtos/System/CountryImportInput.cs create mode 100644 src/Oms.Application/Dtos/System/CountryImportOutput.cs create mode 100644 src/Oms.Application/Dtos/System/CreateCountryInput.cs create mode 100644 src/Oms.Application/Dtos/System/UpdateCountryInput.cs create mode 100644 src/Oms.Application/Shared/CountryImportHelper.cs diff --git a/src/Oms.Application/Dtos/System/CountryImportInput.cs b/src/Oms.Application/Dtos/System/CountryImportInput.cs new file mode 100644 index 0000000..5340a37 --- /dev/null +++ b/src/Oms.Application/Dtos/System/CountryImportInput.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace Oms.Application.Dtos.System; + +/// +/// 国家/地区 Excel 导入入参。 +/// +public class CountryImportInput +{ + /// + /// 导入模式:incremental=按二字码新增或更新;full=按二字码新增或更新,并软删除文件中未出现的国家。 + /// + public string ImportMode { get; set; } = ImportModes.Incremental; + + /// + /// Excel 文件。 + /// + public IFormFile File { get; set; } = null!; + + public static class ImportModes + { + public const string Incremental = "incremental"; + public const string Full = "full"; + } +} diff --git a/src/Oms.Application/Dtos/System/CountryImportOutput.cs b/src/Oms.Application/Dtos/System/CountryImportOutput.cs new file mode 100644 index 0000000..6c7ffee --- /dev/null +++ b/src/Oms.Application/Dtos/System/CountryImportOutput.cs @@ -0,0 +1,48 @@ +namespace Oms.Application.Dtos.System; + +/// +/// 国家/地区 Excel 导入结果。 +/// +public class CountryImportOutput +{ + /// + /// 成功写入条数。 + /// + public int SuccessCount { get; set; } + + /// + /// 跳过条数。 + /// + public int SkipCount { get; set; } + + /// + /// 失败条数。 + /// + public int FailCount { get; set; } + + /// + /// 全量导入时软删除的条数。 + /// + public int DeletedCount { get; set; } + + /// + /// 失败行明细。 + /// + public List Failures { get; set; } = []; +} + +/// +/// 导入失败行。 +/// +public class CountryImportFailRow +{ + /// + /// Excel 行号(含表头,从 2 起为数据行)。 + /// + public int RowNumber { get; set; } + + /// + /// 失败原因。 + /// + public string Reason { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/CountryOutput.cs b/src/Oms.Application/Dtos/System/CountryOutput.cs index 9d375a4..0129f30 100644 --- a/src/Oms.Application/Dtos/System/CountryOutput.cs +++ b/src/Oms.Application/Dtos/System/CountryOutput.cs @@ -24,4 +24,14 @@ public class CountryOutput /// ISO 3166-1 alpha-2 国家二字码。 /// public string CountryCode { get; set; } = string.Empty; + + /// + /// 创建时间。 + /// + public DateTime CreateTime { get; set; } + + /// + /// 更新时间;未更新时为空。 + /// + public DateTime? UpdateTime { get; set; } } diff --git a/src/Oms.Application/Dtos/System/CreateCountryInput.cs b/src/Oms.Application/Dtos/System/CreateCountryInput.cs new file mode 100644 index 0000000..1c7d1db --- /dev/null +++ b/src/Oms.Application/Dtos/System/CreateCountryInput.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 新增国家/地区入参。 +/// +public class CreateCountryInput +{ + /// + /// 国家中文名。 + /// + [Required] + public string ChineseName { get; set; } = string.Empty; + + /// + /// 国家英文名。 + /// + [Required] + public string EnglishName { get; set; } = string.Empty; + + /// + /// ISO 3166-1 alpha-2 国家二字码。 + /// + [Required] + public string CountryCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/System/UpdateCountryInput.cs b/src/Oms.Application/Dtos/System/UpdateCountryInput.cs new file mode 100644 index 0000000..9e13236 --- /dev/null +++ b/src/Oms.Application/Dtos/System/UpdateCountryInput.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.System; + +/// +/// 更新国家/地区入参。 +/// +public class UpdateCountryInput +{ + /// + /// 主键。 + /// + [Required] + public int Id { get; set; } + + /// + /// 国家中文名。 + /// + [Required] + public string ChineseName { get; set; } = string.Empty; + + /// + /// 国家英文名。 + /// + [Required] + public string EnglishName { get; set; } = string.Empty; + + /// + /// ISO 3166-1 alpha-2 国家二字码。 + /// + [Required] + public string CountryCode { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/System/CountryService.cs b/src/Oms.Application/Services/System/CountryService.cs index 51c93d6..77fa27b 100644 --- a/src/Oms.Application/Services/System/CountryService.cs +++ b/src/Oms.Application/Services/System/CountryService.cs @@ -1,6 +1,9 @@ using Fake.Application; using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Shared; using Oms.Application.Dtos.System; +using Oms.Application.Shared; +using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Repositories; namespace Oms.Application.Services.System; @@ -16,20 +19,249 @@ public class CountryService : ApplicationService /// /// 查询国家列表。 /// - /// 国家列表,返回全部有效国家主数据。 + /// 国家列表,返回全部有效国家主数据,按二字码、英文名排序。 [HttpGet("/system/country/list")] public async Task> GetListAsync() { return (await CountryRepository.GetListAsync()) .OrderBy(x => x.CountryCode) .ThenBy(x => x.EnglishName) - .Select(x => new CountryOutput - { - Id = x.Id, - ChineseName = x.ChineseName, - EnglishName = x.EnglishName, - CountryCode = x.CountryCode - }) + .Select(ToOutput) .ToList(); } + + /// + /// 新增国家/地区。二字码已存在时失败;若仅存在同码软删除记录则恢复该记录。 + /// + /// 中文名、英文名、二字码。 + /// 无返回体,仅完成新增。 + [HttpPost("/system/country/create")] + public async Task CreateAsync(CreateCountryInput input) + { + var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode); + await SaveAsync(row, excludeId: null, updateWhenCodeExists: false); + } + + /// + /// 更新国家/地区。二字码与其他有效记录冲突时失败。 + /// + /// 主键及中文名、英文名、二字码。 + /// 无返回体,仅完成更新。 + [HttpPost("/system/country/update")] + public async Task UpdateAsync(UpdateCountryInput input) + { + var entity = await GetOrThrowAsync(input.Id); + var row = ToImportRow(input.ChineseName, input.EnglishName, input.CountryCode); + await SaveAsync(row, excludeId: entity.Id, updateWhenCodeExists: false); + } + + /// + /// 删除国家/地区(软删除)。 + /// + /// 目标主键。 + /// 无返回体,仅完成删除。 + [HttpPost("/system/country/delete")] + public async Task DeleteAsync(IdInput input) + { + var entity = await GetOrThrowAsync(input.Id); + await CountryRepository.DeleteAsync(entity); + } + + /// + /// 下载国家/地区导入模板。 + /// + /// xlsx 文件,列为国家二字码、中文名、英文名。 + [HttpGet("/system/country/import-template")] + public async Task DownloadImportTemplateAsync() + { + var bytes = await CountryImportHelper.BuildTemplateBytesAsync(); + return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = CountryImportHelper.TemplateFileName + }; + } + + /// + /// 导入国家/地区(Excel)。增量按二字码新增或更新;全量还会软删除文件中未出现的国家。 + /// + /// Excel 文件与导入模式。 + /// 成功、跳过、失败及全量删除条数。 + [HttpPost("/system/country/import")] + public async Task ImportAsync([FromForm] CountryImportInput input) + { + if (input.File == null || input.File.Length == 0) + { + throw new BusinessException("请上传 Excel 文件"); + } + + var importMode = NormalizeImportMode(input.ImportMode); + await using var stream = input.File.OpenReadStream(); + var parsedRows = await CountryImportHelper.ParseImportRowsAsync(stream); + if (parsedRows.Count == 0) + { + throw new BusinessException("Excel 中无有效数据行"); + } + + var output = new CountryImportOutput(); + var touchedCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + var seenCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (rowNumber, row) in parsedRows) + { + var rawCode = row.CountryCode?.Trim(); + if (!string.IsNullOrWhiteSpace(rawCode)) + { + touchedCodes.Add(rawCode.ToUpperInvariant()); + } + + try + { + CountryImportHelper.Normalize(row); + var code = row.CountryCode ?? string.Empty; + if (!seenCodes.Add(code)) + { + output.SkipCount++; + output.Failures.Add(new CountryImportFailRow + { + RowNumber = rowNumber, + Reason = "文件内二字码重复,已跳过" + }); + continue; + } + + await SaveAsync(row, excludeId: null, updateWhenCodeExists: true); + output.SuccessCount++; + } + catch (Exception ex) + { + output.FailCount++; + output.Failures.Add(new CountryImportFailRow + { + RowNumber = rowNumber, + Reason = ex.Message + }); + } + } + + if (importMode == CountryImportInput.ImportModes.Full && output.SuccessCount > 0) + { + var existing = await CountryRepository.GetListAsync(); + foreach (var entity in existing) + { + var code = entity.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty; + if (touchedCodes.Contains(code)) + { + continue; + } + + await CountryRepository.DeleteAsync(entity); + output.DeletedCount++; + } + } + + return output; + } + + private async Task SaveAsync(CountryImportRow row, int? excludeId, bool updateWhenCodeExists) + { + try + { + CountryImportHelper.Normalize(row); + } + catch (InvalidOperationException ex) + { + throw new BusinessException(ex.Message); + } + + var active = await CountryRepository.GetByCountryCodeAsync(row.CountryCode); + if (active != null && active.Id != excludeId) + { + if (updateWhenCodeExists) + { + ApplyNames(active, row); + await CountryRepository.UpdateAsync(active); + return; + } + + throw new BusinessException("国家二字码已存在"); + } + + var deleted = await CountryRepository.GetByCountryCodeIncludingDeletedAsync(row.CountryCode); + if (deleted != null && deleted.IsDeleted && deleted.Id != excludeId) + { + if (excludeId != null) + { + throw new BusinessException("国家二字码已被删除记录占用,请新增同码以恢复"); + } + + deleted.IsDeleted = false; + deleted.CountryCode = row.CountryCode; + ApplyNames(deleted, row); + await CountryRepository.UpdateAsync(deleted); + return; + } + + if (excludeId != null) + { + var entity = await GetOrThrowAsync(excludeId.Value); + entity.CountryCode = row.CountryCode; + ApplyNames(entity, row); + await CountryRepository.UpdateAsync(entity); + return; + } + + await CountryRepository.InsertAsync(new Country + { + CountryCode = row.CountryCode, + ChineseName = row.ChineseName, + EnglishName = row.EnglishName + }); + } + + private static void ApplyNames(Country entity, CountryImportRow row) + { + entity.ChineseName = row.ChineseName; + entity.EnglishName = row.EnglishName; + } + + private static CountryImportRow ToImportRow(string chineseName, string englishName, string countryCode) => + new() + { + ChineseName = chineseName, + EnglishName = englishName, + CountryCode = countryCode + }; + + private async Task GetOrThrowAsync(int id) + { + var entity = await CountryRepository.FirstOrDefaultAsync(x => x.Id == id); + if (entity == null) + { + throw new BusinessException("国家不存在"); + } + + return entity; + } + + private static CountryOutput ToOutput(Country x) => new() + { + Id = x.Id, + ChineseName = x.ChineseName, + EnglishName = x.EnglishName, + CountryCode = x.CountryCode, + CreateTime = x.CreateTime, + UpdateTime = x.UpdateTime == default ? null : x.UpdateTime + }; + + private static string NormalizeImportMode(string? mode) + { + if (string.Equals(mode, CountryImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase)) + { + return CountryImportInput.ImportModes.Full; + } + + return CountryImportInput.ImportModes.Incremental; + } } diff --git a/src/Oms.Application/Shared/CountryImportHelper.cs b/src/Oms.Application/Shared/CountryImportHelper.cs new file mode 100644 index 0000000..240f913 --- /dev/null +++ b/src/Oms.Application/Shared/CountryImportHelper.cs @@ -0,0 +1,179 @@ +using System.Text.RegularExpressions; +using MiniExcelLibs; + +namespace Oms.Application.Shared; + +/// +/// 国家/地区 Excel 模板与导入解析。 +/// +public static class CountryImportHelper +{ + public const string TemplateSheetName = "导入模板"; + public const string TemplateFileName = "国家地区导入模板.xlsx"; + + private static readonly Regex CountryCodePattern = new("^[A-Za-z]{2}$", RegexOptions.Compiled); + + private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["国家二字码"] = nameof(CountryImportRow.CountryCode), + ["二字码"] = nameof(CountryImportRow.CountryCode), + ["国家代码"] = nameof(CountryImportRow.CountryCode), + ["CountryCode"] = nameof(CountryImportRow.CountryCode), + ["countryCode"] = nameof(CountryImportRow.CountryCode), + ["中文名"] = nameof(CountryImportRow.ChineseName), + ["国家中文名"] = nameof(CountryImportRow.ChineseName), + ["ChineseName"] = nameof(CountryImportRow.ChineseName), + ["chineseName"] = nameof(CountryImportRow.ChineseName), + ["英文名"] = nameof(CountryImportRow.EnglishName), + ["国家英文名"] = nameof(CountryImportRow.EnglishName), + ["EnglishName"] = nameof(CountryImportRow.EnglishName), + ["englishName"] = nameof(CountryImportRow.EnglishName), + }; + + public static async Task BuildTemplateBytesAsync() + { + var templateRows = new List> + { + new() + { + ["国家二字码"] = "CN", + ["中文名"] = "中国", + ["英文名"] = "China" + }, + new() + { + ["国家二字码"] = "US", + ["中文名"] = "美国", + ["英文名"] = "United States" + } + }; + + var sheets = new Dictionary + { + [TemplateSheetName] = templateRows + }; + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, sheets); + return stream.ToArray(); + } + + public static async Task> ParseImportRowsAsync(Stream fileStream) + { + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var sheetNames = MiniExcel.GetSheetNames(fileStream); + var sheetName = sheetNames.FirstOrDefault(x => + string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase)) + ?? sheetNames.FirstOrDefault(); + + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName); + var result = new List<(int RowNumber, CountryImportRow Row)>(); + var rowNumber = 1; + + foreach (var row in rows) + { + rowNumber++; + if (row is not IDictionary rowDict || IsEmptyRow(rowDict)) + { + continue; + } + + result.Add((rowNumber, ParseRow(rowDict))); + } + + return result; + } + + public static void Normalize(CountryImportRow row) + { + row.CountryCode = row.CountryCode?.Trim().ToUpperInvariant() ?? string.Empty; + row.ChineseName = row.ChineseName?.Trim() ?? string.Empty; + row.EnglishName = row.EnglishName?.Trim() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(row.CountryCode)) + { + throw new InvalidOperationException("国家二字码不能为空"); + } + + if (!CountryCodePattern.IsMatch(row.CountryCode)) + { + throw new InvalidOperationException("国家二字码须为 2 位字母,如 CN、US"); + } + + if (string.IsNullOrWhiteSpace(row.ChineseName)) + { + throw new InvalidOperationException("中文名不能为空"); + } + + if (row.ChineseName.Length > 128) + { + throw new InvalidOperationException("中文名不可超过 128 个字符"); + } + + if (string.IsNullOrWhiteSpace(row.EnglishName)) + { + throw new InvalidOperationException("英文名不能为空"); + } + + if (row.EnglishName.Length > 128) + { + throw new InvalidOperationException("英文名不可超过 128 个字符"); + } + } + + private static CountryImportRow ParseRow(IDictionary rowDict) + { + var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (header, value) in rowDict) + { + if (string.IsNullOrWhiteSpace(header) || !HeaderAliases.TryGetValue(header.Trim(), out var field)) + { + continue; + } + + mapped[field] = NormalizeCell(value); + } + + return new CountryImportRow + { + CountryCode = mapped.GetValueOrDefault(nameof(CountryImportRow.CountryCode)) ?? string.Empty, + ChineseName = mapped.GetValueOrDefault(nameof(CountryImportRow.ChineseName)) ?? string.Empty, + EnglishName = mapped.GetValueOrDefault(nameof(CountryImportRow.EnglishName)) ?? string.Empty + }; + } + + private static string? NormalizeCell(object? value) + { + if (value is null) + { + return null; + } + + var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static bool IsEmptyRow(IDictionary rowDict) => + rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value))); +} + +/// +/// 国家导入行。 +/// +public class CountryImportRow +{ + public string CountryCode { get; set; } = string.Empty; + + public string ChineseName { get; set; } = string.Empty; + + public string EnglishName { get; set; } = string.Empty; +} diff --git a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs index 3ca1add..8f5367e 100644 --- a/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs +++ b/src/Oms.Domain/SystemAggregate/Repositories/ICountryRepository.cs @@ -6,4 +6,9 @@ namespace Oms.Domain.SystemAggregate.Repositories; public interface ICountryRepository : IRepository { Task GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default); + + /// + /// 按二字码查询,包含已软删除记录,用于恢复同码数据。 + /// + Task GetByCountryCodeIncludingDeletedAsync(string countryCode, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/System/CountryRepository.cs b/src/Oms.SqlSugarCore/System/CountryRepository.cs index 988c6ac..8a7c2f7 100644 --- a/src/Oms.SqlSugarCore/System/CountryRepository.cs +++ b/src/Oms.SqlSugarCore/System/CountryRepository.cs @@ -11,4 +11,15 @@ public class CountryRepository : OmsSqlSugarRepository, ICountryReposit { return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken); } + + public async Task GetByCountryCodeIncludingDeletedAsync( + string countryCode, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await SoftDeleteQuery.FirstIncludingDeletedAsync( + db, + x => x.CountryCode == countryCode, + cancellationToken); + } }