perf(F5+): 行数据按需拉取(结果行版本信号)+ 修复 progress/light 恒判 missing
行数据按需拉取(审查 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 全绿
This commit is contained in:
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(service.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(service.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(deleteBrandRunService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(patrolDeleteTaskService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(priceTrackTaskService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(productRiskTaskService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(
|
||||
@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(publishTaskService.progressLight(request.getUserId(), request.getTaskIds()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(queryAsinTaskService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(taskService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(shopMatchTaskService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
+1
-1
@@ -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<SimilarAsinTaskLightBatchVo> progressLight(@Valid @RequestBody SimilarAsinTaskLightRequest request) {
|
||||
// 归属过滤:只查询属于该用户的任务(userId 未传时保持原行为,兼容未升级调用方)
|
||||
return ApiResponse.success(service.progressLight(progressOwnershipSupport
|
||||
|
||||
+26
@@ -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<FileResultEntity> {
|
||||
|
||||
/**
|
||||
* 各任务结果行的版本("行数据按需拉取"的变更信号)。
|
||||
*
|
||||
* <p>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("""
|
||||
<script>
|
||||
SELECT task_id AS taskId, MAX(updated_at) AS latestRowAt, COUNT(*) AS rowCount
|
||||
FROM biz_file_result
|
||||
WHERE module_type = #{moduleType}
|
||||
AND task_id IN
|
||||
<foreach item="taskId" collection="taskIds" open="(" separator="," close=")">#{taskId}</foreach>
|
||||
GROUP BY task_id
|
||||
</script>
|
||||
""")
|
||||
List<TaskRowsVersionDto> selectRowsUpdatedAtByTaskIds(@Param("moduleType") String moduleType,
|
||||
@Param("taskIds") List<Long> taskIds);
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.nanri.aiimage.modules.task.model.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 任务结果行的版本信号(「行数据按需拉取」用,2026-09 审查 F5 后续)。
|
||||
*
|
||||
* <p>latestRowAt 是该任务结果行的最后变更时间(biz_file_result.updated_at,见 V125),
|
||||
* rowCount 是行数——两者一起构成版本:既能发现行内容被原地更新,也能发现行被删除。
|
||||
*/
|
||||
public record TaskRowsVersionDto(Long taskId, LocalDateTime latestRowAt, Long rowCount) {
|
||||
}
|
||||
+11
@@ -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)。
|
||||
*
|
||||
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时**不会**触发自动更新,
|
||||
* 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结版本、让前端漏刷新。
|
||||
*/
|
||||
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
+6
@@ -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;
|
||||
}
|
||||
|
||||
+21
@@ -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<Long> taskIds) {
|
||||
@@ -41,7 +45,9 @@ public class TaskProgressLightAssembler {
|
||||
return vo;
|
||||
}
|
||||
LambdaQueryWrapper<FileTaskEntity> query = new LambdaQueryWrapper<FileTaskEntity>()
|
||||
// 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<Long, TaskFileJobEntity> jobMap = taskFileJobService.findAssembleJobsByTaskIds(moduleType, new ArrayList<>(taskMap.keySet()));
|
||||
// 行数据版本:一次 GROUP BY 拿回每个任务结果行的(最后变更时间, 行数),作为行数据按需拉取的信号
|
||||
Map<Long, String> 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;
|
||||
|
||||
+2
-2
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
// 透传 userId:此前传 null = 不过滤归属,匿名遍历 taskId 即可读他人任务状态(2026-09 审查)
|
||||
return ApiResponse.success(withdrawTaskService.progressLight(request.getTaskIds(), request.getUserId()));
|
||||
|
||||
@@ -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;
|
||||
+5
-3
@@ -25,7 +25,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
class TaskProgressLightContractTest {
|
||||
|
||||
private static final Set<String> 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));
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -120,7 +120,8 @@ class ResultFilePhaseContractTest {
|
||||
assertTrue(root.containsKey("items"), "响应必须含 items");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> first = ((java.util.List<Map<String, Object>>) 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"));
|
||||
|
||||
+85
-1
@@ -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。
|
||||
*
|
||||
* <p>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<LambdaQueryWrapper<FileTaskEntity>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -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<FileTaskEntity> taskDb = new ArrayList<>();
|
||||
|
||||
|
||||
@@ -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<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
@@ -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
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface TaskProgressLightItem {
|
||||
fileError?: string | null
|
||||
fileReady?: boolean | null
|
||||
updatedAt?: string | null
|
||||
/** 结果行版本(「最后变更时间毫秒#行数」):未变说明行数据没动过,可跳过重型 batch。 */
|
||||
rowsVersion?: string | null
|
||||
}
|
||||
|
||||
export interface GetModuleProgressLightOptions {
|
||||
|
||||
@@ -61,6 +61,77 @@ export interface PollingBatchLike<T> {
|
||||
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<number, string>()
|
||||
return {
|
||||
/**
|
||||
* 返回"需要拉取行明细"的任务 id(信号变化 / 拿不到信号的)。
|
||||
* 同时记录本轮看到的信号(便于下轮比较)。
|
||||
*/
|
||||
selectTasksNeedingRows(items: unknown[] | null | undefined, requestedIds: number[]): number[] {
|
||||
const signatureById = new Map<number, string>()
|
||||
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<TBatch> {
|
||||
/** 重型端点兜底(轻量端点异常或返回空时调用),不传则不兜底 */
|
||||
fallback?: () => Promise<TBatch>
|
||||
@@ -83,6 +154,23 @@ export function pickPollingBatch<T>(
|
||||
return { items: items as unknown as T[], missingTaskIds: missing }
|
||||
}
|
||||
|
||||
/**
|
||||
* light 是否把**全部**请求任务都判成"不存在"。
|
||||
*
|
||||
* <p>整体性结论风险最大:消费方把 missing 当"任务已消失"(跟价页直接返回 FAILED)。
|
||||
* 2026-09-14 线上即出现过 light 因后端漏选过滤字段而恒判 missing,故这种结论要用重型端点复核。
|
||||
*/
|
||||
export function lightClaimsAllTasksMissing(
|
||||
picked: PollingBatchLike<unknown> | 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<TBatch extends PollingBatchLike<un
|
||||
const light = await getModuleProgressLight(module, taskIds)
|
||||
const picked = pickPollingBatch<unknown>(light.items, light.missingTaskIds)
|
||||
if (picked) {
|
||||
if (fallback && lightClaimsAllTasksMissing(picked, taskIds)) {
|
||||
// 复核:以重型端点为准(它若也判不存在,结论一致;若返回了条目,说明 light 错了)
|
||||
return fallback()
|
||||
}
|
||||
return picked as TBatch
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -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 数组')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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], '老后端不给信号时保持旧行为')
|
||||
})
|
||||
Reference in New Issue
Block a user