feat: 国家地区支持增删改与导入,列表返回创建和更新时间
All checks were successful
Build and Deploy / deploy (push) Successful in 1m0s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-16 15:54:22 +08:00
parent 5afd204d36
commit 8f64bbf02d
9 changed files with 578 additions and 8 deletions

View File

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Http;
namespace Oms.Application.Dtos.System;
/// <summary>
/// 国家/地区 Excel 导入入参。
/// </summary>
public class CountryImportInput
{
/// <summary>
/// 导入模式:incremental=按二字码新增或更新;full=按二字码新增或更新,并软删除文件中未出现的国家。
/// </summary>
public string ImportMode { get; set; } = ImportModes.Incremental;
/// <summary>
/// Excel 文件。
/// </summary>
public IFormFile File { get; set; } = null!;
public static class ImportModes
{
public const string Incremental = "incremental";
public const string Full = "full";
}
}

View File

@ -0,0 +1,48 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 国家/地区 Excel 导入结果。
/// </summary>
public class CountryImportOutput
{
/// <summary>
/// 成功写入条数。
/// </summary>
public int SuccessCount { get; set; }
/// <summary>
/// 跳过条数。
/// </summary>
public int SkipCount { get; set; }
/// <summary>
/// 失败条数。
/// </summary>
public int FailCount { get; set; }
/// <summary>
/// 全量导入时软删除的条数。
/// </summary>
public int DeletedCount { get; set; }
/// <summary>
/// 失败行明细。
/// </summary>
public List<CountryImportFailRow> Failures { get; set; } = [];
}
/// <summary>
/// 导入失败行。
/// </summary>
public class CountryImportFailRow
{
/// <summary>
/// Excel 行号(含表头,从 2 起为数据行)。
/// </summary>
public int RowNumber { get; set; }
/// <summary>
/// 失败原因。
/// </summary>
public string Reason { get; set; } = string.Empty;
}

View File

@ -24,4 +24,14 @@ public class CountryOutput
/// ISO 3166-1 alpha-2 国家二字码。
/// </summary>
public string CountryCode { get; set; } = string.Empty;
/// <summary>
/// 创建时间。
/// </summary>
public DateTime CreateTime { get; set; }
/// <summary>
/// 更新时间;未更新时为空。
/// </summary>
public DateTime? UpdateTime { get; set; }
}

View File

@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.System;
/// <summary>
/// 新增国家/地区入参。
/// </summary>
public class CreateCountryInput
{
/// <summary>
/// 国家中文名。
/// </summary>
[Required]
public string ChineseName { get; set; } = string.Empty;
/// <summary>
/// 国家英文名。
/// </summary>
[Required]
public string EnglishName { get; set; } = string.Empty;
/// <summary>
/// ISO 3166-1 alpha-2 国家二字码。
/// </summary>
[Required]
public string CountryCode { get; set; } = string.Empty;
}

View File

@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.System;
/// <summary>
/// 更新国家/地区入参。
/// </summary>
public class UpdateCountryInput
{
/// <summary>
/// 主键。
/// </summary>
[Required]
public int Id { get; set; }
/// <summary>
/// 国家中文名。
/// </summary>
[Required]
public string ChineseName { get; set; } = string.Empty;
/// <summary>
/// 国家英文名。
/// </summary>
[Required]
public string EnglishName { get; set; } = string.Empty;
/// <summary>
/// ISO 3166-1 alpha-2 国家二字码。
/// </summary>
[Required]
public string CountryCode { get; set; } = string.Empty;
}

View File

