优化图片尺寸

This commit is contained in:
super
2026-05-24 02:31:58 +08:00
parent 1e087c1aae
commit a1376b51b0
7 changed files with 114 additions and 58 deletions

View File

@@ -465,7 +465,9 @@ public class AppearancePatentCozeClient {
text(firstNonNull(node.get("patent_reason"),
firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")),
firstNonNull(itemNode.get("patent_reason"),
firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason"))))))
firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason")))))),
text(firstNonNull(firstNonNull(node.get("score"), node.get("Score")),
firstNonNull(itemNode.get("score"), itemNode.get("Score"))))
));
}
}
@@ -570,6 +572,7 @@ public class AppearancePatentCozeClient {
row.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason());
row.setScore(result.score());
}
private RestClient restClient() {
@@ -600,13 +603,14 @@ public class AppearancePatentCozeClient {
row.setTitleReason(source.getTitleReason());
row.setAppearanceReason(source.getAppearanceReason());
row.setPatentReason(source.getPatentReason());
row.setScore(source.getScore());
return row;
}
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
String reviewMessage = failureMessage == null || failureMessage.isBlank()
? "Coze 检测失败,待人工复核"
: "Coze 检测失败,待人工复核" + failureMessage;
? "Coze 检测失败"
: "Coze 检测失败:" + failureMessage;
if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage);
}
@@ -797,7 +801,9 @@ public class AppearancePatentCozeClient {
|| item.has("appearanceReason")
|| item.has("patent_reason")
|| item.has("patentReason")
|| item.has("patent reason"));
|| item.has("patent reason")
|| item.has("score")
|| item.has("Score"));
}
private boolean isSuccessfulWorkflowStatus(String status) {
@@ -1054,7 +1060,8 @@ public class AppearancePatentCozeClient {
String status,
String titleReason,
String appearanceReason,
String patentReason
String patentReason,
String score
) {
}

View File

@@ -78,6 +78,10 @@ public class AppearancePatentResultRowDto {
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
private String patentReason;
@JsonAlias({"score", "Score", "评分"})
@Schema(description = "Coze 回流的评分(外观维度),透传到最终 xlsx 第一张 sheet 的“评分”列。", accessMode = Schema.AccessMode.READ_ONLY)
private String score;
/**
* 仅内存生命周期标记,标识当前 status 是 markFailed 时合成出来的(而不是用户/Python 真实回传)。
* 不入库、不参与 chunk 序列化(@JsonIgnore用于导出 / 重新上传判定时区分"系统合成 FAILED"与"用户真正失败"

View File

@@ -128,7 +128,8 @@ public class AppearancePatentTaskService {
"外观维度(外观设计专利)",
"专利维度(发明/实用新型专利)",
"结论",
"状态"
"状态",
"评分"
);
private final LocalFileStorageService localFileStorageService;
@@ -1265,6 +1266,7 @@ public class AppearancePatentTaskService {
row.setTitleReason(representative.getTitleReason());
row.setAppearanceReason(representative.getAppearanceReason());
row.setPatentReason(representative.getPatentReason());
row.setScore(representative.getScore());
row.setDone(representative.getDone());
return row;
}
@@ -3240,7 +3242,8 @@ public class AppearancePatentTaskService {
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk()));
row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk()));
row.createCell(col++).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow));
row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow));
row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow));
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getScore(), ""));
}
writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap);
workbook.write(fos);
@@ -4041,11 +4044,12 @@ public class AppearancePatentTaskService {
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
return value;
}
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
if (row != null && isTechnicalCozeFailure(row.getError())) {
return "待人工复核";
return firstNonBlank(row.getError(), "");
}
if (row != null && isFailedCozeStatusValue(row.getStatus())) {
return "待人工复核";
return firstNonBlank(row.getError(), "");
}
return firstNonBlank(value, "");
}
@@ -4058,11 +4062,12 @@ public class AppearancePatentTaskService {
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
return row.getConclusion();
}
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
if (isTechnicalCozeFailure(row.getError())) {
return "待人工复核";
return firstNonBlank(row.getError(), "");
}
if (isFailedCozeStatusValue(row.getStatus())) {
return "待人工复核";
return firstNonBlank(row.getError(), "");
}
return firstNonBlank(row.getConclusion(), "");
}
@@ -4070,8 +4075,8 @@ public class AppearancePatentTaskService {
/**
* 导出最终 xlsx 时的状态展示:
* - 如果 status 是 markFailed 合成的failureSyntheticStatus=true
* 且 row 实际并没有任何风险维度结果,导出层降级为"待人工复核",避免用户原样
* 重新上传时该行被反复识别为失败行重新触发 Coze。
* 且 row 实际并没有任何风险维度结果,导出层展示错误信息(有则放入,无则留空),
* 避免用户原样重新上传时该行被反复识别为失败行重新触发 Coze。
* - 真正业务侧失败 / Python 真实回传 FAILED 仍按原值显示。
*/
private String userFacingStatus(AppearancePatentResultRowDto row) {
@@ -4082,7 +4087,8 @@ public class AppearancePatentTaskService {
if (!row.isFailureSyntheticStatus()) {
return original;
}
return "待人工复核";
// 合成状态:有错误信息则放入错误信息,没有则留空
return firstNonBlank(row.getError(), "");
}
private boolean isTechnicalCozeFailure(String value) {

View File

@@ -766,8 +766,8 @@ public class SimilarAsinCozeClient {
private SimilarAsinResultRowDto markFailed(SimilarAsinResultRowDto row, String failureMessage) {
String reviewMessage = failureMessage == null || failureMessage.isBlank()
? "Coze 检测失败,待人工复核"
: "Coze 检测失败,待人工复核" + failureMessage;
? "Coze 检测失败"
: "Coze 检测失败:" + failureMessage;
if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage);
}

View File

@@ -3341,11 +3341,12 @@ public class SimilarAsinTaskService {
font.setBold(true);
headerStyle.setFont(font);
Drawing<?> patriarch = sheet.createDrawingPatriarch();
Map<String, SimilarAsinImageEmbedder.ResizedImage> taskImageCache = new ConcurrentHashMap<>();
sheet.setColumnWidth(IMG_COL_MAIN, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256);
sheet.setColumnWidth(IMG_COL_PUZZLE1, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256);
sheet.setColumnWidth(IMG_COL_PUZZLE2, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256);
Drawing<?> patriarch = sheet.createDrawingPatriarch();
Map<String, byte[]> taskImageCache = new ConcurrentHashMap<>();
List<String> resultHeaders = new ArrayList<>(RESULT_HEADERS);
Row header = sheet.createRow(0);
@@ -3359,7 +3360,6 @@ public class SimilarAsinTaskService {
for (SimilarAsinParsedRowVo parsedRow : rowsToWrite == null ? List.<SimilarAsinParsedRowVo>of() : rowsToWrite) {
SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap);
Row row = sheet.createRow(rowIndex);
row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS);
int col = 0;
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId()));
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), ""));
@@ -3371,6 +3371,10 @@ public class SimilarAsinTaskService {
row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getCategory()));
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), ""));
if (resultRow != null) {
// 行高在 embed 前先固定到 IMAGE_ROW_HEIGHT_POINTSExcel 上限 409pt
// embed() 用 IMAGE_COL_WIDTH_CHARS / IMAGE_ROW_HEIGHT_POINTS 常量推导 EMU anchor与运行时单元格尺寸解耦
// 顺序前置只是为了让 sheet 实际行高与 anchor 假设一致,避免后续维护误读。
row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS);
imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_MAIN, resultRow.getMainUrl(), row, taskImageCache);
imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_PUZZLE1, resultRow.getPuzzleImg1(), row, taskImageCache);
imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_PUZZLE2, resultRow.getPuzzleImg2(), row, taskImageCache);
@@ -4135,8 +4139,9 @@ public class SimilarAsinTaskService {
if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
return value;
}
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
if (row != null && isTechnicalCozeFailure(row.getError())) {
return "待人工复核";
return firstNonBlank(row.getError(), "");
}
return firstNonBlank(value, "");
}
@@ -4167,8 +4172,9 @@ public class SimilarAsinTaskService {
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
return row.getConclusion();
}
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
if (isTechnicalCozeFailure(row.getError())) {
return "待人工复核";
return firstNonBlank(row.getError(), "");
}
return firstNonBlank(row.getConclusion(), "");
}

