Compare commits

...

2 Commits

Author SHA1 Message Date
huangzd1997 52c59755ab 修复货源查询 404 行被过滤且状态列置空:Python 404 的 ASIN 保留导出并标记「ASIN未找到」
Build Backend JAR / build (push) Has been cancelled
线上现象:货源采集任务 26402(1212.xlsx)结果文件中存在 Python 端
404 Not Found 的行没有图,且状态列空白——这些行实际已完成采集但被后端过滤。

根因链:
1. Python 404 行 title/url/图全空 → isMissingRequired 必填校验拦截 →
   markRowsFailed(status=FAILED,error 保留 "404 Not Found");
2. isExportableResultRow 对「其它错误需带 title/url/图」的判断把这类 404 行
   过滤掉,即使存在也被 isSilentOutputFailure 把状态列置空。

修复:
- 新增 isAsinNotFound 判定(404 / not found / 商品未找到 等文案,大小写不敏感);
- resolveResultStatus 在图片判定后、rawStatus→失败 映射前插入
  「ASIN未找到」状态;isExportableResultRow 对 404 行直接放行;
- writeResultWorkbook 状态列 gate 排除 404 行(不再被置空);
- 新增 2 条单元测试(404/中文文案命中、普通技术失败不误判)。
2026-09-02 13:51:10 +08:00
huangzd1997 478233ba7a 修复采集数据结果明细 module_type 写读大小写不一致:写入端误用小写 collectdata 导致 task-50 之后所有采集任务明细行生成结果文件时读不到
线上现象:任务 26334 采集完成(去重 42/无效品牌 62/品牌拒绝 6/保留 85)
但结果文件「采集数据结果」sheet 只有表头 0 行,历史卡片「保留 0」。

根因链:
1. CollectDataResultItemBatchWriter(task-50 新增写入端)常量误写为小写
   "collectdata",与 CollectDataService.MODULE_TYPE(大写 COLLECT_DATA)不一致;
2. loadFinalRows 按大写查 biz_task_result_item 命中 0 行 → 组装空表 →
   stats.finalRowCount 被 rows.size()=0 覆盖,UI 显示保留 0;
3. 生产库 5386 行小写 collectdata(8/31 起约 15 个任务)全部读不到,
   大写 COLLECT_DATA 仅存 1067 行历史数据。

修复:
- 写入常量改回大写 COLLECT_DATA(与读取端一致),现有行查询 in(大写,小写)
  兼容存量,旧任务重试幂等命中不重复插入;
- loadFinalRows / deleteTask / deleteHistory 查询改 in(MODULE_TYPE, LEGACY_MODULE_TYPE),
  历史小写行可读出并正确清理(RustFS 对象不泄漏);
- 新增 2 条回归测试(写入 module_type 必须与读取端常量一致、小写存量行重试命中),
  collectdata 全包 168 测试全绿。
