diff --git a/src/modules/workflow/Application/BuildFlow/Common/Services/ExcelParsedRow.cs b/src/modules/workflow/Application/BuildFlow/Common/Services/ExcelParsedRow.cs new file mode 100644 index 0000000..9ace960 --- /dev/null +++ b/src/modules/workflow/Application/BuildFlow/Common/Services/ExcelParsedRow.cs @@ -0,0 +1,7 @@ +namespace Workflow.Application.BuildFlow.Common.Services; + +/// 解析后的 Excel 行。 +public sealed record ExcelParsedRow( + int RowNumber, + string JsonObject, + IReadOnlyList Errors); diff --git a/src/modules/workflow/Application/BuildFlow/Common/Services/FormSchemaExcelService.cs b/src/modules/workflow/Application/BuildFlow/Common/Services/FormSchemaExcelService.cs index fc346df..74e5185 100644 --- a/src/modules/workflow/Application/BuildFlow/Common/Services/FormSchemaExcelService.cs +++ b/src/modules/workflow/Application/BuildFlow/Common/Services/FormSchemaExcelService.cs @@ -53,7 +53,7 @@ public class FormSchemaExcelService : IFormSchemaExcelService } /// - public IReadOnlyList Parse(string schemaJson, byte[] xlsxBytes) + public IEnumerable Parse(string schemaJson, byte[] xlsxBytes) { var fields = ExtractFields(schemaJson); using var ms = new MemoryStream(xlsxBytes); @@ -62,19 +62,16 @@ public class FormSchemaExcelService : IFormSchemaExcelService var lastRow = ws.LastRowUsed(); var headerMap = BuildHeaderMap(ws, fields); - var result = new List(); if (lastRow is null) { - return result; + yield break; } for (var r = 2; r <= lastRow.RowNumber(); r++) { var (json, errors) = BuildRowJson(ws, r, headerMap, fields); - result.Add(new ExcelParsedRow(r, json, errors)); + yield return new ExcelParsedRow(r, json, errors); } - - return result; } private static IReadOnlyList ExtractFields(string schemaJson) diff --git a/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs b/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs index a7dda3f..3e1e3b7 100644 --- a/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs +++ b/src/modules/workflow/Application/BuildFlow/Common/Services/IFormSchemaExcelService.cs @@ -12,12 +12,6 @@ public interface IFormSchemaExcelService /// 根据 SchemaJson 字段顺序导出已有 FormData 列表为 xlsx 字节流。 byte[] BuildExport(string schemaJson, string formName, IEnumerable dataJsonRows); - /// 解析上传 xlsx 字节流,按字段顺序返回每行的 JSON 对象字符串。 - IReadOnlyList Parse(string schemaJson, byte[] xlsxBytes); + /// 解析上传 xlsx 字节流,按字段顺序惰性返回每行的 JSON 对象字符串。 + IEnumerable Parse(string schemaJson, byte[] xlsxBytes); } - -/// 解析后的 Excel 行。 -public sealed record ExcelParsedRow( - int RowNumber, - string JsonObject, - IReadOnlyList Errors); diff --git a/src/modules/workflow/Application/BuildFlow/Imports/Services/ImportJobProcessor.cs b/src/modules/workflow/Application/BuildFlow/Imports/Services/ImportJobProcessor.cs index 9198ff0..e329c27 100644 --- a/src/modules/workflow/Application/BuildFlow/Imports/Services/ImportJobProcessor.cs +++ b/src/modules/workflow/Application/BuildFlow/Imports/Services/ImportJobProcessor.cs @@ -62,25 +62,24 @@ public class ImportJobProcessor( jobId, leaseOwner, leaseDuration, - rows.Count, ct); if (nextRowIndex is null) return; - if (nextRowIndex.Value > rows.Count) - { - throw new BusinessException( - ErrorCode.Business, - $"导入任务进度游标 {nextRowIndex.Value} 超过总行数 {rows.Count}"); - } var submitHandler = new SubmitFormDataCommandHandler( db, new FormValueDerivationService([], [], db), new FormAggregateRefreshService(db)); - for (var i = nextRowIndex.Value; i < rows.Count; i++) + var rowIndex = 0; + foreach (var row in rows) { ct.ThrowIfCancellationRequested(); - var row = rows[i]; + if (rowIndex < nextRowIndex.Value) + { + rowIndex++; + continue; + } + if (row.Errors.Count > 0) { var recorded = await CommitFailedRowAsync( @@ -89,8 +88,10 @@ public class ImportJobProcessor( leaseDuration, row.RowNumber, row.Errors.Select(e => e.Message).ToList(), + rowIndex + 1, ct); if (!recorded) return; + rowIndex++; continue; } @@ -101,7 +102,7 @@ public class ImportJobProcessor( leaseOwner, leaseDuration, job.FormDefinitionId, - i, + rowIndex, row.JsonObject, submitHandler, ct); @@ -119,12 +120,22 @@ public class ImportJobProcessor( leaseDuration, row.RowNumber, [ex.Message], + rowIndex + 1, ct); if (!recorded) return; } + + rowIndex++; } - await CompleteJobAsync(jobId, leaseOwner, ct); + if (nextRowIndex.Value > rowIndex) + { + throw new BusinessException( + ErrorCode.Business, + $"导入任务进度游标 {nextRowIndex.Value} 超过总行数 {rowIndex}"); + } + + await CompleteJobAsync(jobId, leaseOwner, rowIndex, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -141,7 +152,6 @@ public class ImportJobProcessor( Guid jobId, string leaseOwner, TimeSpan leaseDuration, - int totalRows, CancellationToken ct) { db.ChangeTracker.Clear(); @@ -153,7 +163,6 @@ public class ImportJobProcessor( return null; } - job.TotalRows = totalRows; RenewLease(job, leaseDuration); await db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); @@ -184,6 +193,7 @@ public class ImportJobProcessor( new SubmitFormDataCommand(formDefinitionId, instanceId, dataJson), ct); job.SuccessRows++; + job.TotalRows = Math.Max(job.TotalRows, rowIndex + 1); RenewLease(job, leaseDuration); await db.SaveChangesAsync(ct); await transaction.CommitAsync(ct); @@ -196,6 +206,7 @@ public class ImportJobProcessor( TimeSpan leaseDuration, int rowNumber, IReadOnlyList messages, + int processedRows, CancellationToken ct) { db.ChangeTracker.Clear(); @@ -210,6 +221,7 @@ public class ImportJobProcessor( var errors = DeserializeErrors(job.ErrorJson); errors.Add(new ImportRowError(rowNumber, messages)); job.FailedRows++; + job.TotalRows = Math.Max(job.TotalRows, processedRows); job.ErrorJson = JsonSerializer.Serialize(errors, JsonOptions); RenewLease(job, leaseDuration); await db.SaveChangesAsync(ct); @@ -220,6 +232,7 @@ public class ImportJobProcessor( private async Task CompleteJobAsync( Guid jobId, string leaseOwner, + int totalRows, CancellationToken ct) { db.ChangeTracker.Clear(); @@ -237,6 +250,7 @@ public class ImportJobProcessor( ? BuildFlowImportJobStatus.Failed : BuildFlowImportJobStatus.PartialSuccess; job.ErrorMessage = null; + job.TotalRows = totalRows; job.FinishedAt = DateTime.UtcNow; job.LastHeartbeatAt = DateTime.UtcNow; job.LeaseOwner = null; diff --git a/src/modules/workflow/Application/Form/Derivation/FormValueDerivationService.cs b/src/modules/workflow/Application/Form/Derivation/FormValueDerivationService.cs index b078136..000b417 100644 --- a/src/modules/workflow/Application/Form/Derivation/FormValueDerivationService.cs +++ b/src/modules/workflow/Application/Form/Derivation/FormValueDerivationService.cs @@ -2,6 +2,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; using Workflow.Application.Form.Relation; +using Workflow.Application.Form.Schema; using Workflow.Domain.Enums; using Workflow.Infrastructure.Persistence; @@ -33,15 +34,22 @@ public sealed class FormValueDerivationService : IFormValueDerivationService _db = db; } - public async Task DeriveAsync( + public Task DeriveAsync( string schemaJson, string dataJson, FormDerivationContext context, CancellationToken ct) + => DeriveAsync(PreparedFormSchema.Create(schemaJson), dataJson, context, ct); + + public async Task DeriveAsync( + PreparedFormSchema preparedSchema, + string dataJson, + FormDerivationContext context, + CancellationToken ct) { ct.ThrowIfCancellationRequested(); - var schema = JsonNode.Parse(schemaJson) as JsonObject ?? new JsonObject(); + var schema = preparedSchema.Schema.DeepClone().AsObject(); var data = JsonNode.Parse(dataJson) as JsonObject ?? new JsonObject(); var derivedPaths = new List(); diff --git a/src/modules/workflow/Application/Form/Derivation/IFormValueDerivationService.cs b/src/modules/workflow/Application/Form/Derivation/IFormValueDerivationService.cs index e913fdd..4923225 100644 --- a/src/modules/workflow/Application/Form/Derivation/IFormValueDerivationService.cs +++ b/src/modules/workflow/Application/Form/Derivation/IFormValueDerivationService.cs @@ -1,3 +1,5 @@ +using Workflow.Application.Form.Schema; + namespace Workflow.Application.Form.Derivation; /// 统一表单提交前派生值管线:计算字段、关联快照、统计字段都在这里收口。 @@ -8,4 +10,11 @@ public interface IFormValueDerivationService string dataJson, FormDerivationContext context, CancellationToken ct); + + /// 使用预解析 Schema 执行派生,供批量场景复用字段元数据。 + Task DeriveAsync( + PreparedFormSchema schema, + string dataJson, + FormDerivationContext context, + CancellationToken ct); } diff --git a/src/modules/workflow/Application/Form/Schema/FormDataValidator.cs b/src/modules/workflow/Application/Form/Schema/FormDataValidator.cs index b69987a..d382653 100644 --- a/src/modules/workflow/Application/Form/Schema/FormDataValidator.cs +++ b/src/modules/workflow/Application/Form/Schema/FormDataValidator.cs @@ -33,6 +33,31 @@ public static class FormDataValidator IReadOnlyDictionary? nodeFieldPermissions = null) { var schemaValidation = SchemaValidator.Validate(schemaJson, allowedComponents); + return Validate( + schemaValidation, + dataJson, + currentNodeKey, + nodeFieldPermissions); + } + + /// 使用预解析 Schema 校验表单数据,避免批量场景逐行重复解析。 + public static FormDataValidationResult Validate( + PreparedFormSchema schema, + string dataJson, + string? currentNodeKey = null, + IReadOnlyDictionary? nodeFieldPermissions = null) + => Validate( + schema.Validation, + dataJson, + currentNodeKey, + nodeFieldPermissions); + + private static FormDataValidationResult Validate( + SchemaValidationResult schemaValidation, + string dataJson, + string? currentNodeKey, + IReadOnlyDictionary? nodeFieldPermissions) + { if (!schemaValidation.IsValid) { return FormDataValidationResult.Invalid(schemaValidation.Errors); diff --git a/src/modules/workflow/Application/Form/Schema/PreparedFormSchema.cs b/src/modules/workflow/Application/Form/Schema/PreparedFormSchema.cs new file mode 100644 index 0000000..fd8b332 --- /dev/null +++ b/src/modules/workflow/Application/Form/Schema/PreparedFormSchema.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Nodes; + +namespace Workflow.Application.Form.Schema; + +/// +/// 预解析表单 Schema。批量用例复用字段校验结果和 JSON 结构,避免逐行重复解析。 +/// +public sealed class PreparedFormSchema +{ + private PreparedFormSchema( + string schemaJson, + JsonObject schema, + SchemaValidationResult validation) + { + SchemaJson = schemaJson; + Schema = schema; + Validation = validation; + } + + /// 原始 Schema JSON。 + public string SchemaJson { get; } + + /// 解析后的 Schema JSON 对象。 + public JsonObject Schema { get; } + + /// 已提取字段元数据的 Schema 校验结果。 + public SchemaValidationResult Validation { get; } + + /// 校验并准备一个可复用的表单 Schema。 + public static PreparedFormSchema Create(string schemaJson) + { + var validation = SchemaValidator.Validate(schemaJson); + if (!validation.IsValid) + { + throw new BusinessException( + ErrorCode.InvalidParam, + $"表单 Schema 无效: {string.Join("; ", validation.Errors)}"); + } + + var schema = JsonNode.Parse(schemaJson) as JsonObject ?? new JsonObject(); + return new PreparedFormSchema(schemaJson, schema, validation); + } +} diff --git a/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs b/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs index d1ce096..c821301 100644 --- a/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs +++ b/tests/BuildFlow.Workflow.Tests/BuildFlow/FormSchemaExcelServiceTests.cs @@ -49,7 +49,7 @@ public class FormSchemaExcelServiceTests using var outMs = new MemoryStream(); wb.SaveAs(outMs); - var rows = _service.Parse(SampleSchema, outMs.ToArray()); + var rows = _service.Parse(SampleSchema, outMs.ToArray()).ToList(); rows.Should().HaveCount(1); var row = rows[0]; @@ -73,7 +73,7 @@ public class FormSchemaExcelServiceTests using var outMs = new MemoryStream(); wb.SaveAs(outMs); - var rows = _service.Parse(SampleSchema, outMs.ToArray()); + var rows = _service.Parse(SampleSchema, outMs.ToArray()).ToList(); rows.Should().HaveCount(1); rows[0].Errors.Should().ContainSingle(e => e.Message.Contains("必填")); diff --git a/tests/BuildFlow.Workflow.Tests/Performance/LazyExcelRowTests.cs b/tests/BuildFlow.Workflow.Tests/Performance/LazyExcelRowTests.cs new file mode 100644 index 0000000..5877624 --- /dev/null +++ b/tests/BuildFlow.Workflow.Tests/Performance/LazyExcelRowTests.cs @@ -0,0 +1,42 @@ +using ClosedXML.Excel; +using FluentAssertions; +using Workflow.Application.BuildFlow.Common.Services; +using Xunit; + +namespace BuildFlow.Workflow.Tests.Performance; + +/// +/// Excel 导入行惰性枚举契约测试。 +/// +public class LazyExcelRowTests +{ + [Fact] + public void ParseReturnsLazyEnumerableInsteadOfMaterializedList() + { + var service = new FormSchemaExcelService(); + var template = service.BuildTemplate(Schema, "惰性导入"); + using var input = new MemoryStream(template); + using var workbook = new XLWorkbook(input); + var worksheet = workbook.Worksheets.First(); + worksheet.Cell(2, 1).Value = "first"; + worksheet.Cell(3, 1).Value = "second"; + worksheet.Cell(4, 1).Value = "third"; + using var output = new MemoryStream(); + workbook.SaveAs(output); + + var rows = service.Parse(Schema, output.ToArray()); + + rows.Should().NotBeAssignableTo>(); + rows.Take(1).Should().ContainSingle(x => + x.RowNumber == 2 && x.JsonObject.Contains("first", StringComparison.Ordinal)); + } + + private const string Schema = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "title": "名称", "required": true, "x-component": "Input" } + } + } + """; +} diff --git a/tests/BuildFlow.Workflow.Tests/Performance/PreparedFormSchemaTests.cs b/tests/BuildFlow.Workflow.Tests/Performance/PreparedFormSchemaTests.cs new file mode 100644 index 0000000..7d464a3 --- /dev/null +++ b/tests/BuildFlow.Workflow.Tests/Performance/PreparedFormSchemaTests.cs @@ -0,0 +1,89 @@ +using BuildFlow.Shared.Domain.Errors; +using BuildFlow.Shared.Domain.Exceptions; +using FluentAssertions; +using System.Text.Json.Nodes; +using Workflow.Application.Form.Derivation; +using Workflow.Application.Form.Schema; +using Xunit; + +namespace BuildFlow.Workflow.Tests.Performance; + +/// +/// 预解析表单 Schema 在批量校验和派生中的复用测试。 +/// +public class PreparedFormSchemaTests +{ + [Fact] + public async Task PreparedSchemaCanBeReusedForValidationAndDerivation() + { + var prepared = PreparedFormSchema.Create(ComputedSchema); + var derivation = new FormValueDerivationService([], []); + + var firstValidation = FormDataValidator.Validate( + prepared, + """{"quantity":2,"unitPrice":10}"""); + var first = await derivation.DeriveAsync( + prepared, + """{"quantity":2,"unitPrice":10}""", + new FormDerivationContext(Guid.NewGuid(), Guid.NewGuid(), null), + CancellationToken.None); + var second = await derivation.DeriveAsync( + prepared, + """{"quantity":3,"unitPrice":5}""", + new FormDerivationContext(Guid.NewGuid(), Guid.NewGuid(), null), + CancellationToken.None); + + firstValidation.IsValid.Should().BeTrue(); + ReadTotal(first.DataJson).Should().Be(20); + ReadTotal(second.DataJson).Should().Be(15); + prepared.Validation.Fields.Should().HaveCount(3); + } + + [Fact] + public void InvalidSchemaCannotBePrepared() + { + var act = () => PreparedFormSchema.Create(""" + { + "type": "object", + "properties": { + "supplier": { + "type": "object", + "x-component": "RelationPicker", + "x-field-kind": "relation", + "x-relation": { "idField": "id" } + } + } + } + """); + + var exception = act.Should().Throw().Which; + exception.Code.Should().Be(ErrorCode.InvalidParam); + } + + private static decimal ReadTotal(string dataJson) + => JsonNode.Parse(dataJson)!["total"]!.GetValue(); + + private const string ComputedSchema = """ + { + "type": "object", + "properties": { + "quantity": { "type": "number", "x-component": "InputNumber" }, + "unitPrice": { "type": "number", "x-component": "InputNumber" }, + "total": { + "type": "number", + "x-component": "InputNumber", + "x-field-kind": "computed", + "x-derived": { + "mode": "computed", + "writePolicy": "overwrite", + "expression": { + "operator": "multiply", + "left": { "type": "field", "path": "quantity" }, + "right": { "type": "field", "path": "unitPrice" } + } + } + } + } + } + """; +} diff --git a/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs b/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs index 32f10a0..acdd788 100644 --- a/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs +++ b/tests/BuildFlow.Workflow.Tests/Reliability/ImportJobRecoveryTests.cs @@ -201,7 +201,7 @@ public class ImportJobRecoveryTests(PostgresWorkflowFixture fixture) string formName, IEnumerable dataJsonRows) => []; - public IReadOnlyList Parse(string schemaJson, byte[] xlsxBytes) => rows; + public IEnumerable Parse(string schemaJson, byte[] xlsxBytes) => rows; } private sealed class CancellingRows( @@ -220,7 +220,14 @@ public class ImportJobRecoveryTests(PostgresWorkflowFixture fixture) } } - public IEnumerator GetEnumerator() => rows.GetEnumerator(); + public IEnumerator GetEnumerator() + { + for (var index = 0; index < rows.Count; index++) + { + if (index == cancelAtIndex) cancel(); + yield return rows[index]; + } + } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();