优化图片尺寸

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"), text(firstNonNull(node.get("patent_reason"),
firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")), firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")),
firstNonNull(itemNode.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.setTitleReason(result.titleReason());
row.setAppearanceReason(result.appearanceReason()); row.setAppearanceReason(result.appearanceReason());
row.setPatentReason(result.patentReason()); row.setPatentReason(result.patentReason());
row.setScore(result.score());
} }
private RestClient restClient() { private RestClient restClient() {
@@ -600,13 +603,14 @@ public class AppearancePatentCozeClient {
row.setTitleReason(source.getTitleReason()); row.setTitleReason(source.getTitleReason());
row.setAppearanceReason(source.getAppearanceReason()); row.setAppearanceReason(source.getAppearanceReason());
row.setPatentReason(source.getPatentReason()); row.setPatentReason(source.getPatentReason());
row.setScore(source.getScore());
return row; return row;
} }
private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) { private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) {
String reviewMessage = failureMessage == null || failureMessage.isBlank() String reviewMessage = failureMessage == null || failureMessage.isBlank()
? "Coze 检测失败,待人工复核" ? "Coze 检测失败"
: "Coze 检测失败,待人工复核" + failureMessage; : "Coze 检测失败:" + failureMessage;
if (row.getError() == null || row.getError().isBlank()) { if (row.getError() == null || row.getError().isBlank()) {
row.setError(failureMessage); row.setError(failureMessage);
} }
@@ -797,7 +801,9 @@ public class AppearancePatentCozeClient {
|| item.has("appearanceReason") || item.has("appearanceReason")
|| item.has("patent_reason") || item.has("patent_reason")
|| item.has("patentReason") || item.has("patentReason")
|| item.has("patent reason")); || item.has("patent reason")
|| item.has("score")
|| item.has("Score"));
} }
private boolean isSuccessfulWorkflowStatus(String status) { private boolean isSuccessfulWorkflowStatus(String status) {
@@ -1054,7 +1060,8 @@ public class AppearancePatentCozeClient {
String status, String status,
String titleReason, String titleReason,
String appearanceReason, 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) @Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
private String patentReason; private String patentReason;
@JsonAlias({"score", "Score", "评分"})
@Schema(description = "Coze 回流的评分(外观维度),透传到最终 xlsx 第一张 sheet 的“评分”列。", accessMode = Schema.AccessMode.READ_ONLY)
private String score;
/** /**
* 仅内存生命周期标记,标识当前 status 是 markFailed 时合成出来的(而不是用户/Python 真实回传)。 * 仅内存生命周期标记,标识当前 status 是 markFailed 时合成出来的(而不是用户/Python 真实回传)。
* 不入库、不参与 chunk 序列化(@JsonIgnore用于导出 / 重新上传判定时区分"系统合成 FAILED"与"用户真正失败" * 不入库、不参与 chunk 序列化(@JsonIgnore用于导出 / 重新上传判定时区分"系统合成 FAILED"与"用户真正失败"

View File

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

View File

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

View File

@@ -3341,11 +3341,12 @@ public class SimilarAsinTaskService {
font.setBold(true); font.setBold(true);
headerStyle.setFont(font); 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_MAIN, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256);
sheet.setColumnWidth(IMG_COL_PUZZLE1, 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); 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); List<String> resultHeaders = new ArrayList<>(RESULT_HEADERS);
Row header = sheet.createRow(0); Row header = sheet.createRow(0);
@@ -3359,7 +3360,6 @@ public class SimilarAsinTaskService {
for (SimilarAsinParsedRowVo parsedRow : rowsToWrite == null ? List.<SimilarAsinParsedRowVo>of() : rowsToWrite) { for (SimilarAsinParsedRowVo parsedRow : rowsToWrite == null ? List.<SimilarAsinParsedRowVo>of() : rowsToWrite) {
SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap); SimilarAsinResultRowDto resultRow = findResultRow(parsedRow, resultMap);
Row row = sheet.createRow(rowIndex); Row row = sheet.createRow(rowIndex);
row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS);
int col = 0; int col = 0;
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId()));
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); 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 ? "" : userFacingCozeCellValue(resultRow, resultRow.getCategory()));
row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), "")); row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), ""));
if (resultRow != null) { 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_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_PUZZLE1, resultRow.getPuzzleImg1(), row, taskImageCache);
imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_PUZZLE2, resultRow.getPuzzleImg2(), 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)) { if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) {
return value; return value;
} }
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
if (row != null && isTechnicalCozeFailure(row.getError())) { if (row != null && isTechnicalCozeFailure(row.getError())) {
return "待人工复核"; return firstNonBlank(row.getError(), "");
} }
return firstNonBlank(value, ""); return firstNonBlank(value, "");
} }
@@ -4167,8 +4172,9 @@ public class SimilarAsinTaskService {
if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) { if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) {
return row.getConclusion(); return row.getConclusion();
} }
// coze 技术性失败:有错误信息则放入错误信息,没有则留空
if (isTechnicalCozeFailure(row.getError())) { if (isTechnicalCozeFailure(row.getError())) {
return "待人工复核"; return firstNonBlank(row.getError(), "");
} }
return firstNonBlank(row.getConclusion(), ""); 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.Row;
import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.util.Units;
import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -58,10 +59,9 @@ public class SimilarAsinImageEmbedder {
static final int DOWNLOAD_TIMEOUT_SECONDS = 5; static final int DOWNLOAD_TIMEOUT_SECONDS = 5;
static final int DOWNLOAD_MAX_RETRY = 1; static final int DOWNLOAD_MAX_RETRY = 1;
static final int DOWNLOAD_POOL_SIZE = 8; 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 float IMAGE_ROW_HEIGHT_POINTS = 409f;
public static final int IMAGE_COL_WIDTH_CHARS = 80; 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 int TARGET_LONG_EDGE_PX = 1280;
static final float JPEG_QUALITY = 0.75f; static final float JPEG_QUALITY = 0.75f;
static final int MAX_THUMB_SIZE_BYTES = 300 * 1024; static final int MAX_THUMB_SIZE_BYTES = 300 * 1024;
@@ -92,21 +92,21 @@ public class SimilarAsinImageEmbedder {
/** /**
* 嵌入单元格:下载 → resize → POI addPicture → createPicture全链路任一失败即 URL 文本兜底。 * 嵌入单元格:下载 → resize → POI addPicture → createPicture全链路任一失败即 URL 文本兜底。
* taskImageCache 由调用方在 writeResultWorkbook 入口创建(任务级 ConcurrentHashMap方法返回随栈帧释放 * 返回值成功时为缩略图实际像素width/height失败兜底为 null调用方据此自适应行高/列宽
*/ */
public void embed(Sheet sheet, public ImageDim embed(Sheet sheet,
Drawing<?> patriarch, Drawing<?> patriarch,
SXSSFWorkbook wb, SXSSFWorkbook wb,
int rowIdx, int rowIdx,
int colIdx, int colIdx,
String url, String url,
Row row, Row row,
Map<String, byte[]> taskImageCache) { Map<String, ResizedImage> taskImageCache) {
if (url == null || url.isBlank()) { if (url == null || url.isBlank()) {
return; return null;
} }
String trimmedUrl = url.trim(); String trimmedUrl = url.trim();
byte[] thumb; ResizedImage thumb;
try { try {
thumb = taskImageCache.get(trimmedUrl); thumb = taskImageCache.get(trimmedUrl);
boolean cacheHit = thumb != null; boolean cacheHit = thumb != null;
@@ -114,50 +114,74 @@ public class SimilarAsinImageEmbedder {
byte[] raw = downloadWithRetry(trimmedUrl); byte[] raw = downloadWithRetry(trimmedUrl);
thumb = resizeImage(trimmedUrl, raw); thumb = resizeImage(trimmedUrl, raw);
taskImageCache.put(trimmedUrl, thumb); 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 { } 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) { } catch (UnsupportedUrlException ex) {
log.warn("[similar-asin][image] unsupported-url url={} reason={}", trimmedUrl, ex.getMessage()); log.warn("[similar-asin][image] unsupported-url url={} reason={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl); row.createCell(colIdx).setCellValue(trimmedUrl);
return; return null;
} catch (DownloadOversizeException ex) { } catch (DownloadOversizeException ex) {
log.warn("[similar-asin][image] download-oversize url={} bytes={}", trimmedUrl, ex.size()); log.warn("[similar-asin][image] download-oversize url={} bytes={}", trimmedUrl, ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl); row.createCell(colIdx).setCellValue(trimmedUrl);
return; return null;
} catch (ResizeOversizeException ex) { } catch (ResizeOversizeException ex) {
log.warn("[similar-asin][image] resize-oversize url={} bytes={}", ex.url(), ex.size()); log.warn("[similar-asin][image] resize-oversize url={} bytes={}", ex.url(), ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl); row.createCell(colIdx).setCellValue(trimmedUrl);
return; return null;
} catch (ResizeException ex) { } catch (ResizeException ex) {
log.warn("[similar-asin][image] resize-fail url={} err={}", trimmedUrl, ex.getMessage()); log.warn("[similar-asin][image] resize-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl); row.createCell(colIdx).setCellValue(trimmedUrl);
return; return null;
} catch (IOException | TimeoutException ex) { } catch (IOException | TimeoutException ex) {
log.warn("[similar-asin][image] download-fail url={} err={}", trimmedUrl, ex.getMessage()); log.warn("[similar-asin][image] download-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl); row.createCell(colIdx).setCellValue(trimmedUrl);
return; return null;
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage()); log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl); row.createCell(colIdx).setCellValue(trimmedUrl);
return; return null;
} }
try { 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(); ClientAnchor anchor = wb.getCreationHelper().createClientAnchor();
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_DONT_RESIZE);
anchor.setCol1(colIdx); anchor.setCol1(colIdx);
anchor.setRow1(rowIdx); anchor.setRow1(rowIdx);
anchor.setCol2(colIdx + 1); anchor.setCol2(colIdx);
anchor.setRow2(rowIdx + 1); anchor.setRow2(rowIdx);
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE); // 显式 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); patriarch.createPicture(anchor, picIdx);
ImageDim dim = new ImageDim(thumb.width(), thumb.height());
// POI 已经把缩略图字节复制进 picture poolB 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。 // POI 已经把缩略图字节复制进 picture poolB 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。
// 同 URL 行内重复出现概率较低,且 cache 仍承担同任务跨行去重收益(首次 miss 时已经 put 过)。
taskImageCache.remove(trimmedUrl); taskImageCache.remove(trimmedUrl);
return dim;
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage()); log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl); 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 触发文本兜底。 * 出口校验字节数 ≤ MAX_THUMB_SIZE_BYTES超限抛 ResizeOversizeException 触发文本兜底。
*/ */
byte[] resizeImage(String sourceUrl, byte[] raw) throws IOException { ResizedImage resizeImage(String sourceUrl, byte[] raw) throws IOException {
guardImageDimensions(sourceUrl, raw); guardImageDimensions(sourceUrl, raw);
BufferedImage src = ImageIO.read(new ByteArrayInputStream(raw)); BufferedImage src = ImageIO.read(new ByteArrayInputStream(raw));
if (src == null) { if (src == null) {
@@ -318,9 +342,15 @@ public class SimilarAsinImageEmbedder {
if (baos.size() > MAX_THUMB_SIZE_BYTES) { if (baos.size() > MAX_THUMB_SIZE_BYTES) {
throw new ResizeOversizeException(sourceUrl, baos.size()); 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。 */ /** 在解码整张位图前用 ImageReader 仅读取头部尺寸,避免“图像炸弹”导致 heap OOM。 */
private static void guardImageDimensions(String sourceUrl, byte[] raw) throws IOException { private static void guardImageDimensions(String sourceUrl, byte[] raw) throws IOException {
try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(raw))) { try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(raw))) {

View File

@@ -35,18 +35,21 @@ class SimilarAsinImageEmbedderTest {
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(src, "jpg", baos); 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); assertNotNull(thumb);
assertTrue(thumb.length > 0, "thumb 不可为空"); assertNotNull(thumb.bytes());
assertTrue(thumb.length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES, assertTrue(thumb.bytes().length > 0, "thumb 不可为空");
"thumb 字节应 <= 40KB 上限,实际=" + thumb.length); 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 重新解码"); assertNotNull(decoded, "thumb 应可被 ImageIO 重新解码");
int longEdge = Math.max(decoded.getWidth(), decoded.getHeight()); assertEquals(thumb.width(), decoded.getWidth(), "返回宽度应与解码宽度一致");
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, longEdge, assertEquals(thumb.height(), decoded.getHeight(), "返回高度应与解码高度一致");
"长边应等于 TARGET_LONG_EDGE_PX=300");
} }
@Test @Test