2026-09-02 13:51:10 +08:00
5 changed files with 124 additions and 8 deletions
@@ -97,6 +97,13 @@ public class CollectDataService {
public static final String MODULE_TYPE = "COLLECT_DATA";
/**
* task-50 上线初期写入端(CollectDataResultItemBatchWriter)曾误用小写
* "collectdata",遗留 5386 行存量数据;读取端查询统一 in(MODULE_TYPE, LEGACY_MODULE_TYPE)
* 兼容,否则这些任务的明细行在生成结果文件时查不出来。
*/
private static final String LEGACY_MODULE_TYPE = "collectdata";
public TaskProgressLightBatchVo progressLight(List<Long> taskIds) {
return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds);
}
@@ -937,7 +944,7 @@ public class CollectDataService {
private List<CollectDataResultRowVo> loadFinalRows(Long taskId) {
List<TaskResultItemEntity> rows = taskResultItemMapper.selectList(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.in(TaskResultItemEntity::getModuleType, MODULE_TYPE, LEGACY_MODULE_TYPE)
.orderByAsc(TaskResultItemEntity::getId));
if (rows == null || rows.isEmpty()) {
return new ArrayList<>();
@@ -1258,7 +1265,7 @@ public class CollectDataService {
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, task.getId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE));
.in(TaskResultItemEntity::getModuleType, MODULE_TYPE, LEGACY_MODULE_TYPE));
deleteTransientTaskPayloads(
taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
.select(TaskChunkEntity::getPayloadJson)
@@ -1267,7 +1274,7 @@ public class CollectDataService {
taskResultItemMapper.selectList(new LambdaQueryWrapper<TaskResultItemEntity>()
.select(TaskResultItemEntity::getPayloadJson)
.eq(TaskResultItemEntity::getTaskId, task.getId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)));
.in(TaskResultItemEntity::getModuleType, MODULE_TYPE, LEGACY_MODULE_TYPE)));
taskFileJobService.deleteTaskJobs(task.getId(), MODULE_TYPE);
fileTaskMapper.deleteById(task.getId());
}
@@ -1280,11 +1287,11 @@ public class CollectDataService {
List<TaskResultItemEntity> resultItems = taskResultItemMapper.selectList(new LambdaQueryWrapper<TaskResultItemEntity>()
.select(TaskResultItemEntity::getPayloadJson)
.eq(TaskResultItemEntity::getTaskId, row.getTaskId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.in(TaskResultItemEntity::getModuleType, MODULE_TYPE, LEGACY_MODULE_TYPE)
.eq(TaskResultItemEntity::getResultId, row.getId()));
taskResultItemMapper.delete(new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, row.getTaskId())
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.in(TaskResultItemEntity::getModuleType, MODULE_TYPE, LEGACY_MODULE_TYPE)
.eq(TaskResultItemEntity::getResultId, row.getId()));
// 与 deleteTask 一致:先删 DB 行再物理删对象,保证行删除与对象删除一致。
deleteResultItemPayloads(resultItems);
@@ -27,7 +27,12 @@ import java.util.Objects;
@Component
public class CollectDataResultItemBatchWriter {
private static final String MODULE_TYPE = "collectdata";
/** 必须与 CollectDataService.MODULE_TYPE 一致(大写 COLLECT_DATA)。
* 曾因写端误用小写 "collectdata",导致 task-50 之后所有采集任务的明细行
* 在生成结果文件时查不出来(biz_task_result_item.module_type 大小写分叉)。 */
private static final String MODULE_TYPE = "COLLECT_DATA";
/** 历史存量小写值,查询现有行时兼容,避免旧任务重试重复插入(与 CollectDataService.LEGACY_MODULE_TYPE 对应)。 */
private static final String LEGACY_MODULE_TYPE = "collectdata";
private static final int DEFAULT_BATCH_SIZE = 100;
private final TaskResultItemMapper taskResultItemMapper;
@@ -63,10 +68,12 @@ public class CollectDataResultItemBatchWriter {
}
String scopeHash = sha256(scopeKey);
// 一次性取回本 scope 现有行,构建 item_key → 现有行 映射(hash 相等即跳过)。
// 现有行查询兼容历史小写 "collectdata"(见 MODULE_TYPE 注释),避免旧任务重试
// 在同一唯一键 (task_id, module_type, scope_hash, item_key) 下重复插入。
List<TaskResultItemEntity> existingList = taskResultItemMapper.selectList(
new LambdaQueryWrapper<TaskResultItemEntity>()
.eq(TaskResultItemEntity::getTaskId, taskId)
.eq(TaskResultItemEntity::getModuleType, MODULE_TYPE)
.in(TaskResultItemEntity::getModuleType, MODULE_TYPE, LEGACY_MODULE_TYPE)
.eq(TaskResultItemEntity::getScopeHash, scopeHash));
Map<String, TaskResultItemEntity> existingByKey = new HashMap<>();
for (TaskResultItemEntity existing : existingList) {
@@ -4623,7 +4623,8 @@ public class SimilarAsinTaskService {
String isConform = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getIsConform());
String reason = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getReason());
String category = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getCategory());
String status = resultRow == null || isSilentOutputFailure(resultRow)
String status = resultRow == null
|| (isSilentOutputFailure(resultRow) && !isAsinNotFound(resultRow.getError()))
? ""
: resolveResultStatus(resultRow, isStock, similarity, isConform, reason, category);
row.createCell(col++).setCellValue(isStock);
@@ -4858,6 +4859,12 @@ public class SimilarAsinTaskService {
if (hasText(row.getMainUrl()) || hasText(row.getPuzzleImg1()) || hasText(row.getPuzzleImg2())) {
return "\u6210\u529f";
}
// Python \u7aef\u641c\u4e0d\u5230\u5546\u54c1\uff08404\uff09\u7684\u884c\uff1a\u72b6\u6001\u5217\u660e\u786e\u6807\u8bb0\uff0c\u4eba\u5de5\u590d\u6838\u65f6\u4e00\u773c\u53ef\u8fa8\u3002
// \u653e\u5728\u56fe\u7247/\u72b6\u6001\u5224\u65ad\u4e4b\u524d\uff1a404 \u884c\u5fc5\u7136\u65e0\u56fe\u4e14 status \u5df2\u88ab markRowFailed \u7f6e\u4e3a FAILED\uff0c
// \u4e0d\u80fd\u843d\u56de"\u5931\u8d25"\uff08\u90a3\u662f LLM/\u751f\u6210\u94fe\u8def\u5931\u8d25\u8bed\u4e49\uff0c\u4e0e"ASIN \u641c\u4e0d\u5230"\u662f\u4e24\u56de\u4e8b\uff09\u3002
if (isAsinNotFound(row.getError())) {
return "ASIN\u672a\u627e\u5230";
}
String rawStatus = row.getStatus() == null ? "" : row.getStatus().trim();
if (!rawStatus.isBlank()) {
return isFailedStatus(rawStatus) ? "\u5931\u8d25" : "\u6210\u529f";
@@ -4872,6 +4879,22 @@ public class SimilarAsinTaskService {
return hasText(row.getConclusion()) ? "\u6210\u529f" : "\u5931\u8d25";
}
/**
* \u5224\u5b9a\u662f\u5426"ASIN \u672a\u627e\u5230"\uff1aPython \u7aef\u6293\u53d6 404\uff08error="404 Not Found"\uff09\u6216\u7b49\u4ef7\u6587\u6848\u3002
* \u547d\u4e2d\u8be5\u5224\u5b9a\u7684\u884c\u4e0d\u4f1a\u88ab\u5f53\u6210\u751f\u6210\u5931\u8d25\u8ba1\u91cd\u8bd5\uff0c\u4e5f\u4e0d\u4f1a\u88ab\u5bfc\u51fa\u8fc7\u6ee4\uff0c\u72b6\u6001\u5217\u663e\u793a"ASIN\u672a\u627e\u5230"\u3002
*/
static boolean isAsinNotFound(String error) {
String normalized = error == null ? "" : error.toLowerCase(Locale.ROOT).trim();
if (normalized.isBlank()) {
return false;
}
return normalized.contains("404")
|| normalized.contains("not found")
|| normalized.contains("asin \u672a\u627e\u5230")
|| normalized.contains("asin\u4e0d\u5b58\u5728")
|| normalized.contains("\u5546\u54c1\u672a\u627e\u5230");
}
private static boolean isFailedStatus(String status) {
String normalized = status == null ? "" : status.trim().toLowerCase(Locale.ROOT);
return normalized.contains("fail")
@@ -4895,6 +4918,11 @@ public class SimilarAsinTaskService {
if (error.isBlank()) {
return false;
}
// Python 端搜不到商品的 404 虽然 title/url/图全空也必须导出
// 并在状态列标记"ASIN未找到"供人工复核"其它错误需带 title/url/图"之前判断
if (isAsinNotFound(error)) {
return true;
}
return !normalize(row.getTitle()).isBlank()
|| !normalize(row.getUrl()).isBlank()
|| row.hasImageUrl();
@@ -72,6 +72,45 @@ class CollectDataResultItemBatchWriterTest {
assertEquals(1L, entities.get(0).getTaskId(), "task_id 保留");
}
@Test
void test_module_type_matches_reader_constant() {
// 回归:写入端 module_type 必须与 CollectDataService.MODULE_TYPE(读取端)
// 写同一大小写值,否则生成结果文件时 loadFinalRows 查不出来(曾用小写
// "collectdata" 导致 task-50 之后所有采集任务明细行读不到)。
when(taskResultItemMapper.selectList(any())).thenReturn(List.of());
when(taskResultItemMapper.upsertBatch(anyList())).thenReturn(1);
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike")), "rustfs:detail/abc");
ArgumentCaptor<List> captor = ArgumentCaptor.forClass(List.class);
verify(taskResultItemMapper).upsertBatch(captor.capture());
TaskResultItemEntity entity = (TaskResultItemEntity) captor.getValue().get(0);
assertEquals("COLLECT_DATA", entity.getModuleType(), "写入 module_type 须为读取端大写常量");
}
@Test
void test_legacy_lowercase_rows_are_found_on_retry() {
// 回归:历史小写 "collectdata" 存量行在旧任务重提时也要被现有行查询
// 命中(按大小写兼容匹配),避免同一唯一键重复插入。
String refJson = codec.encodeRef(0, 0, "rustfs:detail/x");
TaskResultItemEntity legacy = new TaskResultItemEntity();
legacy.setId(100L);
legacy.setModuleType("collectdata");
legacy.setItemKey("asin:B000000001");
legacy.setPayloadJson(refJson);
legacy.setPayloadHash(sha256(refJson));
when(taskResultItemMapper.selectList(any())).thenReturn(List.of(legacy));
CollectDataResultItemBatchWriter.UpsertCounts counts =
writer.upsertAccepted(1L, 2L, "task:1", 0,
List.of(row("B000000001", "Nike")), "rustfs:detail/x");
assertEquals(0, counts.insertedOrUpdated(), "小写存量行命中 → 全部跳过");
assertEquals(1, counts.skipped(), "1 行跳过");
verify(taskResultItemMapper, never()).upsertBatch(anyList());
}
@Test
void test_task_050_task_normal_multiple_items() {
// 批量场景:75 行(8 批次),顺序稳定不丢失,每批数量正确。
@@ -54,6 +54,41 @@ class SimilarAsinTaskServiceTest {
assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withVisibleResultData, "", "80%", "", "", ""));
}
@Test
void resultStatusMarksAsinNotFoundRows() {
// Python \u7aef 404 \u884c\uff1aerror="404 Not Found"\uff0cstatus \u88ab markRowFailed \u7f6e\u4e3a FAILED\uff0c
// \u4f46\u72b6\u6001\u5217\u5fc5\u987b\u663e\u793a"ASIN\u672a\u627e\u5230"\uff0c\u4e0d\u80fd\u843d\u56de\u666e\u901a"\u5931\u8d25"\u3002
SimilarAsinResultRowDto notFound = new SimilarAsinResultRowDto();
notFound.setError("404 Not Found");
notFound.setStatus("FAILED");
notFound.setReason("required field missing (asin/title/url), skip LLM");
assertEquals("ASIN\u672a\u627e\u5230", SimilarAsinTaskService.resolveResultStatus(notFound));
// \u5176\u5b83\u7b49\u4ef7\u6587\u6848\u540c\u6837\u547d\u4e2d
SimilarAsinResultRowDto chinese = new SimilarAsinResultRowDto();
chinese.setError("\u5546\u54c1\u672a\u627e\u5230");
assertEquals("ASIN\u672a\u627e\u5230", SimilarAsinTaskService.resolveResultStatus(chinese));
// \u666e\u901a\u6280\u672f\u6027\u5931\u8d25\u4e0d\u8bef\u5224\uff1aerror \u4e0d\u542b 404/not found
SimilarAsinResultRowDto llmFailure = new SimilarAsinResultRowDto();
llmFailure.setError("LLM \u8c03\u7528\u8d85\u65f6");
llmFailure.setStatus("FAILED");
assertEquals("\u5931\u8d25", SimilarAsinTaskService.resolveResultStatus(llmFailure));
}
@Test
void asinNotFoundDetectionMatchesPythonErrors() {
assertTrue(SimilarAsinTaskService.isAsinNotFound("404 Not Found"));
assertTrue(SimilarAsinTaskService.isAsinNotFound("404 not found"));
assertTrue(SimilarAsinTaskService.isAsinNotFound("404"));
assertTrue(SimilarAsinTaskService.isAsinNotFound("\u5546\u54c1\u672a\u627e\u5230"));
assertTrue(SimilarAsinTaskService.isAsinNotFound("ASIN \u672a\u627e\u5230"));
assertFalse(SimilarAsinTaskService.isAsinNotFound(null));
assertFalse(SimilarAsinTaskService.isAsinNotFound(""));
assertFalse(SimilarAsinTaskService.isAsinNotFound("LLM \u8c03\u7528\u8d85\u65f6"));
assertFalse(SimilarAsinTaskService.isAsinNotFound("\u4e0a\u4f20\u5931\u8d25"));
}
@Test
void fileBuildProgressIsTerminalOnlyAfterTaskAndStageComplete() {
assertTrue(SimilarAsinTaskService.isTerminalFileBuildProgress("SUCCESS", 3, 3));