提交货源图片和相似ASIN优化

This commit is contained in:
super
2026-05-24 21:59:21 +08:00
parent 532438faba
commit 7503e3fa8b
14 changed files with 1265 additions and 163 deletions

3
.gitignore vendored
View File

@@ -43,6 +43,7 @@ MANIFEST
.installed.cfg
# Build / packaging
app.zip
build/
dist/
develop-eggs/
@@ -108,4 +109,4 @@ xlsx/
OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md
架构.md
*ts.%
.omc
.omc

View File

@@ -417,8 +417,8 @@ class AmamzonBase(ZiniaoDriver):
"""
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if level == "ERROR":
show_notification(message, "error")
# if level == "ERROR":
# show_notification(message, "error")
print(f"[{timestamp}] [{self.mark_name}] [{level}] {message}")
except Exception as e:
print(f"输出出错,{e}")
@@ -689,8 +689,8 @@ class TaskBase:
"""
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if level == "ERROR":
show_notification(message, "error")
# if level == "ERROR":
# show_notification(message, "error")
print(f"[{timestamp}] [{self.task_name}] [{level}] {message}")
except Exception as e:
print(f"输出出错,{e}")
@@ -739,7 +739,7 @@ class TaskBase:
if not shop_data:
mes = f"获取店铺凭证失败,响应数据: {shop_data.get('message', '未知错误')}"
self.log(mes, "ERROR")
show_notification(mes, "ERROR")
# show_notification(mes, "ERROR")
continue
password = shop_data["data"]["password"]

View File

@@ -674,12 +674,12 @@ class ApproveTask(TaskBase):
return
current_url = None
max_retries = 200 #只要没有完成,一直重试
for _ in range(max_retries):
try:
self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter,target_url=current_url)
driver.reset_already_asin()
driver.close_store()
break
except Exception as e:
self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR")
@@ -777,7 +777,7 @@ class ApproveTask(TaskBase):
}
result.append(country_data)
if len(result) > 20:
if len(result) > 10:
self.post_result_batch(task_id, shop_name, country_code, result)
result = []

View File

@@ -9,7 +9,7 @@ from datetime import datetime
from DrissionPage import Chromium, ChromiumOptions
from collections import defaultdict
from config import base_dir
from config import base_dir,debug
from amazon.tool import get_shop_info,show_notification
@@ -46,7 +46,8 @@ class ChromeAmzoneBase:
杀死当前谷歌浏览器进程,并使用 drissionpage 启动谷歌浏览器,使用系统安装的浏览器默认用户文件夹
"""
print("正在关闭现有的chromium浏览器进程...")
os.system('taskkill /f /t /im chrome.exe')
if not debug:
os.system('taskkill /f /t /im chrome.exe')
time.sleep(2)
print("正在启动chromium浏览器...")
@@ -70,8 +71,8 @@ class ChromeAmzoneBase:
"""
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if level == "ERROR":
show_notification(message, "error")
# if level == "ERROR":
# show_notification(message, "error")
print(f"[{timestamp}] [{self.mark_name}] [{level}] {message}")
except Exception as e:
print(f"输出出错,{e}")

View File

@@ -79,7 +79,8 @@ class ChromeAmzone(ChromeAmzoneBase):
except Exception as e:
error_msg = f"运行出错: {traceback.format_exc()}"
self.log(error_msg)
show_notification(f"采集失败: {str(e)}", "error")
# show_notification(f"采集失败: {str(e)}", "error")
raise RuntimeError(error_msg)
return {}
def _scrape_data(self):
@@ -100,8 +101,27 @@ class ChromeAmzone(ChromeAmzoneBase):
title_ele = self.tab.ele('xpath://h1[@id="title"]',timeout=30)
title = title_ele.text
data["title"] = title
sku_ele_ls = self.tab.eles('xpath://ul[@class="a-unordered-list a-vertical a-spacing-mini"]', timeout=20)
if len(sku_ele_ls) > 0:
data["title"] += sku_ele_ls[0].text
imge_ele = self.tab.ele('xpath://div[@id="imgTagWrapperId"]//img',timeout=20)
image_url = imge_ele.attr("src")
image_url = ""
data_a_dynamic_image = imge_ele.attr("data-a-dynamic-image")
if data_a_dynamic_image:
dynamic_image_json = json.loads(data_a_dynamic_image)
self.log(f"图片信息:{dynamic_image_json}")
max_area = 0
for url, (width, height) in dynamic_image_json.items():
area = width * height
if area > max_area:
max_area = area
image_url = url
if not image_url:
image_url = imge_ele.attr("src")
data["image_url"] = image_url
return data
@@ -263,7 +283,7 @@ class SpiderTask(TaskBase):
result.append(res)
print("================")
is_done = gp_index == len(groups)-1
if len(result) > 20 or is_done:
if len(result) > 10 or is_done:
self.post_result(task_id=task_id,chunkIndex=gp_index+1,chunkTotal=len(groups),
asin=asin,item_data=result,is_done=is_done)
result = []

View File

@@ -378,7 +378,7 @@ class MatchTak(TaskBase):
"done": False
})
if len(result) > 20:
if len(result) > 10:
self.post_result_batch(task_id, shop_name, country_code,result)
result = []

File diff suppressed because one or more lines are too long

View File

