From 197643a8fad3260a55b95addc3361bf346ddeea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Tue, 8 Sep 2026 19:01:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=95=86=E6=A0=87=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95=E3=80=81?= =?UTF-8?q?=E8=B4=A7=E6=BA=90=E7=BB=93=E6=9E=9C=E5=9B=BE=E5=88=87=E5=9B=9E?= =?UTF-8?q?Excel=E5=86=85=E5=B5=8C=E9=98=B2WPS=20#REF=E3=80=81=E5=A4=96?= =?UTF-8?q?=E8=A7=82=E4=B8=93=E5=88=A9=E6=A0=87=E9=A2=98=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=94=B9gemini-3.8-flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - brand-check: 单品牌商标查询失败(异常/空响应/query_faild_data 非空)自动重试, 次数与间隔可配(retry-times 默认3含首次 / retry-interval-millis 1000),命中侵权不重试; collectdata 品牌过滤与外观专利商标核验两侧同时受益 - similarasin: 货源结果Excel图片由WPS DISPIMG 切回 Excel 365 richData 单元格内嵌, 修复 WPS 打开 DISPIMG 显示 #REF 的兼容回归;下载失败单元格回退为URL文本 - appearance-patent: 标题商标提取模型由 deepseek-v4-flash 切 gemini-3.8-flash --- .../config/AppearancePatentProperties.java | 2 +- .../aiimage/config/BrandCheckProperties.java | 4 + .../brand/client/BrandCheckClient.java | 50 ++++++++-- .../service/SimilarAsinTaskService.java | 8 +- .../util/SimilarAsinImageEmbedder.java | 47 ++++----- .../resources/application-local.example.yml | 2 +- .../src/main/resources/application.yml | 4 +- .../AppearancePatentLlmClientHttpTest.java | 27 +++--- .../brand/client/BrandCheckClientTest.java | 95 +++++++++++++++++++ .../util/SimilarAsinImageEmbedderTest.java | 5 +- 10 files changed, 191 insertions(+), 53 deletions(-) diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java index f06de610..ead29a4c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java @@ -14,7 +14,7 @@ public class AppearancePatentProperties { /** * 商标关键词提取模型 */ - private String titleModel = "deepseek-v4-flash"; + private String titleModel = "gemini-3.8-flash"; /** * 外观检测模型(视觉) */ diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/BrandCheckProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/BrandCheckProperties.java index 802e6b41..f81b8cdb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/BrandCheckProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/BrandCheckProperties.java @@ -10,6 +10,10 @@ public class BrandCheckProperties { private String path = "/brand_check"; private String token = ""; private String defaultStrategy = "Terms"; + /** 单品牌商标查询的重试次数(含首次),连续失败超过该次数才判定为查询失败。 */ + private int retryTimes = 3; + /** 每次查询失败后到下一次重试前的等待毫秒数。 */ + private int retryIntervalMillis = 1000; private int connectTimeoutMillis = 10000; private int readTimeoutMillis = 60000; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java index 1cd1457f..4ca929cf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/client/BrandCheckClient.java @@ -105,15 +105,49 @@ public class BrandCheckClient { } private BrandCheckOutcome checkOneBrand(String brand, String strategy) { - try { - BrandCheckResponse response = check(brand, strategy); - if (response == null) { - return new BrandCheckOutcome(List.of(), List.of(brand)); + int attempts = Math.max(1, properties.getRetryTimes()); + BrandCheckResponse response = null; + Exception lastFailure = null; + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + response = check(brand, strategy); + } catch (Exception ex) { + lastFailure = ex; + response = null; + if (attempt < attempts) { + log.warn("[brand-check] 请求异常将重试 brand={} attempt={}/{} err={}", + brand, attempt, attempts, ex.getMessage()); + sleepBeforeRetry(); + } + continue; } - return new BrandCheckOutcome(nullToEmpty(response.getFaildData()), nullToEmpty(response.getQueryFaildData())); - } catch (Exception ex) { - log.warn("[brand-check] request failed brand={} strategy={} err={}", brand, strategy, ex.getMessage()); - return new BrandCheckOutcome(List.of(), List.of(brand)); + if (response != null && nullToEmpty(response.getQueryFaildData()).isEmpty()) { + // 本次查询完成且无失败项:命中侵权(faild_data)属有效结论,不重试。 + return new BrandCheckOutcome(nullToEmpty(response.getFaildData()), List.of()); + } + if (attempt < attempts) { + log.warn("[brand-check] 服务端返回查询失败将重试 brand={} attempt={}/{}", + brand, attempt, attempts); + sleepBeforeRetry(); + } + } + log.warn("[brand-check] 重试耗尽判定查询失败 brand={} attempts={} lastErr={}", + brand, attempts, + lastFailure == null ? "query_faild_data 持续非空" : lastFailure.getMessage()); + return new BrandCheckOutcome(List.of(), + response == null ? List.of(brand) : nullToEmpty(response.getQueryFaildData())); + } + + private void sleepBeforeRetry() { + long delayMillis = Math.max(0L, properties.getRetryIntervalMillis()); + if (delayMillis <= 0L) { + return; + } + try { + Thread.sleep(delayMillis); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("商标查询重试被中断", interruptedException); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 357a6caa..b8acba4c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -41,7 +41,7 @@ import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinExcelPar import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinGroupingConverter; import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinHistoryAssembler; import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache; -import com.nanri.aiimage.modules.similarasin.util.WpsCellImageWriter; +import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; @@ -4553,8 +4553,8 @@ public class SimilarAsinTaskService { Map resultMap, Map taskImageCache) { ensureResultAssemblyNotInterrupted(); - // 单元格内嵌图片:写完 workbook 后 patch WPS DISPIMG 结构(WPS/钉钉/飞书/Excel365 均可显示)。 - WpsCellImageWriter.Session cellImageSession = WpsCellImageWriter.createSession(); + // 单元格内嵌图片:写完 workbook 后 patch Excel 365 richData 单元格图片结构(跨 Excel/WPS 打开均可见)。 + ExcelCellImageWriter.Session cellImageSession = ExcelCellImageWriter.createSession(); SimilarAsinImageEmbedder.ImageSpool imageSpool; try { Path spoolDir = Files.createTempDirectory(xlsx.getParentFile().toPath(), "similar-asin-images-"); @@ -4670,7 +4670,7 @@ public class SimilarAsinTaskService { if (!cellImageSession.isEmpty()) { try { ensureResultAssemblyNotInterrupted(); - WpsCellImageWriter.patchXlsxFile(xlsx, cellImageSession); + ExcelCellImageWriter.patchXlsxFile(xlsx, cellImageSession); } catch (IOException ex) { String cause = ex.toString(); log.error("[similar-asin] excel cell image patch failed xlsx={} images={} err={}", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index b8dc1f4b..d5acceeb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -611,7 +611,7 @@ public class SimilarAsinImageEmbedder { String url, Row row, Map taskImageCache, - WpsCellImageWriter.Session excelCellImageSession) { + ExcelCellImageWriter.Session excelCellImageSession) { return embedAsExcelCellImage(rowIdx, colIdx, url, row, taskImageCache, excelCellImageSession, null); } @@ -620,39 +620,40 @@ public class SimilarAsinImageEmbedder { String url, Row row, Map taskImageCache, - WpsCellImageWriter.Session excelCellImageSession, + ExcelCellImageWriter.Session excelCellImageSession, ImageSpool imageSpool) { if (url == null || url.isBlank() || excelCellImageSession == null) { return null; } String trimmedUrl = url.trim(); SpoolImage spooled = imageSpool == null ? null : imageSpool.get(trimmedUrl); - String imageId = null; - ImageDim dim = null; if (spooled != null) { // 预取已落盘:按路径注册,patch 阶段再读文件,避免整份图片字节驻留堆内存。 - imageId = excelCellImageSession.registerImageByPath(spooled.path()); - dim = new ImageDim(spooled.width(), spooled.height()); - } else if (imageSpool != null) { - // 预取未命中说明该 URL 下载失败:不重试、不把 URL 文本写进单元格,留空并打日志。 - log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (prefetch did not spool), leave blank", - trimmedUrl, colIdx); + row.createCell(colIdx).setCellValue("#VALUE!"); + excelCellImageSession.registerImage(rowIdx, colIdx, spooled.path()); + return new ImageDim(spooled.width(), spooled.height()); + } + if (imageSpool != null) { + // 预取未命中说明该 URL 下载失败:不重试,单元格回退为 URL 文本便于人工核对。 + row.createCell(colIdx).setCellValue(trimmedUrl); return null; - } else { - ResizedImage thumb = downloadAndResize(trimmedUrl, taskImageCache); - if (thumb == null) { - log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (download failed), leave blank", - trimmedUrl, colIdx); - return null; - } - imageId = excelCellImageSession.registerImage(thumb.bytes()); + } + ResizedImage thumb = downloadAndResize(trimmedUrl, taskImageCache); + if (thumb == null) { + return null; + } + try { + row.createCell(colIdx).setCellValue("#VALUE!"); + excelCellImageSession.registerImage(rowIdx, colIdx, thumb.bytes()); taskImageCache.remove(trimmedUrl); - dim = new ImageDim(thumb.width(), thumb.height()); + return new ImageDim(thumb.width(), thumb.height()); + } catch (RuntimeException ex) { + taskImageCache.remove(trimmedUrl); + log.warn("[similar-asin][image] excel-cell-image-fail url={} colIdx={} err={}", + trimmedUrl, colIdx, ex.getMessage()); + row.createCell(colIdx).setCellValue(trimmedUrl); + return null; } - if (imageId != null) { - row.createCell(colIdx).setCellFormula(WpsCellImageWriter.dispImgFormula(imageId)); - } - return dim; } private boolean hasLocalCachedThumb(String url) { diff --git a/backend-java/src/main/resources/application-local.example.yml b/backend-java/src/main/resources/application-local.example.yml index 64de7704..973b46de 100644 --- a/backend-java/src/main/resources/application-local.example.yml +++ b/backend-java/src/main/resources/application-local.example.yml @@ -62,7 +62,7 @@ AIIMAGE_MODULE_CLEANUP_CRON=0 0 0 * * * AIIMAGE_MODULE_CLEANUP_MODULE_TYPES=DEDUPE,SPLIT,CONVERT,DELETE_BRAND AIIMAGE_APPEARANCE_PATENT_LLM_HOST=https://ai.t8star.org -AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL=deepseek-v4-flash +AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL=gemini-3.8-flash AIIMAGE_APPEARANCE_PATENT_APPEARANCE_MODEL=gemini-3.8-flash AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS=64000 AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE=10 diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 3ee51a4c..3b119d8f 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -237,11 +237,13 @@ aiimage: path: ${AIIMAGE_BRAND_CHECK_PATH:/brand_check} token: ${AIIMAGE_BRAND_CHECK_TOKEN:} default-strategy: ${AIIMAGE_BRAND_CHECK_DEFAULT_STRATEGY:Terms} + retry-times: ${AIIMAGE_BRAND_CHECK_RETRY_TIMES:3} + retry-interval-millis: ${AIIMAGE_BRAND_CHECK_RETRY_INTERVAL_MILLIS:1000} connect-timeout-millis: ${AIIMAGE_BRAND_CHECK_CONNECT_TIMEOUT_MILLIS:10000} read-timeout-millis: ${AIIMAGE_BRAND_CHECK_READ_TIMEOUT_MILLIS:60000} appearance-patent: llm-host: ${AIIMAGE_APPEARANCE_PATENT_LLM_HOST:https://ai.t8star.org} - title-model: ${AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL:deepseek-v4-flash} + title-model: ${AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL:gemini-3.8-flash} appearance-model: ${AIIMAGE_APPEARANCE_PATENT_APPEARANCE_MODEL:gemini-3.8-flash} llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000} llm-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_CONNECT_TIMEOUT_MILLIS:10000} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java index a405abac..a8c22123 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java @@ -63,22 +63,23 @@ class AppearancePatentLlmClientHttpTest { String model = String.valueOf(request.get("model")); callCounts.computeIfAbsent(model, ignored -> new AtomicInteger()).incrementAndGet(); + // 标题提取与外观检测现同用 gemini-3.8-flash,按请求是否带 image_url 区分两路: + // 外观检测消息带 image_url parts,标题商标提取仅纯文本。 String content; - if (model.contains("deepseek")) { - // 商标提取:按标题内容返回品牌词或"无" - String messages = String.valueOf(request.get("messages")); - if (messages.contains("Apple")) { - content = "Apple,Apple"; - } else { - content = "无"; - } - } else { + if (body.contains("\"type\":\"image_url\"")) { // 外观检测:返回 JSON(带 ```json 包裹与换行,模拟脏输出) - if (messagesBodyContains(exchange, "原创个性杯")) { + if (body.contains("原创个性杯")) { content = "```json\n{\"appearance_status\": \"侵权\", \"appearance_reason\": \"【视觉拆解】:特殊造型\\n【判定依据】:高度相似知名设计\"}\n```"; } else { content = "{\"appearance_status\": \"无侵权\", \"appearance_reason\": \"【视觉拆解】:普通直筒杯。\\n【对比评估】:行业通用基础形状。\\n【判定依据】:无侵权。\"}"; } + } else { + // 商标提取:按标题内容返回品牌词或"无" + if (String.valueOf(request.get("messages")).contains("Apple")) { + content = "Apple,Apple"; + } else { + content = "无"; + } } String response = objectMapper.writeValueAsString(Map.of( "model", model, @@ -125,8 +126,8 @@ class AppearancePatentLlmClientHttpTest { assertThat(result.getAppearanceRisk()).isEqualTo("无侵权"); assertThat(result.getAppearanceReason()).contains("视觉拆解"); assertThat(result.getTitleReason()).contains("Apple"); - assertThat(callCounts.get("deepseek-v4-flash")).hasValue(1); - assertThat(callCounts.get("gemini-3.8-flash")).hasValue(1); + assertThat(callCounts.get("gemini-3.8-flash")).hasValue(2); + assertThat(callCounts.get("deepseek-v4-flash")).isNull(); } @Test @@ -182,7 +183,7 @@ class AppearancePatentLlmClientHttpTest { client.inspectRows(List.of(row("6", "B006", "普通数据线", "", "https://img.example.com/6.jpg")), null, "test-key"); String appearanceBody = capturedBodies.stream() - .filter(b -> b.contains("gemini-3.8-flash")) + .filter(b -> b.contains("\"type\":\"image_url\"")) .findFirst() .orElseThrow(); assertThat(appearanceBody).contains("https://img.example.com/6.jpg"); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java index abd2f948..abc5ea0b 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/brand/client/BrandCheckClientTest.java @@ -1,9 +1,15 @@ package com.nanri.aiimage.modules.brand.client; import com.nanri.aiimage.config.BrandCheckProperties; +import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; @@ -26,4 +32,93 @@ class BrandCheckClientTest { assertThat(brands).containsExactly("任天堂", "Sony"); } + + @Test + void queryFailureRetriesUntilSuccess() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + HttpServer server = startBrandCheckServer(exchange -> { + int count = attempts.incrementAndGet(); + String body = count < 3 + ? "{\"faild_data\":[],\"query_faild_data\":[\"Apple\"]}" + : "{\"faild_data\":[],\"query_faild_data\":[]}"; + respond(exchange, body); + }); + try { + BrandCheckClient client = newClient(server, 3); + + BrandCheckClient.BrandCheckBatchResult result = client.checkAll(List.of("Apple"), "Terms"); + + assertThat(attempts.get()).as("前两次失败后应重试到成功").isEqualTo(3); + assertThat(result.hasFailedData()).isFalse(); + assertThat(result.hasQueryFailedData()).isFalse(); + } finally { + server.stop(0); + } + } + + @Test + void persistentQueryFailureIsReportedAfterExhaustingRetries() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + HttpServer server = startBrandCheckServer(exchange -> { + attempts.incrementAndGet(); + respond(exchange, "{\"faild_data\":[],\"query_faild_data\":[\"Apple\"]}"); + }); + try { + BrandCheckClient client = newClient(server, 3); + + BrandCheckClient.BrandCheckBatchResult result = client.checkAll(List.of("Apple"), "Terms"); + + assertThat(attempts.get()).as("持续失败应耗尽重试次数").isEqualTo(3); + assertThat(result.hasFailedData()).isFalse(); + assertThat(result.hasQueryFailedData()).isTrue(); + assertThat(result.queryFaildData()).contains("Apple"); + } finally { + server.stop(0); + } + } + + @Test + void confirmedInfringementIsNotRetried() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + HttpServer server = startBrandCheckServer(exchange -> { + attempts.incrementAndGet(); + respond(exchange, "{\"faild_data\":[\"Apple\"],\"query_faild_data\":[]}"); + }); + try { + BrandCheckClient client = newClient(server, 3); + + BrandCheckClient.BrandCheckBatchResult result = client.checkAll(List.of("Apple"), "Terms"); + + assertThat(attempts.get()).as("命中侵权属有效结论,不应重试").isEqualTo(1); + assertThat(result.hasFailedData()).isTrue(); + assertThat(result.hasQueryFailedData()).isFalse(); + } finally { + server.stop(0); + } + } + + private BrandCheckClient newClient(HttpServer server, int retryTimes) { + BrandCheckProperties props = new BrandCheckProperties(); + props.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + props.setPath("/brand_check"); + props.setRetryTimes(retryTimes); + props.setRetryIntervalMillis(1); + return new BrandCheckClient(props, null); + } + + private HttpServer startBrandCheckServer(com.sun.net.httpserver.HttpHandler handler) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/brand_check", handler); + server.start(); + return server; + } + + private void respond(com.sun.net.httpserver.HttpExchange exchange, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java index cbecc779..a6808030 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java @@ -229,12 +229,13 @@ class SimilarAsinImageEmbedderTest { var row = workbook.createSheet().createRow(1); SimilarAsinImageEmbedder.ImageDim result = failedEmbedder.embedAsExcelCellImage( - 1, 9, url, row, new HashMap<>(), WpsCellImageWriter.createSession(), spool); + 1, 9, url, row, new HashMap<>(), ExcelCellImageWriter.createSession(), spool); assertEquals(SimilarAsinImageEmbedder.DOWNLOAD_MAX_RETRY + 1, callsAfterPrefetch); assertEquals(callsAfterPrefetch, networkCalls.get()); assertNull(result); - assertNull(row.getCell(9), "预取失败不应把 URL 文本写进单元格,应留空"); + assertTrue(row.getCell(9) != null, "预取失败应回退为 URL 文本便于人工核对"); + assertEquals(url, row.getCell(9).getStringCellValue()); } finally { failedEmbedder.shutdown(); }