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,