View File

@@ -12,6 +12,7 @@ import org.apache.poi.ss.usermodel.Drawing;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.util.Units;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Component;
@@ -58,10 +59,9 @@ public class SimilarAsinImageEmbedder {
static final int DOWNLOAD_TIMEOUT_SECONDS = 5;
static final int DOWNLOAD_MAX_RETRY = 1;
static final int DOWNLOAD_POOL_SIZE = 8;
// B 方案:单元格放大到 Excel 上限附近255 char ≈ 1785 px / 409.5 pt ≈ 546 px保持 16:9 显示空间
// 单元格固定尺寸图片在其中等比缩放不拉伸resize 仍按长边 1280 px 控制堆体积
public static final float IMAGE_ROW_HEIGHT_POINTS = 409f;
public static final int IMAGE_COL_WIDTH_CHARS = 80;
// 源图长边 1280 px 给 Excel 缩放/打印留余量q=0.75 + 300KB 出口硬上限主动控制单图体积。
static final int TARGET_LONG_EDGE_PX = 1280;
static final float JPEG_QUALITY = 0.75f;
static final int MAX_THUMB_SIZE_BYTES = 300 * 1024;
@@ -92,21 +92,21 @@ public class SimilarAsinImageEmbedder {
/**
* 嵌入单元格:下载 → resize → POI addPicture → createPicture全链路任一失败即 URL 文本兜底。
* taskImageCache 由调用方在 writeResultWorkbook 入口创建(任务级 ConcurrentHashMap方法返回随栈帧释放
* 返回值成功时为缩略图实际像素width/height失败兜底为 null调用方据此自适应行高/列宽
*/
public void embed(Sheet sheet,
Drawing<?> patriarch,
SXSSFWorkbook wb,
int rowIdx,
int colIdx,
String url,
Row row,
Map<String, byte[]> taskImageCache) {
public ImageDim embed(Sheet sheet,
Drawing<?> patriarch,
SXSSFWorkbook wb,
int rowIdx,
int colIdx,
String url,
Row row,
Map<String, ResizedImage> taskImageCache) {
if (url == null || url.isBlank()) {
return;
return null;
}
String trimmedUrl = url.trim();
byte[] thumb;
ResizedImage thumb;
try {
thumb = taskImageCache.get(trimmedUrl);
boolean cacheHit = thumb != null;
@@ -114,50 +114,74 @@ public class SimilarAsinImageEmbedder {
byte[] raw = downloadWithRetry(trimmedUrl);
thumb = resizeImage(trimmedUrl, raw);
taskImageCache.put(trimmedUrl, thumb);
log.info("[similar-asin][image] cache-miss url={} bytes={}", trimmedUrl, thumb.length);
log.info("[similar-asin][image] cache-miss url={} bytes={} dims={}x{}",
trimmedUrl, thumb.bytes().length, thumb.width(), thumb.height());
} else {
log.debug("[similar-asin][image] cache-hit url={} bytes={}", trimmedUrl, thumb.length);
log.debug("[similar-asin][image] cache-hit url={} bytes={} dims={}x{}",
trimmedUrl, thumb.bytes().length, thumb.width(), thumb.height());
}
} catch (UnsupportedUrlException ex) {
log.warn("[similar-asin][image] unsupported-url url={} reason={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
return null;
} catch (DownloadOversizeException ex) {
log.warn("[similar-asin][image] download-oversize url={} bytes={}", trimmedUrl, ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
return null;
} catch (ResizeOversizeException ex) {
log.warn("[similar-asin][image] resize-oversize url={} bytes={}", ex.url(), ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
return null;
} catch (ResizeException ex) {
log.warn("[similar-asin][image] resize-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
return null;
} catch (IOException | TimeoutException ex) {
log.warn("[similar-asin][image] download-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
return null;
} catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return;
return null;
}
try {
int picIdx = wb.addPicture(thumb, Workbook.PICTURE_TYPE_JPEG);
int picIdx = wb.addPicture(thumb.bytes(), Workbook.PICTURE_TYPE_JPEG);
ClientAnchor anchor = wb.getCreationHelper().createClientAnchor();
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_DONT_RESIZE);
anchor.setCol1(colIdx);
anchor.setRow1(rowIdx);
anchor.setCol2(colIdx + 1);
anchor.setRow2(rowIdx + 1);
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
anchor.setCol2(colIdx);
anchor.setRow2(rowIdx);
// 显式 EMU anchorcol1==col2 / row1==row2仅靠 dx/dy 居中等比缩放。
// picture.resize() 会按"调用时刻"的实际行高/列宽推算 col2/row2
// 但 service 端可能在 embed 之后才 setHeightInPoints(409),导致 anchor 跨多行被纵向拉伸。
// 这里彻底脱离运行时单元格尺寸依赖,单元格大小通过 IMAGE_COL_WIDTH_CHARS / IMAGE_ROW_HEIGHT_POINTS 常量推导。
double cellWidthEmu = IMAGE_COL_WIDTH_CHARS * 7.0 * Units.EMU_PER_PIXEL;
double cellHeightEmu = IMAGE_ROW_HEIGHT_POINTS * (double) Units.EMU_PER_POINT;
double picWidthEmu = thumb.width() * (double) Units.EMU_PER_PIXEL;
double picHeightEmu = thumb.height() * (double) Units.EMU_PER_PIXEL;
double scale = Math.min(cellWidthEmu / picWidthEmu, cellHeightEmu / picHeightEmu) * 0.98;
if (scale > 1.0) {
scale = 1.0;
}
double displayedW = picWidthEmu * scale;
double displayedH = picHeightEmu * scale;
int dx1 = (int) Math.round((cellWidthEmu - displayedW) / 2.0);
int dy1 = (int) Math.round((cellHeightEmu - displayedH) / 2.0);
anchor.setDx1(dx1);
anchor.setDy1(dy1);
anchor.setDx2((int) Math.round(dx1 + displayedW));
anchor.setDy2((int) Math.round(dy1 + displayedH));
patriarch.createPicture(anchor, picIdx);
ImageDim dim = new ImageDim(thumb.width(), thumb.height());
// POI 已经把缩略图字节复制进 picture poolB 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。
// 同 URL 行内重复出现概率较低,且 cache 仍承担同任务跨行去重收益(首次 miss 时已经 put 过)。
taskImageCache.remove(trimmedUrl);
return dim;
} catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
}
}
@@ -272,10 +296,10 @@ public class SimilarAsinImageEmbedder {
}
/**
* 长边 300px 等比缩放 + JPEG q=0.85 输出;ImageWriter / ImageOutputStream 双层 try-finally 释放 native 资源
* 等比缩放到长边 TARGET_LONG_EDGE_PXJPEG q=0.75 输出;返回字节 + 实际像素,供调用方按图自适应单元格尺寸
* 出口校验字节数 ≤ MAX_THUMB_SIZE_BYTES超限抛 ResizeOversizeException 触发文本兜底。
*/
byte[] resizeImage(String sourceUrl, byte[] raw) throws IOException {
ResizedImage resizeImage(String sourceUrl, byte[] raw) throws IOException {
guardImageDimensions(sourceUrl, raw);
BufferedImage src = ImageIO.read(new ByteArrayInputStream(raw));
if (src == null) {
@@ -318,9 +342,15 @@ public class SimilarAsinImageEmbedder {
if (baos.size() > MAX_THUMB_SIZE_BYTES) {
throw new ResizeOversizeException(sourceUrl, baos.size());
}
return baos.toByteArray();
return new ResizedImage(baos.toByteArray(), dstW, dstH);
}
/** 缩略图字节 + 实际像素A 副本随 taskImageCache 生命周期B 副本进 picture pool。 */
public record ResizedImage(byte[] bytes, int width, int height) { }
/** embed() 成功时返回的缩略图实际像素,调用方据此自适应行高/列宽。 */
public record ImageDim(int width, int height) { }
/** 在解码整张位图前用 ImageReader 仅读取头部尺寸,避免“图像炸弹”导致 heap OOM。 */
private static void guardImageDimensions(String sourceUrl, byte[] raw) throws IOException {
try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(raw))) {

View File

@@ -35,18 +35,21 @@ class SimilarAsinImageEmbedderTest {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(src, "jpg", baos);
byte[] thumb = embedder.resizeImage("https://example.com/big.jpg", baos.toByteArray());
SimilarAsinImageEmbedder.ResizedImage thumb = embedder.resizeImage("https://example.com/big.jpg", baos.toByteArray());
assertNotNull(thumb);
assertTrue(thumb.length > 0, "thumb 不可为空");
assertTrue(thumb.length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES,
"thumb 字节应 <= 40KB 上限,实际=" + thumb.length);
assertNotNull(thumb.bytes());
assertTrue(thumb.bytes().length > 0, "thumb 不可为空");
assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES,
"thumb 字节应 <= 300KB 上限,实际=" + thumb.bytes().length);
BufferedImage decoded = ImageIO.read(new java.io.ByteArrayInputStream(thumb));
// 验证返回的像素尺寸与实际解码一致
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(thumb.width(), thumb.height()),
"长边应等于 TARGET_LONG_EDGE_PX=" + SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX);
BufferedImage decoded = ImageIO.read(new java.io.ByteArrayInputStream(thumb.bytes()));
assertNotNull(decoded, "thumb 应可被 ImageIO 重新解码");
int longEdge = Math.max(decoded.getWidth(), decoded.getHeight());
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, longEdge,
"长边应等于 TARGET_LONG_EDGE_PX=300");
assertEquals(thumb.width(), decoded.getWidth(), "返回宽度应与解码宽度一致");
assertEquals(thumb.height(), decoded.getHeight(), "返回高度应与解码高度一致");
}
@Test