diff --git a/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Exports/DownloadExportEndpoint.cs b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Exports/DownloadExportEndpoint.cs
index ad1c8b4..7a797c9 100644
--- a/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Exports/DownloadExportEndpoint.cs
+++ b/src/BuildFlow.Host/Endpoints/Workflow/BuildFlow/Exports/DownloadExportEndpoint.cs
@@ -28,7 +28,21 @@ public class DownloadExportEndpoint(IMediator mediator) : Endpoint
- public byte[] BuildExport(string schemaJson, string formName, IEnumerable dataJsonRows)
+ public async Task WriteExportAsync(
+ string schemaJson,
+ string formName,
+ IAsyncEnumerable dataJsonRows,
+ Stream output,
+ CancellationToken ct)
{
var fields = ExtractFields(schemaJson);
- using var wb = new XLWorkbook();
- var ws = wb.AddWorksheet(SanitizeSheetName(formName));
- WriteHeader(ws, fields);
-
- var rowIndex = 2;
- foreach (var dataJson in dataJsonRows)
+ using var document = SpreadsheetDocument.Create(
+ output,
+ SpreadsheetDocumentType.Workbook,
+ true);
+ var workbookPart = document.AddWorkbookPart();
+ workbookPart.Workbook = new Workbook();
+ var worksheetPart = workbookPart.AddNewPart();
+ var sheets = workbookPart.Workbook.AppendChild(new Sheets());
+ sheets.Append(new Sheet
{
- using var doc = System.Text.Json.JsonDocument.Parse(dataJson);
- WriteDataRow(ws, fields, rowIndex, doc.RootElement);
- rowIndex++;
+ Id = workbookPart.GetIdOfPart(worksheetPart),
+ SheetId = 1,
+ Name = SanitizeSheetName(formName),
+ });
+
+ using (var writer = OpenXmlWriter.Create(worksheetPart))
+ {
+ writer.WriteStartElement(new Worksheet());
+ writer.WriteStartElement(new SheetData());
+ WriteExportRow(
+ writer,
+ 1,
+ fields.Select(field => string.IsNullOrWhiteSpace(field.Title) ? field.Path : field.Title));
+
+ uint rowIndex = 2;
+ await foreach (var dataJson in dataJsonRows.WithCancellation(ct))
+ {
+ ct.ThrowIfCancellationRequested();
+ using var json = System.Text.Json.JsonDocument.Parse(dataJson);
+ WriteExportRow(
+ writer,
+ rowIndex,
+ fields.Select(field =>
+ TryReadFieldValue(json.RootElement, field.Path, out var value)
+ ? value
+ : string.Empty));
+ rowIndex++;
+ }
+
+ writer.WriteEndElement();
+ writer.WriteEndElement();
}
- using var ms = new MemoryStream();
- wb.SaveAs(ms);
- return ms.ToArray();
+ workbookPart.Workbook.Save();
}
///
@@ -164,17 +198,24 @@ public class FormSchemaExcelService : IFormSchemaExcelService
ws.Row(rowNumber).Style.Font.FontColor = XLColor.Gray;
}
- private static void WriteDataRow(IXLWorksheet ws, IReadOnlyList fields, int rowNumber, System.Text.Json.JsonElement root)
+ private static void WriteExportRow(
+ OpenXmlWriter writer,
+ uint rowIndex,
+ IEnumerable values)
{
- for (var i = 0; i < fields.Count; i++)
+ writer.WriteStartElement(new Row { RowIndex = rowIndex });
+ foreach (var value in values)
{
- var field = fields[i];
- var cell = ws.Cell(rowNumber, i + 1);
- if (TryReadFieldValue(root, field.Path, out var raw))
+ var text = new Text(value)
{
- cell.Value = raw;
- }
+ Space = SpaceProcessingModeValues.Preserve,
+ };
+ writer.WriteElement(new Cell(new InlineString(text))
+ {
+ DataType = CellValues.InlineString,
+ });
}
+ writer.WriteEndElement();
}
private static Dictionary BuildHeaderMap(
diff --git a/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs b/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs
index 991f3b8..4d36dec 100644
--- a/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs
+++ b/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs
@@ -9,8 +9,13 @@ public interface IFormSchemaExcelService
/// 根据 SchemaJson 生成导入模版 xlsx 字节流(仅表头 + 必填批注)。
byte[] BuildTemplate(string schemaJson, string formName);
- /// 根据 SchemaJson 字段顺序导出已有 FormData 列表为 xlsx 字节流。
- byte[] BuildExport(string schemaJson, string formName, IEnumerable dataJsonRows);
+ /// 按 SchemaJson 字段顺序将异步数据行顺序写入 xlsx 输出流。
+ Task WriteExportAsync(
+ string schemaJson,
+ string formName,
+ IAsyncEnumerable dataJsonRows,
+ Stream output,
+ CancellationToken ct);
/// 从 xlsx 流逐行解析数据,并在枚举阶段强制执行行数与单元格长度上限。
IEnumerable Parse(
diff --git a/src/modules/workflow/Application/BuildFlow/Exports/Handlers/DownloadImportTemplateQueryHandler.cs b/src/modules/workflow/Application/BuildFlow/Exports/Handlers/DownloadImportTemplateQueryHandler.cs
index ebef402..ee32764 100644
--- a/src/modules/workflow/Application/BuildFlow/Exports/Handlers/DownloadImportTemplateQueryHandler.cs
+++ b/src/modules/workflow/Application/BuildFlow/Exports/Handlers/DownloadImportTemplateQueryHandler.cs
@@ -1,6 +1,7 @@
using MediatR;
using Workflow.Application.BuildFlow.Common.Services;
using Workflow.Application.BuildFlow.Exports.Queries;
+using Workflow.Application.BuildFlow.Imports.DTOs;
using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.BuildFlow.Exports.Handlers;
@@ -8,25 +9,27 @@ namespace Workflow.Application.BuildFlow.Exports.Handlers;
/// 下载导入模版处理器。按 FormDefinition.SchemaJson 字段生成 xlsx。
public class DownloadImportTemplateQueryHandler(
WorkflowDbContext db,
- IFormSchemaExcelService excelService) : IRequestHandler
+ IFormSchemaExcelService excelService) : IRequestHandler
{
- public async Task Handle(DownloadImportTemplateQuery req, CancellationToken ct)
+ public async Task Handle(DownloadImportTemplateQuery req, CancellationToken ct)
{
var form = await db.FormDefinitions
.AsNoTracking()
.Where(f => f.Id == req.FormDefinitionId)
.Select(f => new { f.Name, f.SchemaJson })
.FirstOrDefaultAsync(ct)
- ?? throw new BusinessException($"表单定义 {req.FormDefinitionId} 不存在");
+ ?? throw new NotFoundException(
+ ErrorCode.NotFound,
+ $"表单定义 {req.FormDefinitionId} 不存在");
var content = excelService.BuildTemplate(form.SchemaJson ?? "{}", form.Name);
var fileName = $"{SanitizeFileName(form.Name)}-导入模版.xlsx";
- return new ExportFormDataResult(content, fileName, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ return new ImportTemplateResult(content, fileName, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
}
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
- return new string(name.Where(c => !invalid.Contains(c)).ToArray()).Trim();
+ return string.Concat(name.Where(c => !invalid.Contains(c))).Trim();
}
}
diff --git a/src/modules/workflow/Application/BuildFlow/Exports/Handlers/ExportFormDataQueryHandler.cs b/src/modules/workflow/Application/BuildFlow/Exports/Handlers/ExportFormDataQueryHandler.cs
index 613bb85..910647b 100644
--- a/src/modules/workflow/Application/BuildFlow/Exports/Handlers/ExportFormDataQueryHandler.cs
+++ b/src/modules/workflow/Application/BuildFlow/Exports/Handlers/ExportFormDataQueryHandler.cs
@@ -1,15 +1,22 @@
+using System.Runtime.CompilerServices;
using MediatR;
+using Microsoft.Extensions.Options;
using Workflow.Application.BuildFlow.Common.Services;
using Workflow.Application.BuildFlow.Exports.Queries;
+using Workflow.Application.Reliability;
using Workflow.Infrastructure.Persistence;
namespace Workflow.Application.BuildFlow.Exports.Handlers;
-/// 导出表单数据处理器。拉取所有 FormData.DataJson,按 Schema 字段顺序输出 xlsx。
+/// 按稳定 keyset 分页读取 FormData,并顺序写入临时 xlsx 文件。
public class ExportFormDataQueryHandler(
WorkflowDbContext db,
- IFormSchemaExcelService excelService) : IRequestHandler
+ IFormSchemaExcelService excelService,
+ IOptions options) : IRequestHandler
{
+ private static readonly string ExportDirectory =
+ Path.Combine(Path.GetTempPath(), "buildflow-exports");
+
public async Task Handle(ExportFormDataQuery req, CancellationToken ct)
{
var form = await db.FormDefinitions
@@ -17,23 +24,95 @@ public class ExportFormDataQueryHandler(
.Where(f => f.Id == req.FormDefinitionId)
.Select(f => new { f.Name, f.SchemaJson })
.FirstOrDefaultAsync(ct)
- ?? throw new BusinessException($"表单定义 {req.FormDefinitionId} 不存在");
+ ?? throw new NotFoundException(
+ ErrorCode.NotFound,
+ $"表单定义 {req.FormDefinitionId} 不存在");
- var dataRows = await db.FormData
- .AsNoTracking()
- .Where(d => d.FormDefinitionId == req.FormDefinitionId)
- .OrderBy(d => d.CreatedAt)
- .Select(d => d.DataJson)
- .ToListAsync(ct);
-
- var content = excelService.BuildExport(form.SchemaJson ?? "{}", form.Name, dataRows);
+ Directory.CreateDirectory(ExportDirectory);
+ var filePath = Path.Combine(
+ ExportDirectory,
+ $"buildflow-export-{Guid.NewGuid():N}.xlsx");
var fileName = $"{SanitizeFileName(form.Name)}-{DateTime.UtcNow:yyyyMMddHHmmss}.xlsx";
- return new ExportFormDataResult(content, fileName, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ try
+ {
+ await using (var output = new FileStream(
+ filePath,
+ FileMode.CreateNew,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ 81_920,
+ FileOptions.Asynchronous | FileOptions.SequentialScan))
+ {
+ await excelService.WriteExportAsync(
+ form.SchemaJson ?? "{}",
+ form.Name,
+ ReadRowsAsync(
+ req.FormDefinitionId,
+ options.Value.Import.ExportPageSize,
+ ct),
+ output,
+ ct);
+ await output.FlushAsync(ct);
+ }
+
+ return new ExportFormDataResult(
+ filePath,
+ fileName,
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ }
+ catch
+ {
+ File.Delete(filePath);
+ throw;
+ }
+ }
+
+ private async IAsyncEnumerable ReadRowsAsync(
+ Guid formDefinitionId,
+ int pageSize,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ DateTime? lastCreatedAt = null;
+ var lastId = Guid.Empty;
+ while (true)
+ {
+ var query = db.FormData
+ .AsNoTracking()
+ .Where(data => data.FormDefinitionId == formDefinitionId);
+ if (lastCreatedAt.HasValue)
+ {
+ var cursorCreatedAt = lastCreatedAt.Value;
+ var cursorId = lastId;
+ query = query.Where(data =>
+ data.CreatedAt > cursorCreatedAt
+ || (data.CreatedAt == cursorCreatedAt && data.Id.CompareTo(cursorId) > 0));
+ }
+
+ var pageCount = 0;
+ await foreach (var row in query
+ .OrderBy(data => data.CreatedAt)
+ .ThenBy(data => data.Id)
+ .Select(data => new { data.CreatedAt, data.Id, data.DataJson })
+ .Take(pageSize)
+ .AsAsyncEnumerable()
+ .WithCancellation(ct))
+ {
+ pageCount++;
+ lastCreatedAt = row.CreatedAt;
+ lastId = row.Id;
+ yield return row.DataJson;
+ }
+
+ if (pageCount < pageSize)
+ {
+ yield break;
+ }
+ }
}
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
- return new string(name.Where(c => !invalid.Contains(c)).ToArray()).Trim();
+ return string.Concat(name.Where(c => !invalid.Contains(c))).Trim();
}
}
diff --git a/src/modules/workflow/Application/BuildFlow/Exports/Queries/DownloadImportTemplateQuery.cs b/src/modules/workflow/Application/BuildFlow/Exports/Queries/DownloadImportTemplateQuery.cs
index 59b0ace..1c4fb84 100644
--- a/src/modules/workflow/Application/BuildFlow/Exports/Queries/DownloadImportTemplateQuery.cs
+++ b/src/modules/workflow/Application/BuildFlow/Exports/Queries/DownloadImportTemplateQuery.cs
@@ -1,6 +1,7 @@
using MediatR;
+using Workflow.Application.BuildFlow.Imports.DTOs;
namespace Workflow.Application.BuildFlow.Exports.Queries;
/// 下载导入模版查询。按 FormDefinition.SchemaJson 字段生成 xlsx。
-public sealed record DownloadImportTemplateQuery(Guid FormDefinitionId) : IRequest;
+public sealed record DownloadImportTemplateQuery(Guid FormDefinitionId) : IRequest;
diff --git a/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataQuery.cs b/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataQuery.cs
index 2251288..7458e88 100644
--- a/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataQuery.cs
+++ b/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataQuery.cs
@@ -2,5 +2,5 @@ using MediatR;
namespace Workflow.Application.BuildFlow.Exports.Queries;
-/// 导出表单数据为 xlsx 字节流查询。返回 xlsx 文件字节。
+/// 导出表单数据为临时 xlsx 文件的查询。
public sealed record ExportFormDataQuery(Guid FormDefinitionId) : IRequest;
diff --git a/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataResult.cs b/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataResult.cs
index d6f307a..caedf9b 100644
--- a/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataResult.cs
+++ b/src/modules/workflow/Application/BuildFlow/Exports/Queries/ExportFormDataResult.cs
@@ -1,4 +1,7 @@
namespace Workflow.Application.BuildFlow.Exports.Queries;
-/// 导出结果:xlsx 字节流 + 建议文件名。
-public sealed record ExportFormDataResult(byte[] Content, string FileName, string ContentType);
+/// 导出结果:临时 xlsx 文件路径、建议文件名和响应内容类型。
+public sealed record ExportFormDataResult(
+ string FilePath,
+ string FileName,
+ string ContentType);
diff --git a/src/modules/workflow/Application/BuildFlow/Imports/DTOs/ImportTemplateResult.cs b/src/modules/workflow/Application/BuildFlow/Imports/DTOs/ImportTemplateResult.cs
new file mode 100644
index 0000000..dff9c91
--- /dev/null
+++ b/src/modules/workflow/Application/BuildFlow/Imports/DTOs/ImportTemplateResult.cs
@@ -0,0 +1,7 @@
+namespace Workflow.Application.BuildFlow.Imports.DTOs;
+
+/// 导入模板下载结果:小型 xlsx 内容、建议文件名和响应内容类型。
+public sealed record ImportTemplateResult(
+ byte[] Content,
+ string FileName,
+ string ContentType);
diff --git a/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs b/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs
index 3cb95c4..1f15081 100644
--- a/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs
+++ b/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs
@@ -82,7 +82,7 @@ public class FormSchemaExcelServiceTests
}
[Fact]
- public void BuildExport_OutputsDataRowsByFieldOrder()
+ public async Task WriteExportAsync_OutputsDataRowsByFieldOrder()
{
var dataRows = new[]
{
@@ -90,12 +90,29 @@ public class FormSchemaExcelServiceTests
"""{"name":"王五","amount":300,"approved":false}""",
};
- var bytes = _service.BuildExport(SampleSchema, "测试表单", dataRows);
+ using var ms = new MemoryStream();
+ await _service.WriteExportAsync(
+ SampleSchema,
+ "测试表单",
+ ToAsyncEnumerable(dataRows),
+ ms,
+ CancellationToken.None);
+ ms.Position = 0;
- using var ms = new MemoryStream(bytes);
using var wb = new ClosedXML.Excel.XLWorkbook(ms);
var ws = wb.Worksheets.First();
ws.Cell(2, 1).GetString().Should().Be("李四");
ws.Cell(3, 1).GetString().Should().Be("王五");
}
+
+ private static async IAsyncEnumerable ToAsyncEnumerable(
+ IEnumerable rows)
+ {
+ foreach (var row in rows)
+ {
+ yield return row;
+ }
+
+ await Task.CompletedTask;
+ }
}
diff --git a/tests/BuildFlow.Workflow.Tests/Performance/CancellingExportExcelService.cs b/tests/BuildFlow.Workflow.Tests/Performance/CancellingExportExcelService.cs
new file mode 100644
index 0000000..5c92efb
--- /dev/null
+++ b/tests/BuildFlow.Workflow.Tests/Performance/CancellingExportExcelService.cs
@@ -0,0 +1,38 @@
+using Workflow.Application.BuildFlow.Common.Services;
+
+namespace BuildFlow.Workflow.Tests.Performance;
+
+/// 在导出写入阶段触发取消或指定异常,并记录实际输出路径。
+public sealed class CancellingExportExcelService(
+ CancellationTokenSource? cancellation = null,
+ Exception? exception = null) : IFormSchemaExcelService
+{
+ /// handler 传入的临时文件路径。
+ public string? OutputPath { get; private set; }
+
+ public byte[] BuildTemplate(string schemaJson, string formName) => [];
+
+ public IEnumerable Parse(
+ string schemaJson,
+ Stream xlsxStream,
+ int maxRows,
+ int maxCellTextLength) => [];
+
+ /// 记录路径后触发测试指定的失败。
+ public Task WriteExportAsync(
+ string schemaJson,
+ string formName,
+ IAsyncEnumerable dataJsonRows,
+ Stream output,
+ CancellationToken ct)
+ {
+ OutputPath = (output as FileStream)?.Name;
+ if (cancellation is not null)
+ {
+ cancellation.Cancel();
+ ct.ThrowIfCancellationRequested();
+ }
+
+ throw exception ?? new InvalidOperationException("测试导出写入失败");
+ }
+}
diff --git a/tests/BuildFlow.Workflow.Tests/Performance/CountingExportPageInterceptor.cs b/tests/BuildFlow.Workflow.Tests/Performance/CountingExportPageInterceptor.cs
new file mode 100644
index 0000000..2160617
--- /dev/null
+++ b/tests/BuildFlow.Workflow.Tests/Performance/CountingExportPageInterceptor.cs
@@ -0,0 +1,39 @@
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace BuildFlow.Workflow.Tests.Performance;
+
+/// 仅统计读取 b_form_data 的分页 SELECT 命令。
+public sealed class CountingExportPageInterceptor : DbCommandInterceptor
+{
+ /// 已执行的表单数据分页查询数。
+ public int PageSelectCount { get; private set; }
+
+ public override InterceptionResult ReaderExecuting(
+ DbCommand command,
+ CommandEventData eventData,
+ InterceptionResult result)
+ {
+ Count(command);
+ return result;
+ }
+
+ public override ValueTask> ReaderExecutingAsync(
+ DbCommand command,
+ CommandEventData eventData,
+ InterceptionResult result,
+ CancellationToken cancellationToken = default)
+ {
+ Count(command);
+ return ValueTask.FromResult(result);
+ }
+
+ private void Count(DbCommand command)
+ {
+ if (command.CommandText.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)
+ && command.CommandText.Contains("b_form_data", StringComparison.OrdinalIgnoreCase))
+ {
+ PageSelectCount++;
+ }
+ }
+}
diff --git a/tests/BuildFlow.Workflow.Tests/Performance/EnumerableFormSchemaExcelService.cs b/tests/BuildFlow.Workflow.Tests/Performance/EnumerableFormSchemaExcelService.cs
index 18425c9..7db6965 100644
--- a/tests/BuildFlow.Workflow.Tests/Performance/EnumerableFormSchemaExcelService.cs
+++ b/tests/BuildFlow.Workflow.Tests/Performance/EnumerableFormSchemaExcelService.cs
@@ -8,10 +8,12 @@ public sealed class EnumerableFormSchemaExcelService(IEnumerable
{
public byte[] BuildTemplate(string schemaJson, string formName) => [];
- public byte[] BuildExport(
+ public Task WriteExportAsync(
string schemaJson,
string formName,
- IEnumerable dataJsonRows) => [];
+ IAsyncEnumerable dataJsonRows,
+ Stream output,
+ CancellationToken ct) => Task.CompletedTask;
public IEnumerable Parse(
string schemaJson,
diff --git a/tests/BuildFlow.Workflow.Tests/Performance/OpenXmlExportBoundaryTests.cs b/tests/BuildFlow.Workflow.Tests/Performance/OpenXmlExportBoundaryTests.cs
new file mode 100644
index 0000000..e0284bf
--- /dev/null
+++ b/tests/BuildFlow.Workflow.Tests/Performance/OpenXmlExportBoundaryTests.cs
@@ -0,0 +1,156 @@
+using ClosedXML.Excel;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Workflow.Application.BuildFlow.Common.Services;
+using Workflow.Application.BuildFlow.Exports.Handlers;
+using Workflow.Application.BuildFlow.Exports.Queries;
+using Workflow.Application.Reliability;
+using Workflow.Domain.Entities;
+using Workflow.Domain.Enums;
+using Xunit;
+
+namespace BuildFlow.Workflow.Tests.Performance;
+
+/// Excel 导出的 keyset 分页、顺序写入和临时文件清理边界测试。
+[Collection(Reliability.PostgresWorkflowCollection.Name)]
+public class OpenXmlExportBoundaryTests(Reliability.PostgresWorkflowFixture fixture)
+{
+ [Fact]
+ public async Task TwelveHundredOneRowsUseThreePagesAndKeepStableOrder()
+ {
+ await ResetAsync();
+ var formId = await SeedFormAsync();
+ await SeedRowsAsync(formId, 1_201);
+ var interceptor = new CountingExportPageInterceptor();
+ await using var db = fixture.CreateDbContext(interceptor);
+ var handler = CreateHandler(db, new FormSchemaExcelService(), exportPageSize: 500);
+ string? filePath = null;
+
+ try
+ {
+ var result = await handler.Handle(
+ new ExportFormDataQuery(formId),
+ CancellationToken.None);
+ filePath = result.FilePath;
+
+ interceptor.PageSelectCount.Should().Be(3);
+ File.Exists(filePath).Should().BeTrue();
+ typeof(ExportFormDataResult).GetProperties()
+ .Should().NotContain(property => property.PropertyType == typeof(byte[]));
+ using var workbook = new XLWorkbook(filePath);
+ var worksheet = workbook.Worksheets.First();
+ worksheet.LastRowUsed()!.RowNumber().Should().Be(1_202);
+ for (var index = 0; index < 1_201; index++)
+ {
+ worksheet.Cell(index + 2, 1).GetString()
+ .Should().Be($"row-{index:D4}");
+ }
+ }
+ finally
+ {
+ if (filePath is not null)
+ {
+ File.Delete(filePath);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task CancellationDeletesTemporaryExportFile()
+ {
+ await ResetAsync();
+ var formId = await SeedFormAsync();
+ using var cts = new CancellationTokenSource();
+ var excel = new CancellingExportExcelService(cts);
+ await using var db = fixture.CreateDbContext();
+ var handler = CreateHandler(db, excel, exportPageSize: 500);
+
+ var act = () => handler.Handle(new ExportFormDataQuery(formId), cts.Token);
+
+ await act.Should().ThrowAsync();
+ excel.OutputPath.Should().NotBeNull();
+ File.Exists(excel.OutputPath).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task WriterFailureDeletesTemporaryExportFile()
+ {
+ await ResetAsync();
+ var formId = await SeedFormAsync();
+ var excel = new CancellingExportExcelService(
+ exception: new InvalidOperationException("writer failed"));
+ await using var db = fixture.CreateDbContext();
+ var handler = CreateHandler(db, excel, exportPageSize: 500);
+
+ var act = () => handler.Handle(new ExportFormDataQuery(formId), CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ excel.OutputPath.Should().NotBeNull();
+ File.Exists(excel.OutputPath).Should().BeFalse();
+ }
+
+ private static ExportFormDataQueryHandler CreateHandler(
+ WorkflowDbContext db,
+ IFormSchemaExcelService excelService,
+ int exportPageSize)
+ => new(
+ db,
+ excelService,
+ Options.Create(new WorkflowJobOptions
+ {
+ Import = new WorkflowJobOptions.ImportJobSection
+ {
+ ExportPageSize = exportPageSize,
+ },
+ }));
+
+ private async Task ResetAsync()
+ {
+ await using var db = fixture.CreateDbContext();
+ await db.FormData.ExecuteDeleteAsync();
+ await db.FormDefinitions.ExecuteDeleteAsync();
+ }
+
+ private async Task SeedFormAsync()
+ {
+ await using var db = fixture.CreateDbContext();
+ var formId = Guid.NewGuid();
+ db.FormDefinitions.Add(new FormDefinition
+ {
+ Id = formId,
+ Name = "分页导出测试",
+ Code = $"export-{formId:N}",
+ Version = 1,
+ SchemaJson = Schema,
+ Status = FormStatus.Published,
+ });
+ await db.SaveChangesAsync();
+ return formId;
+ }
+
+ private async Task SeedRowsAsync(Guid formId, int count)
+ {
+ await using var db = fixture.CreateDbContext();
+ var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ db.FormData.AddRange(Enumerable.Range(0, count).Select(index => new FormData
+ {
+ Id = Guid.NewGuid(),
+ FormDefinitionId = formId,
+ InstanceId = Guid.NewGuid(),
+ DataJson = $$"""{"name":"row-{{index:D4}}"}""",
+ CreatedAt = start.AddMilliseconds(index),
+ UpdatedAt = start.AddMilliseconds(index),
+ }));
+ await db.SaveChangesAsync();
+ }
+
+ private const string Schema = """
+ {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string", "title": "名称", "x-component": "Input" }
+ }
+ }
+ """;
+}
diff --git a/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs b/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs
index 5435a2d..f11f806 100644
--- a/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs
+++ b/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs
@@ -213,10 +213,12 @@ public class ImportJobRecoveryTests(PostgresWorkflowFixture fixture)
{
public byte[] BuildTemplate(string schemaJson, string formName) => [];
- public byte[] BuildExport(
+ public Task WriteExportAsync(
string schemaJson,
string formName,
- IEnumerable dataJsonRows) => [];
+ IAsyncEnumerable dataJsonRows,
+ Stream output,
+ CancellationToken ct) => Task.CompletedTask;
public IEnumerable Parse(
string schemaJson,