新需求更新 同步更新

This commit is contained in:
supernijia
2026-08-06 01:11:54 +08:00
parent 9048bbb7f8
commit 28e7fce11c
112 changed files with 7739 additions and 637 deletions
@@ -1,6 +1,7 @@
package com.nanri.aiimage.modules.similarasin.client;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
@@ -180,4 +181,60 @@ class SimilarAsinCozeClientTest {
assertTrue(json.contains("\"price\":10"));
assertTrue(json.contains("\"price\":80"));
}
@Test
@SuppressWarnings("unchecked")
void buildParametersIncludesCategorySwitch() throws Exception {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0CATEGORY1");
row.setTitle("Category test");
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class, boolean.class);
method.setAccessible(true);
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", false, true);
assertEquals(Boolean.TRUE, parameters.get("category_switch"));
assertEquals(Boolean.FALSE, parameters.get("img_switch"));
}
@Test
void imageOnlyWorkflowOutputIsExtractedAndMergedByAsin() throws Exception {
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
String imageData = """
{"data":[{
"asin":"B0BQNHDP2F",
"main_url":"https://example.com/main.jpg",
"puzzle_img1":"https://example.com/puzzle-1.jpg",
"puzzle_img2":"https://example.com/puzzle-2.jpg"
}]}
""";
String workflowOutput = objectMapper.writeValueAsString(Map.of(
"node_status", "{}",
"Output", imageData));
var historyResponse = objectMapper.createObjectNode();
historyResponse.put("code", 0);
historyResponse.putArray("data")
.addObject()
.put("execute_status", "Success")
.put("output", workflowOutput);
Method extract = SimilarAsinCozeClient.class.getDeclaredMethod("extractResultDataText", JsonNode.class);
extract.setAccessible(true);
String dataText = (String) extract.invoke(client, historyResponse);
SimilarAsinResultRowDto source = new SimilarAsinResultRowDto();
source.setAsin("B0BQNHDP2F");
List<SimilarAsinResultRowDto> merged = client.mergeRowsFromDataText(List.of(source), dataText);
assertFalse(dataText.isBlank());
assertEquals(1, merged.size());
assertEquals("https://example.com/main.jpg", merged.getFirst().getMainUrl());
assertEquals("https://example.com/puzzle-1.jpg", merged.getFirst().getPuzzleImg1());
assertEquals("https://example.com/puzzle-2.jpg", merged.getFirst().getPuzzleImg2());
Method resolvedCount = SimilarAsinCozeClient.class.getDeclaredMethod("resolvedCount", List.class);
resolvedCount.setAccessible(true);
assertEquals(1, resolvedCount.invoke(client, merged));
}
}
@@ -1,9 +1,11 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -60,6 +62,57 @@ class SimilarAsinTaskServiceTest {
assertFalse(SimilarAsinTaskService.isTerminalFileBuildProgress("SUCCESS", 2, 3));
}
@Test
void failedResultRowsWithBlankIsConformEnableCategoryRetry() {
SimilarAsinParsedRowVo blankCategory = new SimilarAsinParsedRowVo();
blankCategory.setValues(new LinkedHashMap<>());
blankCategory.getValues().put("status", "FAILED");
blankCategory.getValues().put("is_conform", "");
assertTrue(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
List.of("id", "asin", "country", "is_conform", "status"),
List.of(blankCategory),
true));
assertFalse(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
List.of("id", "asin", "country", "is_conform", "status"),
List.of(blankCategory),
false));
blankCategory.getValues().put("is_conform", "符合");
assertFalse(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
List.of("id", "asin", "country", "is_conform", "status"),
List.of(blankCategory),
true));
}
@Test
void firstPassImageResultWorkbookKeepsAllRowsForSecondParse() {
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
row.setValues(new LinkedHashMap<>());
row.getValues().put("状态", "成功");
row.getValues().put("是否有货", "");
row.getValues().put("相似度", "");
row.getValues().put("是否符合类目", "");
row.getValues().put("不符合理由", "");
row.getValues().put("产品类目", "");
assertTrue(SimilarAsinTaskService.isFirstPassResultWorkbook(
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
List.of(row)));
row.getValues().put("是否符合类目", "符合");
assertFalse(SimilarAsinTaskService.isFirstPassResultWorkbook(
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
List.of(row)));
row.getValues().put("是否符合类目", "");
row.getValues().put("状态", "失败");
assertFalse(SimilarAsinTaskService.isFirstPassResultWorkbook(
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
List.of(row)));
}
private int staticIntField(String name) throws Exception {
Field field = SimilarAsinTaskService.class.getDeclaredField(name);
field.setAccessible(true);
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.similarasin.util;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -11,7 +12,9 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ExcelCellImageWriterTest {
@@ -42,6 +45,48 @@ class ExcelCellImageWriterTest {
}
}
@Test
void streamsRegisteredImagePathIntoWorkbookMedia() throws Exception {
Path dir = Files.createTempDirectory("excel-cell-image-path-test-");
Path xlsx = dir.resolve("result.xlsx");
Path image = dir.resolve("thumb.jpeg");
byte[] imageBytes = new byte[]{7, 8, 9, 10};
writeMinimalWorkbook(xlsx);
Files.write(image, imageBytes);
ExcelCellImageWriter.Session session = ExcelCellImageWriter.createSession();
session.registerImage(1, 9, image);
ExcelCellImageWriter.patchXlsxFile(xlsx.toFile(), session);
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
assertArrayEquals(imageBytes,
zip.getInputStream(zip.getEntry("xl/media/excelcellimage1.jpeg")).readAllBytes());
}
}
@Test
void patchesTenThousandImageCellsWithinLinearTimeBudget() {
assertTimeoutPreemptively(Duration.ofSeconds(10), () -> {
Path dir = Files.createTempDirectory("excel-cell-image-linear-test-");
Path xlsx = dir.resolve("result.xlsx");
writeWorkbookWithImageCells(xlsx, 10_000);
ExcelCellImageWriter.Session session = ExcelCellImageWriter.createSession();
for (int row = 1; row <= 10_000; row++) {
session.registerImage(row, 9, new byte[]{1});
}
ExcelCellImageWriter.patchXlsxFile(xlsx.toFile(), session);
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
String sheet = read(zip, "xl/worksheets/sheet1.xml");
assertEquals(10_000, maxVm(sheet));
assertTrue(sheet.contains("<c r=\"J10001\" t=\"e\" vm=\"10000\"><v>#VALUE!</v></c>"));
}
});
}
private static int maxVm(String sheet) {
Matcher matcher = Pattern.compile("vm=\"(\\d+)\"").matcher(sheet);
int max = -1;
@@ -99,6 +144,20 @@ class ExcelCellImageWriterTest {
}
}
private static void writeWorkbookWithImageCells(Path xlsx, int imageCount) throws Exception {
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(xlsx))) {
write(out, "[Content_Types].xml", "<Types></Types>");
write(out, "xl/_rels/workbook.xml.rels", "<Relationships></Relationships>");
StringBuilder sheet = new StringBuilder("<worksheet><sheetData>");
for (int row = 2; row <= imageCount + 1; row++) {
sheet.append("<row r=\"").append(row).append("\"><c r=\"J").append(row)
.append("\" t=\"inlineStr\"><is><t>image</t></is></c></row>");
}
sheet.append("</sheetData></worksheet>");
write(out, "xl/worksheets/sheet1.xml", sheet.toString());
}
}
private static void write(ZipOutputStream out, String name, String content) throws Exception {
out.putNextEntry(new ZipEntry(name));
out.write(content.getBytes(StandardCharsets.UTF_8));
@@ -1,7 +1,15 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.nanri.aiimage.config.OssProperties;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
@@ -10,24 +18,39 @@ import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;
class SimilarAsinImageEmbedderTest {
// properties=null 时构造函数走 DEFAULT_DOWNLOAD_TIMEOUT_SECONDS / DEFAULT_DOWNLOAD_POOL_SIZE 兜底。
private final SimilarAsinImageEmbedder embedder = new SimilarAsinImageEmbedder(null, createOssStorageService());
@AfterEach
void shutDownEmbedder() {
embedder.shutdown();
}
private static OssStorageService createOssStorageService() {
OssProperties properties = new OssProperties();
properties.setEndpoint("https://oss.aishufu.top");
@@ -40,6 +63,133 @@ class SimilarAsinImageEmbedderTest {
return new OssStorageService(properties);
}
@Test
void defaultsImageDownloadPoolToEight() {
assertEquals(8, new SimilarAsinProperties().getImageDownloadPoolSize());
assertEquals(8, embedder.downloadPoolSize());
}
@Test
void writesFetchedThumbnailToPersistentCacheForNextEmbedderInstance() throws Exception {
Path cacheDir = Files.createTempDirectory("similar-asin-image-cache-test-");
String url = "https://images.example.com/product.jpg";
byte[] sourceImage = createJpegBytes();
AtomicInteger firstNetworkCalls = new AtomicInteger();
AtomicInteger secondNetworkCalls = new AtomicInteger();
SimilarAsinImageEmbedder first = new SimilarAsinImageEmbedder(
properties(1, 5, cacheDir), createOssStorageService());
SimilarAsinImageEmbedder second = new SimilarAsinImageEmbedder(
properties(1, 5, cacheDir), createOssStorageService());
try {
replaceHttpClient(first, respondingClient(firstNetworkCalls, sourceImage));
SimilarAsinImageEmbedder.ResizedImage fetched = first.fetchAndResizeForCache(url);
assertNotNull(fetched);
assertEquals(1, firstNetworkCalls.get());
try (var cachedFiles = Files.walk(cacheDir)) {
assertEquals(1L, cachedFiles.filter(Files::isRegularFile).count());
}
replaceHttpClient(second, new OkHttpClient.Builder()
.addInterceptor(chain -> {
secondNetworkCalls.incrementAndGet();
throw new AssertionError("persistent cache miss triggered a second network request");
})
.build());
SimilarAsinImageEmbedder.ResizedImage cached = second.fetchAndResizeForCache(url);
assertNotNull(cached);
assertArrayEquals(fetched.bytes(), cached.bytes());
assertEquals(fetched.width(), cached.width());
assertEquals(fetched.height(), cached.height());
assertEquals(0, secondNetworkCalls.get());
} finally {
first.shutdown();
second.shutdown();
deleteRecursively(cacheDir);
}
}
@Test
void diskPrefetchDeadlineCancelsInFlightDownload() throws Exception {
SimilarAsinImageEmbedder deadlineEmbedder = new SimilarAsinImageEmbedder(
properties(1, 1, null), createOssStorageService());
CountDownLatch downloadStarted = new CountDownLatch(1);
CountDownLatch cancellationObserved = new CountDownLatch(1);
replaceHttpClient(deadlineEmbedder, new OkHttpClient.Builder()
.addInterceptor(chain -> {
downloadStarted.countDown();
try {
new CountDownLatch(1).await();
throw new AssertionError("blocking download unexpectedly completed");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
cancellationObserved.countDown();
throw new IOException("cancelled", ex);
}
})
.build());
Path spoolDir = Files.createTempDirectory("similar-asin-prefetch-deadline-test-");
try (SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(spoolDir)) {
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> deadlineEmbedder.prefetchToDisk(
List.of("https://images.example.com/slow.jpg"), spool));
assertTrue(downloadStarted.await(100, TimeUnit.MILLISECONDS));
assertTrue(cancellationObserved.await(1, TimeUnit.SECONDS),
"deadline should interrupt the active image download");
assertEquals(0, spool.size());
} finally {
deadlineEmbedder.shutdown();
}
}
@Test
void failedDiskPrefetchIsNotDownloadedAgainWhileEmbedding() throws Exception {
SimilarAsinImageEmbedder failedEmbedder = new SimilarAsinImageEmbedder(
properties(1, 5, null), createOssStorageService());
AtomicInteger networkCalls = new AtomicInteger();
replaceHttpClient(failedEmbedder, failingClient(networkCalls));
String url = "https://images.example.com/missing.jpg";
Path spoolDir = Files.createTempDirectory("similar-asin-prefetch-failed-test-");
try (SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(spoolDir);
XSSFWorkbook workbook = new XSSFWorkbook()) {
failedEmbedder.prefetchToDisk(List.of(url), spool);
int callsAfterPrefetch = networkCalls.get();
var row = workbook.createSheet().createRow(1);
SimilarAsinImageEmbedder.ImageDim result = failedEmbedder.embedAsExcelCellImage(
1, 9, url, row, new HashMap<>(), ExcelCellImageWriter.createSession(), spool);
assertEquals(SimilarAsinImageEmbedder.DOWNLOAD_MAX_RETRY + 1, callsAfterPrefetch);
assertEquals(callsAfterPrefetch, networkCalls.get());
assertNull(result);
assertEquals(url, row.getCell(9).getStringCellValue());
} finally {
failedEmbedder.shutdown();
}
}
@Test
void imageSpoolWritesThumbnailAndDeletesTaskDirectoryOnClose() throws Exception {
Path directory = Files.createTempDirectory("similar-asin-image-spool-test-");
byte[] bytes = new byte[]{1, 3, 5, 7};
SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(directory);
try {
SimilarAsinImageEmbedder.SpoolImage image = spool.put(
"https://example.com/image.jpg",
new SimilarAsinImageEmbedder.ResizedImage(bytes, 120, 80));
assertEquals(1, spool.size());
assertEquals(120, image.width());
assertEquals(80, image.height());
assertArrayEquals(bytes, Files.readAllBytes(image.path()));
} finally {
spool.close();
}
assertFalse(Files.exists(directory));
}
@Test
void normalizesLegacyMinioUrlBeforeHttpsValidation() {
String normalized = embedder.normalizeAndValidateDownloadUrl(
@@ -247,4 +397,75 @@ class SimilarAsinImageEmbedderTest {
assertTrue(summary.contains("IOException: outer"));
assertTrue(summary.contains("cause=TimeoutException: inner"));
}
private static SimilarAsinProperties properties(int poolSize, int prefetchTimeoutSeconds, Path cacheDir) {
SimilarAsinProperties properties = new SimilarAsinProperties();
properties.setImageDownloadPoolSize(poolSize);
properties.setImagePrefetchTimeoutSeconds(prefetchTimeoutSeconds);
properties.setImageLocalCacheDir(cacheDir == null ? "" : cacheDir.toString());
return properties;
}
private static OkHttpClient respondingClient(AtomicInteger calls, byte[] imageBytes) {
return new OkHttpClient.Builder()
.addInterceptor(chain -> {
calls.incrementAndGet();
return response(chain.request(), 200, "OK", imageBytes);
})
.build();
}
private static OkHttpClient failingClient(AtomicInteger calls) {
return new OkHttpClient.Builder()
.addInterceptor(chain -> {
calls.incrementAndGet();
return response(chain.request(), 503, "Unavailable", new byte[0]);
})
.build();
}
private static Response response(okhttp3.Request request,
int status,
String message,
byte[] body) {
return new Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(status)
.message(message)
.body(ResponseBody.create(body, MediaType.get("image/jpeg")))
.build();
}
private static void replaceHttpClient(SimilarAsinImageEmbedder target,
OkHttpClient httpClient) throws ReflectiveOperationException {
Field field = SimilarAsinImageEmbedder.class.getDeclaredField("httpClient");
field.setAccessible(true);
field.set(target, httpClient);
}
private static byte[] createJpegBytes() throws IOException {
BufferedImage image = new BufferedImage(64, 48, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setColor(new Color(0x24, 0x68, 0xAC));
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
} finally {
graphics.dispose();
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", output);
return output.toByteArray();
}
private static void deleteRecursively(Path directory) throws IOException {
if (!Files.exists(directory)) {
return;
}
try (var paths = Files.walk(directory)) {
for (Path path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}