@@ -10,9 +10,10 @@ from datetime import datetime
from collections import defaultdict
import requests
from urllib.parse import quote
import os
from curl_cffi import requests as requests_frp
from config import base_dir
from amazon.tool import show_notification,get_shop_info,remove_special_characters,split_currency_values
@@ -76,7 +77,9 @@ class ChromeAmzone(ChromeAmzoneBase):
将图片链接URL 或本地文件路径)转换为 Base64 编码的字符串。
"""
if image_source.startswith(('http://', 'https://')):
response = requests.get(image_source, timeout=10)
response = requests.get(image_source, timeout=10,headers={
"user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0 18444"
})
response.raise_for_status() # 非 2xx 状态码将抛出异常
image_data = response.content
else:
@@ -153,7 +156,7 @@ class ChromeAmzone(ChromeAmzoneBase):
# 发送第一次请求
try:
response = requests_frp.post(url, headers=headers, params=params, json=data, impersonate="chrome101",
cookies=cookie, proxies=proxies)
cookies=cookie, proxies=proxies,verify=False)
response.encoding = "utf-8"
# 检查响应状态码和数据有效性
@@ -176,6 +179,7 @@ class ChromeAmzone(ChromeAmzoneBase):
need_retry = True
# 如果需要重试且配置了代理URL
# need_retry = True
if need_retry and CONFIG_PROXY_URL and not proxies:
try:
proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10)
@@ -204,7 +208,7 @@ class ChromeAmzone(ChromeAmzoneBase):
if proxy_ip:
proxies = {
"http": f"http://{proxy_ip}",
"https": f"http://{proxy_ip}",
"https": f"https://{proxy_ip}",
}
if proxies:
@@ -251,7 +255,23 @@ class ChromeAmzone(ChromeAmzoneBase):
title = title_ele.text
data["title"] = title
imge_ele = self.tab.ele('xpath://div[@id="imgTagWrapperId"]//img',timeout=20)
image_url = imge_ele.attr("src")
image_url = ""
data_a_dynamic_image = imge_ele.attr("data-a-dynamic-image")
if data_a_dynamic_image:
dynamic_image_json = json.loads(data_a_dynamic_image)
self.log(f"图片信息:{dynamic_image_json}")
max_area = 0
for url, (width, height) in dynamic_image_json.items():
area = width * height
if area > max_area:
max_area = area
image_url = url
if not image_url:
image_url = imge_ele.attr("src")
data["image_url"] = image_url
# sku
@@ -277,12 +297,13 @@ class ChromeAmzone(ChromeAmzoneBase):
Returns:
dict: 包含采集到的数据
"""
return_data = {}
try:
# 验证国家是否支持
if country not in self.country_info:
error_msg = f"不支持的国家: {country},支持的国家有: {list(self.country_info.keys())}"
print(error_msg)
show_notification(error_msg, "error")
# show_notification(error_msg, "error")
return None
# 获取国家配置
@@ -314,6 +335,8 @@ class ChromeAmzone(ChromeAmzoneBase):
self.log("正在抓取商品数据...")
data = self._scrape_data()
return_data.update(data)
# 判断是否采集标题出错
new_size = "220,220"
pattern = r"\._(?:[A-Z]+)?(\d+)_\."
@@ -321,22 +344,25 @@ class ChromeAmzone(ChromeAmzoneBase):
# 使用正则替换
image_new_url = re.sub(pattern, lambda m: replacement, data["image_url"])
print(image_new_url)
iamge_base64 = self.image_to_base64(image_new_url)
similar_data = []
# 4、请求获取插件的数据
for page_num in range(total_page):
resp_data = self.get_aliprice_data(
page=page_num,
title = data["title"],
domain=domain,
category=data["category"],
imageBase64 = iamge_base64
)
self.log(f"Aliprice 扩展数据获取:{str(resp_data)[0:200]}")
if len(resp_data.get("data",[])) > 0:
for i in resp_data.get("data"):
similar_data.append(i)
if image_new_url:
for page_num in range(total_page):
resp_data = self.get_aliprice_data(
page=page_num+1,
title = data["title"],
domain=domain,
category=data["category"],
imageBase64 = iamge_base64
)
self.log(f"Aliprice 扩展数据获取:{str(resp_data)[0:200]}")
if len(resp_data.get("data",[])) > 0:
for i in resp_data.get("data"):
similar_data.append(i)
data["similar_data"] = similar_data
@@ -346,14 +372,17 @@ class ChromeAmzone(ChromeAmzoneBase):
data['url'] = product_url
data['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print(f"数据抓取完成: {json.dumps(data)}")
return data
print(f"数据抓取完成: {json.dumps(return_data)}")
return_data.update(data)
return return_data
except Exception as e:
error_msg = f"运行出错: {traceback.format_exc()}"
print(error_msg)
show_notification(f"采集失败: {str(e)}", "error")
return {}
# show_notification(f"采集失败: {str(e)}", "error")
raise RuntimeError(f"{traceback.format_exc()}")
return return_data
class SimilarAsinTask(TaskBase):
@@ -478,10 +507,9 @@ class SimilarAsinTask(TaskBase):
result = []
for gp_index,gp in enumerate(groups):
items = gp.get("items", [])
return_data = {}
group_item = []
for index,value in enumerate(items):
print(value)
return_data = {
'image_url': "",
'title': "",
@@ -494,37 +522,36 @@ class SimilarAsinTask(TaskBase):
country = value.get("country")
for _ in range(max_retry):
try:
return_data = chrome.run(country, asin,total_page=2) or {}
return_data = chrome.run(country, asin,total_page=1) or {}
self.log(f"抓取结果->{return_data}")
break
except Exception as e:
# if "与页面的连接已断开" in str(e):
chrome = ChromeAmzone()
self.log(f"{asin}抓取数据报错,{e}")
if not isinstance(return_data, dict):
return_data = {}
if return_data.get("image_url"):
break
# if not isinstance(return_data, dict):
# return_data = {}
# if return_data.get("image_url"):
# break
group_item.append({
"sourceFileKey": value.get("sourceFileKey",""),
"sourceFilename": value.get("sourceFilename",""),
"rowToken": value.get("rowToken",""),
"groupKey": value.get("groupKey",""),
"id": value.get("values",{}).get("id",""),
"asin": value.get("asin",""),
"sku" : return_data.get("sku",""),
"country": value.get("country",""),
"url": return_data.get("image_url",""),
"title": return_data.get("title",""),
"done": False,
"urls" : [i.get("ori_picture") for i in return_data.get("similar_data",[])[:16]]
})
# task_id: int, chunkIndex:int,chunkTotal: int, country_code: str, asin: str, status: dict,error:str="",
# item_data:dict={},
group_item = [
{
"sourceFileKey": i.get("sourceFileKey"),
"sourceFilename": i.get("sourceFilename"),
"rowToken": i.get("rowToken"),
"groupKey": i.get("groupKey"),
"id": i.get("id"),
"asin": i.get("asin"),
"sku" : return_data.get("sku"),
"country": i.get("country"),
"url": return_data.get("image_url"),
"title": return_data.get("title"),
"done": False,
"urls" : [i.get("ori_picture") for i in return_data.get("similar_data")]
}
for i in items
]
# task_id: int, chunkIndex:int,chunkTotal: int, country_code: str, asin: str, status: dict,error:str="",
# item_data:dict={},
res = {
"sourceFileKey": gp.get("sourceFileKey"),
"sourceFilename": gp.get("sourceFilename"),
@@ -629,7 +656,7 @@ class SimilarAsinTask(TaskBase):
time.sleep(2)
self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR")
raise RuntimeError("已达到最大重试次数,结果回传最终失败")
# raise RuntimeError("已达到最大重试次数,结果回传最终失败")
if __name__ == '__main__':
spide = ChromeAmzone()

View File

@@ -71,6 +71,17 @@ public class SimilarAsinProperties {
*/
private boolean cozeUseLegacyItemFieldOrder = false;
/**
* 是否启用 P0-3 merge 增量缓冲:每个 batch DONE 时仅缓冲 cozeRows
* 不立即合并到 chunkfinalize 前一次性按 chunkScopeHash 分组合并,
* 把 OSS chunk 读写从 1000+ 次降到 chunk 数量级。
* 仅作用于"正常 poll DONE"路径;失败 batch / 单 batch 任务 / 其他
* 11 个 mergeCozeRowsIntoChunk 调用点保留原立即 merge 行为。
* 出现问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED=false
* 一键回滚到老路径。
*/
private boolean cozeResultBufferEnabled = true;
@Data
public static class CozeCredential {
private String name;

View File

@@ -34,6 +34,7 @@ import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskBatchVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskDetailVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskItemVo;
import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
@@ -56,7 +57,6 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Drawing;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
@@ -892,7 +892,7 @@ public class SimilarAsinTaskService {
touchJavaSideTaskActivity(task.getId());
} else if (isResultSubmissionComplete(task.getId())) {
maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext(
job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0, null));
job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0, null, null));
}
}
@@ -1436,6 +1436,9 @@ public class SimilarAsinTaskService {
boolean shouldAssembleResult = assembleWorkbook && (finalError == null || finalError.isBlank() || hasPersistedResultRows);
if (shouldAssembleResult && shouldAssembleSynchronously()) {
try {
// P0-3stale-recovery 同步 finalize 路径走这里,确保
// assemble 之前缓冲的 cozeRows 已合并到 chunk。
flushBufferedCozeResults(task.getId());
assembleResultWorkbook(task, result);
} catch (Exception ex) {
log.warn("[similar-asin] assemble result workbook failed taskId={} err={}", task.getId(), ex.getMessage());
@@ -1554,6 +1557,16 @@ public class SimilarAsinTaskService {
saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result");
return false;
}
// P0-3stale-recovery / 异常路径兜底 —— maybeFinalizeCozeJob 可能未能成功 flush
// Redis 锁竞争 / 异常退出),在 assemble 阶段读 chunk 之前再 flush 一次,幂等。
try {
flushBufferedCozeResults(task.getId());
} catch (Exception flushEx) {
log.warn("[similar-asin] result file job flush buffered coze results failed taskId={} jobId={} err={}",
task.getId(), job.getId(), flushEx.getMessage(), flushEx);
throw new BusinessException("flush buffered coze results failed: "
+ firstNonBlank(flushEx.getMessage(), "unknown error"));
}
saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze");
boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId);
if (pendingCoze) {
@@ -2020,7 +2033,8 @@ public class SimilarAsinTaskService {
batchTotal,
currentInstanceId(),
0,
credentialName
credentialName,
null
);
String batchPayload = writeJson(batchRows, "serialize pending coze batch payload failed");
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
@@ -2074,7 +2088,8 @@ public class SimilarAsinTaskService {
batchTotal,
currentInstanceId(),
0,
credentialName
credentialName,
null
);
String batchPayload = writeJson(batchRows, "serialize coze batch payload failed");
String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast(
@@ -2192,10 +2207,27 @@ public class SimilarAsinTaskService {
if (!failureMessage.isBlank()) {
cozeRows = cozeClient.markRowsFailed(batchRows, failureMessage);
}
FileTaskEntity task = taskForPoll(state.getTaskId());
if (task != null) {
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = allRowsByBaseIdForPoll(task);
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
// P0-3仅"DONE 且 batchTotal>1 且 feature toggle 开启"时缓冲 cozeRows 到 transient storage
// 由 finalize 阶段一次性合并到 chunk。失败 batch / 单 batch 任务保留原立即 merge 路径。
boolean buffered = false;
if (failureMessage.isBlank()
&& isCozeResultBufferEnabled()
&& context.batchTotal() != null && context.batchTotal() > 1
&& cozeRows != null && !cozeRows.isEmpty()) {
CozeBatchContext bufferedContext = bufferCozeResultForFlush(state, context, cozeRows);
if (bufferedContext != null
&& bufferedContext.resultPayloadPointer() != null
&& !bufferedContext.resultPayloadPointer().isBlank()) {
context = bufferedContext;
buffered = true;
}
}
if (!buffered) {
FileTaskEntity task = taskForPoll(state.getTaskId());
if (task != null) {
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = allRowsByBaseIdForPoll(task);
mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId);
}
}
markCozeStateTerminal(state,
failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED,
@@ -2473,7 +2505,8 @@ public class SimilarAsinTaskService {
partTotal,
parentContext.ownerInstanceId(),
retryCount,
firstNonBlank(credentialName, parentContext.credentialName())
firstNonBlank(credentialName, parentContext.credentialName()),
null
);
LocalDateTime now = LocalDateTime.now();
String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed");
@@ -2911,6 +2944,17 @@ public class SimilarAsinTaskService {
touchJavaSideTaskActivity(taskId);
return;
}
// P0-3requeue assemble 之前一次性把缓冲的 cozeRows 合并到 chunk。
// 失败时 markFailed job 并阻止 requeue避免 assemble 阶段读到不完整 chunk。
try {
flushBufferedCozeResults(taskId);
} catch (Exception flushEx) {
String message = firstNonBlank(flushEx.getMessage(), "flush buffered coze results failed");
log.warn("[similar-asin] coze finalize flush failed, marking job failed taskId={} jobId={} err={}",
taskId, job.getId(), message, flushEx);
taskFileJobService.markFailed(job, message);
return;
}
boolean requeued = taskFileJobService.requeue(job.getId(), "Coze results ready, assembling xlsx");
if (requeued) {
log.info("[similar-asin] coze async results ready, result file job requeued taskId={} jobId={} resultId={}",
@@ -3076,6 +3120,204 @@ public class SimilarAsinTaskService {
}
}
/**
* P0-3读取 state 缓冲的 cozeRows。
* 与 readCozeBatchRows读输入 batchRows不同这里读的是
* 通过 bufferCozeResultForFlush 写入 transient storage 的 DONE 结果。
*/
private List<SimilarAsinResultRowDto> readBufferedCozeRows(String resultPayloadPointer) {
if (resultPayloadPointer == null || resultPayloadPointer.isBlank()) {
return List.of();
}
try {
String payloadJson = transientPayloadStorageService.resolvePayload(
resultPayloadPointer, "read similar ASIN coze result buffer failed");
if (payloadJson == null || payloadJson.isBlank()) {
return List.of();
}
JsonNode array = objectMapper.readTree(payloadJson);
if (!array.isArray()) {
return List.of();
}
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
for (JsonNode node : array) {
rows.add(objectMapper.treeToValue(node, SimilarAsinResultRowDto.class));
}
return rows;
} catch (Exception ex) {
log.warn("[similar-asin] read coze result buffer failed pointer={} err={}",
resultPayloadPointer, ex.getMessage());
return List.of();
}
}
/**
* P0-3把单个 DONE batch 的 cozeRows 缓冲到 transient storage不立即写 chunk
* 返回更新后的 CozeBatchContext含 resultPayloadPointer调用方需写回 stateJson。
* 缓冲失败时返回原 context调用方应回退到立即 merge 路径。
*/
private CozeBatchContext bufferCozeResultForFlush(TaskScopeStateEntity state,
CozeBatchContext context,
List<SimilarAsinResultRowDto> cozeRows) {
if (state == null || context == null || cozeRows == null || cozeRows.isEmpty()) {
return context;
}
try {
String payloadJson = writeJson(cozeRows, "serialize coze result buffer failed");
String pointer = transientPayloadStorageService.storeParsedPayloadEntry(
MODULE_TYPE, state.getTaskId(), state.getScopeHash(),
"coze-result-" + state.getId(), payloadJson, true);
if (pointer == null || pointer.isBlank()) {
return context;
}
CozeBatchContext bufferedContext = withResultPayloadPointer(context, pointer);
String updatedStateJson = writeJson(bufferedContext, "serialize coze result buffer context failed");
int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.set(TaskScopeStateEntity::getStateJson, updatedStateJson)
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
if (updated <= 0) {
transientPayloadStorageService.deletePayloadIfPresent(pointer);
return context;
}
state.setStateJson(updatedStateJson);
return bufferedContext;
} catch (Exception ex) {
log.warn("[similar-asin] buffer coze result failed taskId={} stateId={} err={}",
state.getTaskId(), state.getId(), ex.getMessage());
return context;
}
}
/**
* P0-3清掉 state 上缓冲的 pointer + 删除 transient payload。
* 在 flush 成功合并到 chunk 后调用。
*/
private void clearBufferedCozeResult(TaskScopeStateEntity state, CozeBatchContext context) {
if (state == null || context == null) {
return;
}
String pointer = context.resultPayloadPointer();
if (pointer == null || pointer.isBlank()) {
return;
}
try {
CozeBatchContext clearedContext = withResultPayloadPointer(context, null);
String updatedStateJson = writeJson(clearedContext, "serialize coze result buffer cleared context failed");
taskScopeStateMapper.update(null, new LambdaUpdateWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getId, state.getId())
.set(TaskScopeStateEntity::getStateJson, updatedStateJson)
.set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now()));
state.setStateJson(updatedStateJson);
transientPayloadStorageService.deletePayloadIfPresent(pointer);
} catch (Exception ex) {
log.warn("[similar-asin] clear coze result buffer failed taskId={} stateId={} err={}",
state.getTaskId(), state.getId(), ex.getMessage());
}
}
private boolean isCozeResultBufferEnabled() {
return properties.isCozeResultBufferEnabled();
}
/**
* P0-3在 finalize 之前一次性把所有 DONE/FAILED state 上缓冲的 cozeRows
* 按 chunkScopeHash 分组合并到 chunk。把每个 batch 的"loadSubmittedChunks +
* readChunkRows × N + writeChunkPayload × M"压缩成"读 chunk 一次 +
* 写 chunk 一次"。
*/
private void flushBufferedCozeResults(Long taskId) {
if (taskId == null || taskId <= 0) {
return;
}
List<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.eq(TaskScopeStateEntity::getTaskId, taskId)
.eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)
.in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_DONE, COZE_STATUS_FAILED)));
if (states == null || states.isEmpty()) {
return;
}
List<BufferedFlushEntry> entries = new ArrayList<>();
for (TaskScopeStateEntity state : states) {
CozeBatchContext context = readCozeBatchContext(state);
if (context == null) {
continue;
}
String pointer = context.resultPayloadPointer();
if (pointer == null || pointer.isBlank()) {
continue;
}
List<SimilarAsinResultRowDto> rows = readBufferedCozeRows(pointer);
if (rows == null || rows.isEmpty()) {
// pointer 存在但 payload 已被清理(被 GC 或上一次 flush 部分成功),直接清 pointer。
clearBufferedCozeResult(state, context);
continue;
}
entries.add(new BufferedFlushEntry(state, context, rows));
}
if (entries.isEmpty()) {
return;
}
FileTaskEntity task = fileTaskMapper.selectById(taskId);
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
return;
}
Map<String, List<SimilarAsinParsedRowVo>> allRowsByBaseId = loadAllRowsByBaseId(task);
// 按 chunkScopeHash 分组(空字符串占位 null让 mergeCozeRowsIntoChunk 走全 chunk 扫描匹配)
Map<String, List<BufferedFlushEntry>> entriesByChunk = new LinkedHashMap<>();
for (BufferedFlushEntry entry : entries) {
String key = entry.context().chunkScopeHash() == null ? "" : entry.context().chunkScopeHash();
entriesByChunk.computeIfAbsent(key, ignored -> new ArrayList<>()).add(entry);
}
int totalFlushed = 0;
int failedGroups = 0;
for (Map.Entry<String, List<BufferedFlushEntry>> group : entriesByChunk.entrySet()) {
List<BufferedFlushEntry> groupEntries = group.getValue();
List<SimilarAsinResultRowDto> aggregated = new ArrayList<>();
for (BufferedFlushEntry entry : groupEntries) {
aggregated.addAll(entry.rows());
}
if (aggregated.isEmpty()) {
continue;
}
String chunkScopeHash = group.getKey().isEmpty() ? null : group.getKey();
Integer chunkIndex = null;
if (chunkScopeHash != null) {
for (BufferedFlushEntry entry : groupEntries) {
if (entry.context().chunkIndex() != null) {
chunkIndex = entry.context().chunkIndex();
break;
}
}
}
try {
mergeCozeRowsIntoChunk(task, chunkScopeHash, chunkIndex, aggregated, allRowsByBaseId);
for (BufferedFlushEntry entry : groupEntries) {
clearBufferedCozeResult(entry.state(), entry.context());
}
totalFlushed += aggregated.size();
} catch (Exception ex) {
failedGroups++;
log.warn("[similar-asin] flush buffered coze results group failed taskId={} chunkScopeHash={} err={}",
taskId, chunkScopeHash, ex.getMessage(), ex);
// 不 clear pointer留待下次 finalize 或 stale-recovery 重试
}
}
if (totalFlushed > 0 || failedGroups > 0) {
log.info("[similar-asin] flushed buffered coze results taskId={} entries={} totalRows={} chunkGroups={} failedGroups={}",
taskId, entries.size(), totalFlushed, entriesByChunk.size(), failedGroups);
}
if (failedGroups > 0) {
// 让 finalize 能感知到失败,调用方应避免继续 requeue assemble。
throw new IllegalStateException("flush buffered coze results failed groups=" + failedGroups);
}
}
private record BufferedFlushEntry(TaskScopeStateEntity state,
CozeBatchContext context,
List<SimilarAsinResultRowDto> rows) {
}
private CozeBatchContext readCozeBatchContext(TaskScopeStateEntity state) {
if (state == null || state.getStateJson() == null || state.getStateJson().isBlank()) {
return null;
@@ -3103,7 +3345,23 @@ public class SimilarAsinTaskService {
context.batchTotal(),
context.ownerInstanceId(),
submitRetryCount,
context.credentialName()
context.credentialName(),
context.resultPayloadPointer()
);
}
private CozeBatchContext withResultPayloadPointer(CozeBatchContext context, String resultPayloadPointer) {
return new CozeBatchContext(
context.jobId(),
context.resultId(),
context.chunkScopeHash(),
context.chunkIndex(),
context.batchIndex(),
context.batchTotal(),
context.ownerInstanceId(),
context.submitRetryCount(),
context.credentialName(),
resultPayloadPointer
);
}
@@ -3361,11 +3619,13 @@ public class SimilarAsinTaskService {
private void assembleResultWorkbook(FileTaskEntity task, FileResultEntity result) {
SimilarAsinParsedPayloadDto parsed = readParsedPayload(task);
Map<String, SimilarAsinResultRowDto> resultMap = loadPersistedResultRowsWithRetry(task.getId(), parsed.getAllItems().size());
int persistedResultRows = resultMap.size();
resultMap.entrySet().removeIf(entry -> !isExportableResultRow(entry.getValue()));
long resolvedRows = parsed.getAllItems().stream()
.filter(row -> findResultRow(row, resultMap) != null)
.count();
log.info("[similar-asin] assemble workbook taskId={} parsedRows={} resultRows={} resolvedRows={}",
task.getId(), parsed.getAllItems().size(), resultMap.size(), resolvedRows);
log.info("[similar-asin] assemble workbook taskId={} parsedRows={} persistedRows={} resultRows={} resolvedRows={}",
task.getId(), parsed.getAllItems().size(), persistedResultRows, resultMap.size(), resolvedRows);
if (!parsed.getAllItems().isEmpty() && resultMap.isEmpty()) {
throw new BusinessException("相似ASIN检测结果为空请稍后重试生成结果文件");
}
@@ -3645,6 +3905,9 @@ public class SimilarAsinTaskService {
private void writeResultWorkbook(File xlsx,
List<SimilarAsinParsedRowVo> rowsToWrite,
Map<String, SimilarAsinResultRowDto> resultMap) {
// Excel 365 "Place in Cell" 图片:写完 workbook 后 patch richData 单元格图片结构。
// 这不是浮动 Drawing因此点击图片区域会选中单元格图片不能被拖到任意位置也不需要工作表保护。
ExcelCellImageWriter.Session excelCellImageSession = ExcelCellImageWriter.createSession();
try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); FileOutputStream fos = new FileOutputStream(xlsx)) {
Sheet sheet = workbook.createSheet("相似asin检测");
CellStyle headerStyle = workbook.createCellStyle();
@@ -3652,7 +3915,6 @@ 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);
@@ -3668,7 +3930,7 @@ public class SimilarAsinTaskService {
}
// P2-8第一遍预扫所有图片 URL并行下载到 taskImageCache
// POI 写入仍单线程串行 embed()cache 命中直接 resize+addPicture),下载/写入解耦。
// POI 写入仍单线程串行注册 cell imagecache 命中直接 resize + register),下载/写入解耦。
List<String> prefetchUrls = collectImageUrlsForPrefetch(rowsToWrite, resultMap);
if (!prefetchUrls.isEmpty()) {
long prefetchStart = System.currentTimeMillis();
@@ -3692,13 +3954,14 @@ 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 假设一致,避免后续维护误读。
// 行高固定到 IMAGE_ROW_HEIGHT_POINTSExcel 上限 409pt
row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS);
imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_MAIN, resultRow.getMainUrl(), row, taskImageCache, false);
imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_PUZZLE1, resultRow.getPuzzleImg1(), row, taskImageCache, true);
imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_PUZZLE2, resultRow.getPuzzleImg2(), row, taskImageCache, true);
imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_MAIN,
resultRow.getMainUrl(), row, taskImageCache, excelCellImageSession);
imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE1,
resultRow.getPuzzleImg1(), row, taskImageCache, excelCellImageSession);
imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE2,
resultRow.getPuzzleImg2(), row, taskImageCache, excelCellImageSession);
}
rowIndex++;
}
@@ -3707,6 +3970,16 @@ public class SimilarAsinTaskService {
} catch (Exception ex) {
throw new BusinessException("生成相似ASIN检测结果失败", ex);
}
if (!excelCellImageSession.isEmpty()) {
try {
ExcelCellImageWriter.patchXlsxFile(xlsx, excelCellImageSession);
} catch (IOException ex) {
String cause = ex.toString();
log.error("[similar-asin] excel cell image patch failed xlsx={} images={} err={}",
xlsx.getAbsolutePath(), excelCellImageSession.size(), cause, ex);
throw new BusinessException("写入相似ASIN结果 Excel 单元格图片失败: " + cause, ex);
}
}
}
/**
@@ -3854,7 +4127,9 @@ public class SimilarAsinTaskService {
}
private boolean shouldInspectRow(SimilarAsinResultRowDto row) {
return row != null && (!normalize(row.getTitle()).isBlank() || row.hasImageUrl());
return row != null && (!rowKey(row).isBlank()
|| !normalize(row.getTitle()).isBlank()
|| row.hasImageUrl());
}
private boolean hasResolvedCozeFields(SimilarAsinResultRowDto row) {
@@ -3873,6 +4148,22 @@ public class SimilarAsinTaskService {
|| hasUsableCozeField(row.getStatus());
}
private boolean isExportableResultRow(SimilarAsinResultRowDto row) {
if (row == null) {
return false;
}
if (hasResolvedCozeFields(row)) {
return true;
}
String error = normalize(row.getError());
if (error.isBlank()) {
return false;
}
return !normalize(row.getTitle()).isBlank()
|| !normalize(row.getUrl()).isBlank()
|| row.hasImageUrl();
}
private boolean hasUsableCozeField(String value) {
String normalized = normalize(value);
return !normalized.isBlank() && !isTechnicalCozeFailure(normalized);
@@ -4590,7 +4881,8 @@ public class SimilarAsinTaskService {
Integer batchTotal,
String ownerInstanceId,
Integer submitRetryCount,
String credentialName) {
String credentialName,
String resultPayloadPointer) {
}
private static class SourceRowsBuilder {

View File

@@ -0,0 +1,430 @@
package com.nanri.aiimage.modules.similarasin.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* Excel 365 "Place in Cell" 图片写入器。
* <p>
* 该格式不是传统 drawing 浮动图片,而是 richData 单元格值:点击图片区域选中的是单元格,
* 图片无法像浮动对象一样拖到任意位置,也不需要开启工作表保护。
*/
public final class ExcelCellImageWriter {
private static final Logger log = LoggerFactory.getLogger(ExcelCellImageWriter.class);
private static final String CONTENT_TYPES_PART = "[Content_Types].xml";
private static final String WORKBOOK_RELS_PART = "xl/_rels/workbook.xml.rels";
private static final String SHEET1_PART = "xl/worksheets/sheet1.xml";
private static final String RICH_VALUE_RELS_PART = "xl/richData/_rels/richValueRel.xml.rels";
private static final String RD_RICH_VALUE_PART = "xl/richData/rdrichvalue.xml";
private static final String RD_RICH_VALUE_STRUCTURE_PART = "xl/richData/rdrichvaluestructure.xml";
private static final String RD_RICH_VALUE_TYPES_PART = "xl/richData/rdRichValueTypes.xml";
private static final String RICH_VALUE_REL_PART = "xl/richData/richValueRel.xml";
private static final String METADATA_PART = "xl/metadata.xml";
private static final String REL_TYPE_IMAGE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
private static final String REL_TYPE_RICH_VALUE_STRUCTURE =
"http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueStructure";
private static final String REL_TYPE_RICH_VALUE =
"http://schemas.microsoft.com/office/2017/06/relationships/rdRichValue";
private static final String REL_TYPE_RICH_VALUE_REL =
"http://schemas.microsoft.com/office/2022/10/relationships/richValueRel";
private static final String REL_TYPE_SHEET_METADATA =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata";
private static final String REL_TYPE_RICH_VALUE_TYPES =
"http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueTypes";
private ExcelCellImageWriter() {
}
public static Session createSession() {
return new Session();
}
public static void patchXlsxFile(File xlsx, Session session) throws IOException {
if (xlsx == null || !xlsx.exists() || session == null || session.isEmpty()) {
return;
}
Path xlsxPath = xlsx.toPath().toAbsolutePath();
Path parent = xlsxPath.getParent();
if (parent == null) {
throw new IOException("[excel-cell-image] xlsx has no parent directory: " + xlsxPath);
}
Path tmp = Files.createTempFile(parent, "excel-cell-image-", ".xlsx");
boolean success = false;
Set<String> replacementNames = replacementEntryNames(session);
int patchedCells = -1;
try {
try (ZipFile zfIn = new ZipFile(xlsxPath.toFile());
ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(tmp))) {
Enumeration<? extends ZipEntry> entries = zfIn.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (replacementNames.contains(name)) {
continue;
}
if (entry.isDirectory()) {
writeDirectoryEntry(zout, name);
continue;
}
byte[] bytes;
try (InputStream in = zfIn.getInputStream(entry)) {
bytes = readAll(in);
}
if (CONTENT_TYPES_PART.equals(name)) {
bytes = patchContentTypes(bytes);
} else if (WORKBOOK_RELS_PART.equals(name)) {
bytes = patchWorkbookRels(bytes);
} else if (SHEET1_PART.equals(name)) {
PatchSheetResult sheetResult = patchSheet(bytes, session);
bytes = sheetResult.bytes();
patchedCells = sheetResult.patchedCount();
if (sheetResult.patchedCount() != session.size()) {
log.warn("[excel-cell-image] patched cells mismatch file={} images={} patchedCells={}",
xlsx.getName(), session.size(), sheetResult.patchedCount());
}
}
writeEntry(zout, name, bytes);
}
writeEntry(zout, RICH_VALUE_RELS_PART, buildRichValueRels(session).getBytes(StandardCharsets.UTF_8));
writeEntry(zout, RD_RICH_VALUE_PART, buildRdRichValue(session).getBytes(StandardCharsets.UTF_8));
writeEntry(zout, RD_RICH_VALUE_STRUCTURE_PART, buildRdRichValueStructure().getBytes(StandardCharsets.UTF_8));
writeEntry(zout, RD_RICH_VALUE_TYPES_PART, buildRdRichValueTypes().getBytes(StandardCharsets.UTF_8));
writeEntry(zout, RICH_VALUE_REL_PART, buildRichValueRel(session).getBytes(StandardCharsets.UTF_8));
writeEntry(zout, METADATA_PART, buildMetadata(session).getBytes(StandardCharsets.UTF_8));
int idx = 1;
for (RegisteredCellImage image : session.images) {
writeEntry(zout, mediaPart(idx), image.bytes);
idx++;
}
}
try {
Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException moveEx) {
try {
Thread.sleep(200L);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING);
log.warn("[excel-cell-image] patch xlsx move retried after error file={} cause={}",
xlsx.getName(), moveEx.toString());
}
success = true;
log.info("[excel-cell-image] patch xlsx finished file={} images={} patchedCells={}",
xlsx.getName(), session.size(), patchedCells);
} catch (IOException ioe) {
log.error("[excel-cell-image] patch xlsx failed file={} images={} err={}",
xlsx.getName(), session.size(), ioe.toString(), ioe);
throw ioe;
} finally {
if (!success) {
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {
// 临时文件清理失败不影响主流程
}
}
}
}
private static Set<String> replacementEntryNames(Session session) {
Set<String> names = new HashSet<>();
names.add(RICH_VALUE_RELS_PART);
names.add(RD_RICH_VALUE_PART);
names.add(RD_RICH_VALUE_STRUCTURE_PART);
names.add(RD_RICH_VALUE_TYPES_PART);
names.add(RICH_VALUE_REL_PART);
names.add(METADATA_PART);
for (int i = 1; i <= session.images.size(); i++) {
names.add(mediaPart(i));
}
return names;
}
private static PatchSheetResult patchSheet(byte[] original, Session session) {
String content = new String(original, StandardCharsets.UTF_8);
int idx = 1;
int patchedCount = 0;
for (RegisteredCellImage image : session.images) {
String replacement = "<c r=\"" + image.cellRef + "\" t=\"e\" vm=\"" + idx + "\"><v>#VALUE!</v></c>";
Pattern pattern = Pattern.compile("<c\\b(?=[^>]*\\br=\"" + Pattern.quote(image.cellRef)
+ "\")[^>]*(?:/>|>.*?</c>)", Pattern.DOTALL);
Matcher matcher = pattern.matcher(content);
if (matcher.find()) {
content = matcher.replaceFirst(Matcher.quoteReplacement(replacement));
patchedCount++;
} else {
log.warn("[excel-cell-image] cell {} not found in sheet xml, skip image", image.cellRef);
}
idx++;
}
return new PatchSheetResult(content.getBytes(StandardCharsets.UTF_8), patchedCount);
}
private static byte[] patchContentTypes(byte[] original) {
String content = new String(original, StandardCharsets.UTF_8);
StringBuilder injection = new StringBuilder();
if (!content.contains("Extension=\"jpeg\"")) {
injection.append("<Default Extension=\"jpeg\" ContentType=\"image/jpeg\"/>");
}
appendOverrideIfMissing(content, injection, "/xl/metadata.xml",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml");
appendOverrideIfMissing(content, injection, "/xl/richData/richValueRel.xml",
"application/vnd.ms-excel.richvaluerel+xml");
appendOverrideIfMissing(content, injection, "/xl/richData/rdrichvalue.xml",
"application/vnd.ms-excel.rdrichvalue+xml");
appendOverrideIfMissing(content, injection, "/xl/richData/rdrichvaluestructure.xml",
"application/vnd.ms-excel.rdrichvaluestructure+xml");
appendOverrideIfMissing(content, injection, "/xl/richData/rdRichValueTypes.xml",
"application/vnd.ms-excel.rdrichvaluetypes+xml");
if (injection.isEmpty()) {
return original;
}
int closeIdx = content.lastIndexOf("</Types>");
if (closeIdx < 0) {
log.warn("[excel-cell-image] [Content_Types].xml missing </Types>, skip patch");
return original;
}
return (content.substring(0, closeIdx) + injection + content.substring(closeIdx))
.getBytes(StandardCharsets.UTF_8);
}
private static void appendOverrideIfMissing(String content, StringBuilder injection, String partName, String contentType) {
if (!content.contains("PartName=\"" + partName + "\"")) {
injection.append("<Override PartName=\"").append(partName)
.append("\" ContentType=\"").append(contentType).append("\"/>");
}
}
private static byte[] patchWorkbookRels(byte[] original) {
String content = new String(original, StandardCharsets.UTF_8);
int maxId = findMaxRId(content);
StringBuilder injection = new StringBuilder();
maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE_STRUCTURE,
"richData/rdrichvaluestructure.xml");
maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE,
"richData/rdrichvalue.xml");
maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE_REL,
"richData/richValueRel.xml");
maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_SHEET_METADATA,
"metadata.xml");
appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE_TYPES,
"richData/rdRichValueTypes.xml");
if (injection.isEmpty()) {
return original;
}
int closeIdx = content.lastIndexOf("</Relationships>");
if (closeIdx < 0) {
log.warn("[excel-cell-image] xl/_rels/workbook.xml.rels missing </Relationships>, skip patch");
return original;
}
return (content.substring(0, closeIdx) + injection + content.substring(closeIdx))
.getBytes(StandardCharsets.UTF_8);
}
private static int appendRelationshipIfMissing(String content,
StringBuilder injection,
int maxId,
String type,
String target) {
if (content.contains("Target=\"" + target + "\"")) {
return maxId;
}
int next = maxId + 1;
injection.append("<Relationship Id=\"rId").append(next)
.append("\" Type=\"").append(type)
.append("\" Target=\"").append(target).append("\"/>");
return next;
}
private static String buildRichValueRels(Session session) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
sb.append("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">");
for (int i = 1; i <= session.images.size(); i++) {
sb.append("<Relationship Id=\"rId").append(i)
.append("\" Type=\"").append(REL_TYPE_IMAGE)
.append("\" Target=\"../media/excelcellimage").append(i).append(".jpeg\"/>");
}
sb.append("</Relationships>");
return sb.toString();
}
private static String buildRdRichValue(Session session) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
sb.append("<rvData xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2017/richdata\" count=\"")
.append(session.images.size()).append("\">");
for (int i = 0; i < session.images.size(); i++) {
sb.append("<rv s=\"0\"><v>").append(i).append("</v><v>5</v></rv>");
}
sb.append("</rvData>");
return sb.toString();
}
private static String buildRdRichValueStructure() {
return "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
+ "<rvStructures xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2017/richdata\" count=\"1\">"
+ "<s t=\"_localImage\"><k n=\"_rvRel:LocalImageIdentifier\" t=\"i\"/><k n=\"CalcOrigin\" t=\"i\"/></s>"
+ "</rvStructures>";
}
private static String buildRdRichValueTypes() {
return "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>"
+ "<rvTypesInfo xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2017/richdata2\""
+ " xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\" mc:Ignorable=\"x\""
+ " xmlns:x=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">"
+ "<global><keyFlags>"
+ "<key name=\"_Self\"><flag name=\"ExcludeFromFile\" value=\"1\"/><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_DisplayString\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_Flags\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_Format\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_SubLabel\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_Attribution\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_Icon\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_Display\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_CanonicalPropertyNames\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "<key name=\"_ClassificationId\"><flag name=\"ExcludeFromCalcComparison\" value=\"1\"/></key>"
+ "</keyFlags></global></rvTypesInfo>";
}
private static String buildRichValueRel(Session session) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
sb.append("<richValueRels xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel\"")
.append(" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">");
for (int i = 1; i <= session.images.size(); i++) {
sb.append("<rel r:id=\"rId").append(i).append("\"/>");
}
sb.append("</richValueRels>");
return sb.toString();
}
private static String buildMetadata(Session session) {
StringBuilder sb = new StringBuilder();
int count = session.images.size();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
sb.append("<metadata xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"")
.append(" xmlns:xlrd=\"http://schemas.microsoft.com/office/spreadsheetml/2017/richdata\">")
.append("<metadataTypes count=\"1\">")
.append("<metadataType name=\"XLRICHVALUE\" minSupportedVersion=\"120000\" copy=\"1\" pasteAll=\"1\"")
.append(" pasteValues=\"1\" merge=\"1\" splitFirst=\"1\" rowColShift=\"1\" clearFormats=\"1\"")
.append(" clearComments=\"1\" assign=\"1\" coerce=\"1\"/>")
.append("</metadataTypes>")
.append("<futureMetadata name=\"XLRICHVALUE\" count=\"").append(count).append("\">");
for (int i = 0; i < count; i++) {
sb.append("<bk><extLst><ext uri=\"{3e2802c4-a4d2-4d8b-9148-e3be6c30e623}\">")
.append("<xlrd:rvb i=\"").append(i).append("\"/>")
.append("</ext></extLst></bk>");
}
sb.append("</futureMetadata><valueMetadata count=\"").append(count).append("\">");
for (int i = 0; i < count; i++) {
sb.append("<bk><rc t=\"1\" v=\"").append(i).append("\"/></bk>");
}
sb.append("</valueMetadata></metadata>");
return sb.toString();
}
private static int findMaxRId(String content) {
int maxId = 0;
Matcher matcher = Pattern.compile("Id=\"rId(\\d+)\"").matcher(content);
while (matcher.find()) {
try {
maxId = Math.max(maxId, Integer.parseInt(matcher.group(1)));
} catch (NumberFormatException ignored) {
// 非数字 rId 不影响新增关系
}
}
return maxId;
}
private static String mediaPart(int idx) {
return "xl/media/excelcellimage" + idx + ".jpeg";
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) {
baos.write(buf, 0, n);
}
return baos.toByteArray();
}
private static void writeEntry(ZipOutputStream zout, String name, byte[] bytes) throws IOException {
ZipEntry entry = new ZipEntry(name);
zout.putNextEntry(entry);
zout.write(bytes);
zout.closeEntry();
}
private static void writeDirectoryEntry(ZipOutputStream zout, String name) throws IOException {
ZipEntry entry = new ZipEntry(name);
zout.putNextEntry(entry);
zout.closeEntry();
}
public static final class Session {
private final List<RegisteredCellImage> images = new ArrayList<>();
public synchronized void registerImage(int rowIdx, int colIdx, byte[] jpegBytes) {
if (jpegBytes == null || jpegBytes.length == 0) {
return;
}
images.add(new RegisteredCellImage(cellRef(rowIdx, colIdx), jpegBytes));
}
public synchronized boolean isEmpty() {
return images.isEmpty();
}
public synchronized int size() {
return images.size();
}
private static String cellRef(int rowIdx, int colIdx) {
return columnName(colIdx) + (rowIdx + 1);
}
private static String columnName(int colIdx) {
int value = colIdx + 1;
StringBuilder sb = new StringBuilder();
while (value > 0) {
int rem = (value - 1) % 26;
sb.append((char) ('A' + rem));
value = (value - 1) / 26;
}
return sb.reverse().toString();
}
}
private record RegisteredCellImage(String cellRef, byte[] bytes) {
}
private record PatchSheetResult(byte[] bytes, int patchedCount) {
}
}

