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:
2026-09-14 12:08:25 +08:00
parent 9166656673
commit 5ea52e5291
32 changed files with 641 additions and 36 deletions
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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()));
@@ -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
@@ -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_atV125 新增,列定义 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);
}
@@ -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) {
}
@@ -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;
}
@@ -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;
}
@@ -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;
@@ -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;
@@ -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));
}
@@ -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"));
@@ -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,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<>();