优化图片尺寸
This commit is contained in:
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"与"用户真正失败",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_POINTS(Excel 上限 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(), "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 anchor:col1==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 pool(B 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。
|
// POI 已经把缩略图字节复制进 picture pool(B 副本),此时移除 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_PX,JPEG 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))) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user