View File

@@ -8,13 +8,8 @@ import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.poi.ss.usermodel.ClientAnchor;
import org.apache.poi.ss.usermodel.Drawing;
import org.apache.poi.ss.usermodel.Cell;
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;
import javax.imageio.IIOImage;
@@ -173,25 +168,15 @@ public class SimilarAsinImageEmbedder {
}
/**
* 嵌入单元格:下载 resize → POI addPicture → createPicture全链路任一失败即 URL 文本兜底。
* 返回值成功时为缩略图实际像素width/height失败兜底为 null调用方据此自适应行高/列宽
* 抽取共享:下载 + resize + 写 cache。任一失败即写文本兜底cell 走 row.createCell(colIdx).setCellValue
* 失败时返回 null成功返回缩略图。给 Excel richData 单元格图片写入路径使用
*/
public ImageDim embed(Sheet sheet,
Drawing<?> patriarch,
SXSSFWorkbook wb,
int rowIdx,
int colIdx,
String url,
Row row,
Map<String, ResizedImage> taskImageCache,
boolean fillCell) {
if (url == null || url.isBlank()) {
return null;
}
String trimmedUrl = url.trim();
ResizedImage thumb;
private ResizedImage downloadAndResize(String trimmedUrl,
int colIdx,
Row row,
Map<String, ResizedImage> taskImageCache) {
try {
thumb = taskImageCache.get(trimmedUrl);
ResizedImage thumb = taskImageCache.get(trimmedUrl);
boolean cacheHit = thumb != null;
if (!cacheHit) {
byte[] raw = downloadWithRetry(trimmedUrl);
@@ -203,73 +188,46 @@ public class SimilarAsinImageEmbedder {
log.debug("[similar-asin][image] cache-hit url={} bytes={} dims={}x{}",
trimmedUrl, thumb.bytes().length, thumb.width(), thumb.height());
}
return thumb;
} catch (UnsupportedUrlException ex) {
log.warn("[similar-asin][image] unsupported-url url={} reason={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
} catch (DownloadOversizeException ex) {
log.warn("[similar-asin][image] download-oversize url={} bytes={}", trimmedUrl, ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
} catch (ResizeOversizeException ex) {
log.warn("[similar-asin][image] resize-oversize url={} bytes={}", ex.url(), ex.size());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
} catch (ResizeException ex) {
log.warn("[similar-asin][image] resize-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
} catch (IOException | TimeoutException ex) {
log.warn("[similar-asin][image] download-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
} catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
}
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
}
public ImageDim embedAsExcelCellImage(int rowIdx,
int colIdx,
String url,
Row row,
Map<String, ResizedImage> taskImageCache,
ExcelCellImageWriter.Session excelCellImageSession) {
if (url == null || url.isBlank() || excelCellImageSession == null) {
return null;
}
String trimmedUrl = url.trim();
ResizedImage thumb = downloadAndResize(trimmedUrl, colIdx, row, taskImageCache);
if (thumb == null) {
return null;
}
try {
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);
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;
if (fillCell) {
anchor.setDx1(0);
anchor.setDy1(0);
anchor.setDx2((int) Math.round(cellWidthEmu));
anchor.setDy2((int) Math.round(cellHeightEmu));
} else {
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 副本可立刻释放。
Cell cell = row.createCell(colIdx);
cell.setCellValue("#VALUE!");
excelCellImageSession.registerImage(rowIdx, colIdx, thumb.bytes());
taskImageCache.remove(trimmedUrl);
return dim;
return new ImageDim(thumb.width(), thumb.height());
} catch (RuntimeException ex) {
log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage());
log.warn("[similar-asin][image] excel-cell-image-fail url={} err={}", trimmedUrl, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
}

View File

@@ -0,0 +1,356 @@
package com.nanri.aiimage.modules.similarasin.util;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* WPS DISPIMG "图片嵌入单元格" 协议写入器。
* <p>
* Excel 1997-2007 OOXML 没有 "图片嵌入单元格" 概念POI 5.2.5 也不支持写入 Excel 365 的 IMAGE() 函数。
* 本类基于 WPS 在 OOXML 上的扩展协议cellimages.xml让生成的 xlsx 在 WPS、钉钉表格、
* 飞书表格、Excel 365 中表现为 "图片真嵌入到单元格内":解除工作表保护后图片仍跟着单元格走,
* 拷贝粘贴行/排序时图片随单元格移动,无法被鼠标拖出。
* <p>
* 用法:
* <pre>
* WpsCellImageWriter.Session session = WpsCellImageWriter.createSession();
* // 写每个 cell
* String id = session.registerImage(jpegBytes);
* cell.setCellFormula(WpsCellImageWriter.dispImgFormula(id));
* // workbook.write(file) 之后调用:
* WpsCellImageWriter.patchXlsxFile(xlsx, session);
* </pre>
* <p>
* 协议参考WPS Office 表格扩展 cellimages.xml。
*/
@Slf4j
public final class WpsCellImageWriter {
public static final String CELLIMAGES_PART = "xl/cellimages.xml";
public static final String CELLIMAGES_RELS_PART = "xl/_rels/cellimages.xml.rels";
public static final String CONTENT_TYPES_PART = "[Content_Types].xml";
public static final String WORKBOOK_RELS_PART = "xl/_rels/workbook.xml.rels";
public static final String CELLIMAGES_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.cellimages+xml";
public static final String CELLIMAGES_REL_TYPE =
"http://www.wps.cn/officeDocument/2018/relationships/cellimage";
public static final String IMAGE_REL_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
private WpsCellImageWriter() {
}
public static Session createSession() {
return new Session();
}
/**
* 构造 DISPIMG 公式。
* <p>
* 第二个参数固定 1图片自动缩放到单元格大小与 IMAGE_ROW_HEIGHT_POINTS / IMAGE_COL_WIDTH_CHARS 配合,
* 既不会拉伸变形也能填充单元格)。
*/
public static String dispImgFormula(String id) {
if (id == null || id.isBlank()) {
return null;
}
return "_xlfn.DISPIMG(\"" + id + "\",1)";
}
/**
* 在已写出的 xlsx 文件上 patch DISPIMG 协议所需的 4 个 part
* - 新建 xl/cellimages.xml
* - 新建 xl/_rels/cellimages.xml.rels
* - 新建 xl/media/cellimage{N}.jpeg
* - 修改 [Content_Types].xml加 Override
* - 修改 xl/_rels/workbook.xml.rels加 Relationship
* <p>
* 通过临时文件 + REPLACE_EXISTING move 实现:失败时 xlsx 文件保持原样不被破坏。
* 历史使用 ATOMIC_MOVE+REPLACE_EXISTING但 Java NIO 规范说 ATOMIC_MOVE 下其它选项被忽略,
* Windows 实现遇到目标已存在会抛 AccessDeniedException / FileAlreadyExistsException
* 故改为常规 REPLACE_EXISTINGWindows 内部走 MoveFileEx行为已非常接近原子
*/
public static void patchXlsxFile(File xlsx, Session session) throws IOException {
if (xlsx == null || !xlsx.exists() || session == null || session.isEmpty()) {
return;
}
Path xlsxPath = xlsx.toPath().toAbsolutePath();
Path parent = xlsxPath.getParent();
if (parent == null) {
throw new IOException("[wps-dispimg] xlsx has no parent directory: " + xlsxPath);
}
Path tmp = Files.createTempFile(parent, "wps-dispimg-", ".xlsx");
boolean success = false;
try {
// 使用 ZipFile基于中央目录读替代 ZipInputStream顺序流读
// 原因POI SXSSFWorkbook 写出的 xlsx部分 entry 的 LFHLocal File Header会用 data descriptor
// 把 size 字段置 0把真实 CRC/size 放到 entry 末尾。ZipInputStream 在 STORED 模式下按 LFH 的 size=0
// 校验,会抛 "ZipException: invalid entry size (expected 0 but got N bytes)"。ZipFile 直接读中央目录
// 拿到真实 size绕开该问题。
try (ZipFile zfIn = new ZipFile(xlsxPath.toFile());
ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(tmp))) {
Enumeration<? extends ZipEntry> entries = zfIn.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (entry.isDirectory()) {
writeDirectoryEntry(zout, name);
continue;
}
byte[] bytes;
try (InputStream in = zfIn.getInputStream(entry)) {
bytes = readAll(in);
}
if (CONTENT_TYPES_PART.equals(name)) {
bytes = patchContentTypes(bytes);
} else if (WORKBOOK_RELS_PART.equals(name)) {
bytes = patchWorkbookRels(bytes);
}
writeEntry(zout, name, bytes);
}
writeEntry(zout, CELLIMAGES_PART,
buildCellImagesXml(session).getBytes(StandardCharsets.UTF_8));
writeEntry(zout, CELLIMAGES_RELS_PART,
buildCellImagesRelsXml(session).getBytes(StandardCharsets.UTF_8));
int idx = 1;
for (RegisteredImage img : session.images) {
writeEntry(zout, "xl/media/cellimage" + idx + ".jpeg", img.bytes);
idx++;
}
}
try {
Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException moveEx) {
// Windows 上偶发 AccessDenied如反病毒/索引服务短暂占用源文件retry 一次。
try {
Thread.sleep(200L);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING);
log.warn("[wps-dispimg] patch xlsx move retried after error file={} cause={}",
xlsx.getName(), moveEx.toString());
}
success = true;
log.info("[wps-dispimg] patch xlsx finished file={} images={}", xlsx.getName(), session.size());
} catch (IOException ioe) {
log.error("[wps-dispimg] patch xlsx failed file={} images={} err={}",
xlsx.getName(), session.size(), ioe.toString(), ioe);
throw ioe;
} finally {
if (!success) {
try {
Files.deleteIfExists(tmp);
} catch (IOException ignored) {
// 临时文件清理失败不影响主流程
}
}
}
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) {
baos.write(buf, 0, n);
}
return baos.toByteArray();
}
private static void writeEntry(ZipOutputStream zout, String name, byte[] bytes) throws IOException {
ZipEntry entry = new ZipEntry(name);
zout.putNextEntry(entry);
zout.write(bytes);
zout.closeEntry();
}
private static void writeDirectoryEntry(ZipOutputStream zout, String name) throws IOException {
ZipEntry entry = new ZipEntry(name);
zout.putNextEntry(entry);
zout.closeEntry();
}
static byte[] patchContentTypes(byte[] original) {
String content = new String(original, StandardCharsets.UTF_8);
// 已经存在则跳过(幂等:避免重复 patch
if (content.contains("PartName=\"/" + CELLIMAGES_PART + "\"")) {
return original;
}
String injection = "<Override PartName=\"/" + CELLIMAGES_PART
+ "\" ContentType=\"" + CELLIMAGES_CONTENT_TYPE + "\"/>";
int closeIdx = content.lastIndexOf("</Types>");
if (closeIdx < 0) {
log.warn("[wps-dispimg] [Content_Types].xml missing </Types>, skip patch");
return original;
}
String patched = content.substring(0, closeIdx) + injection + content.substring(closeIdx);
return patched.getBytes(StandardCharsets.UTF_8);
}
static byte[] patchWorkbookRels(byte[] original) {
String content = new String(original, StandardCharsets.UTF_8);
if (content.contains("Target=\"cellimages.xml\"")) {
return original;
}
// 扫描已有最大 rId
int maxId = 0;
Matcher m = Pattern.compile("Id=\"rId(\\d+)\"").matcher(content);
while (m.find()) {
try {
maxId = Math.max(maxId, Integer.parseInt(m.group(1)));
} catch (NumberFormatException ignored) {
// 非数字 rId 无影响
}
}
String newId = "rId" + (maxId + 1);
String injection = "<Relationship Id=\"" + newId
+ "\" Type=\"" + CELLIMAGES_REL_TYPE
+ "\" Target=\"cellimages.xml\"/>";
int closeIdx = content.lastIndexOf("</Relationships>");
if (closeIdx < 0) {
log.warn("[wps-dispimg] xl/_rels/workbook.xml.rels missing </Relationships>, skip patch");
return original;
}
String patched = content.substring(0, closeIdx) + injection + content.substring(closeIdx);
return patched.getBytes(StandardCharsets.UTF_8);
}
static String buildCellImagesXml(Session session) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
sb.append("<etc:cellImages")
.append(" xmlns:etc=\"http://www.wps.cn/officeDocument/2017/etCustomData\"")
.append(" xmlns:xdr=\"http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing\"")
.append(" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"")
.append(" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">");
int idx = 1;
for (RegisteredImage img : session.images) {
// cNvPr id 从 2 起步、descr 为空字符串、spPr 末尾追加 a:ln/a:noFill —— 与 WPS 实测样本完全对齐,
// 减少 Excel 在"修复"路径上把整个 part 丢弃的概率。
int cNvPrId = idx + 1;
sb.append("<etc:cellImage>")
.append("<xdr:pic>")
.append("<xdr:nvPicPr>")
.append("<xdr:cNvPr id=\"").append(cNvPrId)
.append("\" name=\"").append(img.id)
.append("\" descr=\"\"/>")
.append("<xdr:cNvPicPr><a:picLocks noChangeAspect=\"1\"/></xdr:cNvPicPr>")
.append("</xdr:nvPicPr>")
.append("<xdr:blipFill>")
.append("<a:blip xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"")
.append(" r:embed=\"rId").append(idx).append("\"/>")
.append("<a:stretch><a:fillRect/></a:stretch>")
.append("</xdr:blipFill>")
.append("<xdr:spPr>")
.append("<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom>")
.append("<a:ln><a:noFill/></a:ln>")
.append("</xdr:spPr>")
.append("</xdr:pic>")
.append("</etc:cellImage>");
idx++;
}
sb.append("</etc:cellImages>");
return sb.toString();
}
static String buildCellImagesRelsXml(Session session) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
sb.append("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">");
int idx = 1;
for (RegisteredImage ignored : session.images) {
sb.append("<Relationship Id=\"rId").append(idx)
.append("\" Type=\"").append(IMAGE_REL_TYPE)
.append("\" Target=\"media/cellimage").append(idx)
.append(".jpeg\"/>");
idx++;
}
sb.append("</Relationships>");
return sb.toString();
}
/**
* 同一 task 内的图片注册会话。线程安全synchronized
*/
public static final class Session {
final List<RegisteredImage> images = new ArrayList<>();
// 字节级去重:相同图片只写一次 cellimage{N}.jpeg节省 xlsx 体积。
private final Map<String, String> bytesHashToId = new LinkedHashMap<>();
public synchronized String registerImage(byte[] jpegBytes) {
if (jpegBytes == null || jpegBytes.length == 0) {
return null;
}
String hash = md5Hex(jpegBytes);
String existingId = bytesHashToId.get(hash);
if (existingId != null) {
return existingId;
}
String id = newImageId();
images.add(new RegisteredImage(id, jpegBytes));
bytesHashToId.put(hash, id);
return id;
}
public synchronized boolean isEmpty() {
return images.isEmpty();
}
public synchronized int size() {
return images.size();
}
private static String newImageId() {
// ID_xxx 格式24 hex chars模拟 WPS 默认风格,避免特殊字符让公式解析出错。
return "ID_" + UUID.randomUUID().toString().replace("-", "").toUpperCase();
}
private static String md5Hex(byte[] bytes) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(bytes);
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException ex) {
// MD5 缺失只在极端 JVM 下出现,退化用 hashCode 即可保证 session 内"内存唯一性"
return "len" + bytes.length + ":h" + Arrays.hashCode(bytes);
}
}
}
static final class RegisteredImage {
final String id;
final byte[] bytes;
RegisteredImage(String id, byte[] bytes) {
this.id = id;
this.bytes = bytes;
}
}
}

View File

@@ -181,6 +181,7 @@ aiimage:
stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *}
coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true}
coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false}
coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true}
security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:}