From 5ea52e5291e78e52029285f202d45dc48ae81628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Mon, 14 Sep 2026 12:08:25 +0800 Subject: [PATCH] =?UTF-8?q?perf(F5+):=20=E8=A1=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=8C=89=E9=9C=80=E6=8B=89=E5=8F=96=EF=BC=88=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E8=A1=8C=E7=89=88=E6=9C=AC=E4=BF=A1=E5=8F=B7=EF=BC=89+=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20progress/light=20=E6=81=92=E5=88=A4=20miss?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 行数据按需拉取(审查 F5 后续): - V125 给 biz_file_result 补 updated_at(DEFAULT/ON UPDATE 由数据库维护, 实体标注 insertStrategy/updateStrategy=NEVER —— 否则 selectById→updateById 的 写回会把旧值写回去、ON UPDATE 不触发,版本信号静默冻结) - 装配器回传 rowsVersion=「最后变更时间毫秒#行数」,5 个品牌工具页版本未变即跳过 带行明细的重型 batch;前端变更信号为 rowsVersion + status/fileStatus/fileReady 复合 (任务收尾常见「行早写完、之后才置成功」,只看行版本会把界面卡在旧状态) 修复线上缺陷(同一功能验证时暴露): - TaskProgressLightAssembler 列裁剪漏选 module_type 却用它做模块过滤 → getModuleType() 恒为 null → light 恒把任务判成 missing;第七批把 light 接进 跟价/定时匹配/商品风险的轮询后,消费方会把运行中任务判为 FAILED - 补选中列 + 守卫用例 taskQueryMustSelectModuleType(已反向验证:去掉修复即红) - 前端 lightClaimsAllTasksMissing:整体性 missing 结论用重型端点复核后再采信 契约与文档:light 白名单补 rowsVersion(Java 契约测试 / spec 06 §2 / 12 个端点描述) 测试:mvn test 2901 全绿;前端 npm test 765 全绿 --- .../AppearancePatentController.java | 4 +- .../controller/CollectDataController.java | 4 +- .../controller/DeleteBrandRunController.java | 4 +- .../controller/PatrolDeleteController.java | 4 +- .../controller/PriceTrackController.java | 4 +- .../ProductRiskResolveController.java | 4 +- .../publish/controller/PublishController.java | 4 +- .../controller/QueryAsinTaskController.java | 4 +- .../ShopDataCrawlTaskController.java | 4 +- .../controller/ShopMatchController.java | 4 +- .../controller/SimilarAsinController.java | 2 +- .../modules/task/mapper/FileResultMapper.java | 26 ++++++ .../task/model/dto/TaskRowsVersionDto.java | 12 +++ .../task/model/entity/FileResultEntity.java | 11 +++ .../task/model/vo/TaskProgressLightVo.java | 6 ++ .../service/TaskProgressLightAssembler.java | 21 +++++ .../controller/WithdrawTaskController.java | 4 +- .../db/V125__file_result_updated_at.sql | 29 ++++++ .../task/TaskProgressLightContractTest.java | 8 +- .../contract/ResultFilePhaseContractTest.java | 3 +- .../TaskProgressLightAssemblerTest.java | 86 ++++++++++++++++- .../TaskProgressLightPermissionTest.java | 4 +- .../components/BrandAppearancePatentTab.vue | 23 ++++- .../brand/components/BrandPatrolDeleteTab.vue | 27 +++++- .../brand/components/BrandQueryAsinTab.vue | 28 +++++- .../components/BrandShopDataCrawlTab.vue | 16 ++++ .../brand/components/BrandWithdrawTab.vue | 28 +++++- frontend-vue/src/shared/api/progress-light.ts | 2 + .../src/shared/api/task-progress-polling.ts | 92 +++++++++++++++++++ .../tests/brand-rows-version-wiring.test.ts | 61 ++++++++++++ frontend-vue/tests/progress-light.test.ts | 59 ++++++++++++ .../tests/rows-version-tracker.test.ts | 89 ++++++++++++++++++ 32 files changed, 641 insertions(+), 36 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskRowsVersionDto.java create mode 100644 backend-java/src/main/resources/db/V125__file_result_updated_at.sql create mode 100644 frontend-vue/tests/brand-rows-version-wiring.test.ts create mode 100644 frontend-vue/tests/rows-version-tracker.test.ts diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java index 39050fff..7e9cbc45 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -110,8 +110,8 @@ public class AppearancePatentController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(service.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java index 530c5cc8..8dd34ea7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java @@ -121,8 +121,8 @@ public class CollectDataController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(service.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index a6015128..054a3849 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -102,8 +102,8 @@ public class DeleteBrandRunController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(deleteBrandRunService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java index 5a70605e..25a270e2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java @@ -177,8 +177,8 @@ public class PatrolDeleteController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(patrolDeleteTaskService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index d57e0090..17aa730e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -279,8 +279,8 @@ public class PriceTrackController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(priceTrackTaskService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java index 21cfe9f1..69cf1e56 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java @@ -177,8 +177,8 @@ public class ProductRiskResolveController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(productRiskTaskService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java index bf5b81b4..93e22316 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java @@ -108,8 +108,8 @@ public class PublishController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。user_id 可省略,传入时仅返回该用户的任务。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。user_id 可省略,传入时仅返回该用户的任务。") public ApiResponse progressLight( @Valid @RequestBody TaskProgressLightRequest request) { return ApiResponse.success(publishTaskService.progressLight(request.getUserId(), request.getTaskIds())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java index 674096d6..4c2f1aca 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java @@ -147,8 +147,8 @@ public class QueryAsinTaskController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(queryAsinTaskService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java index fa677d49..42cd3093 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java @@ -161,8 +161,8 @@ public class ShopDataCrawlTaskController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(taskService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java index 767875a4..5e2555f5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java @@ -201,8 +201,8 @@ public class ShopMatchController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(shopMatchTaskService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java index 215a162a..280ffcdc 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java @@ -128,7 +128,7 @@ public class SimilarAsinController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody SimilarAsinTaskLightRequest request) { // 归属过滤:只查询属于该用户的任务(userId 未传时保持原行为,兼容未升级调用方) return ApiResponse.success(service.progressLight(progressOwnershipSupport diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java index 8df360db..2d8dfe79 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java @@ -1,9 +1,35 @@ package com.nanri.aiimage.modules.task.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.dto.TaskRowsVersionDto; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; @Mapper public interface FileResultMapper extends BaseMapper { + + /** + * 各任务结果行的版本("行数据按需拉取"的变更信号)。 + * + *