@ -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
/// <summary>
/// 查询国家列表。
/// </summary>
/// <returns>国家列表,返回全部有效国家主数据。</returns>
/// <returns>国家列表,返回全部有效国家主数据,按二字码、英文名排序。</returns>
[HttpGet("/system/country/list")]
public async Task<List<CountryOutput>> GetListAsync()
{
return (await CountryRepository.GetListAsync())
.OrderBy(x => x.CountryCode)
.ThenBy(x => x.EnglishName)
.Select(x => new CountryOutput
.Select(ToOutput)
.ToList();
}
/// <summary>
/// 新增国家/地区。二字码已存在时失败;若仅存在同码软删除记录则恢复该记录。
/// </summary>
/// <param name="input">中文名、英文名、二字码。</param>
/// <returns>无返回体,仅完成新增。</returns>
[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);
}
/// <summary>
/// 更新国家/地区。二字码与其他有效记录冲突时失败。
/// </summary>
/// <param name="input">主键及中文名、英文名、二字码。</param>
/// <returns>无返回体,仅完成更新。</returns>
[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);
}
/// <summary>
/// 删除国家/地区(软删除)。
/// </summary>
/// <param name="input">目标主键。</param>
/// <returns>无返回体,仅完成删除。</returns>
[HttpPost("/system/country/delete")]
public async Task DeleteAsync(IdInput input)
{
var entity = await GetOrThrowAsync(input.Id);
await CountryRepository.DeleteAsync(entity);
}
/// <summary>
/// 下载国家/地区导入模板。
/// </summary>
/// <returns>xlsx 文件,列为国家二字码、中文名、英文名。</returns>
[HttpGet("/system/country/import-template")]
public async Task<FileContentResult> DownloadImportTemplateAsync()
{
var bytes = await CountryImportHelper.BuildTemplateBytesAsync();
return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = CountryImportHelper.TemplateFileName
};
}
/// <summary>
/// 导入国家/地区(Excel)。增量按二字码新增或更新;全量还会软删除文件中未出现的国家。
/// </summary>
/// <param name="input">Excel 文件与导入模式。</param>
/// <returns>成功、跳过、失败及全量删除条数。</returns>
[HttpPost("/system/country/import")]
public async Task<CountryImportOutput> 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<string>(StringComparer.OrdinalIgnoreCase);
var seenCodes = new HashSet<string>(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<Country> 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
})
.ToList();
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;
}
}

View File

@ -0,0 +1,179 @@
using System.Text.RegularExpressions;
using MiniExcelLibs;
namespace Oms.Application.Shared;
/// <summary>
/// 国家/地区 Excel 模板与导入解析。
/// </summary>
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<string, string> 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<byte[]> BuildTemplateBytesAsync()
{
var templateRows = new List<Dictionary<string, object?>>
{
new()
{
["国家二字码"] = "CN",
["中文名"] = "中国",
["英文名"] = "China"
},
new()
{
["国家二字码"] = "US",
["中文名"] = "美国",
["英文名"] = "United States"
}
};
var sheets = new Dictionary<string, object>
{
[TemplateSheetName] = templateRows
};
await using var stream = new MemoryStream();
await MiniExcel.SaveAsAsync(stream, sheets);
return stream.ToArray();
}
public static async Task<List<(int RowNumber, CountryImportRow Row)>> 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<string, object> 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<string, object> rowDict)
{
var mapped = new Dictionary<string, string?>(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<string, object> rowDict) =>
rowDict.Values.All(value => string.IsNullOrWhiteSpace(NormalizeCell(value)));
}
/// <summary>
/// 国家导入行。
/// </summary>
public class CountryImportRow
{
public string CountryCode { get; set; } = string.Empty;
public string ChineseName { get; set; } = string.Empty;
public string EnglishName { get; set; } = string.Empty;
}

View File

@ -6,4 +6,9 @@ namespace Oms.Domain.SystemAggregate.Repositories;
public interface ICountryRepository : IRepository<Country>
{
Task<Country?> GetByCountryCodeAsync(string countryCode, CancellationToken cancellationToken = default);
/// <summary>
/// 按二字码查询,包含已软删除记录,用于恢复同码数据。
/// </summary>
Task<Country?> GetByCountryCodeIncludingDeletedAsync(string countryCode, CancellationToken cancellationToken = default);
}

View File

@ -11,4 +11,15 @@ public class CountryRepository : OmsSqlSugarRepository<Country>, ICountryReposit
{
return FirstOrDefaultAsync(x => x.CountryCode == countryCode, cancellationToken);
}
public async Task<Country?> GetByCountryCodeIncludingDeletedAsync(
string countryCode,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
return await SoftDeleteQuery.FirstIncludingDeletedAsync<Country>(
db,
x => x.CountryCode == countryCode,
cancellationToken);
}
}