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);
+ }
}