latestRowAt 取 biz_file_result.updated_at(V125 新增,列定义 ON UPDATE CURRENT_TIMESTAMP), + * 原地更新(result_file_url / row_count 等)与新插入都会刷新它;rowCount 用于识别行被删除。 + * 前端据 (latestRowAt, rowCount) 判断该任务的行数据是否变化,未变就不必再拉带行明细的重型 batch。 + * GROUP BY 走 (task_id) 索引,每任务一行。 + */ + @Select(""" + + """) + List selectRowsUpdatedAtByTaskIds(@Param("moduleType") String moduleType, + @Param("taskIds") List taskIds); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskRowsVersionDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskRowsVersionDto.java new file mode 100644 index 00000000..a4a90379 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskRowsVersionDto.java @@ -0,0 +1,12 @@ +package com.nanri.aiimage.modules.task.model.dto; + +import java.time.LocalDateTime; + +/** + * 任务结果行的版本信号(「行数据按需拉取」用,2026-09 审查 F5 后续)。 + * + *

latestRowAt 是该任务结果行的最后变更时间(biz_file_result.updated_at,见 V125), + * rowCount 是行数——两者一起构成版本:既能发现行内容被原地更新,也能发现行被删除。 + */ +public record TaskRowsVersionDto(Long taskId, LocalDateTime latestRowAt, Long rowCount) { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java index aed5a5c5..9c67b292 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java @@ -1,6 +1,8 @@ package com.nanri.aiimage.modules.task.model.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -26,4 +28,13 @@ public class FileResultEntity { private String errorMessage; private Long userId; private LocalDateTime createdAt; + /** + * 最后变更时间(V125 新增):由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP), + * 作为「该任务结果行是否变化」的版本信号(progress/light 的 rowsVersion)。 + * + *

禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时**不会**触发自动更新, + * 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结版本、让前端漏刷新。 + */ + @TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightVo.java index 358fd465..ff50ca02 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightVo.java @@ -25,4 +25,10 @@ public class TaskProgressLightVo { private Boolean fileReady; @Schema(description = "最后更新时间,ISO 本地时间字符串。", example = "2026-04-26T10:05:00") private String updatedAt; + /** + * 结果行版本(该任务 biz_file_result 行 MAX(updated_at) 的毫秒值,无行时为 null)。 + * 前端据此判断"行数据是否有变化":版本未变就不必再拉取带行明细的重型 batch(行数据按需拉取)。 + */ + @Schema(description = "结果行版本(毫秒),用于行数据按需拉取。", example = "1757788800000") + private String rowsVersion; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssembler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssembler.java index 69592a1f..7251e03d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssembler.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssembler.java @@ -1,7 +1,9 @@ package com.nanri.aiimage.modules.task.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.dto.TaskRowsVersionDto; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @@ -9,6 +11,7 @@ import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightVo; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.time.ZoneId; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -28,6 +31,7 @@ public class TaskProgressLightAssembler { public static final int MAX_TASK_IDS = 200; private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; private final TaskFileJobService taskFileJobService; public TaskProgressLightBatchVo assemble(String moduleType, Long userId, List taskIds) { @@ -41,7 +45,9 @@ public class TaskProgressLightAssembler { return vo; } LambdaQueryWrapper query = new LambdaQueryWrapper() + // module_type 必须选中:下面用它做模块过滤,漏选则 getModuleType() 恒为 null → 所有任务被判 missing .select(FileTaskEntity::getId, + FileTaskEntity::getModuleType, FileTaskEntity::getStatus, FileTaskEntity::getUpdatedAt) .in(FileTaskEntity::getId, normalizedIds); @@ -55,6 +61,20 @@ public class TaskProgressLightAssembler { } } Map jobMap = taskFileJobService.findAssembleJobsByTaskIds(moduleType, new ArrayList<>(taskMap.keySet())); + // 行数据版本:一次 GROUP BY 拿回每个任务结果行的(最后变更时间, 行数),作为行数据按需拉取的信号 + Map rowsVersions = new LinkedHashMap<>(); + if (!taskMap.isEmpty()) { + for (TaskRowsVersionDto version : fileResultMapper.selectRowsUpdatedAtByTaskIds( + moduleType, new ArrayList<>(taskMap.keySet()))) { + if (version == null || version.taskId() == null || version.latestRowAt() == null) { + continue; + } + // 编码为「毫秒#行数」:行内容被原地更新与行被删除都会改变该串 + rowsVersions.put(version.taskId(), version.latestRowAt() + .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + "#" + (version.rowCount() == null ? 0L : version.rowCount())); + } + } for (Long taskId : normalizedIds) { FileTaskEntity task = taskMap.get(taskId); if (task == null) { @@ -69,6 +89,7 @@ public class TaskProgressLightAssembler { item.setFileStatus(job == null ? null : job.getStatus()); item.setFileError(job == null ? null : job.getErrorMessage()); item.setUpdatedAt(task.getUpdatedAt() == null ? null : task.getUpdatedAt().toString()); + item.setRowsVersion(rowsVersions.get(taskId)); vo.getItems().add(item); } return vo; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java index 0176da4f..92885519 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java @@ -117,8 +117,8 @@ public class WithdrawTaskController { @PostMapping("/tasks/progress/light") @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", - description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," - + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt/rowsVersion)," + + "不返回行明细内容(仅多一次结果行版本聚合查询),响应体更小;旧 progress/batch 端点保留不动。") public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { // 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查) return ApiResponse.success(withdrawTaskService.progressLight(request.getTaskIds(), request.getUserId())); diff --git a/backend-java/src/main/resources/db/V125__file_result_updated_at.sql b/backend-java/src/main/resources/db/V125__file_result_updated_at.sql new file mode 100644 index 00000000..d7fb3527 --- /dev/null +++ b/backend-java/src/main/resources/db/V125__file_result_updated_at.sql @@ -0,0 +1,29 @@ +-- V125: biz_file_result 增加 updated_at 列(结果行最后变更时间) +-- +-- 背景:前端「行数据按需拉取」需要「该任务的结果行是否变化」这一可靠信号—— +-- progress/light 端点回传结果行版本,前端版本未变就不拉带行明细的重型 batch。 +-- 该表原本只有 created_at:而**原地更新**(result_file_url / row_count / error_message 等, +-- 全项目 20+ 处 updateById)不会刷新任何时间戳,created_at 无法充当版本信号。 +-- +-- 列由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP); +-- Java 侧 FileResultEntity.updatedAt 标注 insertStrategy=NEVER / updateStrategy=NEVER, +-- 保证 MyBatis-Plus 永不显式写它。这是必要的:MySQL 规则是「UPDATE 语句显式给某列赋值时不触发 +-- 该列的自动更新」,而本项目写回模式多为 selectById → 改字段 → updateById(实体上带着旧值), +-- 不禁写就会把旧值写回去,版本信号随之失效。 +-- +-- 风险:ADD COLUMN 走 INSTANT/INPLACE,生产该表仅数百行,秒级完成;建议低峰执行。 +-- 回滚:ALTER TABLE biz_file_result DROP COLUMN updated_at; + +SET @db_name = DATABASE(); + +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'biz_file_result' AND COLUMN_NAME = 'updated_at' +); +SET @sql := IF(@col_exists = 0, + 'ALTER TABLE biz_file_result ADD COLUMN updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''最后变更时间'' AFTER created_at', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/TaskProgressLightContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/TaskProgressLightContractTest.java index 38cb2917..368a947e 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/TaskProgressLightContractTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/TaskProgressLightContractTest.java @@ -25,7 +25,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class TaskProgressLightContractTest { private static final Set WHITELIST = new TreeSet<>(Set.of( - "taskId", "status", "statusCode", "fileStatus", "fileError", "fileReady", "updatedAt")); + // rowsVersion:结果行版本(「行数据按需拉取」信号,2026-09-14 加入),仍是轻量字段、不含行内容 + "taskId", "status", "statusCode", "fileStatus", "fileError", "fileReady", "updatedAt", "rowsVersion")); private final ObjectMapper objectMapper = new ObjectMapper(); @@ -112,8 +113,9 @@ class TaskProgressLightContractTest { @Test void commonShapeWhitelistOrderedDocumented() { - // 回归门禁:与 spec 06 §2 白名单一致(taskId/status/statusCode?/fileStatus?/fileError?/fileReady?/updatedAt) - assertEquals(List.of("fileError", "fileReady", "fileStatus", "status", "statusCode", "taskId", "updatedAt"), + // 回归门禁:与 spec 06 §2 白名单一致(taskId/status/statusCode?/fileStatus?/fileError?/fileReady?/updatedAt + // + 2026-09-14 新增 rowsVersion:结果行版本,行数据按需拉取的信号,仍是轻量字段) + assertEquals(List.of("fileError", "fileReady", "fileStatus", "rowsVersion", "status", "statusCode", "taskId", "updatedAt"), new java.util.ArrayList<>(WHITELIST)); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultFilePhaseContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultFilePhaseContractTest.java index bd3b9ad7..0fea0477 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultFilePhaseContractTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/contract/ResultFilePhaseContractTest.java @@ -120,7 +120,8 @@ class ResultFilePhaseContractTest { assertTrue(root.containsKey("items"), "响应必须含 items"); @SuppressWarnings("unchecked") Map first = ((java.util.List>) root.get("items")).get(0); - assertEquals(Set.of("taskId", "status", "statusCode", "fileStatus", "fileError", "fileReady", "updatedAt"), + assertEquals(Set.of("taskId", "status", "statusCode", "fileStatus", "fileError", "fileReady", "updatedAt", + "rowsVersion"), first.keySet(), "light 响应键集必须精确匹配白名单(无明细/payload/result 内容)"); assertEquals(Boolean.TRUE, first.get("fileReady")); assertEquals("SUCCESS", first.get("fileStatus")); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssemblerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssemblerTest.java index ded38f98..39895bbd 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssemblerTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssemblerTest.java @@ -3,7 +3,9 @@ package com.nanri.aiimage.modules.task.service; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.dto.TaskRowsVersionDto; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @@ -45,9 +47,10 @@ class TaskProgressLightAssemblerTest { } private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class); + private final FileResultMapper fileResultMapper = mock(FileResultMapper.class); private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); private final TaskProgressLightAssembler assembler = - new TaskProgressLightAssembler(fileTaskMapper, taskFileJobService); + new TaskProgressLightAssembler(fileTaskMapper, fileResultMapper, taskFileJobService); private static FileTaskEntity task(long id, String moduleType, String status, LocalDateTime updatedAt) { FileTaskEntity t = new FileTaskEntity(); @@ -203,4 +206,85 @@ class TaskProgressLightAssemblerTest { assertTrue(item.getFileReady() != null); assertTrue(item.getUpdatedAt() != null); } + + /** 行数据按需拉取:light 端点回传结果行版本,格式「最后变更时间毫秒#行数」,无结果行时为 null。 */ + @Test + void rowsVersionFromResultRows() { + stubTasks(List.of( + task(1L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 1)), + task(2L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 2)))); + stubJobs(Map.of()); + LocalDateTime rowsUpdatedAt = LocalDateTime.of(2026, 8, 1, 11, 30, 5); + when(fileResultMapper.selectRowsUpdatedAtByTaskIds(eq("publish"), anyList())) + .thenReturn(List.of(new TaskRowsVersionDto(1L, rowsUpdatedAt, 3L))); + + TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L)); + + String expected = rowsUpdatedAt + .atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + "#3"; + assertEquals(expected, vo.getItems().get(0).getRowsVersion()); + assertNull(vo.getItems().get(1).getRowsVersion(), "没有结果行的任务版本必须为 null"); + verify(fileResultMapper).selectRowsUpdatedAtByTaskIds(eq("publish"), anyList()); + } + + /** 行数变化(行被删除)也要体现为版本变化;行数为空按 0 处理。 */ + @Test + void rowsVersionCountsDeletedRows() { + stubTasks(List.of(task(1L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 1)))); + stubJobs(Map.of()); + LocalDateTime rowsUpdatedAt = LocalDateTime.of(2026, 8, 1, 11, 30, 5); + + when(fileResultMapper.selectRowsUpdatedAtByTaskIds(eq("publish"), anyList())) + .thenReturn(List.of(new TaskRowsVersionDto(1L, rowsUpdatedAt, 5L))); + String withFive = assembler.assemble("publish", null, List.of(1L)).getItems().get(0).getRowsVersion(); + + when(fileResultMapper.selectRowsUpdatedAtByTaskIds(eq("publish"), anyList())) + .thenReturn(List.of(new TaskRowsVersionDto(1L, rowsUpdatedAt, 4L))); + String withFour = assembler.assemble("publish", null, List.of(1L)).getItems().get(0).getRowsVersion(); + + assertFalse(withFive.equals(withFour), "同一时间戳下行数变化必须产生不同版本"); + + when(fileResultMapper.selectRowsUpdatedAtByTaskIds(eq("publish"), anyList())) + .thenReturn(List.of(new TaskRowsVersionDto(1L, rowsUpdatedAt, null))); + assertEquals(String.valueOf(rowsUpdatedAt + .atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()) + "#0", + assembler.assemble("publish", null, List.of(1L)).getItems().get(0).getRowsVersion(), + "行数缺失按 0 计数"); + } + + /** 任务全不存在时不应查结果行(避免无谓查询)。 */ + @Test + void rowsVersionQuerySkippedWhenNoTasks() { + stubTasks(List.of()); + stubJobs(Map.of()); + + TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(999L)); + + assertTrue(vo.getItems().isEmpty()); + verify(fileResultMapper, org.mockito.Mockito.never()) + .selectRowsUpdatedAtByTaskIds(any(), anyList()); + } + + /** + * 守卫:任务查询的选中列必须包含 module_type。 + * + *

2026-09-14 线上事故——装配器用 {@code moduleType.equals(task.getModuleType())} 做模块过滤, + * 但 select 只取 id/status/updated_at → getModuleType() 恒为 null → **所有任务被判 missing**。 + * 单测此前全绿是因为 mock 返回的是完整实体、不模拟列投影;第七批把 light 接进轮询后, + * 消费方把 missing 当"任务已消失"(跟价页直接返回 FAILED),影响真实任务。 + */ + @Test + void taskQueryMustSelectModuleType() { + stubTasks(List.of(task(1L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 1)))); + stubJobs(Map.of()); + + assembler.assemble("publish", null, List.of(1L)); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(fileTaskMapper).selectList(captor.capture()); + String select = captor.getValue().getSqlSelect(); + assertTrue(select != null && select.contains("module_type"), + "选中列必须含 module_type,否则模块过滤把所有任务判成 missing(线上真实事故),实际=" + select); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightPermissionTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightPermissionTest.java index 32dfb14f..54c9a8df 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightPermissionTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightPermissionTest.java @@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.task.service; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; @@ -33,9 +34,10 @@ import static org.mockito.Mockito.when; class TaskProgressLightPermissionTest { private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class); + private final FileResultMapper fileResultMapper = mock(FileResultMapper.class); private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); private final TaskProgressLightAssembler assembler = - new TaskProgressLightAssembler(fileTaskMapper, taskFileJobService); + new TaskProgressLightAssembler(fileTaskMapper, fileResultMapper, taskFileJobService); private final List taskDb = new ArrayList<>(); diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index 25d84451..948def1c 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -113,6 +113,8 @@ import { passGuard } from '@/shared/dispatch-guard-ui' import { formatDateTime } from '@/shared/utils/datetime' import { uploadPathsToJava } from '@/shared/utils/upload-to-java' import { runBatchDelete } from '@/shared/utils/batch-delete' +import { getModuleProgressLight } from '@/shared/api/progress-light.ts' +import { createRowsVersionTracker } from '@/shared/api/task-progress-polling.ts' const selectedFileNames = ref([]) const uploadedFiles = ref([]) @@ -545,6 +547,9 @@ function scheduleNextPoll(immediate = false) { else pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs()) } +/** 结果行版本跟踪(审查 F5 后续):版本未变的任务跳过行明细拉取 */ +const rowsVersionTracker = createRowsVersionTracker() + async function refreshTaskProgress() { if (pollingInFlight.value || (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length)) { if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) stopPolling() @@ -556,7 +561,23 @@ async function refreshTaskProgress() { let shouldRefreshHistory = false const taskIds = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])) if (taskIds.length) { - const batch = await getAppearancePatentTaskProgressBatch(taskIds, { force: pendingFileTaskIds.value.length > 0 }) + // 行数据按需拉取(审查 F5 后续):等结果文件(pending)时必须强刷;其余情况版本未变的任务跳过 + let heavyIds = taskIds + const mustForce = pendingFileTaskIds.value.length > 0 + if (!mustForce) { + try { + const light = await getModuleProgressLight('appearancePatent', taskIds) + heavyIds = rowsVersionTracker.selectTasksNeedingRows(light.items || [], taskIds) + if (!heavyIds.length && !(light.missingTaskIds || []).length) { + // 行数据与状态都没变:本轮不拉行明细,直接结束 + return + } + } catch { + // 轻量端点不可用(老后端/网络)→ 照旧全量拉取,行为与原来一致 + heavyIds = taskIds + } + } + const batch = await getAppearancePatentTaskProgressBatch(heavyIds, { force: mustForce }) for (const detail of batch.items || []) { const task = detail.task if (!task?.id) continue diff --git a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue index 9afc7eb5..ce3469e6 100644 --- a/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandPatrolDeleteTab.vue @@ -285,6 +285,8 @@ import { useHistoryPolling } from '@/shared/composables/useHistoryPolling' import { runBatchDelete } from '@/shared/utils/batch-delete' import { isRecordMissingError } from '@/shared/utils/task-queue-state.ts' import { useTablePaging } from '@/shared/composables/useTablePaging.ts' +import { getModuleProgressLight } from '@/shared/api/progress-light.ts' +import { createRowsVersionTracker } from '@/shared/api/task-progress-polling.ts' const MAX_TRANSIENT_ERRORS = 30; /** 任务终态后等待结果文件(Java 侧异步生成)的最大轮次,12 × 10s ≈ 2 分钟 */ @@ -797,6 +799,9 @@ function upsertHistoryItems(incoming: PatrolDeleteHistoryItem[]) { mergeHistoryProgressItems(incoming); } +/** 结果行版本跟踪:版本未变的任务不再拉取行明细(F5 后续) */ +const rowsVersionTracker = createRowsVersionTracker() + async function refreshActiveTaskProgress(taskIds?: number[]) { const ids = Array.from( new Set( @@ -809,9 +814,27 @@ async function refreshActiveTaskProgress(taskIds?: number[]) { if (!ids.length) { return []; } - const batch = await getPatrolDeleteTaskProgressBatch(ids); + // 行数据按需拉取(审查 F5 后续):先问轻量端点拿"结果行版本",版本未变的任务跳过重型 batch + let heavyIds = ids; + let missingIds: number[] = []; + try { + const light = await getModuleProgressLight('patrolDelete', ids); + heavyIds = rowsVersionTracker.selectTasksNeedingRows(light.items || [], ids); + missingIds = light.missingTaskIds || []; + if (!heavyIds.length) { + if (missingIds.length) { + await loadHistory(); + } + return []; + } + } catch { + // 轻量端点不可用(老后端/网络)→ 照旧全量拉取,行为与原来一致 + heavyIds = ids; + } + const batch = await getPatrolDeleteTaskProgressBatch(heavyIds); + missingIds = Array.from(new Set([...missingIds, ...(batch.missingTaskIds || [])])); mergeHistoryProgressItems(batch.items || []); - if ((batch.missingTaskIds || []).length) { + if (missingIds.length) { await loadHistory(); } return batch.missingTaskIds || []; diff --git a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue index b2ab5a78..0b973975 100644 --- a/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandQueryAsinTab.vue @@ -227,6 +227,8 @@ import { useHistoryPolling } from '@/shared/composables/useHistoryPolling' import { runBatchDelete } from '@/shared/utils/batch-delete' import { isRecordMissingError, parseTaskStartTimes, removeTaskStartTime, upsertTaskStartTime } from '@/shared/utils/task-queue-state.ts' import { useTablePaging } from '@/shared/composables/useTablePaging.ts' +import { getModuleProgressLight } from '@/shared/api/progress-light.ts' +import { createRowsVersionTracker } from '@/shared/api/task-progress-polling.ts' const MAX_TRANSIENT_ERRORS = 30; const ziniaoVersion = useZiniaoVersion(); @@ -705,6 +707,9 @@ function mergeHistoryProgressItems(incoming: QueryAsinHistoryItem[]) { historyItems.value = merged; } +/** 结果行版本跟踪:版本未变的任务不再拉取行明细(F5 后续) */ +const rowsVersionTracker = createRowsVersionTracker() + async function refreshActiveTaskProgress(taskIds?: number[]) { const ids = Array.from( new Set( @@ -717,9 +722,28 @@ async function refreshActiveTaskProgress(taskIds?: number[]) { if (!ids.length) { return; } - const batch = await getQueryAsinTaskProgressBatch(ids); + // 行数据按需拉取(审查 F5 后续):先问轻量端点拿"结果行版本",版本未变的任务跳过重型 batch + let heavyIds = ids; + let missingIds: number[] = []; + try { + const light = await getModuleProgressLight('queryAsin', ids); + heavyIds = rowsVersionTracker.selectTasksNeedingRows(light.items || [], ids); + missingIds = light.missingTaskIds || []; + if (!heavyIds.length) { + if (missingIds.length) { + await loadHistory(); + reconcileActiveQueueTaskWithHistory(); + } + return; + } + } catch { + // 轻量端点不可用(老后端/网络)→ 照旧全量拉取,行为与原来一致 + heavyIds = ids; + } + const batch = await getQueryAsinTaskProgressBatch(heavyIds); + missingIds = Array.from(new Set([...missingIds, ...(batch.missingTaskIds || [])])); mergeHistoryProgressItems(batch.items || []); - if ((batch.missingTaskIds || []).length) { + if (missingIds.length) { await loadHistory(); reconcileActiveQueueTaskWithHistory(); } diff --git a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue index 46146b69..2192a50a 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue @@ -162,6 +162,8 @@ import { } from '@/shared/api/java-modules' import { runBatchDelete } from '@/shared/utils/batch-delete' import { useTablePaging } from '@/shared/composables/useTablePaging.ts' +import { getModuleProgressLight } from '@/shared/api/progress-light.ts' +import { createRowsVersionTracker } from '@/shared/api/task-progress-polling.ts' const COUNTRY_OPTIONS = [ { code: 'UK', label: '英国' }, @@ -399,7 +401,21 @@ function mergeProgress(detail: ShopDataCrawlTaskDetailVo) { function isTaskDetail(row: ShopDataCrawlTaskDetailVo | ShopDataCrawlHistoryItem): row is ShopDataCrawlTaskDetailVo { return 'task' in row || 'items' in row } +/** 结果行版本跟踪(F5 后续):版本未变的任务跳过行明细拉取 */ +const rowsVersionTracker = createRowsVersionTracker() + async function refreshTaskProgress(taskId: number) { + // 行数据按需拉取(审查 F5 后续):该任务结果行版本未变时跳过重型 batch;任务已被删除则照旧走原路径 + try { + const light = await getModuleProgressLight('shopDataCrawl', [taskId]) + const missing = light.missingTaskIds || [] + if (!missing.length && !rowsVersionTracker.selectTasksNeedingRows(light.items || [], [taskId]).length) { + // 返回 true = 任务仍存在(调用方以此为「任务消失→FAILED」判据,不能返回 undefined) + return true + } + } catch { + /* 轻量端点不可用 → 照旧全量拉取 */ + } const batch = await getShopDataCrawlTaskProgressBatch([taskId]) for (const row of batch.items || []) { if (isTaskDetail(row)) { diff --git a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue index f0feda53..7dd31ef6 100644 --- a/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandWithdrawTab.vue @@ -267,6 +267,8 @@ import { useHistoryPolling } from '@/shared/composables/useHistoryPolling' import { runBatchDelete } from '@/shared/utils/batch-delete' import { isRecordMissingError, parseTaskStartTimes, removeTaskStartTime, upsertTaskStartTime } from '@/shared/utils/task-queue-state.ts' import { useTablePaging } from '@/shared/composables/useTablePaging.ts' +import { getModuleProgressLight } from '@/shared/api/progress-light.ts' +import { createRowsVersionTracker } from '@/shared/api/task-progress-polling.ts' const MAX_TRANSIENT_ERRORS = 30; const ziniaoVersion = useZiniaoVersion(); @@ -829,6 +831,9 @@ function mergeHistoryProgressItems(incoming: WithdrawHistoryItem[]) { historyItems.value = merged; } +/** 结果行版本跟踪:版本未变的任务不再拉取行明细(F5 后续) */ +const rowsVersionTracker = createRowsVersionTracker() + async function refreshActiveTaskProgress(taskIds?: number[]) { const ids = Array.from( new Set( @@ -841,9 +846,28 @@ async function refreshActiveTaskProgress(taskIds?: number[]) { if (!ids.length) { return; } - const batch = await getWithdrawTaskProgressBatch(ids); + // 行数据按需拉取(审查 F5 后续):先问轻量端点拿"结果行版本",版本未变的任务跳过重型 batch + let heavyIds = ids; + let missingIds: number[] = []; + try { + const light = await getModuleProgressLight('withdraw', ids); + heavyIds = rowsVersionTracker.selectTasksNeedingRows(light.items || [], ids); + missingIds = light.missingTaskIds || []; + if (!heavyIds.length) { + if (missingIds.length) { + await loadHistory(); + reconcileActiveQueueTaskWithHistory(); + } + return; + } + } catch { + // 轻量端点不可用(老后端/网络)→ 照旧全量拉取,行为与原来一致 + heavyIds = ids; + } + const batch = await getWithdrawTaskProgressBatch(heavyIds); + missingIds = Array.from(new Set([...missingIds, ...(batch.missingTaskIds || [])])); mergeHistoryProgressItems(batch.items || []); - if ((batch.missingTaskIds || []).length) { + if (missingIds.length) { await loadHistory(); reconcileActiveQueueTaskWithHistory(); } diff --git a/frontend-vue/src/shared/api/progress-light.ts b/frontend-vue/src/shared/api/progress-light.ts index 5c634548..8e6c82b7 100644 --- a/frontend-vue/src/shared/api/progress-light.ts +++ b/frontend-vue/src/shared/api/progress-light.ts @@ -42,6 +42,8 @@ export interface TaskProgressLightItem { fileError?: string | null fileReady?: boolean | null updatedAt?: string | null + /** 结果行版本(「最后变更时间毫秒#行数」):未变说明行数据没动过,可跳过重型 batch。 */ + rowsVersion?: string | null } export interface GetModuleProgressLightOptions { diff --git a/frontend-vue/src/shared/api/task-progress-polling.ts b/frontend-vue/src/shared/api/task-progress-polling.ts index ddd6fc8d..8a2099ec 100644 --- a/frontend-vue/src/shared/api/task-progress-polling.ts +++ b/frontend-vue/src/shared/api/task-progress-polling.ts @@ -61,6 +61,77 @@ export interface PollingBatchLike { missingTaskIds?: number[] } +/** + * 行数据按需拉取(审查 F5 后续): + * 轻量响应里的 rowsVersion 是该任务**结果行**的版本(最后变更时间毫秒 + 行数,后端一次 GROUP BY 查出)。 + * 版本没变说明行数据没动过,就不必再拉带行明细的重型 batch。 + * + * 变更信号是**复合**的:行版本之外还看 status / fileStatus / fileReady——任务收尾时常见 + * 「行已经写完、之后任务才置成功」或「结果文件才就绪」,只看行版本会把界面卡在旧状态。 + * + * 安全兜底(保证零回归): + * - 四项信号全空(老后端 / 字段缺失)→ 视为拿不到信号,该任务照旧每轮拉取; + * - 轻量请求本身失败时调用方回退为全量拉取(见各页 catch 分支)。 + */ +export interface TaskProgressChangeSignal { + taskId?: number + rowsVersion?: string | null + status?: string | null + fileStatus?: string | null + fileReady?: boolean | null +} + +/** 组装变更信号;taskId 非法或四项信号全空时返回 null(= 拿不到信号,必须拉取)。 */ +function changeSignatureOf(raw: unknown): { taskId: number; signature: string } | null { + const item = raw as TaskProgressChangeSignal | null + if (!item || typeof item.taskId !== 'number' || !(item.taskId > 0)) return null + const rowsVersion = typeof item.rowsVersion === 'string' ? item.rowsVersion : '' + const status = typeof item.status === 'string' ? item.status : '' + const fileStatus = typeof item.fileStatus === 'string' ? item.fileStatus : '' + const fileReady = item.fileReady === true ? '1' : item.fileReady === false ? '0' : '' + if (!rowsVersion && !status && !fileStatus && !fileReady) return null + return { taskId: item.taskId, signature: [rowsVersion, status, fileStatus, fileReady].join('|') } +} + +export function createRowsVersionTracker() { + const seen = new Map() + return { + /** + * 返回"需要拉取行明细"的任务 id(信号变化 / 拿不到信号的)。 + * 同时记录本轮看到的信号(便于下轮比较)。 + */ + selectTasksNeedingRows(items: unknown[] | null | undefined, requestedIds: number[]): number[] { + const signatureById = new Map() + for (const raw of items || []) { + const parsed = changeSignatureOf(raw) + if (parsed) signatureById.set(parsed.taskId, parsed.signature) + } + const needRows: number[] = [] + for (const taskId of requestedIds) { + const signature = signatureById.get(taskId) + if (signature === undefined) { + needRows.push(taskId) + continue + } + if (seen.get(taskId) !== signature) { + needRows.push(taskId) + } + } + for (const [taskId, signature] of signatureById) { + seen.set(taskId, signature) + } + return needRows + }, + /** 任务结束/被删除时清掉记录,避免 Map 无界增长。 */ + forget(taskId: number): void { + seen.delete(taskId) + }, + reset(): void { + seen.clear() + }, + } +} + export interface PollingProgressOptions { /** 重型端点兜底(轻量端点异常或返回空时调用),不传则不兜底 */ fallback?: () => Promise @@ -83,6 +154,23 @@ export function pickPollingBatch( return { items: items as unknown as T[], missingTaskIds: missing } } +/** + * light 是否把**全部**请求任务都判成"不存在"。 + * + *

整体性结论风险最大:消费方把 missing 当"任务已消失"(跟价页直接返回 FAILED)。 + * 2026-09-14 线上即出现过 light 因后端漏选过滤字段而恒判 missing,故这种结论要用重型端点复核。 + */ +export function lightClaimsAllTasksMissing( + picked: PollingBatchLike | null | undefined, + requestedIds: number[], +): boolean { + const ids = (requestedIds || []).filter((id) => typeof id === 'number' && id > 0) + if (!ids.length) return false + if ((picked?.items || []).length) return false + const missing = picked?.missingTaskIds || [] + return ids.every((id) => missing.includes(id)) +} + /** * 取任务进度:轻量端点优先,异常/空结果时回退重型端点。 * 轻量端点返回 empty(既没有 items 也没有 missingTaskIds)时也走兜底—— @@ -98,6 +186,10 @@ export async function getPollingProgressBatch(light.items, light.missingTaskIds) if (picked) { + if (fallback && lightClaimsAllTasksMissing(picked, taskIds)) { + // 复核:以重型端点为准(它若也判不存在,结论一致;若返回了条目,说明 light 错了) + return fallback() + } return picked as TBatch } } catch { diff --git a/frontend-vue/tests/brand-rows-version-wiring.test.ts b/frontend-vue/tests/brand-rows-version-wiring.test.ts new file mode 100644 index 00000000..f75116ff --- /dev/null +++ b/frontend-vue/tests/brand-rows-version-wiring.test.ts @@ -0,0 +1,61 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +/** + * 「行数据按需拉取」接线守卫(审查 F5 后续): + * 5 个品牌工具页用轻量端点的结果行版本跳过重型 batch;这里守住两件最容易写错的事 + * ——①接线没被误删;②跳过分支的返回值必须符合调用方契约(曾经把 shopDataCrawl 写成裸 return, + * 而调用方 waitForTerminal 把 falsy 当「任务已消失 → FAILED」,会误杀正在跑的任务)。 + */ +const PAGES_DIR = join(dirname(fileURLToPath(import.meta.url)), '../src/pages/brand/components') + +const WIRED_PAGES = [ + 'BrandQueryAsinTab.vue', + 'BrandWithdrawTab.vue', + 'BrandPatrolDeleteTab.vue', + 'BrandShopDataCrawlTab.vue', + 'BrandAppearancePatentTab.vue', +] + +function sourceOf(page: string) { + return readFileSync(join(PAGES_DIR, page), 'utf8') +} + +test('已接线的品牌工具页仍使用结果行版本跳过重型 batch', () => { + for (const page of WIRED_PAGES) { + const source = sourceOf(page) + assert.ok(source.includes('createRowsVersionTracker'), `${page} 缺少版本跟踪器声明`) + assert.ok(source.includes('selectTasksNeedingRows'), `${page} 缺少按需拉取判断`) + assert.ok(source.includes('getModuleProgressLight'), `${page} 缺少轻量端点调用`) + } +}) + +test('轻量请求失败必须回退为全量拉取(不得因此少拉任务)', () => { + // 多任务页:catch 里把 heavyIds 还原成全部任务 + for (const page of ['BrandQueryAsinTab.vue', 'BrandWithdrawTab.vue', 'BrandPatrolDeleteTab.vue']) { + assert.match(sourceOf(page), /catch\s*\{[\s\S]{0,200}?heavyIds = ids/, `${page} 缺少轻量失败回退`) + } + assert.match(sourceOf('BrandAppearancePatentTab.vue'), /catch\s*\{[\s\S]{0,200}?heavyIds = taskIds/, + 'BrandAppearancePatentTab 缺少轻量失败回退') + + // 单任务页:catch 里不能有 return(直接落到批量拉取) + const shopDataCrawl = sourceOf('BrandShopDataCrawlTab.vue') + const catchBlock = shopDataCrawl.match(/catch\s*\{([^}]*)\}/)?.[1] ?? '' + assert.ok(catchBlock.length > 0, 'BrandShopDataCrawlTab 未找到 catch 分支') + assert.ok(!catchBlock.includes('return'), 'BrandShopDataCrawlTab 轻量失败时不得提前返回,必须照旧拉批量') +}) + +test('跳过分支的返回值符合调用方契约', () => { + const shopDataCrawl = sourceOf('BrandShopDataCrawlTab.vue') + // waitForTerminal: `if (!exists) return 'FAILED'` —— 任务仍在时必须返回 true + assert.match(shopDataCrawl, /selectTasksNeedingRows\([\s\S]{0,200}?return true/, + 'BrandShopDataCrawlTab 跳过分支必须 return true(false/undefined 会被当成任务已消失)') + + const patrolDelete = sourceOf('BrandPatrolDeleteTab.vue') + // waitForTaskTerminal: `missingTaskIds.includes(taskId)` —— 未消失时返回空数组 + assert.match(patrolDelete, /selectTasksNeedingRows\([\s\S]{0,300}?return \[\]/, + 'BrandPatrolDeleteTab 跳过分支必须返回空 missingTaskIds 数组') +}) diff --git a/frontend-vue/tests/progress-light.test.ts b/frontend-vue/tests/progress-light.test.ts index a8e646ab..b7a0c18a 100644 --- a/frontend-vue/tests/progress-light.test.ts +++ b/frontend-vue/tests/progress-light.test.ts @@ -7,6 +7,10 @@ import { type ProgressLightModule, type TaskProgressLightItem, } from '../src/shared/api/progress-light.ts' +import { + getPollingProgressBatch, + lightClaimsAllTasksMissing, +} from '../src/shared/api/task-progress-polling.ts' import type { TaskProgressBatchVo } from '../src/shared/api/types/task.ts' function setupWindow() { @@ -164,3 +168,58 @@ test('test_light_reexport', async () => { assert.equal(javaModules.getModuleProgressLight, direct.getModuleProgressLight, 'java-modules 应 re-export getModuleProgressLight') assert.equal(javaModules.TaskProgressLightItem, direct.TaskProgressLightItem, '类型应同一引用') }) + +test('test_light_all_missing_needs_heavy_recheck', async (t) => { + setupWindow() + // light 恒判 missing(2026-09-14 线上真实故障形态)→ 必须用重型端点复核,不能直接采信 + mockRequest(t, () => okResponse({ items: [], missingTaskIds: [9101] })) + let heavyCalls = 0 + const heavy = { items: [{ taskId: 9101, status: 'RUNNING' }], missingTaskIds: [] } + + const batch = await getPollingProgressBatch<{ items: unknown[]; missingTaskIds: number[] }>( + 'priceTrack', + [9101], + { fallback: () => { heavyCalls += 1; return Promise.resolve(heavy) } }, + ) + + assert.equal(heavyCalls, 1, '整体性 missing 结论必须复核') + assert.deepEqual(batch, heavy, '以重型端点结果为准') +}) + +test('test_light_all_missing_confirmed_by_heavy', async (t) => { + setupWindow() + mockRequest(t, () => okResponse({ items: [], missingTaskIds: [9102] })) + const heavy = { items: [], missingTaskIds: [9102] } + + const batch = await getPollingProgressBatch<{ items: unknown[]; missingTaskIds: number[] }>( + 'withdraw', + [9102], + { fallback: () => Promise.resolve(heavy) }, + ) + + assert.deepEqual(batch, heavy, '重型端点也判不存在时结论一致') +}) + +test('test_light_with_items_skips_recheck', async (t) => { + setupWindow() + mockRequest(t, () => okResponse({ items: [{ taskId: 9103, status: 'RUNNING' }], missingTaskIds: [] })) + let heavyCalls = 0 + + const batch = await getPollingProgressBatch<{ items: unknown[]; missingTaskIds: number[] }>( + 'shopMatch', + [9103], + { fallback: () => { heavyCalls += 1; return Promise.resolve({ items: [], missingTaskIds: [] }) } }, + ) + + assert.equal(heavyCalls, 0, '有条目时不做复核,保持省流') + assert.equal((batch.items || []).length, 1) +}) + +test('test_light_claims_all_missing_semantics', () => { + assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [1] }, [1]), true) + assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [1] }, [1, 2]), false, '部分缺失不算整体性结论') + assert.equal(lightClaimsAllTasksMissing({ items: [{ taskId: 1 }], missingTaskIds: [] }, [1]), false, '有条目即不成立') + assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [] }, [1]), false) + assert.equal(lightClaimsAllTasksMissing(null, [1]), false) + assert.equal(lightClaimsAllTasksMissing({ items: [], missingTaskIds: [1] }, []), false) +}) diff --git a/frontend-vue/tests/rows-version-tracker.test.ts b/frontend-vue/tests/rows-version-tracker.test.ts new file mode 100644 index 00000000..d3b3be84 --- /dev/null +++ b/frontend-vue/tests/rows-version-tracker.test.ts @@ -0,0 +1,89 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createRowsVersionTracker } from '../src/shared/api/task-progress-polling.ts' + +test('首次见到的任务需要拉取行数据', () => { + const tracker = createRowsVersionTracker() + + const need = tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100' }], [1]) + + assert.deepEqual(need, [1]) +}) + +test('版本未变时不再拉取,版本变化后重新拉取', () => { + const tracker = createRowsVersionTracker() + tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100' }], [1]) + + assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100' }], [1]), [], '版本相同跳过') + assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '101' }], [1]), [1], '版本变化要拉') +}) + +test('拿不到版本(老后端/字段缺失)时照旧拉取', () => { + const tracker = createRowsVersionTracker() + + assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1 }], [1]), [1]) + assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: null }], [1]), [1]) + assert.deepEqual(tracker.selectTasksNeedingRows([], [7]), [7], '响应里没有该任务也拉') +}) + +test('多任务各自独立判断', () => { + const tracker = createRowsVersionTracker() + tracker.selectTasksNeedingRows( + [{ taskId: 1, rowsVersion: 'a' }, { taskId: 2, rowsVersion: 'b' }], + [1, 2], + ) + + const need = tracker.selectTasksNeedingRows( + [{ taskId: 1, rowsVersion: 'a' }, { taskId: 2, rowsVersion: 'b2' }], + [1, 2], + ) + + assert.deepEqual(need, [2]) +}) + +test('forget 后该任务会被重新拉取(任务重跑场景)', () => { + const tracker = createRowsVersionTracker() + tracker.selectTasksNeedingRows([{ taskId: 5, rowsVersion: 'v1' }], [5]) + + tracker.forget(5) + + assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 5, rowsVersion: 'v1' }], [5]), [5]) +}) + +test('任务状态推进(行没变)也要重新拉取', () => { + const tracker = createRowsVersionTracker() + tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100', status: 'RUNNING' }], [1]) + + assert.deepEqual( + tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100', status: 'RUNNING' }], [1]), + [], + '行版本与状态都没变:跳过', + ) + assert.deepEqual( + tracker.selectTasksNeedingRows([{ taskId: 1, rowsVersion: '100', status: 'SUCCESS' }], [1]), + [1], + '行版本没变但任务已成功:必须拉取,否则界面卡在运行中', + ) +}) + +test('结果文件状态变化也要重新拉取', () => { + const tracker = createRowsVersionTracker() + tracker.selectTasksNeedingRows([{ taskId: 1, status: 'RUNNING', fileStatus: 'RUNNING', fileReady: false }], [1]) + + assert.deepEqual( + tracker.selectTasksNeedingRows([{ taskId: 1, status: 'RUNNING', fileStatus: 'RUNNING', fileReady: false }], [1]), + [], + ) + assert.deepEqual( + tracker.selectTasksNeedingRows([{ taskId: 1, status: 'RUNNING', fileStatus: 'SUCCESS', fileReady: true }], [1]), + [1], + '文件就绪变化要能触发拉取(下载按钮依赖它)', + ) +}) + +test('四项信号全空视为拿不到信号:每轮都拉取', () => { + const tracker = createRowsVersionTracker() + + assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1 }], [1]), [1]) + assert.deepEqual(tracker.selectTasksNeedingRows([{ taskId: 1 }], [1]), [1], '老后端不给信号时保持旧行为') +})