已发布的《第十篇:电子元件管理实战——Excel 导入导出》讲的是"怎么导入"。这篇往前一步,讲导入这条链路里的四个工程问题:列名怎么映射、数据怎么验证、权限怎么控、事务怎么保证。
一、导入的完整链路
点击"导入"按钮(ShowImportButton)
↓ AuthButton("add") 决定按钮是否显示
ShowImportDialog:DropUpload(.xlsx)
↓ 文件大小 ≤ 5MB
↓ AuthButton("add") 服务端二次校验
↓ MiniExcel:memoryStream.Query<TItem>()
↓ OnBeforeImportAsync(自定义校验)
↓ FilterAuthorizedImportRowsAsync(数据权限过滤)
↓ InsertOrUpdate + UpdateColumns
↓ OnFinishImportAsync(导入后处理)
↓ OperationLog + 重新查询 + Toast 提示
下面按这条链路的顺序展开。
二、模板下载:列从哪来
protected async Task DownloadExcelTemplate()
{
if (OnExportAllDataAsync.HasDelegate)
{
var args = new AdminExportEventArgs<TItem> { Items = new(), IsTemplate = true };
await OnExportAllDataAsync.InvokeAsync(args);
return;
}
await TableExport.ExportExcelAsync(new List<TItem>(), GetExportColumns().Where(x => x.IsVisibleWhenAdd != false), new TableExportOptions { EnableAutoFilter = false }, $"{typeof(TItem).Name}_template.xlsx");
}
两个分支:
- 页面注册了
OnExportAllDataAsync→ 由页面自己产出模板(IsTemplate = true可以据此区分"导出模板"和"导出数据"); - 否则 → 用当前可见列导出空表作为模板。
注意 .Where(x => x.IsVisibleWhenAdd != false):新增时不可见的列(比如"创建时间""创建人")不会出现在模板里,避免用户填了却写不进去。
一个必须实测的点:表头与属性名的匹配
导出侧的列头来自列的显示名:
row[((IEditorItem)col).GetDisplayName() ?? field] = await FormatExportValueAsync(...);
导入侧用的是 MiniExcel 的强类型读取:
var rows = memoryStream.Query<TItem>().ToList();
Query<TItem>() 是按列名匹配实体属性的。如果你的列头是中文显示名(例如 [DisplayName("标题")] Title),而属性名是英文,导入时就会读不到值。
处理方式有两种:
- 让 Excel 的列名与实体属性名一致(把模板表头改成属性名);
- 用
OnImportFromDictionaryAsync拿原始字典,自己做列名映射(下一节)。
这一点建议在接入后先用一份真实 Excel 做一次往返测试。
三、导入解析:三种回调
导入弹窗的主体逻辑:
op.Component = BootstrapDynamicComponent.CreateComponent<DropUpload>(new Dictionary<string, object?>
{
[nameof(DropUpload.ShowProgress)] = true,
[nameof(DropUpload.ShowFooter)] = true,
[nameof(DropUpload.Accept)] = ".xlsx",
[nameof(DropUpload.FooterText)] = ImportMessage,
[nameof(DropUpload.OnChange)] = async Task (UploadFile file) =>
{
if (file == null || file.File == null) return;
// 服务器端验证当文件大于 5MB 时提示文件太大信息
if (file.Size > MaxFileLength)
{
await MessageService.Error(CommonLocalizer["文件大小超过 5MB"]);
file.Code = 1;
file.Error = CommonLocalizer["文件大小超过 5MB"];
return;
}
ReadToken ??= new CancellationTokenSource();
try
{
// 服务端导入校验:与 UI 导入按钮显隐使用同一权限判定,管理员自动放行
if (!await admin.AuthButton("add"))
{
await MessageService.Error(string.Format(CommonLocalizer["没有权限执行该操作"]));
return;
}
using var browserStream = file.File.OpenReadStream(MaxFileLength, ReadToken.Token);
using var memoryStream = new MemoryStream();
await browserStream.CopyToAsync(memoryStream, ReadToken.Token);
memoryStream.Position = 0;
...
}
}
});
文件大小上限来自常量:
static long MaxFileLength => 5 * 1024 * 1024; // 文件最大 5M
三种处理路径:
| 回调 | 输入 | 适用场景 |
|---|---|---|
| 默认 | List<TItem> |
列名与实体属性一致的标准场景 |
OnImportFromDictionaryAsync |
IEnumerable<IDictionary<string, object>> |
列名不固定、需要自定义映射 |
OnImportAsync |
List<TItem> |
完全接管写库逻辑(返回受影响行数) |
字典导入的实现细节:
if (OnImportFromDictionaryAsync.HasDelegate)
{
var dictItems = memoryStream.Query(true).Cast<IDictionary<string, object>>();
var args = new AdminImportDictionaryEventArgs { Items = dictItems };
await OnImportFromDictionaryAsync.InvokeAsync(args);
if (args.Cancel) return;
affectedRows = dictItems.Count();
}
注意 args.Items 是 IEnumerable,页面可以替换它(AdminImportDictionaryEventArgs.Items 有 setter),所以"过滤掉不合格的行"也能在这里做。
四、数据验证:框架做多少,页面做多少
框架在导入路径上只做三件事:
- 文件必须是
.xlsx(DropUpload.Accept); - 文件大小 ≤ 5MB;
- 有
add权限。
实体上的 [Required]、[MaxLength] 等数据注解不会在导入时自动生效——因为导入是直接走 InsertOrUpdate 的,没有经过编辑表单的 EditContext 校验。
所以业务校验要放在 OnBeforeImportAsync:
[Parameter]
public EventCallback<AdminImportEventArgs<TItem>> OnBeforeImportAsync { get; set; }
页面用法示例(伪代码结构,按你项目里的服务写):
private async Task OnBeforeImport(AdminImportEventArgs<Order> e)
{
var errors = new List<string>();
foreach (var (item, index) in e.Items.Select((x, i) => (x, i + 2)))
{
if (string.IsNullOrWhiteSpace(item.OrderNo))
errors.Add($"第 {index} 行:订单号不能为空");
if (item.Amount <= 0)
errors.Add($"第 {index} 行:金额必须大于 0");
}
if (errors.Count > 0)
{
await SwalService.Warning("导入校验失败", string.Join("<br/>", errors.Take(20)));
e.Cancel = true; // 取消整批导入
}
}
AdminImportEventArgs<TItem> 提供了 Items(可整体替换)和 Cancel(取消导入):
public class AdminImportEventArgs<TItem> where TItem : class
{
public List<TItem> Items { get; set; } = new();
public bool Cancel { get; set; }
}
这里有两个选择:
e.Cancel = true→ 整批不导入(适合"有一条错就全部退回");- 直接改
e.Items(剔除错误行)→ 只导入合法行(适合"尽量导入")。
注意"重复数据"的判定也属于业务校验:框架的默认实现是 InsertOrUpdate,Id > 0 更新、Id = 0 新增,不会自动按业务字段去重。要按"订单号"这类业务键去重,需要在 OnBeforeImportAsync 或 OnImportAsync 里自己查。
五、数据权限:最容易被忽略的一条
默认导入路径:
// 数据权限:Excel 导入/更新同样不能操作当前用户无权限的数据。
// Id > 0 的行会走数据库按主键更新,因此必须逐行确认该记录在当前用户的数据权限范围内,
// 不能只依赖前端按钮权限(否则伪造 Id 即可越权更新他人数据)
rows = await FilterAuthorizedImportRowsAsync(rows);
if (rows.Count == 0) return;
affectedRows = await _repo.Orm.InsertOrUpdate<TItem>()
.SetSource(rows)
.UpdateColumns(updateColumns)
.ExecuteAffrowsAsync();
过滤实现:
private async Task<List<TItem>> FilterAuthorizedImportRowsAsync(List<TItem> rows)
{
if (rows.Count == 0 || !(UseDataPermission || _autoDataPermission))
{
return rows;
}
var authorized = await FilterAuthorizedAsync(rows);
if (authorized.Count < rows.Count)
{
var rejected = rows.Count - authorized.Count;
await ToastService.Warning(
CommonLocalizer["导入结果"],
string.Format(CommonLocalizer["已忽略 {0} 条没有权限操作的数据。"], rejected));
}
return authorized;
}
它的判定复用查询路径的规则(FilterAuthorizedAsync → ApplyDataPermission),所以"界面上看不到的数据,导入也改不了"。
测试直接覆盖了这个场景:
/// <summary>
/// Excel 导入/更新路径:Id=100 OrgId=B 的行不能让用户更新他人的记录。
/// </summary>
[Fact]
public async Task ExcelImport_OtherOrgRow_IsDropped()
{
var (mine, theirs) = await SeedAsync();
// 模拟 Excel 中提交的内容:一条自己的 + 一条他人的(伪造 OrgId/Id)
theirs.Name = "被篡改";
var candidates = new List<ScopedRecord> { mine, theirs };
var authorized = await _repo.FilterAuthorizedAsync<ScopedRecord, long>(_admin, candidates, true);
authorized.Should().HaveCount(1);
authorized.Single().Id.Should().Be(mine.Id);
}
如果你用 OnImportAsync 完全接管导入,这段过滤不会执行,需要自己补上等价的权限校验。
六、字段映射与批量写入
更新哪些列由可见列决定:
var updateColumns = GetExportColumns()
.Where(x => x.IsVisibleWhenAdd != false)
.Select(x => x.GetFieldName())
.ToArray();
然后一次性批量写入:
affectedRows = await _repo.Orm.InsertOrUpdate<TItem>()
.SetSource(rows)
.UpdateColumns(updateColumns)
.ExecuteAffrowsAsync();
| 行为 | 说明 |
|---|---|
Id > 0 |
按主键更新,且只更新 updateColumns 里的列 |
Id = 0 |
新增 |
没在 updateColumns 里的列 |
即使 Excel 里填了也不会写入 |
这是一条重要的安全边界:Excel 里多出来的列不会污染数据库。但也意味着"新增时不可见的列"无法通过导入赋值(比如创建人、创建时间)。
新增记录的审计字段由框架的 AuditValue 自动填充(CreatedUserId / CreatedTime / 数据权限的 OrgId),不需要在 Excel 里提供。
七、事务:默认实现与自定义实现的差别
这里要说清楚,避免误解:
默认导入路径没有显式开启事务。 它执行的是一条 InsertOrUpdate ... ExecuteAffrowsAsync() 语句,由数据库保证这条语句自身的原子性。
如果你需要"整批数据要么全成功、要么全失败"(例如:第 100 行违反唯一约束时,前 99 行也要回滚),要用 OnImportAsync 自己控制:
// 示意:用 UnitOfWork 包裹整批写入
using var uow = _uowManager.Begin(); // UnitOfWorkManager 由 DI 注入
try
{
await repo.Orm.InsertOrUpdate<Order>().SetSource(rows).ExecuteAffrowsAsync();
await otherRepo.InsertAsync(...); // 其他配套写入
uow.Commit();
}
catch
{
uow.Rollback();
throw;
}
关于事务与仓储绑定,审批模块那篇(第 18 篇)有完整讲解,原理一样:仓储必须绑定到同一个 UnitOfWork,否则回滚不完整。
八、异常处理
catch (Exception ex)
{
ServiceProvider.GetService<OperationLogService>()?.AddLog(OperationType.Import, OperationResult.Failure, failureReason: ex.ToString());
await ToastService.Warning(CommonLocalizer["导入失败"], ex.Message);
}
成功时也会记一笔:
if (affectedRows > 0)
{
// 添加操作日志
ServiceProvider.GetService<OperationLogService>()?.AddLog(OperationType.Import, OperationResult.Success);
// 重新查询数据
await base.QueryAsync(1);
}
await ToastService.Success(CommonLocalizer["导入结果"], string.Format(CommonLocalizer["成功导入{0}条数据。"], affectedRows));
失败原因写进操作日志的 failureReason,界面上只显示 ex.Message。导入是"用户操作",出了问题要能在操作日志里查到上下文。
九、导出:同一套过滤,不一样的查询
导出走 OnExportAllAsync,和查询共用过滤链路,但查询方式不同:
var select = GetSelect();
if (OnBeforeQuery.HasDelegate)
{
// 注意:导出时 IsExport = true
await OnBeforeQuery.InvokeAsync(new AdminQueryEventArgs<TItem>(select, context.Options) { IsExport = true });
}
var columns = GetExportColumns().ToList();
...
// 只查询导出列(动态投影),避免 SELECT * 把大文本/导航数据全部拉回来;
// 查询链路不变(OnBeforeQuery 过滤 + 数据权限 + 动态过滤 + 排序),Include 不会执行
rows = await LoadExportRowsAsync(select, columns, context.Options, lookupService, exportOptions);
几个要点:
IsExport = true:页面在OnBeforeQuery里可以据此跳过Include(上面的注释明确说了"Include 不会执行");- 动态投影:按列名用
Reflection.Emit生成 DTO 再投影查询,避免把正文这类大字段拉回来; - 投影失败回退:捕获异常后回退到整实体查询,保证导出可用;
- MiniExcel 写流:直接
SaveAsAsync到内存流再交给DownloadService,避免逐单元格异步格式化的开销。
十、常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 导入后字段全为空 | Excel 列名与实体属性名不匹配 | 用 OnImportFromDictionaryAsync 做映射,或让列名等于属性名 |
| 导入报"没有权限" | 当前用户没有 add 按钮权限 |
角色勾上该菜单的"添加"按钮 |
| 部分行没导入且提示"已忽略 N 条" | 数据权限过滤 | 正常行为;确认这些行是否属于当前用户的数据范围 |
| 修改了 Excel 里的"创建时间"但没生效 | 该列不在 updateColumns 里 |
用可见列,或在 OnImportAsync 里自己处理 |
| 一批里有一条错,其余也没进去 | 数据库约束导致整条语句失败 | 需要"部分成功"就在导入前过滤数据 |
| 导入后想全回滚 | 默认实现没有显式事务 | 用 OnImportAsync + UnitOfWork |
| 数据重复插入 | Id = 0 的行会被当成新增 |
在 OnBeforeImportAsync 里按业务键去重,或让 Excel 带主键 |
十一、小结
Excel 导入这条链路上,框架负责"安全与一致性底线",页面负责"业务规则":
| 关注点 | 由谁负责 | 实现位置 |
|---|---|---|
| 文件类型与大小 | 框架 | DropUpload.Accept、MaxFileLength |
| 按钮权限 + 服务端权限 | 框架 | AuthButton("add") |
| 列名 → 实体映射 | 框架(按属性名)/ 页面(自定义映射) | Query<TItem>() / OnImportFromDictionaryAsync |
| 业务校验、去重 | 页面 | OnBeforeImportAsync |
| 数据权限 | 框架 | FilterAuthorizedImportRowsAsync |
| 只更新允许的列 | 框架 | UpdateColumns |
| 全批次事务 | 页面(可选) | OnImportAsync + UnitOfWork |
| 操作日志 | 框架 | OperationLogService.AddLog |
一句话:框架保证"能导入的都在权限范围内、只会写允许的列",业务规则由你在导入前拦住。
如果你正在用 .NET 10 + Blazor 做后台,导入导出是高频需求。EasyAdminBlazor 的 AdminTable 已经把模板下载、导入校验、数据权限、批量写入串成了一条链路,可以直接用,也可以按自己的业务规则在回调里扩展。