如何基于C# Entity Framework在API端生成带数据绑定的PDF
基于C# EF Core的API侧PDF生成+数据绑定+邮件附件实现方案
整个流程不需要前端参与,API侧按「仓储层取数映射→PDF服务渲染绑定→邮件附件发送」三个环节拆分实现即可,全程保持职责分离,方便后续维护。
第一步:Repository层完成数据查询与映射
不要在PDF生成逻辑里直接操作DbContext,把数据查询逻辑收敛在仓储层,而且不要直接返回EF跟踪的实体类给PDF渲染模块,专门定义PDF绑定用的强类型ViewModel,避免导航属性循环引用、多余字段加载的问题。
// PDF数据绑定专用模型,和EF实体解耦 public class OrderPdfViewModel { public string OrderNo { get; set; } public string CustomerName { get; set; } public DateTime CreateTime { get; set; } public List<OrderItemPdfDto> Items { get; set; } public decimal TotalAmount { get; set; } } public class OrderItemPdfDto { public string ProductName { get; set; } public decimal Price { get; set; } public int Quantity { get; set; } public decimal SubTotal { get; set; } } // 仓储层实现,用EF查询直接映射成ViewModel public class OrderRepository : IOrderRepository { private readonly AppDbContext _dbContext; public OrderRepository(AppDbContext dbContext) => _dbContext = dbContext; public async Task<OrderPdfViewModel> GetOrderPdfDataAsync(Guid orderId) { var data = await _dbContext.Orders .AsNoTracking() .Where(o => o.Id == orderId) .Select(o => new OrderPdfViewModel { OrderNo = o.OrderNo, CustomerName = o.Customer.FullName, CreateTime = o.CreateTime, TotalAmount = o.TotalAmount, Items = o.Items.Select(i => new OrderItemPdfDto { ProductName = i.Product.Name, Price = i.UnitPrice, Quantity = i.Quantity, SubTotal = i.UnitPrice * i.Quantity }).ToList() }) .FirstOrDefaultAsync(); return data ?? throw new KeyNotFoundException("对应业务数据不存在"); } }
第二步:封装独立PDF生成服务完成数据绑定
PDF生成逻辑不要写在Controller里,单独抽服务层实现,推荐用不需要依赖浏览器内核的PDF生成库,直接Nuget安装即可,部署时不用额外装WebView或者浏览器运行时,适合API服务场景。
安装命令:Install-Package QuestPDF
服务实现代码:
public interface IPdfGenerator { Task<byte[]> GenerateAsync<T>(string templateName, T bindData); } public class PdfGenerator : IPdfGenerator { public async Task<byte[]> GenerateAsync<T>(string templateName, T bindData) { // 这里以订单PDF为例,实际可以根据templateName匹配不同模板 if (templateName != "OrderNotify") throw new NotSupportedException("不支持的PDF模板"); var data = bindData as OrderPdfViewModel; var doc = Document.Create(container => { container.Page(page => { page.Size(PageSizes.A4); page.Margin(2, Unit.Centimetre); // 头部绑定订单号 page.Header().Text($"订单确认通知 - {data.OrderNo}").Bold().FontSize(18).AlignCenter(); // 正文绑定业务字段 page.Content().Column(col => { col.Spacing(12); col.Item().Text($"客户姓名:{data.CustomerName}"); col.Item().Text($"下单时间:{data.CreateTime:yyyy-MM-dd HH:mm:ss}"); // 表格绑定明细列表 col.Item().Table(table => { table.ColumnsDefinition(cd => { cd.RelativeColumn(3); cd.RelativeColumn(1); cd.RelativeColumn(1); cd.RelativeColumn(1); }); table.Header(h => { h.Cell().Text("商品名称").Bold(); h.Cell().Text("单价").Bold(); h.Cell().Text("数量").Bold(); h.Cell().Text("小计").Bold(); }); foreach (var item in data.Items) { table.Cell().Text(item.ProductName); table.Cell().Text(item.Price.ToString("N2")); table.Cell().Text(item.Quantity.ToString()); table.Cell().Text(item.SubTotal.ToString("N2")); } }); col.Item().Text($"订单总金额:{data.TotalAmount:N2} 元").Bold().AlignRight(); }); // 页脚页码 page.Footer().AlignCenter().Text(t => { t.Span("第 "); t.CurrentPageNumber(); t.Span(" 页 / 共 "); t.TotalPages(); t.Span(" 页"); }); }); }); return await doc.GeneratePdfAsync(); } }
核心注意点:
- PDF服务只负责接收已经查好的强类型数据做渲染,不要在服务里直接操作DbContext或者调用Repository,保证职责单一,方便单元测试
- 生成结果直接返回
byte[]二进制数组,不需要把PDF存到服务器本地磁盘,减少IO开销,也避免临时文件清理问题 - 如果习惯用Razor模板写HTML再转PDF也可以,核心逻辑不变:传入强类型绑定数据→模板渲染→输出PDF二进制流
第三步:API接口串联流程,挂载PDF附件发邮件
Controller里通过构造注入依赖的仓储、PDF服务、邮件服务,把整个流程串起来即可,邮件发送直接用.NET内置的System.Net.Mail实现,不需要额外第三方组件。
[ApiController] [Route("api/notify")] public class NotifyController : ControllerBase { private readonly IOrderRepository _orderRepo; private readonly IPdfGenerator _pdfGenerator; private readonly SmtpClient _smtpClient; public NotifyController(IOrderRepository orderRepo, IPdfGenerator pdfGenerator, SmtpClient smtpClient) { _orderRepo = orderRepo; _pdfGenerator = pdfGenerator; _smtpClient = smtpClient; } [HttpPost("order/{orderId}")] public async Task<IActionResult> SendOrderNotify(Guid orderId, [FromQuery] string receiveEmail) { // 1. 从仓储层取绑定用的业务数据 var bindData = await _orderRepo.GetOrderPdfDataAsync(orderId); // 2. 生成PDF二进制流 var pdfBytes = await _pdfGenerator.GenerateAsync("OrderNotify", bindData); // 3. 构造邮件内容 var mail = new MailMessage { From = new MailAddress("notify@yourdomain.com", "业务系统通知"), Subject = $"您的订单{bindData.OrderNo}已确认", Body = $"您好{bindData.CustomerName},您提交的订单已确认生效,详情请查看附件。", IsBodyHtml = false }; mail.To.Add(receiveEmail); // 挂载PDF附件,直接用内存流,不需要读取本地文件 mail.Attachments.Add(new Attachment( new MemoryStream(pdfBytes), $"订单_{bindData.OrderNo}.pdf", MediaTypeNames.Application.Pdf )); // 4. 发送邮件 await _smtpClient.SendMailAsync(mail); return Ok(new { success = true, msg = "通知邮件发送完成" }); } }
常见踩坑说明
- 依赖注入配置时,DbContext、Repository、PdfGenerator、SmtpClient统一注册为Scoped生命周期即可,不要注册为Singleton,避免DbContext被多线程复用导致的跟踪异常
- 如果PDF包含中文,记得把用到的中文字体文件嵌入到程序集里,不然部署到Linux环境后会出现中文乱码
- 大文件PDF生成一定要用异步方法,避免阻塞API请求线程
- 附件用到的MemoryStream不需要手动释放,MailMessage对象Dispose时会自动释放关联的流资源
内容的提问来源于stack exchange,提问作者Anusiya
相关产品推荐
相关产品推荐

