fix: 商标查询失败自动重试、货源结果图切回Excel内嵌防WPS #REF、外观专利标题模型改gemini-3.8-flash
- 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
This commit is contained in:
@@ -14,7 +14,7 @@ public class AppearancePatentProperties {
|
||||
/**
|
||||
* 商标关键词提取模型
|
||||
*/
|
||||
private String titleModel = "deepseek-v4-flash";
|
||||
private String titleModel = "gemini-3.8-flash";
|
||||
/**
|
||||
* 外观检测模型(视觉)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+41
-7
@@ -105,15 +105,49 @@ public class BrandCheckClient {
|
||||
}
|
||||
|
||||
private BrandCheckOutcome checkOneBrand(String brand, String strategy) {
|
||||
int attempts = Math.max(1, properties.getRetryTimes());
|
||||
BrandCheckResponse response = null;
|
||||
Exception lastFailure = null;
|
||||
for (int attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
BrandCheckResponse response = check(brand, strategy);
|
||||
if (response == null) {
|
||||
return new BrandCheckOutcome(List.of(), List.of(brand));
|
||||
}
|
||||
return new BrandCheckOutcome(nullToEmpty(response.getFaildData()), nullToEmpty(response.getQueryFaildData()));
|
||||
response = check(brand, strategy);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[brand-check] request failed brand={} strategy={} err={}", brand, strategy, ex.getMessage());
|
||||
return new BrandCheckOutcome(List.of(), List.of(brand));
|
||||
lastFailure = ex;
|
||||
response = null;
|
||||
if (attempt < attempts) {
|
||||
log.warn("[brand-check] 请求异常将重试 brand={} attempt={}/{} err={}",
|
||||
brand, attempt, attempts, ex.getMessage());
|
||||
sleepBeforeRetry();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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<String, SimilarAsinResultRowDto> resultMap,
|
||||
Map<String, SimilarAsinImageEmbedder.ResizedImage> 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={}",
|
||||
|
||||
+20
-19
@@ -611,7 +611,7 @@ public class SimilarAsinImageEmbedder {
|
||||
String url,
|
||||
Row row,
|
||||
Map<String, ResizedImage> 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<String, ResizedImage> 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());
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
+14
-13
@@ -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");
|
||||
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user