EasyAdminBlazor Excel 导入源码解析:数据验证、权限与事务如何处理?

Original 2026-09-29 875 views

已发布的《第十篇:电子元件管理实战——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),而属性名是英文,导入时就会读不到值。

处理方式有两种:

  1. 让 Excel 的列名与实体属性名一致(把模板表头改成属性名);
  2. 用 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),所以"过滤掉不合格的行"也能在这里做。


四、数据验证:框架做多少,页面做多少

框架在导入路径上只做三件事:

  1. 文件必须是 .xlsx(DropUpload.Accept);
  2. 文件大小 ≤ 5MB;
  3. 有 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 已经把模板下载、导入校验、数据权限、批量写入串成了一条链路,可以直接用,也可以按自己的业务规则在回调里扩展。